21 KiB
Raw Blame History

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
common/utils.py
aiapp/models.py
aiapp/migrations/0004_credentialslot.py
true
CRED-01
truths artifacts key_links
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 表
path provides contains min_lines
common/utils.py mask_token(token, visible_tail=4, mask_char='*') 工具函数 def mask_token 20
path provides contains
aiapp/models.py CredentialSlot 单例模型(含 save 钩子 + get_solo 类方法) class CredentialSlot(models.Model)
path provides contains
aiapp/migrations/0004_credentialslot.py CreateModel(name='CredentialSlot') 迁移 CreateModel
from to via pattern
aiapp/models.py aiapp/migrations/0004_credentialslot.py makemigrations 自动生成pattern 是正则;用 grep -E 匹配 name='CredentialSlot'
from to via pattern
aiapp/models.py CredentialSlot.save AffinitySetting.save 模式 1:1 复刻 userapp/models.py:303-308pattern 是正则;用 grep -E 匹配 if not self.pk and CredentialSlot.objects.exists
落地 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 自动生成)。

<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.pyAffinitySetting 单例三件套,第 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.pymakemigrations 编号)
Task 1新建 common/utils.py 落地 mask_token 工具函数 common/utils.py - common/__init__.py看 common 是否真的不是 Django app — 应只有 1 行或为空,无 apps.py - common/responses.py看 common 既有纯工具文件的 docstring / 注释风格) - common/pagination.py同上确认命名 / 风格一致) 新建文件 `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' -> '***'`)— 这是有意防长度信号泄露
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` 长度 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 完成后由运营覆写为真实值)
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>
完成后由 /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 期望串以何值算)