21 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-credential-data-layer | 01 | execute | 1 |
|
true |
|
|
Purpose:为 Phase 2 管理端 REST、Phase 3 客户端 REST + 日志脱敏奠基。本 plan 不直接产生 REST 响应,也不动 admin。
Output:3 个文件(1 新建、1 追加、1 自动生成)。
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @.planning/phases/01-credential-data-layer/01-CONTEXT.md @.planning/phases/01-credential-data-layer/01-RESEARCH.md @CLAUDE.md @userapp/models.py @aiapp/models.py @aiapp/apps.py来自 userapp/models.py(AffinitySetting 单例三件套,第 247-314 行 — 直接照抄结构):
class AffinitySetting(models.Model):
# ...字段省略...
class Meta:
verbose_name = '好感度系统设置'
verbose_name_plural = '好感度系统设置'
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
来自 aiapp/apps.py:
class AiappConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'aiapp'
verbose_name = 'AI'
(说明 aiapp 已正常注册;新增模型会用 BigAutoField 主键,pk=1 含义不变)
来自 aiapp/models.py(当前内容,executor 在文件末尾追加,不动 Bot / ChatMessage):
# 文件顶部已有:
from django.db import models
from userapp.models import ParadiseUser
class Bot(models.Model): ... # 行 5-14
class ChatMessage(models.Model): ... # 行 16-52
# 文件末尾追加 CredentialSlot
aiapp 现有迁移序号(来自 ls aiapp/migrations/):
- 0001_initial.py
- 0002_initial.py
- 0003_create_rtc_bot.py
- → 新迁移会自动命名
0004_credentialslot.py(makemigrations 编号)
```python
"""通用工具函数集合。
注:common/ 不是 Django app(无 apps.py、未注册到 INSTALLED_APPS),
仅作为跨 app 的纯函数 utility 命名空间使用。
不要在此放 Django Model / Manager / 任何依赖 app registry 的对象。
"""
def mask_token(token: str, visible_tail: int = 4, mask_char: str = '*') -> str:
"""脱敏长 token / secret,仅保留末 N 位明文。
设计动机:CredentialSlot.access_token 在 Admin 列表 / 查看态需仅显示末 4 位;
Phase 3 阿里云日志 formatter 也将复用本函数。
Args:
token: 待脱敏字符串;空字符串 / None 直接返回 ''
visible_tail: 末尾保留明文的字符数(默认 4)
mask_char: 掩码字符(默认 *)
Returns:
脱敏后的字符串。例:
'sk-abcdef1234' -> '*********1234'
'' -> ''
None -> ''
'abc' -> '***' # 短于 visible_tail 时全部脱敏,不暴露长度信号
"""
if not token:
return ''
if len(token) <= visible_tail:
return mask_char * len(token)
return mask_char * (len(token) - visible_tail) + token[-visible_tail:]
```
关键约束:
- 仅这一个函数,不要顺手加 mask_dict / mask_email / 其它脱敏变体(Phase 3 才需要 dict 递归版)
- 不要 `import` 任何 Django 模块(保持 common/utils.py 是纯 Python utility)
- 短输入兜底分支必须保留(见 docstring 示例 `'abc' -> '***'`)— 这是有意防长度信号泄露
cd C:\Users\admin\Desktop\Lila-Server\qy_lty && python -c "from common.utils import mask_token; assert mask_token('sk-abcdef1234') == '*********1234'; assert mask_token('') == ''; assert mask_token(None) == ''; assert mask_token('abc') == '***'; assert mask_token('abcd') == '****'; assert mask_token('abcde') == '*bcde'; print('OK')"
- 文件 `common/utils.py` 存在
- 文件首行后含 `def mask_token(token: str, visible_tail: int = 4, mask_char: str = '*') -> str:` 签名(grep 命中)
- 上面 verify 命令退出码 0 且输出 `OK`
- 文件不含 `import django` / `from django` 等 Django 依赖(grep 0 命中)
- 文件不含其它 mask_* 函数(grep `^def mask` 仅 1 命中)
verify 命令打印 OK;以上 5 条 acceptance criteria 全部满足。
Task 2:在 aiapp/models.py 末尾追加 CredentialSlot 单例模型
aiapp/models.py
- aiapp/models.py(看当前 51 行内容;新增内容必须追加到末尾,不修改 Bot / ChatMessage)
- userapp/models.py 第 247-314 行(AffinitySetting 单例三件套样板,要 1:1 复刻 save 钩子 + get_solo 写法)
- .planning/phases/01-credential-data-layer/01-RESEARCH.md「问题 2」「问题 5」段(字段命名 / verbose_name / __str__ 等约定)
在 `aiapp/models.py` 文件末尾(第 52 行 `ChatMessage.__str__` 之后)追加以下完整代码块(**不**修改文件已有 Bot / ChatMessage 任何一行,也**不**改动 `from django.db import models` / `from userapp.models import ParadiseUser` 这两个 import):
```python
class CredentialSlot(models.Model):
"""通用凭据槽位(单例)— Milestone v1.0 / Phase 1
全局唯一一条记录,存第三方服务(Kimi / 阿里云 / 火山等)的 APP ID + Access Token。
通过 pk=1 + get_or_create + save() 钩子三件套保证单例:
- 任何 .save() 在已有记录时把新对象 pk 改成现有那条
- get_solo() 是单一访问入口(Phase 2/3 视图统一调用)
- DB 层无额外约束,绕过 ORM 的 bulk_create / 原始 SQL 不在保护范围
"""
app_id = models.CharField(
'APP ID', max_length=128, blank=True, default='',
help_text='第三方服务商分配的 APP ID;运营在 Admin 录入'
)
access_token = models.CharField(
'Access Token', max_length=512, blank=True, default='',
help_text='第三方服务商访问令牌;DB 明文存储,Admin 列表/查看态末 4 位脱敏'
)
updated_at = models.DateTimeField('更新时间', auto_now=True)
class Meta:
verbose_name = '凭据槽位'
verbose_name_plural = '凭据槽位'
def __str__(self):
return f"凭据槽位 (updated {self.updated_at:%Y-%m-%d %H:%M})"
def save(self, *args, **kwargs):
# 强制单例:新增时如果已有记录则覆盖到现有 pk
if not self.pk and CredentialSlot.objects.exists():
existing = CredentialSlot.objects.first()
self.pk = existing.pk
super().save(*args, **kwargs)
@classmethod
def get_solo(cls):
"""获取单例实例,不存在则用默认值创建(pk=1)"""
instance, _ = cls.objects.get_or_create(pk=1)
return instance
```
关键约束(违反任一即失败):
- 字段定义必须严格对齐:`app_id` 是 CharField(128, blank=True, default=''),`access_token` 是 CharField(512, blank=True, default=''),`updated_at` 是 DateTimeField(auto_now=True)
- **不**新增 `created_at`(CONTEXT.md 字段集合未列;单例无"创建"语义)
- **不**加 `null=True`(与 RESEARCH 问题 5 约定一致 — 用 `blank=True, default=''`)
- **不**用 `gettext_lazy as _`(与本仓库 14 个模型一致用中文字面量;详见 RESEARCH 问题 3)
- **不**加 `Meta.constraints` / `unique_together` / `UniqueConstraint`(单例靠 save 钩子,不靠 DB 约束 — RESEARCH 反模式段已说明)
- save 钩子必须用 `existing.pk` 动态写法,**不**得硬编码 `self.pk = 1`(RESEARCH 反模式段说明)
- **不**新建 `aiapp/models/credential_slot.py` 子包(CONTEXT 决策 D-01:追加到末尾,不拆子文件)
cd C:\Users\admin\Desktop\Lila-Server\qy_lty && python -c "import django, os; os.environ.setdefault('DJANGO_SETTINGS_MODULE','qy_lty.settings'); django.setup(); from aiapp.models import CredentialSlot; assert CredentialSlot._meta.verbose_name == '凭据槽位'; fields = {f.name: f for f in CredentialSlot._meta.get_fields()}; assert 'app_id' in fields and 'access_token' in fields and 'updated_at' in fields; assert fields['app_id'].max_length == 128; assert fields['access_token'].max_length == 512; assert hasattr(CredentialSlot, 'get_solo'); print('OK')"
- `aiapp/models.py` 行数从 52 增至约 95(追加 ~43 行,含空行)
- grep `class CredentialSlot(models.Model):` 在 `aiapp/models.py` 命中 1 次
- grep `class Bot(models.Model):` 与 `class ChatMessage(models.Model):` 仍各命中 1 次(未被破坏)
- grep `def get_solo(cls):` 在 `aiapp/models.py` 命中 1 次
- grep `if not self.pk and CredentialSlot.objects.exists` 在 `aiapp/models.py` 命中 1 次
- grep `gettext_lazy` / `from django.utils.translation` 在 `aiapp/models.py` 0 命中
- grep `created_at` 在 `aiapp/models.py` 0 命中(CredentialSlot 不应有 created_at)
- 上面 verify 的 Django shell 一行命令退出码 0、输出 OK
- **count 守恒断言**(Plan-level success criterion #1 的"DB 最多 1 条"由 save() 静默重定向实现,非异常拒绝):在 Task 3 migrate 落地后跑一次 shell 自检 — `python -c "import django, os; os.environ.setdefault('DJANGO_SETTINGS_MODULE','qy_lty.settings'); django.setup(); from aiapp.models import CredentialSlot; CredentialSlot.objects.get_or_create(pk=1); CredentialSlot(app_id='probe1', access_token='probe_secret_xxxx').save(); CredentialSlot(app_id='probe2', access_token='another_value').save(); CredentialSlot(app_id='probe3', access_token='third_value').save(); count = CredentialSlot.objects.count(); assert count == 1, f'singleton violated: count={count}'; print('count_invariant_OK')"`,必须无异常抛出且打印 `count_invariant_OK`(验证 N 次 save 后 count 仍为 1)
verify 命令打印 OK;以上 9 条 acceptance criteria 全部满足;Bot / ChatMessage 未被改动;count 守恒断言通过(N 次 save 后 count 仍为 1)。
Task 3:自动生成迁移文件并执行 migrate
aiapp/migrations/0004_credentialslot.py
- aiapp/migrations/0003_create_rtc_bot.py(看本仓库迁移文件命名 / 头部注释风格)
- aiapp/migrations/0001_initial.py(看 dependencies 段格式)
在仓库根目录执行:
```bash
cd C:\Users\admin\Desktop\Lila-Server\qy_lty
python manage.py makemigrations aiapp
```
期望:Django 自动生成 `aiapp/migrations/0004_credentialslot.py`,其内容包含 `migrations.CreateModel(name='CredentialSlot', fields=[...])`。**不要手写迁移文件**;如果 makemigrations 没有生成新迁移(报 "No changes detected"),说明 Task 2 的模型未正确落地,回到 Task 2 排查,**不**得手动创建迁移文件。
生成成功后立即执行:
```bash
python manage.py migrate aiapp
```
期望:输出 `Applying aiapp.0004_credentialslot... OK`,退出码 0。
生成 + migrate 通过后,做一次 shell 自检(验证 success criterion #1 + #3):
```bash
python manage.py shell -c "from aiapp.models import CredentialSlot; obj, created = CredentialSlot.objects.get_or_create(pk=1); print('created=', created, 'app_id=', repr(obj.app_id), 'access_token=', repr(obj.access_token), 'pk=', obj.pk); obj2 = CredentialSlot(app_id='probe_app', access_token='probe_secret_xxxx'); obj2.save(); print('after second save count=', CredentialSlot.objects.count(), 'obj2.pk=', obj2.pk)"
```
期望输出(首次执行):
```
created= True app_id= '' access_token= '' pk= 1
after second save count= 1 obj2.pk= 1
```
(若是非首次,`created=False` 也可,关键是 `count=1` 与 `obj2.pk=1` 必须满足)
**重要 — 探针数据契约(与 Plan 02 联动)**:本任务故意往 DB 写入一条 `access_token='probe_secret_xxxx'` 的记录,且**不清理**。Plan 02 Task 2 浏览器 checkpoint 会读这条数据来验证脱敏显示(`probe_secret_xxxx` 长度 17,mask_token 期望返回 13 个 `*` + `xxxx` = `*************xxxx`)。Executor 必须确认 shell 自检里 `obj2.access_token == 'probe_secret_xxxx'` 写入成功,否则 Plan 02 的脱敏期望串会失配。
关键约束:
- **禁止手写**迁移;必须由 makemigrations 生成
- 迁移文件名必须是 `0004_credentialslot.py`(Django 默认);如出现冲突命名(如 `0004_xxx.py` 已被别的功能占用),停下并报告,**不**得改名硬塞
- 执行 migrate 失败时不要回滚,把完整错误贴回 — 多数原因是 settings 未指向正确数据库或 PostgreSQL 未启动,需用户介入
- 探针记录 `probe_secret_xxxx` 不要清理(Plan 02 依赖此值;Plan 02 checkpoint 完成后由运营覆写为真实值)
cd C:\Users\admin\Desktop\Lila-Server\qy_lty && python manage.py makemigrations aiapp --check --dry-run && python manage.py migrate aiapp && python manage.py showmigrations aiapp | findstr /R "0004.*\[X\]"
- 文件 `aiapp/migrations/0004_credentialslot.py` 存在
- grep `CreateModel` 在该迁移文件命中 1 次以上
- grep `name='CredentialSlot'` 在该迁移文件命中 1 次
- `python manage.py makemigrations aiapp --check --dry-run` 退出码 0(无未生成的模型变更)
- `python manage.py migrate aiapp` 退出码 0(迁移成功应用,无 'unrecognized arguments' 报错 — 注意 Django 4.2 的 migrate 子命令**没有** `--check` 选项,只有 makemigrations 才有)
- `python manage.py showmigrations aiapp` 输出中 `0004_credentialslot` 行带 `[X]` 标记(表示已应用)
- 上面的 shell 自检命令打印 `count= 1` 且 `obj2.pk= 1`
- 数据库中 `aiapp_credentialslot` 表至少有 1 条 pk=1 的记录(探针写入留下了 'probe_app' / 'probe_secret_xxxx',**不需要清理** — Plan 02 的 Admin checkpoint 会覆盖它,且整个 phase 的目标本就是让单条记录可被运营随时改写)
verify 命令三段(makemigrations --check --dry-run / migrate / showmigrations findstr)都退出码 0 且 findstr 命中 `0004.*[X]`;上述 8 条 acceptance criteria 全部满足。
本 plan 完成后必须能在 `python manage.py shell` 中跑通以下脚本(与 RESEARCH §代码示例「验证 success criteria 的 Django shell 脚本」一致)且全部 assert 通过:
from aiapp.models import CredentialSlot
from common.utils import mask_token
obj, created = CredentialSlot.objects.get_or_create(pk=1)
# created 首次为 True,二次为 False,都接受;关键是 pk=1
assert obj.pk == 1
obj2 = CredentialSlot(app_id='test_app', access_token='secret123456')
obj2.save()
assert CredentialSlot.objects.count() == 1
assert obj2.pk == 1
assert mask_token('sk-abcdef1234') == '*********1234'
assert mask_token('') == ''
assert mask_token(None) == ''
assert mask_token('abc') == '***'
print("Plan 01 verification PASS")
关于 ROADMAP success criterion #1("DB 层或模型层拒绝出现第二条记录")的实现说明:
本 plan 用 save() 钩子静默重定向 pk 的方式(仓库既有 AffinitySetting 同款),而非抛 IntegrityError 拒绝。即 CredentialSlot(app_id='x', access_token='y').save() 在已存在记录时不抛异常,而是把新对象的 pk 改写为现有那条的 pk,于是 super().save() 走 UPDATE 路径,不创建第二行。最终效果是 count 守恒为 1,与 ROADMAP 目标"最多一条"语义等价。verify-work agent 在判定 success criterion #1 时应以"N 次 save 后 count == 1"为准,不应期望异常被抛出。Task 2 acceptance 已加 count 守恒 shell 断言显式验证此语义。
<success_criteria>
- 三个文件按 acceptance criteria 落地:common/utils.py(新建)、aiapp/models.py(追加 CredentialSlot)、aiapp/migrations/0004_credentialslot.py(自动生成)
- 覆盖 ROADMAP Phase 1 success criteria 第 1、2、3 条(DB 单例约束、迁移落地 + 字段齐全、首访 get_or_create 拿空记录)
- 注:第 1 条的"DB / 模型层强制最多一条"由 save() 钩子静默重定向 pk 实现(count 守恒),非异常拒绝;详见 verification 段说明
- 覆盖 REQ CRED-01:单例 CredentialSlot 模型 + 迁移;DB 层最多 1 条记录(save 钩子保证);含 app_id / access_token / updated_at
- mask_token 已可被 Plan 02 与 Phase 3 直接 import 复用
- 本 plan 不涉及 Admin / REST / 修改记录(修改记录由 Plan 02 一并写两条;Admin 由 Plan 02 落地)
- 探针数据契约:Task 3 在 DB 留下
access_token='probe_secret_xxxx'的记录,Plan 02 Task 2 浏览器 checkpoint 依赖此值验证脱敏显示 </success_criteria>