Compare commits

...

8 Commits

Author SHA1 Message Date
pmc
cc8ffee168 docs(affinity-P2): 修改记录与任务清单同步 P2 完成状态
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 8m15s
- qy_lty/docs/修改记录.md 顶部追加 P2 阶段条目:service 层 6 模块 + admin API 7 视图的详细产出物清单 + 跨项目联动注意事项
- docs/好感度系统-开发任务清单.md:
  - P2-01 ~ P2-12 状态从  改为 ,每条补充实际产出物路径与验证标准
  - 变更记录加 P2 完成条目,记录 6 项 smoke test 全 PASS 与下一步 P3 指引

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:36:20 +08:00
pmc
7c79b72544 feat(affinity-P2): admin API — Rule/Level CRUD + Setting + Logs + Stats + Devices + Adjust (P2-06~P2-12)
新增 admin 管理端完整 API,挂载在 /api/v1/admin/affinity/ 路径下:

- serializers.py:9 个序列化器
  - AffinityRuleSerializer / AffinityLevelSerializer / AffinitySettingSerializer
    含跨字段 validate(min/max 关系、区间重叠、衰减区间、companion_time 字段必填等)
  - AffinityLogSerializer 只读 + 关联字段展开(user_username/device_code/rule_name)
  - UserDeviceAffinitySerializer 含 device_code/mac/status/level_name
  - AffinityAdjust + AffinityAdjustBatch 用 Serializer 而非 ModelSerializer
  - permissions.py 中 IsAdminUserStaff 复用,所有 view 默认 RedisTokenAuthentication + IsAdminUserStaff

- views.py:7 个视图
  - AffinityRuleAdminViewSet (P2-06):ModelViewSet + 软删 (is_deleted+is_enabled=False) + restore action;?include_deleted=true 返回全集
  - AffinityLevelAdminViewSet (P2-07):同上软删;serializer 跨字段校验区间重叠
  - AffinitySettingView (P2-08):APIView 单例 GET/PUT/PATCH;pk=1 硬约束
  - AffinityLogListView (P2-09):过滤 user_id/device_id/rule_key/source/date_from/date_to;分页 page_size 上限 200;select_related 防 N+1
  - AffinityStatsView (P2-10):avg/max/top_count/active_7d/total_devices/today_interactions/today_change_sum/rule_freq_top/level_distribution;全部基于 UserDevice.active 聚合;今日按 AffinitySetting.timezone 取 local date
  - UserAffinityDevicesView (P2-11):?user_id= 必传 + 404 校验;?include_unbound=true 含历史;默认仅 is_bound=True
  - AffinityAdjustView + AffinityAdjustBatchView (P2-12):委托 AffinityService.admin_adjust;批量遍历 UserDevice.active 逐台调用,返回 per-device 结果数组

- urls.py:DRF DefaultRouter 注册 rules/levels CRUD + 5 个独立 path 挂 settings/logs/stats/devices/adjust*
- admin_urls.py:引入 include 并新增 path('affinity/', include('userapp.affinity.urls'))

Django check 通过,6 URL reverse 全部解析正确:
  /api/v1/admin/affinity/settings/
  /api/v1/admin/affinity/logs/
  /api/v1/admin/affinity/stats/
  /api/v1/admin/affinity/devices/
  /api/v1/admin/affinity/adjust/
  /api/v1/admin/affinity/adjust-batch/

旧的 /api/user/affinity-rules/ 与 /affinity-levels/ 暂保留兼容,前端切到 admin 后即可清理。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:36:11 +08:00
pmc
f26e78c545 feat(affinity-P2): service 层落地 — 唯一写入入口 + Redis 计数器 + 等级映射 + 跨级奖励 + WS 推送 (P2-01~P2-05)
新增 6 个模块,把好感度变化的全部副作用收敛到一个调用入口:

- counters.py (P2-02):Redis 三类计数器
  - affinity💿{device_id}:{rule_key} 冷却
  - affinity:daily:{device_id}:{rule_key}:{YYYYMMDD} 单规则日上限
  - affinity:daily:{device_id}:_global:{YYYYMMDD} 全局正向日上限
  - 自然日按 AffinitySetting.timezone (Asia/Shanghai 默认) 通过 zoneinfo 计算
  - cache.add + cache.incr 实现 set-if-not-exists + atomic-incr 语义,TTL 48h
  - event_id 60s 去重防客户端重复上报

- levels.py (P2-03):等级映射
  - map_value_to_level / update_device_level / progress_to_next_level
  - update_device_level 仅 level 变化时 save(update_fields=['affinity_level'])

- ws.py (P2-05):WebSocket 推送 helper
  - 3 类事件 affinity_update / level_up / level_down
  - asgiref.async_to_sync 包装 channel_layer.group_send
  - 推送故障 fire-and-forget 仅日志记录,不阻塞主流程

- rewards.py (P2-04):跨级奖励发放(A3 方案 B)
  - grant_levels(user_device, from_level, to_level) 逐级独立事务
  - UserLevelRewardGrant 唯一约束保证幂等(决策 11:衰减回升不补发)
  - _dispatch_reward_to_external_systems 是 STUB,P3/P4 接虚拟货币/道具 app 时实现

- services.py (P2-01):AffinityService 主入口
  - apply(user_id, device_id, rule_key, source, event_id, metadata, operator_admin_id, reason)
  - 10 步流水线 [event_id 去重 → 取规则 → 冷却 → 取 UserDevice.active → 计算 + single_cap 钳位 → 规则日上限 → 全局日上限 → 原子写库 → Redis 累加 → 奖励 → WS 推送]
  - admin_adjust 绕过 rule 与冷却,但走 [0, max_affinity] 钳位 + log + 等级缓存 + 奖励 + WS
  - 返回 ApplyResult dataclass 含 ApplyOutcome 枚举(applied / noop_no_rule / noop_cooldown / noop_*_daily_cap / noop_event_duplicate / noop_value_boundary / error)

- permissions.py:IsAdminUserStaff 复用 IsAuthenticated + is_staff 检查

Smoke test 6 项全 PASS:no_rule / chat applied / event_id 去重 / 冷却拦截 / admin_adjust / max_affinity 钳位。
AffinityLog 写库 / UserLevelRewardGrant 幂等 / level 缓存更新 均经事务原子保证。

设计依据:docs/好感度系统功能与规则设计.md §4.3 触发流程 + §6 等级规则 + §9 数据契约。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:35:53 +08:00
pmc
f66e2dfc86 docs(affinity-P1): 归档代码审查报告与修复报告
P1 审查阶段产物补落库(深度审查 + 修复同步过程中漏 commit):

- docs/REVIEW-affinity-P1.md — gsd-code-reviewer 输出,18 项 finding(3 Critical / 9 Warning / 6 Info),含 Cross-Module 调用链分析与 Cross-App FK 一致性表
- docs/REVIEW-affinity-P1-FIX-REPORT.md — gsd-code-fixer 输出,17 FIXED + 1 PARTIAL (WR-008) + 0 SKIPPED 状态明细,对应 4 个修复 commit A/B/C/D 索引

两份报告与 P1 fix commits (33b302c / 9a87f5e / 2a28aa8 / 61e8374) 配套阅读,为 P2 service 层依赖的设计决策 / DB 约束 / 软删语义提供溯源依据。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:35:31 +08:00
pmc
61e8374e6a fix(affinity-P1): WR-002~WR-009 + IN-001~IN-006 综合改进收尾
WR-002 UserLevelRewardGrant.device on_delete CASCADE → SET_NULL,加 device_snapshot_id,
  unique 改为 partial(device 非空时唯一),与 AffinityLog.device SET_NULL 对齐
WR-003 AffinityLog 删除 3 个低价值索引(user/rule_key/source -created_at 复合)
WR-004 event_id 改为 null=True,partial unique 用 isnull=False;RunPython '' → NULL
WR-005 seed 加 companion_30min 默认规则
WR-006 description 显式 default='';DEFAULT_LEVELS 全部补 description
WR-007 seed_affinity 每条 spec 独立事务,部分失败可重跑
WR-008 ParadiseUser.favorability 字段保留 + UserInfoSerializer 移除暴露 + [DEPRECATED] 标记
WR-009(见 Commit B:AffinityLevel.clean + save full_clean 多层兜底)
IN-001 5 个弃用字段 help_text 加 [DEPRECATED — 计划于 P2 完成后删除]
IN-002 DEFAULT_RULES/LEVELS/SETTING 抽到 userapp/affinity/defaults.py
IN-003 AffinitySetting.daily_cap RenameField → global_daily_cap(区分 AffinityRule.daily_cap)
IN-004 AffinityLog.__str__ 用 pk or 'new' 兜底 None
IN-005(见 Commit A:is_active → is_bound 改名)
IN-006(见 Commit C:0006 print 前缀改为 [migration 0006_...])

迁移 0009 手工修正:daily_cap 改名用 RenameField(保留数据),不是 Remove+Add;
event_id '' → NULL 数据兜底;UserLevelRewardGrant on_delete + conditional unique 重建。

详见 docs/REVIEW-affinity-P1.md WR-* / IN-* 与 FIX-REPORT.md。
2026-05-13 10:18:47 +08:00
pmc
2a28aa8b28 fix(affinity-P1): CR-003 修正 0006 数据迁移幂等性
旧 forward 用 target.favorability == 10 判断"未迁移",10 既是初始值也是衰减
常见值,重跑会覆盖合法数据;backward 用 != 10 反向判断会丢衰减回 10 的数据。

改用 AffinityLog source='data_migration' 标记做幂等:
  - forward 写入新值时同步写一条 audit log,metadata 含原 ParadiseUser 值
  - backward 遍历 audit log 反向恢复并删除标记,保证 forward/backward 可循环

同步:AffinityLog.SOURCE_CHOICES 追加 'data_migration' + 0008 AlterField 迁移
更新 Python 端 choices 校验。

option B 选择:直接重写 0006(dev 已跑过但 migrate_count=0 等于未动数据,
django_migrations 表已记录完成不会再跑)。生产部署前需确认 prod 未跑过 0006,
否则需 fake-reverse 流程,详见迁移文件 docstring 与 FIX-REPORT。

详见 docs/REVIEW-affinity-P1.md CR-003。
2026-05-13 10:13:31 +08:00
pmc
9a87f5e2b5 fix(affinity-P1): CR-002 + WR-001 加 Affinity 模型 DB CHECK 约束 + 单例硬约束
AffinityRule / AffinityLevel / AffinitySetting 三表共 13 条 CheckConstraint
覆盖 min ≤ max / 各类 cap > 0 / cooldown ≥ 0 / companion_time 配套字段必填 /
decay 区间合法 / initial ≤ max 等不变量。

AffinitySetting 加 pk=1 单例硬约束(CR-002 + WR-001 联动)+ save() 强制 pk=1,
形成事实单例防御并发首次插入重复行。

模型 clean() 提供 Python 级兜底(给 DRF / admin 友好错误信息);
AffinityLevel.save() 自动 full_clean 触发跨行区间不重叠校验(为 WR-009 铺路)。

详见 docs/REVIEW-affinity-P1.md CR-002 / WR-001。
2026-05-13 10:12:01 +08:00
pmc
33b302c773 fix(affinity-P1): CR-001 + IN-005 修复 UserDevice 软删语义 + is_bound 改名
UserDevice.is_active 改名为 is_bound(消除与 Device.is_active 的命名冲突),
新增 ActiveUserDeviceManager(active manager),4 处控制权解析调用点
(MAC 登录、bind_status、绑定校验、RTC token、绑定 endpoint)切换到
UserDevice.active.filter(...),避免 P2 软删后旧绑定者被签发 user-token、
WS 分组路由错误、RTC 房间归属错乱等安全 / 越权风险。

base_manager_name='objects' 保证 admin 默认 queryset 不受 active 过滤影响。

详见 docs/REVIEW-affinity-P1.md CR-001 / IN-005。
2026-05-13 10:10:14 +08:00
30 changed files with 3974 additions and 221 deletions

View 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 / 183 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~0008prod 第一次 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-001UserDevice 控制权解析未过滤 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 manageradmin / 反向关系user.devices仍走全集 managerCLAUDE.md 加入硬规则确保后续开发不退化。
### CR-002AffinityRule/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-0030006 数据迁移幂等性脆弱
**状态**: 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-001AffinitySetting.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-002UserLevelRewardGrant.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 改为 partialdevice 非空时save() 自动填充 snapshot
- `qy_lty/userapp/migrations/0009_affinity_p1_polish.py` — AlterField + AddField + UniqueConstraint 重建
**应用的修复**: 与 AffinityLog.device SET_NULL 对齐(历史保留语义),同时保留 device_snapshot_id 用于审计。
### WR-003AffinityLog 索引过多
**状态**: 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-004event_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-005seed_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-006description 字段未显式 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-007seed_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-008ParadiseUser.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-009AffinityLevel 区间允许重叠 / 留空隙
**状态**: FIXED
**Commit**: `9a87f5e` (Commit B — 与 CR-002 同 commit应用层多层兜底)
**修改文件**:
- `qy_lty/userapp/models.py` — AffinityLevel.clean() 检查 min ≤ max + 与其它等级区间不重叠exclude is_deleted=Truesave() 自动调 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_activeAffinityLevel.required_points / rewardshelp_text 加 `[DEPRECATED — 计划于 P2 完成后删除]`
- `qy_lty/userapp/migrations/0009_affinity_p1_polish.py` — AlterField × 5
### IN-002DEFAULT_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-003AffinityRule.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-004AffinityLog.__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-005UserDevice.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-0060006 数据迁移 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` — PASSmodels.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())" # 应 0dev 库无成功迁移)
```
---
## 跨文件 / 跨调用点一致性检查
### `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
View 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_found3 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-001UserDevice 既有"最新绑定者"取数逻辑未过滤 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-002AffinityRule / 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-0030006 数据迁移用 `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 Falseforward 写入时设为 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-001AffinitySetting.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)
```
在多 workergunicorn / 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-002UserAffinityDailyCounter / 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-003AffinityLog 索引设计偏重,写入开销可能放大 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-004unique_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-005seed_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-006AffinityLevel.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`)或加 uniqueseed 会一次性失败。
**Fix:**
显式补齐:
```python
DEFAULT_LEVELS = [
{
'level': 1, 'name': '初识',
'description': '初次相识阶段,了解彼此的基础阶段', # 显式
# ...
},
...
]
```
模型侧也建议补 `default=''`
```python
description = models.TextField('等级描述', blank=True, default='')
```
`AffinityRule.description` 同样问题。
**影响范围:**
- 健壮性
- 未来 schema 演进
---
### WR-007seed_affinity @transaction.atomic 包整个 handleforce 模式下部分失败会回滚所有已处理项
**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-008ParadiseUser.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每次访问都查 devicesP2 后端的 serializer 应该改为直接展示 UserDevice 列表而不是 ParadiseUser.favorability。在做 property 改造前,先全文搜索 `favorability` 字段被哪里读写:
```
grep -rn 'favorability' qy_lty/ --include='*.py'
```
并清单化每个调用点的修复计划。
---
### WR-009AffinityLevel 区间允许重叠 / 留空隙,等级匹配结果不确定
**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-001AffinityRule 旧字段 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-002DEFAULT_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-003AffinityRule.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-004AffinityLog __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-005UserDevice.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-0060006 数据迁移 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 1MAC 登录 → 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 → 幂等失效边界 caseWR-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_

View File

@ -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 项全 PASSchat/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 STUBP3/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_sendfire-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 上限 200select_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 SKIPPED4 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 APIrules/levels/settings/logs/stats/devices/adjust×2落地userapp/admin_urls.py 挂载 `/api/v1/admin/affinity/...`Django check 通过6 URL reverse 解析正确service 6 项 smoke test 全 PASSapplied/no_rule/cooldown/event_dup/admin_adjust/clamp存量数据未污染测试设备已重置。可进入 P3 管理端前端接通。 | Claude |

View File

@ -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` 中被硬编码跳过"设备已被其他用户绑定"校验,仅供测试用

View File

@ -0,0 +1,44 @@
"""把 UserDevice.is_active 改名为 is_bound — P1 收尾 IN-005 + CR-001
背景
UserDevice.is_activeP1-08 引入表示"绑定关系有效"软删除标记 Device.is_active
表示"设备已激活"命名冲突select_related('device') ud.device.is_active vs
ud.is_active 语义截然不同极易拼错审查报告 IN-005 CR-001 显式建议合并修复
迁移行为
RenameField PostgreSQL 上是元数据级 ALTER TABLE RENAME COLUMNO(1)
不需要数据拷贝现有 is_active=True 的行经此操作变为 is_bound=True
注意
模型层同步引入 ActiveUserDeviceManageractive 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_activeP1-08 引入),因与 Device.is_active'
'(设备激活态)命名冲突,于 P1 收尾改名为 is_bound。',
verbose_name='绑定有效',
),
),
]

View File

@ -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': '用户设备'},
),
]

View File

@ -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 也用此 managerMeta.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_activeP1-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}"

View File

@ -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

View File

@ -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=TrueCR-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

View File

@ -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_downasgiref 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 暂为 STUBP3/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_nameAffinityAdjust 与 AffinityAdjustBatch 用 Serializer 而非 ModelSerializer
- `userapp/affinity/permissions.py`**新建** — IsAdminUserStaff 复用 IsAuthenticated 并加 is_staff 检查)
- `userapp/affinity/views.py`**新建** — 7 个视图AffinityRuleAdminViewSet / AffinityLevelAdminViewSet ModelViewSet + 软删 perform_destroy + restore actionAffinitySettingView APIView 单例 GET/PUT/PATCHAffinityLogListView 含 user/device/rule/source/date_range/分页过滤AffinityStatsView 聚合 avg/max/top_count/active_7d/today_interactions/rule_freq_top/level_distributionUserAffinityDevicesView 按 user_id 展开设备列表CR-001 默认仅返回 is_bound=TrueAffinityAdjustView + 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_downchannel_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_sizepage_size 上限 200
- **P2-10 stats**:所有指标基于 `UserDevice.active`is_bound=True聚合今日数据按 AffinitySetting.timezone 取 local daterule_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和客户端 APIP5都依赖 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 uniqueAffinitySetting daily_cap → global_daily_capdescription 显式 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+Addevent_id `''` → NULL 数据兜底 RunPythonUserLevelRewardGrant 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=4min/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_activeAffinityLevel.required_points / rewardshelp_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-003Critical**:旧 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_CHOICESPython 校验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=1imports 段补 CheckConstraint / F / ValidationError
- `userapp/migrations/0007_add_affinity_check_constraints.py`**新建** — 由 makemigrations 自动生成13 条 AddConstraint
- **修改类型**: 新增 + 修复Bug
- **修改内容**:
- **CR-002Critical**
- 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-001Warning**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-001Critical**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 RENAMEO(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/)

View File

@ -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')),
# 后续可以添加更多管理员专用接口
]

View File

@ -0,0 +1,5 @@
"""好感度系统业务包
子模块
defaults 默认规则 / 等级 / 设置常量 seed_affinity / 单元测试 / P2 服务层复用
"""

View 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)

View 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',
}

View 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)
若没匹配到任何 AffinityLevelnew_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

View 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)

View 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

View 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

View 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:
"""管理员手动调整。
- 不查 AffinityRulerule=NULLrule_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,
)

View 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)),
]

View 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=Trueis_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=Falseservice 层基于这两个标记拒绝触发)"""
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=Trueinclude_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

View 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,
})

View File

@ -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

View File

@ -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):

View File

@ -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'),
),
]

View File

@ -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='来源'),
),
]

View File

@ -0,0 +1,225 @@
"""好感度系统 P1 收尾综合迁移 — WR-002 / WR-003 / WR-004 / WR-008 / IN-001 / IN-003
操作内容
1. WR-002UserLevelRewardGrant.device on_delete CASCADE SET_NULL device_snapshot_id
unique_together 改为 partial uniquedevice 非空时
2. WR-003AffinityLog 精简索引删除 user/rule_key/source 三个 -created_at 复合索引
3. WR-004AffinityLog.event_id 改为 null=Truepartial unique 条件由 event_id__gt='' 改为
event_id__isnull=FalseRunPython 把现有 '' 改为 NULL
4. WR-008ParadiseUser.favorability verbose_name (已弃用) 标记
5. IN-001AffinityRule/AffinityLevel 5 个弃用字段 help_text [DEPRECATED] 版本标记
6. IN-003AffinitySetting.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 RENAMEO(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-002UserLevelRewardGrant 加 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-006description 显式 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-008ParadiseUser.favorability 标记为弃用 ======
migrations.AlterField(
model_name='paradiseuser',
name='favorability',
field=models.IntegerField(
default=0,
help_text='[DEPRECATED — P2 后删除] 已下沉到 UserDevice.favorability',
verbose_name='好感度(已弃用)',
),
),
# ====== WR-004event_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',
),
),
]

View File

@ -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=1CHECK 约束不能跨行限制行数,
# 但能强制任何 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=1WR-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-003data migration 用作 0006 幂等标记
('data_migration', '数据迁移'),
)
# 关联对象
@ -357,9 +513,12 @@ class AffinityLog(models.Model):
# 来源
source = models.CharField('来源', max_length=30, choices=SOURCE_CHOICES)
# WR-004event_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-004event_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_iddevice 被删后仍可追溯原 pk
if self.device_id and not self.device_snapshot_id:
self.device_snapshot_id = self.device_id
super().save(*args, **kwargs)

View File

@ -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']

View File

@ -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让设备端可以识别"未绑定"状态