2 Commits

Author SHA1 Message Date
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
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