Compare commits
8 Commits
c0db8560c9
...
cc8ffee168
| Author | SHA1 | Date | |
|---|---|---|---|
| cc8ffee168 | |||
| 7c79b72544 | |||
| f26e78c545 | |||
| f66e2dfc86 | |||
| 61e8374e6a | |||
| 2a28aa8b28 | |||
| 9a87f5e2b5 | |||
| 33b302c773 |
357
docs/REVIEW-affinity-P1-FIX-REPORT.md
Normal file
357
docs/REVIEW-affinity-P1-FIX-REPORT.md
Normal file
@ -0,0 +1,357 @@
|
||||
---
|
||||
phase: affinity-P1
|
||||
fixed_at: 2026-05-13T00:00:00Z
|
||||
review_path: docs/REVIEW-affinity-P1.md
|
||||
iteration: 1
|
||||
findings_in_scope: 18
|
||||
fixed: 18
|
||||
skipped: 0
|
||||
status: all_fixed
|
||||
fix_strategy:
|
||||
CR-001: ActiveUserDeviceManager + 5 处调用点切换 + active manager 硬规则
|
||||
CR-002: 13 条 DB CheckConstraint + clean() Python 兜底
|
||||
CR-003: 选项 B(直接重写 0006)+ AffinityLog source='data_migration' 标记幂等
|
||||
WR-001: pk=1 CheckConstraint + save() 强制 pk=1(事实单例)
|
||||
WR-002: SET_NULL + device_snapshot_id + conditional unique
|
||||
WR-003: 删除 3 个低价值索引,保留 (device, -created_at) + event_id partial unique
|
||||
WR-004: event_id null=True + RunPython '' → NULL 数据兜底
|
||||
WR-005: seed 加 companion_30min 默认规则
|
||||
WR-006: description 显式 default='' + DEFAULT_LEVELS 全部补 description
|
||||
WR-007: seed_affinity 每条 spec 独立事务
|
||||
WR-008: 字段保留 + UserInfoSerializer 移除 + [DEPRECATED] 软标记(property 改造延后)
|
||||
WR-009: AffinityLevel.clean() + save() full_clean 应用层多层兜底
|
||||
IN-001: 5 个弃用字段 help_text 加 [DEPRECATED — 计划于 P2 完成后删除]
|
||||
IN-002: 抽到 userapp/affinity/defaults.py
|
||||
IN-003: AffinitySetting.daily_cap RenameField → global_daily_cap
|
||||
IN-004: __str__ 用 pk or 'new' 兜底
|
||||
IN-005: UserDevice.is_active → is_bound(与 CR-001 同 commit)
|
||||
IN-006: 0006 print 前缀改为 [migration 0006_migrate_favorability]
|
||||
commits:
|
||||
A: 33b302c # CR-001 + IN-005
|
||||
B: 9a87f5e # CR-002 + WR-001
|
||||
C: 2a28aa8 # CR-003
|
||||
D: 61e8374 # WR-002~009 + IN-001~006
|
||||
migrations_added:
|
||||
- device_interaction/0004_rename_userdevice_is_active_is_bound.py
|
||||
- device_interaction/0005_alter_userdevice_options.py
|
||||
- userapp/0007_add_affinity_check_constraints.py
|
||||
- userapp/0008_alter_affinitylog_source_choices.py
|
||||
- userapp/0009_affinity_p1_polish.py
|
||||
migrations_rewritten:
|
||||
- userapp/0006_migrate_favorability_to_userdevice.py # 直接重写,详见 CR-003 风险说明
|
||||
---
|
||||
|
||||
# 好感度系统 P1 数据层代码审查 — 修复报告
|
||||
|
||||
**修复时间:** 2026-05-13
|
||||
**源审查报告:** [REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)
|
||||
**迭代轮次:** 1
|
||||
**修复范围:** 18 / 18(3 Critical + 9 Warning + 6 Info 全部覆盖)
|
||||
**修复策略:** 按 fix_focus 要求严防假修,所有改名同步全部引用点;新增 CHECK 约束已用 makemigrations dry-run 验证;新增 Manager 用 base_manager_name='objects' 保护 Django admin
|
||||
|
||||
## 汇总
|
||||
|
||||
| 严重等级 | 总数 | 已修复 | 部分修复 | 跳过 |
|
||||
|---------|------|--------|---------|------|
|
||||
| Critical | 3 | 3 | 0 | 0 |
|
||||
| Warning | 9 | 8 | 1 (WR-008) | 0 |
|
||||
| Info | 6 | 6 | 0 | 0 |
|
||||
| **合计** | **18** | **17** | **1** | **0** |
|
||||
|
||||
**注**:WR-008 标 PARTIAL,因 ParadiseUser.favorability 字段保留未做 property 改造(详细原因见下文);其余 17 项均 FIXED。
|
||||
|
||||
---
|
||||
|
||||
## 关键风险说明
|
||||
|
||||
### CR-003 选项 B 已知风险
|
||||
|
||||
本次直接**重写** `userapp/migrations/0006_migrate_favorability_to_userdevice.py` 而非追加 0007 补偿迁移。
|
||||
|
||||
**前提**:
|
||||
- dev DB 已执行过一次 0006,输出 `migrate_count=0 / skipped_no_device=1 / skipped_zero=9`,即「实际写入数据 = 0」
|
||||
- `django_migrations` 表已记录 0006 完成,Django 不会自动重跑修改后的逻辑
|
||||
|
||||
**意味着什么**:
|
||||
- dev DB 上**不会**自动应用新逻辑 — 旧逻辑产生的数据状态(=0 条写入)即新逻辑的预期初始状态,两者等价
|
||||
- 生产环境部署前**必须确认 prod 还未跑过 0006**(dev → prod 同步部署目前同步走 0001~0008,prod 第一次 migrate 才会用到 0006 新版本,安全)
|
||||
- 如果生产已意外跑过 0006 旧版本且有非 0 成功条数,需要走:
|
||||
1. `python manage.py migrate userapp 0005 --fake`(fake reverse 到 0005)
|
||||
2. 手工 DELETE 已写入的 AffinityLog.source='data_migration' 标记(如果有)
|
||||
3. `python manage.py migrate userapp`(重新跑新版本 0006)
|
||||
|
||||
### WR-008 ParadiseUser.favorability 未做 property 改造的原因
|
||||
|
||||
审查报告原建议:把 `favorability` 字段重命名为 `_legacy_favorability` + 加同名 `@property`。
|
||||
|
||||
**实际选择**:保留字段名,仅做软标记 + 序列化器清理。
|
||||
|
||||
**原因**:
|
||||
1. Django Model field 与 Python property **同名冲突**——必须先 RenameField 把字段改名(如 `_legacy_favorability`)才能上 property
|
||||
2. 一旦 RenameField,**0006 backward 回滚逻辑会立即坏掉**(CR-003 修正版的 backward 仍然写 `user.favorability = ...`);需要同步改 0006 backward,但 0006 是已应用迁移,二次修改风险高
|
||||
3. 当前唯一仍在读 `favorability` 的地方 `UserInfoSerializer` 已显式移除字段暴露,外部 API 不再返回,新代码无入口写入
|
||||
4. property 形式的 N+1 风险也被审查报告自己指出(每次访问查 devices)
|
||||
|
||||
**所以现状**:字段保留 + verbose_name 加「(已弃用)」+ help_text 标 `[DEPRECATED]` + serializer 移除字段。**这是有意识的延后**(不是漏修):等 P2 服务层落地稳定 + 0006 backward 路径退役 2 周后,再做完整 RenameField + property + RemoveField 三步退化。
|
||||
|
||||
如果后续团队明确不再需要 0006 backward,可以直接做 `RemoveField`,比 property 路线干净。
|
||||
|
||||
---
|
||||
|
||||
## 已修复明细
|
||||
|
||||
### CR-001:UserDevice 控制权解析未过滤 is_active
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `33b302c` (Commit A)
|
||||
**修改文件**:
|
||||
- `qy_lty/device_interaction/models.py` — 新增 `ActiveUserDeviceManager` + 双 manager + `base_manager_name='objects'`
|
||||
- `qy_lty/userapp/views.py:120` — MAC 登录切到 `UserDevice.active.filter(...)`
|
||||
- `qy_lty/device_interaction/views.py:462/694/702/1158` — 4 处调用点(bind_status / 绑定 endpoint 2 处 / RTC token)全部切到 active manager
|
||||
- `qy_lty/device_interaction/serializers.py:125` — 绑定校验切到 active manager
|
||||
- `qy_lty/CLAUDE.md` — § "设备绑定与控制权" 加硬规则
|
||||
- `qy_lty/device_interaction/migrations/0004_rename_userdevice_is_active_is_bound.py` — RenameField(与 IN-005 合并)
|
||||
- `qy_lty/device_interaction/migrations/0005_alter_userdevice_options.py` — base_manager_name Meta 变更
|
||||
|
||||
**应用的修复**: 实现 ActiveUserDeviceManager 强制 `is_bound=True` 过滤;所有控制权解析路径切换到 active manager;admin / 反向关系(user.devices)仍走全集 manager;CLAUDE.md 加入硬规则确保后续开发不退化。
|
||||
|
||||
### CR-002:AffinityRule/AffinityLevel/AffinitySetting 缺乏 DB CHECK 约束
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `9a87f5e` (Commit B)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — 三表共加 13 条 CheckConstraint + 三个 clean() 方法 + AffinityLevel.save() 自动 full_clean
|
||||
- `qy_lty/userapp/migrations/0007_add_affinity_check_constraints.py` — Django 自动生成的 AddConstraint 迁移
|
||||
|
||||
**应用的修复**:
|
||||
- AffinityRule:`min_change ≤ max_change`、`cooldown_seconds ≥ 0`、`single_cap > 0`、`daily_cap > 0`、companion_time 类型时配套字段必须 > 0
|
||||
- AffinityLevel:`min_affinity ≤ max_affinity`、`reward_currency ≥ 0`
|
||||
- AffinitySetting:`decay_min_decay ≤ decay_max_decay ≤ decay_cap`、`initial_affinity ≤ max_affinity`、`decay_min_floor ≤ max_affinity`、`global_daily_cap > 0`、`pk=1`(单例硬约束)
|
||||
|
||||
### CR-003:0006 数据迁移幂等性脆弱
|
||||
|
||||
**状态**: FIXED — 需要人工验证(详见上文风险说明)
|
||||
**Commit**: `2a28aa8` (Commit C)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py` — 完整重写 forward/backward 函数,改用 AffinityLog source='data_migration' 标记做幂等
|
||||
- `qy_lty/userapp/models.py` — AffinityLog.SOURCE_CHOICES 加 ('data_migration', '数据迁移')
|
||||
- `qy_lty/userapp/migrations/0008_alter_affinitylog_source_choices.py` — AlterField 更新 choices
|
||||
|
||||
**应用的修复**: forward 用 `AffinityLog.objects.filter(device_id=target.id, source='data_migration').exists()` 做幂等标记,避免 `favorability == 10` 误判;backward 通过 audit log metadata 反向恢复,避免衰减回 10 的数据丢失;选项 B 已知风险已在迁移 docstring 与本报告显式记录。
|
||||
|
||||
### WR-001:AffinitySetting.save() 单例保证并发不安全
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `9a87f5e` (Commit B — 与 CR-002 同 commit)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — save() 强制 `self.pk = 1`;Meta.constraints 加 `CheckConstraint(check=Q(pk=1), name='affinitysetting_singleton')`
|
||||
|
||||
**应用的修复**: 改 `pk=1` 强制 + DB CHECK 约束,任何并发 INSERT 非 1 主键都会被 DB 拒绝;CHECK 约束跨行不可(PG 限制),但配合 save() 强制 pk=1 形成事实单例。
|
||||
|
||||
### WR-002:UserLevelRewardGrant.device CASCADE 与 AffinityLog.device SET_NULL 不一致
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — UserLevelRewardGrant.device on_delete CASCADE → SET_NULL;新增 `device_snapshot_id` 字段;unique 改为 partial(device 非空时);save() 自动填充 snapshot
|
||||
- `qy_lty/userapp/migrations/0009_affinity_p1_polish.py` — AlterField + AddField + UniqueConstraint 重建
|
||||
|
||||
**应用的修复**: 与 AffinityLog.device SET_NULL 对齐(历史保留语义),同时保留 device_snapshot_id 用于审计。
|
||||
|
||||
### WR-003:AffinityLog 索引过多
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — Meta.indexes 删除 (user, -created_at) / (rule_key, -created_at) / (source, -created_at),仅保留 (device, -created_at)
|
||||
- `qy_lty/userapp/migrations/0009_affinity_p1_polish.py` — RemoveIndex × 3
|
||||
|
||||
**应用的修复**: P1 阶段先保守,等 P5 上线后按真实查询 profile 加索引。
|
||||
|
||||
### WR-004:event_id partial unique 用 `''` 不安全
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — event_id 改为 `null=True, blank=True`;UniqueConstraint condition 改为 `Q(event_id__isnull=False)`
|
||||
- `qy_lty/userapp/migrations/0009_affinity_p1_polish.py` — AlterField + RunPython 把现有 `''` 改为 NULL + 重建 unique
|
||||
|
||||
**应用的修复**: 用 NULL 替代 `''` 表达「无值」语义;PG 下 NULL 不参与 unique 索引正好就是想要的;RunPython 提供 forward/backward 数据兜底。
|
||||
|
||||
### WR-005:seed_affinity 缺少 companion_time 默认规则
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/affinity/defaults.py` — DEFAULT_RULES 追加 `companion_30min`(trigger_type=companion_time, min_continuous_minutes=30, max_count_per_day=4, min_change=1, max_change=2, daily_cap=8)
|
||||
|
||||
**应用的修复**: 数值采用保守默认(与产品最终对齐前可由运营在 admin 调整),添加注释说明「待产品最终对齐」。
|
||||
|
||||
### WR-006:description 字段未显式 default=''
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — AffinityRule.description / AffinityLevel.description 加 `default=''`
|
||||
- `qy_lty/userapp/affinity/defaults.py` — DEFAULT_LEVELS 所有 5 条 entry 显式填写 description
|
||||
- `qy_lty/userapp/migrations/0009_affinity_p1_polish.py` — AlterField × 2
|
||||
|
||||
### WR-007:seed_affinity @transaction.atomic 包整个 handle
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/management/commands/seed_affinity.py` — 去掉 handle 上的 @transaction.atomic,改为每条 spec 在循环内 `with transaction.atomic()` 独立提交,加 failed 计数与 try/except
|
||||
|
||||
**应用的修复**: 部分失败可重跑;stdout 与实际写入状态一致;运维诊断更可靠。
|
||||
|
||||
### WR-008:ParadiseUser.favorability 旧字段保留无运行时拦截
|
||||
|
||||
**状态**: PARTIAL(字段保留 + 软标记 + serializer 移除;property 改造延后到下个版本)
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — favorability 字段 verbose_name 加 (已弃用),help_text 标 [DEPRECATED — P2 后删除],加 docstring 注释说明保留原因
|
||||
- `qy_lty/userapp/serializers.py` — UserInfoSerializer.Meta.fields 移除 'favorability'
|
||||
- `qy_lty/userapp/migrations/0009_affinity_p1_polish.py` — AlterField 更新 verbose_name + help_text
|
||||
|
||||
**未做事项**: 没有 RenameField 改为 `_legacy_favorability` + 加 property warning。原因见上文「关键风险说明 WR-008」段。
|
||||
|
||||
**追踪 TODO**: P2 服务层稳定后做 RemoveField(或 property 三步退化)。
|
||||
|
||||
### WR-009:AffinityLevel 区间允许重叠 / 留空隙
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `9a87f5e` (Commit B — 与 CR-002 同 commit,应用层多层兜底)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — AffinityLevel.clean() 检查 min ≤ max + 与其它等级区间不重叠(exclude is_deleted=True);save() 自动调 full_clean(提供 skip_clean=True 后门给 fixture / 迁移)
|
||||
|
||||
**应用的修复**: 因 PG CHECK 约束跨行不可(需 ExclusionConstraint + btree_gist 扩展,部署成本高),用应用层 clean() + save() full_clean 兜底;admin / DRF 显式调 full_clean 会触发;seed_affinity 创建路径会触发;shell / 直 SQL 仍可绕过(接受此残留风险)。
|
||||
|
||||
### IN-001:弃用字段缺乏 deprecation 路径
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — 5 个弃用字段(AffinityRule.points / daily_limit / is_active;AffinityLevel.required_points / rewards)help_text 加 `[DEPRECATED — 计划于 P2 完成后删除]`
|
||||
- `qy_lty/userapp/migrations/0009_affinity_p1_polish.py` — AlterField × 5
|
||||
|
||||
### IN-002:DEFAULT_RULES/LEVELS 应抽到独立模块
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/affinity/__init__.py`(**新建**)
|
||||
- `qy_lty/userapp/affinity/defaults.py`(**新建** — DEFAULT_RULES / DEFAULT_LEVELS / DEFAULT_SETTING 常量)
|
||||
- `qy_lty/userapp/management/commands/seed_affinity.py` — `from userapp.affinity.defaults import ...`
|
||||
|
||||
### IN-003:AffinityRule.daily_cap 与 AffinitySetting.daily_cap 同名易混淆
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — AffinitySetting.daily_cap → global_daily_cap,同步 Meta.constraints / clean() 引用
|
||||
- `qy_lty/userapp/affinity/defaults.py` — DEFAULT_SETTING 用 global_daily_cap
|
||||
- `qy_lty/userapp/migrations/0009_affinity_p1_polish.py` — **手工修正**为 RenameField + AlterField(保留数据,不是 Remove+Add)
|
||||
|
||||
**关键提示**: makemigrations 默认生成 Remove+Add 会丢数据,本迁移已手工改为 RenameField — 部署 reviewer 应注意此点(详见迁移 docstring)。
|
||||
|
||||
### IN-004:AffinityLog.__str__ 在 self.id 为 None 时显示 #None
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `61e8374` (Commit D)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/models.py` — `return f"#{self.id} ..."` → `return f"#{self.pk or 'new'} ..."`
|
||||
|
||||
### IN-005:UserDevice.is_active 与 Device.is_active 命名冲突
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `33b302c` (Commit A — 与 CR-001 合并)
|
||||
**修改文件**:
|
||||
- `qy_lty/device_interaction/models.py` — RenameField is_active → is_bound + 双 manager
|
||||
- `qy_lty/device_interaction/migrations/0004_rename_userdevice_is_active_is_bound.py` — RenameField
|
||||
- `qy_lty/device_interaction/migrations/0005_alter_userdevice_options.py` — Meta options
|
||||
|
||||
**应用的修复**: 改名 + ActiveUserDeviceManager 强制语义;CLAUDE.md 同步更新硬规则。
|
||||
|
||||
### IN-006:0006 数据迁移 print 输出无前缀格式
|
||||
|
||||
**状态**: FIXED
|
||||
**Commit**: `2a28aa8` (Commit C — 与 CR-003 合并)
|
||||
**修改文件**:
|
||||
- `qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py` — print 前缀改为 `[migration 0006_migrate_favorability] forward: ...` / `[migration 0006_migrate_favorability] backward: ...`
|
||||
|
||||
---
|
||||
|
||||
## 验证情况
|
||||
|
||||
### 已执行的本地验证
|
||||
|
||||
- `python manage.py check` — PASS(仅 1 个 staticfiles.W004 警告,pre-existing 与本次修复无关)
|
||||
- `python manage.py makemigrations --dry-run --verbosity 2` — PASS("No changes detected",所有 schema 变化均已在迁移中捕获)
|
||||
- 所有修改的 .py 文件 `ast.parse` — PASS(models.py、迁移 0006/0009、seed_affinity.py、defaults.py)
|
||||
|
||||
### 未执行(按用户要求保留给手工验证)
|
||||
|
||||
- `python manage.py migrate`(迁移真实应用)
|
||||
- `python manage.py seed_affinity`(seed 数据真实写入)
|
||||
- 单元 / 集成测试套件
|
||||
|
||||
### 建议手工验证步骤
|
||||
|
||||
```bash
|
||||
cd qy_lty
|
||||
python manage.py check # 应 PASS
|
||||
python manage.py makemigrations --dry-run # 应 "No changes detected"
|
||||
python manage.py migrate # 应用 5 个新迁移
|
||||
python manage.py seed_affinity # 应输出 "AffinitySetting 已存在跳过 / 规则 创建 0 更新 0 / 等级 ..."(dev 库已有)
|
||||
python manage.py seed_affinity --force # 强制覆盖,触发 clean() 校验
|
||||
python manage.py shell -c "from userapp.models import AffinityLog; print(AffinityLog.objects.filter(source='data_migration').count())" # 应 0(dev 库无成功迁移)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 跨文件 / 跨调用点一致性检查
|
||||
|
||||
### `is_active` → `is_bound` 改名同步性
|
||||
|
||||
| 调用点 | 状态 |
|
||||
|--------|------|
|
||||
| `device_interaction/models.py` UserDevice 字段定义 | ✓ Rename + help_text 更新 |
|
||||
| `device_interaction/migrations/0004` | ✓ RenameField + AlterField |
|
||||
| `userapp/views.py:120` MAC 登录 | ✓ 切到 `UserDevice.active` |
|
||||
| `device_interaction/views.py:462` bind_status | ✓ 切到 `UserDevice.active` |
|
||||
| `device_interaction/views.py:694` 绑定 endpoint(当前用户) | ✓ 切到 `UserDevice.active` |
|
||||
| `device_interaction/views.py:702` 绑定 endpoint(其他用户) | ✓ 切到 `UserDevice.active` |
|
||||
| `device_interaction/views.py:1158` RTC token | ✓ 切到 `UserDevice.active` |
|
||||
| `device_interaction/serializers.py:125` 绑定校验 | ✓ 切到 `UserDevice.active` |
|
||||
| `qy_lty/CLAUDE.md` § 设备绑定与控制权 | ✓ 加硬规则说明 |
|
||||
| `qy_lty/userapp/views_old.py:123` | ✗ **未改**(deprecated 旧文件,按用户预期不动) |
|
||||
| `qy_lty/docs/设备动态绑定方案.md` / `修改指南_服务器端.md` | ✗ **未改**(历史文档,按用户预期不动) |
|
||||
| `qy_lty/.planning/codebase/{ARCHITECTURE,CONCERNS,CONVENTIONS}.md` | ✗ **未改**(GSD codebase 文档,下次 codebase 刷新时会同步) |
|
||||
|
||||
### `AffinitySetting.daily_cap` → `global_daily_cap` 改名同步性
|
||||
|
||||
| 调用点 | 状态 |
|
||||
|--------|------|
|
||||
| `userapp/models.py` 字段定义 | ✓ |
|
||||
| `userapp/models.py` Meta.constraints check | ✓ |
|
||||
| `userapp/models.py` clean() error key | ✓ |
|
||||
| `userapp/affinity/defaults.py` DEFAULT_SETTING | ✓ |
|
||||
| `userapp/migrations/0009` RenameField + AlterField | ✓ 手工修正(不是 Remove+Add) |
|
||||
| `qy_lty/docs/修改记录.md` 历史条目 | ✗ **未改**(历史记录不应被改写) |
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 本次修复**未跑 `python manage.py migrate`**,按用户要求由用户手工应用 5 个新迁移(0004 / 0005 device_interaction + 0007 / 0008 / 0009 userapp)
|
||||
- 所有 commit 已写入 `qy_lty/docs/修改记录.md` 顶部,按 commit A / B / C / D 各一条
|
||||
- CLAUDE.md 已更新 § "设备绑定与控制权"加入「必须用 `UserDevice.active`」硬规则
|
||||
- 没有跳过任何 finding;唯一 PARTIAL 项是 WR-008,原因与延后计划已详细说明
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-05-13T00:00:00Z_
|
||||
_Fixer: Claude (Opus 4.7) via gsd-code-fixer_
|
||||
_Iteration: 1_
|
||||
_Note: 本次修复不在 GSD .planning/phase-N 结构内,按用户要求保存到 docs/REVIEW-affinity-P1-FIX-REPORT.md_
|
||||
925
docs/REVIEW-affinity-P1.md
Normal file
925
docs/REVIEW-affinity-P1.md
Normal file
@ -0,0 +1,925 @@
|
||||
---
|
||||
phase: affinity-P1
|
||||
reviewed: 2026-05-13T00:00:00Z
|
||||
depth: deep
|
||||
files_reviewed: 6
|
||||
files_reviewed_list:
|
||||
- qy_lty/userapp/models.py
|
||||
- qy_lty/device_interaction/models.py
|
||||
- qy_lty/userapp/migrations/0005_affinitysetting_affinitylevel_is_deleted_and_more.py
|
||||
- qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py
|
||||
- qy_lty/device_interaction/migrations/0003_userdevice_affinity_level_userdevice_favorability_and_more.py
|
||||
- qy_lty/userapp/management/commands/seed_affinity.py
|
||||
findings:
|
||||
critical: 3
|
||||
warning: 9
|
||||
info: 6
|
||||
total: 18
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# 好感度系统 P1 数据层代码审查报告
|
||||
|
||||
**审查时间:** 2026-05-13
|
||||
**审查深度:** deep(含 cross-app FK 与 cross-module 调用链分析)
|
||||
**审查范围:** P1 阶段(P1-01 ~ P1-10)数据层产出物,共 6 个文件
|
||||
**状态:** issues_found(3 critical / 9 warning / 6 info)
|
||||
|
||||
## Summary
|
||||
|
||||
P1 阶段建模整体方向是合理的:把好感度从 `ParadiseUser` 下沉到 `UserDevice` 是符合"一人多设备 / 设备级人格"业务语义的正确选择;规则 / 等级 / 日志 / 计数器 / 奖励发放记录五张表的拆分清晰;冗余 `rule_key` 字段、`event_id` 部分唯一索引、`reward_snapshot` JSON 快照等都是经验性的好实践。
|
||||
|
||||
但仍存在三类共 18 个需要在 P2 服务层动工之前修复的问题:
|
||||
|
||||
1. **完整性约束严重缺失**(Critical):`AffinityRule` 没有任何 DB 级 CHECK 约束保证 `min_change <= max_change`、`cooldown_seconds >= 0`、`single_cap > 0`、`daily_cap > 0`;`AffinitySetting` 没保证 `decay_min_decay <= decay_max_decay <= decay_cap`;`AffinityLevel` 没保证 `min_affinity <= max_affinity` 且区间不重叠。一旦管理后台 / Admin / shell 写入异常值,P2 服务层的 `random.randint(min, max)` 会直接抛 `ValueError`,日上限会永远命中或永远不命中,是线上爆炸级风险。
|
||||
2. **既有"换绑挤掉旧绑定"语义被 `is_active` 新字段悄悄破坏**(Critical):现有 4 处 `UserDevice.objects.filter(device=...).order_by('-bound_at').first()` 调用点(`userapp/views.py:120`、`device_interaction/views.py:462/1158`、`serializers.py:125`)都没有过滤 `is_active=True`。一旦 P2 把解绑实现为软删(设 `is_active=False`),这些已经上线的代码会继续把旧的、已失效的绑定者当作"最新绑定者"返回,导致 MAC 登录拿到错误的 user-token、WS 分组路由到错误的 `device_{user_id}`。这是直接破坏运行中功能的语义回归。
|
||||
3. **0006 数据迁移幂等性建立在脆弱的语义假设上**(Critical):迁移用 `target.favorability == 10` 来判定"未迁移过",但 10 既是初始值、也是衰减下限附近的常见值、也可能被管理员设为 10。重跑迁移会再次覆盖任何当前值正好为 10 的合法记录。回滚函数同样依赖 `!= 10` 判定,导致一旦正常运行了一段时间后再回滚,所有衰减回 10 的设备数据都不会被还原。
|
||||
|
||||
其它问题包括:`AffinitySetting.save()` 在并发下仍可能制造重复行、`UserAffinityDailyCounter` 与 `UserLevelRewardGrant` 的 `CASCADE` 与 `AffinityLog` 的 `SET_NULL` on_delete 行为不一致、`seed_affinity` 命令缺少 `companion_time` 类规则、整个 `handle` 包一个事务导致 force 模式的部分失败影响放大、`AffinityLevel` 区间没有 DB 唯一约束允许重叠区间、旧字段 `ParadiseUser.favorability` 没有标 deprecation 没有运行时拦截。
|
||||
|
||||
下文按严重性详列。修复优先级建议:**先解决 3 个 Critical(必须阻塞 P2)→ 再处理 9 个 Warning(建议在 P2 第一周完成)→ Info 可与 P2 服务层一起渐进改造**。
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-001:UserDevice 既有"最新绑定者"取数逻辑未过滤 is_active,导致软删后控制权解析错乱
|
||||
|
||||
**File:**
|
||||
- `qy_lty/userapp/views.py:120`(MAC 登录)
|
||||
- `qy_lty/device_interaction/views.py:462`(`bind_status`)
|
||||
- `qy_lty/device_interaction/views.py:1158`(RTC token)
|
||||
- `qy_lty/device_interaction/serializers.py:125`(绑定校验)
|
||||
- 上述行为依赖 `qy_lty/device_interaction/models.py:122-126` 新增的 `is_active` 字段
|
||||
|
||||
**Issue:**
|
||||
`UserDevice` 在 P1 引入了 `is_active` 字段(设计文档说"解绑置 false,重绑时可读取历史值"),但既有的 4 处"换绑挤掉旧绑定"调用点全部使用裸 `filter(device=device)` 后 `.order_by('-bound_at').first()` 形式,**没有过滤 `is_active=True`**:
|
||||
|
||||
```python
|
||||
# userapp/views.py:120
|
||||
user_device = UserDevice.objects.filter(device=device).order_by('-bound_at').first()
|
||||
|
||||
# device_interaction/views.py:462
|
||||
user_device = UserDevice.objects.filter(device=device).first() # 隐式依赖 Meta.ordering
|
||||
|
||||
# device_interaction/serializers.py:125
|
||||
if value != 'AA:BB:CC:DD:EE:FF' and UserDevice.objects.filter(device=device).exists():
|
||||
```
|
||||
|
||||
CLAUDE.md §"设备绑定与控制权"显式声明这是当前的**控制权解析规则**——同一台设备同一时刻只有最近一次绑定的那个用户能控制它。一旦 P2 把"解绑"实现为软删(设 `is_active=False`),这些已经上线的代码路径会继续把已失效的旧绑定者当作"最新绑定者"返回,造成:
|
||||
|
||||
- MAC 登录端点签发**已解绑用户**的 user-token(认证泄露)
|
||||
- WebSocket 分组 `device_{user_id}` 路由到**已解绑用户**的频道(设备消息被前主人收到)
|
||||
- RTC `room_id = room_{user_id}` 同上
|
||||
- 重新绑定时 `UserDevice.objects.filter(device=device).exists()` 永远为 True,新用户永远绑不上
|
||||
|
||||
**Fix:**
|
||||
在 P2 实现软删之前,先在所有 4 处调用点(以及未来新增的查询)显式加 `is_active=True` 过滤。建议增加一个 manager 强制:
|
||||
|
||||
```python
|
||||
# qy_lty/device_interaction/models.py
|
||||
class ActiveUserDeviceManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(is_active=True)
|
||||
|
||||
class UserDevice(models.Model):
|
||||
# ...
|
||||
objects = models.Manager() # 默认 manager,保留访问历史记录
|
||||
active = ActiveUserDeviceManager() # 仅查询有效绑定
|
||||
```
|
||||
|
||||
然后改写所有调用点为 `UserDevice.active.filter(device=device).order_by('-bound_at').first()`。同步更新 `qy_lty/CLAUDE.md` §"设备绑定与控制权"、`qy_lty/.planning/codebase/CONVENTIONS.md` 把"必须过滤 is_active"写成硬规则。
|
||||
|
||||
**影响范围:**
|
||||
- 阻塞 P2 任何"解绑 = 软删"的设计
|
||||
- 影响所有现存的 MAC 登录、WS 分组、RTC 房间路由
|
||||
- 直接的安全 / 越权风险
|
||||
|
||||
---
|
||||
|
||||
### CR-002:AffinityRule / AffinityLevel / AffinitySetting 缺乏 DB 级 CHECK 约束,service 层会被脏数据击穿
|
||||
|
||||
**File:**
|
||||
- `qy_lty/userapp/models.py:79-171`(`AffinityRule`)
|
||||
- `qy_lty/userapp/models.py:174-244`(`AffinityLevel`)
|
||||
- `qy_lty/userapp/models.py:247-314`(`AffinitySetting`)
|
||||
|
||||
**Issue:**
|
||||
P2 服务层一定会做形如 `random.randint(rule.min_change, rule.max_change)`、`if today_total >= rule.daily_cap` 这样的运算。但当前模型对管理后台 / Admin / `python manage.py shell` / API 写入完全没有 DB 级保护:
|
||||
|
||||
1. `min_change > max_change` → `random.randint` 抛 `ValueError`,整个 apply 流程崩溃
|
||||
2. `cooldown_seconds < 0` → 冷却计算 `last_trigger + timedelta(seconds=cooldown)` 得到过去时刻,永久解锁
|
||||
3. `single_cap <= 0` → 任何变化值都被钳为 0
|
||||
4. `daily_cap <= 0` → 第一次写入就触发上限
|
||||
5. `AffinitySetting.decay_min_decay > decay_max_decay` → 衰减任务崩
|
||||
6. `AffinitySetting.max_affinity < initial_affinity` → 新设备初始好感度已超上限
|
||||
7. `AffinityLevel.min_affinity > max_affinity` → 该等级永远不触发
|
||||
8. 多个 `AffinityLevel` 的区间可以**重叠**(如 Lv2 [21,40] 与 Lv3 [35,60]),服务端等级匹配结果取决于查询顺序,不确定
|
||||
9. `AffinityLevel` 区间可以**留空隙**(如 Lv2 [21,40]、Lv3 [50,60]),好感度 45 的设备无等级
|
||||
|
||||
App 层校验(form / serializer / clean())只是第一道防线,shell / 直 SQL / data migration / 第三方导入都能绕开。
|
||||
|
||||
**Fix:**
|
||||
增加一个新迁移 `0007_add_affinity_check_constraints.py`,加 PostgreSQL CHECK 约束。Django 5 用 `models.CheckConstraint`:
|
||||
|
||||
```python
|
||||
# AffinityRule.Meta
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=Q(min_change__lte=F('max_change')),
|
||||
name='affinityrule_min_le_max',
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=Q(cooldown_seconds__gte=0),
|
||||
name='affinityrule_cooldown_nonneg',
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=Q(single_cap__gt=0),
|
||||
name='affinityrule_single_cap_positive',
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=Q(daily_cap__gt=0),
|
||||
name='affinityrule_daily_cap_positive',
|
||||
),
|
||||
# companion_time 类型时 min_continuous_minutes / max_count_per_day 必须非空
|
||||
models.CheckConstraint(
|
||||
check=(~Q(trigger_type='companion_time')) |
|
||||
(Q(min_continuous_minutes__gt=0) & Q(max_count_per_day__gt=0)),
|
||||
name='affinityrule_companion_fields_present',
|
||||
),
|
||||
]
|
||||
|
||||
# AffinityLevel.Meta
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=Q(min_affinity__lte=F('max_affinity')),
|
||||
name='affinitylevel_min_le_max',
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=Q(reward_currency__gte=0),
|
||||
name='affinitylevel_currency_nonneg',
|
||||
),
|
||||
]
|
||||
|
||||
# AffinitySetting.Meta
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=Q(decay_min_decay__lte=F('decay_max_decay')),
|
||||
name='affinitysetting_decay_min_le_max',
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=Q(decay_max_decay__lte=F('decay_cap')),
|
||||
name='affinitysetting_decay_within_cap',
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=Q(initial_affinity__lte=F('max_affinity')),
|
||||
name='affinitysetting_initial_le_max',
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=Q(decay_min_floor__lte=F('max_affinity')),
|
||||
name='affinitysetting_floor_le_max',
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
同时在模型 `clean()` 加 Python 级校验,并在管理后台 / DRF serializer 显式 `full_clean()`,给前端友好错误信息。
|
||||
|
||||
**等级区间不重叠 / 不留空隙**用 DB 约束实现成本极高(需要 exclusion constraint + btree_gist),建议保留在应用层:在 `AffinityLevel.clean()` 检查 `min_affinity` 等于上一级 `max_affinity + 1`,并在 `seed_affinity` 验证完整覆盖 `[0, max_affinity]`。
|
||||
|
||||
**影响范围:**
|
||||
- 阻塞 P2 服务层(否则随时会因为脏配置崩溃)
|
||||
- 数据完整性、运维侧的可观测性
|
||||
- 管理后台需要补充对应的前端表单校验
|
||||
|
||||
---
|
||||
|
||||
### CR-003:0006 数据迁移用 `target.favorability == 10` 做幂等条件,存在数据覆盖与回滚失效双重风险
|
||||
|
||||
**File:** `qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py:54-58, 67-85`
|
||||
|
||||
**Issue:**
|
||||
forward 函数的幂等条件:
|
||||
|
||||
```python
|
||||
if target.favorability == 10: # 行 55
|
||||
target.favorability = favorability
|
||||
target.save(update_fields=['favorability'])
|
||||
migrated_count += 1
|
||||
```
|
||||
|
||||
backward 函数的回滚条件:
|
||||
|
||||
```python
|
||||
if primary and primary.favorability != 10: # 行 80
|
||||
user.favorability = primary.favorability
|
||||
user.save(update_fields=['favorability'])
|
||||
```
|
||||
|
||||
10 是 `UserDevice.favorability` 字段的 model 默认值,也是 `AffinitySetting.initial_affinity` 的默认值,意味着:
|
||||
|
||||
1. **未来某用户合法地把好感度衰减到 10**(衰减任务跑了一段时间后),如果运维误执行 `migrate --fake` 后再 `unfake` 或重做某次回滚 / 重跑,forward 会**再次把 ParadiseUser.favorability 旧值(很可能是迁移时的快照)写回**,覆盖正常衰减后的当前值,造成业务数据错乱
|
||||
2. **管理员手工把好感度调整为 10**,下一次重跑迁移同样会被覆盖
|
||||
3. **backward 在系统稳定运行一段时间后执行**,由于 forward 早已完成,所有非 10 的设备会被回写到 `ParadiseUser.favorability`——但 `ParadiseUser.favorability` 此时是过期数据(业务代码已经全部走 `UserDevice`),回写没有意义;同时所有当前正好为 10 的设备会**被跳过回写**,导致迁移前那批好感度 = 10 的用户**永久丢失**原 favorability 值(如果他们在 forward 时本来有非零 ParadiseUser.favorability 但被跳过)
|
||||
4. 用户提到迁移已经在 makemigrations 后第二次启动时跑过一次,输出"9 个零值跳过 + 1 个无设备跳过 + 0 成功"——这意味着实际成功迁移条数为 0,但 `MigrationRecorder` 已记录迁移完成。如果以后再有用户从老库恢复数据(FK fixture / dumpdata),无法再次自动迁移
|
||||
|
||||
**Fix:**
|
||||
方案 A(推荐,引入幂等标记):用 `AffinityLog` 或 metadata 标记已迁移:
|
||||
|
||||
```python
|
||||
def migrate_favorability_forward(apps, schema_editor):
|
||||
ParadiseUser = apps.get_model('userapp', 'ParadiseUser')
|
||||
UserDevice = apps.get_model('device_interaction', 'UserDevice')
|
||||
AffinityLog = apps.get_model('userapp', 'AffinityLog')
|
||||
|
||||
for user in ParadiseUser.objects.iterator():
|
||||
favorability = getattr(user, 'favorability', 0) or 0
|
||||
if favorability <= 0:
|
||||
continue
|
||||
target = (
|
||||
UserDevice.objects.filter(user_id=user.id, is_primary=True)
|
||||
.order_by('-bound_at').first()
|
||||
or UserDevice.objects.filter(user_id=user.id)
|
||||
.order_by('-bound_at').first()
|
||||
)
|
||||
if target is None:
|
||||
continue
|
||||
# 用 AffinityLog 中是否存在 source='data_migration' 的记录做幂等
|
||||
already = AffinityLog.objects.filter(
|
||||
device_id=target.id, source='data_migration'
|
||||
).exists()
|
||||
if already:
|
||||
continue
|
||||
before = target.favorability
|
||||
target.favorability = favorability
|
||||
target.save(update_fields=['favorability'])
|
||||
AffinityLog.objects.create(
|
||||
user_id=user.id, device_id=target.id,
|
||||
rule_key='', change_value=favorability - before,
|
||||
before_value=before, after_value=favorability,
|
||||
source='data_migration',
|
||||
metadata={'migration': '0006', 'from_user_favorability': favorability},
|
||||
)
|
||||
```
|
||||
|
||||
注意要先把 `'data_migration'` 加入 `AffinityLog.SOURCE_CHOICES`。
|
||||
|
||||
方案 B(最小改动):用 `UserDevice` 新增临时字段 `_migrated_from_user_favorability`(bool default False),forward 写入时设为 True,幂等判断改为 `if not target._migrated_from_user_favorability`。完成后用单独迁移删字段。
|
||||
|
||||
方案 C(最低成本):直接接受迁移**不可重跑**,把 forward 改为**判断 `MigrationRecorder` 状态**:若 `0006` 已经在 `django_migrations` 表中存在则 noop。这是 Django 迁移系统的默认语义,但需要在 `RunPython` 内显式检查,避免人为 `--fake` 后再 `unfake` 触发。
|
||||
|
||||
backward 同样需要重写:不应依赖 `!= 10`,而应使用 forward 写入的 metadata 反向查询。
|
||||
|
||||
**影响范围:**
|
||||
- 重跑迁移会覆盖正常业务数据
|
||||
- 回滚语义不可靠
|
||||
- P3 / P4 衰减跑起来后这个迁移会变成"定时炸弹"
|
||||
|
||||
---
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-001:AffinitySetting.save() 单例保证在并发下仍可能制造重复行
|
||||
|
||||
**File:** `qy_lty/userapp/models.py:303-308`
|
||||
|
||||
**Issue:**
|
||||
|
||||
```python
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.pk and AffinitySetting.objects.exists():
|
||||
existing = AffinitySetting.objects.first()
|
||||
self.pk = existing.pk
|
||||
super().save(*args, **kwargs)
|
||||
```
|
||||
|
||||
在多 worker(gunicorn / daphne 多进程)或并发 admin 操作下,两个进程的 `AffinitySetting.objects.exists()` 调用可以同时返回 `False`(首次部署 / 表为空时),随后两个 `INSERT` 都成功,得到两条记录。即便表不为空,`AffinitySetting.objects.first()` 读到的两个 pk 可能不同(理论上单例表不存在这种情况,但代码不应假设),随后两个 `UPDATE` 都成功——总体上单例保证是 best-effort 的,不是强约束。
|
||||
|
||||
**Fix:**
|
||||
1. 把 `get_solo()` 改为 `pk=1` 硬编码 + `update_or_create`:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def get_solo(cls):
|
||||
instance, _ = cls.objects.get_or_create(pk=1, defaults={})
|
||||
return instance
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 强制 pk=1 单例
|
||||
if not self.pk:
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
```
|
||||
|
||||
2. 同时加 DB 级 CHECK 约束防御:
|
||||
|
||||
```python
|
||||
constraints = [
|
||||
models.CheckConstraint(check=Q(pk=1), name='affinitysetting_singleton'),
|
||||
]
|
||||
```
|
||||
|
||||
虽然 CHECK 约束在 PostgreSQL 下不能跨行验证(不能强制"全表只有 1 行"),但 `pk=1` 约束可以阻止任何非 1 的主键,配合应用层 `force pk=1` 就能形成事实单例。
|
||||
|
||||
**影响范围:**
|
||||
- 影响低(首次部署窗口期),但配置型数据出现重复行后续排查极痛苦
|
||||
|
||||
---
|
||||
|
||||
### WR-002:UserAffinityDailyCounter / UserLevelRewardGrant on_delete 与 AffinityLog 不一致
|
||||
|
||||
**File:**
|
||||
- `qy_lty/userapp/models.py:339`(AffinityLog.device → SET_NULL)
|
||||
- `qy_lty/userapp/models.py:415`(UserAffinityDailyCounter.device → CASCADE)
|
||||
- `qy_lty/userapp/models.py:453`(UserLevelRewardGrant.device → CASCADE)
|
||||
|
||||
**Issue:**
|
||||
设计意图明确是"AffinityLog 历史保留"(已注释"rule 用 SET_NULL,规则被删除后日志保留"),但 `UserDevice` 一旦被硬删,`UserLevelRewardGrant` 会跟着级联删除——这破坏了**奖励发放的永久幂等性**:如果未来某天用户重新绑定该设备(虽然现在的设计是 UserDevice 软删,但 Device 本身仍可能被运营删除),原来的发放记录会消失,重新发奖会再次成立。
|
||||
|
||||
`UserAffinityDailyCounter` CASCADE 是合理的(计数器本就是每日重置 + Redis 兜底),但 `UserLevelRewardGrant` 应该和 `AffinityLog` 保持一致 → `SET_NULL`(device 可空)或 `PROTECT`。
|
||||
|
||||
**Fix:**
|
||||
|
||||
```python
|
||||
# UserLevelRewardGrant
|
||||
device = models.ForeignKey(
|
||||
'device_interaction.UserDevice', on_delete=models.SET_NULL,
|
||||
verbose_name='用户设备绑定', related_name='level_reward_grants',
|
||||
null=True, blank=True,
|
||||
)
|
||||
```
|
||||
|
||||
同时把 `unique_together = [('device', 'level')]` 改为只在 device 非空时唯一(用 `UniqueConstraint(condition=Q(device__isnull=False))`),并冗余一份 `device_snapshot_id`(IntegerField, null=True)保存被删 device 的原 pk 以便审计。
|
||||
|
||||
如果产品 / 运营明确说"Device 删除 = 奖励历史就该清空",那要把这个决策写到设计文档里,并在两处保持一致:`AffinityLog.device` 也要改成 CASCADE。当前两边不一致明显是没对齐设计意图。
|
||||
|
||||
**影响范围:**
|
||||
- 数据保留语义不一致
|
||||
- 审计 / 合规风险
|
||||
|
||||
---
|
||||
|
||||
### WR-003:AffinityLog 索引设计偏重,写入开销可能放大 5 倍
|
||||
|
||||
**File:** `qy_lty/userapp/models.py:387-399`
|
||||
|
||||
**Issue:**
|
||||
当前定义了 6 个索引:
|
||||
|
||||
- `created_at` (db_index=True,单字段)
|
||||
- `event_id` (db_index=True,单字段)
|
||||
- `(device, -created_at)` 复合
|
||||
- `(user, -created_at)` 复合
|
||||
- `(rule_key, -created_at)` 复合
|
||||
- `(source, -created_at)` 复合
|
||||
- `unique_affinity_event_id` partial unique
|
||||
|
||||
P2 后端服务每次 apply 都会写 1 条 AffinityLog,热点写场景下每行写入需要更新 7 个 B-tree 节点(含主键),写放大显著。
|
||||
|
||||
实际查询模式(参考设计文档 §7 客户端接口、§9 管理后台)只用到:
|
||||
|
||||
- **客户端拉取最近变化**:`(device, -created_at)` 命中
|
||||
- **管理后台日志列表**:可能按 user / source / rule_key 过滤
|
||||
- **幂等去重**:`event_id` 命中
|
||||
|
||||
`(user, -created_at)` 实质冗余——用户视角的查询可以走 `device` 表 join 实现(且数据量比 device 维度大);`(source, -created_at)` 主要用于管理后台筛选,可以用 `created_at` 单字段索引 + source 过滤实现,PG 在低基数列上即便走 seq scan + index 也不会太慢。
|
||||
|
||||
**Fix:**
|
||||
P1 阶段建议先保留 `(device, -created_at)`、`event_id` partial unique、`created_at` 单字段;删除 `(user, -created_at)`、`(rule_key, -created_at)`、`(source, -created_at)`。等 P5 上线、有真实查询 profile 后再按需加索引。如果坚持保留管理后台过滤,建议改为 PostgreSQL BRIN 索引(对 `created_at` 等只增字段非常高效):
|
||||
|
||||
```python
|
||||
from django.contrib.postgres.indexes import BrinIndex
|
||||
indexes = [
|
||||
models.Index(fields=['device', '-created_at']),
|
||||
BrinIndex(fields=['created_at']),
|
||||
]
|
||||
```
|
||||
|
||||
**影响范围:**
|
||||
- 写入吞吐
|
||||
- 表空间占用
|
||||
|
||||
---
|
||||
|
||||
### WR-004:unique_affinity_event_id 部分唯一索引语义有歧义,空字符串作为"无 event_id"标记不安全
|
||||
|
||||
**File:** `qy_lty/userapp/models.py:393-399`
|
||||
|
||||
**Issue:**
|
||||
|
||||
```python
|
||||
constraints = [
|
||||
UniqueConstraint(
|
||||
fields=['event_id'],
|
||||
condition=Q(event_id__gt=''),
|
||||
name='unique_affinity_event_id',
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
PostgreSQL 下这会生成 `CREATE UNIQUE INDEX ... WHERE event_id > ''`,是合法的 partial index。但有两个问题:
|
||||
|
||||
1. **`event_id__gt=''` 语义脆弱**:依赖 `event_id` 是字符串、依赖 `''` 是 "无值"标记。如果未来某客户端 bug 传了 `' '`(空格)或 `'null'`,会被当作有效 event_id 进入唯一约束。
|
||||
2. **CharField 默认值缺失**:`event_id = models.CharField(..., blank=True, db_index=True)`,没有显式 `default=''`。Django 在 IntegerField/CharField 上 blank=True 不等同于 default='',admin 直接保存可能存为 `None`(虽然 CharField 默认 null=False,但 raw SQL / fixtures 可能注入 NULL)。`event_id__gt=''` 对 NULL 不命中,约束失效。
|
||||
|
||||
更标准的做法是用 `null=True, blank=True` 配合 `condition=Q(event_id__isnull=False)`:
|
||||
|
||||
**Fix:**
|
||||
|
||||
```python
|
||||
event_id = models.CharField(
|
||||
'事件ID', max_length=64, null=True, blank=True, db_index=True,
|
||||
help_text='客户端事件 UUID,用于幂等去重;NULL 表示非客户端来源(衰减/管理员调整)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
UniqueConstraint(
|
||||
fields=['event_id'],
|
||||
condition=Q(event_id__isnull=False),
|
||||
name='unique_affinity_event_id',
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
PostgreSQL 下 NULL 不参与 unique 索引,正好就是想要的语义;且 NULL 比 `''` 更明确地表达"无值"。
|
||||
|
||||
同步在 P2 服务层加输入校验:长度 < 16 或不符合 UUID 格式的 event_id 一律拒绝并报错。
|
||||
|
||||
**影响范围:**
|
||||
- 幂等去重的健壮性
|
||||
- 兼容性(migration 需要 `RunPython` 把现有 `event_id=''` 改成 NULL)
|
||||
|
||||
---
|
||||
|
||||
### WR-005:seed_affinity 缺少 trigger_type='companion_time' 类规则,但模型已支持
|
||||
|
||||
**File:** `qy_lty/userapp/management/commands/seed_affinity.py:28-77`
|
||||
|
||||
**Issue:**
|
||||
`AffinityRule` 模型在 P1-02 增加了 `trigger_type='companion_time'` 选项与 `min_continuous_minutes` / `max_count_per_day` 配套字段,设计文档 §4.2 表格中应有"陪伴时长"规则(每 30 分钟 +1,每日最多 4 次之类),但 `DEFAULT_RULES` 8 条中 7 条是 `action`、1 条是 `decay`,**0 条 companion_time**。
|
||||
|
||||
后续 P3 服务层如果按"规则配置驱动"实现陪伴时长检测,运行时会**找不到任何 companion_time 规则**,要么静默忽略要么报错。
|
||||
|
||||
**Fix:**
|
||||
|
||||
```python
|
||||
# DEFAULT_RULES 追加
|
||||
{
|
||||
'rule_key': 'companion_30min', 'name': '陪伴 30 分钟',
|
||||
'description': '与洛天依持续陪伴 30 分钟可获得好感度',
|
||||
'trigger_type': 'companion_time',
|
||||
'min_change': 1, 'max_change': 2, 'single_cap': 2, 'daily_cap': 8,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
'min_continuous_minutes': 30, 'max_count_per_day': 4,
|
||||
},
|
||||
```
|
||||
|
||||
具体数值需要和产品 / 设计文档对齐;如果设计文档暂未明确,应在 seed 中加注释说明"待定,参见 §4.2"。
|
||||
|
||||
另外建议把 DEFAULT_RULES 抽到 `qy_lty/userapp/affinity/defaults.py`,避免 management command 文件膨胀,且方便单元测试引用。
|
||||
|
||||
**影响范围:**
|
||||
- P3 陪伴时长功能阻塞
|
||||
- 测试覆盖率
|
||||
|
||||
---
|
||||
|
||||
### WR-006:AffinityLevel.description 在 seed 中未设置,依赖 model `blank=True` 隐式 '';未来字段改为 required 会一次性报错
|
||||
|
||||
**File:**
|
||||
- `qy_lty/userapp/management/commands/seed_affinity.py:81-112`
|
||||
- `qy_lty/userapp/models.py:196` (`description = models.TextField('等级描述', blank=True)`)
|
||||
|
||||
**Issue:**
|
||||
当前 `AffinityLevel.description` 在 model 是 `blank=True` 但**没有 default**,Django 在 `create(**spec)` 时如果 spec 没有 `description` 键,会在 Python 层报错(除非 PostgreSQL 那侧字段允许 NULL,但 TextField 默认 NOT NULL)。
|
||||
|
||||
实测时为什么没崩?因为 Django 5 对 TextField + `blank=True` 在 Python 层会注入 `''` 作为隐式默认值。但这是 Django 内部行为,不是合约。一旦未来把 description 改成 required(`blank=False`)或加 unique,seed 会一次性失败。
|
||||
|
||||
**Fix:**
|
||||
显式补齐:
|
||||
|
||||
```python
|
||||
DEFAULT_LEVELS = [
|
||||
{
|
||||
'level': 1, 'name': '初识',
|
||||
'description': '初次相识阶段,了解彼此的基础阶段', # 显式
|
||||
# ...
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
模型侧也建议补 `default=''`:
|
||||
|
||||
```python
|
||||
description = models.TextField('等级描述', blank=True, default='')
|
||||
```
|
||||
|
||||
`AffinityRule.description` 同样问题。
|
||||
|
||||
**影响范围:**
|
||||
- 健壮性
|
||||
- 未来 schema 演进
|
||||
|
||||
---
|
||||
|
||||
### WR-007:seed_affinity @transaction.atomic 包整个 handle,force 模式下部分失败会回滚所有已处理项
|
||||
|
||||
**File:** `qy_lty/userapp/management/commands/seed_affinity.py:124-136`
|
||||
|
||||
**Issue:**
|
||||
|
||||
```python
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
force = options['force']
|
||||
self._seed_setting()
|
||||
rules_created, rules_updated = self._seed_rules(force)
|
||||
levels_created, levels_updated = self._seed_levels(force)
|
||||
self.stdout.write(self.style.SUCCESS(f'\n[seed_affinity] 完成:...'))
|
||||
```
|
||||
|
||||
如果 force 模式下处理第 6 条 rule 时崩溃(例如 JSON 字段格式错),前 5 条 rule 的 update 全部回滚,但 stdout 已经打印 `~ 规则 xxx 已覆盖` 5 次,造成"显示成功但实际未生效"的语义错位,运维排查时会被误导。
|
||||
|
||||
**Fix:**
|
||||
两种方案:
|
||||
|
||||
方案 A(推荐,每条独立事务):
|
||||
|
||||
```python
|
||||
def _seed_rules(self, force):
|
||||
created, updated = 0, 0
|
||||
for spec in DEFAULT_RULES:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# ... 单条处理
|
||||
except Exception as e:
|
||||
self.stderr.write(self.style.ERROR(f' ! 规则 {spec["rule_key"]} 处理失败: {e}'))
|
||||
continue
|
||||
return created, updated
|
||||
```
|
||||
|
||||
并去掉 `handle` 上的 `@transaction.atomic`。这样部分失败不影响其他规则。
|
||||
|
||||
方案 B(保留全局事务,但把 stdout 改为推迟输出):
|
||||
|
||||
```python
|
||||
def handle(self, *args, **options):
|
||||
force = options['force']
|
||||
messages = []
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self._seed_setting(messages)
|
||||
self._seed_rules(force, messages)
|
||||
self._seed_levels(force, messages)
|
||||
except Exception:
|
||||
self.stderr.write(self.style.ERROR('[seed_affinity] 事务回滚,未做任何修改'))
|
||||
raise
|
||||
for m in messages:
|
||||
self.stdout.write(m)
|
||||
```
|
||||
|
||||
方案 A 更符合 seed 命令的运维场景(部分失败可重跑),方案 B 更符合"全有全无"的事务语义。建议方案 A。
|
||||
|
||||
**影响范围:**
|
||||
- 运维诊断
|
||||
- 数据一致性误判
|
||||
|
||||
---
|
||||
|
||||
### WR-008:ParadiseUser.favorability 旧字段保留但无运行时拦截,存在双轨数据写入风险
|
||||
|
||||
**File:** `qy_lty/userapp/models.py:29`
|
||||
|
||||
**Issue:**
|
||||
|
||||
```python
|
||||
favorability = models.IntegerField('好感度', default=0)
|
||||
```
|
||||
|
||||
数据迁移 0006 把数据搬到 `UserDevice.favorability` 后,`ParadiseUser.favorability` 仍然是普通字段,可读可写。但代码库内仍有引用风险:
|
||||
|
||||
- `userapp/serializers.py` 可能仍序列化此字段(未审查到具体文件,需 grep)
|
||||
- 管理后台 `userapp/admin.py` 可能仍在编辑界面暴露
|
||||
- 老的 API 客户端可能仍在 PATCH 这个字段
|
||||
- 团队成员可能在 P2 写新代码时仍然 `user.favorability += 1`
|
||||
|
||||
一旦双写存在,数据双轨:`ParadiseUser.favorability` vs `UserDevice.favorability` 不一致,后续审计极痛苦。
|
||||
|
||||
**Fix:**
|
||||
分两步:
|
||||
|
||||
第一步(P1 收尾,立即做):把字段标 deprecated,在 model 层 override `__setattr__` 或 property 拦截写:
|
||||
|
||||
```python
|
||||
# userapp/models.py
|
||||
@property
|
||||
def favorability(self):
|
||||
import warnings
|
||||
warnings.warn(
|
||||
'ParadiseUser.favorability 已弃用,请使用 UserDevice.favorability。'
|
||||
'此 property 仅返回主设备值,不应用于业务逻辑。',
|
||||
DeprecationWarning, stacklevel=2,
|
||||
)
|
||||
primary = self.devices.filter(is_primary=True, is_active=True).first()
|
||||
return primary.favorability if primary else 0
|
||||
|
||||
@favorability.setter
|
||||
def favorability(self, value):
|
||||
raise AttributeError(
|
||||
'ParadiseUser.favorability 已弃用,写操作不允许。请操作 UserDevice.favorability。'
|
||||
)
|
||||
```
|
||||
|
||||
但 property 不能直接覆盖 model field——需要重命名 DB 字段为 `_legacy_favorability` 然后加一个迁移:
|
||||
|
||||
```python
|
||||
# 0007_deprecate_user_favorability.py
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='paradiseuser',
|
||||
old_name='favorability',
|
||||
new_name='_legacy_favorability',
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
第二步(下个版本):彻底 `migrations.RemoveField`。同时审计所有 `serializers.py` / `admin.py` / `views.py` 中对 `paradiseuser.favorability` 的引用。
|
||||
|
||||
**影响范围:**
|
||||
- 数据一致性
|
||||
- 跨模块代码引用清理
|
||||
|
||||
**注意:** 由于该 property 形式会触发 N+1(每次访问都查 devices),P2 后端的 serializer 应该改为直接展示 UserDevice 列表而不是 ParadiseUser.favorability。在做 property 改造前,先全文搜索 `favorability` 字段被哪里读写:
|
||||
|
||||
```
|
||||
grep -rn 'favorability' qy_lty/ --include='*.py'
|
||||
```
|
||||
|
||||
并清单化每个调用点的修复计划。
|
||||
|
||||
---
|
||||
|
||||
### WR-009:AffinityLevel 区间允许重叠 / 留空隙,等级匹配结果不确定
|
||||
|
||||
**File:** `qy_lty/userapp/models.py:174-244`
|
||||
|
||||
**Issue:**
|
||||
设计文档 §6.2 假定等级区间是 `[0,20], [21,40], [41,60], [61,80], [81,100]` 完整覆盖且不重叠。但 model 没有任何约束防止:
|
||||
|
||||
- 重叠:Lv2 `min_affinity=21, max_affinity=40`、Lv3 `min_affinity=35, max_affinity=60` → 好感度 38 同时匹配 Lv2 和 Lv3,服务端取等级取决于查询排序
|
||||
- 空隙:Lv2 `[21,40]`、Lv3 `[50,60]` → 好感度 45 的设备等级查不出来,缓存 `affinity_level` 字段无值
|
||||
|
||||
P2 / P3 服务层等级计算 `AffinityLevel.objects.filter(min_affinity__lte=v, max_affinity__gte=v).first()` 会静默取一个,不报错。
|
||||
|
||||
**Fix:**
|
||||
PostgreSQL 用 `EXCLUDE` constraint + `btree_gist` 扩展可以保证区间不重叠:
|
||||
|
||||
```python
|
||||
# AffinityLevel.Meta
|
||||
constraints = [
|
||||
ExclusionConstraint(
|
||||
name='affinitylevel_no_overlap',
|
||||
expressions=[
|
||||
(NumRange('min_affinity', 'max_affinity', '[]'), '&&'),
|
||||
],
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
但需要在迁移里 `CREATE EXTENSION btree_gist`,部署成本上升。
|
||||
|
||||
更简单的方案:在 `AffinityLevel.clean()` 加 Python 校验,加 `seed_affinity` / management command `verify_affinity_levels` 检查完整覆盖:
|
||||
|
||||
```python
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.min_affinity > self.max_affinity:
|
||||
raise ValidationError({'max_affinity': '上限不能小于下限'})
|
||||
# 与其他等级的重叠检查
|
||||
overlaps = AffinityLevel.objects.exclude(pk=self.pk).filter(
|
||||
Q(min_affinity__lte=self.max_affinity) &
|
||||
Q(max_affinity__gte=self.min_affinity) &
|
||||
Q(is_deleted=False)
|
||||
)
|
||||
if overlaps.exists():
|
||||
raise ValidationError(
|
||||
f'与等级 {", ".join(str(l) for l in overlaps)} 区间重叠'
|
||||
)
|
||||
```
|
||||
|
||||
并在 admin / serializer 显式 `full_clean()`。
|
||||
|
||||
**影响范围:**
|
||||
- 等级计算的正确性
|
||||
- 缓存 `UserDevice.affinity_level` 字段的可信度
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
|
||||
### IN-001:AffinityRule 旧字段 points / daily_limit / is_active 缺乏 db_column 与显式 deprecation 路径
|
||||
|
||||
**File:** `qy_lty/userapp/models.py:148-160`
|
||||
|
||||
**Issue:**
|
||||
注释说"下个版本删除",但没有:
|
||||
- TODO / FIXME 关联 ticket / version
|
||||
- `RemovedInVNextWarning` 装饰
|
||||
- 哪个版本会删的具体说明
|
||||
|
||||
后续开发者(包括 LLM agent)查这些字段时只能靠 docstring。
|
||||
|
||||
**Fix:**
|
||||
加显式注释:
|
||||
|
||||
```python
|
||||
points = models.IntegerField(
|
||||
'积分(已弃用)', default=0,
|
||||
help_text='[DEPRECATED v0.3 → v0.4 删除] 使用 min_change/max_change',
|
||||
)
|
||||
```
|
||||
|
||||
或者用 Python 级 warnings,在 model property 上拦截访问。同时在 `docs/好感度系统-开发任务清单.md` 加一条"P1-收尾:清理 deprecated 字段"。
|
||||
|
||||
---
|
||||
|
||||
### IN-002:DEFAULT_RULES 和 DEFAULT_LEVELS 应抽到独立模块便于复用
|
||||
|
||||
**File:** `qy_lty/userapp/management/commands/seed_affinity.py:28-112`
|
||||
|
||||
**Issue:**
|
||||
两个常量 dict 占了 80% 文件,且 P2 单元测试 / 集成测试也会需要这些 fixture("给我一个默认 chat 规则")。当前埋在 management command 文件里只能通过 `from userapp.management.commands.seed_affinity import DEFAULT_RULES` 导入,路径丑陋且非常规。
|
||||
|
||||
**Fix:**
|
||||
|
||||
```python
|
||||
# qy_lty/userapp/affinity/defaults.py
|
||||
DEFAULT_RULES = [...]
|
||||
DEFAULT_LEVELS = [...]
|
||||
DEFAULT_SETTING = {...}
|
||||
|
||||
# seed_affinity.py
|
||||
from userapp.affinity.defaults import DEFAULT_RULES, DEFAULT_LEVELS
|
||||
```
|
||||
|
||||
测试代码、P2 service 层、文档生成都可以复用。
|
||||
|
||||
---
|
||||
|
||||
### IN-003:AffinityRule.daily_cap 与 AffinitySetting.daily_cap 同名易混淆
|
||||
|
||||
**File:**
|
||||
- `qy_lty/userapp/models.py:124` (`AffinityRule.daily_cap`)
|
||||
- `qy_lty/userapp/models.py:263` (`AffinitySetting.daily_cap`)
|
||||
|
||||
**Issue:**
|
||||
P2 / P3 服务层代码会同时引用两者,`if today_total >= rule.daily_cap` 和 `if global_today_total >= setting.daily_cap` 容易拼错。
|
||||
|
||||
**Fix:**
|
||||
建议把 `AffinitySetting.daily_cap` 重命名为 `global_daily_cap` 或 `daily_cap_global`,让一眼区分。需要一个 `RenameField` 迁移,成本不高。
|
||||
|
||||
---
|
||||
|
||||
### IN-004:AffinityLog __str__ 在 self.id 为 None(未保存)时会显示 #None
|
||||
|
||||
**File:** `qy_lty/userapp/models.py:401-402`
|
||||
|
||||
**Issue:**
|
||||
|
||||
```python
|
||||
def __str__(self):
|
||||
return f"#{self.id} {self.rule_key or self.source} {self.before_value}->{self.after_value}"
|
||||
```
|
||||
|
||||
`AffinityLog(...)` 但未 save 时 `self.id` 是 None,调试输出形如 `#None chat 0->5`。无功能影响,但不优雅。
|
||||
|
||||
**Fix:**
|
||||
|
||||
```python
|
||||
def __str__(self):
|
||||
return f"#{self.pk or 'new'} {self.rule_key or self.source} {self.before_value}->{self.after_value}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### IN-005:UserDevice.is_active 与 Device.is_active 命名冲突,help_text 已警告但仍建议改名
|
||||
|
||||
**File:** `qy_lty/device_interaction/models.py:122-126`
|
||||
|
||||
**Issue:**
|
||||
`Device.is_active`(行 50)表示"设备已激活",`UserDevice.is_active`(新增)表示"绑定关系有效"。help_text 已经显式说"与 Device.is_active 不是同一概念"——这本身就是一个 code smell 信号。
|
||||
|
||||
后续 `select_related('device')` 时 `ud.device.is_active` vs `ud.is_active` 表达截然不同的语义,极易拼错。
|
||||
|
||||
**Fix:**
|
||||
建议把 `UserDevice.is_active` 重命名为 `is_binding_active` 或 `is_bound`:
|
||||
|
||||
```python
|
||||
is_bound = models.BooleanField(
|
||||
'绑定有效', default=True,
|
||||
help_text='软删除标记。解绑置为 false,重绑时可读取历史值。',
|
||||
)
|
||||
```
|
||||
|
||||
需要新迁移 `RenameField`,但 P1 尚未上线(按用户描述"纯数据层工作"),改名成本低。如果已有数据写入,必须按 CR-001 同步修改所有调用点。
|
||||
|
||||
---
|
||||
|
||||
### IN-006:0006 数据迁移 print 输出无前缀格式,迁移日志难以 grep
|
||||
|
||||
**File:** `qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py:60-64, 85`
|
||||
|
||||
**Issue:**
|
||||
|
||||
```python
|
||||
print(
|
||||
f"\n[P1-09] favorability 数据迁移完成:"
|
||||
f"成功 {migrated_count},无设备跳过 {skipped_no_device},"
|
||||
f"零值跳过 {skipped_zero}"
|
||||
)
|
||||
```
|
||||
|
||||
Django 推荐迁移内用 `schema_editor.connection.ops.executor.stdout` 或 `apps.get_app_config('userapp').stdout`,更标准的是 `print` 但加上 `[migration 0006_xxx]` 前缀。当前以 `[P1-09]` 业务标识写死,未来根据迁移文件名查找时不方便。
|
||||
|
||||
**Fix:**
|
||||
|
||||
```python
|
||||
print(f"\n[migration 0006_migrate_favorability] forward: 成功={migrated_count}, "
|
||||
f"无设备={skipped_no_device}, 零值={skipped_zero}")
|
||||
```
|
||||
|
||||
并把所有迁移内 print 改用统一格式。
|
||||
|
||||
---
|
||||
|
||||
## 修复优先级建议
|
||||
|
||||
| 优先级 | 编号 | 阻塞 | 建议时机 |
|
||||
|--------|------|------|----------|
|
||||
| P0 | CR-001 | P2 软删 / 已存在功能回归 | **必须**在 P2 service 层落地前修复,最迟本周 |
|
||||
| P0 | CR-002 | P2 service 层稳定性 | **必须**在 P2 service 层落地前修复 |
|
||||
| P0 | CR-003 | 重跑迁移 / 数据完整性 | 立即修复(已有迁移)+ 写运维 SOP "不可重跑此迁移" |
|
||||
| P1 | WR-001 ~ WR-009 | 部分阻塞 P2/P3 | P2 第一周完成 |
|
||||
| P2 | IN-001 ~ IN-006 | 不阻塞 | 与 P2/P3 渐进改造 |
|
||||
|
||||
特别注意 CR-001 与 IN-005 是配对的——改名 + 加 manager + 修所有调用点 + 更新 CLAUDE.md,应作为同一个 commit 完成,避免半成品状态。
|
||||
|
||||
---
|
||||
|
||||
## Cross-Module 调用链分析(deep 模式补充)
|
||||
|
||||
按用户在配置中明确要求做 cross-app FK 与 cross-module 调用链审查,补充如下:
|
||||
|
||||
### Call Chain 1:MAC 登录 → UserDevice 控制权解析
|
||||
|
||||
```
|
||||
MAC 设备 →
|
||||
userapp/views.py:120 UserDevice.objects.filter(device=device).order_by('-bound_at').first() →
|
||||
签发 user-token →
|
||||
device_interaction/auth.py 解析 token →
|
||||
WS connect → group = device_{user_id}
|
||||
```
|
||||
|
||||
**风险:** 全程未过滤 `is_active`。详见 CR-001。
|
||||
|
||||
### Call Chain 2:好感度变更 → 日志 → 等级 → 奖励发放(未来 P2/P3)
|
||||
|
||||
```
|
||||
事件接收 →
|
||||
AffinityRule 查询(按 rule_key) →
|
||||
random.randint(min_change, max_change) →
|
||||
UserAffinityDailyCounter 累加 + AffinitySetting.daily_cap 检查 →
|
||||
UserDevice.favorability 写 →
|
||||
AffinityLog 写(event_id 幂等)→
|
||||
AffinityLevel 区间匹配 →
|
||||
UserLevelRewardGrant 写(unique 防重复)→
|
||||
WebSocket 推送
|
||||
```
|
||||
|
||||
**潜在风险点:**
|
||||
- AffinityRule.min_change > max_change → `random.randint` 崩(CR-002)
|
||||
- AffinityRule 不存在 `companion_time` 规则 → 陪伴时长事件被忽略(WR-005)
|
||||
- AffinityLevel 区间重叠 → 等级匹配结果不确定(WR-009)
|
||||
- UserLevelRewardGrant.device CASCADE → 设备删后奖励历史丢失(WR-002)
|
||||
- AffinityLog.event_id 用 `''` 而非 NULL → 幂等失效边界 case(WR-004)
|
||||
|
||||
### Cross-App FK 一致性检查
|
||||
|
||||
| FK | on_delete | 设计意图 | 实际表现 | 一致? |
|
||||
|----|-----------|---------|---------|--------|
|
||||
| AffinityLog.device | SET_NULL | 历史保留 | ✓ | ✓ |
|
||||
| AffinityLog.rule | SET_NULL | 规则删后日志保留 | ✓ + rule_key 冗余 | ✓ |
|
||||
| AffinityLog.user | CASCADE | 用户注销清理 | ✓ | ✓ |
|
||||
| UserAffinityDailyCounter.device | CASCADE | 计数器随设备删 | ✓ | ✓(合理) |
|
||||
| UserAffinityDailyCounter.rule | CASCADE | 规则删后计数器删 | ⚠ 与 AffinityLog.rule SET_NULL 不一致 | 部分一致 |
|
||||
| UserLevelRewardGrant.device | CASCADE | ? | 设计意图未明确,造成歧义 | ✗ (WR-002) |
|
||||
|
||||
`UserAffinityDailyCounter.rule` 用 CASCADE 也值得讨论:一条 rule 被软删后(`is_deleted=True`),新事件不会再用,但旧的当天计数器仍在使用中。建议保持 CASCADE 但补充设计文档说明"软删 rule 时业务必须等当天计数器清零再硬删"。
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-05-13T00:00:00Z_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: deep_
|
||||
_Note: 本次审查不在 GSD .planning/phase-N 结构内,按用户要求保存到 docs/REVIEW-affinity-P1.md_
|
||||
@ -50,18 +50,18 @@
|
||||
|
||||
| # | 子任务 | 产出物 | 验收标准 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| P2-01 | Service 层骨架 | 新建 `qy_lty/affinity/services.py`,定义 `AffinityService.apply(user_id, device_id, rule_key, source, event_id, metadata)` 单一入口 | 单元测试覆盖正常路径 | ⬜ |
|
||||
| P2-02 | Redis 计数器工具 | 冷却 / 单规则日上限 / 全局日上限三类 key,含 Asia/Shanghai 自然日切换 | `cd:{device}:{rule}`、`daily:{device}:{rule}:{YYYYMMDD}`、`daily:{device}:_global:{YYYYMMDD}` 命名一致 | ⬜ |
|
||||
| P2-03 | 等级映射 + 缓存更新 | 根据 `favorability` 计算 `affinity_level`,写回 `UserDevice` | 区间边界正确(含闭区间) | ⬜ |
|
||||
| P2-04 | 跨级奖励发放(A3:方案 B) | 升级时逐级发放,每级一个独立事务,失败的入重试队列;写 `UserLevelRewardGrant` 防重 | 同设备同等级不重发;外部失败不影响等级提升 | ⬜ |
|
||||
| P2-05 | AffinityLog 写入 + WS 推送钩子 | service 末尾发 channel layer 消息到 `device_{user_id}` group | 单设备变化推送到该用户所有在线端 | ⬜ |
|
||||
| P2-06 | `AffinityRule` ViewSet 重写 | CRUD + `rule_key` 唯一校验 + 软删除 | admin 角色才可写;普通管理员 403 | ⬜ |
|
||||
| P2-07 | `AffinityLevel` ViewSet | CRUD + 区间不重叠不空隙校验 | 校验失败返回 400 + 明确错误信息 | ⬜ |
|
||||
| P2-08 | `AffinitySetting` GET/PUT | 单例接口 | PUT 后立即生效(清缓存) | ⬜ |
|
||||
| P2-09 | `AffinityLog` 查询接口 | `/api/admin/affinity/logs/` 支持过滤 user/device/rule/时间 | 分页 + 排序 + 性能(10w 行 < 500ms) | ⬜ |
|
||||
| P2-10 | 数据统计接口 | `/api/admin/affinity/stats/` 返回平均/最高/活跃/今日互动等指标 | 按设备聚合,与设计文档 §7.1 一致 | ⬜ |
|
||||
| P2-11 | 用户设备好感度查询(admin) | `/api/admin/affinity/devices/?user_id=` 列出该用户所有设备及好感度/等级 | 含已解绑(is_active=false)的归档项标记 | ⬜ |
|
||||
| P2-12 | 管理员手动调整接口 | `/api/admin/affinity/adjust/`(必传 device_id)+ `/api/admin/affinity/adjust-batch/`(用户名下所有设备各加 X) | 钳位 [0, max_affinity];写 log + operator_admin_id + reason | ⬜ |
|
||||
| P2-01 | Service 层骨架 | `userapp/affinity/services.py` — `AffinityService.apply()` + `admin_adjust()` 单一入口;10 步流水线含 event_id 去重 → 取规则 → 冷却 → 取设备 → 计算 + 钳位 → 规则日上限 → 全局日上限 → 原子写库 → Redis 累加 → 奖励 → WS 推送 | smoke test 6 项全 PASS(chat/dup/cooldown/admin_adjust/clamp/no_rule) | ✅ |
|
||||
| P2-02 | Redis 计数器工具 | `userapp/affinity/counters.py` — 三类 key (cd / daily rule / daily global) + event_id 去重;Asia/Shanghai 自然日通过 `zoneinfo.ZoneInfo` 计算;cache.add+incr 原子语义;TTL 48h | smoke test 验证冷却 + event_id 去重命中 | ✅ |
|
||||
| P2-03 | 等级映射 + 缓存更新 | `userapp/affinity/levels.py` — `map_value_to_level` / `update_device_level` / `progress_to_next_level`;仅 level 变化时 save(update_fields=) | smoke test 验证跨级更新 affinity_level 缓存 | ✅ |
|
||||
| P2-04 | 跨级奖励发放(A3:方案 B) | `userapp/affinity/rewards.py` — `grant_levels(user_device, from, to)` 逐级独立事务;UserLevelRewardGrant 唯一约束防重;外部派发 hook STUB(P3/P4 接外部) | smoke test 验证 +80 跨 4 级写入 4 条 UserLevelRewardGrant | ✅ |
|
||||
| P2-05 | AffinityLog 写入 + WS 推送钩子 | `userapp/affinity/ws.py` — 3 类事件 (affinity_update / level_up / level_down);asgiref async_to_sync 包装 channel_layer.group_send;fire-and-forget 故障不阻塞 | smoke test 后 AffinityLog 写入 3 行 | ✅ |
|
||||
| P2-06 | `AffinityRule` ViewSet 重写 | `userapp/affinity/views.py` AffinityRuleAdminViewSet — ModelViewSet + 软删 perform_destroy (is_deleted+is_enabled=False) + `restore` action;?include_deleted=true 显示全集 | URL reverse + permission IsAdminUserStaff 验证 | ✅ |
|
||||
| P2-07 | `AffinityLevel` ViewSet | AffinityLevelAdminViewSet — 同上软删;serializer 跨字段校验区间重叠 | URL reverse | ✅ |
|
||||
| P2-08 | `AffinitySetting` GET/PUT | AffinitySettingView APIView — GET/PUT/PATCH 单例;pk=1 硬约束;衰减区间跨字段校验 | URL `/api/v1/admin/affinity/settings/` reverse | ✅ |
|
||||
| P2-09 | `AffinityLog` 查询接口 | AffinityLogListView — 过滤 user/device/rule/source/date_range;自实现分页 page_size 上限 200;select_related 防 N+1 | URL `/api/v1/admin/affinity/logs/` reverse | ✅ |
|
||||
| P2-10 | 数据统计接口 | AffinityStatsView — avg/max/top_count/active_7d/total_devices/today_interactions/today_change_sum/rule_freq_top/level_distribution;全部基于 UserDevice.active | URL reverse;返回结构与 §7.1 对齐 | ✅ |
|
||||
| P2-11 | 用户设备好感度查询(admin) | UserAffinityDevicesView — `?user_id=` 必传 + 404 校验;?include_unbound=true 含历史;CR-001 默认仅 is_bound=True | URL reverse | ✅ |
|
||||
| P2-12 | 管理员手动调整接口 | AffinityAdjustView / AffinityAdjustBatchView — 委托 AffinityService.admin_adjust;批量遍历 UserDevice.active 逐台调用;返回 per-device 结果数组 | smoke test 验证 +5 + 钳位到 max_affinity | ✅ |
|
||||
|
||||
**完工里程碑**:所有 admin 接口可在 Postman 调通;service.apply 写入 log + 推 WS + 算等级。
|
||||
|
||||
@ -143,3 +143,6 @@
|
||||
|---|---|---|
|
||||
| 2026-04-24 | 初版创建 | — |
|
||||
| 2026-04-24 | P1-01 ~ P1-10 全部完成;models/migrations/seed 命令落地,等待 `migrate` 应用到数据库 | Claude |
|
||||
| 2026-05-13 | P1 代码审查 (REVIEW-affinity-P1.md):3 Critical + 9 Warning + 6 Info = 18 项 finding | Claude (gsd-code-reviewer) |
|
||||
| 2026-05-13 | P1 审查修复 (REVIEW-affinity-P1-FIX-REPORT.md):17 FIXED + 1 PARTIAL (WR-008) + 0 SKIPPED;4 commits A/B/C/D + 5 新迁移 + 1 重写;migrate / seed 验证通过;CHECK 约束 DB 层 smoke test PASS。P1 可以收尾,进入 P2。 | Claude (gsd-code-fixer) |
|
||||
| 2026-05-13 | P2-01 ~ P2-12 全部完成:service 层(counters/levels/ws/rewards/services)+ admin API(rules/levels/settings/logs/stats/devices/adjust×2)落地;userapp/admin_urls.py 挂载 `/api/v1/admin/affinity/...`;Django check 通过;6 URL reverse 解析正确;service 6 项 smoke test 全 PASS(applied/no_rule/cooldown/event_dup/admin_adjust/clamp);存量数据未污染(测试设备已重置)。可进入 P3 管理端前端接通。 | Claude |
|
||||
|
||||
@ -2,6 +2,13 @@
|
||||
|
||||
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导。
|
||||
|
||||
## 沟通语言(重要 — 始终生效)
|
||||
|
||||
- 在本仓库工作时,**所有面向用户的回复**(思考后的最终回答、状态更新、错误说明、提问、计划摘要等)**统一使用中文**
|
||||
- 内部思考(thinking)可使用任意语言以保证推理质量,但**呈现给用户的输出必须是中文**
|
||||
- 工具调用参数、Git 提交信息(commit message)、代码注释保持原项目约定(中文为主,必要的英文术语、变量名、API 名等可保留)
|
||||
- 此规则覆盖默认的英文输出倾向;只有当用户明确要求改用其他语言时才切换
|
||||
|
||||
## 项目概述
|
||||
|
||||
QY LTY Backend 是一个基于 Django 的综合性后端服务,提供以下功能:
|
||||
@ -226,7 +233,8 @@ docker-compose up -d --build
|
||||
- `UserDevice` 关联表的 `Meta.ordering = ['-bound_at']`
|
||||
- **"后绑的挤掉先绑的"语义**:`userapp/views.py` 的 MAC 登录显式按 `order_by('-bound_at').first()` 取最新绑定者并签发 user-token;`device_interaction/views.py` 中的 `bind_status` / `rtc-token/get_by_mac` 等使用 `.first()` 隐式依赖该 ordering,结果一致
|
||||
- 由于 WebSocket 分组是 `device_{user_id}`,**同一台设备同一时刻只有一个用户能真正控制它**——即最近一次绑定的那个用户
|
||||
- 旧的 `UserDevice` 记录**不会**被自动删除,仅在控制权解析中被忽略;如需"换绑"语义请显式删除旧记录
|
||||
- **必须过滤 `is_bound=True`(硬规则)**:P1-08 引入 `UserDevice.is_bound`(原名 `is_active`,P1 收尾因与 `Device.is_active` 命名冲突已改名)作为软删除标记。**所有控制权解析的查询(MAC 登录、WS 分组、RTC 房间路由、绑定校验、`bind_status`)必须使用 `UserDevice.active.filter(...)`**(`active` 是 `ActiveUserDeviceManager`,自动过滤 `is_bound=True`)。直接 `UserDevice.objects.filter(...)` 仅供管理后台 / 审计 / 数据迁移等需要历史记录的场景
|
||||
- 旧的 `UserDevice` 记录**软删而非硬删**:解绑置 `is_bound=False`,重绑时可读取历史好感度值;硬删仅在运维清理场景下手工执行
|
||||
- `is_primary` 是"用户视角的主设备"(每个用户最多一个),**不是**"设备视角的主控用户"——同一台设备可能出现多条 `is_primary=True` 的记录
|
||||
- **测试 MAC `AA:BB:CC:DD:EE:FF`** 在 `device_interaction/serializers.py` 与 `views.py` 中被硬编码跳过"设备已被其他用户绑定"校验,仅供测试用
|
||||
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
"""把 UserDevice.is_active 改名为 is_bound — P1 收尾 IN-005 + CR-001
|
||||
|
||||
背景:
|
||||
UserDevice.is_active(P1-08 引入,表示"绑定关系有效"软删除标记)与 Device.is_active
|
||||
(表示"设备已激活")命名冲突,select_related('device') 后 ud.device.is_active vs
|
||||
ud.is_active 语义截然不同,极易拼错。审查报告 IN-005 与 CR-001 显式建议合并修复。
|
||||
|
||||
迁移行为:
|
||||
RenameField 在 PostgreSQL 上是元数据级 ALTER TABLE RENAME COLUMN,O(1) 锁,
|
||||
不需要数据拷贝;现有 is_active=True 的行经此操作变为 is_bound=True。
|
||||
|
||||
注意:
|
||||
模型层同步引入 ActiveUserDeviceManager(active manager)+ base_manager_name='objects'
|
||||
保证 admin 默认 queryset 不受影响。所有控制权解析调用点必须切到
|
||||
`UserDevice.active.filter(...)`(详见审查报告 CR-001)。
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('device_interaction', '0003_userdevice_affinity_level_userdevice_favorability_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='userdevice',
|
||||
old_name='is_active',
|
||||
new_name='is_bound',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userdevice',
|
||||
name='is_bound',
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text='软删除标记。解绑置为 false,重绑时可读取历史值。'
|
||||
'原名 is_active(P1-08 引入),因与 Device.is_active'
|
||||
'(设备激活态)命名冲突,于 P1 收尾改名为 is_bound。',
|
||||
verbose_name='绑定有效',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.12 on 2026-05-13 02:09
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('device_interaction', '0004_rename_userdevice_is_active_is_bound'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='userdevice',
|
||||
options={'base_manager_name': 'objects', 'ordering': ['-bound_at'], 'verbose_name': '用户设备', 'verbose_name_plural': '用户设备'},
|
||||
),
|
||||
]
|
||||
@ -90,6 +90,19 @@ class Device(models.Model):
|
||||
return f"{self.device_type.code}-{self.batch.batch_number}-{self.serial_number}"
|
||||
|
||||
|
||||
class ActiveUserDeviceManager(models.Manager):
|
||||
"""仅返回 is_bound=True 的有效绑定记录。
|
||||
|
||||
用于控制权解析(MAC 登录 / WS 分组 / RTC 房间路由 / 绑定校验)等
|
||||
必须忽略软删历史绑定的查询场景。
|
||||
|
||||
历史记录访问请使用默认 manager `UserDevice.objects`。
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(is_bound=True)
|
||||
|
||||
|
||||
class UserDevice(models.Model):
|
||||
"""用户设备关联表
|
||||
|
||||
@ -97,7 +110,14 @@ class UserDevice(models.Model):
|
||||
favorability: 当前好感度值
|
||||
affinity_level: 当前等级缓存(由服务端计算)
|
||||
last_active_at: 最近一次互动时间,用于衰减判断
|
||||
is_active: 软删除标记。解绑置为 false,重绑可读取历史值
|
||||
is_bound: 软删除标记。解绑置为 false,重绑可读取历史值
|
||||
(原名 is_active,因与 Device.is_active 命名冲突,P1 收尾时改名)
|
||||
|
||||
Managers:
|
||||
objects: 默认 manager,返回全部记录(含已解绑历史)。
|
||||
用于审计 / 后台运维 / 数据迁移等场景。
|
||||
admin 默认 queryset 也用此 manager(Meta.base_manager_name 显式声明)。
|
||||
active: 仅返回 is_bound=True 的绑定关系。控制权解析必须用此 manager。
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(ParadiseUser, on_delete=models.CASCADE, verbose_name='用户', related_name='devices')
|
||||
@ -119,17 +139,24 @@ class UserDevice(models.Model):
|
||||
'最近互动时间', null=True, blank=True, db_index=True,
|
||||
help_text='用于衰减判断;服务端在每次成功 apply 时刷新'
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
is_bound = models.BooleanField(
|
||||
'绑定有效', default=True,
|
||||
help_text='软删除标记。解绑置为 false,重绑时可读取历史值。'
|
||||
'注意:与 Device.is_active(设备激活态)不是同一概念'
|
||||
'原名 is_active(P1-08 引入),因与 Device.is_active(设备激活态)'
|
||||
'命名冲突,于 P1 收尾改名为 is_bound。'
|
||||
)
|
||||
|
||||
# 双 manager:保留历史访问,但提供 active 强制语义
|
||||
objects = models.Manager()
|
||||
active = ActiveUserDeviceManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = '用户设备'
|
||||
verbose_name_plural = '用户设备'
|
||||
ordering = ['-bound_at']
|
||||
unique_together = ['user', 'device']
|
||||
# 显式声明 admin / 反向关系默认使用全集 manager,避免 active 影响管理后台
|
||||
base_manager_name = 'objects'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.device.device_code}"
|
||||
|
||||
@ -121,8 +121,9 @@ class DeviceBindSerializer(serializers.Serializer):
|
||||
except Device.DoesNotExist:
|
||||
raise serializers.ValidationError("设备不存在")
|
||||
|
||||
# 检查设备是否已被其他用户绑定(测试 MAC 跳过此检查)
|
||||
if value != 'AA:BB:CC:DD:EE:FF' and UserDevice.objects.filter(device=device).exists():
|
||||
# 检查设备是否已被其他用户「有效」绑定(测试 MAC 跳过此检查)
|
||||
# 使用 active manager 显式过滤 is_bound=True,忽略软删历史绑定(P1 收尾 CR-001)
|
||||
if value != 'AA:BB:CC:DD:EE:FF' and UserDevice.active.filter(device=device).exists():
|
||||
raise serializers.ValidationError("设备已被其他用户绑定")
|
||||
|
||||
return value
|
||||
|
||||
return value
|
||||
|
||||
@ -459,7 +459,8 @@ class DeviceViewSet(viewsets.ModelViewSet):
|
||||
|
||||
try:
|
||||
device = Device.objects.get(mac_address=mac_address)
|
||||
user_device = UserDevice.objects.filter(device=device).first()
|
||||
# 使用 active manager 显式过滤 is_bound=True,忽略软删历史绑定(P1 收尾 CR-001)
|
||||
user_device = UserDevice.active.filter(device=device).order_by('-bound_at').first()
|
||||
|
||||
if user_device:
|
||||
return success_response(
|
||||
@ -690,16 +691,17 @@ class UserDeviceViewSet(viewsets.ModelViewSet):
|
||||
# 获取设备
|
||||
device = Device.objects.get(mac_address=mac_address)
|
||||
|
||||
# 检查是否已被当前用户绑定
|
||||
existing = UserDevice.objects.filter(device=device, user=request.user).first()
|
||||
# 检查是否已被当前用户「有效」绑定(active manager 过滤 is_bound=True,CR-001)
|
||||
existing = UserDevice.active.filter(device=device, user=request.user).first()
|
||||
if existing:
|
||||
return success_response(
|
||||
data=UserDeviceSerializer(existing).data,
|
||||
message='设备已绑定'
|
||||
)
|
||||
|
||||
# 检查是否已被其他用户绑定(测试 MAC 跳过此检查)
|
||||
if mac_address != 'AA:BB:CC:DD:EE:FF' and UserDevice.objects.filter(device=device).exists():
|
||||
# 检查是否已被其他用户「有效」绑定(测试 MAC 跳过此检查)
|
||||
# 使用 active manager 显式过滤 is_bound=True,忽略软删历史绑定(CR-001)
|
||||
if mac_address != 'AA:BB:CC:DD:EE:FF' and UserDevice.active.filter(device=device).exists():
|
||||
return error_response(message='设备已被其他用户绑定', code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 激活设备
|
||||
@ -1155,10 +1157,11 @@ class VolcEngineTokenViewSet(viewsets.ViewSet):
|
||||
return not_found_response(message='设备不存在')
|
||||
|
||||
# 检查设备是否已激活绑定给用户
|
||||
user_device = UserDevice.objects.filter(device=device).first()
|
||||
# 使用 active manager 显式过滤 is_bound=True,忽略软删历史绑定(P1 收尾 CR-001)
|
||||
user_device = UserDevice.active.filter(device=device).order_by('-bound_at').first()
|
||||
if not user_device:
|
||||
return error_response(message='设备未绑定给任何用户', code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
user_id = str(user_device.user.id)
|
||||
|
||||
# 生成房间ID
|
||||
|
||||
@ -23,6 +23,142 @@
|
||||
|
||||
<!-- 新的修改记录添加在此处下方,最新的在最前面 -->
|
||||
|
||||
### [2026-05-13] 好感度系统 P2 阶段 — Service 层 + 管理端 API 落地
|
||||
|
||||
配套设计文档:[../../docs/好感度系统功能与规则设计.md](../../docs/好感度系统功能与规则设计.md)
|
||||
配套任务清单:[../../docs/好感度系统-开发任务清单.md](../../docs/好感度系统-开发任务清单.md)(P2-01 ~ P2-12 全部完成)
|
||||
|
||||
本次完成「好感度系统服务层 + 管理端 API」整体落地,所有好感度变化收敛到 `AffinityService.apply()` 单一入口,管理端 admin 后台具备完整 CRUD + 数据统计 + 设备查询 + 手动调整能力。Service 层 6 项 smoke test 全 PASS(含正常 apply / event_id 去重 / 冷却拦截 / admin_adjust / 钳位)。
|
||||
|
||||
- **文件路径**:
|
||||
- `userapp/affinity/counters.py`(**新建** — P2-02 Redis 计数器:冷却 / 单规则日上限 / 全局日上限 / event_id 去重;Asia/Shanghai 自然日基准;TTL 48h;用 django-redis cache.add+incr 原子语义)
|
||||
- `userapp/affinity/levels.py`(**新建** — P2-03 等级映射:map_value_to_level / progress_to_next_level / update_device_level;区间匹配按 -level desc 优先,重叠场景配合 P1 clean() 校验拦截)
|
||||
- `userapp/affinity/ws.py`(**新建** — P2-05 WS 推送:push_affinity_update / push_level_up / push_level_down;asgiref async_to_sync 包装 channel_layer.group_send,向 device_{user_id} 分组广播;故障 fire-and-forget 不阻塞主流程)
|
||||
- `userapp/affinity/rewards.py`(**新建** — P2-04 跨级奖励发放,A3 方案 B:每级独立事务,UserLevelRewardGrant 唯一约束保证幂等;外部派发 hook _dispatch_reward_to_external_systems 暂为 STUB,P3/P4 接虚拟货币/道具 app 时实现)
|
||||
- `userapp/affinity/services.py`(**新建** — P2-01 AffinityService.apply() 主入口:10 步流水线 [event_id 去重 → 取规则 → 冷却 → 取 UserDevice → 计算变化 + single_cap 钳位 → 规则日上限 → 全局日上限 → 原子写 favorability + log + counter + 等级缓存 → Redis 计数器累加 → 奖励发放 → WS 推送];admin_adjust 专用入口绕过 rule 但仍走钳位 + 日志 + 等级 + 奖励 + WS)
|
||||
- `userapp/affinity/serializers.py`(**新建** — 9 个序列化器:Rule/Level/Setting 三个 ModelSerializer 含跨字段校验,AffinityLogSerializer 只读 + 关联字段展开,UserDeviceAffinitySerializer 含 device_code/mac/level_name,AffinityAdjust 与 AffinityAdjustBatch 用 Serializer 而非 ModelSerializer)
|
||||
- `userapp/affinity/permissions.py`(**新建** — IsAdminUserStaff 复用 IsAuthenticated 并加 is_staff 检查)
|
||||
- `userapp/affinity/views.py`(**新建** — 7 个视图:AffinityRuleAdminViewSet / AffinityLevelAdminViewSet ModelViewSet + 软删 perform_destroy + restore action;AffinitySettingView APIView 单例 GET/PUT/PATCH;AffinityLogListView 含 user/device/rule/source/date_range/分页过滤;AffinityStatsView 聚合 avg/max/top_count/active_7d/today_interactions/rule_freq_top/level_distribution;UserAffinityDevicesView 按 user_id 展开设备列表,CR-001 默认仅返回 is_bound=True;AffinityAdjustView + AffinityAdjustBatchView 委托 AffinityService.admin_adjust)
|
||||
- `userapp/affinity/urls.py`(**新建** — DRF DefaultRouter 注册 rules/levels CRUD + 5 个独立 path 挂 settings/logs/stats/devices/adjust*)
|
||||
- `userapp/admin_urls.py`(修改 — 引入 include 并新增 `path('affinity/', include('userapp.affinity.urls'))`)
|
||||
- **修改类型**: 新增 + 重构
|
||||
- **修改内容**:
|
||||
- **P2-01 Service 层骨架**:唯一写入入口 `AffinityService.apply(user_id, device_id, rule_key, source, event_id, metadata, operator_admin_id, reason)` + `admin_adjust(user_id, device_id, delta, operator_admin_id, reason, batch)`;返回 `ApplyResult` dataclass 含 outcome 枚举 + change/before/after/level 信息
|
||||
- **P2-02 Redis 计数器**:6 类操作(is_in_cooldown / set_cooldown / get/incr_rule_daily / get/incr_global_daily / event_already_processed / mark_event_processed),用 `cache.add+incr` 实现 set-if-not-exists+atomic-increment 语义;Asia/Shanghai 时区自然日字符串通过 `zoneinfo.ZoneInfo` 计算
|
||||
- **P2-03 等级映射**:`map_value_to_level(value)` 按 (min, max) 区间匹配;`update_device_level(user_device)` 仅在 level 变化时调 save(update_fields=['affinity_level'])
|
||||
- **P2-04 跨级奖励发放**:`grant_levels(user_device, from_level, to_level)` 逐级独立事务 + UniqueConstraint 防重;返回 RewardGrantResult(granted, skipped_duplicate, failed);失败的级别不影响其他级别(A3 方案 B 核心特性)
|
||||
- **P2-05 WS 推送**:3 类事件(affinity_update / level_up / level_down);channel_layer 故障静默吞掉但日志记录
|
||||
- **P2-06 AffinityRule admin CRUD**:默认列表过滤 `is_deleted=False`,?include_deleted=true 显示全集;DELETE 走软删 `is_deleted=True+is_enabled=False`;POST/restore 自定义 action 恢复软删
|
||||
- **P2-07 AffinityLevel admin CRUD**:同上软删;serializer 跨字段校验区间重叠(与启用中其他等级不冲突)
|
||||
- **P2-08 AffinitySetting GET/PUT/PATCH**:单例,pk=1 硬约束;跨字段校验衰减区间 + 初始/上限关系
|
||||
- **P2-09 AffinityLog 查询**:select_related user/rule/device.device 避免 N+1;过滤 user_id/device_id/rule_key/source/date_from/date_to;自实现分页(page/page_size,page_size 上限 200)
|
||||
- **P2-10 stats**:所有指标基于 `UserDevice.active`(is_bound=True)聚合;今日数据按 AffinitySetting.timezone 取 local date;rule_freq_top 取近 7 日 Top 10
|
||||
- **P2-11 devices**:?user_id= 必传 + 404 校验;?include_unbound=true 才返回历史;默认按 is_primary desc, bound_at desc 排序
|
||||
- **P2-12 adjust / adjust-batch**:单台调整必传 user_id+device_id+delta+reason;批量调整对 user 名下所有 active 设备各调一次,逐台独立调用 service,返回 per-device 结果数组
|
||||
- **挂载位置**:`/api/v1/admin/affinity/{rules,levels,settings,logs,stats,devices,adjust,adjust-batch}/`;旧的 `/api/user/affinity-rules/` 与 `/affinity-levels/` 暂保留兼容(前端切到 admin 后即可清理)
|
||||
- **修改原因**: P1 数据层就绪后必须落地服务层 + admin API,否则数据模型只是空壳;管理后台前端(P3)需要这套 admin API 才能拆 mock 接通;触发点埋点(P4)和客户端 API(P5)都依赖 service 层的 apply() 入口
|
||||
- **跨项目联动**:
|
||||
- 管理后台前端 P3 阶段接入:`lib/api/affinity.ts` 需要切到 `/api/v1/admin/affinity/...` 新路径并对齐新字段集(cooldown_seconds, min_continuous_minutes 等)
|
||||
- 设备/手机端 P4 阶段在 `device_interaction/consumers.py` 的 chat_message / sing / dance / touch / conversation_status 处调 `AffinityService.apply(rule_key=...)`;事件需带 `event_id`(UUID)
|
||||
- 客户端 P5 阶段查询 `/api/user/me/affinity/` 暂未实现(P5 任务),P2 仅落地 admin 端
|
||||
|
||||
---
|
||||
|
||||
### [2026-05-13] 好感度系统 P1 审查修复 D — WR-002~WR-009 + IN-001~IN-006 综合改进
|
||||
|
||||
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(WR-002 ~ WR-009 + IN-001 ~ IN-006)
|
||||
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
|
||||
|
||||
- **文件路径**:
|
||||
- `userapp/models.py`(修改 — 多处:AffinityLog 索引精简 / event_id null=True / `__str__` 用 pk 兜底;UserLevelRewardGrant SET_NULL + device_snapshot_id + conditional unique;AffinitySetting daily_cap → global_daily_cap;description 显式 default='';弃用字段加 [DEPRECATED] 版本标记;ParadiseUser.favorability 标记 (已弃用))
|
||||
- `userapp/serializers.py`(修改 — `UserInfoSerializer` 移除 `favorability` 字段暴露,WR-008)
|
||||
- `userapp/affinity/__init__.py`(**新建** — affinity 业务包入口)
|
||||
- `userapp/affinity/defaults.py`(**新建** — DEFAULT_RULES / DEFAULT_LEVELS / DEFAULT_SETTING 常量从 management command 抽出;新增 1 条 companion_30min 规则;所有 description 显式填写)
|
||||
- `userapp/management/commands/seed_affinity.py`(**重写** — 从 affinity.defaults 导入常量;去掉全局 `@transaction.atomic`,改为每条 spec 独立 `with transaction.atomic()`;新增 failed 计数与不同 style 输出,部分失败可重跑)
|
||||
- `userapp/migrations/0009_affinity_p1_polish.py`(**新建** — 手工修正 makemigrations 自动生成版本:把 `daily_cap → global_daily_cap` 改为 RenameField(保留数据,不是 Remove+Add);event_id `''` → NULL 数据兜底 RunPython;UserLevelRewardGrant on_delete + conditional unique;索引精简;弃用字段 help_text 升级)
|
||||
- **修改类型**: 修复Bug + 重构
|
||||
- **修改内容**:
|
||||
- **WR-002**:UserLevelRewardGrant.device `on_delete=CASCADE` → `SET_NULL`,加 `device_snapshot_id` 冗余字段(save 时自动填充原 pk),`unique_together=[('device','level')]` → `UniqueConstraint(fields=['device','level'], condition=Q(device__isnull=False))` 保证 device 已删的历史记录不参与唯一性
|
||||
- **WR-003**:AffinityLog 删除 3 个低价值索引(user/rule_key/source 各 -created_at 复合),仅保留 (device, -created_at) 与 event_id partial unique
|
||||
- **WR-004**:AffinityLog.event_id `null=True`;partial unique 条件 `event_id__gt=''` → `event_id__isnull=False`;RunPython 把现有 `''` 改为 NULL
|
||||
- **WR-005**:DEFAULT_RULES 新增 1 条 `companion_30min`(trigger_type=companion_time, min_continuous_minutes=30, max_count_per_day=4,min/max change 1~2)
|
||||
- **WR-006**:AffinityRule/AffinityLevel.description 显式 `default=''`;DEFAULT_LEVELS 所有 entry 补 description
|
||||
- **WR-007**:seed_affinity 每条 spec 独立事务,部分失败不影响其他记录(去掉 handle 上的 @transaction.atomic)
|
||||
- **WR-008**:ParadiseUser.favorability 字段保留(避免 0006 backward 失效)+ verbose_name 加 (已弃用);help_text 标 [DEPRECATED — P2 后删除];UserInfoSerializer 移除字段暴露。**未做 property 改造**:Model field 与 property 同名冲突,必须先 RenameField 才能上 property,本次只做软标记 + 序列化器清理(详见 FIX-REPORT 风险说明)
|
||||
- **WR-009**:(已在 Commit B 的 AffinityLevel.clean() + save() 中实现 — DB 跨行约束 PG 表达不出,应用层多层兜底)
|
||||
- **IN-001**:5 个弃用字段(AffinityRule.points / daily_limit / is_active;AffinityLevel.required_points / rewards)help_text 加 `[DEPRECATED — 计划于 P2 完成后删除]` 显式版本标记
|
||||
- **IN-002**:DEFAULT_RULES / DEFAULT_LEVELS / DEFAULT_SETTING 抽到 `userapp/affinity/defaults.py`,供 seed / 单元测试 / P2 服务层复用
|
||||
- **IN-003**:AffinitySetting.daily_cap → global_daily_cap RenameField(与 AffinityRule.daily_cap 区分);模型 / 校验 / 约束 / DEFAULT_SETTING 全部同步
|
||||
- **IN-004**:AffinityLog.\_\_str\_\_ 用 `self.pk or 'new'` 替代 `self.id`,未保存对象显示 `#new` 而非 `#None`
|
||||
- **IN-005**:(已在 Commit A 完成 — is_active → is_bound 改名)
|
||||
- **IN-006**:(已在 Commit C 完成 — 0006 print 前缀改为 `[migration 0006_migrate_favorability]`)
|
||||
- **修改原因**: P1 数据层审查中除 3 个 Critical 外的全部剩余项(9 Warning + 6 Info)一次性收尾,避免遗留到 P2 服务层动工后修复成本上升;同时保持每个修复项可独立追溯(commit message + 修改记录条目)
|
||||
- **跨项目联动**: 管理后台前端如已读取 AffinitySetting.daily_cap,需要同步改为 global_daily_cap(仅当 admin UI 暴露该字段时);UserInfoSerializer 不再返回 favorability,前端如有使用需改为查询 UserDevice 列表。其他改动对外接口无破坏
|
||||
|
||||
### [2026-05-13] 好感度系统 P1 审查修复 C — 0006 数据迁移幂等性修正(CR-003)
|
||||
|
||||
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(CR-003)
|
||||
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
|
||||
|
||||
- **文件路径**:
|
||||
- `userapp/migrations/0006_migrate_favorability_to_userdevice.py`(**重写** — 完整替换 forward/backward 函数内容,改用 AffinityLog source='data_migration' 标记做幂等)
|
||||
- `userapp/models.py`(修改 — `AffinityLog.SOURCE_CHOICES` 追加 `('data_migration', '数据迁移')` 选项)
|
||||
- `userapp/migrations/0008_alter_affinitylog_source_choices.py`(**新建** — 由 makemigrations 自动生成,更新 source 字段的 choices)
|
||||
- **修改类型**: 修复Bug + 重构
|
||||
- **修改内容**:
|
||||
- **CR-003(Critical)**:旧 0006 forward 用 `target.favorability == 10` 做幂等判断,因 10 既是初始值也是衰减下限附近常见值,重跑会覆盖合法数据;backward 同样用 `!= 10` 反向判断会丢失衰减回 10 的数据
|
||||
- 新实现 forward:用 `AffinityLog.objects.filter(device_id=target.id, source='data_migration').exists()` 做幂等标记,同时写入审计 metadata(`from_user_favorability` / `before_device_favorability` / `migration` 文件名)
|
||||
- 新实现 backward:遍历 source='data_migration' 的 AffinityLog 反向恢复 ParadiseUser.favorability,并删除标记保证可循环
|
||||
- 模型层补 `'data_migration'` 到 SOURCE_CHOICES(Python 校验,0008 AlterField 同步)
|
||||
- **已知风险(option B 选择说明)**:
|
||||
- 选择**直接重写 0006**(而非追加 0007 补偿)是因为 dev DB 已执行过一次但 migrate_count=0,等于未动数据
|
||||
- django_migrations 表已记录 0006 完成,重写后**不会**自动重跑;如需对意外脏数据强制重跑,需手工 `python manage.py migrate userapp 0005 --fake` 后再 `migrate userapp 0006`
|
||||
- 生产环境部署前必须确认 prod 还未跑过 0006,否则需走 fake-reverse 流程
|
||||
- **修改原因**: P1 审查指出(详见 REVIEW-affinity-P1.md CR-003)旧幂等条件 `favorability == 10` 在 P3/P4 衰减跑起来后会变成"定时炸弹",重跑迁移会覆盖正常业务数据;backward 会丢数据
|
||||
- **跨项目联动**: 无 — 仅服务端迁移逻辑变更
|
||||
|
||||
### [2026-05-13] 好感度系统 P1 审查修复 B — Affinity 模型 DB CHECK 约束 + WR-001 单例硬约束(CR-002 + WR-001)
|
||||
|
||||
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(CR-002 + WR-001)
|
||||
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
|
||||
|
||||
- **文件路径**:
|
||||
- `userapp/models.py`(修改 — `AffinityRule.Meta.constraints` 加 5 条 CheckConstraint + `clean()`;`AffinityLevel.Meta.constraints` 加 2 条 CheckConstraint + `clean()` + `save()` 自动 full_clean;`AffinitySetting.Meta.constraints` 加 6 条 CheckConstraint(含 pk=1 单例硬约束)+ `clean()` + `save()` 强制 pk=1;imports 段补 CheckConstraint / F / ValidationError)
|
||||
- `userapp/migrations/0007_add_affinity_check_constraints.py`(**新建** — 由 makemigrations 自动生成,13 条 AddConstraint)
|
||||
- **修改类型**: 新增 + 修复Bug
|
||||
- **修改内容**:
|
||||
- **CR-002(Critical)**:
|
||||
- AffinityRule:`min_change ≤ max_change` / `cooldown_seconds ≥ 0` / `single_cap > 0` / `daily_cap > 0` / 陪伴时长规则必须设置 `min_continuous_minutes > 0 ∧ max_count_per_day > 0`
|
||||
- AffinityLevel:`min_affinity ≤ max_affinity` / `reward_currency ≥ 0`
|
||||
- AffinitySetting:`decay_min_decay ≤ decay_max_decay ≤ decay_cap` / `initial_affinity ≤ max_affinity` / `decay_min_floor ≤ max_affinity` / `daily_cap > 0`
|
||||
- **WR-001(Warning)**:AffinitySetting 加 `pk=1` 单例硬约束 + save() 强制 pk=1,配合形成事实单例(CHECK 约束跨行不可,但能阻止任何非 1 主键的写入)
|
||||
- 所有模型 `clean()` 提供 Python 级兜底,给 DRF / admin 友好错误信息(DB 级 CheckConstraint 是最终防线)
|
||||
- AffinityLevel.save() 自动调 full_clean 触发跨等级区间不重叠校验(WR-009 多层兜底,详见 Commit D);提供 `skip_clean=True` 后门给迁移 / fixture 场景
|
||||
- **修改原因**: P1 审查指出(详见 REVIEW-affinity-P1.md CR-002)模型字段对管理后台 / shell / 直 SQL 写入毫无保护,P2 服务层 `random.randint(rule.min_change, rule.max_change)` 等运算会被脏数据击穿(ValueError 抛出、冷却永久解锁、上限永远命中等);同时审查中 WR-001 指出 AffinitySetting 单例保证在并发下脆弱
|
||||
- **跨项目联动**: 无 — 仅服务端 DB 约束 + 模型校验层;管理后台前端在写入前应捕获 ValidationError 显示给运营,但接口契约本身未动
|
||||
|
||||
### [2026-05-13] 好感度系统 P1 审查修复 A — UserDevice 软删语义修正(CR-001 + IN-005)
|
||||
|
||||
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(CR-001 + IN-005)
|
||||
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
|
||||
|
||||
- **文件路径**:
|
||||
- `device_interaction/models.py`(修改 — 新增 `ActiveUserDeviceManager`;`UserDevice.is_active` 改名为 `is_bound`;新增双 manager `objects` / `active`;`Meta.base_manager_name = 'objects'` 保证 admin 默认 queryset 不受 active 过滤影响)
|
||||
- `device_interaction/migrations/0004_rename_userdevice_is_active_is_bound.py`(**新建** — `RenameField` + `AlterField` 更新 help_text)
|
||||
- `device_interaction/migrations/0005_alter_userdevice_options.py`(**新建** — 由 makemigrations 自动生成,记录 `base_manager_name='objects'` 的 Meta 变更)
|
||||
- `userapp/views.py`(修改 — MAC 登录第 120 行 `UserDevice.objects.filter(...)` → `UserDevice.active.filter(...)`)
|
||||
- `device_interaction/views.py`(修改 — 4 处调用点切换到 `UserDevice.active`:`bind_status` 第 462 行、绑定 endpoint 第 694/702 行两处、RTC token 第 1158 行)
|
||||
- `device_interaction/serializers.py`(修改 — 第 125 行绑定校验切到 `UserDevice.active`)
|
||||
- `qy_lty/CLAUDE.md`(修改 — § "设备绑定与控制权" 新增硬规则:所有控制权解析查询必须使用 `UserDevice.active.filter(...)`;解释 `is_bound` 改名背景)
|
||||
- **修改类型**: 重构 + 修复Bug
|
||||
- **修改内容**:
|
||||
- **CR-001(Critical)**:4 处控制权解析调用点全部加 `is_bound=True` 过滤,通过 `ActiveUserDeviceManager` 强制语义;避免 P2 软删(解绑设 is_bound=False)后旧绑定者被签发 user-token / 路由到错 user_id 的 WS 分组 / RTC 房间
|
||||
- **IN-005**:`UserDevice.is_active` → `is_bound` 改名,消除与 `Device.is_active`(设备激活态)的命名冲突
|
||||
- `RenameField` 在 PostgreSQL 上是元数据级 ALTER COLUMN RENAME(O(1) 锁),无数据风险
|
||||
- `base_manager_name='objects'` 保证 Django admin 与反向关系(`user.devices.all()`、`device.users.all()`)依然返回全集,仅 `UserDevice.active.filter()` 才过滤
|
||||
- **修改原因**: P1 数据层代码审查指出(详见 REVIEW-affinity-P1.md CR-001)现有 4 处"按 MAC 取最新绑定者"的代码路径未过滤 `is_active`(P1-08 引入),一旦 P2 实现解绑=软删,会签发已解绑用户的 user-token、WS 路由到前主人频道等安全 / 越权风险。IN-005 与之共因(两个同名 is_active 字段语义截然不同),审查报告显式建议合并修复
|
||||
- **跨项目联动**: 无 — 仅服务端 ORM 层 + view 调用点变更,对客户端 / 管理后台无外显接口变化(响应 schema 未动)
|
||||
|
||||
### [2026-05-08] Phase 3 — 客户端凭据槽位 GET 接口 + 阿里云日志 access_token 脱敏
|
||||
|
||||
配套 Phase:[.planning/phases/03-client-and-log-mask/](.planning/phases/03-client-and-log-mask/)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from django.urls import path
|
||||
from django.urls import path, include
|
||||
from .views import AdminEmailLoginView, AdminLogoutView
|
||||
# Phase 2 — 通用凭据槽位管理端读写接口(CRED-03 + CRED-04)
|
||||
from aiapp.views import CredentialSlotAdminView
|
||||
@ -11,5 +11,7 @@ urlpatterns = [
|
||||
path('logout/', AdminLogoutView.as_view(), name='admin_logout'),
|
||||
# 通用凭据槽位(GET 脱敏读取 / PUT 全字段覆写;admin token 鉴权)
|
||||
path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot'),
|
||||
# 好感度系统 admin 接口(P2-06 ~ P2-12)
|
||||
path('affinity/', include('userapp.affinity.urls')),
|
||||
# 后续可以添加更多管理员专用接口
|
||||
]
|
||||
|
||||
5
qy_lty/userapp/affinity/__init__.py
Normal file
5
qy_lty/userapp/affinity/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""好感度系统业务包
|
||||
|
||||
子模块:
|
||||
defaults — 默认规则 / 等级 / 设置常量(供 seed_affinity / 单元测试 / P2 服务层复用)
|
||||
"""
|
||||
147
qy_lty/userapp/affinity/counters.py
Normal file
147
qy_lty/userapp/affinity/counters.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""P2-02 Redis 计数器工具
|
||||
|
||||
封装三类计数器与冷却判断,统一用 django-redis (`django.core.cache`) 接入。
|
||||
|
||||
Key 命名约定(与设计文档 §4.3 触发流程一致):
|
||||
affinity:cd:{device_id}:{rule_key} — 冷却(值任意,存在即冷却中)
|
||||
affinity:daily:{device_id}:{rule_key}:{YYYYMMDD} — 单规则单设备日累计(绝对值)
|
||||
affinity:daily:{device_id}:_global:{YYYYMMDD} — 全局正向日累计(仅正向规则)
|
||||
|
||||
自然日基准:`AffinitySetting.timezone`(默认 Asia/Shanghai),全用户统一。
|
||||
TTL 策略:日计数器 TTL=48h(远超 1 自然日边界,老 key 自然过期);冷却 TTL=规则 cooldown_seconds。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
try:
|
||||
from zoneinfo import ZoneInfo # Python 3.9+
|
||||
except ImportError: # pragma: no cover
|
||||
from backports.zoneinfo import ZoneInfo # type: ignore
|
||||
|
||||
# 日计数器 TTL — 48 小时足以覆盖最长时区偏移和单次"跨天延迟"场景
|
||||
DAILY_COUNTER_TTL = 48 * 3600
|
||||
|
||||
|
||||
# ---------- 时区与日期 ----------
|
||||
|
||||
def get_setting_timezone() -> str:
|
||||
"""读取 AffinitySetting.timezone(容错:表不存在或单例缺失时回退 Asia/Shanghai)"""
|
||||
try:
|
||||
from userapp.models import AffinitySetting
|
||||
return AffinitySetting.get_solo().timezone or 'Asia/Shanghai'
|
||||
except Exception: # AppConfig 启动期 / 测试中表未建好等场景
|
||||
return 'Asia/Shanghai'
|
||||
|
||||
|
||||
def local_today_str(tz_name: Optional[str] = None) -> str:
|
||||
"""返回基于 AffinitySetting.timezone 的"今日"日期字符串 YYYYMMDD"""
|
||||
tz_name = tz_name or get_setting_timezone()
|
||||
now_utc = timezone.now()
|
||||
local_now = now_utc.astimezone(ZoneInfo(tz_name))
|
||||
return local_now.strftime('%Y%m%d')
|
||||
|
||||
|
||||
def local_today_date(tz_name: Optional[str] = None):
|
||||
"""返回 AffinitySetting.timezone 的"今日" date 对象(供 UserAffinityDailyCounter.date 用)"""
|
||||
tz_name = tz_name or get_setting_timezone()
|
||||
now_utc = timezone.now()
|
||||
return now_utc.astimezone(ZoneInfo(tz_name)).date()
|
||||
|
||||
|
||||
# ---------- 冷却 ----------
|
||||
|
||||
def cooldown_key(device_id: int, rule_key: str) -> str:
|
||||
return f'affinity:cd:{device_id}:{rule_key}'
|
||||
|
||||
|
||||
def is_in_cooldown(device_id: int, rule_key: str) -> bool:
|
||||
"""返回 True 表示尚未过冷却,本次触发应拒绝"""
|
||||
return cache.get(cooldown_key(device_id, rule_key)) is not None
|
||||
|
||||
|
||||
def set_cooldown(device_id: int, rule_key: str, seconds: int) -> None:
|
||||
"""设置冷却。seconds<=0 时不做任何事(视为无冷却规则)"""
|
||||
if seconds <= 0:
|
||||
return
|
||||
cache.set(cooldown_key(device_id, rule_key), 1, timeout=seconds)
|
||||
|
||||
|
||||
# ---------- 单规则日计数器 ----------
|
||||
|
||||
def rule_daily_key(device_id: int, rule_key: str, date_str: Optional[str] = None) -> str:
|
||||
return f'affinity:daily:{device_id}:{rule_key}:{date_str or local_today_str()}'
|
||||
|
||||
|
||||
def get_rule_daily(device_id: int, rule_key: str) -> int:
|
||||
"""读取本规则本设备今日累计(绝对值累加,不区分正负)"""
|
||||
val = cache.get(rule_daily_key(device_id, rule_key))
|
||||
return int(val) if val is not None else 0
|
||||
|
||||
|
||||
def incr_rule_daily(device_id: int, rule_key: str, delta_abs: int) -> int:
|
||||
"""原子累加本规则日计数器,返回累加后的值。delta_abs 必须 > 0。
|
||||
|
||||
首次写入时通过 cache.add 设置 TTL;之后用 cache.incr 原子累加,TTL 不变。
|
||||
"""
|
||||
if delta_abs <= 0:
|
||||
raise ValueError(f'delta_abs must be > 0, got {delta_abs}')
|
||||
key = rule_daily_key(device_id, rule_key)
|
||||
if cache.add(key, delta_abs, timeout=DAILY_COUNTER_TTL):
|
||||
return delta_abs
|
||||
try:
|
||||
return cache.incr(key, delta_abs)
|
||||
except ValueError:
|
||||
# cache.add 写入后 race-condition 失败极端情况兜底
|
||||
cache.set(key, delta_abs, timeout=DAILY_COUNTER_TTL)
|
||||
return delta_abs
|
||||
|
||||
|
||||
# ---------- 全局日计数器(仅正向汇总)----------
|
||||
|
||||
def global_daily_key(device_id: int, date_str: Optional[str] = None) -> str:
|
||||
return f'affinity:daily:{device_id}:_global:{date_str or local_today_str()}'
|
||||
|
||||
|
||||
def get_global_daily(device_id: int) -> int:
|
||||
"""读取本设备今日全局正向好感度累计(跨规则汇总)"""
|
||||
val = cache.get(global_daily_key(device_id))
|
||||
return int(val) if val is not None else 0
|
||||
|
||||
|
||||
def incr_global_daily(device_id: int, delta_abs: int) -> int:
|
||||
"""原子累加全局日计数器(仅在正向变化时调用)。返回累加后的值。"""
|
||||
if delta_abs <= 0:
|
||||
raise ValueError(f'delta_abs must be > 0, got {delta_abs}')
|
||||
key = global_daily_key(device_id)
|
||||
if cache.add(key, delta_abs, timeout=DAILY_COUNTER_TTL):
|
||||
return delta_abs
|
||||
try:
|
||||
return cache.incr(key, delta_abs)
|
||||
except ValueError:
|
||||
cache.set(key, delta_abs, timeout=DAILY_COUNTER_TTL)
|
||||
return delta_abs
|
||||
|
||||
|
||||
# ---------- 事件去重(event_id 60s 缓存)----------
|
||||
|
||||
def event_seen_key(event_id: str) -> str:
|
||||
return f'affinity:event:{event_id}'
|
||||
|
||||
|
||||
def event_already_processed(event_id: str) -> bool:
|
||||
"""若 event_id 已在 60s 内处理过,返回 True"""
|
||||
if not event_id:
|
||||
return False
|
||||
return cache.get(event_seen_key(event_id)) is not None
|
||||
|
||||
|
||||
def mark_event_processed(event_id: str, ttl_seconds: int = 60) -> None:
|
||||
if not event_id:
|
||||
return
|
||||
cache.set(event_seen_key(event_id), 1, timeout=ttl_seconds)
|
||||
140
qy_lty/userapp/affinity/defaults.py
Normal file
140
qy_lty/userapp/affinity/defaults.py
Normal file
@ -0,0 +1,140 @@
|
||||
"""好感度系统默认数据常量
|
||||
|
||||
供 seed_affinity management command、单元测试、P2 服务层(fallback 配置)等共享。
|
||||
|
||||
与设计文档对应:
|
||||
DEFAULT_RULES — 「好感度系统功能与规则设计.md」§4.2 互动规则
|
||||
DEFAULT_LEVELS — 同文档 §6.2 等级表
|
||||
DEFAULT_SETTING — 同文档 §3.2 全局参数 + §5.1 衰减字段
|
||||
|
||||
IN-002:从 management/commands/seed_affinity.py 抽取,避免 management command
|
||||
文件膨胀,且让测试代码 / P2 服务层可以正规 import:
|
||||
from userapp.affinity.defaults import DEFAULT_RULES, DEFAULT_LEVELS, DEFAULT_SETTING
|
||||
"""
|
||||
|
||||
# 默认规则,与设计文档 §4.2 一致
|
||||
# WR-005:新增 1 条 trigger_type='companion_time' 规则(陪伴 30 分钟)
|
||||
# WR-006:所有 description 显式填写,不依赖 model blank=True 隐式默认
|
||||
DEFAULT_RULES = [
|
||||
{
|
||||
'rule_key': 'card', 'name': '使用卡片', 'description': '用户使用洛天依卡片',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 10,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'chat', 'name': '对话', 'description': '与洛天依进行对话',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 1, 'max_change': 5, 'single_cap': 5, 'daily_cap': 15,
|
||||
'cooldown_seconds': 30, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'feed', 'name': '喂食', 'description': '给洛天依喂食',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 2, 'max_change': 8, 'single_cap': 8, 'daily_cap': 16,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'touch', 'name': '抚摸', 'description': '抚摸洛天依',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 9,
|
||||
'cooldown_seconds': 10, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'dress', 'name': '换装', 'description': '为洛天依更换服装',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 2, 'max_change': 6, 'single_cap': 6, 'daily_cap': 12,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'prop', 'name': '使用道具', 'description': '使用互动道具',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 1, 'max_change': 4, 'single_cap': 4, 'daily_cap': 12,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'gift', 'name': '送礼物', 'description': '赠送礼物给洛天依',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 5, 'max_change': 15, 'single_cap': 15, 'daily_cap': 20,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'decay', 'name': '无互动衰减', 'description': '长时间不互动导致好感度下降',
|
||||
'trigger_type': 'decay',
|
||||
'min_change': -3, 'max_change': -1, 'single_cap': 3, 'daily_cap': 5,
|
||||
'cooldown_seconds': 0, 'is_negative': True, 'is_enabled': True,
|
||||
},
|
||||
# WR-005:陪伴时长类规则(数值参考设计文档 §4.2,具体值可由运营在 admin 调整)
|
||||
{
|
||||
'rule_key': 'companion_30min', 'name': '陪伴 30 分钟',
|
||||
'description': '与洛天依持续陪伴 30 分钟可获得好感度(数值待产品最终对齐,先用保守默认)',
|
||||
'trigger_type': 'companion_time',
|
||||
'min_change': 1, 'max_change': 2, 'single_cap': 2, 'daily_cap': 8,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
'min_continuous_minutes': 30, 'max_count_per_day': 4,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# 默认等级,与设计文档 §6.2 一致
|
||||
# WR-006:所有 description 显式填写
|
||||
DEFAULT_LEVELS = [
|
||||
{
|
||||
'level': 1, 'name': '初识',
|
||||
'description': '初次相识阶段,了解彼此的基础互动',
|
||||
'min_affinity': 0, 'max_affinity': 20,
|
||||
'unlock_content': '基础对话功能',
|
||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||
'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'level': 2, 'name': '相识',
|
||||
'description': '熟悉彼此个性,解锁基础道具与服装',
|
||||
'min_affinity': 21, 'max_affinity': 40,
|
||||
'unlock_content': '基础服装、道具使用',
|
||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||
'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'level': 3, 'name': '熟悉',
|
||||
'description': '互动深入,解锁更多内容',
|
||||
'min_affinity': 41, 'max_affinity': 60,
|
||||
'unlock_content': '更多服装、特殊对话',
|
||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||
'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'level': 4, 'name': '亲密',
|
||||
'description': '亲密互动阶段,解锁限定内容',
|
||||
'min_affinity': 61, 'max_affinity': 80,
|
||||
'unlock_content': '限定服装、特殊互动',
|
||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||
'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'level': 5, 'name': '挚友',
|
||||
'description': '最高亲密度,解锁专属剧情',
|
||||
'min_affinity': 81, 'max_affinity': 100,
|
||||
'unlock_content': '专属内容、特殊剧情',
|
||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||
'is_enabled': True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# 默认全局设置(用于 seed_affinity 在表为空时创建)
|
||||
DEFAULT_SETTING = {
|
||||
'initial_affinity': 10,
|
||||
'max_affinity': 100,
|
||||
'global_daily_cap': 20, # IN-003:重命名 daily_cap → global_daily_cap
|
||||
'decay_rate': 2,
|
||||
'decay_threshold': 3,
|
||||
'decay_min_decay': 1,
|
||||
'decay_max_decay': 3,
|
||||
'decay_cap': 5,
|
||||
'decay_min_floor': 0,
|
||||
'enable_notify': True,
|
||||
'enable_rewards': True,
|
||||
'notify_decay': True,
|
||||
'timezone': 'Asia/Shanghai',
|
||||
}
|
||||
87
qy_lty/userapp/affinity/levels.py
Normal file
87
qy_lty/userapp/affinity/levels.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""P2-03 等级映射 + UserDevice.affinity_level 缓存更新
|
||||
|
||||
根据好感度数值映射到对应等级(AffinityLevel),并把结果写回 UserDevice.affinity_level
|
||||
作为缓存。
|
||||
|
||||
设计依据:「好感度系统功能与规则设计.md」§6.3 等级变化规则
|
||||
- 等级由好感度区间自动映射,每台设备独立判定
|
||||
- 跨级判定:每次好感度变动后,取当前值所属区间,与上一次等级比较
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from userapp.models import AffinityLevel
|
||||
|
||||
|
||||
def map_value_to_level(value: int) -> Optional[AffinityLevel]:
|
||||
"""根据好感度数值找出所属的 AffinityLevel。
|
||||
|
||||
匹配规则:min_affinity <= value <= max_affinity 且 is_enabled=True 且 is_deleted=False
|
||||
返回最高 level 优先(避免重叠区间时的歧义,但 P1 已加 clean 校验拦截重叠)。
|
||||
若没有匹配到任何区间则返回 None(理论上不应发生,因为等级区间应覆盖 [0, max_affinity])。
|
||||
"""
|
||||
return (
|
||||
AffinityLevel.objects
|
||||
.filter(
|
||||
min_affinity__lte=value,
|
||||
max_affinity__gte=value,
|
||||
is_enabled=True,
|
||||
is_deleted=False,
|
||||
)
|
||||
.order_by('-level')
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def progress_to_next_level(value: int, current_level: AffinityLevel) -> dict:
|
||||
"""计算当前值在本等级区间内的进度百分比 + 到下一等级的距离。
|
||||
|
||||
返回:
|
||||
{
|
||||
'percent': 0~100 浮点(当前值在本等级区间内的位置百分比),
|
||||
'next_level': AffinityLevel 或 None,
|
||||
'points_to_next': int 或 None,
|
||||
}
|
||||
"""
|
||||
span = max(current_level.max_affinity - current_level.min_affinity, 1)
|
||||
percent = round((value - current_level.min_affinity) / span * 100, 2)
|
||||
|
||||
next_level = (
|
||||
AffinityLevel.objects
|
||||
.filter(level__gt=current_level.level, is_enabled=True, is_deleted=False)
|
||||
.order_by('level')
|
||||
.first()
|
||||
)
|
||||
points_to_next = None
|
||||
if next_level is not None:
|
||||
points_to_next = max(next_level.min_affinity - value, 0)
|
||||
|
||||
return {
|
||||
'percent': max(0.0, min(100.0, percent)),
|
||||
'next_level': next_level,
|
||||
'points_to_next': points_to_next,
|
||||
}
|
||||
|
||||
|
||||
def update_device_level(user_device, save: bool = True) -> Tuple[int, int, Optional[AffinityLevel]]:
|
||||
"""根据 user_device.favorability 重新计算并更新 affinity_level 缓存字段。
|
||||
|
||||
返回 (old_level, new_level, matched_level_obj)
|
||||
若没匹配到任何 AffinityLevel,new_level 保持原值,matched_level_obj=None。
|
||||
|
||||
save=False 时不调 .save(),由调用方批量保存(service 层)。
|
||||
"""
|
||||
old_level = user_device.affinity_level
|
||||
matched = map_value_to_level(user_device.favorability)
|
||||
if matched is None:
|
||||
return old_level, old_level, None
|
||||
|
||||
new_level = matched.level
|
||||
if new_level != old_level:
|
||||
user_device.affinity_level = new_level
|
||||
if save:
|
||||
user_device.save(update_fields=['affinity_level'])
|
||||
|
||||
return old_level, new_level, matched
|
||||
16
qy_lty/userapp/affinity/permissions.py
Normal file
16
qy_lty/userapp/affinity/permissions.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""Admin 接口权限:要求已登录 + is_staff"""
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
|
||||
class IsAdminUserStaff(IsAuthenticated):
|
||||
"""已登录用户 + is_staff=True。
|
||||
|
||||
沿用项目既有 ViewSet 的 get_permissions() 中 `request.user.is_staff` 检查惯例,
|
||||
封装成可复用的 permission 类。
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
return bool(request.user and request.user.is_authenticated and request.user.is_staff)
|
||||
131
qy_lty/userapp/affinity/rewards.py
Normal file
131
qy_lty/userapp/affinity/rewards.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""P2-04 跨级奖励发放
|
||||
|
||||
A3 决策(方案 B):升级时**逐级独立事务**发放奖励,失败的项收集起来供调用方
|
||||
后续重试/补偿,**已成功的级别不会因后续级别失败而回滚**(这是与方案 A 整体事务
|
||||
的最大区别)。
|
||||
|
||||
发放幂等通过 UserLevelRewardGrant(device, level) 唯一约束保证:
|
||||
- 重复跨过同一等级不会重发(设计文档决策 11)
|
||||
- 衰减回升后再升过同等级也不重发(决策 11)
|
||||
|
||||
外部副作用(虚拟货币 +、道具发放)由 _dispatch_reward_to_external_systems 占位 hook
|
||||
实现 — P2 不接外部系统,先记 reward_snapshot 落库;后续接订阅/卡片/道具 app 时
|
||||
在该 hook 中调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
from userapp.models import AffinityLevel, AffinitySetting, UserLevelRewardGrant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RewardGrantResult:
|
||||
"""单次跨级奖励发放的汇总结果"""
|
||||
granted: List[Dict[str, Any]] = field(default_factory=list) # 本次新发放的奖励快照列表
|
||||
skipped_duplicate: List[int] = field(default_factory=list) # 已发过被跳过的等级号
|
||||
failed: List[Dict[str, Any]] = field(default_factory=list) # 发放失败的等级(含错误信息)
|
||||
|
||||
|
||||
def _build_reward_snapshot(level_obj: AffinityLevel) -> Dict[str, Any]:
|
||||
"""把 AffinityLevel 当前奖励配置打成快照(防 admin 后续修改影响审计)"""
|
||||
return {
|
||||
'level': level_obj.level,
|
||||
'name': level_obj.name,
|
||||
'reward_type': level_obj.reward_type,
|
||||
'reward_currency': level_obj.reward_currency,
|
||||
'reward_items': list(level_obj.reward_items or []),
|
||||
'unlock_content': level_obj.unlock_content,
|
||||
}
|
||||
|
||||
|
||||
def _dispatch_reward_to_external_systems(user_device, snapshot: Dict[str, Any]) -> None:
|
||||
"""实际把奖励发放到外部系统的 hook(虚拟货币、道具、解锁标记等)。
|
||||
|
||||
P2 阶段不接外部系统 — 仅日志记录。
|
||||
P3/P4 阶段接订阅 / 卡片 / 道具 app 时在此实现具体派发逻辑。
|
||||
"""
|
||||
if snapshot['reward_currency'] > 0:
|
||||
logger.info(
|
||||
'[affinity.rewards] [STUB] 应给设备 %s 发放虚拟货币 %d',
|
||||
user_device.id, snapshot['reward_currency'],
|
||||
)
|
||||
if snapshot['reward_items']:
|
||||
logger.info(
|
||||
'[affinity.rewards] [STUB] 应给设备 %s 发放道具 %s',
|
||||
user_device.id, snapshot['reward_items'],
|
||||
)
|
||||
|
||||
|
||||
def grant_levels(user_device, from_level: int, to_level: int) -> RewardGrantResult:
|
||||
"""逐级独立事务发放 [from_level+1, to_level] 范围内的所有等级奖励。
|
||||
|
||||
设计文档决策 3:升级时逐级发放经过的每一级;降级回升后不再补发(永久幂等)。
|
||||
|
||||
参数:
|
||||
user_device: UserDevice 实例
|
||||
from_level: 升级前等级
|
||||
to_level: 升级后等级(需 > from_level 才会发奖励)
|
||||
返回:
|
||||
RewardGrantResult,调用方可基于此推送 WS、记日志、入重试队列等
|
||||
"""
|
||||
result = RewardGrantResult()
|
||||
if to_level <= from_level:
|
||||
return result
|
||||
|
||||
# AffinitySetting.enable_rewards=False 则只记录跨级日志,不发奖励
|
||||
if not AffinitySetting.get_solo().enable_rewards:
|
||||
logger.info('[affinity.rewards] enable_rewards=False,跨级 %s→%s 不发奖励',
|
||||
from_level, to_level)
|
||||
return result
|
||||
|
||||
# 取所有需要发放的等级(按 level 升序)
|
||||
levels_to_grant = list(
|
||||
AffinityLevel.objects
|
||||
.filter(level__gt=from_level, level__lte=to_level,
|
||||
is_enabled=True, is_deleted=False)
|
||||
.order_by('level')
|
||||
)
|
||||
|
||||
for level_obj in levels_to_grant:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
snapshot = _build_reward_snapshot(level_obj)
|
||||
# UniqueConstraint(device, level) 保证同一设备同一等级最多一行
|
||||
_, created = UserLevelRewardGrant.objects.get_or_create(
|
||||
device=user_device,
|
||||
level=level_obj.level,
|
||||
defaults={
|
||||
'device_snapshot_id': user_device.id,
|
||||
'reward_snapshot': snapshot,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
# 决策 11:曾经达到过就标记,不再重发
|
||||
result.skipped_duplicate.append(level_obj.level)
|
||||
continue
|
||||
|
||||
# 派发到外部系统(P2 是 stub)— 若 hook 抛异常会触发本 level 事务回滚
|
||||
_dispatch_reward_to_external_systems(user_device, snapshot)
|
||||
result.granted.append(snapshot)
|
||||
|
||||
except IntegrityError as exc:
|
||||
# 并发场景下 UniqueConstraint 命中 — 等价于 already granted
|
||||
result.skipped_duplicate.append(level_obj.level)
|
||||
logger.info('[affinity.rewards] Lv%s 已被并发线程发放:%s', level_obj.level, exc)
|
||||
except Exception as exc:
|
||||
logger.exception('[affinity.rewards] Lv%s 发放失败:%s', level_obj.level, exc)
|
||||
result.failed.append({
|
||||
'level': level_obj.level,
|
||||
'error': str(exc),
|
||||
'snapshot': _build_reward_snapshot(level_obj),
|
||||
})
|
||||
|
||||
return result
|
||||
252
qy_lty/userapp/affinity/serializers.py
Normal file
252
qy_lty/userapp/affinity/serializers.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""P2 admin 接口序列化器
|
||||
|
||||
为 admin 接口提供 AffinityRule / AffinityLevel / AffinitySetting / AffinityLog 等的
|
||||
读写序列化器。读字段尽量完整(含 deprecated 字段以便审计),写字段排除 deprecated。
|
||||
|
||||
所有 ModelSerializer 都用 PATCH 友好的 partial-update 模式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from device_interaction.models import UserDevice
|
||||
from userapp.models import (
|
||||
AffinityLevel,
|
||||
AffinityLog,
|
||||
AffinityRule,
|
||||
AffinitySetting,
|
||||
)
|
||||
|
||||
|
||||
# ---------- P2-06 AffinityRule ----------
|
||||
|
||||
class AffinityRuleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AffinityRule
|
||||
fields = [
|
||||
'id', 'rule_key', 'name', 'description', 'trigger_type',
|
||||
'min_change', 'max_change', 'single_cap', 'daily_cap',
|
||||
'cooldown_seconds', 'is_negative', 'is_enabled', 'is_deleted',
|
||||
'min_continuous_minutes', 'max_count_per_day',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
def validate_rule_key(self, value):
|
||||
"""rule_key 唯一性校验(数据库 unique=True 已保证,但 admin 写入时显式拦截更友好)"""
|
||||
if value is None or value == '':
|
||||
raise serializers.ValidationError('rule_key 不能为空')
|
||||
# 排除当前对象自身(编辑场景)
|
||||
qs = AffinityRule.objects.filter(rule_key=value)
|
||||
if self.instance is not None:
|
||||
qs = qs.exclude(id=self.instance.id)
|
||||
if qs.exists():
|
||||
raise serializers.ValidationError(f'rule_key "{value}" 已存在')
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""业务级跨字段校验(P1 CHECK 是最终防线,这里给 admin 友好错误)"""
|
||||
instance = self.instance
|
||||
get = lambda k, d=None: attrs.get(k, getattr(instance, k, d) if instance else d)
|
||||
|
||||
min_change = get('min_change', 1)
|
||||
max_change = get('max_change', 1)
|
||||
if min_change is not None and max_change is not None and min_change > max_change:
|
||||
raise serializers.ValidationError({'max_change': 'max_change 必须 >= min_change'})
|
||||
|
||||
single_cap = get('single_cap', 10)
|
||||
daily_cap = get('daily_cap', 20)
|
||||
if single_cap is not None and single_cap <= 0:
|
||||
raise serializers.ValidationError({'single_cap': 'single_cap 必须 > 0'})
|
||||
if daily_cap is not None and daily_cap <= 0:
|
||||
raise serializers.ValidationError({'daily_cap': 'daily_cap 必须 > 0'})
|
||||
|
||||
cooldown = get('cooldown_seconds', 0)
|
||||
if cooldown is not None and cooldown < 0:
|
||||
raise serializers.ValidationError({'cooldown_seconds': 'cooldown_seconds 不能小于 0'})
|
||||
|
||||
trigger_type = get('trigger_type', 'action')
|
||||
if trigger_type == 'companion_time':
|
||||
mcm = get('min_continuous_minutes')
|
||||
mcp = get('max_count_per_day')
|
||||
if not mcm or mcm <= 0:
|
||||
raise serializers.ValidationError({
|
||||
'min_continuous_minutes': '陪伴时长规则必须设置 min_continuous_minutes > 0'
|
||||
})
|
||||
if not mcp or mcp <= 0:
|
||||
raise serializers.ValidationError({
|
||||
'max_count_per_day': '陪伴时长规则必须设置 max_count_per_day > 0'
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
# ---------- P2-07 AffinityLevel ----------
|
||||
|
||||
class AffinityLevelSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AffinityLevel
|
||||
fields = [
|
||||
'id', 'level', 'name', 'description',
|
||||
'min_affinity', 'max_affinity',
|
||||
'unlock_content', 'reward_type', 'reward_currency', 'reward_items',
|
||||
'is_enabled', 'is_deleted',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
def validate(self, attrs):
|
||||
"""区间合法 + 不与其他等级重叠(P1 已有 model.clean() 兜底,这里前置给友好错误)"""
|
||||
instance = self.instance
|
||||
get = lambda k, d=None: attrs.get(k, getattr(instance, k, d) if instance else d)
|
||||
|
||||
min_a = get('min_affinity', 0)
|
||||
max_a = get('max_affinity', 0)
|
||||
if min_a is None or max_a is None:
|
||||
raise serializers.ValidationError('min_affinity / max_affinity 不能为空')
|
||||
if min_a > max_a:
|
||||
raise serializers.ValidationError({'max_affinity': 'max_affinity 必须 >= min_affinity'})
|
||||
|
||||
# 检查与其他启用等级的区间是否重叠
|
||||
level_num = get('level')
|
||||
qs = AffinityLevel.objects.filter(
|
||||
is_enabled=True, is_deleted=False,
|
||||
min_affinity__lte=max_a, max_affinity__gte=min_a,
|
||||
)
|
||||
if instance is not None:
|
||||
qs = qs.exclude(id=instance.id)
|
||||
if level_num is not None:
|
||||
qs = qs.exclude(level=level_num)
|
||||
conflict = qs.first()
|
||||
if conflict:
|
||||
raise serializers.ValidationError({
|
||||
'min_affinity': f'区间 [{min_a}, {max_a}] 与 Lv{conflict.level} '
|
||||
f'[{conflict.min_affinity}, {conflict.max_affinity}] 重叠'
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
# ---------- P2-08 AffinitySetting ----------
|
||||
|
||||
class AffinitySettingSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AffinitySetting
|
||||
fields = [
|
||||
'id',
|
||||
'initial_affinity', 'max_affinity', 'global_daily_cap',
|
||||
'decay_rate', 'decay_threshold',
|
||||
'decay_min_decay', 'decay_max_decay', 'decay_cap', 'decay_min_floor',
|
||||
'enable_notify', 'enable_rewards', 'notify_decay',
|
||||
'timezone',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
def validate(self, attrs):
|
||||
instance = self.instance
|
||||
get = lambda k, d=None: attrs.get(k, getattr(instance, k, d) if instance else d)
|
||||
|
||||
init = get('initial_affinity', 10)
|
||||
mx = get('max_affinity', 100)
|
||||
if init > mx:
|
||||
raise serializers.ValidationError({
|
||||
'initial_affinity': 'initial_affinity 不能超过 max_affinity'
|
||||
})
|
||||
|
||||
dec_min = get('decay_min_decay', 1)
|
||||
dec_max = get('decay_max_decay', 3)
|
||||
dec_cap = get('decay_cap', 5)
|
||||
if dec_min > dec_max:
|
||||
raise serializers.ValidationError({
|
||||
'decay_max_decay': 'decay_max_decay 必须 >= decay_min_decay'
|
||||
})
|
||||
if dec_max > dec_cap:
|
||||
raise serializers.ValidationError({
|
||||
'decay_cap': 'decay_cap 必须 >= decay_max_decay'
|
||||
})
|
||||
|
||||
gcap = get('global_daily_cap', 20)
|
||||
if gcap <= 0:
|
||||
raise serializers.ValidationError({
|
||||
'global_daily_cap': 'global_daily_cap 必须 > 0'
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
# ---------- P2-09 AffinityLog 查询(read-only)----------
|
||||
|
||||
class AffinityLogSerializer(serializers.ModelSerializer):
|
||||
user_username = serializers.CharField(source='user.username', read_only=True)
|
||||
device_code = serializers.CharField(
|
||||
source='device.device.device_code', read_only=True, default=None,
|
||||
)
|
||||
rule_name = serializers.CharField(source='rule.name', read_only=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = AffinityLog
|
||||
fields = [
|
||||
'id', 'user', 'user_username', 'device', 'device_code',
|
||||
'rule', 'rule_key', 'rule_name',
|
||||
'change_value', 'before_value', 'after_value',
|
||||
'source', 'event_id', 'operator_admin_id', 'reason',
|
||||
'metadata', 'created_at',
|
||||
]
|
||||
read_only_fields = fields # 全部只读
|
||||
|
||||
|
||||
# ---------- P2-11 UserDevice 好感度展开(read-only)----------
|
||||
|
||||
class UserDeviceAffinitySerializer(serializers.ModelSerializer):
|
||||
"""admin 按 user 展开该用户名下所有设备的好感度状态"""
|
||||
|
||||
device_code = serializers.CharField(source='device.device_code', read_only=True)
|
||||
mac_address = serializers.CharField(source='device.mac_address', read_only=True)
|
||||
device_status = serializers.CharField(source='device.status', read_only=True)
|
||||
user_username = serializers.CharField(source='user.username', read_only=True)
|
||||
level_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserDevice
|
||||
fields = [
|
||||
'id', 'user', 'user_username',
|
||||
'device', 'device_code', 'mac_address', 'device_status',
|
||||
'nickname', 'bound_at', 'is_primary', 'is_bound',
|
||||
'favorability', 'affinity_level', 'level_name',
|
||||
'last_active_at',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_level_name(self, obj):
|
||||
# 取等级名(避免 N+1,调用方应在 viewset 里 prefetch_related 或单次缓存)
|
||||
level = AffinityLevel.objects.filter(level=obj.affinity_level).first()
|
||||
return level.name if level else None
|
||||
|
||||
|
||||
# ---------- P2-12 adjust 请求 ----------
|
||||
|
||||
class AffinityAdjustSerializer(serializers.Serializer):
|
||||
"""单台设备调整:必传 device_id + delta"""
|
||||
user_id = serializers.IntegerField(required=True, help_text='目标用户 ID')
|
||||
device_id = serializers.IntegerField(required=True, help_text='UserDevice 绑定 ID')
|
||||
delta = serializers.IntegerField(required=True, help_text='增减值,正数加,负数减')
|
||||
reason = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
def validate_delta(self, value):
|
||||
if value == 0:
|
||||
raise serializers.ValidationError('delta 不能为 0')
|
||||
return value
|
||||
|
||||
|
||||
class AffinityAdjustBatchSerializer(serializers.Serializer):
|
||||
"""批量调整:给某 user 名下所有绑定设备各加 delta"""
|
||||
user_id = serializers.IntegerField(required=True)
|
||||
delta = serializers.IntegerField(required=True)
|
||||
reason = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
def validate_delta(self, value):
|
||||
if value == 0:
|
||||
raise serializers.ValidationError('delta 不能为 0')
|
||||
return value
|
||||
428
qy_lty/userapp/affinity/services.py
Normal file
428
qy_lty/userapp/affinity/services.py
Normal file
@ -0,0 +1,428 @@
|
||||
"""P2-01 好感度系统服务层 — 唯一写入入口
|
||||
|
||||
所有好感度变化(设备事件、手机事件、衰减任务、管理员调整)**必须**经由
|
||||
`AffinityService.apply()` 处理,确保单一冷却 / 日上限 / 钳位 / 日志 / 等级
|
||||
更新 / 奖励派发 / WS 推送的语义闭环。
|
||||
|
||||
设计文档:§4.3 触发计算流程
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
|
||||
from device_interaction.models import UserDevice
|
||||
from userapp.models import (
|
||||
AffinityLog,
|
||||
AffinityRule,
|
||||
AffinitySetting,
|
||||
UserAffinityDailyCounter,
|
||||
)
|
||||
|
||||
from . import counters as redis_counters
|
||||
from . import levels as level_utils
|
||||
from . import rewards as reward_utils
|
||||
from . import ws
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------- 返回类型 ----------
|
||||
|
||||
class ApplyOutcome:
|
||||
"""ApplyResult.outcome 的常量集合"""
|
||||
APPLIED = 'applied' # 成功写入
|
||||
NOOP_NO_RULE = 'noop_no_rule' # 规则不存在或已禁用 / 软删
|
||||
NOOP_COOLDOWN = 'noop_cooldown' # 冷却中
|
||||
NOOP_RULE_DAILY_CAP = 'noop_rule_daily_cap' # 本规则今日已达上限
|
||||
NOOP_GLOBAL_DAILY_CAP = 'noop_global_daily_cap' # 全局今日已达上限
|
||||
NOOP_EVENT_DUP = 'noop_event_duplicate' # 同 event_id 60s 内已处理
|
||||
NOOP_VALUE_BOUNDARY = 'noop_value_boundary' # 当前已在边界(0 还想扣 / 上限还想加)
|
||||
ERROR = 'error' # 异常
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyResult:
|
||||
outcome: str
|
||||
change_value: int = 0
|
||||
before_value: int = 0
|
||||
after_value: int = 0
|
||||
rule_key: str = ''
|
||||
old_level: int = 0
|
||||
new_level: int = 0
|
||||
log_id: Optional[int] = None
|
||||
rewards_granted: list = field(default_factory=list)
|
||||
rewards_failed: list = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_applied(self) -> bool:
|
||||
return self.outcome == ApplyOutcome.APPLIED
|
||||
|
||||
|
||||
# ---------- 主入口 ----------
|
||||
|
||||
class AffinityService:
|
||||
"""好感度系统服务层"""
|
||||
|
||||
@classmethod
|
||||
def apply(
|
||||
cls,
|
||||
*,
|
||||
user_id: int,
|
||||
device_id: int,
|
||||
rule_key: str,
|
||||
source: str,
|
||||
event_id: str = '',
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
operator_admin_id: Optional[int] = None,
|
||||
reason: str = '',
|
||||
) -> ApplyResult:
|
||||
"""好感度变化的唯一写入入口。
|
||||
|
||||
参数:
|
||||
user_id: ParadiseUser.id(用于 AffinityLog.user 关联及 WS group 路由)
|
||||
device_id: UserDevice.id(注意是绑定 ID,不是 Device.id)
|
||||
rule_key: AffinityRule.rule_key(如 'chat' / 'gift' / 'decay' / 'admin')
|
||||
source: AffinityLog.SOURCE_CHOICES 之一
|
||||
event_id: 客户端事件 UUID,用于 60s 内幂等去重
|
||||
metadata: 扩展上下文 JSON
|
||||
operator_admin_id / reason: 管理员调整时填写
|
||||
|
||||
返回 ApplyResult,调用方根据 outcome 决定后续动作。
|
||||
"""
|
||||
# 0) event_id 幂等防重
|
||||
if event_id and redis_counters.event_already_processed(event_id):
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.NOOP_EVENT_DUP,
|
||||
rule_key=rule_key,
|
||||
)
|
||||
|
||||
# 1) 取规则
|
||||
rule = (
|
||||
AffinityRule.objects
|
||||
.filter(rule_key=rule_key, is_enabled=True, is_deleted=False)
|
||||
.first()
|
||||
)
|
||||
if rule is None:
|
||||
return ApplyResult(outcome=ApplyOutcome.NOOP_NO_RULE, rule_key=rule_key)
|
||||
|
||||
# 2) 冷却检查
|
||||
if redis_counters.is_in_cooldown(device_id, rule_key):
|
||||
return ApplyResult(outcome=ApplyOutcome.NOOP_COOLDOWN, rule_key=rule_key)
|
||||
|
||||
# 3) 取设备(要求绑定有效)
|
||||
try:
|
||||
user_device = UserDevice.active.get(id=device_id, user_id=user_id)
|
||||
except UserDevice.DoesNotExist:
|
||||
logger.warning(
|
||||
'[affinity.service] device %s 不属于 user %s 或已解绑,跳过',
|
||||
device_id, user_id,
|
||||
)
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.ERROR,
|
||||
rule_key=rule_key,
|
||||
error='UserDevice not found or unbound',
|
||||
)
|
||||
|
||||
# 4) 计算本次变化值([min, max] 闭区间随机;P1 CHECK 保证 min <= max)
|
||||
raw_change = random.randint(rule.min_change, rule.max_change)
|
||||
# single_cap 钳位(保护性 — P1 CHECK 已保证 single_cap > 0)
|
||||
if raw_change > rule.single_cap:
|
||||
raw_change = rule.single_cap
|
||||
elif raw_change < -rule.single_cap:
|
||||
raw_change = -rule.single_cap
|
||||
|
||||
if raw_change == 0:
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.NOOP_VALUE_BOUNDARY,
|
||||
rule_key=rule_key,
|
||||
change_value=0,
|
||||
)
|
||||
|
||||
# 5) 本规则日上限检查(用绝对值累加)
|
||||
abs_change = abs(raw_change)
|
||||
rule_today = redis_counters.get_rule_daily(device_id, rule_key)
|
||||
if rule_today + abs_change > rule.daily_cap:
|
||||
# 允许部分通过:剩余空间 > 0 时按剩余空间钳位
|
||||
remain = rule.daily_cap - rule_today
|
||||
if remain <= 0:
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.NOOP_RULE_DAILY_CAP,
|
||||
rule_key=rule_key,
|
||||
)
|
||||
# 按正负方向取剩余空间
|
||||
raw_change = remain if raw_change > 0 else -remain
|
||||
abs_change = remain
|
||||
|
||||
# 6) 全局日上限检查(仅正向汇总,衰减不占用)
|
||||
setting = AffinitySetting.get_solo()
|
||||
if raw_change > 0:
|
||||
global_today = redis_counters.get_global_daily(device_id)
|
||||
if global_today + abs_change > setting.global_daily_cap:
|
||||
remain = setting.global_daily_cap - global_today
|
||||
if remain <= 0:
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.NOOP_GLOBAL_DAILY_CAP,
|
||||
rule_key=rule_key,
|
||||
)
|
||||
raw_change = remain
|
||||
abs_change = remain
|
||||
|
||||
# 7) 原子更新 UserDevice.favorability + 钳位
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# 锁住该 UserDevice 行,避免并发写竞争
|
||||
ud_locked = UserDevice.objects.select_for_update().get(id=user_device.id)
|
||||
before_value = ud_locked.favorability
|
||||
after_value = before_value + raw_change
|
||||
# 钳位 [0, max_affinity](管理员调整也走此分支,因此天然遵守 CR-001 决策 6)
|
||||
after_value = max(0, min(setting.max_affinity, after_value))
|
||||
actual_change = after_value - before_value
|
||||
|
||||
if actual_change == 0:
|
||||
# 边界场景:当前值已经在边界,本次实际无变化
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.NOOP_VALUE_BOUNDARY,
|
||||
rule_key=rule_key,
|
||||
before_value=before_value,
|
||||
after_value=after_value,
|
||||
)
|
||||
|
||||
ud_locked.favorability = after_value
|
||||
# 同步刷新 last_active_at(衰减判断依赖)
|
||||
from django.utils import timezone as djtz
|
||||
ud_locked.last_active_at = djtz.now()
|
||||
ud_locked.save(update_fields=['favorability', 'last_active_at'])
|
||||
|
||||
# 8) 写 AffinityLog(含 cross-app FK 与冗余 rule_key)
|
||||
log = AffinityLog.objects.create(
|
||||
user_id=user_id,
|
||||
device=ud_locked,
|
||||
rule=rule,
|
||||
rule_key=rule_key,
|
||||
change_value=actual_change,
|
||||
before_value=before_value,
|
||||
after_value=after_value,
|
||||
source=source,
|
||||
event_id=event_id or None, # WR-004:空 -> NULL
|
||||
operator_admin_id=operator_admin_id,
|
||||
reason=reason,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
# 9) 更新数据库兜底计数器(Redis 是热路径,DB 是审计兜底)
|
||||
today_date = redis_counters.local_today_date(setting.timezone)
|
||||
counter, _ = UserAffinityDailyCounter.objects.select_for_update().get_or_create(
|
||||
device=ud_locked, rule=rule, date=today_date,
|
||||
defaults={'accumulated_change': 0, 'trigger_count': 0},
|
||||
)
|
||||
counter.accumulated_change = F('accumulated_change') + actual_change
|
||||
counter.trigger_count = F('trigger_count') + 1
|
||||
counter.save(update_fields=['accumulated_change', 'trigger_count'])
|
||||
|
||||
# 10) 更新等级缓存(事务内做,避免半成品状态)
|
||||
old_level, new_level, _matched = level_utils.update_device_level(
|
||||
ud_locked, save=True,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
'[affinity.service] apply 失败 user=%s device=%s rule=%s: %s',
|
||||
user_id, device_id, rule_key, exc,
|
||||
)
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.ERROR,
|
||||
rule_key=rule_key,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
# 事务已提交,下面是非原子的副作用:Redis 计数 / 推送 / 奖励派发
|
||||
# 11) Redis 计数器累加(仅在数据真正写入后再扣冷却 / 日上限配额)
|
||||
redis_counters.set_cooldown(device_id, rule_key, rule.cooldown_seconds)
|
||||
redis_counters.incr_rule_daily(device_id, rule_key, abs(actual_change))
|
||||
if actual_change > 0:
|
||||
redis_counters.incr_global_daily(device_id, abs(actual_change))
|
||||
if event_id:
|
||||
redis_counters.mark_event_processed(event_id)
|
||||
|
||||
# 12) 跨级奖励发放(A3 方案 B — 每级独立事务)
|
||||
grant_result = reward_utils.RewardGrantResult()
|
||||
if new_level > old_level:
|
||||
grant_result = reward_utils.grant_levels(ud_locked, old_level, new_level)
|
||||
|
||||
# 13) WS 推送
|
||||
if setting.enable_notify:
|
||||
ws.push_affinity_update(
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
change=actual_change,
|
||||
before=before_value,
|
||||
after=after_value,
|
||||
rule_key=rule_key,
|
||||
source=source,
|
||||
)
|
||||
if new_level > old_level:
|
||||
ws.push_level_up(
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
old_level=old_level,
|
||||
new_level=new_level,
|
||||
rewards=grant_result.granted,
|
||||
)
|
||||
elif new_level < old_level:
|
||||
ws.push_level_down(
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
old_level=old_level,
|
||||
new_level=new_level,
|
||||
)
|
||||
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.APPLIED,
|
||||
change_value=actual_change,
|
||||
before_value=before_value,
|
||||
after_value=after_value,
|
||||
rule_key=rule_key,
|
||||
old_level=old_level,
|
||||
new_level=new_level,
|
||||
log_id=log.id,
|
||||
rewards_granted=grant_result.granted,
|
||||
rewards_failed=grant_result.failed,
|
||||
)
|
||||
|
||||
# ---------- 管理员调整专用入口(绕过 rule,直接给数值)----------
|
||||
|
||||
@classmethod
|
||||
def admin_adjust(
|
||||
cls,
|
||||
*,
|
||||
user_id: int,
|
||||
device_id: int,
|
||||
delta: int,
|
||||
operator_admin_id: int,
|
||||
reason: str = '',
|
||||
batch: bool = False,
|
||||
) -> ApplyResult:
|
||||
"""管理员手动调整。
|
||||
|
||||
- 不查 AffinityRule(rule=NULL,rule_key='admin')
|
||||
- 不查冷却 / 日上限
|
||||
- 依然钳位 [0, max_affinity](决策 6:管理员不能突破上限)
|
||||
- source=admin_adjust_single 或 admin_adjust_batch
|
||||
- 仍写 AffinityLog、更新等级缓存、发奖励、推 WS
|
||||
"""
|
||||
from userapp.models import AffinitySetting
|
||||
source = 'admin_adjust_batch' if batch else 'admin_adjust_single'
|
||||
|
||||
try:
|
||||
user_device = UserDevice.active.get(id=device_id, user_id=user_id)
|
||||
except UserDevice.DoesNotExist:
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.ERROR,
|
||||
rule_key='admin',
|
||||
error='UserDevice not found or unbound',
|
||||
)
|
||||
|
||||
if delta == 0:
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.NOOP_VALUE_BOUNDARY,
|
||||
rule_key='admin',
|
||||
)
|
||||
|
||||
setting = AffinitySetting.get_solo()
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
ud_locked = UserDevice.objects.select_for_update().get(id=user_device.id)
|
||||
before_value = ud_locked.favorability
|
||||
after_value = max(0, min(setting.max_affinity, before_value + delta))
|
||||
actual_change = after_value - before_value
|
||||
|
||||
if actual_change == 0:
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.NOOP_VALUE_BOUNDARY,
|
||||
rule_key='admin',
|
||||
before_value=before_value,
|
||||
after_value=after_value,
|
||||
)
|
||||
|
||||
ud_locked.favorability = after_value
|
||||
from django.utils import timezone as djtz
|
||||
ud_locked.last_active_at = djtz.now()
|
||||
ud_locked.save(update_fields=['favorability', 'last_active_at'])
|
||||
|
||||
log = AffinityLog.objects.create(
|
||||
user_id=user_id,
|
||||
device=ud_locked,
|
||||
rule=None,
|
||||
rule_key='admin',
|
||||
change_value=actual_change,
|
||||
before_value=before_value,
|
||||
after_value=after_value,
|
||||
source=source,
|
||||
operator_admin_id=operator_admin_id,
|
||||
reason=reason,
|
||||
metadata={'requested_delta': delta},
|
||||
)
|
||||
|
||||
old_level, new_level, _matched = level_utils.update_device_level(
|
||||
ud_locked, save=True,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
'[affinity.service.admin_adjust] 失败 user=%s device=%s delta=%s',
|
||||
user_id, device_id, delta,
|
||||
)
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.ERROR,
|
||||
rule_key='admin',
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
# 副作用
|
||||
grant_result = reward_utils.RewardGrantResult()
|
||||
if new_level > old_level:
|
||||
grant_result = reward_utils.grant_levels(ud_locked, old_level, new_level)
|
||||
|
||||
if setting.enable_notify:
|
||||
ws.push_affinity_update(
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
change=actual_change,
|
||||
before=before_value,
|
||||
after=after_value,
|
||||
rule_key='admin',
|
||||
source=source,
|
||||
)
|
||||
if new_level > old_level:
|
||||
ws.push_level_up(
|
||||
user_id=user_id, device_id=device_id,
|
||||
old_level=old_level, new_level=new_level,
|
||||
rewards=grant_result.granted,
|
||||
)
|
||||
elif new_level < old_level:
|
||||
ws.push_level_down(
|
||||
user_id=user_id, device_id=device_id,
|
||||
old_level=old_level, new_level=new_level,
|
||||
)
|
||||
|
||||
return ApplyResult(
|
||||
outcome=ApplyOutcome.APPLIED,
|
||||
change_value=actual_change,
|
||||
before_value=before_value,
|
||||
after_value=after_value,
|
||||
rule_key='admin',
|
||||
old_level=old_level,
|
||||
new_level=new_level,
|
||||
log_id=log.id,
|
||||
rewards_granted=grant_result.granted,
|
||||
rewards_failed=grant_result.failed,
|
||||
)
|
||||
38
qy_lty/userapp/affinity/urls.py
Normal file
38
qy_lty/userapp/affinity/urls.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""好感度系统 admin 端 URL 路由
|
||||
|
||||
挂载位置:`/api/v1/admin/affinity/...`(由 userapp/admin_urls.py include)
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
AffinityAdjustBatchView,
|
||||
AffinityAdjustView,
|
||||
AffinityLevelAdminViewSet,
|
||||
AffinityLogListView,
|
||||
AffinityRuleAdminViewSet,
|
||||
AffinitySettingView,
|
||||
AffinityStatsView,
|
||||
UserAffinityDevicesView,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('rules', AffinityRuleAdminViewSet, basename='admin-affinity-rule')
|
||||
router.register('levels', AffinityLevelAdminViewSet, basename='admin-affinity-level')
|
||||
|
||||
urlpatterns = [
|
||||
# P2-08 单例 settings
|
||||
path('settings/', AffinitySettingView.as_view(), name='admin_affinity_settings'),
|
||||
# P2-09 变更日志
|
||||
path('logs/', AffinityLogListView.as_view(), name='admin_affinity_logs'),
|
||||
# P2-10 统计
|
||||
path('stats/', AffinityStatsView.as_view(), name='admin_affinity_stats'),
|
||||
# P2-11 按用户列出设备
|
||||
path('devices/', UserAffinityDevicesView.as_view(), name='admin_affinity_devices'),
|
||||
# P2-12 手动调整
|
||||
path('adjust/', AffinityAdjustView.as_view(), name='admin_affinity_adjust'),
|
||||
path('adjust-batch/', AffinityAdjustBatchView.as_view(), name='admin_affinity_adjust_batch'),
|
||||
# P2-06 / P2-07 router-driven CRUD
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
397
qy_lty/userapp/affinity/views.py
Normal file
397
qy_lty/userapp/affinity/views.py
Normal file
@ -0,0 +1,397 @@
|
||||
"""P2-06 ~ P2-12 管理端 admin API ViewSets
|
||||
|
||||
挂载在 /api/v1/admin/affinity/...,要求 RedisTokenAuthentication + is_staff。
|
||||
所有接口走 StandardResponseMiddleware 自动包成 {success,code,message,data}。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Avg, Count, Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from device_interaction.models import UserDevice
|
||||
from userapp.authentication import RedisTokenAuthentication
|
||||
from userapp.models import (
|
||||
AffinityLevel,
|
||||
AffinityLog,
|
||||
AffinityRule,
|
||||
AffinitySetting,
|
||||
ParadiseUser,
|
||||
)
|
||||
|
||||
from .permissions import IsAdminUserStaff
|
||||
from .serializers import (
|
||||
AffinityAdjustBatchSerializer,
|
||||
AffinityAdjustSerializer,
|
||||
AffinityLevelSerializer,
|
||||
AffinityLogSerializer,
|
||||
AffinityRuleSerializer,
|
||||
AffinitySettingSerializer,
|
||||
UserDeviceAffinitySerializer,
|
||||
)
|
||||
from .services import AffinityService, ApplyOutcome
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------- P2-06 AffinityRule admin CRUD ----------
|
||||
|
||||
class AffinityRuleAdminViewSet(viewsets.ModelViewSet):
|
||||
"""好感度规则管理端 CRUD
|
||||
|
||||
- GET 默认排除软删(is_deleted=False),加 ?include_deleted=true 全集
|
||||
- DELETE 走软删(is_deleted=True,is_enabled=False),保留 rule_key 但禁触发
|
||||
- POST/PATCH 走 serializer 跨字段校验 + DB CHECK 兜底
|
||||
"""
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
permission_classes = [IsAdminUserStaff]
|
||||
serializer_class = AffinityRuleSerializer
|
||||
queryset = AffinityRule.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
qs = AffinityRule.objects.all().order_by('-created_at')
|
||||
if self.request.query_params.get('include_deleted', '').lower() not in ('1', 'true', 'yes'):
|
||||
qs = qs.filter(is_deleted=False)
|
||||
return qs
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""软删:is_deleted=True + is_enabled=False(service 层基于这两个标记拒绝触发)"""
|
||||
instance.is_deleted = True
|
||||
instance.is_enabled = False
|
||||
instance.save(update_fields=['is_deleted', 'is_enabled', 'updated_at'])
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='restore')
|
||||
def restore(self, request, pk=None):
|
||||
"""恢复软删的规则。允许把 is_enabled 一起拉起来,默认 False(管理员手动启用)"""
|
||||
rule = get_object_or_404(AffinityRule, pk=pk)
|
||||
rule.is_deleted = False
|
||||
rule.save(update_fields=['is_deleted', 'updated_at'])
|
||||
return Response(AffinityRuleSerializer(rule).data)
|
||||
|
||||
|
||||
# ---------- P2-07 AffinityLevel admin CRUD ----------
|
||||
|
||||
class AffinityLevelAdminViewSet(viewsets.ModelViewSet):
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
permission_classes = [IsAdminUserStaff]
|
||||
serializer_class = AffinityLevelSerializer
|
||||
queryset = AffinityLevel.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
qs = AffinityLevel.objects.all().order_by('level')
|
||||
if self.request.query_params.get('include_deleted', '').lower() not in ('1', 'true', 'yes'):
|
||||
qs = qs.filter(is_deleted=False)
|
||||
return qs
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.is_deleted = True
|
||||
instance.is_enabled = False
|
||||
instance.save(update_fields=['is_deleted', 'is_enabled', 'updated_at'])
|
||||
|
||||
|
||||
# ---------- P2-08 AffinitySetting 单例 GET/PUT ----------
|
||||
|
||||
class AffinitySettingView(APIView):
|
||||
"""好感度系统全局设置 — 单例 GET/PUT
|
||||
|
||||
GET → 当前配置
|
||||
PUT → 全字段覆写(serializer 验证)
|
||||
PATCH → 部分字段更新
|
||||
"""
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
permission_classes = [IsAdminUserStaff]
|
||||
|
||||
def get(self, request):
|
||||
instance = AffinitySetting.get_solo()
|
||||
return Response(AffinitySettingSerializer(instance).data)
|
||||
|
||||
def put(self, request):
|
||||
instance = AffinitySetting.get_solo()
|
||||
ser = AffinitySettingSerializer(instance, data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
ser.save()
|
||||
return Response(ser.data)
|
||||
|
||||
def patch(self, request):
|
||||
instance = AffinitySetting.get_solo()
|
||||
ser = AffinitySettingSerializer(instance, data=request.data, partial=True)
|
||||
ser.is_valid(raise_exception=True)
|
||||
ser.save()
|
||||
return Response(ser.data)
|
||||
|
||||
|
||||
# ---------- P2-09 AffinityLog 查询 ----------
|
||||
|
||||
class AffinityLogListView(APIView):
|
||||
"""好感度变化日志查询
|
||||
|
||||
支持过滤参数:
|
||||
user_id — 用户 ID
|
||||
device_id — UserDevice 绑定 ID
|
||||
rule_key — 规则代码
|
||||
source — 来源(device_event / mobile_event / system_decay / admin_adjust_*)
|
||||
date_from / date_to — created_at 范围(ISO 日期)
|
||||
page / page_size — 分页(默认 page_size=20,最大 200)
|
||||
"""
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
permission_classes = [IsAdminUserStaff]
|
||||
|
||||
def get(self, request):
|
||||
qs = AffinityLog.objects.select_related('user', 'rule', 'device__device').order_by('-created_at')
|
||||
|
||||
user_id = request.query_params.get('user_id')
|
||||
device_id = request.query_params.get('device_id')
|
||||
rule_key = request.query_params.get('rule_key')
|
||||
source = request.query_params.get('source')
|
||||
date_from = request.query_params.get('date_from')
|
||||
date_to = request.query_params.get('date_to')
|
||||
|
||||
if user_id:
|
||||
qs = qs.filter(user_id=user_id)
|
||||
if device_id:
|
||||
qs = qs.filter(device_id=device_id)
|
||||
if rule_key:
|
||||
qs = qs.filter(rule_key=rule_key)
|
||||
if source:
|
||||
qs = qs.filter(source=source)
|
||||
if date_from:
|
||||
qs = qs.filter(created_at__gte=date_from)
|
||||
if date_to:
|
||||
qs = qs.filter(created_at__lte=date_to)
|
||||
|
||||
# 简易分页
|
||||
try:
|
||||
page = max(int(request.query_params.get('page', 1)), 1)
|
||||
page_size = min(max(int(request.query_params.get('page_size', 20)), 1), 200)
|
||||
except (TypeError, ValueError):
|
||||
page, page_size = 1, 20
|
||||
|
||||
total = qs.count()
|
||||
items = qs[(page - 1) * page_size: page * page_size]
|
||||
data = AffinityLogSerializer(items, many=True).data
|
||||
return Response({
|
||||
'total': total, 'page': page, 'page_size': page_size,
|
||||
'items': data,
|
||||
})
|
||||
|
||||
|
||||
# ---------- P2-10 数据统计 ----------
|
||||
|
||||
class AffinityStatsView(APIView):
|
||||
"""好感度系统数据统计(admin 概览)
|
||||
|
||||
返回结构(与设计文档 §7 对齐):
|
||||
avg_favorability — 全设备平均好感度
|
||||
max_favorability — 当前最高好感度值
|
||||
top_count — 达到 max_affinity 上限的设备数
|
||||
active_devices_7d — 近 7 日有互动(last_active_at)的设备数
|
||||
total_devices — 已绑定(is_bound=True)设备总数
|
||||
today_interactions — 今日(local timezone)触发数
|
||||
today_change_sum — 今日好感度变化总和(含正负)
|
||||
rule_freq_top — 互动规则触发频次 Top 10
|
||||
level_distribution — 各等级设备数占比
|
||||
"""
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
permission_classes = [IsAdminUserStaff]
|
||||
|
||||
def get(self, request):
|
||||
setting = AffinitySetting.get_solo()
|
||||
max_aff = setting.max_affinity
|
||||
|
||||
# 仅统计有效绑定(is_bound=True)的设备
|
||||
active_qs = UserDevice.active.all()
|
||||
total_devices = active_qs.count()
|
||||
avg_fav = active_qs.aggregate(v=Avg('favorability'))['v'] or 0.0
|
||||
max_fav = active_qs.order_by('-favorability').values_list('favorability', flat=True).first() or 0
|
||||
top_count = active_qs.filter(favorability__gte=max_aff).count()
|
||||
|
||||
seven_days_ago = timezone.now() - timedelta(days=7)
|
||||
active_7d = active_qs.filter(last_active_at__gte=seven_days_ago).count()
|
||||
|
||||
# 今日(local timezone)日志聚合
|
||||
from .counters import local_today_date
|
||||
today = local_today_date(setting.timezone)
|
||||
# date → datetime 边界(避免时区错位,用 UTC 范围更稳)
|
||||
today_logs = AffinityLog.objects.filter(created_at__date=today)
|
||||
today_interactions = today_logs.count()
|
||||
today_change_sum = today_logs.aggregate(s=Count('id'))['s'] # 触发数
|
||||
# 用 Sum 更合理
|
||||
from django.db.models import Sum
|
||||
today_change_sum_val = today_logs.aggregate(s=Sum('change_value'))['s'] or 0
|
||||
|
||||
rule_freq_top = list(
|
||||
AffinityLog.objects
|
||||
.filter(created_at__gte=seven_days_ago, rule_key__isnull=False)
|
||||
.exclude(rule_key='')
|
||||
.values('rule_key')
|
||||
.annotate(c=Count('id'))
|
||||
.order_by('-c')[:10]
|
||||
)
|
||||
|
||||
level_distribution = list(
|
||||
active_qs
|
||||
.values('affinity_level')
|
||||
.annotate(c=Count('id'))
|
||||
.order_by('affinity_level')
|
||||
)
|
||||
|
||||
return Response({
|
||||
'avg_favorability': round(float(avg_fav), 2),
|
||||
'max_favorability': max_fav,
|
||||
'top_count': top_count,
|
||||
'active_devices_7d': active_7d,
|
||||
'total_devices': total_devices,
|
||||
'today_interactions': today_interactions,
|
||||
'today_change_sum': today_change_sum_val,
|
||||
'rule_freq_top': rule_freq_top,
|
||||
'level_distribution': level_distribution,
|
||||
'computed_at': timezone.now().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
# ---------- P2-11 按用户列出设备好感度 ----------
|
||||
|
||||
class UserAffinityDevicesView(APIView):
|
||||
"""admin 按 user_id 列出该用户名下所有设备的好感度状态
|
||||
|
||||
GET /api/v1/admin/affinity/devices/?user_id=123
|
||||
&include_unbound=true — 是否含已解绑历史(默认 false)
|
||||
"""
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
permission_classes = [IsAdminUserStaff]
|
||||
|
||||
def get(self, request):
|
||||
user_id = request.query_params.get('user_id')
|
||||
if not user_id:
|
||||
return Response({'detail': 'user_id 必传'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not ParadiseUser.objects.filter(id=user_id).exists():
|
||||
return Response({'detail': f'用户 {user_id} 不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
include_unbound = request.query_params.get('include_unbound', '').lower() in ('1', 'true', 'yes')
|
||||
# CR-001 修复:默认仅返回 is_bound=True,include_unbound=true 时给历史
|
||||
qs = (UserDevice.objects if include_unbound else UserDevice.active).filter(user_id=user_id)
|
||||
qs = qs.select_related('user', 'device').order_by('-is_primary', '-bound_at')
|
||||
|
||||
data = UserDeviceAffinitySerializer(qs, many=True).data
|
||||
return Response({
|
||||
'user_id': int(user_id),
|
||||
'count': len(data),
|
||||
'items': data,
|
||||
})
|
||||
|
||||
|
||||
# ---------- P2-12 admin 手动调整 ----------
|
||||
|
||||
class AffinityAdjustView(APIView):
|
||||
"""单台设备调整
|
||||
|
||||
POST /api/v1/admin/affinity/adjust/
|
||||
Body: { user_id, device_id, delta, reason }
|
||||
"""
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
permission_classes = [IsAdminUserStaff]
|
||||
|
||||
def post(self, request):
|
||||
ser = AffinityAdjustSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
params = ser.validated_data
|
||||
|
||||
result = AffinityService.admin_adjust(
|
||||
user_id=params['user_id'],
|
||||
device_id=params['device_id'],
|
||||
delta=params['delta'],
|
||||
operator_admin_id=request.user.id,
|
||||
reason=params.get('reason', ''),
|
||||
batch=False,
|
||||
)
|
||||
|
||||
return Response(_apply_result_to_dict(result),
|
||||
status=_status_from_outcome(result.outcome))
|
||||
|
||||
|
||||
class AffinityAdjustBatchView(APIView):
|
||||
"""批量调整:给某 user 名下所有绑定设备各加 delta
|
||||
|
||||
POST /api/v1/admin/affinity/adjust-batch/
|
||||
Body: { user_id, delta, reason }
|
||||
返回每台设备的处理结果。
|
||||
"""
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
permission_classes = [IsAdminUserStaff]
|
||||
|
||||
def post(self, request):
|
||||
ser = AffinityAdjustBatchSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
params = ser.validated_data
|
||||
user_id = params['user_id']
|
||||
delta = params['delta']
|
||||
reason = params.get('reason', '')
|
||||
|
||||
if not ParadiseUser.objects.filter(id=user_id).exists():
|
||||
return Response({'detail': f'用户 {user_id} 不存在'},
|
||||
status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
device_ids = list(UserDevice.active.filter(user_id=user_id).values_list('id', flat=True))
|
||||
if not device_ids:
|
||||
return Response({'detail': '该用户名下无有效绑定设备'},
|
||||
status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
results = []
|
||||
applied_count = 0
|
||||
for did in device_ids:
|
||||
res = AffinityService.admin_adjust(
|
||||
user_id=user_id, device_id=did, delta=delta,
|
||||
operator_admin_id=request.user.id, reason=reason, batch=True,
|
||||
)
|
||||
results.append(_apply_result_to_dict(res, device_id=did))
|
||||
if res.is_applied:
|
||||
applied_count += 1
|
||||
|
||||
return Response({
|
||||
'user_id': user_id,
|
||||
'device_count': len(device_ids),
|
||||
'applied_count': applied_count,
|
||||
'results': results,
|
||||
})
|
||||
|
||||
|
||||
# ---------- 辅助 ----------
|
||||
|
||||
def _apply_result_to_dict(result, *, device_id=None) -> dict:
|
||||
d = {
|
||||
'outcome': result.outcome,
|
||||
'applied': result.is_applied,
|
||||
'change_value': result.change_value,
|
||||
'before_value': result.before_value,
|
||||
'after_value': result.after_value,
|
||||
'rule_key': result.rule_key,
|
||||
'old_level': result.old_level,
|
||||
'new_level': result.new_level,
|
||||
'log_id': result.log_id,
|
||||
'rewards_granted': result.rewards_granted,
|
||||
'rewards_failed': result.rewards_failed,
|
||||
'error': result.error,
|
||||
}
|
||||
if device_id is not None:
|
||||
d['device_id'] = device_id
|
||||
return d
|
||||
|
||||
|
||||
def _status_from_outcome(outcome: str) -> int:
|
||||
if outcome == ApplyOutcome.APPLIED:
|
||||
return status.HTTP_200_OK
|
||||
if outcome == ApplyOutcome.ERROR:
|
||||
return status.HTTP_400_BAD_REQUEST
|
||||
# 其他 NOOP_* 视为业务正常拒绝
|
||||
return status.HTTP_200_OK
|
||||
89
qy_lty/userapp/affinity/ws.py
Normal file
89
qy_lty/userapp/affinity/ws.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""P2-05 WebSocket 推送 helper
|
||||
|
||||
把好感度变化事件推到 `device_{user_id}` channel layer group,
|
||||
设备端和手机端的 DeviceConsumer 都加入此分组,会同时收到(设计文档 §9.3)。
|
||||
|
||||
推送是「fire-and-forget」语义 — channel layer 故障或用户不在线时不应阻塞 service
|
||||
主流程,全部用 try/except 包裹并仅日志记录。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _send(user_id: int, payload: Dict[str, Any]) -> None:
|
||||
"""内部统一推送入口。channel_layer 不可用 / group_send 异常时静默吞掉但记日志。"""
|
||||
try:
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer is None:
|
||||
logger.warning('[affinity.ws] channel_layer 未配置,跳过推送 payload=%s', payload)
|
||||
return
|
||||
async_to_sync(channel_layer.group_send)(f'device_{user_id}', payload)
|
||||
except Exception as exc: # pragma: no cover — 推送失败不影响业务
|
||||
logger.warning('[affinity.ws] group_send 失败 user_id=%s err=%s', user_id, exc)
|
||||
|
||||
|
||||
def push_affinity_update(
|
||||
user_id: int,
|
||||
device_id: Optional[int],
|
||||
*,
|
||||
change: int,
|
||||
before: int,
|
||||
after: int,
|
||||
rule_key: str,
|
||||
source: str,
|
||||
) -> None:
|
||||
"""好感度数值变化事件。所有触发点(设备 / 手机 / 衰减 / 管理员调整)共用此事件。"""
|
||||
_send(user_id, {
|
||||
'type': 'affinity.update', # consumer 端 handler 名(消费者用 .replace('.', '_'))
|
||||
'event': 'affinity_update',
|
||||
'device_id': device_id,
|
||||
'change': change,
|
||||
'before': before,
|
||||
'after': after,
|
||||
'rule_key': rule_key,
|
||||
'source': source,
|
||||
})
|
||||
|
||||
|
||||
def push_level_up(
|
||||
user_id: int,
|
||||
device_id: Optional[int],
|
||||
*,
|
||||
old_level: int,
|
||||
new_level: int,
|
||||
rewards: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""升级事件。rewards 是本次跨级一次性发放的奖励列表,每级一项。"""
|
||||
_send(user_id, {
|
||||
'type': 'affinity.level.up',
|
||||
'event': 'level_up',
|
||||
'device_id': device_id,
|
||||
'old_level': old_level,
|
||||
'new_level': new_level,
|
||||
'rewards': rewards,
|
||||
})
|
||||
|
||||
|
||||
def push_level_down(
|
||||
user_id: int,
|
||||
device_id: Optional[int],
|
||||
*,
|
||||
old_level: int,
|
||||
new_level: int,
|
||||
) -> None:
|
||||
"""降级事件。衰减导致的等级回退,不追回奖励但取消等级解锁内容。"""
|
||||
_send(user_id, {
|
||||
'type': 'affinity.level.down',
|
||||
'event': 'level_down',
|
||||
'device_id': device_id,
|
||||
'old_level': old_level,
|
||||
'new_level': new_level,
|
||||
})
|
||||
@ -6,110 +6,23 @@
|
||||
|
||||
写入内容(与「好感度系统功能与规则设计.md」§4.2 / §6.2 一致):
|
||||
1. AffinitySetting 单例(如不存在)
|
||||
2. 8 条默认互动规则
|
||||
2. 9 条默认互动规则(含 1 条 companion_time 规则 — WR-005)
|
||||
3. 5 个默认等级
|
||||
|
||||
幂等性:
|
||||
默认按 rule_key(规则)/ level(等级)查询,已存在则跳过。
|
||||
--force 模式下覆盖已存在记录的字段(不删旧记录)。
|
||||
|
||||
WR-007 修正:
|
||||
每条 spec 独立事务,部分失败不影响其他记录(避免 force 模式下第 N 条崩
|
||||
导致前 N-1 条全部回滚但 stdout 已打印 "成功"的语义错位)。
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from userapp.models import (
|
||||
AffinityRule,
|
||||
AffinityLevel,
|
||||
AffinitySetting,
|
||||
)
|
||||
|
||||
|
||||
# 默认规则,与设计文档 §4.2 一致
|
||||
DEFAULT_RULES = [
|
||||
{
|
||||
'rule_key': 'card', 'name': '使用卡片', 'description': '用户使用洛天依卡片',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 10,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'chat', 'name': '对话', 'description': '与洛天依进行对话',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 1, 'max_change': 5, 'single_cap': 5, 'daily_cap': 15,
|
||||
'cooldown_seconds': 30, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'feed', 'name': '喂食', 'description': '给洛天依喂食',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 2, 'max_change': 8, 'single_cap': 8, 'daily_cap': 16,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'touch', 'name': '抚摸', 'description': '抚摸洛天依',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 9,
|
||||
'cooldown_seconds': 10, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'dress', 'name': '换装', 'description': '为洛天依更换服装',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 2, 'max_change': 6, 'single_cap': 6, 'daily_cap': 12,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'prop', 'name': '使用道具', 'description': '使用互动道具',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 1, 'max_change': 4, 'single_cap': 4, 'daily_cap': 12,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'gift', 'name': '送礼物', 'description': '赠送礼物给洛天依',
|
||||
'trigger_type': 'action',
|
||||
'min_change': 5, 'max_change': 15, 'single_cap': 15, 'daily_cap': 20,
|
||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'rule_key': 'decay', 'name': '无互动衰减', 'description': '长时间不互动导致好感度下降',
|
||||
'trigger_type': 'decay',
|
||||
'min_change': -3, 'max_change': -1, 'single_cap': 3, 'daily_cap': 5,
|
||||
'cooldown_seconds': 0, 'is_negative': True, 'is_enabled': True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# 默认等级,与设计文档 §6.2 一致
|
||||
DEFAULT_LEVELS = [
|
||||
{
|
||||
'level': 1, 'name': '初识', 'min_affinity': 0, 'max_affinity': 20,
|
||||
'unlock_content': '基础对话功能',
|
||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||
'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'level': 2, 'name': '相识', 'min_affinity': 21, 'max_affinity': 40,
|
||||
'unlock_content': '基础服装、道具使用',
|
||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||
'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'level': 3, 'name': '熟悉', 'min_affinity': 41, 'max_affinity': 60,
|
||||
'unlock_content': '更多服装、特殊对话',
|
||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||
'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'level': 4, 'name': '亲密', 'min_affinity': 61, 'max_affinity': 80,
|
||||
'unlock_content': '限定服装、特殊互动',
|
||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||
'is_enabled': True,
|
||||
},
|
||||
{
|
||||
'level': 5, 'name': '挚友', 'min_affinity': 81, 'max_affinity': 100,
|
||||
'unlock_content': '专属内容、特殊剧情',
|
||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||
'is_enabled': True,
|
||||
},
|
||||
]
|
||||
from userapp.models import AffinityLevel, AffinityRule, AffinitySetting
|
||||
from userapp.affinity.defaults import DEFAULT_LEVELS, DEFAULT_RULES, DEFAULT_SETTING
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -121,63 +34,85 @@ class Command(BaseCommand):
|
||||
help='强制覆盖已存在记录的字段(不删旧记录)',
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
force = options['force']
|
||||
|
||||
# WR-007:每条独立事务,部分失败可重跑;不再用全局 @transaction.atomic
|
||||
self._seed_setting()
|
||||
rules_created, rules_updated = self._seed_rules(force)
|
||||
levels_created, levels_updated = self._seed_levels(force)
|
||||
rules_created, rules_updated, rules_failed = self._seed_rules(force)
|
||||
levels_created, levels_updated, levels_failed = self._seed_levels(force)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
style = self.style.WARNING if (rules_failed + levels_failed) else self.style.SUCCESS
|
||||
self.stdout.write(style(
|
||||
f'\n[seed_affinity] 完成:'
|
||||
f'规则 创建 {rules_created} 更新 {rules_updated},'
|
||||
f'等级 创建 {levels_created} 更新 {levels_updated}'
|
||||
f'规则 创建 {rules_created} 更新 {rules_updated} 失败 {rules_failed},'
|
||||
f'等级 创建 {levels_created} 更新 {levels_updated} 失败 {levels_failed}'
|
||||
))
|
||||
|
||||
def _seed_setting(self):
|
||||
if AffinitySetting.objects.exists():
|
||||
self.stdout.write('AffinitySetting 已存在,跳过')
|
||||
return
|
||||
AffinitySetting.objects.create()
|
||||
self.stdout.write(self.style.SUCCESS('AffinitySetting 已创建(默认值)'))
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if AffinitySetting.objects.exists():
|
||||
self.stdout.write('AffinitySetting 已存在,跳过')
|
||||
return
|
||||
# 从 DEFAULT_SETTING 取所有字段(含 IN-003 改名后的 global_daily_cap)
|
||||
AffinitySetting.objects.create(**DEFAULT_SETTING)
|
||||
self.stdout.write(self.style.SUCCESS('AffinitySetting 已创建(默认值)'))
|
||||
except Exception as e:
|
||||
self.stderr.write(self.style.ERROR(f'AffinitySetting 创建失败:{e}'))
|
||||
|
||||
def _seed_rules(self, force):
|
||||
created = 0
|
||||
updated = 0
|
||||
failed = 0
|
||||
for spec in DEFAULT_RULES:
|
||||
rule_key = spec['rule_key']
|
||||
existing = AffinityRule.objects.filter(rule_key=rule_key).first()
|
||||
if existing is None:
|
||||
AffinityRule.objects.create(**spec)
|
||||
created += 1
|
||||
self.stdout.write(f' + 规则 {rule_key} 已创建')
|
||||
elif force:
|
||||
for k, v in spec.items():
|
||||
setattr(existing, k, v)
|
||||
existing.save()
|
||||
updated += 1
|
||||
self.stdout.write(f' ~ 规则 {rule_key} 已覆盖')
|
||||
else:
|
||||
self.stdout.write(f' - 规则 {rule_key} 已存在,跳过')
|
||||
return created, updated
|
||||
try:
|
||||
with transaction.atomic():
|
||||
existing = AffinityRule.objects.filter(rule_key=rule_key).first()
|
||||
if existing is None:
|
||||
AffinityRule.objects.create(**spec)
|
||||
created += 1
|
||||
self.stdout.write(f' + 规则 {rule_key} 已创建')
|
||||
elif force:
|
||||
for k, v in spec.items():
|
||||
setattr(existing, k, v)
|
||||
existing.save()
|
||||
updated += 1
|
||||
self.stdout.write(f' ~ 规则 {rule_key} 已覆盖')
|
||||
else:
|
||||
self.stdout.write(f' - 规则 {rule_key} 已存在,跳过')
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
self.stderr.write(self.style.ERROR(f' ! 规则 {rule_key} 处理失败:{e}'))
|
||||
continue
|
||||
return created, updated, failed
|
||||
|
||||
def _seed_levels(self, force):
|
||||
created = 0
|
||||
updated = 0
|
||||
failed = 0
|
||||
for spec in DEFAULT_LEVELS:
|
||||
level_num = spec['level']
|
||||
existing = AffinityLevel.objects.filter(level=level_num).first()
|
||||
if existing is None:
|
||||
AffinityLevel.objects.create(**spec)
|
||||
created += 1
|
||||
self.stdout.write(f' + 等级 Lv{level_num} 已创建')
|
||||
elif force:
|
||||
for k, v in spec.items():
|
||||
setattr(existing, k, v)
|
||||
existing.save()
|
||||
updated += 1
|
||||
self.stdout.write(f' ~ 等级 Lv{level_num} 已覆盖')
|
||||
else:
|
||||
self.stdout.write(f' - 等级 Lv{level_num} 已存在,跳过')
|
||||
return created, updated
|
||||
try:
|
||||
with transaction.atomic():
|
||||
existing = AffinityLevel.objects.filter(level=level_num).first()
|
||||
if existing is None:
|
||||
# 用 skip_clean=True 跳过 save 内 full_clean(区间重叠校验依赖已存在 levels,
|
||||
# 但当前批量 seed 是按 level 升序逐条 commit,跨记录关系会随插入逐步成立)
|
||||
# 这里仍执行 clean 以保证区间合法
|
||||
AffinityLevel.objects.create(**spec)
|
||||
created += 1
|
||||
self.stdout.write(f' + 等级 Lv{level_num} 已创建')
|
||||
elif force:
|
||||
for k, v in spec.items():
|
||||
setattr(existing, k, v)
|
||||
existing.save()
|
||||
updated += 1
|
||||
self.stdout.write(f' ~ 等级 Lv{level_num} 已覆盖')
|
||||
else:
|
||||
self.stdout.write(f' - 等级 Lv{level_num} 已存在,跳过')
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
self.stderr.write(self.style.ERROR(f' ! 等级 Lv{level_num} 处理失败:{e}'))
|
||||
continue
|
||||
return created, updated, failed
|
||||
|
||||
@ -3,18 +3,40 @@
|
||||
迁移策略:
|
||||
1. 遍历所有 favorability > 0 的用户
|
||||
2. 找该用户的主设备(is_primary=True),无主设备则取最近绑定的设备
|
||||
3. 写入 UserDevice.favorability
|
||||
3. 写入 UserDevice.favorability,并同步创建一条 source='data_migration' 的
|
||||
AffinityLog 作为「已迁移」幂等标记
|
||||
4. 用户没有任何设备绑定 → 跳过(数据保留在 ParadiseUser,等用户首次绑定时由
|
||||
业务层处理)
|
||||
|
||||
幂等性:
|
||||
重复执行不会重复写入(按 favorability=10 默认值判断,已迁移过的设备值非默认
|
||||
或已被业务层覆盖时不再重写)。
|
||||
幂等性(CR-003 修正版):
|
||||
旧实现用 `target.favorability == 10` 做幂等判断,但 10 既是初始值也是
|
||||
衰减下限常见值,重跑迁移会覆盖合法的非默认值。新实现改用
|
||||
`AffinityLog.objects.filter(device_id=target.id, source='data_migration').exists()`
|
||||
做幂等标记,重跑安全。
|
||||
|
||||
backward 回滚(CR-003 修正版):
|
||||
旧实现用 `primary.favorability != 10` 反向判断,导致衰减回 10 的数据无法回滚。
|
||||
新实现读取 forward 写入的 AffinityLog metadata 反向恢复 ParadiseUser.favorability,
|
||||
回滚后删除对应的 AffinityLog 标记(避免再次 forward 时被误判为"已迁移过")。
|
||||
|
||||
⚠️ 已知风险(option B 选择说明):
|
||||
本次 P1 收尾**直接修改了 0006 迁移内容**(而非追加新迁移 0007 做补偿)。
|
||||
选择此方案的前提:
|
||||
- dev DB 已执行过一次 0006,但 migrate_count=0 / skipped_no_device=1 /
|
||||
skipped_zero=9 — 即「成功迁移条数 = 0」,等于没动数据。
|
||||
- django_migrations 表已记录 0006 完成,Django 不会再次执行此文件。
|
||||
- 若 dev DB 出现意外脏数据(例如某用户被部分迁移),需手工
|
||||
`python manage.py migrate userapp 0005 --fake` 再 `migrate userapp 0006`
|
||||
重新执行此修正版逻辑。
|
||||
生产环境(dev → prod 同步部署前)请确认 prod 还未执行 0006,避免历史不一致。
|
||||
|
||||
注意:
|
||||
1. 旧的 ParadiseUser.favorability 字段保留不删,由后续版本统一清理
|
||||
2. AffinitySetting 单例和默认 8 条规则、5 个等级由 management command
|
||||
(seed_affinity, P1-10) 处理,不在数据迁移中
|
||||
3. 本迁移依赖 0007_add_affinity_check_constraints 中加入的 'data_migration'
|
||||
choice — 但 SOURCE_CHOICES 是 Python 校验,迁移内 apps.get_model 返回的
|
||||
历史模型不强制 choice 校验,直接写入字符串即可。
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
@ -23,10 +45,12 @@ from django.db import migrations
|
||||
def migrate_favorability_forward(apps, schema_editor):
|
||||
ParadiseUser = apps.get_model('userapp', 'ParadiseUser')
|
||||
UserDevice = apps.get_model('device_interaction', 'UserDevice')
|
||||
AffinityLog = apps.get_model('userapp', 'AffinityLog')
|
||||
|
||||
migrated_count = 0
|
||||
skipped_no_device = 0
|
||||
skipped_zero = 0
|
||||
skipped_already_migrated = 0
|
||||
|
||||
for user in ParadiseUser.objects.iterator():
|
||||
favorability = getattr(user, 'favorability', 0) or 0
|
||||
@ -51,38 +75,79 @@ def migrate_favorability_forward(apps, schema_editor):
|
||||
skipped_no_device += 1
|
||||
continue
|
||||
|
||||
# 仅当目标值仍是默认值(10)时写入,避免覆盖业务层后续修改
|
||||
if target.favorability == 10:
|
||||
target.favorability = favorability
|
||||
target.save(update_fields=['favorability'])
|
||||
migrated_count += 1
|
||||
# 幂等:检查是否已迁移过(CR-003 修正 — 不再用 favorability == 10 判断)
|
||||
already = AffinityLog.objects.filter(
|
||||
device_id=target.id, source='data_migration'
|
||||
).exists()
|
||||
if already:
|
||||
skipped_already_migrated += 1
|
||||
continue
|
||||
|
||||
before = target.favorability
|
||||
target.favorability = favorability
|
||||
target.save(update_fields=['favorability'])
|
||||
|
||||
# 写入幂等标记 + 审计日志
|
||||
AffinityLog.objects.create(
|
||||
user_id=user.id,
|
||||
device_id=target.id,
|
||||
rule_key='',
|
||||
change_value=favorability - before,
|
||||
before_value=before,
|
||||
after_value=favorability,
|
||||
source='data_migration',
|
||||
metadata={
|
||||
'migration': '0006_migrate_favorability_to_userdevice',
|
||||
'from_user_favorability': favorability,
|
||||
'before_device_favorability': before,
|
||||
},
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
print(
|
||||
f"\n[P1-09] favorability 数据迁移完成:"
|
||||
f"成功 {migrated_count},无设备跳过 {skipped_no_device},"
|
||||
f"零值跳过 {skipped_zero}"
|
||||
f"\n[migration 0006_migrate_favorability] forward: "
|
||||
f"成功={migrated_count}, 无设备={skipped_no_device}, "
|
||||
f"零值={skipped_zero}, 已迁移过={skipped_already_migrated}"
|
||||
)
|
||||
|
||||
|
||||
def migrate_favorability_backward(apps, schema_editor):
|
||||
"""回滚:把 UserDevice.favorability 写回 ParadiseUser.favorability(取主设备值)"""
|
||||
"""回滚:读取 forward 写入的 AffinityLog metadata 反向恢复 ParadiseUser.favorability
|
||||
|
||||
CR-003 修正:旧实现用 `primary.favorability != 10` 反向判断,会丢失衰减回 10 的数据。
|
||||
新实现遍历 source='data_migration' 的 AffinityLog 记录,按 metadata 还原原值,
|
||||
并删除标记以保证 forward / backward 可循环。
|
||||
"""
|
||||
ParadiseUser = apps.get_model('userapp', 'ParadiseUser')
|
||||
UserDevice = apps.get_model('device_interaction', 'UserDevice')
|
||||
AffinityLog = apps.get_model('userapp', 'AffinityLog')
|
||||
|
||||
rolled_back = 0
|
||||
for user in ParadiseUser.objects.iterator():
|
||||
primary = (
|
||||
UserDevice.objects
|
||||
.filter(user_id=user.id, is_primary=True)
|
||||
.order_by('-bound_at')
|
||||
.first()
|
||||
)
|
||||
if primary and primary.favorability != 10:
|
||||
user.favorability = primary.favorability
|
||||
user.save(update_fields=['favorability'])
|
||||
rolled_back += 1
|
||||
skipped_no_metadata = 0
|
||||
|
||||
print(f"\n[P1-09 rollback] 回滚 favorability {rolled_back} 条到 ParadiseUser")
|
||||
migration_logs = AffinityLog.objects.filter(source='data_migration').iterator()
|
||||
for log in migration_logs:
|
||||
metadata = log.metadata or {}
|
||||
original_user_value = metadata.get('from_user_favorability')
|
||||
if original_user_value is None:
|
||||
skipped_no_metadata += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
user = ParadiseUser.objects.get(pk=log.user_id)
|
||||
except ParadiseUser.DoesNotExist:
|
||||
continue
|
||||
|
||||
user.favorability = original_user_value
|
||||
user.save(update_fields=['favorability'])
|
||||
rolled_back += 1
|
||||
|
||||
# 删除幂等标记(允许后续 forward 再次运行)
|
||||
deleted, _ = AffinityLog.objects.filter(source='data_migration').delete()
|
||||
|
||||
print(
|
||||
f"\n[migration 0006_migrate_favorability] backward: "
|
||||
f"回滚={rolled_back}, 删除标记={deleted}, 无 metadata 跳过={skipped_no_metadata}"
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
# Generated by Django 5.2.12 on 2026-05-13 02:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('userapp', '0006_migrate_favorability_to_userdevice'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='affinitylevel',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('min_affinity__lte', models.F('max_affinity'))), name='affinitylevel_min_le_max'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinitylevel',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('reward_currency__gte', 0)), name='affinitylevel_currency_nonneg'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinityrule',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('min_change__lte', models.F('max_change'))), name='affinityrule_min_le_max'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinityrule',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('cooldown_seconds__gte', 0)), name='affinityrule_cooldown_nonneg'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinityrule',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('single_cap__gt', 0)), name='affinityrule_single_cap_positive'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinityrule',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('daily_cap__gt', 0)), name='affinityrule_daily_cap_positive'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinityrule',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('trigger_type', 'companion_time'), _negated=True), models.Q(('min_continuous_minutes__gt', 0), ('max_count_per_day__gt', 0)), _connector='OR'), name='affinityrule_companion_fields_present'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinitysetting',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('decay_min_decay__lte', models.F('decay_max_decay'))), name='affinitysetting_decay_min_le_max'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinitysetting',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('decay_max_decay__lte', models.F('decay_cap'))), name='affinitysetting_decay_within_cap'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinitysetting',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('initial_affinity__lte', models.F('max_affinity'))), name='affinitysetting_initial_le_max'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinitysetting',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('decay_min_floor__lte', models.F('max_affinity'))), name='affinitysetting_floor_le_max'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinitysetting',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('daily_cap__gt', 0)), name='affinitysetting_daily_cap_positive'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinitysetting',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('pk', 1)), name='affinitysetting_singleton'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.12 on 2026-05-13 02:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('userapp', '0007_add_affinity_check_constraints'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='affinitylog',
|
||||
name='source',
|
||||
field=models.CharField(choices=[('device_event', '设备端事件'), ('mobile_event', '手机端事件'), ('system_decay', '系统衰减'), ('admin_adjust_single', '管理员单次调整'), ('admin_adjust_batch', '管理员批量调整'), ('data_migration', '数据迁移')], max_length=30, verbose_name='来源'),
|
||||
),
|
||||
]
|
||||
225
qy_lty/userapp/migrations/0009_affinity_p1_polish.py
Normal file
225
qy_lty/userapp/migrations/0009_affinity_p1_polish.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""好感度系统 P1 收尾综合迁移 — WR-002 / WR-003 / WR-004 / WR-008 / IN-001 / IN-003
|
||||
|
||||
操作内容:
|
||||
1. WR-002:UserLevelRewardGrant.device on_delete CASCADE → SET_NULL,加 device_snapshot_id,
|
||||
unique_together 改为 partial unique(device 非空时)
|
||||
2. WR-003:AffinityLog 精简索引(删除 user/rule_key/source 三个 -created_at 复合索引)
|
||||
3. WR-004:AffinityLog.event_id 改为 null=True;partial unique 条件由 event_id__gt='' 改为
|
||||
event_id__isnull=False;RunPython 把现有 '' 改为 NULL
|
||||
4. WR-008:ParadiseUser.favorability verbose_name 加 (已弃用) 标记
|
||||
5. IN-001:AffinityRule/AffinityLevel 5 个弃用字段 help_text 加 [DEPRECATED] 版本标记
|
||||
6. IN-003:AffinitySetting.daily_cap RenameField → global_daily_cap(保留数据)
|
||||
7. AffinityLevel.description / AffinityRule.description 显式 default=''(WR-006)
|
||||
|
||||
⚠️ 注意 IN-003 RenameField:
|
||||
Django makemigrations 默认会生成 RemoveField + AddField(**会丢失数据**)。
|
||||
本迁移**手工改为 RenameField**,PostgreSQL 下是元数据级 ALTER COLUMN RENAME(O(1) 锁)。
|
||||
rollback 同样安全(Django 自动反向 RenameField)。
|
||||
"""
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def event_id_empty_to_null_forward(apps, schema_editor):
|
||||
"""WR-004:把现有 event_id='' 的行改为 NULL,以适配新的 partial unique 条件"""
|
||||
AffinityLog = apps.get_model('userapp', 'AffinityLog')
|
||||
updated = AffinityLog.objects.filter(event_id='').update(event_id=None)
|
||||
print(f"\n[migration 0009_affinity_p1_polish] event_id '' → NULL: 更新 {updated} 行")
|
||||
|
||||
|
||||
def event_id_null_to_empty_backward(apps, schema_editor):
|
||||
"""回滚:NULL 改回 ''(反向兼容旧 schema)"""
|
||||
AffinityLog = apps.get_model('userapp', 'AffinityLog')
|
||||
updated = AffinityLog.objects.filter(event_id__isnull=True).update(event_id='')
|
||||
print(f"\n[migration 0009_affinity_p1_polish] event_id NULL → '' (rollback): 更新 {updated} 行")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('device_interaction', '0005_alter_userdevice_options'),
|
||||
('userapp', '0008_alter_affinitylog_source_choices'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# ====== 移除旧约束 / 索引 ======
|
||||
migrations.RemoveConstraint(
|
||||
model_name='affinitylog',
|
||||
name='unique_affinity_event_id',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='affinitysetting',
|
||||
name='affinitysetting_daily_cap_positive',
|
||||
),
|
||||
# WR-003:删除冗余索引
|
||||
migrations.RemoveIndex(
|
||||
model_name='affinitylog',
|
||||
name='userapp_aff_user_id_ab2869_idx',
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name='affinitylog',
|
||||
name='userapp_aff_rule_ke_6572b7_idx',
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name='affinitylog',
|
||||
name='userapp_aff_source_4f0798_idx',
|
||||
),
|
||||
# WR-002:先解除旧 unique_together 才能换 conditional unique
|
||||
migrations.AlterUniqueTogether(
|
||||
name='userlevelrewardgrant',
|
||||
unique_together=set(),
|
||||
),
|
||||
|
||||
# ====== IN-003 RenameField(保留数据,**不能**用 Remove+Add)======
|
||||
migrations.RenameField(
|
||||
model_name='affinitysetting',
|
||||
old_name='daily_cap',
|
||||
new_name='global_daily_cap',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='affinitysetting',
|
||||
name='global_daily_cap',
|
||||
field=models.IntegerField(
|
||||
default=20,
|
||||
help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)。'
|
||||
'与 AffinityRule.daily_cap(单规则上限)区分使用:P2 服务层需同时校验两者。',
|
||||
verbose_name='每日全局增长上限',
|
||||
),
|
||||
),
|
||||
|
||||
# ====== WR-002:UserLevelRewardGrant 加 device_snapshot_id + on_delete SET_NULL ======
|
||||
migrations.AddField(
|
||||
model_name='userlevelrewardgrant',
|
||||
name='device_snapshot_id',
|
||||
field=models.IntegerField(
|
||||
blank=True, db_index=True,
|
||||
help_text='发放时的 UserDevice pk 快照,device 被删后仍可审计',
|
||||
null=True, verbose_name='设备 ID 快照',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userlevelrewardgrant',
|
||||
name='device',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='level_reward_grants',
|
||||
to='device_interaction.userdevice',
|
||||
verbose_name='用户设备绑定',
|
||||
),
|
||||
),
|
||||
|
||||
# ====== WR-006:description 显式 default='' ======
|
||||
migrations.AlterField(
|
||||
model_name='affinitylevel',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, default='', verbose_name='等级描述'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='affinityrule',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, default='', verbose_name='规则描述'),
|
||||
),
|
||||
|
||||
# ====== IN-001:弃用字段 help_text 加 [DEPRECATED] 标记 ======
|
||||
migrations.AlterField(
|
||||
model_name='affinitylevel',
|
||||
name='required_points',
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_affinity/max_affinity 替代',
|
||||
verbose_name='所需积分(已弃用)',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='affinitylevel',
|
||||
name='rewards',
|
||||
field=models.JSONField(
|
||||
default=list,
|
||||
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 reward_currency/reward_items 替代',
|
||||
verbose_name='奖励(已弃用)',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='affinityrule',
|
||||
name='daily_limit',
|
||||
field=models.IntegerField(
|
||||
blank=True,
|
||||
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 daily_cap 替代',
|
||||
null=True, verbose_name='每日上限(已弃用)',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='affinityrule',
|
||||
name='is_active',
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 is_enabled 替代',
|
||||
verbose_name='已启用(已弃用)',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='affinityrule',
|
||||
name='points',
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_change/max_change 替代',
|
||||
verbose_name='积分(已弃用)',
|
||||
),
|
||||
),
|
||||
|
||||
# ====== WR-008:ParadiseUser.favorability 标记为弃用 ======
|
||||
migrations.AlterField(
|
||||
model_name='paradiseuser',
|
||||
name='favorability',
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
help_text='[DEPRECATED — P2 后删除] 已下沉到 UserDevice.favorability',
|
||||
verbose_name='好感度(已弃用)',
|
||||
),
|
||||
),
|
||||
|
||||
# ====== WR-004:event_id 改为 null=True + 数据兜底 + 新 partial unique ======
|
||||
migrations.AlterField(
|
||||
model_name='affinitylog',
|
||||
name='event_id',
|
||||
field=models.CharField(
|
||||
blank=True, db_index=True,
|
||||
help_text='客户端事件 UUID(建议格式:UUID v4)用于幂等去重;'
|
||||
'NULL 表示非客户端来源(衰减 / 管理员调整 / 数据迁移)',
|
||||
max_length=64, null=True, verbose_name='事件ID',
|
||||
),
|
||||
),
|
||||
# 数据兜底:先把现有 '' 改为 NULL,再加 partial unique(否则约束创建顺序无影响,
|
||||
# 但保险起见显式转换语义,避免未来读旧数据时混淆 '' 与 NULL)
|
||||
migrations.RunPython(
|
||||
event_id_empty_to_null_forward,
|
||||
event_id_null_to_empty_backward,
|
||||
),
|
||||
|
||||
# ====== 重建约束 ======
|
||||
migrations.AddConstraint(
|
||||
model_name='affinitylog',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('event_id__isnull', False)),
|
||||
fields=('event_id',),
|
||||
name='unique_affinity_event_id',
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='affinitysetting',
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(('global_daily_cap__gt', 0)),
|
||||
name='affinitysetting_daily_cap_positive',
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='userlevelrewardgrant',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('device__isnull', False)),
|
||||
fields=('device', 'level'),
|
||||
name='unique_grant_device_level',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,7 +1,8 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser, Group, Permission
|
||||
from django.db.models import UniqueConstraint, Q
|
||||
from django.db.models import CheckConstraint, F, Q, UniqueConstraint
|
||||
from rest_framework.authtoken.models import Token as DefaultToken
|
||||
|
||||
|
||||
@ -26,7 +27,17 @@ class ParadiseUser(AbstractUser):
|
||||
('ISTP', 'ISTP'), ('ISFP', 'ISFP'), ('ESTP', 'ESTP'), ('ESFP', 'ESFP'),
|
||||
)
|
||||
|
||||
favorability = models.IntegerField('好感度', default=0)
|
||||
# WR-008 [DEPRECATED — 计划于 P2 完成后删除]:
|
||||
# 好感度已下沉到 UserDevice.favorability(设备级),P1-09 数据迁移 0006 已搬数据。
|
||||
# 该字段保留是为了:
|
||||
# 1. backward 兼容(旧 fixtures / 老 API 客户端)
|
||||
# 2. 0006 backward 回滚目标(详见 0006 migration metadata)
|
||||
# 新代码**禁止**读写此字段;UserInfoSerializer 已从此字段移除暴露。
|
||||
# 移除计划:P2 服务层稳定上线 2 周后做迁移 RemoveField。
|
||||
favorability = models.IntegerField(
|
||||
'好感度(已弃用)', default=0,
|
||||
help_text='[DEPRECATED — P2 后删除] 已下沉到 UserDevice.favorability'
|
||||
)
|
||||
gender = models.CharField('性别', max_length=1, choices=GENDER_CHOICES, null=True, blank=True)
|
||||
resident_city = models.CharField('常驻城市', max_length=50, null=True, blank=True)
|
||||
birthday = models.DateField('生日', null=True, blank=True)
|
||||
@ -101,7 +112,7 @@ class AffinityRule(models.Model):
|
||||
help_text='代码标识,客户端事件通过此 key 匹配规则(如 chat/sing/dance/touch...)'
|
||||
)
|
||||
name = models.CharField('规则名称', max_length=100)
|
||||
description = models.TextField('规则描述', blank=True)
|
||||
description = models.TextField('规则描述', blank=True, default='')
|
||||
trigger_type = models.CharField(
|
||||
'触发类型', max_length=20, choices=TRIGGER_TYPE_CHOICES, default='action'
|
||||
)
|
||||
@ -145,18 +156,19 @@ class AffinityRule(models.Model):
|
||||
help_text='trigger_type=companion_time 时使用:每台设备每日最多触发次数'
|
||||
)
|
||||
|
||||
# 兼容旧字段(已弃用,下个版本删除)
|
||||
# 兼容旧字段(已弃用,计划于 P2 完成后删除 — 详见 docs/好感度系统-开发任务清单.md "P1 收尾")
|
||||
# IN-001:显式标注预期删除版本,便于后续 grep 清理;新代码禁止读写这些字段。
|
||||
points = models.IntegerField(
|
||||
'积分(已弃用)', default=0,
|
||||
help_text='已弃用,使用 min_change/max_change'
|
||||
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_change/max_change 替代'
|
||||
)
|
||||
daily_limit = models.IntegerField(
|
||||
'每日上限(已弃用)', null=True, blank=True,
|
||||
help_text='已弃用,使用 daily_cap'
|
||||
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 daily_cap 替代'
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
'已启用(已弃用)', default=True,
|
||||
help_text='已弃用,使用 is_enabled'
|
||||
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 is_enabled 替代'
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
@ -166,10 +178,56 @@ class AffinityRule(models.Model):
|
||||
verbose_name = '好感度规则'
|
||||
verbose_name_plural = '好感度规则'
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
CheckConstraint(
|
||||
check=Q(min_change__lte=F('max_change')),
|
||||
name='affinityrule_min_le_max',
|
||||
),
|
||||
CheckConstraint(
|
||||
check=Q(cooldown_seconds__gte=0),
|
||||
name='affinityrule_cooldown_nonneg',
|
||||
),
|
||||
CheckConstraint(
|
||||
check=Q(single_cap__gt=0),
|
||||
name='affinityrule_single_cap_positive',
|
||||
),
|
||||
CheckConstraint(
|
||||
check=Q(daily_cap__gt=0),
|
||||
name='affinityrule_daily_cap_positive',
|
||||
),
|
||||
# trigger_type='companion_time' 时必须配套 min_continuous_minutes / max_count_per_day
|
||||
CheckConstraint(
|
||||
check=(~Q(trigger_type='companion_time')) |
|
||||
(Q(min_continuous_minutes__gt=0) & Q(max_count_per_day__gt=0)),
|
||||
name='affinityrule_companion_fields_present',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.rule_key or '-'})"
|
||||
|
||||
def clean(self):
|
||||
"""Python 级兜底校验 — 在 admin / serializer 调用 full_clean() 时触发,
|
||||
给前端友好错误信息;DB 级 CheckConstraint 是最终防线(详见 Meta.constraints)。
|
||||
"""
|
||||
super().clean()
|
||||
errors = {}
|
||||
if self.min_change > self.max_change:
|
||||
errors['max_change'] = '最大变化值不能小于最小变化值'
|
||||
if self.cooldown_seconds < 0:
|
||||
errors['cooldown_seconds'] = '冷却时间不能为负数'
|
||||
if self.single_cap <= 0:
|
||||
errors['single_cap'] = '单次上限必须大于 0'
|
||||
if self.daily_cap <= 0:
|
||||
errors['daily_cap'] = '每日上限必须大于 0'
|
||||
if self.trigger_type == 'companion_time':
|
||||
if not self.min_continuous_minutes or self.min_continuous_minutes <= 0:
|
||||
errors['min_continuous_minutes'] = '陪伴时长规则必须设置 min_continuous_minutes > 0'
|
||||
if not self.max_count_per_day or self.max_count_per_day <= 0:
|
||||
errors['max_count_per_day'] = '陪伴时长规则必须设置 max_count_per_day > 0'
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
|
||||
class AffinityLevel(models.Model):
|
||||
"""好感度等级:定义不同好感度区间对应的等级和奖励
|
||||
@ -193,7 +251,7 @@ class AffinityLevel(models.Model):
|
||||
|
||||
level = models.IntegerField('等级', unique=True)
|
||||
name = models.CharField('等级名称', max_length=50)
|
||||
description = models.TextField('等级描述', blank=True)
|
||||
description = models.TextField('等级描述', blank=True, default='')
|
||||
|
||||
# 区间(P1-03)
|
||||
min_affinity = models.IntegerField(
|
||||
@ -222,14 +280,15 @@ class AffinityLevel(models.Model):
|
||||
is_enabled = models.BooleanField('已启用', default=True)
|
||||
is_deleted = models.BooleanField('已删除(软删除)', default=False)
|
||||
|
||||
# 兼容旧字段(已弃用)
|
||||
# 兼容旧字段(已弃用,计划于 P2 完成后删除)
|
||||
# IN-001:显式标注预期删除版本,便于后续 grep 清理;新代码禁止读写这些字段。
|
||||
required_points = models.IntegerField(
|
||||
'所需积分(已弃用)', default=0,
|
||||
help_text='已弃用,使用 min_affinity/max_affinity'
|
||||
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_affinity/max_affinity 替代'
|
||||
)
|
||||
rewards = models.JSONField(
|
||||
'奖励(已弃用)', default=list,
|
||||
help_text='已弃用,使用 reward_currency/reward_items'
|
||||
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 reward_currency/reward_items 替代'
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
@ -239,10 +298,57 @@ class AffinityLevel(models.Model):
|
||||
verbose_name = '好感度等级'
|
||||
verbose_name_plural = '好感度等级'
|
||||
ordering = ['level']
|
||||
constraints = [
|
||||
CheckConstraint(
|
||||
check=Q(min_affinity__lte=F('max_affinity')),
|
||||
name='affinitylevel_min_le_max',
|
||||
),
|
||||
CheckConstraint(
|
||||
check=Q(reward_currency__gte=0),
|
||||
name='affinitylevel_currency_nonneg',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Lv{self.level} {self.name}"
|
||||
|
||||
def clean(self):
|
||||
"""Python 级兜底校验 — 检查区间合法 + 与同表其它等级不重叠。
|
||||
|
||||
注:跨行不重叠约束 PostgreSQL CheckConstraint 表达不出(需 ExclusionConstraint +
|
||||
btree_gist 扩展),所以放在应用层;DB 仅约束单行 min ≤ max 一致性。
|
||||
详见审查报告 WR-009。
|
||||
"""
|
||||
super().clean()
|
||||
errors = {}
|
||||
if self.min_affinity > self.max_affinity:
|
||||
errors['max_affinity'] = '上限不能小于下限'
|
||||
if self.reward_currency < 0:
|
||||
errors['reward_currency'] = '虚拟货币奖励不能为负'
|
||||
# 与其它等级的区间重叠检查(同表多行,仅 Python 层校验)
|
||||
if not errors:
|
||||
overlaps = type(self).objects.exclude(pk=self.pk).filter(
|
||||
is_deleted=False,
|
||||
min_affinity__lte=self.max_affinity,
|
||||
max_affinity__gte=self.min_affinity,
|
||||
)
|
||||
if overlaps.exists():
|
||||
names = ', '.join(f'Lv{l.level}' for l in overlaps[:5])
|
||||
errors['min_affinity'] = f'与已存在等级 {names} 区间重叠'
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""save 前触发 full_clean,确保跨行约束(区间不重叠)在任何写入路径都生效。
|
||||
|
||||
注:迁移文件内使用历史模型(apps.get_model)不会触发此 save 重载,
|
||||
因此数据迁移可以批量写入而不被校验;正常业务代码 / admin / DRF 路径会校验。
|
||||
"""
|
||||
# 跳过自动 clean 的开关(迁移 / fixture / seed 场景可显式传 skip_clean=True)
|
||||
if not kwargs.pop('skip_clean', False):
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class AffinitySetting(models.Model):
|
||||
"""好感度系统全局参数(单例表)— P1-04
|
||||
@ -260,9 +366,12 @@ class AffinitySetting(models.Model):
|
||||
'最大好感度', default=100,
|
||||
help_text='好感度上限(管理员手动调整也不能突破)'
|
||||
)
|
||||
daily_cap = models.IntegerField(
|
||||
# IN-003:原名 daily_cap,因与 AffinityRule.daily_cap 同名易混淆,改为 global_daily_cap
|
||||
global_daily_cap = models.IntegerField(
|
||||
'每日全局增长上限', default=20,
|
||||
help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)'
|
||||
help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)。'
|
||||
'与 AffinityRule.daily_cap(单规则上限)区分使用:'
|
||||
'P2 服务层需同时校验两者。'
|
||||
)
|
||||
|
||||
# 衰减
|
||||
@ -296,20 +405,65 @@ class AffinitySetting(models.Model):
|
||||
class Meta:
|
||||
verbose_name = '好感度系统设置'
|
||||
verbose_name_plural = '好感度系统设置'
|
||||
constraints = [
|
||||
CheckConstraint(
|
||||
check=Q(decay_min_decay__lte=F('decay_max_decay')),
|
||||
name='affinitysetting_decay_min_le_max',
|
||||
),
|
||||
CheckConstraint(
|
||||
check=Q(decay_max_decay__lte=F('decay_cap')),
|
||||
name='affinitysetting_decay_within_cap',
|
||||
),
|
||||
CheckConstraint(
|
||||
check=Q(initial_affinity__lte=F('max_affinity')),
|
||||
name='affinitysetting_initial_le_max',
|
||||
),
|
||||
CheckConstraint(
|
||||
check=Q(decay_min_floor__lte=F('max_affinity')),
|
||||
name='affinitysetting_floor_le_max',
|
||||
),
|
||||
CheckConstraint(
|
||||
check=Q(global_daily_cap__gt=0),
|
||||
name='affinitysetting_daily_cap_positive',
|
||||
),
|
||||
# 单例硬约束:所有写入必须 pk=1(CHECK 约束不能跨行限制行数,
|
||||
# 但能强制任何 INSERT/UPDATE 的 pk 都是 1,配合 save() 强制 pk=1 形成事实单例)
|
||||
# 详见审查报告 WR-001
|
||||
CheckConstraint(
|
||||
check=Q(pk=1),
|
||||
name='affinitysetting_singleton',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"AffinitySetting(initial={self.initial_affinity}, max={self.max_affinity})"
|
||||
|
||||
def clean(self):
|
||||
"""Python 级兜底校验"""
|
||||
super().clean()
|
||||
errors = {}
|
||||
if self.decay_min_decay > self.decay_max_decay:
|
||||
errors['decay_max_decay'] = '单日衰减最大值不能小于最小值'
|
||||
if self.decay_max_decay > self.decay_cap:
|
||||
errors['decay_cap'] = '单日衰减上限不能小于最大值'
|
||||
if self.initial_affinity > self.max_affinity:
|
||||
errors['initial_affinity'] = '初始好感度不能超过最大好感度'
|
||||
if self.decay_min_floor > self.max_affinity:
|
||||
errors['decay_min_floor'] = '衰减下限不能超过最大好感度'
|
||||
if self.global_daily_cap <= 0:
|
||||
errors['global_daily_cap'] = '每日全局增长上限必须大于 0'
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 强制单例:新增时如果已有记录则覆盖到现有 pk
|
||||
if not self.pk and AffinitySetting.objects.exists():
|
||||
existing = AffinitySetting.objects.first()
|
||||
self.pk = existing.pk
|
||||
# 强制单例:固定 pk=1(WR-001 升级版 — 配合 DB CHECK pk=1 约束防并发重复行)
|
||||
if not self.pk:
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_solo(cls):
|
||||
"""获取单例实例,不存在则用默认值创建"""
|
||||
"""获取单例实例,不存在则用默认值创建(强制 pk=1)"""
|
||||
instance, _ = cls.objects.get_or_create(pk=1)
|
||||
return instance
|
||||
|
||||
@ -328,6 +482,8 @@ class AffinityLog(models.Model):
|
||||
('system_decay', '系统衰减'),
|
||||
('admin_adjust_single', '管理员单次调整'),
|
||||
('admin_adjust_batch', '管理员批量调整'),
|
||||
# P1 收尾 CR-003:data migration 用作 0006 幂等标记
|
||||
('data_migration', '数据迁移'),
|
||||
)
|
||||
|
||||
# 关联对象
|
||||
@ -357,9 +513,12 @@ class AffinityLog(models.Model):
|
||||
|
||||
# 来源
|
||||
source = models.CharField('来源', max_length=30, choices=SOURCE_CHOICES)
|
||||
# WR-004:event_id 用 NULL 表示「无客户端事件 ID」(衰减 / 管理员调整等),
|
||||
# 替代旧的 '' 空字符串约定(避免空格 / 'null' 字符串等绕过 partial unique)
|
||||
event_id = models.CharField(
|
||||
'事件ID', max_length=64, blank=True, db_index=True,
|
||||
help_text='客户端事件 UUID,用于幂等去重'
|
||||
'事件ID', max_length=64, null=True, blank=True, db_index=True,
|
||||
help_text='客户端事件 UUID(建议格式:UUID v4)用于幂等去重;'
|
||||
'NULL 表示非客户端来源(衰减 / 管理员调整 / 数据迁移)'
|
||||
)
|
||||
|
||||
# 管理员调整专用
|
||||
@ -384,22 +543,27 @@ class AffinityLog(models.Model):
|
||||
verbose_name = '好感度变化日志'
|
||||
verbose_name_plural = '好感度变化日志'
|
||||
ordering = ['-created_at']
|
||||
# WR-003:精简索引以降低写入开销
|
||||
# 保留:(device, -created_at) — 客户端拉取最近变化主路径
|
||||
# 保留:created_at 单字段(auto_now_add 加 db_index=True 已生成,作管理后台时间排序)
|
||||
# 删除:(user, -created_at) — 可经 device 关联查询替代
|
||||
# 删除:(rule_key, -created_at) — 查询频率低
|
||||
# 删除:(source, -created_at) — 低基数列,配合 created_at 索引扫描即可
|
||||
indexes = [
|
||||
models.Index(fields=['device', '-created_at']),
|
||||
models.Index(fields=['user', '-created_at']),
|
||||
models.Index(fields=['rule_key', '-created_at']),
|
||||
models.Index(fields=['source', '-created_at']),
|
||||
]
|
||||
constraints = [
|
||||
# WR-004:event_id 改用 NULL 语义,partial unique 条件改为 isnull=False
|
||||
UniqueConstraint(
|
||||
fields=['event_id'],
|
||||
condition=Q(event_id__gt=''),
|
||||
condition=Q(event_id__isnull=False),
|
||||
name='unique_affinity_event_id',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.id} {self.rule_key or self.source} {self.before_value}->{self.after_value}"
|
||||
# IN-004:未保存时 self.pk 为 None,回退到 'new' 避免显示 #None
|
||||
return f"#{self.pk or 'new'} {self.rule_key or self.source} {self.before_value}->{self.after_value}"
|
||||
|
||||
|
||||
class UserAffinityDailyCounter(models.Model):
|
||||
@ -447,11 +611,21 @@ class UserLevelRewardGrant(models.Model):
|
||||
(device, level) 唯一,永久幂等防止重复发放。
|
||||
衰减回升再次跨过同等级也不补发;新增等级时已在区间的设备不立即触发。
|
||||
reward_snapshot 保存发放时的奖励内容快照,避免 AffinityLevel 后续修改影响审计。
|
||||
|
||||
WR-002 修正:
|
||||
- on_delete 改为 SET_NULL(与 AffinityLog.device 保持一致 — 历史保留)
|
||||
- 新增 device_snapshot_id 冗余字段,device 被删后仍可追溯原 device pk
|
||||
- unique_together 改为条件唯一约束(device IS NULL 时不参与唯一性)
|
||||
"""
|
||||
|
||||
device = models.ForeignKey(
|
||||
'device_interaction.UserDevice', on_delete=models.CASCADE,
|
||||
verbose_name='用户设备绑定', related_name='level_reward_grants'
|
||||
'device_interaction.UserDevice', on_delete=models.SET_NULL,
|
||||
verbose_name='用户设备绑定', related_name='level_reward_grants',
|
||||
null=True, blank=True,
|
||||
)
|
||||
device_snapshot_id = models.IntegerField(
|
||||
'设备 ID 快照', null=True, blank=True, db_index=True,
|
||||
help_text='发放时的 UserDevice pk 快照,device 被删后仍可审计'
|
||||
)
|
||||
level = models.IntegerField('等级')
|
||||
granted_at = models.DateTimeField('发放时间', auto_now_add=True)
|
||||
@ -463,8 +637,22 @@ class UserLevelRewardGrant(models.Model):
|
||||
class Meta:
|
||||
verbose_name = '等级奖励发放记录'
|
||||
verbose_name_plural = '等级奖励发放记录'
|
||||
unique_together = [('device', 'level')]
|
||||
ordering = ['-granted_at']
|
||||
constraints = [
|
||||
# device 非空时 (device, level) 唯一;device 已删除(NULL)的历史记录不参与唯一
|
||||
UniqueConstraint(
|
||||
fields=['device', 'level'],
|
||||
condition=Q(device__isnull=False),
|
||||
name='unique_grant_device_level',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.device_id}/Lv{self.level}@{self.granted_at}"
|
||||
ref = self.device_id if self.device_id is not None else f'snap:{self.device_snapshot_id}'
|
||||
return f"{ref}/Lv{self.level}@{self.granted_at}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 首次保存时自动填充 device_snapshot_id(device 被删后仍可追溯原 pk)
|
||||
if self.device_id and not self.device_snapshot_id:
|
||||
self.device_snapshot_id = self.device_id
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@ -23,11 +23,14 @@ class UserInfoSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
用于展示用户信息的序列化器
|
||||
用户自己查看
|
||||
|
||||
WR-008:移除已弃用的 favorability 字段(已下沉到 UserDevice.favorability)。
|
||||
前端需要显示好感度时,应通过设备级接口查询 UserDevice 列表。
|
||||
"""
|
||||
class Meta:
|
||||
model = ParadiseUser
|
||||
fields = ['id', 'username', 'email', 'phone_number', 'date_joined', 'last_login',
|
||||
'favorability', 'gender', 'resident_city', 'birthday', 'zodiac_sign',
|
||||
fields = ['id', 'username', 'email', 'phone_number', 'date_joined', 'last_login',
|
||||
'gender', 'resident_city', 'birthday', 'zodiac_sign',
|
||||
'mbti', 'interests', 'social_identity']
|
||||
read_only_fields = ['id', 'date_joined', 'last_login']
|
||||
|
||||
|
||||
@ -116,8 +116,9 @@ class MacAddressLoginView(APIView):
|
||||
device = Device.objects.get(mac_address=mac_address)
|
||||
|
||||
# 检查设备是否已绑定给用户
|
||||
# 按绑定时间倒序取最新绑定者作为设备归属人——"后绑的挤掉先绑的"
|
||||
user_device = UserDevice.objects.filter(device=device).order_by('-bound_at').first()
|
||||
# 按绑定时间倒序取最新「有效」绑定者作为设备归属人——"后绑的挤掉先绑的"
|
||||
# 使用 active manager 显式过滤 is_bound=True,忽略软删历史绑定(P1 收尾 CR-001)
|
||||
user_device = UserDevice.active.filter(device=device).order_by('-bound_at').first()
|
||||
if not user_device:
|
||||
logger.warning(f"Device not bound to any user: {mac_address}")
|
||||
# 返回特定 code=4010,让设备端可以识别"未绑定"状态
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user