---
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=True，obj.app_id == '' 且 obj.access_token == ''"
    - "在已有 1 条记录的情况下执行 CredentialSlot(app_id='x', access_token='y').save()，DB 仍只有 1 条记录，且新对象 pk 被重定向到现有那条"
    - "python manage.py migrate 退出码为 0，PostgreSQL 出现 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-308；pattern 是正则；用 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。

Output：3 个文件（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.py（AffinitySetting 单例三件套，第 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` 长度 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 完成后由运营覆写为真实值）
  </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>