lty/qy_lty/docs/修改记录.md
zyc a6a8d64fd4
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 8m49s
fix(settings): tencent audio config 缺 default 导致 Pod CrashLoopBackOff
AUDIO_SERVICE_CONFIG 顶层求值,即使 provider='huoshan' 不走 tencent,
config() 缺 default 也会让 settings 整体 import 失败。本地 .env 有空值
兜底所以本地正常;a9d00a4 取消跟踪 .env 后 CI 镜像无兜底,新 Pod 全崩,
老 Pod 因新 Pod 不 ready 未被滚动替换,外部观感即"仍连旧阿里云 Redis"。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:15:40 +08:00

586 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 服务器端代码修改记录
本文档记录每次对服务器端代码的修改,方便追踪变更历史。
---
## 修改格式说明
每次修改按以下格式记录:
```
### [日期] 修改简述
- **文件路径**: 相对于项目根目录的文件路径
- **修改类型**: 新增 / 修改 / 删除 / 重构 / 修复Bug
- **修改内容**: 具体修改了什么
- **修改原因**: 为什么要做这个修改
```
---
## 修改历史
<!-- 新的修改记录添加在此处下方,最新的在最前面 -->
### [2026-05-18] 修复线上 Pod CrashLoopBackOff — settings.py 中 tencent audio key 缺 default 导致 Django 启动炸
通过 SSH 登测试服 k3s 排查发现 deploy 后新 Pod 全部 CrashLoopBackOff老 Pod4 天前镜像env 仍是阿里云 Redis因新 Pod 不 ready 未被替换,外部观感即"推到远端仍连阿里云 Redis"。Pod 日志显示 Django settings import 阶段抛 `decouple.UndefinedValueError: AUDIO_SERVICE_TENCENT_API_KEY not found`
- **文件路径**:
- `qy_lty/settings.py`**修改** — 第 441/442 行)
- **修改类型**: 修复Bug
- **修改内容**:
- `config('AUDIO_SERVICE_TENCENT_API_KEY')``config('AUDIO_SERVICE_TENCENT_API_KEY', default='')`
- `config('AUDIO_SERVICE_TENCENT_API_SECRET')``config('AUDIO_SERVICE_TENCENT_API_SECRET', default='')`
- **修改原因**:
- `AUDIO_SERVICE_CONFIG` 是顶层求值的 dict即使 `AUDIO_SERVICE_PROVIDER='huoshan'` 不走 tencent 分支,`config()` 没 default 时缺环境变量也会让整个 settings 模块 import 失败
- 本地 `qy_lty/.env` 里有空值 `AUDIO_SERVICE_TENCENT_API_KEY=` 兜底所以本地正常commit a9d00a4 把 `.env` 从 Git 取消跟踪后 CI 构建出的镜像里没有 `.env`K8s yaml 又没声明这两个 key差异第一次被放大
- **根因链**: 新 Pod 因缺 env 崩 → readiness 失败 → 老 Pod 未被滚动替换 → 老 Pod 的 REDIS_LOCATION 还是阿里云旧值(验证:`kubectl get pod <old-pod> -o jsonpath='{.spec.containers[0].env...}'` 显示 `redis://r-7xvat0vez5clwbzk5vpd...aliyuncs.com`,新 Pod 显示火山 URL 已正确注入)
- **验证**: push 触发 CI 后 `kubectl get pods -l app=lty-backend` 应只剩新 Pod 且 Running`lty-backend-664c5d6888-*` 自动下线
- **后续防护建议(未做)**: 把 `AUDIO_SERVICE_CONFIG` 改为 lazy 求值(按 provider 解析),或所有非 active provider 的 `config()` 全部加 default避免同类问题再现
---
### [2026-05-18] k8s yaml 与 deploy.yaml sed 源串同步为火山 Redis消除"yaml 与实际部署不一致"
承接同日 CI deploy.yaml 切换条目,原 k8s/backend-deployment-prod.yaml 里 REDIS_LOCATION/REDIS_PASSWORD 仍是阿里云旧值(作为 sed 的"被替换源占位"),导致直接读 yaml 容易误以为线上跑的是阿里云。本次把 k8s yaml 字面值与 deploy.yaml sed 源串**同步**切到火山,使 yaml 静态读起来即真实部署状态。
- **文件路径**:
- `k8s/backend-deployment-prod.yaml`**修改** — L44 REDIS_LOCATION 改火山完整 URLL46 REDIS_PASSWORD 改 `Zyc188208`;注释补充提示)
- `.gitea/workflows/deploy.yaml`**修改** — L131 sed 源串改火山 URLL132 sed 源串改 `Zyc188208`,与 k8s yaml 字面值保持一致才能被 sed 匹配)
- **修改类型**: 配置同步
- **修改内容**:
1. k8s yaml 直接以火山 URL/密码为字面值
2. deploy.yaml sed 源串同步更新;功能上 sed 变为"同值替换"(无副作用),但保留 sed 行以便未来仍可通过 env 注入差异化值
- **修改原因**:
- 静态读 yaml 与运行时实际部署不一致,是排障与代码审查时的常见坑
- 用户明确选择"最直观"路径(牺牲未来切 Redis 的同步成本,换日常可读性)
- **代价**:
- 未来再切 Redis 时需同步改 **4 处**k8s yaml ×2、deploy.yaml env ×4 (prod+dev 各 2 行)、deploy.yaml sed 源串 ×2
- 火山 Redis 密码 `Zyc188208` 现在在 git 里出现 3 个位置k8s yaml、deploy.yaml env、deploy.yaml sed 源),泄密面扩大但本质同旧条目(已与用户确认接受)
- **验证**: 待下次 CI 触发后线上 pod 启动日志 `Cache Status: OK`
---
### [2026-05-18] CI/CD deploy.yaml Redis 同步切换为火山实例
承接同日上一条 [.env / settings.py Redis 切换](#2026-05-18-redis-切换为火山引擎实例--修复-channel_layers-不支持-acl-username) 完成线下 dev 环境后,需要让 CI 部署到 k3s 时也用同一套火山 Redis否则 dev/prod 部署到线上后仍连旧阿里云实例10054
- **文件路径**:
- `.gitea/workflows/deploy.yaml`**修改** — 三处)
- **修改类型**: 配置切换
- **修改内容**:
1. 第 33 行prod / main+masterREDIS_LOCATION 改为火山完整 URL并**新增** `REDIS_PASSWORD=Zyc188208` 环境变量
2. 第 44 行dev同上
3. 第 129 行后**新增** 一行 sed`sed -i "s|vAhRnAA6VMco|${{ env.REDIS_PASSWORD }}|g" k8s/backend-deployment-prod.yaml`
- **修改原因**:
- 原 deploy.yaml 只用 sed 替换了 `REDIS_LOCATION` 占位,没有替换 `REDIS_PASSWORD` 占位 —— 即便改 LOCATION 也会让 Django CACHES OPTIONS.PASSWORD 仍用阿里云旧密码 `vAhRnAA6VMco`,与火山实例密码 `Zyc188208` 不一致
- prod 阿里云 Redis 已不可用dev/prod 部署目标统一为火山实例(用户决定)
- **特别说明**:
- sed 源串 `redis://r-7xvat0vez5clwbzk5vpd...``vAhRnAA6VMco` 是 k8s/backend-deployment-prod.yaml 模板里的字面占位值,**保持不变**才能被 sed 匹配
- 本次火山 Redis URL + 密码进入 deploy.yaml进而进入 git 历史。已与用户确认接受此风险;要彻底走 secrets 注入需后续 rotate 密钥并改造 CI 变量管理
- **验证**:
- 本地 daphne 已确认 `Cache Status: OK`(见上一条)
- 部署后线上验证项pod 启动日志的 `Cache Status: OK` + `/api/v1/admin/login/` 不再 500
---
### [2026-05-18] Redis 切换为火山引擎实例 + 修复 CHANNEL_LAYERS 不支持 ACL username
原阿里云 Redis 实例(`r-7xvat0vez5clwbzk5vpd.redis.rds.aliyuncs.com:6379`)连接被远端 RST10054导致 `/api/v1/admin/login/` 等所有依赖 token / 缓存的接口报 `ConnectionError`。改用火山引擎 Redis 实例(`redis-shzlsczo52dft8mia.redis.volces.com:6379/3`,用户 `zyc`)。
切换过程中发现 `CHANNEL_LAYERS` 配置硬拼 URL 时只放了 password、没有 username导致带 ACL username 的 Redis如本次的火山实例会拼出畸形 URL`redis://:pwd@host` 缺 user。改为直接消费完整 `REDIS_LOCATION`(已含 `zyc:Zyc188208@`),后续切换实例只改 .env 即可。
- **文件路径**:
- `.env`**修改** — 旧 REDIS_LOCATION/PASSWORD 注释保留作为回滚参考;新 REDIS_LOCATION 含完整 `zyc:Zyc188208@` 凭据REDIS_PASSWORD 仍保留 `Zyc188208` 以兼容 `django-redis` OPTIONS.PASSWORD
- `qy_lty/settings.py`**修改** — 第 519 行 CHANNEL_LAYERS hosts 由 `f"redis://:{config('REDIS_PASSWORD')}@{config('REDIS_LOCATION').replace('redis://', '')}"` 改为 `[config('REDIS_LOCATION')]`
- **修改类型**: 配置切换 + 修复Bug
- **修改内容**:
1. Redis 实例 URL 切换到火山引擎,库号由 `/0` 改为 `/3`
2. CHANNEL_LAYERS hosts 不再二次拼接,直接读取完整 URL不再丢失 ACL username
- **修改原因**:
- 阿里云 Redis 不可用(白名单 / 实例状态 / 网络出口任一原因均会触发 10054 RST登录 / token / 缓存全线 500
- channels_redis 接受标准 redis URL`user:pass@`),原硬拼方式只能表达 password-only遇到 ACL 模式实例无法登录通道层
- **验证**: 重启 daphne 后启动日志 `Cache Status: OK`,延迟 296.67msHTTP 8000 监听正常
- **回滚**: 取消 `.env` 中两行火山配置注释 → 取消阿里云两行注释CHANNEL_LAYERS 改回原拼接(若回到不带 username 的实例可保留新写法,更通用)
---
### [2026-05-13] 好感度系统 P2 阶段 — Service 层 + 管理端 API 落地
配套设计文档:[../../docs/好感度系统功能与规则设计.md](../../docs/好感度系统功能与规则设计.md)
配套任务清单:[../../docs/好感度系统-开发任务清单.md](../../docs/好感度系统-开发任务清单.md)P2-01 ~ P2-12 全部完成)
本次完成「好感度系统服务层 + 管理端 API」整体落地所有好感度变化收敛到 `AffinityService.apply()` 单一入口,管理端 admin 后台具备完整 CRUD + 数据统计 + 设备查询 + 手动调整能力。Service 层 6 项 smoke test 全 PASS含正常 apply / event_id 去重 / 冷却拦截 / admin_adjust / 钳位)。
- **文件路径**:
- `userapp/affinity/counters.py`**新建** — P2-02 Redis 计数器:冷却 / 单规则日上限 / 全局日上限 / event_id 去重Asia/Shanghai 自然日基准TTL 48h用 django-redis cache.add+incr 原子语义)
- `userapp/affinity/levels.py`**新建** — P2-03 等级映射map_value_to_level / progress_to_next_level / update_device_level区间匹配按 -level desc 优先,重叠场景配合 P1 clean() 校验拦截)
- `userapp/affinity/ws.py`**新建** — P2-05 WS 推送push_affinity_update / push_level_up / push_level_downasgiref async_to_sync 包装 channel_layer.group_send向 device_{user_id} 分组广播;故障 fire-and-forget 不阻塞主流程)
- `userapp/affinity/rewards.py`**新建** — P2-04 跨级奖励发放A3 方案 B每级独立事务UserLevelRewardGrant 唯一约束保证幂等;外部派发 hook _dispatch_reward_to_external_systems 暂为 STUBP3/P4 接虚拟货币/道具 app 时实现)
- `userapp/affinity/services.py`**新建** — P2-01 AffinityService.apply() 主入口10 步流水线 [event_id 去重 → 取规则 → 冷却 → 取 UserDevice → 计算变化 + single_cap 钳位 → 规则日上限 → 全局日上限 → 原子写 favorability + log + counter + 等级缓存 → Redis 计数器累加 → 奖励发放 → WS 推送]admin_adjust 专用入口绕过 rule 但仍走钳位 + 日志 + 等级 + 奖励 + WS
- `userapp/affinity/serializers.py`**新建** — 9 个序列化器Rule/Level/Setting 三个 ModelSerializer 含跨字段校验AffinityLogSerializer 只读 + 关联字段展开UserDeviceAffinitySerializer 含 device_code/mac/level_nameAffinityAdjust 与 AffinityAdjustBatch 用 Serializer 而非 ModelSerializer
- `userapp/affinity/permissions.py`**新建** — IsAdminUserStaff 复用 IsAuthenticated 并加 is_staff 检查)
- `userapp/affinity/views.py`**新建** — 7 个视图AffinityRuleAdminViewSet / AffinityLevelAdminViewSet ModelViewSet + 软删 perform_destroy + restore actionAffinitySettingView APIView 单例 GET/PUT/PATCHAffinityLogListView 含 user/device/rule/source/date_range/分页过滤AffinityStatsView 聚合 avg/max/top_count/active_7d/today_interactions/rule_freq_top/level_distributionUserAffinityDevicesView 按 user_id 展开设备列表CR-001 默认仅返回 is_bound=TrueAffinityAdjustView + AffinityAdjustBatchView 委托 AffinityService.admin_adjust
- `userapp/affinity/urls.py`**新建** — DRF DefaultRouter 注册 rules/levels CRUD + 5 个独立 path 挂 settings/logs/stats/devices/adjust*
- `userapp/admin_urls.py`(修改 — 引入 include 并新增 `path('affinity/', include('userapp.affinity.urls'))`
- **修改类型**: 新增 + 重构
- **修改内容**:
- **P2-01 Service 层骨架**:唯一写入入口 `AffinityService.apply(user_id, device_id, rule_key, source, event_id, metadata, operator_admin_id, reason)` + `admin_adjust(user_id, device_id, delta, operator_admin_id, reason, batch)`;返回 `ApplyResult` dataclass 含 outcome 枚举 + change/before/after/level 信息
- **P2-02 Redis 计数器**6 类操作is_in_cooldown / set_cooldown / get/incr_rule_daily / get/incr_global_daily / event_already_processed / mark_event_processed`cache.add+incr` 实现 set-if-not-exists+atomic-increment 语义Asia/Shanghai 时区自然日字符串通过 `zoneinfo.ZoneInfo` 计算
- **P2-03 等级映射**`map_value_to_level(value)` 按 (min, max) 区间匹配;`update_device_level(user_device)` 仅在 level 变化时调 save(update_fields=['affinity_level'])
- **P2-04 跨级奖励发放**`grant_levels(user_device, from_level, to_level)` 逐级独立事务 + UniqueConstraint 防重;返回 RewardGrantResult(granted, skipped_duplicate, failed)失败的级别不影响其他级别A3 方案 B 核心特性)
- **P2-05 WS 推送**3 类事件affinity_update / level_up / level_downchannel_layer 故障静默吞掉但日志记录
- **P2-06 AffinityRule admin CRUD**:默认列表过滤 `is_deleted=False`?include_deleted=true 显示全集DELETE 走软删 `is_deleted=True+is_enabled=False`POST/restore 自定义 action 恢复软删
- **P2-07 AffinityLevel admin CRUD**同上软删serializer 跨字段校验区间重叠(与启用中其他等级不冲突)
- **P2-08 AffinitySetting GET/PUT/PATCH**单例pk=1 硬约束;跨字段校验衰减区间 + 初始/上限关系
- **P2-09 AffinityLog 查询**select_related user/rule/device.device 避免 N+1过滤 user_id/device_id/rule_key/source/date_from/date_to自实现分页page/page_sizepage_size 上限 200
- **P2-10 stats**:所有指标基于 `UserDevice.active`is_bound=True聚合今日数据按 AffinitySetting.timezone 取 local daterule_freq_top 取近 7 日 Top 10
- **P2-11 devices**?user_id= 必传 + 404 校验;?include_unbound=true 才返回历史;默认按 is_primary desc, bound_at desc 排序
- **P2-12 adjust / adjust-batch**:单台调整必传 user_id+device_id+delta+reason批量调整对 user 名下所有 active 设备各调一次,逐台独立调用 service返回 per-device 结果数组
- **挂载位置**`/api/v1/admin/affinity/{rules,levels,settings,logs,stats,devices,adjust,adjust-batch}/`;旧的 `/api/user/affinity-rules/``/affinity-levels/` 暂保留兼容(前端切到 admin 后即可清理)
- **修改原因**: P1 数据层就绪后必须落地服务层 + admin API否则数据模型只是空壳管理后台前端P3需要这套 admin API 才能拆 mock 接通触发点埋点P4和客户端 APIP5都依赖 service 层的 apply() 入口
- **跨项目联动**:
- 管理后台前端 P3 阶段接入:`lib/api/affinity.ts` 需要切到 `/api/v1/admin/affinity/...` 新路径并对齐新字段集cooldown_seconds, min_continuous_minutes 等)
- 设备/手机端 P4 阶段在 `device_interaction/consumers.py` 的 chat_message / sing / dance / touch / conversation_status 处调 `AffinityService.apply(rule_key=...)`;事件需带 `event_id`UUID
- 客户端 P5 阶段查询 `/api/user/me/affinity/` 暂未实现P5 任务P2 仅落地 admin 端
---
### [2026-05-13] 好感度系统 P1 审查修复 D — WR-002~WR-009 + IN-001~IN-006 综合改进
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)WR-002 ~ WR-009 + IN-001 ~ IN-006
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
- **文件路径**:
- `userapp/models.py`(修改 — 多处AffinityLog 索引精简 / event_id null=True / `__str__` 用 pk 兜底UserLevelRewardGrant SET_NULL + device_snapshot_id + conditional uniqueAffinitySetting daily_cap → global_daily_capdescription 显式 default='';弃用字段加 [DEPRECATED] 版本标记ParadiseUser.favorability 标记 (已弃用)
- `userapp/serializers.py`(修改 — `UserInfoSerializer` 移除 `favorability` 字段暴露WR-008
- `userapp/affinity/__init__.py`**新建** — affinity 业务包入口)
- `userapp/affinity/defaults.py`**新建** — DEFAULT_RULES / DEFAULT_LEVELS / DEFAULT_SETTING 常量从 management command 抽出;新增 1 条 companion_30min 规则;所有 description 显式填写)
- `userapp/management/commands/seed_affinity.py`**重写** — 从 affinity.defaults 导入常量;去掉全局 `@transaction.atomic`,改为每条 spec 独立 `with transaction.atomic()`;新增 failed 计数与不同 style 输出,部分失败可重跑)
- `userapp/migrations/0009_affinity_p1_polish.py`**新建** — 手工修正 makemigrations 自动生成版本:把 `daily_cap → global_daily_cap` 改为 RenameField保留数据不是 Remove+Addevent_id `''` → NULL 数据兜底 RunPythonUserLevelRewardGrant on_delete + conditional unique索引精简弃用字段 help_text 升级)
- **修改类型**: 修复Bug + 重构
- **修改内容**:
- **WR-002**UserLevelRewardGrant.device `on_delete=CASCADE``SET_NULL`,加 `device_snapshot_id` 冗余字段save 时自动填充原 pk`unique_together=[('device','level')]``UniqueConstraint(fields=['device','level'], condition=Q(device__isnull=False))` 保证 device 已删的历史记录不参与唯一性
- **WR-003**AffinityLog 删除 3 个低价值索引user/rule_key/source 各 -created_at 复合),仅保留 (device, -created_at) 与 event_id partial unique
- **WR-004**AffinityLog.event_id `null=True`partial unique 条件 `event_id__gt=''``event_id__isnull=False`RunPython 把现有 `''` 改为 NULL
- **WR-005**DEFAULT_RULES 新增 1 条 `companion_30min`trigger_type=companion_time, min_continuous_minutes=30, max_count_per_day=4min/max change 1~2
- **WR-006**AffinityRule/AffinityLevel.description 显式 `default=''`DEFAULT_LEVELS 所有 entry 补 description
- **WR-007**seed_affinity 每条 spec 独立事务,部分失败不影响其他记录(去掉 handle 上的 @transaction.atomic
- **WR-008**ParadiseUser.favorability 字段保留(避免 0006 backward 失效)+ verbose_name 加 (已弃用)help_text 标 [DEPRECATED — P2 后删除]UserInfoSerializer 移除字段暴露。**未做 property 改造**Model field 与 property 同名冲突,必须先 RenameField 才能上 property本次只做软标记 + 序列化器清理(详见 FIX-REPORT 风险说明)
- **WR-009**:(已在 Commit B 的 AffinityLevel.clean() + save() 中实现 — DB 跨行约束 PG 表达不出,应用层多层兜底)
- **IN-001**5 个弃用字段AffinityRule.points / daily_limit / is_activeAffinityLevel.required_points / rewardshelp_text 加 `[DEPRECATED — 计划于 P2 完成后删除]` 显式版本标记
- **IN-002**DEFAULT_RULES / DEFAULT_LEVELS / DEFAULT_SETTING 抽到 `userapp/affinity/defaults.py`,供 seed / 单元测试 / P2 服务层复用
- **IN-003**AffinitySetting.daily_cap → global_daily_cap RenameField与 AffinityRule.daily_cap 区分);模型 / 校验 / 约束 / DEFAULT_SETTING 全部同步
- **IN-004**AffinityLog.\_\_str\_\_ 用 `self.pk or 'new'` 替代 `self.id`,未保存对象显示 `#new` 而非 `#None`
- **IN-005**:(已在 Commit A 完成 — is_active → is_bound 改名)
- **IN-006**:(已在 Commit C 完成 — 0006 print 前缀改为 `[migration 0006_migrate_favorability]`
- **修改原因**: P1 数据层审查中除 3 个 Critical 外的全部剩余项9 Warning + 6 Info一次性收尾避免遗留到 P2 服务层动工后修复成本上升同时保持每个修复项可独立追溯commit message + 修改记录条目)
- **跨项目联动**: 管理后台前端如已读取 AffinitySetting.daily_cap需要同步改为 global_daily_cap仅当 admin UI 暴露该字段时UserInfoSerializer 不再返回 favorability前端如有使用需改为查询 UserDevice 列表。其他改动对外接口无破坏
### [2026-05-13] 好感度系统 P1 审查修复 C — 0006 数据迁移幂等性修正CR-003
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)CR-003
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
- **文件路径**:
- `userapp/migrations/0006_migrate_favorability_to_userdevice.py`**重写** — 完整替换 forward/backward 函数内容,改用 AffinityLog source='data_migration' 标记做幂等)
- `userapp/models.py`(修改 — `AffinityLog.SOURCE_CHOICES` 追加 `('data_migration', '数据迁移')` 选项)
- `userapp/migrations/0008_alter_affinitylog_source_choices.py`**新建** — 由 makemigrations 自动生成,更新 source 字段的 choices
- **修改类型**: 修复Bug + 重构
- **修改内容**:
- **CR-003Critical**:旧 0006 forward 用 `target.favorability == 10` 做幂等判断,因 10 既是初始值也是衰减下限附近常见值重跑会覆盖合法数据backward 同样用 `!= 10` 反向判断会丢失衰减回 10 的数据
- 新实现 forward`AffinityLog.objects.filter(device_id=target.id, source='data_migration').exists()` 做幂等标记,同时写入审计 metadata`from_user_favorability` / `before_device_favorability` / `migration` 文件名)
- 新实现 backward遍历 source='data_migration' 的 AffinityLog 反向恢复 ParadiseUser.favorability并删除标记保证可循环
- 模型层补 `'data_migration'` 到 SOURCE_CHOICESPython 校验0008 AlterField 同步)
- **已知风险option B 选择说明)**:
- 选择**直接重写 0006**(而非追加 0007 补偿)是因为 dev DB 已执行过一次但 migrate_count=0等于未动数据
- django_migrations 表已记录 0006 完成,重写后**不会**自动重跑;如需对意外脏数据强制重跑,需手工 `python manage.py migrate userapp 0005 --fake` 后再 `migrate userapp 0006`
- 生产环境部署前必须确认 prod 还未跑过 0006否则需走 fake-reverse 流程
- **修改原因**: P1 审查指出(详见 REVIEW-affinity-P1.md CR-003旧幂等条件 `favorability == 10` 在 P3/P4 衰减跑起来后会变成"定时炸弹"重跑迁移会覆盖正常业务数据backward 会丢数据
- **跨项目联动**: 无 — 仅服务端迁移逻辑变更
### [2026-05-13] 好感度系统 P1 审查修复 B — Affinity 模型 DB CHECK 约束 + WR-001 单例硬约束CR-002 + WR-001
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)CR-002 + WR-001
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
- **文件路径**:
- `userapp/models.py`(修改 — `AffinityRule.Meta.constraints` 加 5 条 CheckConstraint + `clean()``AffinityLevel.Meta.constraints` 加 2 条 CheckConstraint + `clean()` + `save()` 自动 full_clean`AffinitySetting.Meta.constraints` 加 6 条 CheckConstraint含 pk=1 单例硬约束)+ `clean()` + `save()` 强制 pk=1imports 段补 CheckConstraint / F / ValidationError
- `userapp/migrations/0007_add_affinity_check_constraints.py`**新建** — 由 makemigrations 自动生成13 条 AddConstraint
- **修改类型**: 新增 + 修复Bug
- **修改内容**:
- **CR-002Critical**
- AffinityRule`min_change ≤ max_change` / `cooldown_seconds ≥ 0` / `single_cap > 0` / `daily_cap > 0` / 陪伴时长规则必须设置 `min_continuous_minutes > 0 ∧ max_count_per_day > 0`
- AffinityLevel`min_affinity ≤ max_affinity` / `reward_currency ≥ 0`
- AffinitySetting`decay_min_decay ≤ decay_max_decay ≤ decay_cap` / `initial_affinity ≤ max_affinity` / `decay_min_floor ≤ max_affinity` / `daily_cap > 0`
- **WR-001Warning**AffinitySetting 加 `pk=1` 单例硬约束 + save() 强制 pk=1配合形成事实单例CHECK 约束跨行不可,但能阻止任何非 1 主键的写入)
- 所有模型 `clean()` 提供 Python 级兜底,给 DRF / admin 友好错误信息DB 级 CheckConstraint 是最终防线)
- AffinityLevel.save() 自动调 full_clean 触发跨等级区间不重叠校验WR-009 多层兜底,详见 Commit D提供 `skip_clean=True` 后门给迁移 / fixture 场景
- **修改原因**: P1 审查指出(详见 REVIEW-affinity-P1.md CR-002模型字段对管理后台 / shell / 直 SQL 写入毫无保护P2 服务层 `random.randint(rule.min_change, rule.max_change)` 等运算会被脏数据击穿ValueError 抛出、冷却永久解锁、上限永远命中等);同时审查中 WR-001 指出 AffinitySetting 单例保证在并发下脆弱
- **跨项目联动**: 无 — 仅服务端 DB 约束 + 模型校验层;管理后台前端在写入前应捕获 ValidationError 显示给运营,但接口契约本身未动
### [2026-05-13] 好感度系统 P1 审查修复 A — UserDevice 软删语义修正CR-001 + IN-005
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)CR-001 + IN-005
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
- **文件路径**:
- `device_interaction/models.py`(修改 — 新增 `ActiveUserDeviceManager``UserDevice.is_active` 改名为 `is_bound`;新增双 manager `objects` / `active``Meta.base_manager_name = 'objects'` 保证 admin 默认 queryset 不受 active 过滤影响)
- `device_interaction/migrations/0004_rename_userdevice_is_active_is_bound.py`**新建** — `RenameField` + `AlterField` 更新 help_text
- `device_interaction/migrations/0005_alter_userdevice_options.py`**新建** — 由 makemigrations 自动生成,记录 `base_manager_name='objects'` 的 Meta 变更)
- `userapp/views.py`(修改 — MAC 登录第 120 行 `UserDevice.objects.filter(...)``UserDevice.active.filter(...)`
- `device_interaction/views.py`(修改 — 4 处调用点切换到 `UserDevice.active``bind_status` 第 462 行、绑定 endpoint 第 694/702 行两处、RTC token 第 1158 行)
- `device_interaction/serializers.py`(修改 — 第 125 行绑定校验切到 `UserDevice.active`
- `qy_lty/CLAUDE.md`(修改 — § "设备绑定与控制权" 新增硬规则:所有控制权解析查询必须使用 `UserDevice.active.filter(...)`;解释 `is_bound` 改名背景)
- **修改类型**: 重构 + 修复Bug
- **修改内容**:
- **CR-001Critical**4 处控制权解析调用点全部加 `is_bound=True` 过滤,通过 `ActiveUserDeviceManager` 强制语义;避免 P2 软删(解绑设 is_bound=False后旧绑定者被签发 user-token / 路由到错 user_id 的 WS 分组 / RTC 房间
- **IN-005**`UserDevice.is_active``is_bound` 改名,消除与 `Device.is_active`(设备激活态)的命名冲突
- `RenameField` 在 PostgreSQL 上是元数据级 ALTER COLUMN RENAMEO(1) 锁),无数据风险
- `base_manager_name='objects'` 保证 Django admin 与反向关系(`user.devices.all()``device.users.all()`)依然返回全集,仅 `UserDevice.active.filter()` 才过滤
- **修改原因**: P1 数据层代码审查指出(详见 REVIEW-affinity-P1.md CR-001现有 4 处"按 MAC 取最新绑定者"的代码路径未过滤 `is_active`P1-08 引入),一旦 P2 实现解绑=软删,会签发已解绑用户的 user-token、WS 路由到前主人频道等安全 / 越权风险。IN-005 与之共因(两个同名 is_active 字段语义截然不同),审查报告显式建议合并修复
- **跨项目联动**: 无 — 仅服务端 ORM 层 + view 调用点变更,对客户端 / 管理后台无外显接口变化(响应 schema 未动)
### [2026-05-08] Phase 3 — 客户端凭据槽位 GET 接口 + 阿里云日志 access_token 脱敏
配套 Phase[.planning/phases/03-client-and-log-mask/](.planning/phases/03-client-and-log-mask/)
覆盖需求CRED-05 + CRED-06
设计参考1:1 复刻 `aiapp.views.CredentialSlotAdminView` 的 GET 部分(删 `_ensure_admin` / `_build_response_data` / PUT 三处),实现明文返回客户端 view新建 `common/logging/filters.py:AccessTokenMaskFilter` 作为 LOGGING.handlers 层防御性兜底
- **文件路径**:
- `aiapp/views.py`(修改 — 文件末尾追加 `_credential_slot_client_data_schema` 客户端响应 schema + `CredentialSlotClientView` APIView 类,仅 GET明文返回imports 段未动Phase 2 既有 `CredentialSlotAdminView` 未动)
- `qy_lty/urls.py`(修改 — imports 段追加 `from aiapp.views import CredentialSlotClientView``api_urlpatterns` 列表中追加 `path('credential-slot/', CredentialSlotClientView.as_view(), name='client_credential_slot')`,注册位置:`common/upload/` 之后、`v1/admin/` 之前)
- `common/logging/__init__.py`**新建** — 空文件,让 `common.logging` 成为可 import 的 Python 包)
- `common/logging/filters.py`**新建** — `AccessTokenMaskFilter(logging.Filter)` 类 + 4 个 regex 模式JSON / Python dict repr / URL query / 等号或冒号兜底)+ `filter()` 方法重写 `record.msg``record.args` 中的 access_token 字段值为 `mask_token(value)` 输出)
- `qy_lty/settings.py`(修改 — `LOGGING` 字典新增 `'filters'` 段(用 `'()': 'common.logging.filters.AccessTokenMaskFilter'` dictConfig 工厂语法);`'handlers'.aliyun``'handlers'.console` 各追加 `'filters': ['access_token_mask']`loggers 段 5 条 logger 完全未动)
- **修改类型**: 新增
- **修改内容**:
- 暴露 `GET /api/credential-slot/`(路径与管理端 `/api/v1/admin/credential-slot/` **完全分开**,客户端走 `/api/` 一级命名空间不进 `v1/admin/` 子路径):`RedisTokenAuthentication` + `IsAuthenticated`**不**做 is_staff 二次校验admin / user token 都允许admin 用户是手机用户超集CONTEXT 锁定决策);返回 `{ success, code, message, data: { app_id, access_token: <**明文**>, updated_at } }`Access Token 直接返回 `serializer.data`(不调 `mask_token`供手机端LTY_App_Project_URP/ 设备端LTY_Project实际调用阿里云 / 火山 / 腾讯第三方服务
- 新建 `AccessTokenMaskFilter`4 个正则模式覆盖 JSON 字符串(`"access_token":"VALUE"`、Python dict repr`'access_token':'VALUE'`、URL query`access_token=VALUE`)、等号或冒号兜底(`access_token: VALUE`)共 4 种序列化形态filter 同时改 `record.msg``record.args`(避免 Formatter 阶段再用 `%` 拼接出明文per RESEARCH Pitfall 2只匹配 `access_token` 字段名为前缀锚点,**不**误伤 `Authorization header:` / `Bearer` / 裸 user tokenper RESEARCH Pitfall 3filter 永远 `return True` 不丢弃 recordper RESEARCH Pitfall 1
- LOGGING dictConfig 注册filter 段用 `'()': '...'` 工厂语法(不是 `'class'`per RESEARCH Pitfall 5filter 挂在 `handlers.aliyun` / `handlers.console` 两个 handler 上(**不**挂 loggers 段per RESEARCH Pitfall 1 — 挂 logger 仅过滤直接通过该 logger 的 record挂 handler 才统一覆盖所有 logger → handler 路径);既有 5 条 logger 配置完全未动
- Swagger / ReDoc 自动暴露method-level `@swagger_auto_schema` 装饰器;响应 data schema 用独立 `_credential_slot_client_data_schema`access_token 字段 description 显式标注「明文 Access Token供手机/设备端实际调用第三方服务(管理端同接口会脱敏返回末 4 位)」,避免前端误解明文 / 脱敏
- 不引入新依赖(沿用 Django 4.2.13 + DRF + drf-yasg + Phase 1/2 落地的 `CredentialSlot.get_solo` / `CredentialSlotSerializer` / `mask_token`
- **修改原因**: Milestone v1.0「通用凭据槽位APP ID + Access Token」Phase 3 收尾 phase — 同时落地客户端读取CRED-05与日志脱敏CRED-06。客户端读取需要明文手机/设备端 Unity 调阿里云 / 火山 / 腾讯 SDK 时第三方 API 校验 token 字符级一致),所以 view 层不脱敏;但「明文走 view」会让任何后续开发者写 `logger.info(f"PUT body: {request.data}")` 类代码立即把 access_token 打到阿里云日志服务,所以新增 LOGGING.handlers 层 filter 作为防御性兜底。RESEARCH 已实证:当前仓库**没有**任何代码 logger 输出 `CredentialSlot.access_token` 明文(`StandardResponseMiddleware` 不打日志、view 不显式 logger 字段、Django 默认 access log 不含 body所以 CRED-06 的端到端验证靠**单元测试**伪造 LogRecord 验证 filter 行为4 种序列化形态 + 不误伤 Authorization 字段)+ 1 条端到端 logger.info 真实输出脱敏验证,不靠端到端找泄露路径。这是 CRED-06 的真实价值 — 防御性兜底,让未来代码改动天然安全
- **跨项目联动**: 无 — 客户端 GET `/api/credential-slot/` 给 Unity 客户端(`LTY_Project` / `LTY_App_Project_URP`)使用,那两个 repo 各自维护修改记录,不在本仓库范畴;`qy-lty-admin`Web 管理后台前端)**不消费**此接口(管理端走 Phase 2 落地的 `/api/v1/admin/credential-slot/`,由 admin token 鉴权 + 脱敏返回。CLAUDE.md 跨项目规则下:本 phase 既不影响 qy-lty-admin 也不与 Unity 客户端在同一仓库,故不在 qy-lty-admin/docs/修改记录.md 写互引条目Unity 客户端改动由 LTY_Project / LTY_App_Project_URP 在自身仓库各自记录
- **后续动作**: Milestone v1.0 至此完成;下一周期 milestone 候选见 `.planning/REQUIREMENTS.md` 「候选优先级」段HIGHACH-02 / SMS 频率限制 / DEBUG 收紧 / 测试基础设施 / 测试 MAC 硬编码MEDIUM好感度 P2-P4 / Python 版本升级 / device_interaction 拆分)
### [2026-05-07] Phase 2 — 管理端通用凭据槽位 REST 接口GET 脱敏 / PUT 覆写)
配套 Phase[.planning/phases/02-admin-rest/](.planning/phases/02-admin-rest/)
覆盖需求CRED-03 + CRED-04
设计参考1:1 复刻 `aiapp.views.RTCChatHistoryAPIView``aiapp/views.py:434-555`)的单 URL 多方法 APIView 风格
- **文件路径**:
- `aiapp/serializers.py`(修改 — 顶部 import 追加 `CredentialSlot`,文件末尾追加 `CredentialSlotSerializer` ModelSerializer 类)
- `aiapp/views.py`(修改 — 顶部 import 追加 `CredentialSlot` / `CredentialSlotSerializer` / `mask_token` / `get_standardized_response_schema`;文件末尾追加 `CredentialSlotPutRequestSchema` swagger 请求体 + `_credential_slot_data_schema` 响应 data schema + `CredentialSlotAdminView` APIView 类)
- `userapp/admin_urls.py`(修改 — 追加 `from aiapp.views import CredentialSlotAdminView``path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot')`
- **修改类型**: 新增
- **修改内容**:
- 暴露 `GET /api/v1/admin/credential-slot/`admin token 鉴权(`RedisTokenAuthentication` + 视图内 `is_staff` 二次校验,不发明 admin-only permission 类);返回 `{ success, code, message, data: { app_id, access_token: <末 4 位脱敏掩码>, updated_at } }`,脱敏由 view 层调 `common.utils.mask_token` 完成serializer 不参与脱敏,避免双重责任)
- 暴露 `PUT /api/v1/admin/credential-slot/`admin token 鉴权;接受 `{ app_id, access_token }` 全字段覆写;空记录场景自动走 `CredentialSlot.get_solo()``get_or_create(pk=1)`;写入后 `updated_at``auto_now=True` 自动刷新;响应同样脱敏 access_token避免运营在 admin UI 看到自己刚提交的明文回显)
- 鉴权拒绝矩阵:无 token → 401DRF NotAuthenticated → middleware 兜底标准壳层);持普通 user token非 staff→ 403 + `message="需要管理员权限"`
- Swagger / ReDoc 自动暴露method-level `@swagger_auto_schema` 装饰器;响应 schema 配 `common.swagger_utils.get_standardized_response_schema()`access_token 字段 description 显式标注「Access Token 末 4 位脱敏掩码(如 "*********1234")」,避免前端误解为明文
- 不引入新依赖(沿用 Django 4.2.13 + DRF + drf-yasg + Phase 1 落地的 `CredentialSlot.get_solo` / `mask_token`
- **修改原因**: Milestone v1.0「通用凭据槽位APP ID + Access Token」Phase 2 — 给管理后台前端qy-lty-admin暴露受控的凭据读写入口让运营无需进 Django Admin 也能管理凭据GET 与 PUT 响应均脱敏,避免明文经管理端 UI / 浏览器 devtools / 阿里云日志GET 响应体路径)泄露;为 Phase 3 客户端明文 GET 接口 + 阿里云日志 formatter 提供"接口已上线、凭据可写入"的稳定起点
- **跨项目联动**: 前端联动条目 [qy-lty-admin/docs/修改记录.md](../../qy-lty-admin/docs/修改记录.md) 同期 `[2026-05-07] Phase 2 — 锁定后端通用凭据槽位 REST 接口契约(消费方文档化)`。本 phase 是 Milestone v1.0 首次跨项目接口契约落地:本仓库(服务端)暴露 `/api/v1/admin/credential-slot/` GET/PUT前端 `qy-lty-admin` 后续 phase 将基于该契约写 API client含 React Hooks 调用 + 表单录入 UI。前后端各自维护独立修改记录本条与对方条目互相引用便于未来回查接口的双向上下游
### [2026-05-07] Phase 1 — Django Admin 注册凭据槽位(脱敏 + 单例约束 + 禁删)
配套 Phase[.planning/phases/01-credential-data-layer/](.planning/phases/01-credential-data-layer/)
覆盖需求CRED-02
- **文件路径**: `aiapp/admin.py`(修改 — 顶部 import 追加 `CredentialSlot``mask_token`,文件末尾追加 `CredentialSlotAdmin` 注册)
- **修改类型**: 新增
- **修改内容**:
- 注册 `CredentialSlotAdmin``list_display = ('id', 'app_id', 'access_token_masked', 'updated_at')`,其中 `access_token_masked` 是计算字段(调 `common.utils.mask_token` 仅显示末 4 位掩码)
- `fieldsets` 分「凭据信息」(`app_id` / `access_token` 明文可写)+「元数据」(`updated_at` 只读、可折叠)
- 重写 `has_add_permission`:已存在记录时返回 `False`Admin 列表页隐藏「增加」按钮,强制单例语义)
- 重写 `has_delete_permission`:永远返回 `False`(含批量动作;防运营误删丢失单例)
- 不修改既有 `BotAdmin` / `ChatMessageAdmin` 注册块
- **修改原因**: CRED-02 — 在 SimpleUI 后台为运营提供受控的凭据录入入口;列表 / 查看态脱敏防截图 / 录屏泄露;编辑态保留明文供录入;新增 / 删除按钮隐藏强制单例语义不被运营误操作破坏
- **跨项目联动**: 无 — qy-lty-admin 同期 v1.0 前端集成 milestone 已规划但未启动;待前端启动 phase 后由对方仓库写一条互引条目。本改动仅触及服务端 Django Admin运营访问 `/admin/aiapp/credentialslot/` 直接录入),与 `qy-lty-admin/`Web 管理后台前端)无 API 联动CLAUDE.md 跨项目规则下纯服务端改动不需要在 `qy-lty-admin/docs/修改记录.md` 写互引条目。Phase 2 暴露 `/api/v1/admin/credential-slot/` 接口时再做前后端联动。
### [2026-05-07] Phase 1 — 凭据槽位数据层CredentialSlot 单例模型 + 迁移 + mask_token 工具)
配套 Phase[.planning/phases/01-credential-data-layer/](.planning/phases/01-credential-data-layer/)
覆盖需求CRED-01
设计参考1:1 复刻 `userapp.models.AffinitySetting``userapp/models.py:247-314`)的 pk=1 + `save()` 钩子 + `get_solo()` 单例三件套
- **文件路径**:
- `common/utils.py`(新增 — `mask_token(token, visible_tail=4)` 工具函数,供本 Phase Admin 与 Phase 3 阿里云日志 formatter 共用)
- `aiapp/models.py`(修改 — 文件末尾追加 `CredentialSlot` 模型3 字段 + save 钩子 + `get_solo` 类方法)
- `aiapp/migrations/0004_credentialslot.py`(新增 — `python manage.py makemigrations aiapp` 自动生成)
- **修改类型**: 新增
- **修改内容**:
- 新增 `CredentialSlot` 模型aiapp app`app_id` CharField(128, blank=True, default='')、`access_token` CharField(512, blank=True, default='')、`updated_at` DateTimeField(auto_now=True)`save()` 钩子在已有记录时把新对象 pk 改为现有那条;`get_solo()` 类方法走 `get_or_create(pk=1)`
- 新增 `common.utils.mask_token(token, visible_tail=4, mask_char='*')`:空输入返回 `''`;短于 visible_tail 时全脱敏不暴露长度;其余保留末 N 位明文
- 自动生成迁移 `aiapp/migrations/0004_credentialslot.py``python manage.py migrate` 通过;首次访问 `CredentialSlot.objects.get_or_create(pk=1)` 拿到一条空记录
- **修改原因**: Milestone v1.0「通用凭据槽位APP ID + Access Token」Phase 1 — 在 DB 层落地全局单例的凭据存储槽位,为 Phase 2 管理端 REST、Phase 3 客户端 REST + 日志脱敏奠基mask_token 抽到 `common/` 让 Phase 3 阿里云日志 formatter 直接复用,避免重复实现
- **后续动作**: Phase 2 暴露 `/api/v1/admin/credential-slot/` GET脱敏 / PUT覆写Phase 3 暴露 `/api/credential-slot/` GET 明文 + 阿里云日志 formatter 用 `mask_token` 过滤 `access_token` 字段
- **跨项目联动**: 无 — qy-lty-admin 同期 v1.0 前端集成 milestone 已规划但未启动;待前端启动 phase 后由对方仓库写一条互引条目。本改动是纯数据层 + 工具函数,无任何 HTTP / WebSocket 接口暴露,`qy-lty-admin` 与 Unity 客户端均无感知;不需要在前端写互引条目。
### [2026-05-07] 引入 GSD 工作流并完成 brownfield 文档化初始化
- **文件路径**:
- `.planning/config.json`(新增)
- `.planning/PROJECT.md`(新增)
- `.planning/REQUIREMENTS.md`(新增)
- `.planning/STATE.md`(新增)
- `.planning/codebase/STACK.md` / `INTEGRATIONS.md` / `ARCHITECTURE.md` / `STRUCTURE.md` / `CONVENTIONS.md` / `TESTING.md` / `CONCERNS.md`(前序 commit `64a8cb8` 已建)
- **修改类型**: 新增
- **修改内容**: 在 `qy_lty/` 下引入 [GSDGet Shit Done](https://github.com/anthropics/get-shit-done) 工作流目录 `.planning/`,包含:
1. `.planning/codebase/` — 7 份 codebase 反向工程文档(栈 / 集成 / 架构 / 目录 / 规约 / 测试 / 隐患)
2. `.planning/PROJECT.md` — 项目愿景 + Core Value + 已交付能力Validated+ 关键决策记录
3. `.planning/REQUIREMENTS.md` — 把已上线能力拆为带 REQ-ID 的清单AUTH/AI/DEV/CARD/ACH/SUB/AFF/VI/INF/ADM/DEPActive 段留空待 `/gsd-new-milestone` 启动
4. `.planning/STATE.md` — 工作流状态机入口
5. `.planning/config.json` — 工作流偏好YOLO / Coarse / Parallel / 三类辅助 agent 全开 / Balanced 模型档)
- **修改原因**:
- 后续新功能 / 重构通过 GSD 走「discuss → plan → execute → verify」标准流程避免无规划的散弹式提交
- 反向梳理一遍现状形成文档基线,方便新成员(含 AI agent秒级进入上下文
- `.planning/` 锚定在 `qy_lty\` 而非父级 `Lila-Server\`,遵循 CLAUDE.md「`qy_lty``qy-lty-admin` 是独立项目」原则;通过预创建空 `.planning/` 目录强制锚定生效
- **后续动作**: 新功能开发使用 `/gsd-new-milestone` 启动候选优先级HIGH 项含成就条件校验缺失、SMS 限流、DEBUG/CORS 收紧、测试 MAC 后门移除、测试基础设施搭建)见 `.planning/REQUIREMENTS.md`
### [2026-05-07] CLAUDE.md 新增「沟通语言」规则 — 强制中文回复
- **文件路径**: `CLAUDE.md`
- **修改类型**: 新增
- **修改内容**: 在文件顶部(项目概述之前)新增 `## 沟通语言(重要 — 始终生效)` 章节明确所有面向用户的回复统一使用中文内部思考可用任意语言工具调用参数、commit message、代码注释保持项目原有约定此规则覆盖默认英文输出倾向仅在用户显式要求时切换。
- **修改原因**: 用户要求把"思考后的回答用中文显示"沉淀为本仓库长期生效的工作规则,避免每次会话重复声明,并让后续任何 Claude/Copilot 会话进入仓库即自动遵循。
### [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 字段
配套手机端记录:`LTY_App_Project_URP/docs/修改记录.md` 同日"修复 B' 双倒真正根因:时间戳时区解析"条目。
手机端实测 B' 方案出现 UI 双倒,根因定位为:服务端 `chat_msg.timestamp.isoformat()` 输出 UTC 带时区的 ISO8601`+00:00`),客户端 Unity Mono `DateTime.TryParse` 对此处理不稳定,可能丢失时区信息导致与本地时间戳比较时差 8 小时 → 替换匹配窗口15s永远不命中 → 走"作为新消息插入"兜底分支 → 双倒。
服务端最稳妥的修复方式:在 group_send payload 多附一个无时区歧义的 unix 秒级时间戳,让客户端优先使用。
#### 修改strategy B 落库后 group_send payload 新增 timestamp_unix
- **文件路径**: `device_interaction/views.py`
- **修改类型**: 增强
- **修改内容**:
- `conversation_status` action 内字幕落库分支(约 L1438 附近)的 `channel_layer.group_send` payload 新增字段:
```python
'timestamp_unix': int(chat_msg.timestamp.timestamp()),
```
- 保留原 `timestamp` 字段ISO8601兼容老客户端不破坏现有约定
- **修改原因**:
- Unix 秒级时间戳是绝对值,跨语言跨时区零歧义
- 客户端 `DateTimeOffset.FromUnixTimeSeconds(...).LocalDateTime` 转换可靠
- 服务端代价极小(一次 `.timestamp()` 调用),收益是消除一类隐性双倒 bug
#### 客户端配套改动(仅记录依赖关系)
- `Assets/Scripts/AI/ChatLogManager.cs` 的 `ServerPersistedData` 结构体新增 `long timestamp_unix` 字段
- `OnServerChatPersisted` 时间戳解析改为:优先 `timestamp_unix` > 0 → fallback `DateTimeOffset.TryParse(timestamp)` → fallback `DateTime.Now`
- `LoadChatHistoryFromServer` 同步改用 `DateTimeOffset.TryParse`GET 接口暂未提供 unix 字段)
#### 验证
服务端部署后,客户端 Console 应能在 `[匹配诊断]` 日志中看到 delta 缩小到秒级(之前是 ~28800s。修复确认后客户端会删除诊断日志。
#### 待跟进
- `aiapp/views.py` 的 `RTCChatHistoryAPIView.get` 也可在响应里加 `timestamp_unix` 字段进一步收紧(非必须,因为 GET 路径双倒不直接受影响 —— 走的是覆盖式拉取)
---
### [2026-04-29] 手机端聊天记录切换服务端字幕落库B' 方案 服务端部分)
配套手机端方案文档:`LTY_App_Project_URP/docs/手机端聊天记录_切换服务端字幕落库方案.md`。手机端已实施 B'(本地 ASR 实时显示 + 服务端 webhook 静默替换服务端需要补三件事strategy B 落库后 group_send 推回客户端、DeviceConsumer 加 handler、RTCChatHistoryAPIView 灰度期去重 + since_id 增量拉取。
#### 修改 1strategy B 落库成功后 group_send 转推
- **文件路径**: `device_interaction/views.py`
- **修改类型**: 新增功能
- **修改内容**:
- 在 `conversation_status` action 内字幕落库分支(约 L1414 `ChatMessage.objects.create(...)` 处):
- 把 `create()` 返回值赋给变量 `chat_msg`,落库成功 log 加上 `id` 字段
- 落库成功后追加 `channel_layer.group_send` 调用,向 `device_{paradise_user_id}` 群组发送 `type='chat_message_persisted'` 消息payload 含 `id` / `sender` / `message` / `timestamp` / `source_client`
- 用独立 `try/except` 包住,转推失败仅 warning 日志,不影响主落库流程
- `source_client` 暂传 `'unknown'`(决策点 #3 落定后改为 `'phone'` / `'device'`
- **修改原因**:
- 手机端 B' 方案需要服务端在字幕入库后通过 WebSocket 把"权威 LLM 原始版本"推回客户端
- 手机端按 `chat_msg.id` 去重 + 按 `(sender, timestamp ±10s)` 匹配本地待替换队列做静默替换,达到 UI 与 DB 字符级一致
- 不影响设备端:设备端不订阅 `chat_message_persisted` 类型即可DeviceConsumer handler 仅向已实现处理的客户端透传)
#### 修改 2DeviceConsumer 加 chat_message_persisted handler
- **文件路径**: `device_interaction/consumers.py`
- **修改类型**: 新增功能
- **修改内容**:
- 在 `conversation_subtitle` handler 之后新增 `chat_message_persisted` handler
- 接收 group_send 事件后通过 `self.send` 把 JSON 推到 WebSocket 客户端
- 日志记录 `id` / `sender` / `source_client` 用于后续排查
- **修改原因**:
- Channels 协议要求 group_send 的 `type` 字段值在 Consumer 上有同名方法处理,否则消息被丢弃且报警
- 必须与修改 1 同步部署,否则 strategy B 的 group_send 调用会失败
#### 修改 3RTCChatHistoryAPIView 灰度期 POST 去重 + GET since_id 支持
- **文件路径**: `aiapp/views.py`
- **修改类型**: 新增功能 + 增强
- **修改内容**:
- `RTCChatHistoryAPIView.post()` 入口加去重判定:同一 `(user, bot, sender, message)` 在 `±2s` 时间窗内已存在则跳过 `create`,返回 `deduplicated: true`
- `RTCChatHistoryAPIView.get()` 支持 `since_id` query 参数:传入则返回 `id > since_id` 的消息(升序,最多 page_size 条),未传则保持原最近 page_size 条逻辑
- **修改原因**:
- **灰度期双倒保护**:手机端 App 发版到用户手里需要时间,老版仍走 POST 落库strategy B webhook 此时也在落库 → 同一对话产生重复行。POST 去重让两条路径并存而不致脏库
- **重放保护**strategy B 自身被火山重试或客户端重连补提时,去重也能挡住
- **WebSocket 漏推兜底**B' 方案手机端 5s 超时未收到 `chat_message_persisted` 时调 `GET ?since_id=<last>` 增量拉取替换队列里待修正的消息
#### 关联代码(手机端,仅记录依赖关系)
- 手机端 `Assets/Scripts/Manager/WebSocketNetworking.cs` 已新增 `chat_message_persisted` 类型分发分支
- 手机端 `Assets/Scripts/AI/ChatLogManager.cs` 已新增 `OnServerChatPersisted` 方法、`_pendingReplaceQueue` 与 5s 超时兜底
- 手机端 `Assets/Scripts/AI/getJson.cs` 已加 `Config.SubtitleConfig.SubtitleMode=1`
#### 部署顺序与回滚
- **部署顺序**:服务端先部署(修改 1+2+3 三处一同上线)→ 验证 group_send 通道工作 → 手机端再发版
- **回滚**:三处改动都用独立 try/except 包住,可独立 git revert
- 修改 1 revertstrategy B 主流程不受影响,只是不再 group_send手机端 UI 替换路径变为 5s 超时兜底
- 修改 2 revert与修改 1 必须同时 revert否则 group_send 收方为空报警
- 修改 3 revertPOST 不再去重(灰度期会出现双倒,需人工清理 DBGET 不再支持 since_id手机端兜底拉取无效
#### 待跟进 TODO
- 决策点 #3服务端区分手机端 / 设备端 RTC sessionmac 标记 / task_id 命名规则)→ `source_client` 字段填充真实值,让两端按需过滤
- 决策点 #5服务端验证打断时是否仍 flush 部分内容;如不 flush手机端打断分支应跳过入待替换队列以避免 5s 超时空触发
- Phase 0 步骤 1DB 双轨验证SQL 见 `LTY_App_Project_URP/docs/手机端聊天记录_切换服务端字幕落库方案.md`
- Phase 0 步骤 4清理历史脏数据如发现
---
### [2026-03-17] 修复手机号登录时 IntegrityError
- **文件路径**: `userapp/views.py`
- **修改类型**: 修复Bug
- **修改内容**: `PhoneLoginView.post()` 中 `get_or_create` 新增 `defaults={'username': phone_number}`
- **修改原因**: 新用户首次通过手机号登录时,`get_or_create` 未设置 `username` 字段,导致 `username=""` 与数据库中已有空 username 记录冲突,触发 `IntegrityError: duplicate key value violates unique constraint "userapp_paradiseuser_username_key"`。改为用手机号作为默认 username保证唯一性。