385 lines
21 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.

---
phase: 01-credential-data-layer
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- common/utils.py
- aiapp/models.py
- aiapp/migrations/0004_credentialslot.py
autonomous: true
requirements:
- CRED-01
must_haves:
truths:
- "common.utils.mask_token('sk-abcdef1234') 返回 '*********1234'(末 4 位明文)"
- "common.utils.mask_token('') 与 mask_token(None) 都返回 ''"
- "common.utils.mask_token('abc') 返回 '***'(短输入全脱敏)"
- "aiapp.models.CredentialSlot 类存在并含 app_id / access_token / updated_at 三个字段"
- "首次执行 CredentialSlot.objects.get_or_create(pk=1) 时 created=Trueobj.app_id == '' 且 obj.access_token == ''"
- "在已有 1 条记录的情况下执行 CredentialSlot(app_id='x', access_token='y').save()DB 仍只有 1 条记录,且新对象 pk 被重定向到现有那条"
- "python manage.py migrate 退出码为 0PostgreSQL 出现 aiapp_credentialslot 表"
artifacts:
- path: common/utils.py
provides: "mask_token(token, visible_tail=4, mask_char='*') 工具函数"
contains: "def mask_token"
min_lines: 20
- path: aiapp/models.py
provides: "CredentialSlot 单例模型(含 save 钩子 + get_solo 类方法)"
contains: "class CredentialSlot(models.Model)"
- path: aiapp/migrations/0004_credentialslot.py
provides: "CreateModel(name='CredentialSlot') 迁移"
contains: "CreateModel"
key_links:
- from: aiapp/models.py
to: aiapp/migrations/0004_credentialslot.py
via: "makemigrations 自动生成pattern 是正则;用 grep -E 匹配"
pattern: "name='CredentialSlot'"
- from: aiapp/models.py CredentialSlot.save
to: AffinitySetting.save 模式
via: "1:1 复刻 userapp/models.py:303-308pattern 是正则;用 grep -E 匹配"
pattern: "if not self.pk and CredentialSlot.objects.exists"
---
<objective>
落地 Milestone v1.0「通用凭据槽位」Phase 1 的数据层 + 通用脱敏工具:
- 在 aiapp 新增 `CredentialSlot` 单例 Django 模型pk=1 + save 钩子 + get_solo覆盖 CRED-01
- 自动生成迁移文件dev 环境 `python manage.py migrate` 通过
- 在 common/utils.py 新建 `mask_token(token, visible_tail=4)` 工具函数,本 phase Plan 02 的 Admin 脱敏会复用Phase 3 的阿里云日志 formatter 也会复用
Purpose为 Phase 2 管理端 REST、Phase 3 客户端 REST + 日志脱敏奠基。本 plan 不直接产生 REST 响应,也不动 admin。
Output3 个文件1 新建、1 追加、1 自动生成)。
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- 关键已存在符号executor 不需要再去 grep -->
来自 userapp/models.pyAffinitySetting 单例三件套,第 247-314 行 — 直接照抄结构):
```python
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
```python
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
```python
# 文件顶部已有:
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 编号)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1新建 common/utils.py 落地 mask_token 工具函数</name>
<files>common/utils.py</files>
<read_first>
- common/__init__.py看 common 是否真的不是 Django app — 应只有 1 行或为空,无 apps.py
- common/responses.py看 common 既有纯工具文件的 docstring / 注释风格)
- common/pagination.py同上确认命名 / 风格一致)
</read_first>
<action>
新建文件 `common/utils.py`**必须是新建,禁止覆盖既有文件**ls 显示当前 common/ 下没有 utils.py完整内容如下
```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' -> '***'`)— 这是有意防长度信号泄露
</action>
<verify>
<automated>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')"</automated>
</verify>
<acceptance_criteria>
- 文件 `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 命中)
</acceptance_criteria>
<done>verify 命令打印 OK以上 5 条 acceptance criteria 全部满足。</done>
</task>
<task type="auto">
<name>Task 2在 aiapp/models.py 末尾追加 CredentialSlot 单例模型</name>
<files>aiapp/models.py</files>
<read_first>
- 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__ 等约定)
</read_first>
<action>
在 `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追加到末尾不拆子文件
</action>
<verify>
<automated>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')"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>verify 命令打印 OK以上 9 条 acceptance criteria 全部满足Bot / ChatMessage 未被改动count 守恒断言通过N 次 save 后 count 仍为 1。</done>
</task>
<task type="auto">
<name>Task 3自动生成迁移文件并执行 migrate</name>
<files>aiapp/migrations/0004_credentialslot.py</files>
<read_first>
- aiapp/migrations/0003_create_rtc_bot.py看本仓库迁移文件命名 / 头部注释风格)
- aiapp/migrations/0001_initial.py看 dependencies 段格式)
</read_first>
<action>
在仓库根目录执行:
```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` 长度 17mask_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 完成后由运营覆写为真实值)
</action>
<verify>
<automated>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\]"</automated>
</verify>
<acceptance_criteria>
- 文件 `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 的目标本就是让单条记录可被运营随时改写)
</acceptance_criteria>
<done>verify 命令三段makemigrations --check --dry-run / migrate / showmigrations findstr都退出码 0 且 findstr 命中 `0004.*[X]`;上述 8 条 acceptance criteria 全部满足。</done>
</task>
</tasks>
<verification>
本 plan 完成后必须能在 `python manage.py shell` 中跑通以下脚本(与 RESEARCH §代码示例「验证 success criteria 的 Django shell 脚本」一致)且全部 assert 通过:
```python
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 断言显式验证此语义。
</verification>
<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>
<output>
完成后由 /gsd-execute-phase 自动生成 `.planning/phases/01-credential-data-layer/01-01-SUMMARY.md`,须含:
- 实际新增 / 修改的代码行数
- 迁移文件实际名称(确认是 0004_credentialslot.py
- 自检 shell 脚本的输出
- mask_token 验证结果
- 给 Plan 02 / Phase 2 / Phase 3 的 hand-off 说明CredentialSlot.get_solo() / mask_token 是公开入口)
- **探针数据当前值确认**`SELECT app_id, access_token FROM aiapp_credentialslot WHERE pk=1` 的实际内容(让 Plan 02 Task 2 checkpoint 知道 mask 期望串以何值算)
</output>
</content>
</invoke>