diff --git a/docs/好感度系统-开发任务清单.md b/docs/好感度系统-开发任务清单.md new file mode 100644 index 0000000..0f17945 --- /dev/null +++ b/docs/好感度系统-开发任务清单.md @@ -0,0 +1,145 @@ +# 好感度系统 — 开发任务清单 + +> 配套文档:[好感度系统功能与规则设计.md](./好感度系统功能与规则设计.md) +> +> 维护说明:每完成一个子任务,把状态标记为 ✅ 并附上 commit hash。需求变更时直接修订本文,决策点的最新结论以《设计》文档「决策记录」为准。 + +--- + +## 总览 + +| 阶段 | 目标 | 子任务数 | 依赖 | 关联待敲定项 | +|---|---|---|---|---| +| **P1** | 数据模型扩展 + 迁移 | 10 | — | — | +| **P2** | Service 层 + 管理端 API | 12 | P1 | A3(跨级事务,建模时用默认) | +| **P3** | 管理端前端接通(拆 mock) | 8 | P2 | — | +| **P4** | 触发点埋点(设备/手机事件接入 service) | 7 | P2 | — | +| **P5** | 客户端 API + 衰减定时任务 | 6 | P2、P4 | A1(解绑重绑)、A4(通知通道) | +| **P6** | 观测、性能、并发加固 | 5 | P4 | — | + +**子任务合计:48 项** + +每个子任务独立可提交、可回滚(atomic commit 粒度)。 + +--- + +## P1 数据模型扩展 + 迁移 + +**目标**:所有 schema 变更落地,迁移脚本能跑通存量数据,**不写业务逻辑**。 + +| # | 子任务 | 产出物 | 验收标准 | 状态 | +|---|---|---|---|---| +| P1-01 | `AffinityRule` 字段扩展 | 新增字段:`rule_key`、`trigger_type`、`min_change`、`max_change`、`single_cap`、`daily_cap`、`cooldown_seconds`、`is_negative`、`is_enabled`、`is_deleted` | migration 通过;`rule_key` 唯一索引;旧字段 `points`/`daily_limit` 标记 deprecated 并保留一个版本 | ✅ | +| P1-02 | `AffinityRule` 陪伴时长字段 | 同表加字段 `min_continuous_minutes`、`max_count_per_day`(仅 `trigger_type=companion_time` 时使用) | migration 通过;nullable | ✅ | +| P1-03 | `AffinityLevel` 字段扩展 | 新增 `min_affinity`、`max_affinity`、`unlock_content`、`reward_type`、`reward_currency`、`reward_items`(JSON)、`is_enabled`、`is_deleted` | migration 通过;旧字段 `required_points` 保留 | ✅ | +| P1-04 | 新建 `AffinitySetting`(单例表) | 字段:`initial_affinity`、`max_affinity`、`daily_cap`、`decay_rate`、`decay_threshold`、`enable_notify`、`enable_rewards`、`timezone`、衰减相关字段 | migration 通过;初始化一行默认数据 | ✅ | +| P1-05 | 新建 `AffinityLog` | 字段:`user`、`device`、`rule`(SET NULL)、`rule_key`(冗余文本)、`change_value`、`before_value`、`after_value`、`source`、`event_id`、`operator_admin_id`、`reason`、`metadata`(JSON)、`created_at` | migration 通过;`(device, created_at)` 索引;`event_id` 唯一去重 | ✅ | +| P1-06 | 新建 `UserAffinityDailyCounter`(DB 兜底) | 字段:`device`、`rule`、`date`、`accumulated_change`、`trigger_count`,唯一 `(device, rule, date)` | migration 通过;热路径仍走 Redis,每晚凌晨从 Redis 落库 | ✅ | +| P1-07 | 新建 `UserLevelRewardGrant` | 字段:`device`、`level`、`granted_at`、`reward_snapshot`(JSON),唯一 `(device, level)` | migration 通过 | ✅ | +| P1-08 | `UserDevice` 加字段 | 新增 `favorability`(int, default=10)、`affinity_level`(int, default=1)、`last_active_at`(datetime)、`is_active`(bool, default=true) | migration 通过;保留软删除选项 | ✅ | +| P1-09 | 数据迁移脚本 | management command:把 `ParadiseUser.favorability` 写到该用户主设备 `UserDevice.favorability`;无主设备时写到第一台 | 脚本幂等;存量用户 0 丢失;产出迁移报告 | ✅ | +| P1-10 | 默认数据 seed | management command:初始化 8 条默认规则、5 个等级、`AffinitySetting` 默认值 | 命令重复执行不重复插入;与设计文档默认值一致 | ✅ | + +**完工里程碑**:`python manage.py migrate` 通过,`python manage.py seed_affinity` 写入默认数据,admin 后台 ORM 层能看到所有表。 + +--- + +## P2 Service 层 + 管理端 API + +**目标**:业务逻辑统一入口建好,管理端所有接口可用(前端先用 Postman 跑)。 + +| # | 子任务 | 产出物 | 验收标准 | 状态 | +|---|---|---|---|---| +| 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 | ⬜ | + +**完工里程碑**:所有 admin 接口可在 Postman 调通;service.apply 写入 log + 推 WS + 算等级。 + +--- + +## P3 管理端前端接通(拆 mock) + +**目标**:前端 [page.tsx](../qy-lty-admin/app/affinity/page.tsx) 全面去 mock,所有读写走真实接口。 + +| # | 子任务 | 产出物 | 验收标准 | 状态 | +|---|---|---|---|---| +| P3-01 | `lib/api/affinity.ts` 字段对齐 | 与 P2 字段同步:增 cooldown_seconds、min/max_change 等 | TypeScript 类型与后端 serializer 一致 | ⬜ | +| P3-02 | 互动规则页拆 mock | `page.tsx` `initialRules` 删除,改 useEffect 拉 `getAffinityRules()` | 刷新页面看到的是数据库数据 | ⬜ | +| P3-03 | `AffinityRuleDialog` 接通真接口 | 替换 [affinity-rule-dialog.tsx:112](../qy-lty-admin/components/affinity/affinity-rule-dialog.tsx#L112) 的 setTimeout,调 create/update | 保存成功后表格自动刷新 | ⬜ | +| P3-04 | 等级页拆 mock + Dialog 接通 | 同上对 levels | 同上 | ⬜ | +| P3-05 | 系统设置面板接通 | `/api/admin/affinity/settings/` GET/PUT | 修改后刷新仍生效 | ⬜ | +| P3-06 | 衰减规则面板接通 | 同 settings 接口 | 同上 | ⬜ | +| P3-07 | 数据统计页接通 stats 接口 | 4 个指标卡片 + 分布图表换真数据 | 和 admin 后台 `/stats/` 返回一致 | ⬜ | +| P3-08 | 新建变化日志查询页 | 新页面 `/affinity/logs`,支持 user/device/规则/时间过滤 | 分页可用;单条点击展开 metadata | ⬜ | + +**完工里程碑**:管理后台所有功能用真实数据,无任何 setTimeout / 本地 useState mock。 + +--- + +## P4 触发点埋点(设备/手机事件接入 service) + +**目标**:用户在设备/App 上的真实操作触发好感度变化。 + +| # | 子任务 | 产出物 | 验收标准 | 状态 | +|---|---|---|---|---| +| P4-01 | `chat_message` 接入 service | [device_interaction/consumers.py](../qy_lty/device_interaction/consumers.py) `chat_message` 处理函数末尾调 `apply(rule_key='chat')` | 设备发对话消息后 favorability 增加 | ⬜ | +| P4-02 | `sing` / `dance` / `touch` 接入 | 同上对三个动作消息 | 三种动作各自独立 rule 触发 | ⬜ | +| P4-03 | 陪伴时长计时器 | `conversation_status` begin 启动 Redis timer,end 计算时长 → `floor(duration / min_continuous_minutes)` 次 `apply(rule_key='companion_time')` | 聊够 X 分钟正确加分;中途断线只算到中断时刻 | ⬜ | +| P4-04 | 喂食 / 送礼 / 换装 / 道具接入 | 对应 HTTP ViewSet 的 `perform_create` 调 service(必须带 device_id) | 手机端动作触发好感度 | ⬜ | +| P4-05 | WS 推送实现 | channel layer consumer 处理 `affinity_update`、`level_up`、`level_down` 三类事件 | 用户多端在线全部收到 | ⬜ | +| P4-06 | `event_id` 去重 | 客户端事件携带 UUID,服务端 60s Redis 缓存防重复 | 同一 event_id 重复发送只生效一次 | ⬜ | +| P4-07 | 串行锁 | `(device_id, rule_key)` Redis 锁,避免并发导致计数器/上限判断失误 | 100 并发触发同 rule,最终值与日志一致 | ⬜ | + +**完工里程碑**:实际拿设备聊天 / 用 App 抚摸等,能在 admin 日志页看到记录。 + +--- + +## P5 客户端 API + 衰减定时任务 + +**目标**:手机端和设备端能查询、能领奖;不活跃设备每日自动衰减。 + +| # | 子任务 | 产出物 | 验收标准 | 状态 | +|---|---|---|---|---| +| P5-01 | `/api/user/me/affinity/`(多设备列表) | 默认返回当前用户所有 `is_active=true` 设备的好感度、等级、下一级进度 | 返回数据结构与 §9.2 一致 | ⬜ | +| P5-02 | `/api/user/me/affinity/?device_id=` | 单设备详情:当前值、等级、近期 N 条变化、已解锁内容 | 含进度百分比 | ⬜ | +| P5-03 | `/api/user/me/affinity/claim-reward/` | 领奖接口(必传 device_id + level) | 重复领取返回幂等成功;未达等级返回 403 | ⬜ | +| P5-04 | 衰减定时任务 | 新增 management command + celery/django-q beat,每天 00:30 跑 | 不活跃设备次日有衰减日志;活跃设备无衰减 | ⬜ | +| P5-05 | 设备解绑 / 重绑处理(A1 决策点) | 解绑置 `is_active=false`;重绑读默认方案 | 待 A1 拍板后确认是否恢复历史值 | ⬜ | +| P5-06 | 客户端首屏同步 | 文档约定:设备/手机登录后调一次 `/api/user/me/affinity/?device_id=` | 客户端 SDK 含此调用(前端实现侧) | ⬜ | + +**完工里程碑**:用户在 App 看到所有设备好感度;离线一段时间登录后看到衰减后的值。 + +--- + +## P6 观测、性能、并发加固 + +**目标**:上线前的观察手段、压测、数据修复能力。 + +| # | 子任务 | 产出物 | 验收标准 | 状态 | +|---|---|---|---|---| +| P6-01 | 结构化日志 | structlog 在 service.apply 入口埋点(user/device/rule/source/result) | grep 一个 device_id 能拼出完整轨迹 | ⬜ | +| P6-02 | Prometheus 指标 | `apply_total{rule, source, result}`、`cooldown_hit`、`daily_cap_hit`、`decay_runs`、`reward_grant_failed` | Grafana 面板 4 张图 | ⬜ | +| P6-03 | 并发压测 | 单设备 100 并发触发同 rule 的脚本 | 最终 favorability、log 数、计数器三者一致 | ⬜ | +| P6-04 | Redis ↔ DB 一致性校验 | 离线脚本:对比 Redis 当日计数器与 DB `UserAffinityDailyCounter` | 偏差告警 | ⬜ | +| P6-05 | 数据修复工具 | management command:扫描 favorability 越界的设备并钳位修复 | 可 dry-run + 实际修复模式 | ⬜ | + +**完工里程碑**:监控面板可观测;压测无数据竞争;越界数据可一键修复。 + +--- + +## 变更记录 + +| 日期 | 变更 | 操作人 | +|---|---|---| +| 2026-04-24 | 初版创建 | — | +| 2026-04-24 | P1-01 ~ P1-10 全部完成;models/migrations/seed 命令落地,等待 `migrate` 应用到数据库 | Claude | diff --git a/docs/好感度系统功能与规则设计.md b/docs/好感度系统功能与规则设计.md index 4370d93..d842f72 100644 --- a/docs/好感度系统功能与规则设计.md +++ b/docs/好感度系统功能与规则设计.md @@ -8,13 +8,18 @@ ## 一、系统定位 -好感度系统用于刻画**用户与洛天依**之间的亲密度关系。其核心价值: +好感度系统用于刻画**每一台洛天依设备与用户**之间的亲密度关系。其核心价值: -- 让用户的互动行为产生**持续、可感知的数值反馈** +- 让用户对每台设备的互动行为产生**持续、可感知的数值反馈** - 通过**等级 + 解锁内容**形成长期陪伴动机 -- 对不活跃用户通过**衰减机制**保留流失预警与召回空间 +- 对不活跃设备通过**衰减机制**保留流失预警与召回空间 -好感度是一个 `[0, max_affinity]` 区间的整数(默认上限 100),每个用户独立维护,记录在 `ParadiseUser.favorability` 字段。 +**关键设计:好感度是「设备级」而不是「用户级」。** + +- 一个用户可以绑定多台设备 +- 每台设备维护自己独立的好感度值、等级、解锁内容 +- 同一用户在不同设备上的好感度互不影响 +- 数值范围 `[0, max_affinity]`(默认上限 100),落库到 `UserDevice.favorability` --- @@ -26,7 +31,7 @@ |---|---|---| | 系统概览 | 系统概览 | 关键指标卡片 + 全局基础参数设置 | | 互动规则 | 互动规则 | 管理各类互动行为的好感度变化规则 | -| 衰减规则 | 互动规则页下半 | 配置不活跃用户的好感度衰减策略 | +| 衰减规则 | 互动规则页下半 | 配置不活跃设备的好感度衰减策略 | | 等级奖励 | 等级奖励 | 管理好感度等级划分与奖励发放 | | 数据统计 | 数据统计 | 好感度分布、互动分析、趋势监控 | @@ -36,29 +41,33 @@ ### 3.1 关键指标卡片 +所有指标的**统计基本单位是「设备」**(每条 `UserDevice` 绑定算一份)。 + | 指标 | 含义 | 数据源 | |---|---|---| -| 平均好感度 | 所有用户 `favorability` 平均值 | 聚合 `ParadiseUser` | -| 最高好感度 | 系统中达到上限的用户数 | `favorability = max_affinity` 计数 | -| 互动次数/日 | 当天触发的所有规则次数总和 | 聚合 `AffinityLog`(待建) | -| 活跃用户比例 | 近 N 天有互动的用户占比 | 聚合用户活跃状态 | +| 平均好感度 | 所有「用户-设备绑定」的 `favorability` 平均值 | 聚合 `UserDevice` | +| 最高好感度 | 达到 `max_affinity` 上限的**设备数** | `UserDevice.favorability = max_affinity` 计数 | +| 互动次数/日 | 当天触发的所有规则次数总和 | 聚合 `AffinityLog` | +| 活跃用户比例 | 近 N 天有互动的**设备**占比 | 聚合 `UserDevice.last_active_at` | -### 3.2 全局参数(AffinitySetting) +### 3.2 全局参数(AffinitySetting,单例) | 字段 | 默认值 | 说明 | |---|---|---| -| `initial_affinity` | 10 | 新用户创建时的初始好感度 | -| `max_affinity` | 100 | 好感度上限(所有规则累计不会超过此值) | -| `daily_cap` | 20 | 单用户每日好感度**净增长**上限(跨规则汇总) | +| `initial_affinity` | 10 | 新建 `UserDevice` 绑定时的初始好感度 | +| `max_affinity` | 100 | 好感度上限(管理员手动调整也不能突破) | +| `daily_cap` | 20 | **每台设备**每日好感度净增长上限(跨规则汇总) | | `decay_rate` | 2 点/天 | 全局默认衰减速率(衰减规则可覆盖) | -| `decay_threshold` | 3 天 | 不互动多少天后开始衰减 | +| `decay_threshold` | 3 天 | 设备多少天不互动后开始衰减 | | `enable_notify` | true | 好感度变化是否推送通知 | | `enable_rewards` | true | 是否启用等级奖励发放 | +| `timezone` | `Asia/Shanghai` | 自然日 0 点重置基准(全用户统一) | **规则**: -- `initial_affinity` 只影响**新用户**,不回溯修改已有用户 -- `max_affinity` 调低后,**已超过的用户保留原值**,但不再增加 -- `daily_cap` 是**跨所有正向规则**的顶层限制,触达后当日所有增益无效(衰减不受此限) +- `initial_affinity` 只影响**新建的设备绑定**,不回溯修改已有绑定 +- `max_affinity` 调低后,**已超过的设备保留原值**,但不再增加 +- `daily_cap` 是**每设备**跨所有正向规则的顶层日增长上限,触达后当日所有正向增益无效(衰减不受此限) +- `timezone` 决定"今天"的边界 — 默认 `Asia/Shanghai`,0 点(北京时间)重置所有日上限计数器 --- @@ -73,15 +82,17 @@ | `type` / `rule_key` | enum | `chat` | **代码标识**,服务端事件通过它匹配规则,不可重复 | | `description` | string | `与洛天依进行对话` | 展示描述 | | `minChange` | int | 1 | 单次好感度变化最小值 | -| `maxChange` | int | 5 | 单次好感度变化最大值(最终取 `[min, max]` 的随机整数) | +| `maxChange` | int | 5 | 单次好感度变化最大值 | | `singleCap` | int | 5 | 单次变化绝对值上限(保护性钳位,防止数据异常) | -| `dailyCap` | int | 15 | **本规则**每日累计变化上限(绝对值) | +| `dailyCap` | int | 15 | **本规则在每台设备**每日累计变化上限(绝对值) | | `isNegative` | bool | false | 是否负向规则。正向用正数,负向用负数 | | `isEnabled` | bool | true | 是否启用,禁用后事件不触发 | +**变化值算法**:每次触发,服务端在 `[minChange, maxChange]` 闭区间内**随机取整数**作为本次变化值。如果两个值相等就是固定值,不等则随机。 + ### 4.2 默认规则集(8 条) -| rule_key | 名称 | 范围 | 单次/日 | 正负 | 触发来源 | +| rule_key | 名称 | 范围 | 单次/日(每设备) | 正负 | 触发来源 | |---|---|---|---|---|---| | `card` | 使用卡片 | +1 ~ +3 | 3 / 10 | 正 | 手机端(使用卡片 API) | | `chat` | 对话 | +1 ~ +5 | 5 / 15 | 正 | 设备端 + 手机端(聊天消息) | @@ -95,44 +106,68 @@ ### 4.3 触发计算流程 ``` -收到事件 (user_id, rule_key, 来源上下文) +收到事件 (user_id, device_id, rule_key, 来源上下文) │ ▼ -① 取规则 → 若 is_enabled=false,丢弃 +① 校验:device_id 必须属于 user_id(UserDevice 存在) │ ▼ -② 冷却检查(Redis:affinity:cd:{user}:{rule_key}) +② 取规则 → 若 is_enabled=false,丢弃 + │ + ▼ +③ 冷却检查(Redis:affinity:cd:{device_id}:{rule_key}) │ 未到冷却 → 丢弃 ▼ -③ 本规则日上限检查(affinity:daily:{user}:{rule_key}:{date}) +④ 本规则日上限检查(affinity:daily:{device_id}:{rule_key}:{date}) │ 已满 → 丢弃 ▼ -④ 全局日上限检查(正向事件才检查) +⑤ 全局日上限检查(仅正向,affinity:daily:{device_id}:_global:{date}) │ 已满 → 丢弃 ▼ -⑤ 计算变化值 = random(min, max) +⑥ 计算变化值 = random(min, max) ↓ 按 single_cap 钳位 │ ▼ -⑥ 原子更新 ParadiseUser.favorability +⑦ 原子更新 UserDevice.favorability ↓ 钳位到 [0, max_affinity] │ ▼ -⑦ 写 AffinityLog、更新计数器 +⑧ 写 AffinityLog、更新计数器、刷新 UserDevice.last_active_at │ ▼ -⑧ 判断是否跨越等级边界 - │ 是 → 触发等级变更事件(发奖励 + 推通知) +⑨ 判断是否跨越等级边界 + │ 是 → 触发等级变更事件(发奖励 + 推通知,详见 6.3) ▼ -⑨ 通过 WebSocket 向用户的所有在线端推送 affinity_update +⑩ 通过 WebSocket 向用户的所有在线端推送 affinity_update(payload 含 device_id) ``` ### 4.4 规则设计约定 -- **单一写入入口**:所有好感度变化必须经由服务端统一入口,客户端不能直接增减。 -- **rule_key 即契约**:客户端事件不携带分值,只报「我触发了 `gift` 规则」,具体加多少由服务端按规则算。规则可被管理员随时调整,客户端无需改动。 -- **幂等防护**:同一 `rule_key` + 同一设备事件 ID 在冷却窗口内只生效一次,防抖防重复。 -- **禁用规则的兜底**:管理员禁用某规则后,客户端若继续上报该事件,服务端静默丢弃(不报错、不扣冷却)。 +- **单一写入入口**:所有好感度变化必须经由服务端统一入口,客户端不能直接增减 +- **rule_key 即契约**:客户端事件不携带分值,只报「设备 X 触发了 `gift` 规则」,具体加多少由服务端按规则算 +- **幂等防护**:同一 `device_id` + 同一 `rule_key` + 同一事件 ID 在冷却窗口内只生效一次 +- **禁用规则的兜底**:管理员禁用某规则后,客户端若继续上报该事件,服务端静默丢弃 + +### 4.5 陪伴时长规则(特殊规则) + +陪伴时长有两个独立配置字段,不同于普通规则: + +| 字段 | 默认 | 说明 | +|---|---|---| +| `min_continuous_minutes` | 5 | 单次会话需达到的**最小连续分钟数**才计一次 | +| `max_count_per_day` | 6 | **每台设备每日**最多触发次数 | +| `change_per_count` | +3 | 每次满足后获得的好感度(在 [min, max] 区间随机) | + +**计算方式**: +- 服务端在 `conversation_status` 收到 `begin` 时启动会话计时器 +- 收到 `end` 时计算时长 = `end_at − start_at` +- 触发次数 = `floor(时长_分钟 / min_continuous_minutes)`,受当日剩余次数(`max_count_per_day` 减去已触发数)限制 +- 每触发一次,按规则计算变化值并记一条 `AffinityLog` +- 中断(断线、强制结束)只算到中断时刻 + +**示例**:`min_continuous_minutes=5, max_count_per_day=6` +- 用户聊了 17 分钟 → `floor(17/5)=3` 次 → 加 3 次好感度 +- 用户当天已聊过 5 次(已触发 5 次),又聊 17 分钟 → 只能再触发 1 次(剩余 1 次) --- @@ -144,26 +179,27 @@ | 字段 | 默认值 | 说明 | |---|---|---| -| `decay_start_days` | 3 | 不互动多少天后开始衰减 | +| `decay_start_days` | 3 | 设备多少天不互动后开始衰减 | | `decay_rate_per_day` | 2 点/天 | 平均每日衰减点数 | | `min_decay` | 1 | 单日衰减最小值 | | `max_decay` | 3 | 单日衰减最大值 | -| `decay_cap` | 5 点/天 | 单日衰减上限(保护性) | +| `decay_cap` | 5 点/天 | 单设备单日衰减上限(保护性) | | `min_floor` | 0 | **衰减下限**,好感度不会低于此值 | | `notify_decay` | true | 是否通知用户「好感度下降了」 | ### 5.2 衰减执行 -- **频次**:每日 00:30 由定时任务统一跑一次 -- **命中对象**:`last_active_at < now - decay_start_days` 的用户 -- **落库**:衰减也写 `AffinityLog`,`source='system_decay'` -- **下限保护**:若用户当前好感度 ≤ `min_floor` 则跳过 -- **与互动的关系**:用户当天有互动即**重置不活跃计数**,次日不衰减 +- **频次**:每日 00:30(Asia/Shanghai)由定时任务统一跑一次 +- **命中对象**:`UserDevice.last_active_at < now - decay_start_days` 的**设备** +- **每台设备独立判断**:同一用户多设备只衰减不活跃的那几台 +- **落库**:每条衰减都写 `AffinityLog`,`source='system_decay'`,`device_id` 必填 +- **下限保护**:若设备当前好感度 ≤ `min_floor` 则跳过 +- **与互动的关系**:设备当天有互动即**重置不活跃计数**(`last_active_at` 刷新),次日不衰减 ### 5.3 设计权衡 -- 衰减**不占用** `daily_cap` 全局日上限(因为它是扣减,不是增益) -- 衰减日志会产生大量记录,可考虑按天合并写一条,减少 `AffinityLog` 膨胀 +- 衰减**不占用**全局日上限(`AffinitySetting.daily_cap` 只管正向增益) +- 衰减日志按**设备 × 天**写一条(一天对一台设备最多一条衰减记录),减少 `AffinityLog` 膨胀 - 若「当日互动」和「当日衰减」同时命中,先执行衰减再执行互动(让用户感受到「我回来了 → 好感度止跌回升」) --- @@ -195,13 +231,27 @@ ### 6.3 等级变化规则 -- 等级由好感度区间**自动映射**,不是独立字段 -- **跨级判定**:每次好感度变动后,取当前值所属区间,与上一次等级比较 - - 升级 → 发放目标等级的奖励(**只发最终落点等级**,跳级不补发中间等级) - - 降级(衰减导致) → 不追回奖励,但取消解锁内容访问权限 -- **奖励幂等**:同一用户同一等级的「首次达到奖励」只发一次;降级后再升级不重复发放 +**等级是「设备级」概念**:每台 `UserDevice` 各自维护当前等级,与同用户其他设备无关。 -### 6.4 区间约束 +- 等级由好感度区间**自动映射**,不是独立字段(缓存到 `UserDevice.affinity_level`) +- **跨级判定**:每次好感度变动后,取当前值所属区间,与上一次等级比较 + - **升级**:从 N 级到 M 级(M > N)→ **逐级发放** N+1、N+2、…、M 每一级的奖励(一次性发完) + - **降级**(衰减导致):不追回奖励,但取消该设备对应等级的解锁内容访问权限 +- **奖励幂等**:用 `UserLevelRewardGrant(device_id, level)` 表记录"已发放"标记 + - 同一设备同一等级**永久只发一次** + - 降级回升再次跨过同等级 → **不再发放**(已发标记保留,不清除) + - 标记跨衰减保留:用户从 5 级衰减到 4 级,再升回 5 级,5 级奖励不重发 +- **新增等级的存量处理**:管理员新增等级时(如新增 6 级),已经处于符合条件区间的设备**不立即触发**,需要下次好感度变化时才判定 + +### 6.4 跨设备解锁 + +**解锁内容仅在达到该等级的那台设备上有效**: + +- 设备 A 升到 4 级解锁了限定服装 → 限定服装只能在设备 A 上使用 +- 同一用户拿设备 B 登录手机端 → 看不到/用不了设备 A 解锁的内容 +- 客户端展示要明确按 `device_id` 过滤已解锁内容 + +### 6.5 区间约束 - 区间**不得重叠**,管理端保存时做校验 - 区间**不得有空隙**(如等级 2 的 max=40,则等级 3 的 min 必须是 41) @@ -211,44 +261,45 @@ ## 七、模块 5 — 数据统计 -### 7.1 概览指标 +### 7.1 概览指标(按设备聚合) - 平均好感度 / 中位数 / 最高好感度 -- 活跃用户数(近 7 日有互动) +- 活跃设备数(近 7 日有互动的 `UserDevice` 数) - 今日互动次数、今日新增好感度总量 ### 7.2 分布分析 -- 好感度区间分布(0-20、21-40…) -- 各等级用户数占比 +- 好感度区间分布(0-20、21-40…),按设备数统计 +- 各等级设备数占比 - 各互动规则触发频次 Top N ### 7.3 趋势分析 -- 日/周/月的平均好感度变化曲线 +- 日/周/月的设备平均好感度变化曲线 - 日互动量趋势 -- 衰减命中用户数趋势 +- 衰减命中设备数趋势 ### 7.4 用户级查询 -- 按用户 ID 查询其好感度当前值、等级、近期变化日志 -- 管理员手动调整(加减好感度,走 `source='admin_adjust'` 记 log) +- 按用户 ID 查询:返回该用户名下**所有设备**的好感度列表(device_id、设备名、当前值、等级、最近互动时间) +- 单设备维度:可点击展开看具体某台设备的近期变化日志 +- 管理员手动调整:必须**指定 `device_id`**,不能给"用户"加好感度(详见 9.1) --- ## 八、多端触发点一览 -好感度变化可由以下端点触发,所有端都走**同一个服务端入口**: +好感度变化可由以下端点触发,所有事件**必须携带 `device_id`**,最终走同一个服务端入口: -| 触发来源 | 规则 | 通道 | 位置参考 | -|---|---|---|---| -| 设备端上报「用户发起对话」 | `chat` | WebSocket | [device_interaction/consumers.py](../qy_lty/device_interaction/consumers.py) `chat_message` | -| 设备端对话结束(陪伴时长) | `chat` 或独立 `companion_time` | WebSocket | `conversation_status` 的 begin/end | -| 手机端点击「唱歌/跳舞/抚摸」 | 对应 rule_key | WebSocket | consumers.py `sing` / `dance` / `touch` | -| 手机端赠礼 / 喂食 / 换装 / 用道具 | 对应 rule_key | HTTP | 对应业务 ViewSet 钩子 | -| 管理员手动调整 | 无 rule | HTTP Admin API | 管理后台 | -| 衰减定时任务 | `decay` | 后台任务 | 定时调度 | +| 触发来源 | 规则 | 通道 | device_id 来源 | 位置参考 | +|---|---|---|---|---| +| 设备端上报「用户发起对话」 | `chat` | WebSocket | 设备登录态自带 | [device_interaction/consumers.py](../qy_lty/device_interaction/consumers.py) `chat_message` | +| 设备端对话结束(陪伴时长) | `companion_time` | WebSocket | 设备登录态自带 | `conversation_status` 的 begin/end | +| 手机端点击「唱歌/跳舞/抚摸」 | 对应 rule_key | WebSocket | 手机端选定的当前设备 | consumers.py `sing` / `dance` / `touch` | +| 手机端赠礼 / 喂食 / 换装 / 用道具 | 对应 rule_key | HTTP | 请求体显式传 device_id | 对应业务 ViewSet 钩子 | +| 管理员手动调整 | 无 rule | HTTP Admin API | 请求体显式传 device_id | 管理后台 | +| 衰减定时任务 | `decay` | 后台任务 | 任务遍历 UserDevice 时取 | 定时调度 | **身份识别**: -- 设备端:MAC 登录获取 token → 服务端通过 `UserDevice` 找到 `user_id` -- 手机端:Redis token 认证 → 直接拿到 `user_id` -- 服务端只认 `user_id`,与触发端无关 +- 设备端:MAC 登录获取 token → 服务端通过 `UserDevice` 反查 `user_id` 和 `device_id`,事件自动绑定到该设备 +- 手机端:Redis token 认证拿到 `user_id`,**事件必须显式指定要操作哪台设备**(用户在 App 里可能切换设备) +- 服务端只认 `(user_id, device_id)` 二元组,与触发端无关 --- @@ -258,27 +309,34 @@ | 接口 | 方法 | 用途 | |---|---|---| -| `/api/admin/affinity/rules/` | GET/POST/PATCH/DELETE | 互动规则 CRUD | -| `/api/admin/affinity/levels/` | GET/POST/PATCH/DELETE | 等级 CRUD | +| `/api/admin/affinity/rules/` | GET/POST/PATCH/DELETE | 互动规则 CRUD(用户全局配置,与设备无关) | +| `/api/admin/affinity/levels/` | GET/POST/PATCH/DELETE | 等级 CRUD(用户全局配置) | | `/api/admin/affinity/settings/` | GET/PUT | 全局参数(单例) | -| `/api/admin/affinity/logs/` | GET | 变化日志查询(可按 user、rule、时间过滤) | -| `/api/admin/affinity/stats/` | GET | 统计聚合 | -| `/api/admin/affinity/adjust/` | POST | 管理员手动调整(必须留审计) | +| `/api/admin/affinity/logs/` | GET | 变化日志查询(可按 user / device / rule / 时间过滤) | +| `/api/admin/affinity/stats/` | GET | 统计聚合(设备维度) | +| `/api/admin/affinity/devices/` | GET | 按 user_id 列出该用户所有设备及好感度 | +| `/api/admin/affinity/adjust/` | POST | 手动调整**单台设备**好感度,必传 `device_id`,钳位到 `[0, max_affinity]` | +| `/api/admin/affinity/adjust-batch/` | POST | 批量给某用户**名下所有设备**各 ±X(用于群体补偿) | ### 9.2 客户端(手机端 / 设备端共用) | 接口 | 方法 | 用途 | |---|---|---| -| `/api/user/me/affinity/` | GET | 当前好感度、等级、下一级进度、近期变化 | -| `/api/user/me/affinity/claim-reward/` | POST | 领取等级奖励 | +| `/api/user/me/affinity/` | GET | 返回**当前用户名下所有设备**的好感度列表(含 device_id、当前值、等级、下一级进度) | +| `/api/user/me/affinity/?device_id=xxx` | GET | 查询指定设备的详情(近期变化、已解锁内容) | +| `/api/user/me/affinity/claim-reward/` | POST | 领取奖励(必传 `device_id` + `level`) | ### 9.3 WebSocket 实时推送 +所有 WS 事件 payload 必须包含 `device_id`,客户端按 device_id 路由到对应 UI: + | 事件 | 方向 | payload | |---|---|---| -| `affinity_update` | 服务端 → 用户 | `{change, before, after, rule_key, source}` | -| `level_up` | 服务端 → 用户 | `{old_level, new_level, reward}` | -| `level_down` | 服务端 → 用户 | `{old_level, new_level}`(衰减导致降级) | +| `affinity_update` | 服务端 → 用户的所有在线端(手机+设备) | `{device_id, change, before, after, rule_key, source}` | +| `level_up` | 服务端 → 用户的所有在线端 | `{device_id, old_level, new_level, rewards: [...]}`(rewards 为本次跨级一次性发放的所有等级奖励列表) | +| `level_down` | 服务端 → 用户的所有在线端 | `{device_id, old_level, new_level}`(衰减导致降级) | + +**多设备并行**:用户绑了 3 台设备,同时在线时收到 3 条独立的 `affinity_update`,前端按 `device_id` 显示在不同卡片。 --- @@ -290,9 +348,12 @@ | 等级 CRUD | ✅ UI 完成 | ⚠️ 缺字段 | ⚠️ 需扩展 | — | | 全局设置 | ✅ UI 完成 | ❌ 无表 | ❌ 无接口 | — | | 衰减配置 | ✅ UI 完成 | ❌ 无表 | ❌ 无接口 | ❌ 无定时任务 | -| 变化日志 | ❌ 无 UI | ❌ 无表 | ❌ 无接口 | — | +| **设备级好感度迁移** | — | ❌ `UserDevice.favorability` 字段缺 | — | — | +| 变化日志 | ❌ 无 UI | ❌ 无表(需带 device 维度) | ❌ 无接口 | — | | 数据统计 | ⚠️ Mock 展示 | — | ❌ 无聚合接口 | — | -| 客户端查询 | — | — | ❌ 无接口 | — | +| 客户端查询(多设备列表) | — | — | ❌ 无接口 | — | +| 等级奖励发放标记 | — | ❌ `UserLevelRewardGrant` 表缺 | — | — | +| 陪伴时长规则 | ⚠️ UI 没字段 | ❌ 规则字段缺 | — | ❌ 计时器未实现 | | WS 实时推送 | — | — | — | ❌ 未接入 | | 设备/手机事件埋点 | — | — | — | ❌ 未接入 | @@ -306,8 +367,78 @@ |---|---| | rule_key | 互动规则的代码级标识(如 `chat`),客户端事件通过它匹配规则 | | single_cap | 单次变化绝对值上限(保护性钳位) | -| daily_cap | 单规则每日累计变化上限 | -| 全局日上限 | 跨所有正向规则的顶层日增长上限(`AffinitySetting.daily_cap`) | -| 冷却 | 同一用户同一规则的最小触发间隔 | -| source | 变化来源:`device_event` / `mobile_event` / `system_decay` / `admin_adjust` | -| 跨级 | 好感度变化使得用户从一个等级区间移动到另一个 | +| daily_cap(规则) | 单设备单规则每日累计变化上限 | +| 全局日上限 | 单设备跨所有正向规则的顶层日增长上限(`AffinitySetting.daily_cap`) | +| 冷却 | 同一设备同一规则的最小触发间隔 | +| source | 变化来源:`device_event` / `mobile_event` / `system_decay` / `admin_adjust_single` / `admin_adjust_batch` | +| 跨级 | 好感度变化使得设备从一个等级区间移动到另一个 | +| UserDevice | 用户与设备的绑定关系,好感度落在这一行 | +| 已发奖励标记 | `UserLevelRewardGrant(device_id, level)` 唯一记录,永久保留 | + +--- + +## 十二、决策记录 + +以下决策已确认,作为开发依据: + +| # | 决策点 | 结论 | +|---|---|---| +| 1 | 规则值域 | `min_change` 和 `max_change` 之间**随机取整数** | +| 2 | 陪伴时长 | 配两个字段:`min_continuous_minutes`(最小连续分钟数)+ `max_count_per_day`(每日最多次数)。算法:满 X 分钟触发 1 次,封顶 Y 次 | +| 3 | 跨级奖励 | 升级时**逐级发放**经过的每一级;降级回升后**不再补发**(永久幂等) | +| 4 | 衰减频次 | 每天 00:30 一次(Asia/Shanghai) | +| 5 | 日上限重置 | 自然日 0 点重置(Asia/Shanghai 时区) | +| 6 | 管理员钳位 | 手动调整**不能突破** `max_affinity` 上限和 `0` 下限 | +| 7 | 推送范围 | `affinity_update` / `level_up` 推送给手机端**和**设备端(用户的所有在线端) | +| 8 | 好感度归属 | **设备级**:好感度落在 `UserDevice.favorability`,每台设备独立。等级、解锁内容、衰减、日上限**全部按设备独立计算** | +| 8.A | 好感度字段 | 新增 `UserDevice.favorability`,原 `ParadiseUser.favorability` 废弃(迁移时把已有值写到主设备绑定上) | +| 8.B | 等级判定 | 每台设备独立判定 | +| 8.C | 跨设备解锁 | 解锁内容**只在解锁那台设备**上可用,不跨设备共享 | +| 8.D | 客户端查询 | `/api/user/me/affinity/` 默认返回**全部设备列表**,可带 `device_id` 查指定设备 | +| 8.E | 衰减判定 | 按每台设备的 `last_active_at` 各自判断 | +| 8.F | 规则日上限 | 每台设备各自计 | +| 8.G | 统计口径 | 平均好感度等指标按**设备**聚合;用户查询页展开每台设备 | +| 9 | 跨级新增等级 | 新增等级后已在范围内的设备**不立即触发**,下次变化时再判 | +| 10 | 修改已有等级奖励 | 已发过的设备**不补发** | +| 11 | 已发标记跨衰减 | 衰减导致降级**不清除**已发标记 | +| 12 | 管理员调整归属 | 必须指定 `device_id`;额外提供「批量给名下所有设备各加 X」的接口 | +| 13 | 冷却字段 | `cooldown_seconds` **暴露给管理员**配置,作为 `AffinityRule` 的字段 | +| 14 | 客户端首屏同步 | 设备/手机端登录后**主动调用** `/api/user/me/affinity/?device_id=xxx` 拉一次最新值,避免 WS 离线期间漏推 | + +--- + +## 十三、待敲定细节(不阻塞 P1/P2,按建议默认方案先走,需要时再定) + +### 13.1 必须在指定阶段前敲定 + +| # | 决策点 | 默认方案(建模阶段保留选项的兜底做法) | 真正阻塞阶段 | +|---|---|---|---| +| A1 | 设备解绑后重新绑定的好感度 | `UserDevice` 加 `is_active` 软删除字段,数据保留。具体行为(恢复 / 归零)可后期切换 | P5(客户端绑定接口) | +| A2 | 设备转移给新用户 | 旧 `UserDevice` 标记 `is_active=false` 归档;新用户新建一条 `UserDevice` 从 `initial_affinity` 开始 | 仅当产品确认需要转移流程时 | +| A3 | 跨级奖励的事务边界 | 方案 B:每级独立事务,失败的标记为待补发,后台重试 | P3(service 层奖励发放) | +| A4 | 好感度变化通知投递通道 | 先实现 WebSocket 实时推送一种通道;站内信、APP 推送后续叠加 | P5(客户端通知 UI) | + +### 13.2 非阻塞,待数据/运营反馈后再定 + +| # | 决策点 | 默认方案 | 备注 | +|---|---|---|---| +| B1 | 等级 / 规则删除语义 | 软删除(`is_deleted=true`),日志保留完整。`AffinityLog.rule` 用 SET NULL,冗余 `rule_key` 文本字段保留可读性 | 永远不必硬定 | +| B2 | `AffinityLog` 保留期限 | 暂永久保留;衰减日志按"设备×天"合并降低增长。日志表上线半年后再决定归档策略 | 数据量起来再看 | +| B3 | 通知聚合频次 | 每次变化都推 WS(前端按需做防抖) | 前端体验出来再调 | + +### 13.3 接受默认方案,非紧急(如有异议再讨论) + +| # | 决策点 | 默认方案 | +|---|---|---| +| C1 | 首次绑定是否触发等级 1 奖励 | 触发。`initial_affinity=10` 落在等级 1 区间,发等级 1 奖励 | +| C2 | 等级名称是否允许改 | 允许。改名只影响展示,等级序号不变,不重发奖励 | +| C3 | 管理员操作审计 | `AffinityLog` 加 `operator_admin_id`(仅 admin 来源时填)+ `reason` 文本字段(备注) | +| C4 | 客户端缓存策略 | 服务端为准;客户端只缓存展示,WS 收不到时主动拉接口 | +| C5 | `max_affinity` 调整对存量等级的影响 | 调高:自动扩展最高等级 max;调低:超出设备保留原值,下次变化时按新边界判等级 | +| C6 | 进度条算法 | `(value - level.min) / (level.max - level.min) × 100%` | +| C7 | 多账号防刷 | 同一 MAC 在 7 天内最多绑定 3 个不同用户(`UserDevice.bound_at` 限制) | +| C8 | 跨规则同时触发顺序 | 严格按服务端接收顺序串行处理(`(device_id, rule_key)` 维度的 Redis 锁) | +| C9 | 同一事件 ID 防重 | 客户端事件携带 `event_id`(UUID),服务端缓存 60s 去重 | +| C10 | 设备登录后客户端首屏 | 拉一次 `/api/user/me/affinity/?device_id=当前设备` 同步最新值(已敲定,13 列决策) | + +--- diff --git a/qy-lty-admin/CLAUDE.md b/qy-lty-admin/CLAUDE.md new file mode 100644 index 0000000..061351e --- /dev/null +++ b/qy-lty-admin/CLAUDE.md @@ -0,0 +1,106 @@ +# CLAUDE.md + +本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导。 + +## 项目概述 + +**洛天依应用管理后台 (qy-lty-admin)** —— 基于 **Next.js 15 (App Router) + React 19 + TypeScript** 构建的 Web 管理后台,搭配 Tailwind CSS + Radix UI / shadcn 风格组件库。 + +后端 API 由独立项目 [qy_lty](../qy_lty/) 提供(Django,路径 `C:\Users\admin\Desktop\Lila-Server\qy_lty`),通过 `NEXT_PUBLIC_API_BASE_URL` 配置接入,主要使用 `/api/v1/admin/` 模块。 + +## 开发命令 + +```bash +# 安装依赖(Dockerfile 用 yarn + 淘宝镜像源;本地 npm/pnpm 也可) +npm install + +# 开发模式 +npm run dev # 默认 http://localhost:3000 + +# 生产构建(standalone 输出) +npm run build && npm run start + +# 类型检查 +npm run lint +``` + +环境变量(`.env.local`): +- `NEXT_PUBLIC_API_BASE_URL` —— 后端 qy_lty 服务地址 + +## 架构要点 + +### 技术栈 +| 类别 | 选型 | +|------|------| +| 框架 | Next.js 15.2.4(App Router、standalone 输出)| +| 语言 | TypeScript 5、React 19 | +| 样式 | Tailwind CSS 3.4 + `tailwindcss-animate` | +| 组件 | Radix UI + shadcn 风格封装([components/ui/](components/ui/))| +| HTTP 客户端 | Axios(含请求/响应拦截器)| +| 表单 | React Hook Form + Zod | +| 图表 | Recharts | +| 通知 | Sonner + Radix Toast | + +### 路由与页面(App Router) +- `app/page.tsx` —— 仪表盘首页 +- `app/login/`、`app/register/`、`app/forgot-password/` —— 账户流 +- 业务模块(按权限分组): + - **AI 管理**:`/ai-model` + - **内容管理**:`/outfits`、`/props`、`/home-decor`、`/food`、`/songs`、`/dances`、`/achievements`、`/affinity` + - **系统管理**:`/users`、`/permissions`、`/settings` + +### 鉴权与权限 +- 角色信息存于 `localStorage.user_role` +- 模块权限矩阵定义在 [lib/permissions.ts](lib/permissions.ts) +- 侧边栏 [components/sidebar.tsx](components/sidebar.tsx) 客户端按角色过滤可见菜单 +- 路由保护中间件:[middleware.ts](middleware.ts) + +### API 集成 +- Axios 实例 + 拦截器统一注入 token +- 所有请求走 `NEXT_PUBLIC_API_BASE_URL + /api/v1/admin/...` +- 后端鉴权 token 复用 qy_lty 的 Redis token 体系(admin token key 为 `admin_token:{token}`) + +## 跨仓库联动 + +本仓库**依赖** [qy_lty](../qy_lty/) 后端的 `/api/v1/admin/` 接口。修改任何接口契约(请求/响应结构、字段、URL)的改动**必须同时**: +1. 在 [qy_lty/docs/修改记录.md](../qy_lty/docs/修改记录.md) 记录服务端改动 +2. 在 [docs/修改记录.md](docs/修改记录.md) 记录前端配套改动 +3. 两端条目相互引用对方的同期条目 + +## 项目修改记录规则(重要 — 自动执行) + +每次对本仓库代码做出改动后,**必须**在同一会话内把变更追加到 [docs/修改记录.md](docs/修改记录.md) **顶部**(最新在最前),不要等用户提醒。条目格式遵循该文件头部"修改格式说明"约定: + +``` +### [日期] 修改简述 + +- **文件路径**: 相对于项目根目录的文件路径 +- **修改类型**: 新增 / 修改 / 删除 / 重构 / 修复Bug +- **修改内容**: 具体修改了什么 +- **修改原因**: 为什么要做这个修改 +``` + +### `qy-lty-admin` 与 `qy_lty` 是独立项目,各自维护 + +- `qy-lty-admin/docs/修改记录.md` —— **仅**记录管理后台前端(本仓库)改动 +- `qy_lty/docs/修改记录.md` —— **仅**记录服务端改动 + +跨项目联动改动:**两端各写一条**,相互引用对方的修改记录条目,不要混在同一条记录里。 + +### 适用范围 + +- 业务代码、配置、`package.json`、Dockerfile、CI、文档结构性改动 → **必须**记录 +- 单纯的 typo 修复、注释微调 → 可省略 +- 临时调试脚本、`.gitignore` 中文件、本地实验文件 → 不记录 + +## 注意事项 + +- **语言**:用户偏好**中文**沟通;注释、commit message 用中文 +- **包管理器**:项目同时存在 `package-lock.json` 和 `pnpm-lock.yaml`;Dockerfile 用 yarn + 淘宝镜像源。本地开发任选其一即可,但**不要混用**导致 lock 文件冲突 +- **shadcn 组件**:[components/ui/](components/ui/) 下的组件是 shadcn 复制粘贴风格(不是 npm 包),可以直接修改源码而不破坏第三方升级 + +## 相关文档 + +- [README.md](README.md) —— 完整功能概览、权限矩阵、部署指南 +- [docs/修改记录.md](docs/修改记录.md) —— 本仓库变更历史(强制维护) +- [../qy_lty/CLAUDE.md](../qy_lty/CLAUDE.md) —— 后端项目上下文与跨仓库联动规则 diff --git a/qy-lty-admin/docs/修改记录.md b/qy-lty-admin/docs/修改记录.md new file mode 100644 index 0000000..298cc07 --- /dev/null +++ b/qy-lty-admin/docs/修改记录.md @@ -0,0 +1,38 @@ +# 管理后台前端代码修改记录 + +本文档记录每次对管理后台前端(**qy-lty-admin**,Next.js + React)代码的修改,方便追踪变更历史。 + +> **范围说明**:本仓库与服务端 [qy_lty](../../qy_lty/) 是独立项目,各自维护独立的修改记录。**仅记录本仓库(管理后台前端)改动**;跨项目联动改动需在 [qy_lty/docs/修改记录.md](../../qy_lty/docs/修改记录.md) 同期写一条相互引用的条目。 + +--- + +## 修改格式说明 + +每次修改按以下格式记录: + +``` +### [日期] 修改简述 + +- **文件路径**: 相对于项目根目录的文件路径 +- **修改类型**: 新增 / 修改 / 删除 / 重构 / 修复Bug +- **修改内容**: 具体修改了什么 +- **修改原因**: 为什么要做这个修改 +``` + +--- + +## 修改历史 + + + +### [2026-04-30] 初始化 CLAUDE.md 与 docs/修改记录.md 骨架 + +- **文件路径**: `CLAUDE.md`、`docs/修改记录.md` +- **修改类型**: 新增 +- **修改内容**: + - 新建 `CLAUDE.md`:写明项目身份(Next.js 15 App Router + React 19 后台)、技术栈、路由分组、鉴权与权限模型、与 `qy_lty` 后端的依赖关系,并嵌入"项目修改记录规则(重要 — 自动执行)"段落 + - 新建本文件 `docs/修改记录.md`:以 `qy_lty/docs/修改记录.md` 同名文件为骨架,划清"仅记录管理后台前端改动"的边界 +- **修改原因**: + - 此前本仓库无 CLAUDE.md 和修改记录文档,新会话进入工作时缺乏上下文锚点;与服务端 `qy_lty` 项目的改动追踪互不可见 + - 配套服务端 `qy_lty/CLAUDE.md` 同日新增的"项目修改记录规则"段落,要求两端各自独立维护修改记录、跨项目联动改动相互引用,从源头避免漏记和混记 + - 服务端同期条目:[qy_lty/docs/修改记录.md](../../qy_lty/docs/修改记录.md) 2026-04-30 "CLAUDE.md 新增项目修改记录规则段落" diff --git a/qy_lty/CLAUDE.md b/qy_lty/CLAUDE.md index 4ba08af..442741c 100644 --- a/qy_lty/CLAUDE.md +++ b/qy_lty/CLAUDE.md @@ -245,3 +245,29 @@ docker-compose up -d --build - 深度定制 SimpleUI 主题 - 支持多语言(中文/英文) - 为不同模块配置自定义图标与组织结构 + +## 项目修改记录规则(重要 — 自动执行) + +每次对本仓库代码做出改动后,**必须**在同一会话内把变更追加到 [docs/修改记录.md](docs/修改记录.md) **顶部**(最新在最前),不要等用户提醒。条目格式遵循该文件头部"修改格式说明"约定: + +``` +### [日期] 修改简述 + +- **文件路径**: 相对于项目根目录的文件路径 +- **修改类型**: 新增 / 修改 / 删除 / 重构 / 修复Bug +- **修改内容**: 具体修改了什么 +- **修改原因**: 为什么要做这个修改 +``` + +### `qy_lty` 与 `qy-lty-admin` 是独立项目,各自维护 + +- `qy_lty/docs/修改记录.md` — **仅**记录服务端(本仓库)改动 +- `qy-lty-admin/docs/修改记录.md` — **仅**记录管理后台前端改动;如该文件不存在则新建(基于 qy_lty 同名文件的格式骨架) + +跨项目联动(例如新增服务端接口 + 管理后台调用):**两端各写一条,相互引用**对方的修改记录条目,不要把两端混进同一条记录里。 + +### 适用范围 + +- 业务代码、配置、迁移文件、CI / k8s / Dockerfile、文档结构性改动 → **必须**记录 +- 单纯的 typo 修复、注释微调 → 可省略 +- 临时调试脚本、`.gitignore` 中文件、本地实验文件 → 不记录 diff --git a/qy_lty/device_interaction/migrations/0003_userdevice_affinity_level_userdevice_favorability_and_more.py b/qy_lty/device_interaction/migrations/0003_userdevice_affinity_level_userdevice_favorability_and_more.py new file mode 100644 index 0000000..07bfe49 --- /dev/null +++ b/qy_lty/device_interaction/migrations/0003_userdevice_affinity_level_userdevice_favorability_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.12 on 2026-04-30 10:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('device_interaction', '0002_device_battery_level_device_brightness_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='userdevice', + name='affinity_level', + field=models.IntegerField(default=1, help_text='缓存值,由服务端在好感度变化时同步更新', verbose_name='当前等级'), + ), + migrations.AddField( + model_name='userdevice', + name='favorability', + field=models.IntegerField(default=10, help_text='设备级好感度,每台设备独立。初始值取自 AffinitySetting.initial_affinity', verbose_name='好感度'), + ), + migrations.AddField( + model_name='userdevice', + name='is_active', + field=models.BooleanField(default=True, help_text='软删除标记。解绑置为 false,重绑时可读取历史值。注意:与 Device.is_active(设备激活态)不是同一概念', verbose_name='绑定有效'), + ), + migrations.AddField( + model_name='userdevice', + name='last_active_at', + field=models.DateTimeField(blank=True, db_index=True, help_text='用于衰减判断;服务端在每次成功 apply 时刷新', null=True, verbose_name='最近互动时间'), + ), + ] diff --git a/qy_lty/device_interaction/models.py b/qy_lty/device_interaction/models.py index 43353af..6deaabe 100644 --- a/qy_lty/device_interaction/models.py +++ b/qy_lty/device_interaction/models.py @@ -91,13 +91,40 @@ class Device(models.Model): class UserDevice(models.Model): - """用户设备关联表""" + """用户设备关联表 + + P1-08 扩展:好感度变更为「设备级」,每条 UserDevice 绑定独立维护 + favorability: 当前好感度值 + affinity_level: 当前等级缓存(由服务端计算) + last_active_at: 最近一次互动时间,用于衰减判断 + is_active: 软删除标记。解绑置为 false,重绑可读取历史值 + """ + user = models.ForeignKey(ParadiseUser, on_delete=models.CASCADE, verbose_name='用户', related_name='devices') device = models.ForeignKey(Device, on_delete=models.CASCADE, verbose_name='设备', related_name='users') nickname = models.CharField('设备昵称', max_length=100, blank=True, null=True) bound_at = models.DateTimeField('绑定时间', auto_now_add=True) is_primary = models.BooleanField('是否主要设备', default=False) + # P1-08 好感度相关字段(设备级) + favorability = models.IntegerField( + '好感度', default=10, + help_text='设备级好感度,每台设备独立。初始值取自 AffinitySetting.initial_affinity' + ) + affinity_level = models.IntegerField( + '当前等级', default=1, + help_text='缓存值,由服务端在好感度变化时同步更新' + ) + last_active_at = models.DateTimeField( + '最近互动时间', null=True, blank=True, db_index=True, + help_text='用于衰减判断;服务端在每次成功 apply 时刷新' + ) + is_active = models.BooleanField( + '绑定有效', default=True, + help_text='软删除标记。解绑置为 false,重绑时可读取历史值。' + '注意:与 Device.is_active(设备激活态)不是同一概念' + ) + class Meta: verbose_name = '用户设备' verbose_name_plural = '用户设备' diff --git a/qy_lty/docs/修改记录.md b/qy_lty/docs/修改记录.md index 7392a1c..1910b2e 100644 --- a/qy_lty/docs/修改记录.md +++ b/qy_lty/docs/修改记录.md @@ -23,6 +23,128 @@ +### [2026-04-24] 好感度系统 P1 阶段 — 数据模型扩展 + 迁移 + seed 命令 + +配套设计文档:[docs/好感度系统功能与规则设计.md](好感度系统功能与规则设计.md) +配套任务清单:[docs/好感度系统-开发任务清单.md](好感度系统-开发任务清单.md)(P1-01 ~ P1-10 全部完成) + +本次改动把好感度系统的数据层从「用户级单值」(`ParadiseUser.favorability`)演进到「设备级独立计数」(`UserDevice.favorability`),并补齐规则、等级、配置、日志、计数器、奖励发放标记 6 类表,为后续 P2 service 层开发奠基。 + +#### P1-01 / P1-02 / P1-03 — AffinityRule、AffinityLevel 字段扩展 + +- **文件路径**: `userapp/models.py` +- **修改类型**: 重构 +- **修改内容**: + - `AffinityRule` 新增字段:`rule_key`(代码标识)、`trigger_type`(action / companion_time / decay)、`min_change` / `max_change`([min,max] 闭区间随机)、`single_cap` / `daily_cap` / `cooldown_seconds`、`is_negative` / `is_enabled` / `is_deleted`、`min_continuous_minutes` / `max_count_per_day`(陪伴时长专用) + - `AffinityLevel` 新增字段:`min_affinity` / `max_affinity`(区间)、`unlock_content`、`reward_type` / `reward_currency` / `reward_items`、`is_enabled` / `is_deleted` + - 旧字段 `points` / `daily_limit` / `is_active`(Rule)、`required_points` / `rewards`(Level)保留作为兼容字段,注释标记 "已弃用",下个版本删除 +- **修改原因**: + - 旧字段无法满足设计文档 §4 / §6 的规则与等级配置维度(缺范围、缺冷却、缺奖励细分) + - 软删除字段 `is_deleted` 是 13.1-B1 默认方案的兜底,保留删除决策的可逆性 + +#### P1-04 — 新增 AffinitySetting(单例表) + +- **文件路径**: `userapp/models.py` +- **修改类型**: 新增 +- **修改内容**: + - 新增 `AffinitySetting` 模型,存全局参数:`initial_affinity`、`max_affinity`、`daily_cap`(全局日上限)、衰减相关 6 字段、通知开关、`timezone`(默认 Asia/Shanghai) + - `save()` 强制单例:新增时若已有记录则覆盖到现有 pk + - `get_solo()` 类方法:取唯一实例,不存在则创建默认 +- **修改原因**: 设计文档 §3.2 全局参数 + §5.1 衰减字段需要持久化配置,单例表是简单可靠的存储模式 + +#### P1-05 — 新增 AffinityLog(变化日志) + +- **文件路径**: `userapp/models.py` +- **修改类型**: 新增 +- **修改内容**: + - 新增 `AffinityLog` 模型:`user` / `device`(SET_NULL) / `rule`(SET_NULL) / `rule_key`(冗余文本)、`change_value` / `before_value` / `after_value`、`source`(5 种来源)、`event_id`(幂等去重)、`operator_admin_id` + `reason`(管理员调整审计)、`metadata`(JSON) + - 索引:`(device, -created_at)` / `(user, -created_at)` / `(rule_key, -created_at)` / `(source, -created_at)` + - 部分唯一约束 `unique_affinity_event_id`:仅当 `event_id` 非空时唯一 +- **修改原因**: + - 所有好感度变化必须可审计、可追溯(设计文档 §9.3 + §13 决策记录的 12 项) + - `event_id` 唯一约束实现服务端去重(决策 C9) + +#### P1-06 — 新增 UserAffinityDailyCounter(每日计数器) + +- **文件路径**: `userapp/models.py` +- **修改类型**: 新增 +- **修改内容**: 新增 `UserAffinityDailyCounter` 模型:`(device, rule, date)` 唯一,`accumulated_change` + `trigger_count` +- **修改原因**: 热路径走 Redis(`daily:{device}:{rule}:{YYYYMMDD}`),数据库表作为审计兜底,每晚定时任务把 Redis 当日数据落库 + +#### P1-07 — 新增 UserLevelRewardGrant(等级奖励发放标记) + +- **文件路径**: `userapp/models.py` +- **修改类型**: 新增 +- **修改内容**: 新增 `UserLevelRewardGrant` 模型:`(device, level)` 唯一,`reward_snapshot` 保存发放时奖励快照 +- **修改原因**: + - 决策 3 + 决策 11:升级逐级发奖励,永久幂等,衰减回升后不补发 + - `reward_snapshot` 防止 `AffinityLevel` 后续修改影响审计 + +#### P1-08 — UserDevice 加好感度字段(设备级模型核心) + +- **文件路径**: `device_interaction/models.py` +- **修改类型**: 修改 +- **修改内容**: + - `UserDevice` 新增 4 字段:`favorability`(默认 10)、`affinity_level`(默认 1)、`last_active_at`(带 db_index)、`is_active`(绑定有效软删除标记) + - 在 docstring 中说明:`UserDevice.is_active` 是绑定软删除标记,与 `Device.is_active`(设备激活态)不是同一概念 +- **修改原因**: 决策 8 — 好感度归属为「设备级」,每条用户-设备绑定独立维护值、等级、解锁内容 + +#### P1 自动迁移文件 — 由 makemigrations 生成 + +- **文件路径**: + - `device_interaction/migrations/0003_userdevice_affinity_level_userdevice_favorability_and_more.py` + - `userapp/migrations/0005_affinitysetting_affinitylevel_is_deleted_and_more.py` +- **修改类型**: 新增 +- **修改内容**: Django 自动生成的 schema 迁移,按依赖顺序处理跨应用 FK(AffinityLog → UserDevice) +- **修改原因**: P1-01 ~ P1-08 模型变更需要落库 + +#### P1-09 — 数据迁移:ParadiseUser.favorability → UserDevice.favorability + +- **文件路径**: `userapp/migrations/0006_migrate_favorability_to_userdevice.py` +- **修改类型**: 新增 +- **修改内容**: + - 手写 RunPython 数据迁移:遍历所有 favorability > 0 的用户,写到主设备(无主设备则取最近绑定) + - 仅当目标 `UserDevice.favorability == 10`(默认值)时写入,避免覆盖业务层后续修改 + - 提供 `migrate_favorability_backward` 回滚函数 + - 旧 `ParadiseUser.favorability` 字段保留不删,由后续版本统一清理 +- **修改原因**: 设备级模型上线时,存量用户的好感度数据不能丢失,需平滑迁移到主设备 + +#### P1-10 — seed 默认数据 management command + +- **文件路径**: + - `userapp/management/__init__.py`(新建空文件) + - `userapp/management/commands/__init__.py`(新建空文件) + - `userapp/management/commands/seed_affinity.py` +- **修改类型**: 新增 +- **修改内容**: + - 新增 `python manage.py seed_affinity` 命令:写入 AffinitySetting 单例 + 8 条默认规则 + 5 个默认等级 + - 默认数据与设计文档 §4.2 / §6.2 一致;规则带 `rule_key`、`cooldown_seconds`(chat=30s,touch=10s,其余 0),等级带 `min_affinity`/`max_affinity` 闭区间 + - 幂等:默认按 `rule_key` / `level` 查询,已存在则跳过;`--force` 模式下覆盖已存在记录 +- **修改原因**: 提供一键初始化能力,避免管理员手工逐条添加,且保证默认值与文档一致 + +#### 后续步骤(不属于本次改动,留待用户确认后执行) + +1. 在合适时机执行 `python manage.py migrate` 应用 schema 变更和数据迁移 +2. 执行 `python manage.py seed_affinity` 写入默认规则/等级/配置 +3. 进入 P2 阶段(service 层 + 管理端 API),见任务清单 + +--- + +### [2026-04-30] CLAUDE.md 新增"项目修改记录规则"段落 + +- **文件路径**: `CLAUDE.md` +- **修改类型**: 新增 +- **修改内容**: + - 在文末追加"项目修改记录规则(重要 — 自动执行)"段落,明确要求每次代码改动后必须在同一会话内追加到 `docs/修改记录.md` 顶部 + - 划清 `qy_lty` 与 `qy-lty-admin` 各自独立维护修改记录的边界,跨项目联动改动两端各写一条互相引用 + - 列出适用范围:业务/配置/迁移/CI/k8s/Dockerfile/文档结构性改动必须记录;typo / 临时调试脚本可省 +- **修改原因**: + - 之前修改记录靠手工维护,部分改动遗漏未追加导致追踪历史中断 + - 规则写进 CLAUDE.md 后,Claude Code 在每次会话中可自动遵守,减少漏记风险 + - 配套同步在 `qy-lty-admin/CLAUDE.md` 和 `qy-lty-admin/docs/修改记录.md` 建立独立修改记录骨架(详见 `qy-lty-admin/docs/修改记录.md` 同日条目) + +--- + ### [2026-04-29] strategy B group_send 推回消息体新增 timestamp_unix 字段 配套手机端记录:`LTY_App_Project_URP/docs/修改记录.md` 同日"修复 B' 双倒真正根因:时间戳时区解析"条目。 diff --git a/qy_lty/userapp/management/__init__.py b/qy_lty/userapp/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qy_lty/userapp/management/commands/__init__.py b/qy_lty/userapp/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qy_lty/userapp/management/commands/seed_affinity.py b/qy_lty/userapp/management/commands/seed_affinity.py new file mode 100644 index 0000000..c046771 --- /dev/null +++ b/qy_lty/userapp/management/commands/seed_affinity.py @@ -0,0 +1,183 @@ +"""P1-10 默认数据 seed 命令 + +用法: + python manage.py seed_affinity # 写入默认数据,已存在则跳过 + python manage.py seed_affinity --force # 强制覆盖已存在的默认规则/等级 + +写入内容(与「好感度系统功能与规则设计.md」§4.2 / §6.2 一致): + 1. AffinitySetting 单例(如不存在) + 2. 8 条默认互动规则 + 3. 5 个默认等级 + +幂等性: + 默认按 rule_key(规则)/ level(等级)查询,已存在则跳过。 + --force 模式下覆盖已存在记录的字段(不删旧记录)。 +""" + +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, + }, +] + + +class Command(BaseCommand): + help = '初始化好感度系统默认数据(AffinitySetting / AffinityRule / AffinityLevel)' + + def add_arguments(self, parser): + parser.add_argument( + '--force', action='store_true', + help='强制覆盖已存在记录的字段(不删旧记录)', + ) + + @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] 完成:' + f'规则 创建 {rules_created} 更新 {rules_updated},' + f'等级 创建 {levels_created} 更新 {levels_updated}' + )) + + def _seed_setting(self): + if AffinitySetting.objects.exists(): + self.stdout.write('AffinitySetting 已存在,跳过') + return + AffinitySetting.objects.create() + self.stdout.write(self.style.SUCCESS('AffinitySetting 已创建(默认值)')) + + def _seed_rules(self, force): + created = 0 + updated = 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 + + def _seed_levels(self, force): + created = 0 + updated = 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 diff --git a/qy_lty/userapp/migrations/0005_affinitysetting_affinitylevel_is_deleted_and_more.py b/qy_lty/userapp/migrations/0005_affinitysetting_affinitylevel_is_deleted_and_more.py new file mode 100644 index 0000000..3aeb39d --- /dev/null +++ b/qy_lty/userapp/migrations/0005_affinitysetting_affinitylevel_is_deleted_and_more.py @@ -0,0 +1,227 @@ +# Generated by Django 5.2.12 on 2026-04-30 10:08 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('device_interaction', '0003_userdevice_affinity_level_userdevice_favorability_and_more'), + ('userapp', '0004_affinitylevel_affinityrule'), + ] + + operations = [ + migrations.CreateModel( + name='AffinitySetting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('initial_affinity', models.IntegerField(default=10, help_text='新建 UserDevice 绑定时的初始好感度', verbose_name='初始好感度')), + ('max_affinity', models.IntegerField(default=100, help_text='好感度上限(管理员手动调整也不能突破)', verbose_name='最大好感度')), + ('daily_cap', models.IntegerField(default=20, help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)', verbose_name='每日全局增长上限')), + ('decay_rate', models.IntegerField(default=2, verbose_name='全局衰减速率(点/天)')), + ('decay_threshold', models.IntegerField(default=3, help_text='设备多少天不互动后开始衰减', verbose_name='衰减开始天数')), + ('decay_min_decay', models.IntegerField(default=1, verbose_name='单日衰减最小值')), + ('decay_max_decay', models.IntegerField(default=3, verbose_name='单日衰减最大值')), + ('decay_cap', models.IntegerField(default=5, verbose_name='单日衰减上限')), + ('decay_min_floor', models.IntegerField(default=0, help_text='好感度不会低于此值', verbose_name='衰减下限')), + ('enable_notify', models.BooleanField(default=True, verbose_name='启用变化通知')), + ('enable_rewards', models.BooleanField(default=True, verbose_name='启用等级奖励')), + ('notify_decay', models.BooleanField(default=True, verbose_name='衰减时通知')), + ('timezone', models.CharField(default='Asia/Shanghai', help_text='自然日 0 点重置基准(全用户统一)', max_length=50, verbose_name='时区')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '好感度系统设置', + 'verbose_name_plural': '好感度系统设置', + }, + ), + migrations.AddField( + model_name='affinitylevel', + name='is_deleted', + field=models.BooleanField(default=False, verbose_name='已删除(软删除)'), + ), + migrations.AddField( + model_name='affinitylevel', + name='is_enabled', + field=models.BooleanField(default=True, verbose_name='已启用'), + ), + migrations.AddField( + model_name='affinitylevel', + name='max_affinity', + field=models.IntegerField(default=0, help_text='等级好感度区间上限(闭区间)', verbose_name='最大好感度'), + ), + migrations.AddField( + model_name='affinitylevel', + name='min_affinity', + field=models.IntegerField(default=0, help_text='等级好感度区间下限(闭区间)', verbose_name='最小好感度'), + ), + migrations.AddField( + model_name='affinitylevel', + name='reward_currency', + field=models.IntegerField(default=0, verbose_name='虚拟货币奖励'), + ), + migrations.AddField( + model_name='affinitylevel', + name='reward_items', + field=models.JSONField(default=list, help_text='道具列表,如 [{"item_id": 1, "qty": 2}]', verbose_name='道具奖励'), + ), + migrations.AddField( + model_name='affinitylevel', + name='reward_type', + field=models.CharField(choices=[('unlock', '内容解锁'), ('item', '道具奖励'), ('currency', '虚拟货币'), ('mixed', '混合奖励'), ('none', '无奖励')], default='unlock', max_length=10, verbose_name='奖励类型'), + ), + migrations.AddField( + model_name='affinitylevel', + name='unlock_content', + field=models.TextField(blank=True, help_text='展示文案,描述该等级解锁的功能/外观/剧情等', verbose_name='解锁内容'), + ), + migrations.AddField( + model_name='affinityrule', + name='cooldown_seconds', + field=models.IntegerField(default=0, help_text='同一设备同一规则的最小触发间隔,0 表示无冷却', verbose_name='冷却时间(秒)'), + ), + migrations.AddField( + model_name='affinityrule', + name='daily_cap', + field=models.IntegerField(default=20, help_text='本规则在每台设备每日累计变化上限(绝对值)', verbose_name='每日上限'), + ), + migrations.AddField( + model_name='affinityrule', + name='is_deleted', + field=models.BooleanField(default=False, verbose_name='已删除(软删除)'), + ), + migrations.AddField( + model_name='affinityrule', + name='is_enabled', + field=models.BooleanField(default=True, verbose_name='已启用'), + ), + migrations.AddField( + model_name='affinityrule', + name='is_negative', + field=models.BooleanField(default=False, verbose_name='是否负向规则'), + ), + migrations.AddField( + model_name='affinityrule', + name='max_change', + field=models.IntegerField(default=1, help_text='单次变化最大值(含),实际值在 [min, max] 之间随机取整数', verbose_name='最大变化值'), + ), + migrations.AddField( + model_name='affinityrule', + name='max_count_per_day', + field=models.IntegerField(blank=True, help_text='trigger_type=companion_time 时使用:每台设备每日最多触发次数', null=True, verbose_name='每日最多次数'), + ), + migrations.AddField( + model_name='affinityrule', + name='min_change', + field=models.IntegerField(default=1, help_text='单次变化最小值,负数表示扣分', verbose_name='最小变化值'), + ), + migrations.AddField( + model_name='affinityrule', + name='min_continuous_minutes', + field=models.IntegerField(blank=True, help_text='trigger_type=companion_time 时使用:满 X 分钟触发 1 次', null=True, verbose_name='最小连续分钟数'), + ), + migrations.AddField( + model_name='affinityrule', + name='rule_key', + field=models.CharField(blank=True, help_text='代码标识,客户端事件通过此 key 匹配规则(如 chat/sing/dance/touch...)', max_length=50, null=True, unique=True, verbose_name='规则代码'), + ), + migrations.AddField( + model_name='affinityrule', + name='single_cap', + field=models.IntegerField(default=10, help_text='单次变化绝对值上限(保护性钳位)', verbose_name='单次上限'), + ), + migrations.AddField( + model_name='affinityrule', + name='trigger_type', + field=models.CharField(choices=[('action', '动作触发'), ('companion_time', '陪伴时长'), ('decay', '衰减')], default='action', max_length=20, verbose_name='触发类型'), + ), + migrations.AlterField( + model_name='affinitylevel', + name='required_points', + field=models.IntegerField(default=0, help_text='已弃用,使用 min_affinity/max_affinity', verbose_name='所需积分(已弃用)'), + ), + migrations.AlterField( + model_name='affinitylevel', + name='rewards', + field=models.JSONField(default=list, help_text='已弃用,使用 reward_currency/reward_items', verbose_name='奖励(已弃用)'), + ), + migrations.AlterField( + model_name='affinityrule', + name='daily_limit', + field=models.IntegerField(blank=True, help_text='已弃用,使用 daily_cap', null=True, verbose_name='每日上限(已弃用)'), + ), + migrations.AlterField( + model_name='affinityrule', + name='is_active', + field=models.BooleanField(default=True, help_text='已弃用,使用 is_enabled', verbose_name='已启用(已弃用)'), + ), + migrations.AlterField( + model_name='affinityrule', + name='points', + field=models.IntegerField(default=0, help_text='已弃用,使用 min_change/max_change', verbose_name='积分(已弃用)'), + ), + migrations.CreateModel( + name='AffinityLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rule_key', models.CharField(blank=True, help_text='冗余 rule_key 文本,规则被删除后仍可读', max_length=50, verbose_name='规则代码(冗余)')), + ('change_value', models.IntegerField(help_text='正负整数,含符号', verbose_name='变化值')), + ('before_value', models.IntegerField(verbose_name='变化前值')), + ('after_value', models.IntegerField(verbose_name='变化后值')), + ('source', models.CharField(choices=[('device_event', '设备端事件'), ('mobile_event', '手机端事件'), ('system_decay', '系统衰减'), ('admin_adjust_single', '管理员单次调整'), ('admin_adjust_batch', '管理员批量调整')], max_length=30, verbose_name='来源')), + ('event_id', models.CharField(blank=True, db_index=True, help_text='客户端事件 UUID,用于幂等去重', max_length=64, verbose_name='事件ID')), + ('operator_admin_id', models.IntegerField(blank=True, help_text='source=admin_adjust_* 时记录操作管理员', null=True, verbose_name='操作管理员ID')), + ('reason', models.TextField(blank=True, help_text='管理员调整时填写的原因/备注', verbose_name='操作原因')), + ('metadata', models.JSONField(blank=True, default=dict, help_text='扩展字段,如对话 message_id、设备状态等', verbose_name='元数据')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')), + ('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='affinity_logs', to='device_interaction.userdevice', verbose_name='用户设备绑定')), + ('rule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='logs', to='userapp.affinityrule', verbose_name='规则')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='affinity_logs', to=settings.AUTH_USER_MODEL, verbose_name='用户')), + ], + options={ + 'verbose_name': '好感度变化日志', + 'verbose_name_plural': '好感度变化日志', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['device', '-created_at'], name='userapp_aff_device__42aa0b_idx'), models.Index(fields=['user', '-created_at'], name='userapp_aff_user_id_ab2869_idx'), models.Index(fields=['rule_key', '-created_at'], name='userapp_aff_rule_ke_6572b7_idx'), models.Index(fields=['source', '-created_at'], name='userapp_aff_source_4f0798_idx')], + 'constraints': [models.UniqueConstraint(condition=models.Q(('event_id__gt', '')), fields=('event_id',), name='unique_affinity_event_id')], + }, + ), + migrations.CreateModel( + name='UserAffinityDailyCounter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(db_index=True, help_text='Asia/Shanghai 时区的自然日', verbose_name='日期')), + ('accumulated_change', models.IntegerField(default=0, verbose_name='累计变化(含符号)')), + ('trigger_count', models.IntegerField(default=0, verbose_name='触发次数')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_counters', to='device_interaction.userdevice', verbose_name='用户设备绑定')), + ('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_counters', to='userapp.affinityrule', verbose_name='规则')), + ], + options={ + 'verbose_name': '好感度每日计数器', + 'verbose_name_plural': '好感度每日计数器', + 'indexes': [models.Index(fields=['date'], name='userapp_use_date_c2efd8_idx')], + 'unique_together': {('device', 'rule', 'date')}, + }, + ), + migrations.CreateModel( + name='UserLevelRewardGrant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.IntegerField(verbose_name='等级')), + ('granted_at', models.DateTimeField(auto_now_add=True, verbose_name='发放时间')), + ('reward_snapshot', models.JSONField(default=dict, help_text='发放时的奖励内容快照(防 AffinityLevel 后续修改)', verbose_name='奖励快照')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='level_reward_grants', to='device_interaction.userdevice', verbose_name='用户设备绑定')), + ], + options={ + 'verbose_name': '等级奖励发放记录', + 'verbose_name_plural': '等级奖励发放记录', + 'ordering': ['-granted_at'], + 'unique_together': {('device', 'level')}, + }, + ), + ] diff --git a/qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py b/qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py new file mode 100644 index 0000000..e6a65da --- /dev/null +++ b/qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py @@ -0,0 +1,100 @@ +"""P1-09 数据迁移:把 ParadiseUser.favorability 写到该用户主设备 UserDevice.favorability + +迁移策略: + 1. 遍历所有 favorability > 0 的用户 + 2. 找该用户的主设备(is_primary=True),无主设备则取最近绑定的设备 + 3. 写入 UserDevice.favorability + 4. 用户没有任何设备绑定 → 跳过(数据保留在 ParadiseUser,等用户首次绑定时由 + 业务层处理) + +幂等性: + 重复执行不会重复写入(按 favorability=10 默认值判断,已迁移过的设备值非默认 + 或已被业务层覆盖时不再重写)。 + +注意: + 1. 旧的 ParadiseUser.favorability 字段保留不删,由后续版本统一清理 + 2. AffinitySetting 单例和默认 8 条规则、5 个等级由 management command + (seed_affinity, P1-10) 处理,不在数据迁移中 +""" + +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') + + migrated_count = 0 + skipped_no_device = 0 + skipped_zero = 0 + + for user in ParadiseUser.objects.iterator(): + favorability = getattr(user, 'favorability', 0) or 0 + if favorability <= 0: + skipped_zero += 1 + continue + + # 优先取主设备,没有就取最近绑定的设备 + primary = ( + UserDevice.objects + .filter(user_id=user.id, is_primary=True) + .order_by('-bound_at') + .first() + ) + target = primary or ( + UserDevice.objects + .filter(user_id=user.id) + .order_by('-bound_at') + .first() + ) + if target is None: + skipped_no_device += 1 + continue + + # 仅当目标值仍是默认值(10)时写入,避免覆盖业务层后续修改 + if target.favorability == 10: + target.favorability = favorability + target.save(update_fields=['favorability']) + migrated_count += 1 + + print( + f"\n[P1-09] favorability 数据迁移完成:" + f"成功 {migrated_count},无设备跳过 {skipped_no_device}," + f"零值跳过 {skipped_zero}" + ) + + +def migrate_favorability_backward(apps, schema_editor): + """回滚:把 UserDevice.favorability 写回 ParadiseUser.favorability(取主设备值)""" + ParadiseUser = apps.get_model('userapp', 'ParadiseUser') + UserDevice = apps.get_model('device_interaction', 'UserDevice') + + 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 + + print(f"\n[P1-09 rollback] 回滚 favorability {rolled_back} 条到 ParadiseUser") + + +class Migration(migrations.Migration): + + dependencies = [ + ('userapp', '0005_affinitysetting_affinitylevel_is_deleted_and_more'), + ('device_interaction', '0003_userdevice_affinity_level_userdevice_favorability_and_more'), + ] + + operations = [ + migrations.RunPython( + migrate_favorability_forward, + migrate_favorability_backward, + ), + ] diff --git a/qy_lty/userapp/models.py b/qy_lty/userapp/models.py index 0356cd0..706fa18 100644 --- a/qy_lty/userapp/models.py +++ b/qy_lty/userapp/models.py @@ -77,12 +77,88 @@ class ParadiseUser(AbstractUser): class AffinityRule(models.Model): - """好感度规则:定义哪些行为可以获得好感度积分""" + """好感度规则:定义哪些行为可以获得好感度积分 + + P1-01/P1-02 扩展后字段: + rule_key: 代码标识,客户端事件通过此匹配规则 + trigger_type: 触发类型 (action / companion_time / decay) + min_change/max_change: 单次变化范围([min, max] 闭区间随机取整数) + single_cap/daily_cap/cooldown_seconds: 上限保护 + is_negative/is_enabled/is_deleted: 标记 + min_continuous_minutes/max_count_per_day: 陪伴时长专用字段 + 旧字段 points/daily_limit/is_active 保留作为兼容字段,下个版本删除。 + """ + + TRIGGER_TYPE_CHOICES = ( + ('action', '动作触发'), + ('companion_time', '陪伴时长'), + ('decay', '衰减'), + ) + + # 业务字段 + rule_key = models.CharField( + '规则代码', max_length=50, unique=True, null=True, blank=True, + help_text='代码标识,客户端事件通过此 key 匹配规则(如 chat/sing/dance/touch...)' + ) name = models.CharField('规则名称', max_length=100) description = models.TextField('规则描述', blank=True) - points = models.IntegerField('积分', default=0) - daily_limit = models.IntegerField('每日上限', null=True, blank=True) - is_active = models.BooleanField('已启用', default=True) + trigger_type = models.CharField( + '触发类型', max_length=20, choices=TRIGGER_TYPE_CHOICES, default='action' + ) + + # 变化值 + min_change = models.IntegerField( + '最小变化值', default=1, + help_text='单次变化最小值,负数表示扣分' + ) + max_change = models.IntegerField( + '最大变化值', default=1, + help_text='单次变化最大值(含),实际值在 [min, max] 之间随机取整数' + ) + + # 上限保护 + single_cap = models.IntegerField( + '单次上限', default=10, + help_text='单次变化绝对值上限(保护性钳位)' + ) + daily_cap = models.IntegerField( + '每日上限', default=20, + help_text='本规则在每台设备每日累计变化上限(绝对值)' + ) + cooldown_seconds = models.IntegerField( + '冷却时间(秒)', default=0, + help_text='同一设备同一规则的最小触发间隔,0 表示无冷却' + ) + + # 标记 + is_negative = models.BooleanField('是否负向规则', default=False) + is_enabled = models.BooleanField('已启用', default=True) + is_deleted = models.BooleanField('已删除(软删除)', default=False) + + # 陪伴时长专用字段(P1-02) + min_continuous_minutes = models.IntegerField( + '最小连续分钟数', null=True, blank=True, + help_text='trigger_type=companion_time 时使用:满 X 分钟触发 1 次' + ) + max_count_per_day = models.IntegerField( + '每日最多次数', null=True, blank=True, + help_text='trigger_type=companion_time 时使用:每台设备每日最多触发次数' + ) + + # 兼容旧字段(已弃用,下个版本删除) + points = models.IntegerField( + '积分(已弃用)', default=0, + help_text='已弃用,使用 min_change/max_change' + ) + daily_limit = models.IntegerField( + '每日上限(已弃用)', null=True, blank=True, + help_text='已弃用,使用 daily_cap' + ) + is_active = models.BooleanField( + '已启用(已弃用)', default=True, + help_text='已弃用,使用 is_enabled' + ) + created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) @@ -92,16 +168,70 @@ class AffinityRule(models.Model): ordering = ['-created_at'] def __str__(self): - return self.name + return f"{self.name} ({self.rule_key or '-'})" class AffinityLevel(models.Model): - """好感度等级:定义不同好感度区间对应的等级和奖励""" + """好感度等级:定义不同好感度区间对应的等级和奖励 + + P1-03 扩展后字段: + min_affinity/max_affinity: 等级好感度区间(闭区间) + unlock_content: 解锁内容文案 + reward_type: 奖励类型 (unlock / item / currency / mixed / none) + reward_currency/reward_items: 奖励内容 + is_enabled/is_deleted: 标记 + 旧字段 required_points/rewards 保留作为兼容字段,下个版本删除。 + """ + + REWARD_TYPE_CHOICES = ( + ('unlock', '内容解锁'), + ('item', '道具奖励'), + ('currency', '虚拟货币'), + ('mixed', '混合奖励'), + ('none', '无奖励'), + ) + level = models.IntegerField('等级', unique=True) name = models.CharField('等级名称', max_length=50) description = models.TextField('等级描述', blank=True) - required_points = models.IntegerField('所需积分') - rewards = models.JSONField('奖励', default=list) + + # 区间(P1-03) + min_affinity = models.IntegerField( + '最小好感度', default=0, + help_text='等级好感度区间下限(闭区间)' + ) + max_affinity = models.IntegerField( + '最大好感度', default=0, + help_text='等级好感度区间上限(闭区间)' + ) + + # 解锁/奖励 + unlock_content = models.TextField( + '解锁内容', blank=True, + help_text='展示文案,描述该等级解锁的功能/外观/剧情等' + ) + reward_type = models.CharField( + '奖励类型', max_length=10, choices=REWARD_TYPE_CHOICES, default='unlock' + ) + reward_currency = models.IntegerField('虚拟货币奖励', default=0) + reward_items = models.JSONField( + '道具奖励', default=list, + help_text='道具列表,如 [{"item_id": 1, "qty": 2}]' + ) + + is_enabled = models.BooleanField('已启用', default=True) + is_deleted = models.BooleanField('已删除(软删除)', default=False) + + # 兼容旧字段(已弃用) + required_points = models.IntegerField( + '所需积分(已弃用)', default=0, + help_text='已弃用,使用 min_affinity/max_affinity' + ) + rewards = models.JSONField( + '奖励(已弃用)', default=list, + help_text='已弃用,使用 reward_currency/reward_items' + ) + created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) @@ -112,3 +242,229 @@ class AffinityLevel(models.Model): def __str__(self): return f"Lv{self.level} {self.name}" + + +class AffinitySetting(models.Model): + """好感度系统全局参数(单例表)— P1-04 + + 所有字段对应「好感度系统功能与规则设计.md」§3.2 全局参数 + §5.1 衰减字段。 + 通过 get_solo() 取唯一实例,save() 强制单例。 + """ + + # 全局参数 + initial_affinity = models.IntegerField( + '初始好感度', default=10, + help_text='新建 UserDevice 绑定时的初始好感度' + ) + max_affinity = models.IntegerField( + '最大好感度', default=100, + help_text='好感度上限(管理员手动调整也不能突破)' + ) + daily_cap = models.IntegerField( + '每日全局增长上限', default=20, + help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)' + ) + + # 衰减 + decay_rate = models.IntegerField('全局衰减速率(点/天)', default=2) + decay_threshold = models.IntegerField( + '衰减开始天数', default=3, + help_text='设备多少天不互动后开始衰减' + ) + decay_min_decay = models.IntegerField('单日衰减最小值', default=1) + decay_max_decay = models.IntegerField('单日衰减最大值', default=3) + decay_cap = models.IntegerField('单日衰减上限', default=5) + decay_min_floor = models.IntegerField( + '衰减下限', default=0, + help_text='好感度不会低于此值' + ) + + # 通知/奖励 + enable_notify = models.BooleanField('启用变化通知', default=True) + enable_rewards = models.BooleanField('启用等级奖励', default=True) + notify_decay = models.BooleanField('衰减时通知', default=True) + + # 时区 + timezone = models.CharField( + '时区', max_length=50, default='Asia/Shanghai', + help_text='自然日 0 点重置基准(全用户统一)' + ) + + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + verbose_name = '好感度系统设置' + verbose_name_plural = '好感度系统设置' + + def __str__(self): + return f"AffinitySetting(initial={self.initial_affinity}, max={self.max_affinity})" + + def save(self, *args, **kwargs): + # 强制单例:新增时如果已有记录则覆盖到现有 pk + if not self.pk and AffinitySetting.objects.exists(): + existing = AffinitySetting.objects.first() + self.pk = existing.pk + super().save(*args, **kwargs) + + @classmethod + def get_solo(cls): + """获取单例实例,不存在则用默认值创建""" + instance, _ = cls.objects.get_or_create(pk=1) + return instance + + +class AffinityLog(models.Model): + """好感度变化日志 — P1-05 + + 记录每次好感度变动的来源、值、上下文。 + rule 用 SET_NULL,规则被删除后日志保留;rule_key 冗余文本字段保证可读性。 + event_id 用于客户端事件幂等去重(部分唯一索引)。 + """ + + SOURCE_CHOICES = ( + ('device_event', '设备端事件'), + ('mobile_event', '手机端事件'), + ('system_decay', '系统衰减'), + ('admin_adjust_single', '管理员单次调整'), + ('admin_adjust_batch', '管理员批量调整'), + ) + + # 关联对象 + user = models.ForeignKey( + 'userapp.ParadiseUser', on_delete=models.CASCADE, + verbose_name='用户', related_name='affinity_logs' + ) + device = models.ForeignKey( + 'device_interaction.UserDevice', on_delete=models.SET_NULL, + verbose_name='用户设备绑定', related_name='affinity_logs', + null=True, blank=True + ) + rule = models.ForeignKey( + 'AffinityRule', on_delete=models.SET_NULL, + verbose_name='规则', related_name='logs', + null=True, blank=True + ) + rule_key = models.CharField( + '规则代码(冗余)', max_length=50, blank=True, + help_text='冗余 rule_key 文本,规则被删除后仍可读' + ) + + # 变化数据 + change_value = models.IntegerField('变化值', help_text='正负整数,含符号') + before_value = models.IntegerField('变化前值') + after_value = models.IntegerField('变化后值') + + # 来源 + source = models.CharField('来源', max_length=30, choices=SOURCE_CHOICES) + event_id = models.CharField( + '事件ID', max_length=64, blank=True, db_index=True, + help_text='客户端事件 UUID,用于幂等去重' + ) + + # 管理员调整专用 + operator_admin_id = models.IntegerField( + '操作管理员ID', null=True, blank=True, + help_text='source=admin_adjust_* 时记录操作管理员' + ) + reason = models.TextField( + '操作原因', blank=True, + help_text='管理员调整时填写的原因/备注' + ) + + # 扩展上下文 + metadata = models.JSONField( + '元数据', default=dict, blank=True, + help_text='扩展字段,如对话 message_id、设备状态等' + ) + + created_at = models.DateTimeField('创建时间', auto_now_add=True, db_index=True) + + class Meta: + verbose_name = '好感度变化日志' + verbose_name_plural = '好感度变化日志' + ordering = ['-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 = [ + UniqueConstraint( + fields=['event_id'], + condition=Q(event_id__gt=''), + name='unique_affinity_event_id', + ), + ] + + def __str__(self): + return f"#{self.id} {self.rule_key or self.source} {self.before_value}->{self.after_value}" + + +class UserAffinityDailyCounter(models.Model): + """好感度每日计数器(数据库兜底,热路径走 Redis)— P1-06 + + Redis key 命名约定: + daily:{device_id}:{rule_key}:{YYYYMMDD} -> trigger_count + accumulated_change + daily:{device_id}:_global:{YYYYMMDD} -> 全局日上限(仅正向汇总) + 每晚定时任务把 Redis 当日数据落库,作为审计/查询兜底。 + """ + + device = models.ForeignKey( + 'device_interaction.UserDevice', on_delete=models.CASCADE, + verbose_name='用户设备绑定', related_name='daily_counters' + ) + rule = models.ForeignKey( + 'AffinityRule', on_delete=models.CASCADE, + verbose_name='规则', related_name='daily_counters' + ) + date = models.DateField( + '日期', db_index=True, + help_text='Asia/Shanghai 时区的自然日' + ) + accumulated_change = models.IntegerField('累计变化(含符号)', default=0) + trigger_count = models.IntegerField('触发次数', default=0) + + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + verbose_name = '好感度每日计数器' + verbose_name_plural = '好感度每日计数器' + unique_together = [('device', 'rule', 'date')] + indexes = [ + models.Index(fields=['date']), + ] + + def __str__(self): + return f"{self.device_id}/{self.rule_id}/{self.date}={self.accumulated_change}" + + +class UserLevelRewardGrant(models.Model): + """等级奖励发放记录 — P1-07 + + (device, level) 唯一,永久幂等防止重复发放。 + 衰减回升再次跨过同等级也不补发;新增等级时已在区间的设备不立即触发。 + reward_snapshot 保存发放时的奖励内容快照,避免 AffinityLevel 后续修改影响审计。 + """ + + device = models.ForeignKey( + 'device_interaction.UserDevice', on_delete=models.CASCADE, + verbose_name='用户设备绑定', related_name='level_reward_grants' + ) + level = models.IntegerField('等级') + granted_at = models.DateTimeField('发放时间', auto_now_add=True) + reward_snapshot = models.JSONField( + '奖励快照', default=dict, + help_text='发放时的奖励内容快照(防 AffinityLevel 后续修改)' + ) + + class Meta: + verbose_name = '等级奖励发放记录' + verbose_name_plural = '等级奖励发放记录' + unique_together = [('device', 'level')] + ordering = ['-granted_at'] + + def __str__(self): + return f"{self.device_id}/Lv{self.level}@{self.granted_at}"