feat: implement affinity (favorability) system
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 8m44s

- Add affinity level/setting models and migrations
- Migrate favorability data to UserDevice
- Add management commands for userapp
- Add admin CLAUDE.md and docs
- Update affinity system design doc and task checklist
- Update device_interaction and userapp models

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pmc 2026-05-06 17:18:30 +08:00
parent a13a081105
commit 2d82b2ef7f
14 changed files with 1590 additions and 96 deletions

View File

@ -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 timerend 计算时长 → `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 |

View File

@ -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 关键指标卡片 ### 3.1 关键指标卡片
所有指标的**统计基本单位是「设备」**(每条 `UserDevice` 绑定算一份)。
| 指标 | 含义 | 数据源 | | 指标 | 含义 | 数据源 |
|---|---|---| |---|---|---|
| 平均好感度 | 所有用户 `favorability` 平均值 | 聚合 `ParadiseUser` | | 平均好感度 | 所有用户-设备绑定」的 `favorability` 平均值 | 聚合 `UserDevice` |
| 最高好感度 | 系统中达到上限的用户数 | `favorability = max_affinity` 计数 | | 最高好感度 | 达到 `max_affinity` 上限的**设备数** | `UserDevice.favorability = max_affinity` 计数 |
| 互动次数/日 | 当天触发的所有规则次数总和 | 聚合 `AffinityLog`(待建) | | 互动次数/日 | 当天触发的所有规则次数总和 | 聚合 `AffinityLog` |
| 活跃用户比例 | 近 N 天有互动的用户占比 | 聚合用户活跃状态 | | 活跃用户比例 | 近 N 天有互动的**设备**占比 | 聚合 `UserDevice.last_active_at` |
### 3.2 全局参数AffinitySetting ### 3.2 全局参数AffinitySetting,单例
| 字段 | 默认值 | 说明 | | 字段 | 默认值 | 说明 |
|---|---|---| |---|---|---|
| `initial_affinity` | 10 | 新用户创建时的初始好感度 | | `initial_affinity` | 10 | 新建 `UserDevice` 绑定时的初始好感度 |
| `max_affinity` | 100 | 好感度上限(所有规则累计不会超过此值 | | `max_affinity` | 100 | 好感度上限(管理员手动调整也不能突破 |
| `daily_cap` | 20 | 单用户每日好感度**净增长**上限(跨规则汇总) | | `daily_cap` | 20 | **每台设备**每日好感度净增长上限(跨规则汇总) |
| `decay_rate` | 2 点/天 | 全局默认衰减速率(衰减规则可覆盖) | | `decay_rate` | 2 点/天 | 全局默认衰减速率(衰减规则可覆盖) |
| `decay_threshold` | 3 天 | 不互动多少天后开始衰减 | | `decay_threshold` | 3 天 | 设备多少天不互动后开始衰减 |
| `enable_notify` | true | 好感度变化是否推送通知 | | `enable_notify` | true | 好感度变化是否推送通知 |
| `enable_rewards` | true | 是否启用等级奖励发放 | | `enable_rewards` | true | 是否启用等级奖励发放 |
| `timezone` | `Asia/Shanghai` | 自然日 0 点重置基准(全用户统一) |
**规则** **规则**
- `initial_affinity` 只影响**新用户**,不回溯修改已有用户 - `initial_affinity` 只影响**新建的设备绑定**,不回溯修改已有绑定
- `max_affinity` 调低后,**已超过的用户保留原值**,但不再增加 - `max_affinity` 调低后,**已超过的设备保留原值**,但不再增加
- `daily_cap` 是**跨所有正向规则**的顶层限制,触达后当日所有增益无效(衰减不受此限) - `daily_cap` 是**每设备**跨所有正向规则的顶层日增长上限,触达后当日所有正向增益无效(衰减不受此限)
- `timezone` 决定"今天"的边界 — 默认 `Asia/Shanghai`0 点(北京时间)重置所有日上限计数器
--- ---
@ -73,15 +82,17 @@
| `type` / `rule_key` | enum | `chat` | **代码标识**,服务端事件通过它匹配规则,不可重复 | | `type` / `rule_key` | enum | `chat` | **代码标识**,服务端事件通过它匹配规则,不可重复 |
| `description` | string | `与洛天依进行对话` | 展示描述 | | `description` | string | `与洛天依进行对话` | 展示描述 |
| `minChange` | int | 1 | 单次好感度变化最小值 | | `minChange` | int | 1 | 单次好感度变化最小值 |
| `maxChange` | int | 5 | 单次好感度变化最大值(最终取 `[min, max]` 的随机整数) | | `maxChange` | int | 5 | 单次好感度变化最大值 |
| `singleCap` | int | 5 | 单次变化绝对值上限(保护性钳位,防止数据异常) | | `singleCap` | int | 5 | 单次变化绝对值上限(保护性钳位,防止数据异常) |
| `dailyCap` | int | 15 | **本规则**每日累计变化上限(绝对值) | | `dailyCap` | int | 15 | **本规则在每台设备**每日累计变化上限(绝对值) |
| `isNegative` | bool | false | 是否负向规则。正向用正数,负向用负数 | | `isNegative` | bool | false | 是否负向规则。正向用正数,负向用负数 |
| `isEnabled` | bool | true | 是否启用,禁用后事件不触发 | | `isEnabled` | bool | true | 是否启用,禁用后事件不触发 |
**变化值算法**:每次触发,服务端在 `[minChange, maxChange]` 闭区间内**随机取整数**作为本次变化值。如果两个值相等就是固定值,不等则随机。
### 4.2 默认规则集8 条) ### 4.2 默认规则集8 条)
| rule_key | 名称 | 范围 | 单次/日 | 正负 | 触发来源 | | rule_key | 名称 | 范围 | 单次/日(每设备) | 正负 | 触发来源 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| `card` | 使用卡片 | +1 ~ +3 | 3 / 10 | 正 | 手机端(使用卡片 API | | `card` | 使用卡片 | +1 ~ +3 | 3 / 10 | 正 | 手机端(使用卡片 API |
| `chat` | 对话 | +1 ~ +5 | 5 / 15 | 正 | 设备端 + 手机端(聊天消息) | | `chat` | 对话 | +1 ~ +5 | 5 / 15 | 正 | 设备端 + 手机端(聊天消息) |
@ -95,44 +106,68 @@
### 4.3 触发计算流程 ### 4.3 触发计算流程
``` ```
收到事件 (user_id, rule_key, 来源上下文) 收到事件 (user_id, device_id, rule_key, 来源上下文)
取规则 → 若 is_enabled=false丢弃 校验device_id 必须属于 user_idUserDevice 存在)
② 冷却检查Redisaffinity:cd:{user}:{rule_key} ② 取规则 → 若 is_enabled=false丢弃
③ 冷却检查Redisaffinity: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 钳位 ↓ 按 single_cap 钳位
⑥ 原子更新 ParadiseUser.favorability ⑦ 原子更新 UserDevice.favorability
↓ 钳位到 [0, max_affinity] ↓ 钳位到 [0, max_affinity]
⑦ 写 AffinityLog、更新计数器 ⑧ 写 AffinityLog、更新计数器、刷新 UserDevice.last_active_at
判断是否跨越等级边界 判断是否跨越等级边界
│ 是 → 触发等级变更事件(发奖励 + 推通知) │ 是 → 触发等级变更事件(发奖励 + 推通知,详见 6.3
通过 WebSocket 向用户的所有在线端推送 affinity_update 通过 WebSocket 向用户的所有在线端推送 affinity_updatepayload 含 device_id
``` ```
### 4.4 规则设计约定 ### 4.4 规则设计约定
- **单一写入入口**:所有好感度变化必须经由服务端统一入口,客户端不能直接增减。 - **单一写入入口**:所有好感度变化必须经由服务端统一入口,客户端不能直接增减
- **rule_key 即契约**:客户端事件不携带分值,只报「我触发了 `gift` 规则」,具体加多少由服务端按规则算。规则可被管理员随时调整,客户端无需改动。 - **rule_key 即契约**:客户端事件不携带分值,只报「设备 X 触发了 `gift` 规则」,具体加多少由服务端按规则算
- **幂等防护**:同一 `rule_key` + 同一设备事件 ID 在冷却窗口内只生效一次,防抖防重复。 - **幂等防护**:同一 `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 点/天 | 平均每日衰减点数 | | `decay_rate_per_day` | 2 点/天 | 平均每日衰减点数 |
| `min_decay` | 1 | 单日衰减最小值 | | `min_decay` | 1 | 单日衰减最小值 |
| `max_decay` | 3 | 单日衰减最大值 | | `max_decay` | 3 | 单日衰减最大值 |
| `decay_cap` | 5 点/天 | 单日衰减上限(保护性) | | `decay_cap` | 5 点/天 | 单设备单日衰减上限(保护性) |
| `min_floor` | 0 | **衰减下限**,好感度不会低于此值 | | `min_floor` | 0 | **衰减下限**,好感度不会低于此值 |
| `notify_decay` | true | 是否通知用户「好感度下降了」 | | `notify_decay` | true | 是否通知用户「好感度下降了」 |
### 5.2 衰减执行 ### 5.2 衰减执行
- **频次**:每日 00:30 由定时任务统一跑一次 - **频次**:每日 00:30Asia/Shanghai由定时任务统一跑一次
- **命中对象**`last_active_at < now - decay_start_days` 的用户 - **命中对象**`UserDevice.last_active_at < now - decay_start_days` 的**设备**
- **落库**:衰减也写 `AffinityLog``source='system_decay'` - **每台设备独立判断**:同一用户多设备只衰减不活跃的那几台
- **下限保护**:若用户当前好感度 ≤ `min_floor` 则跳过 - **落库**:每条衰减都写 `AffinityLog``source='system_decay'``device_id` 必填
- **与互动的关系**:用户当天有互动即**重置不活跃计数**,次日不衰减 - **下限保护**:若设备当前好感度 ≤ `min_floor` 则跳过
- **与互动的关系**:设备当天有互动即**重置不活跃计数**`last_active_at` 刷新),次日不衰减
### 5.3 设计权衡 ### 5.3 设计权衡
- 衰减**不占用** `daily_cap` 全局日上限(因为它是扣减,不是增益) - 衰减**不占用**全局日上限(`AffinitySetting.daily_cap` 只管正向增益)
- 衰减日志会产生大量记录,可考虑按天合并写一条,减少 `AffinityLog` 膨胀 - 衰减日志按**设备 × 天**写一条(一天对一台设备最多一条衰减记录),减少 `AffinityLog` 膨胀
- 若「当日互动」和「当日衰减」同时命中,先执行衰减再执行互动(让用户感受到「我回来了 → 好感度止跌回升」) - 若「当日互动」和「当日衰减」同时命中,先执行衰减再执行互动(让用户感受到「我回来了 → 好感度止跌回升」)
--- ---
@ -195,13 +231,27 @@
### 6.3 等级变化规则 ### 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 - 区间**不得有空隙**(如等级 2 的 max=40则等级 3 的 min 必须是 41
@ -211,44 +261,45 @@
## 七、模块 5 — 数据统计 ## 七、模块 5 — 数据统计
### 7.1 概览指标 ### 7.1 概览指标(按设备聚合)
- 平均好感度 / 中位数 / 最高好感度 - 平均好感度 / 中位数 / 最高好感度
- 活跃用户数(近 7 日有互动 - 活跃设备数(近 7 日有互动的 `UserDevice`
- 今日互动次数、今日新增好感度总量 - 今日互动次数、今日新增好感度总量
### 7.2 分布分析 ### 7.2 分布分析
- 好感度区间分布0-20、21-40… - 好感度区间分布0-20、21-40…,按设备数统计
- 各等级用户数占比 - 各等级设备数占比
- 各互动规则触发频次 Top N - 各互动规则触发频次 Top N
### 7.3 趋势分析 ### 7.3 趋势分析
- 日/周/月的平均好感度变化曲线 - 日/周/月的设备平均好感度变化曲线
- 日互动量趋势 - 日互动量趋势
- 衰减命中用户数趋势 - 衰减命中设备数趋势
### 7.4 用户级查询 ### 7.4 用户级查询
- 按用户 ID 查询其好感度当前值、等级、近期变化日志 - 按用户 ID 查询:返回该用户名下**所有设备**的好感度列表device_id、设备名、当前值、等级、最近互动时间
- 管理员手动调整(加减好感度,走 `source='admin_adjust'` 记 log - 单设备维度:可点击展开看具体某台设备的近期变化日志
- 管理员手动调整:必须**指定 `device_id`**,不能给"用户"加好感度(详见 9.1
--- ---
## 八、多端触发点一览 ## 八、多端触发点一览
好感度变化可由以下端点触发,所有端都走**同一个服务端入口** 好感度变化可由以下端点触发,所有事件**必须携带 `device_id`**,最终走同一个服务端入口
| 触发来源 | 规则 | 通道 | 位置参考 | | 触发来源 | 规则 | 通道 | device_id 来源 | 位置参考 |
|---|---|---|---| |---|---|---|---|---|
| 设备端上报「用户发起对话」 | `chat` | WebSocket | [device_interaction/consumers.py](../qy_lty/device_interaction/consumers.py) `chat_message` | | 设备端上报「用户发起对话」 | `chat` | WebSocket | 设备登录态自带 | [device_interaction/consumers.py](../qy_lty/device_interaction/consumers.py) `chat_message` |
| 设备端对话结束(陪伴时长) | `chat` 或独立 `companion_time` | WebSocket | `conversation_status` 的 begin/end | | 设备端对话结束(陪伴时长) | `companion_time` | WebSocket | 设备登录态自带 | `conversation_status` 的 begin/end |
| 手机端点击「唱歌/跳舞/抚摸」 | 对应 rule_key | WebSocket | consumers.py `sing` / `dance` / `touch` | | 手机端点击「唱歌/跳舞/抚摸」 | 对应 rule_key | WebSocket | 手机端选定的当前设备 | consumers.py `sing` / `dance` / `touch` |
| 手机端赠礼 / 喂食 / 换装 / 用道具 | 对应 rule_key | HTTP | 对应业务 ViewSet 钩子 | | 手机端赠礼 / 喂食 / 换装 / 用道具 | 对应 rule_key | HTTP | 请求体显式传 device_id | 对应业务 ViewSet 钩子 |
| 管理员手动调整 | 无 rule | HTTP Admin API | 管理后台 | | 管理员手动调整 | 无 rule | HTTP Admin API | 请求体显式传 device_id | 管理后台 |
| 衰减定时任务 | `decay` | 后台任务 | 定时调度 | | 衰减定时任务 | `decay` | 后台任务 | 任务遍历 UserDevice 时取 | 定时调度 |
**身份识别** **身份识别**
- 设备端MAC 登录获取 token → 服务端通过 `UserDevice` 找到 `user_id` - 设备端MAC 登录获取 token → 服务端通过 `UserDevice` 反查 `user_id``device_id`,事件自动绑定到该设备
- 手机端Redis token 认证 → 直接拿到 `user_id` - 手机端Redis token 认证拿到 `user_id`**事件必须显式指定要操作哪台设备**(用户在 App 里可能切换设备)
- 服务端只认 `user_id`,与触发端无关 - 服务端只认 `(user_id, device_id)` 二元组,与触发端无关
--- ---
@ -258,27 +309,34 @@
| 接口 | 方法 | 用途 | | 接口 | 方法 | 用途 |
|---|---|---| |---|---|---|
| `/api/admin/affinity/rules/` | 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/levels/` | GET/POST/PATCH/DELETE | 等级 CRUD(用户全局配置) |
| `/api/admin/affinity/settings/` | GET/PUT | 全局参数(单例) | | `/api/admin/affinity/settings/` | GET/PUT | 全局参数(单例) |
| `/api/admin/affinity/logs/` | GET | 变化日志查询(可按 user、rule、时间过滤 | | `/api/admin/affinity/logs/` | GET | 变化日志查询(可按 user / device / rule / 时间过滤) |
| `/api/admin/affinity/stats/` | GET | 统计聚合 | | `/api/admin/affinity/stats/` | GET | 统计聚合(设备维度) |
| `/api/admin/affinity/adjust/` | POST | 管理员手动调整(必须留审计) | | `/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 客户端(手机端 / 设备端共用) ### 9.2 客户端(手机端 / 设备端共用)
| 接口 | 方法 | 用途 | | 接口 | 方法 | 用途 |
|---|---|---| |---|---|---|
| `/api/user/me/affinity/` | GET | 当前好感度、等级、下一级进度、近期变化 | | `/api/user/me/affinity/` | GET | 返回**当前用户名下所有设备**的好感度列表(含 device_id、当前值、等级、下一级进度 |
| `/api/user/me/affinity/claim-reward/` | POST | 领取等级奖励 | | `/api/user/me/affinity/?device_id=xxx` | GET | 查询指定设备的详情(近期变化、已解锁内容) |
| `/api/user/me/affinity/claim-reward/` | POST | 领取奖励(必传 `device_id` + `level` |
### 9.3 WebSocket 实时推送 ### 9.3 WebSocket 实时推送
所有 WS 事件 payload 必须包含 `device_id`,客户端按 device_id 路由到对应 UI
| 事件 | 方向 | payload | | 事件 | 方向 | payload |
|---|---|---| |---|---|---|
| `affinity_update` | 服务端 → 用户 | `{change, before, after, rule_key, source}` | | `affinity_update` | 服务端 → 用户的所有在线端(手机+设备) | `{device_id, change, before, after, rule_key, source}` |
| `level_up` | 服务端 → 用户 | `{old_level, new_level, reward}` | | `level_up` | 服务端 → 用户的所有在线端 | `{device_id, old_level, new_level, rewards: [...]}`rewards 为本次跨级一次性发放的所有等级奖励列表) |
| `level_down` | 服务端 → 用户 | `{old_level, new_level}`(衰减导致降级) | | `level_down` | 服务端 → 用户的所有在线端 | `{device_id, old_level, new_level}`(衰减导致降级) |
**多设备并行**:用户绑了 3 台设备,同时在线时收到 3 条独立的 `affinity_update`,前端按 `device_id` 显示在不同卡片。
--- ---
@ -290,9 +348,12 @@
| 等级 CRUD | ✅ UI 完成 | ⚠️ 缺字段 | ⚠️ 需扩展 | — | | 等级 CRUD | ✅ UI 完成 | ⚠️ 缺字段 | ⚠️ 需扩展 | — |
| 全局设置 | ✅ UI 完成 | ❌ 无表 | ❌ 无接口 | — | | 全局设置 | ✅ UI 完成 | ❌ 无表 | ❌ 无接口 | — |
| 衰减配置 | ✅ UI 完成 | ❌ 无表 | ❌ 无接口 | ❌ 无定时任务 | | 衰减配置 | ✅ UI 完成 | ❌ 无表 | ❌ 无接口 | ❌ 无定时任务 |
| 变化日志 | ❌ 无 UI | ❌ 无表 | ❌ 无接口 | — | | **设备级好感度迁移** | — | ❌ `UserDevice.favorability` 字段缺 | — | — |
| 变化日志 | ❌ 无 UI | ❌ 无表(需带 device 维度) | ❌ 无接口 | — |
| 数据统计 | ⚠️ Mock 展示 | — | ❌ 无聚合接口 | — | | 数据统计 | ⚠️ Mock 展示 | — | ❌ 无聚合接口 | — |
| 客户端查询 | — | — | ❌ 无接口 | — | | 客户端查询(多设备列表) | — | — | ❌ 无接口 | — |
| 等级奖励发放标记 | — | ❌ `UserLevelRewardGrant` 表缺 | — | — |
| 陪伴时长规则 | ⚠️ UI 没字段 | ❌ 规则字段缺 | — | ❌ 计时器未实现 |
| WS 实时推送 | — | — | — | ❌ 未接入 | | WS 实时推送 | — | — | — | ❌ 未接入 |
| 设备/手机事件埋点 | — | — | — | ❌ 未接入 | | 设备/手机事件埋点 | — | — | — | ❌ 未接入 |
@ -306,8 +367,78 @@
|---|---| |---|---|
| rule_key | 互动规则的代码级标识(如 `chat`),客户端事件通过它匹配规则 | | rule_key | 互动规则的代码级标识(如 `chat`),客户端事件通过它匹配规则 |
| single_cap | 单次变化绝对值上限(保护性钳位) | | single_cap | 单次变化绝对值上限(保护性钳位) |
| daily_cap | 单规则每日累计变化上限 | | daily_cap规则 | 单设备单规则每日累计变化上限 |
| 全局日上限 | 跨所有正向规则的顶层日增长上限(`AffinitySetting.daily_cap` | | 全局日上限 | 单设备跨所有正向规则的顶层日增长上限(`AffinitySetting.daily_cap` |
| 冷却 | 同一用户同一规则的最小触发间隔 | | 冷却 | 同一设备同一规则的最小触发间隔 |
| source | 变化来源:`device_event` / `mobile_event` / `system_decay` / `admin_adjust` | | 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每级独立事务失败的标记为待补发后台重试 | P3service 层奖励发放) |
| 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 列决策) |
---

106
qy-lty-admin/CLAUDE.md Normal file
View File

@ -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.4App 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) —— 后端项目上下文与跨仓库联动规则

View File

@ -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 新增项目修改记录规则段落"

View File

@ -245,3 +245,29 @@ docker-compose up -d --build
- 深度定制 SimpleUI 主题 - 深度定制 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` 中文件、本地实验文件 → 不记录

View File

@ -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='最近互动时间'),
),
]

View File

@ -91,13 +91,40 @@ class Device(models.Model):
class UserDevice(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') 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') device = models.ForeignKey(Device, on_delete=models.CASCADE, verbose_name='设备', related_name='users')
nickname = models.CharField('设备昵称', max_length=100, blank=True, null=True) nickname = models.CharField('设备昵称', max_length=100, blank=True, null=True)
bound_at = models.DateTimeField('绑定时间', auto_now_add=True) bound_at = models.DateTimeField('绑定时间', auto_now_add=True)
is_primary = models.BooleanField('是否主要设备', default=False) 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: class Meta:
verbose_name = '用户设备' verbose_name = '用户设备'
verbose_name_plural = '用户设备' verbose_name_plural = '用户设备'

View File

@ -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 迁移,按依赖顺序处理跨应用 FKAffinityLog → 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=30stouch=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 字段 ### [2026-04-29] strategy B group_send 推回消息体新增 timestamp_unix 字段
配套手机端记录:`LTY_App_Project_URP/docs/修改记录.md` 同日"修复 B' 双倒真正根因:时间戳时区解析"条目。 配套手机端记录:`LTY_App_Project_URP/docs/修改记录.md` 同日"修复 B' 双倒真正根因:时间戳时区解析"条目。

View File

View File

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

View File

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

View File

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

View File

@ -77,12 +77,88 @@ class ParadiseUser(AbstractUser):
class AffinityRule(models.Model): 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) name = models.CharField('规则名称', max_length=100)
description = models.TextField('规则描述', blank=True) description = models.TextField('规则描述', blank=True)
points = models.IntegerField('积分', default=0) trigger_type = models.CharField(
daily_limit = models.IntegerField('每日上限', null=True, blank=True) '触发类型', max_length=20, choices=TRIGGER_TYPE_CHOICES, default='action'
is_active = models.BooleanField('已启用', default=True) )
# 变化值
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) created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True) updated_at = models.DateTimeField('更新时间', auto_now=True)
@ -92,16 +168,70 @@ class AffinityRule(models.Model):
ordering = ['-created_at'] ordering = ['-created_at']
def __str__(self): def __str__(self):
return self.name return f"{self.name} ({self.rule_key or '-'})"
class AffinityLevel(models.Model): 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) level = models.IntegerField('等级', unique=True)
name = models.CharField('等级名称', max_length=50) name = models.CharField('等级名称', max_length=50)
description = models.TextField('等级描述', blank=True) 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) created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True) updated_at = models.DateTimeField('更新时间', auto_now=True)
@ -112,3 +242,229 @@ class AffinityLevel(models.Model):
def __str__(self): def __str__(self):
return f"Lv{self.level} {self.name}" 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}"