# Phase 1：凭据槽位数据层 — 研究

**调研日期**：2026-05-07
**领域**：Django 4.2 单例模型 + Django Admin（SimpleUI 主题）+ 中文 i18n + 字段脱敏
**整体置信度**：HIGH（绝大多数答案在本仓库现有代码内可直接核对）

## 摘要

本调研回答 6 个 plan 启动前必须先弄清的问题。最重要的发现：

1. **本仓库已存在一个生产可用的单例模型范本** —— `userapp.models.AffinitySetting`（约 247-314 行）。它就是 CONTEXT.md 锁定的 `pk=1 + get_or_create` 模式的现成实现，连 `save()` 钩子和 `get_solo()` 类方法都已落地。**Phase 1 应直接 1:1 复刻它**，不要重新发明。
2. **`common/` 不是 Django app**（没有 `apps.py`，未在 `INSTALLED_APPS` 注册），只能放工具函数，不能放 Django 模型。
3. **本仓库的 i18n 现状是"宣称双语，实际中文硬编码"** —— `LANGUAGES` 与 `LOCALE_PATHS` 在 `settings.py` 第 207-218 行被注释掉，全仓库只有 7 处 `verbose_name=_('...')`，却有 159 处 `verbose_name='...'`（中文字面量）。CONTEXT.md 提到的"`_()` 标记翻译"是约定俗成的"愿景"，实操按现有模型一致性写中文字面量即可，不要为本 phase 单独引入 `gettext_lazy`，会和 14 个其它模型不一致。
4. **`mask_token` / `mask_secret` 工具在仓库里完全不存在**，需新建；放 `common/utils.py`（新文件），后续 Phase 3 阿里云日志脱敏可复用。
5. **单例 Admin 没有现成范本** —— `AffinitySetting` 没有 admin 注册条目；`card/admin.py:262`（`CardUsageLogAdmin`）有 `has_add_permission` 返回 `False` 的写法可以参考；但本 phase 需要"已存在 1 条时禁新增 / 不存在时允许 1 次新增"的条件式权限，需自己写。

**主要建议**：把 `CredentialSlot` 放进 `aiapp/models.py`（凭据语义偏 AI 服务商接入，aiapp 现仅 51 行非常瘦），admin 注册在 `aiapp/admin.py`，脱敏工具放新建的 `common/utils.py`，所有 verbose_name 用中文字面量与既有 14 个模型保持一致。

---

## User Constraints (from CONTEXT.md)

### Locked Decisions（不要质疑、不要给替代方案）

- **单例语义**：`pk=1` 固定主键 + `get_or_create(pk=1)` 模式；`save()` 钩子强制 `self.pk = 1`。**不**用单字段唯一约束方案。
- **字段集合**（最小集，沿用 ParadiseUser / 其它现有模型命名习惯）：
  - `app_id`：`CharField(max_length=128, blank=True, default='')`
  - `access_token`：`CharField(max_length=512, blank=True, default='')`
  - `updated_at`：`DateTimeField(auto_now=True)`
- **数据迁移**：`python manage.py makemigrations <app>` 自动生成，不手写；命名沿用 Django 默认 `0001_initial.py` / `000X_*.py`。
- **不引入新依赖**：不使用 `django-encrypted-model-fields` 等加密库；at-rest 加密推迟。
- **Access Token 仅 Admin 展示态脱敏**：list 列 / 查看态末 4 位掩码（如 `****abcd`）；编辑表单明文供运营录入。DB 始终明文。
- **Admin 隐藏"新增"按钮**：重写 `has_add_permission`，记录已存在时返回 `False`、不存在时返回 `True`。
- **Admin 禁止删除**：重写 `has_delete_permission` 永远返回 `False`。
- **i18n**：模型 `Meta.verbose_name` / `verbose_name_plural` 以及 admin 列表方法的 `short_description` 用 `_()` 标记翻译。⚠ 见下方"研究问题 5"补充说明：本仓库实际 i18n 落地情况与此条约束有冲突，已在 Open Questions 标注。
- **兼容性**：Django 4.2.13、Python 3.8；沿用 `StandardResponseMiddleware`（本 phase 不直接产生 REST 响应，无关）。

### Claude's Discretion（planner / executor 自行决定的细节）

- 模型放在 `aiapp/models.py` 还是 `aiapp/models/credential_slot.py` 子文件 —— 取决于 `aiapp/models.py` 现有大小。**已核实：`aiapp/models.py` 仅 51 行，2 个模型（Bot、ChatMessage），直接追加在尾部即可，不必拆子文件。**
- 是否把 `access_token_masked` 工具函数抽到 `common/utils.py` 复用（Phase 3 阿里云日志脱敏可能也用得上）—— **强烈推荐抽**，避免 Phase 3 再写一份。
- Admin 列表页的字段顺序、过滤器等 UX 细节。
- `verbose_name` 中文字面量（如"凭据槽位"还是"通用凭据"）—— 推荐"凭据槽位"，与 ROADMAP / REQUIREMENTS 全文措辞一致。

### Deferred Ideas（OUT OF SCOPE — 不要碰）

- at-rest 加密 `access_token` 字段（依赖 PostgreSQL 访问控制即可，未来开新 phase 评估）
- 谁在何时改了 `access_token` 的审计日志（Django Admin 自带 `LogEntry` 已经记录基本信息）
- 管理端 / 客户端 REST 接口（分别在 Phase 2 / Phase 3）
- 阿里云日志脱敏过滤器（Phase 3 处理）
- 任何前端工作（在 `qy-lty-admin/` 独立项目）

---

## Phase 需求

| ID | 描述 | 研究支撑 |
|----|------|----------|
| **CRED-01** | 单例 `CredentialSlot` Django 模型 + 迁移；DB 层强制最多一条记录（`pk=1`）；含 `app_id`、`access_token`、`updated_at` 字段 | 研究问题 #1（app 归属决策）+ #2（`AffinitySetting` 单例范本）+ #5（字段命名约定） |
| **CRED-02** | Django Admin 注册：列表/查看态对 `access_token` 脱敏；编辑态明文；隐藏"新增"按钮（强制单例语义）；禁止删除 | 研究问题 #3（Admin 注册模板）+ #4（脱敏工具实现） |

---

## Project Constraints (from CLAUDE.md)

| 约束 | 内容 | 对本 phase 的影响 |
|------|------|--------------------|
| 沟通语言 | 所有面向用户回复用中文 | 不影响代码；影响 plan / verify-work agent 的对话措辞 |
| 修改记录强制 | 每次代码 / 配置 / 迁移 / Docker / 文档结构性改动**必须**追加到 `qy_lty/docs/修改记录.md` 顶部 | **本 phase 至少需要 2 条修改记录条目**（详见研究问题 #6） |
| 跨项目互不混合 | `qy_lty/docs/修改记录.md` 仅记录服务端，`qy-lty-admin/docs/修改记录.md` 仅记录前端；跨项目联动各写一条相互引用 | 本 phase 是**纯服务端**改动，只在 `qy_lty/docs/修改记录.md` 写；不需要联动 qy-lty-admin（前端联动属于 Phase 2/3） |
| 沿用现有约定 | 工具调用参数 / commit message / 代码注释保持原项目约定（中文为主） | 模型 verbose_name、字段 verbose_name 用中文字面量 |

---

## 架构责任映射

| 能力 | 主层 | 次层 | 理由 |
|------|------|------|------|
| 凭据存储 | 数据库（PostgreSQL）| ORM 层（Django Model） | 单条记录、单例语义在 DB 强制（pk=1） |
| 单例约束执行 | Model 层（`save()` 钩子）| ORM 默认主键 | DB 主键唯一约束 + `save()` 钩子双保险 |
| 写入入口（Phase 1）| Django Admin（HTML 表单）| `auto_now=True` 字段 | 运营手动录入；Phase 1 不出 REST |
| 脱敏（展示态）| Admin ModelAdmin 计算字段 | `common/utils.py` 工具函数 | 复用同一脱敏逻辑给 Phase 3 日志过滤器 |
| 国际化 | Django i18n（`_()` + locale/）| Meta.verbose_name 字面量 | 见 Open Questions：本仓库实际是中文字面量为主 |

---

## 研究问题逐条回答

### 问题 1：CredentialSlot 单例模型放在哪个 app？

**答**：放进 `aiapp`（追加到 `aiapp/models.py` 尾部）。

**依据**：

- **`common/` 不是 Django app**。仓库里 `common/` 没有 `apps.py`，也未出现在 `qy_lty/settings.py` 的 `INSTALLED_APPS`（第 43-74 行）中；`common/__init__.py` 只有 1 行，是纯 Python utility 包。**Django 模型必须放进已注册 app**，所以 `common` 直接出局。 [VERIFIED: 通过 grep INSTALLED_APPS 与读 common/__init__.py、未发现 common/apps.py]
- **`aiapp` 是最自然的归属**。当前 milestone 的"凭据"语义指 AI 服务商（Kimi / 阿里云 NLS / 火山引擎 / 腾讯）的 APP ID + Access Token，与 `aiapp` 的"AI 对话与多服务商语音抽象"主题强相关；ROADMAP / REQUIREMENTS / CONTEXT 也未明示具体服务商，意味着这是个"AI 服务接入凭据"槽位。 [VERIFIED: 读 aiapp/apps.py 第 7 行 `verbose_name = 'AI'`、CLAUDE.md 102 行 aiapp 描述]
- **`aiapp/models.py` 体积合适**：仅 51 行、2 个模型（`Bot`、`ChatMessage`），追加 1 个新模型不会让文件臃肿。**不需要拆 `aiapp/models/credential_slot.py` 子目录**。 [VERIFIED: 读 aiapp/models.py 全文]
- **`userapp` 不合适**：该 app 主题是"用户身份与认证"（ParadiseUser、Affinity*），把第三方服务凭据塞进去语义错位；而且 `userapp/models.py` 已有 471 行 + 6 个模型（含 `AffinitySetting` 等好感度系统），本就承重大。
- **不建议为此新建 app**：单例 + 2 字段太轻量，新建 app 会带来 `apps.py` / `migrations/` / `admin.py` / 注册到 INSTALLED_APPS 一连串成本，性价比低。

**唯一可能改写此结论的情况**：若运营预期未来在同一槽位扩展为"按服务商分别存凭据"（Kimi 一组、阿里云一组、火山一组），那么"全局单例"语义本身就站不住，整个数据建模需要重谈。**这超出 Phase 1 范围**。

### 问题 2：本仓库已有 pk=1 单例模型范本可参考吗？

**答**：有，**`userapp.models.AffinitySetting`**（`userapp/models.py` 第 247-314 行）。Phase 1 应**直接 1:1 复刻它的单例三件套**，不要自创。

**`AffinitySetting` 的单例三件套（VERIFIED：直接读源码摘抄）**：

```python
# 1) Meta（无特殊单例约束，靠 save() + get_solo() 在 Python 层保证）
class Meta:
    verbose_name = '好感度系统设置'
    verbose_name_plural = '好感度系统设置'

# 2) save() 钩子 —— 阻止第二条记录被插入：新对象时若已存在记录，强制把 pk 改成现有那条
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)

# 3) get_solo() 类方法 —— 单一访问入口
@classmethod
def get_solo(cls):
    """获取单例实例，不存在则用默认值创建"""
    instance, _ = cls.objects.get_or_create(pk=1)
    return instance
```

**对 CredentialSlot 的复刻方案（推荐字面照搬）**：

```python
# aiapp/models.py 末尾追加
class CredentialSlot(models.Model):
    """通用凭据槽位（单例）— 全局唯一一条记录

    APP ID + Access Token 的全局存储槽位，供管理后台运营录入、
    手机端/设备端读取后调用第三方服务（Kimi / 阿里云 / 火山等）。

    单例语义通过 pk=1 + save() 钩子保证，配合 get_solo() 入口。
    """

    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
```

**与 CONTEXT.md "锁定 pk=1" 的对照**：

- CONTEXT.md 说"`pk=1` 固定主键 + `save()` 钩子强制 `self.pk = 1`"。
- `AffinitySetting` 的实际写法是"若已存在记录则把新对象的 `pk` 改成已存在那条的 `pk`"——**不是直接 `self.pk = 1`**，但效果等价：第一次 `get_or_create(pk=1)` 让首条记录的 `pk` 就是 1，往后不存在第二条记录，`save()` 钩子永远走 fallback。
- **这两种写法在 PostgreSQL 上行为完全一致**，但 `AffinitySetting` 的写法更鲁棒（即使首条记录因某种原因 `pk != 1`，钩子也会把第二次 `create()` 重定向到现有记录）。**建议用 `AffinitySetting` 的写法**，与既有代码一致即"工程上正确"。 [VERIFIED]

**注意**：DB 层并未额外加 `UniqueConstraint` 来限制行数 —— 单例语义完全靠 `save()` 钩子在 Python 层执行。这意味着**绕过 ORM 的批量 SQL 插入（`bulk_create` / 原始 SQL）能逃过钩子**，但 Phase 1 只走 Admin 入口，不会触发这种边界。 [VERIFIED：读 AffinitySetting 全文未见 constraints / unique_together / UniqueConstraint]

### 问题 3：SimpleUI + django-rosetta 中英 i18n 的 Admin 注册写法

**答**：本仓库的 i18n 现状与 CONTEXT.md 的预期**有偏差**，需澄清后再决定写法（已在 Open Questions 标注）。下面给出**两套模板**和"实操推荐"。

**仓库现状（VERIFIED）**：

| 项目 | 状态 |
|------|------|
| `INSTALLED_APPS` 中 `'rosetta'` / `'simpleui'` | ✓ 已注册（settings.py:44-45） |
| `LANGUAGE_CODE` | `'zh-hans'`（settings.py:200） |
| `USE_I18N` | `True`（settings.py:201） |
| `LANGUAGES = [...]` | **被注释掉**（settings.py:208-213） |
| `LOCALE_PATHS = [...]` | **被注释掉**（settings.py:216-218） |
| `locale/en/LC_MESSAGES/django.po` | ✓ 文件存在 |
| `locale/zh_HAns/LC_MESSAGES/django.po` | ✓ 文件存在 |
| `verbose_name=_('...')` 全仓占用 | **7 处**（card/models.py 1 处、subscription_app/models.py 6 处） |
| `verbose_name='...'`（中文字面量）全仓占用 | **159 处**（横跨 14 个模型文件） |
| `userapp/admin.py`、`aiapp/admin.py`、`device_interaction/admin.py` 是否使用 `_()` | **均不使用**；admin 文件是纯中文字面量风格 |

[VERIFIED：通过 grep 与逐文件读取]

**结论**：本仓库**实操约定是中文硬编码**，CONTEXT.md 说的"`_()` 标记翻译"在现存 admin.py 中并未实施。`rosetta` / `_().po` 文件存在但属于早期未完成的 i18n 计划。

**模板 A：保持仓库一致性（中文字面量，强烈推荐）**

```python
# aiapp/admin.py 末尾追加
from .models import Bot, ChatMessage, CredentialSlot

@admin.register(CredentialSlot)
class CredentialSlotAdmin(admin.ModelAdmin):
    """通用凭据槽位 Admin（单例）"""

    list_display = ('id', 'app_id', 'access_token_masked', 'updated_at')
    readonly_fields = ('updated_at',)

    fieldsets = (
        ('基本信息', {
            'fields': ('app_id', 'access_token')
        }),
        ('元数据', {
            'fields': ('updated_at',)
        }),
    )

    def access_token_masked(self, obj):
        """列表/查看态展示脱敏后的 Access Token（仅末 4 位）"""
        from common.utils import mask_token
        return mask_token(obj.access_token)
    access_token_masked.short_description = 'Access Token (脱敏)'

    def has_add_permission(self, request):
        # 单例语义：已存在记录时禁止新增
        return not CredentialSlot.objects.exists()

    def has_delete_permission(self, request, obj=None):
        # 永远禁止删除，避免运营误删后单例语义丢失
        return False

    class Meta:
        verbose_name = '凭据槽位'
        verbose_name_plural = '凭据槽位管理'
```

**模板 B：严格按 CONTEXT.md 用 `gettext_lazy`（仅当 planner 确认要新立 i18n 起点时采用）**

```python
from django.utils.translation import gettext_lazy as _
# ... model + admin 内所有面向用户字符串都用 _('...') 包裹
class Meta:
    verbose_name = _('凭据槽位')
    verbose_name_plural = _('凭据槽位')

def access_token_masked(self, obj): ...
access_token_masked.short_description = _('Access Token (脱敏)')
```

⚠ 模板 B 的代价：本 phase 落地后只有 `aiapp/admin.py` + `aiapp/models.py` 的 CredentialSlot 部分使用 `_()`，aiapp 的另外两个模型（`Bot`、`ChatMessage`）仍是中文字面量；未来若想真正双语，需要批量回改 14 个模型 + 跑 `makemessages` / `compilemessages` —— **超出 Phase 1 scope，属于独立的 i18n milestone**。

**实操建议**：planner 先在 plan 文件里**显式问用户**：

> 本仓库当前 159 处中文字面量 vs 7 处 `_()`，admin 文件无一处用 `_()`。Phase 1 需要保持一致性吗？
> - **A.** 保持一致（中文字面量；CONTEXT.md 锁定的 `_()` 约束**作废**） ← 推荐
> - **B.** 为本 phase 单独引入 `_()`（与 14 个其它模型不一致）
> - **C.** 把"批量回改 i18n"作为独立 milestone 立项，本 phase 先按 A 做

如果用户允许 discretion，按模板 A 写。

[VERIFIED: 通过 grep `verbose_name=_` 与 `verbose_name='` 对比、读 settings.py 200-218 行、读 4 个 admin.py]

**SimpleUI 主题对 admin 写法的影响**：**几乎为零**。SimpleUI 是渲染主题层，不改变 ModelAdmin API；它增强 admin UI 但 `list_display` / `fieldsets` / `has_add_permission` / `has_delete_permission` 等用法和原生 Django 4.2 admin **完全一致**。因此模板 A/B 在 SimpleUI 下都能直接工作。 [CITED: settings.py 462-498 SimpleUI 配置段、CLAUDE.md 251-254 描述]

### 问题 4：本仓库已存在 mask_token / mask_secret 工具吗？

**答**：**不存在**。需要新建。 [VERIFIED：grep `mask|脱敏|redact` 全仓只命中 `.planning/` 文档（5 处），代码侧 0 处]

**推荐实现**（4-6 行，放新建文件 `common/utils.py`）：

```python
# common/utils.py（新建）
"""通用工具函数。

注：common/ 不是 Django app（无 apps.py、未注册），仅用于跨 app 的纯函数工具。
不要在此放 Django Model / Manager / 任何依赖 app registry 的对象。
"""


def mask_token(token: str, visible_tail: int = 4, mask_char: str = '*') -> str:
    """脱敏一个长 token / secret，仅保留末 N 位明文。

    Args:
        token: 待脱敏字符串；空字符串、None 直接返回空串
        visible_tail: 末尾保留明文的字符数（默认 4）
        mask_char: 掩码字符（默认 *）

    Returns:
        如 'abcdef1234' -> '****1234'；'' -> ''；None -> ''；'abc' -> '***'
        （短于 visible_tail 时全部脱敏，避免泄露长度信号）

    用例：
        - aiapp/admin.py CredentialSlotAdmin.access_token_masked()
        - 后续 Phase 3 阿里云日志 formatter 可复用
    """
    if not token:
        return ''
    if len(token) <= visible_tail:
        return mask_char * len(token)
    return mask_char * (len(token) - visible_tail) + token[-visible_tail:]
```

**为什么放 `common/utils.py`**：

- `common/` 已有同性质的纯工具文件（`responses.py`、`pagination.py`、`swagger_utils.py`），与 codebase CONVENTIONS.md "共享工具：通用中间件/辅助：添加到 `common/` 目录" 一致。
- 抽到 `common` 让 Phase 3 阿里云日志 formatter 直接 `from common.utils import mask_token` 复用，无环依赖。
- 不放 `aiapp/utils.py` 是因为 Phase 3 的日志过滤器要全局生效（`common/aliyun_logging.py`），跨 app 引 `aiapp` 反向依赖不合适。

**短输入边界处理（重要）**：上面实现把短于 `visible_tail` 的 token 全部脱敏。这是**有意为之**——若直接返回 `token[-visible_tail:]` 会导致 3 字符 token 完整暴露；若返回 `'****' + token` 则泄露"原 token 短"的信号。CONTEXT.md 锁定"末 4 位"，本实现遵守且对短输入兜底安全。

**推迟扩展**：Phase 3 的"日志中识别 `access_token` 字段并脱敏"会再加一个 `mask_dict_recursive(d, sensitive_keys=['access_token', 'app_id', ...])` 之类的递归函数，但**不是 Phase 1 的事**。Phase 1 只交付最小 `mask_token`。

### 问题 5：模型字段命名约定（从 ParadiseUser、Device、CardCategory 抽取）

**已 VERIFIED 的约定**（来自 `userapp/models.py`、`device_interaction/models.py`、`card/models.py` 直接观察）：

| 维度 | 约定 | 证据 |
|------|------|------|
| 字段名 | `snake_case` | `phone_number`、`zodiac_sign`、`mac_address`、`device_code`、`is_primary`、`bound_at`、`unique_id`、`batch_number` |
| 布尔字段命名 | `is_*` 或 `has_*` 前缀 | `is_active`、`is_primary`、`is_enabled`、`is_deleted`、`is_negative`、`manufactured`（反例：少数动作过去式） |
| 时间戳字段 | `*_at` 后缀 | `created_at`、`updated_at`、`bound_at`、`activated_at`、`published_at`、`used_at`、`manufactured_at` |
| `created_at` | `auto_now_add=True` | 全仓一致 |
| `updated_at` | `auto_now=True` | 全仓一致 |
| `verbose_name` | **中文字面量**（极少数 `_()`） | 159:7（见问题 3） |
| `Meta.verbose_name_plural` | 通常与 `verbose_name` 相同（中文不区分单复数） | `'凭据槽位'` / `'凭据槽位'` |
| `Meta.ordering` | 时间倒序（`['-created_at']` / `['-bound_at']` / `['-date_joined']`）；按业务自然序（`['level']` / `['timestamp']`） | ParadiseUser、AffinityRule、AffinityLog、Card、CardBatch、UserDevice 全部 `['-created_at']` 或 `['-*_at']` 风格 |
| `__str__` | 返回中文/含字段值的可读字符串 | `f"#{self.id} {self.rule_key or self.source} ..."`、`f"{self.username} ..."` |
| 长 token 字段 | 用 `CharField`（不是 `TextField`），`max_length=512` 是合理上限（覆盖常见 JWT） | 当前仓库无类似字段；CONTEXT.md 锁定 512，与 `description = models.TextField(max_length=2048)`（aiapp/models.py:39）保持数量级一致 |
| 允许空字符串字段 | `blank=True, default=''`（不用 `null=True`） | ParadiseUser.phone_number（`null=True, blank=True` —— 反例，因为加了 unique 约束）；多数文本字段是 `blank=True, default=''`（card/models.py 大量例子） |

**对 `CredentialSlot` 字段写法的推论**：

```python
app_id = models.CharField('APP ID', max_length=128, blank=True, default='')
access_token = models.CharField('Access Token', max_length=512, blank=True, default='')
updated_at = models.DateTimeField('更新时间', auto_now=True)
```

- `'APP ID'` / `'Access Token'` 是常见技术名词，保留英文不翻译——与 `'MBTI性格'`、`'OSS'` 风格一致。
- 不加 `null=True`，因为没有唯一约束；用 `blank=True, default=''` 让 Admin 表单允许空提交、首次 `get_or_create(pk=1)` 拿到的空记录字段为 `''` 而非 `None`，与 CONTEXT.md success criterion #3（`obj.app_id == ''`）一致。
- **不需要 `created_at`** —— 单例只有一行，"创建"等于"首次 `get_or_create`"，业务上没意义；CONTEXT.md 字段集合也未列。

[VERIFIED: 直接读取 4 个 models.py 文件汇总]

### 问题 6：修改记录格式（docs/修改记录.md 文件头说明）

**VERIFIED：来自 `qy_lty/docs/修改记录.md` 第 7-19 行原文**：

```
### [日期] 修改简述

- **文件路径**: 相对于项目根目录的文件路径
- **修改类型**: 新增 / 修改 / 删除 / 重构 / 修复Bug
- **修改内容**: 具体修改了什么
- **修改原因**: 为什么要做这个修改
```

**追加位置**：文件第 23 行 `<!-- 新的修改记录添加在此处下方，最新的在最前面 -->` 注释**之下**。

**VERIFIED 的实操扩展（从既有条目观察）**：

- 复杂条目可在主条目下嵌 `#### P1-04 — 新增 AffinitySetting（单例表）` 之类的子段（修改记录.md:73 等行有先例），用于一次提交多文件 / 多模块的情况
- 某些条目末尾有 `- **后续动作**: ...`（修改记录.md:45）做下一步交接说明
- 关联设计文档可在条目顶部加 `配套设计文档：[docs/xxx.md](xxx.md)` 链接（修改记录.md:56-57）

**Phase 1 落地建议条目数（推荐 2 条）**：

```markdown
### [YYYY-MM-DD] Phase 1 — 凭据槽位数据层（CredentialSlot 单例模型 + 迁移）

- **文件路径**:
  - `aiapp/models.py`（修改 — 追加 CredentialSlot 单例模型，参考 userapp.AffinitySetting 模式）
  - `aiapp/migrations/000X_credentialslot.py`（新增 — makemigrations 自动生成）
  - `common/utils.py`（新增 — mask_token 工具函数，供 Phase 3 复用）
- **修改类型**: 新增
- **修改内容**:
  - 新增 `CredentialSlot` 模型（aiapp app）：3 字段（`app_id`、`access_token`、`updated_at`）；`pk=1` + `get_or_create` 单例语义；`save()` 钩子阻止第二条记录；`get_solo()` 类方法
  - 新增 `common.utils.mask_token(token, visible_tail=4)` 工具函数
  - 自动生成迁移 `aiapp/migrations/000X_credentialslot.py`，`python manage.py migrate` 通过
- **修改原因**:
  - Milestone v1.0「通用凭据槽位」Phase 1：在 DB 层落地全局单例的 APP ID + Access Token 存储槽位，为 Phase 2 管理端 REST、Phase 3 客户端 REST + 日志脱敏奠基
  - 复用 `userapp.AffinitySetting` 的成熟 pk=1 单例模式；mask_token 抽到 common/utils.py 让 Phase 3 阿里云日志 formatter 可直接复用
- **后续动作**: Phase 2 暴露 `/api/v1/admin/credential-slot/` GET/PUT；Phase 3 暴露 `/api/credential-slot/` GET 明文 + 阿里云日志 mask `access_token`

### [YYYY-MM-DD] Phase 1 — Django Admin 注册凭据槽位（脱敏 + 单例约束 + 禁删）

- **文件路径**: `aiapp/admin.py`（修改 — 新增 CredentialSlotAdmin）
- **修改类型**: 新增
- **修改内容**:
  - 注册 `CredentialSlotAdmin`：`list_display` 含 `access_token_masked`（末 4 位掩码）、`app_id`、`updated_at`
  - 编辑表单 `app_id` / `access_token` 明文（运营录入需要）；`updated_at` 设 `readonly_fields`
  - `has_add_permission` 在已存在记录时返回 `False`（Admin 列表页隐藏「新增」按钮）
  - `has_delete_permission` 永远返回 `False`（避免运营误删丢失单例语义）
- **修改原因**: CRED-02：在 SimpleUI 后台为运营提供受控的凭据录入入口；列表 / 查看态脱敏防截图泄露；编辑态明文供录入；新增 / 删除按钮隐藏强制单例语义
```

**为什么是 2 条（而不是 1 条合并）**：CRED-01（数据层）和 CRED-02（Admin）虽属同一 phase，但是**两个独立 commit / 两次 verify**的合理粒度，分开记录方便回溯。Plan 写者可视情况合并为 1 条带子段，但**不可省略**。

[VERIFIED: 读 docs/修改记录.md 第 1-150 行原文 + CLAUDE.md 第 256-281 行强制规则]

---

## 标准技术栈（本 phase）

### 核心
| 库 | 版本 | 用途 | 依据 |
|----|------|------|------|
| Django | 4.2.13 | ORM、Admin、迁移 | settings.py:4、CLAUDE.md:191 [VERIFIED] |
| django-simpleui | （未锁版本）| Admin 主题 | requirements.txt + settings.py:45、462-498 [VERIFIED] |
| django-rosetta | （未锁版本）| Admin 翻译 UI（在 INSTALLED_APPS，但本仓库实际未启用 LANGUAGES/LOCALE_PATHS）| settings.py:44 [VERIFIED] |
| Python | 3.8 | 运行环境 | Dockerfile:2、PROJECT.md「约束」段 [VERIFIED] |

### 支持
| 库 | 用途 | 何时使用 |
|----|------|----------|
| `django.utils.translation.gettext_lazy as _` | 字符串国际化标记 | 仅当 planner 决定为本 phase 启用 i18n 时（见问题 3，**不推荐**） |

### 不引入
| 包 | 原因 |
|----|------|
| `django-encrypted-model-fields` | CONTEXT.md 锁定"不引入新依赖"；at-rest 加密推迟 |
| `django-solo` 等单例库 | `AffinitySetting` 已展示自实现单例只需 ~10 行；引第三方包反而引入 1 个版本约束 |

**版本核实备注**：CONTEXT.md 限定 Python 3.8 + Django 4.2.13，**不需要执行 `npm view` / `pip index versions`** —— 版本由项目本身锁定，研究者只验证写法兼容性即可。Django 4.2.13 LTS 的 `ModelAdmin.has_add_permission(self, request)` 与 `has_delete_permission(self, request, obj=None)` 签名稳定（自 Django 1.x 起未变）。 [CITED: https://docs.djangoproject.com/en/4.2/ref/contrib/admin/#django.contrib.admin.ModelAdmin.has_add_permission]

---

## 架构模式

### 系统数据流图（本 phase）

```
[运营浏览器]
    │
    │ HTTP（admin session cookie）
    ▼
[Django Admin /admin/aiapp/credentialslot/]
    │
    │ POST 表单 → ModelForm 校验
    ▼
[CredentialSlotAdmin.save_model()]   ← has_add_permission 守门（已有记录则 403）
    │                                  has_delete_permission = False（无删除路径）
    │ obj.save()
    ▼
[CredentialSlot.save() 钩子]   ← 强制 pk = 已存在那条 OR 走 get_or_create(pk=1)
    │
    │ INSERT / UPDATE
    ▼
[PostgreSQL: aiapp_credentialslot 表]
    │ 单行：(pk=1, app_id, access_token 明文, updated_at)
    │
    │ SELECT
    ▼
[Admin 列表 / 查看态]
    │
    │ access_token_masked() → common.utils.mask_token(...)
    ▼
[渲染 HTML：****abcd（仅末 4 位）]
```

读端（Phase 2/3 之事，本 phase 不实现）：

```
[管理端前端]   [手机/设备客户端]
      │              │
      │ GET          │ GET
      ▼              ▼
  /api/v1/admin/   /api/credential-slot/
  credential-slot/    │
      │              │
      │ admin token  │ user token
      ▼              ▼
  脱敏返回         明文返回
      │              │
      └────┬────────┘
           ▼
  CredentialSlot.get_solo()  ← Phase 2/3 视图共同入口
           │
           ▼
       PostgreSQL
```

### 推荐落地结构

```
qy_lty/
├── aiapp/
│   ├── models.py             # 追加 class CredentialSlot 在文件末尾
│   ├── admin.py              # 追加 @admin.register(CredentialSlot) 注册
│   └── migrations/
│       └── 000X_credentialslot.py   # makemigrations 自动生成
│
├── common/
│   └── utils.py              # 新建：mask_token(...)
│
└── docs/
    └── 修改记录.md           # 顶部追加 2 条修改记录
```

### 模式 1：单例模型（pk=1 + get_or_create + save 钩子）

**何时用**：全局单条配置 / 凭据 / 槽位，Admin 录入，不希望出现"两条都半填的记录" / "运营误删" 等运营事故。

**实现**：见问题 2。

### 模式 2：Admin 计算字段脱敏

**何时用**：DB 字段是敏感明文，Admin 列表 / 查看态希望仅展示部分。

**实现**：

```python
class CredentialSlotAdmin(admin.ModelAdmin):
    list_display = ('id', 'app_id', 'access_token_masked', 'updated_at')
    # NOTE: list_display 中 access_token_masked 是方法名，不是字段名
    # readonly_fields 仅放真实模型字段（updated_at），不放计算字段

    def access_token_masked(self, obj):
        from common.utils import mask_token
        return mask_token(obj.access_token)
    access_token_masked.short_description = 'Access Token (脱敏)'
```

**关键点**：

- `access_token` 本身**仍**作为可编辑字段保留（不放进 `readonly_fields`），编辑表单显示明文 input。
- 计算字段 `access_token_masked` **只**出现在 `list_display`，不出现在 `fields` / `fieldsets`，否则编辑表单也会显示一个无意义的只读脱敏行。

[VERIFIED: 与 Django 4.2 Admin 文档语义一致；与 card/admin.py 既有计算字段写法风格一致]

### 模式 3：条件式 has_add_permission（单例约束）

```python
def has_add_permission(self, request):
    return not CredentialSlot.objects.exists()
```

**为什么是 `.exists()` 而不是 `.count() == 0`**：性能等价，但 `.exists()` 更地道，是 Django ORM 文档推荐写法。

**为什么是"已存在则 False"而不是"永远 False"**：首次部署时表为空，运营**应该能**新增第一条；之后 `.exists()` 为真，新增按钮就消失。这正是 CONTEXT.md success criterion #5 的语义。

[CITED: https://books.agiliq.com/projects/django-admin-cookbook/en/latest/remove_add_delete.html、https://dev.to/danilovmy/how-to-solve-the-singleton-problem-in-django-modeladmin-g42]

### 反模式（避免）

- **❌ 在 `Meta` 加 `UniqueConstraint(fields=[一个固定常量])`**：Django 不允许约束基于常量字段；强行用一个非空字段加唯一约束等价于 CONTEXT.md 已经否决的方案。**走 pk=1 + 钩子。**
- **❌ 用 `OneToOneField(some_other_model, primary_key=True)` 来强制单例**：CredentialSlot 没有合适的"父对象"可挂；徒增复杂度。
- **❌ 在 `save()` 钩子里 `self.pk = 1` 硬编码**：当首条记录 pk 因迁移合并历史等原因不是 1 时（极少见），硬编码会让钩子破坏现有数据。**用 `AffinitySetting` 那种 `existing.pk` 动态写法**。
- **❌ `access_token` 字段使用 `TextField` 而非 `CharField(max_length=512)`**：CONTEXT.md 已锁 max_length=512；TextField 无长度上限会让 Admin form 默认渲染成 textarea，UX 不符。
- **❌ 把 `mask_token` 放进 `aiapp/utils.py`**：Phase 3 的全局日志过滤器在 `common/aliyun_logging.py`，跨 app 引 aiapp 不合适；放 `common/utils.py` 是唯一对的位置。
- **❌ 重新实现单例语义而不复用 `AffinitySetting` 的写法**：会让仓库出现两套不一样的单例约定，未来维护成本翻倍。

---

## Don't Hand-Roll

| 问题 | 不要造 | 改用 | 原因 |
|------|--------|------|------|
| 单例模型 | 自己写 `if exists: raise` 校验 | 1:1 复刻 `userapp.AffinitySetting` 的 `save()` + `get_solo()` | 仓库已有验证过的 4 行实现；引第三方 `django-solo` 反而违反"不引入新依赖" |
| 字符串脱敏 | 仓库无既有工具，确实要自建 | `common/utils.py` 中**只**写最小 `mask_token()`（4-6 行） | 不要直接引 `python-stdnum` / `cryptography` 之类的重型库，CONTEXT.md 锁"不引入新依赖" |
| 数据迁移 | 手写 SQL | `python manage.py makemigrations aiapp` | Django 4.2 自动迁移生成器对单纯新表完全胜任 |
| 隐藏 Admin 新增按钮 | 改 SimpleUI 模板 | 重写 `has_add_permission` | Django 内置机制已生效（按钮根据 permission 自动隐藏，参 SimpleUI 也兼容） |
| Admin 表单字段控制 | 自定义 ModelForm | 用 `fieldsets` + `readonly_fields` | 与 `CardTemplateAdmin`、`UserDeviceAdmin` 等本仓库其它 Admin 一致 |

**核心洞察**：本 phase 不是"开发新组件"，而是"对照 `AffinitySetting` 范本写一遍 + 加 admin 注册 + 写 4 行脱敏函数"。**总变更量预计 < 80 行 Python**，迁移文件由 Django 自动生成。复杂度全部来自审慎遵守约束（i18n 取舍、修改记录、单例语义、SimpleUI 兼容），**不在代码本身**。

---

## 运行时状态盘点

> 本 phase 是**新建**功能，**不**涉及重命名 / 迁移 / refactor，本节按要求列示但都是 "无 — N/A"。

| 类别 | 找到的内容 | 所需动作 |
|------|------------|----------|
| 存储数据 | 无（CredentialSlot 是新表，PostgreSQL 中尚不存在） | 无（迁移会自动建表） |
| 在线服务配置 | 无（不依赖外部 SaaS / n8n / Datadog） | 无 |
| 操作系统注册的状态 | 无 | 无 |
| Secrets / 环境变量 | **不**新增任何 env var；DB 凭据沿用 `POSTGRESQL_DATABASE_*` | 无 |
| 构建产物 / 已安装包 | 无（不引入新 pip 包） | 无 |

**规范化提问的答案**：本 phase 落地后，迁移会在 PostgreSQL 创建 `aiapp_credentialslot` 表；首次访问 Admin 通过 `get_or_create(pk=1)` 写入第一条空记录；除此之外**无任何**已部署运行时副作用需要清理 / 同步。

---

## 常见陷阱

### 陷阱 1：以为 `pk=1` 是 DB 层硬约束

**会发生什么**：开发者以为单例由 `pk=1` 唯一约束保证，结果直接 `bulk_create([CredentialSlot(), CredentialSlot()])` 或写原始 SQL `INSERT INTO aiapp_credentialslot ...`，绕过 Python `save()` 钩子，DB 出现两条记录。

**为什么**：`AffinitySetting` / 本 phase 的单例语义**完全在 Python `save()` 层**，DB 层只有标准 PK 约束（即每条记录有唯一 pk，但**没**约束 pk 必须等于某个常量）。

**如何避免**：

- 文档明确：所有写入路径必须经 `obj.save()` 或 `get_or_create(pk=1)`，**不要**用 `bulk_create` / 原始 SQL
- Phase 2 PUT 视图实现里只用 `instance.save()`，不用 manager 旁路
- verify-work 阶段在 Django shell 验证：`CredentialSlot.objects.bulk_create([CredentialSlot()])` 会破坏单例语义（这是已知接受的局限性，写在 `__doc__` 里即可）

**预警信号**：DB 中突然出现 2+ 条记录；`get_solo()` 返回的对象 pk != 1；Phase 2 PUT 接口不更新而是新建。

### 陷阱 2：把 access_token 加进 readonly_fields

**会发生什么**：开发者看到"列表态脱敏"的需求，直觉性地把 `access_token` 也放进 `readonly_fields`，结果**编辑表单也变只读**，运营无法录入。

**为什么**：`readonly_fields` 控制的是**编辑表单**，`list_display` + 计算方法控制的是**列表 / 查看态**。两者解耦。

**如何避免**：

- `list_display` 写**计算方法名** `access_token_masked`，**不**写真实字段名 `access_token`
- `readonly_fields` 仅放 `('updated_at',)`
- `fields` / `fieldsets` 中保留 `access_token` 作为正常可写字段

**预警信号**：Admin 编辑表单中 `access_token` 字段不是 input 而是只读文本。

### 陷阱 3：忘记 has_delete_permission

**会发生什么**：CONTEXT.md 明确要求"禁止删除"，但只重写了 `has_add_permission`，运营在编辑页底部仍看到"删除"按钮，误点一次就让单例语义失守（虽然下次访问 `get_or_create(pk=1)` 会自动重建，但 `app_id` / `access_token` 字段恢复到空，运营要重新录入）。

**如何避免**：在 `CredentialSlotAdmin` 中**两个**权限方法都重写：

```python
def has_add_permission(self, request):
    return not CredentialSlot.objects.exists()

def has_delete_permission(self, request, obj=None):
    return False
```

**预警信号**：success criterion #6 失败 —— 编辑页底部仍有 "Delete" 按钮 / 批量动作含 "Delete selected"。

### 陷阱 4：i18n 半推半就

**会发生什么**：planner 严守 CONTEXT.md 字面"用 `_()` 标记"指令，结果只有 CredentialSlot 字符串包了 `_()`，仓库其它 14 个模型仍是中文字面量，未来翻译者抓 `.po` 文件时只看到 1 个模型的字符串，"翻译了等于没翻译"。

**如何避免**：planner 在生成 plan 之前**显式问用户**（见问题 3 的"实操建议"段）。如果用户允许 discretion，**与仓库一致用中文字面量**。

**预警信号**：plan 文档里同时出现 `_('凭据槽位')` 和"沿用本仓库其它 admin 的中文字面量风格"两种自相矛盾的描述。

### 陷阱 5：忘了写修改记录

**会发生什么**：CLAUDE.md 第 256-281 行的"修改记录强制规则"是该项目的**承重约定**，多次提交不写记录会让维护者无法追踪变更链路（这条规则比代码质量门槛还高）。

**如何避免**：plan 中**显式列出**两条修改记录条目作为可执行 task；verify-work 阶段把"`docs/修改记录.md` 顶部存在本 phase 条目"列为 success criterion。

**预警信号**：commit 落地了但 `docs/修改记录.md` git diff 为空。

---

## 代码示例

### 完整 CredentialSlot 模型（追加到 `aiapp/models.py` 末尾）

来源：1:1 复刻 `userapp.AffinitySetting`（`userapp/models.py:247-314`）+ 套用 CONTEXT.md 字段集合。 [VERIFIED]

```python
# aiapp/models.py 末尾追加（与 Bot、ChatMessage 并列）

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
```

### 完整 CredentialSlotAdmin（追加到 `aiapp/admin.py`）

```python
# aiapp/admin.py 顶部更新 import
from .models import Bot, ChatMessage, CredentialSlot
from common.utils import mask_token


@admin.register(CredentialSlot)
class CredentialSlotAdmin(admin.ModelAdmin):
    """通用凭据槽位 Admin（单例）— Milestone v1.0 / Phase 1

    UX 行为：
        - 列表 / 查看态 access_token 显示末 4 位掩码
        - 编辑表单 access_token 明文（运营录入需要）
        - 已存在记录时隐藏「新增」按钮
        - 永远禁止删除（防运营误操作丢失单例）
    """

    list_display = ('id', 'app_id', 'access_token_masked', 'updated_at')
    readonly_fields = ('updated_at',)

    fieldsets = (
        ('凭据信息', {
            'fields': ('app_id', 'access_token'),
            'description': '第三方服务商分配的 APP ID + Access Token；保存后立即对手机端 / 设备端生效',
        }),
        ('元数据', {
            'fields': ('updated_at',),
            'classes': ('collapse',),
        }),
    )

    def access_token_masked(self, obj):
        return mask_token(obj.access_token)
    access_token_masked.short_description = 'Access Token (脱敏)'

    def has_add_permission(self, request):
        # 已存在记录时隐藏「新增」，配合 has_delete_permission 强制单例
        return not CredentialSlot.objects.exists()

    def has_delete_permission(self, request, obj=None):
        # 永远禁止删除（含批量动作）
        return False
```

### 完整 mask_token（新建 `common/utils.py`）

```python
# common/utils.py（新建）
"""通用工具函数集合。

注：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:]
```

### 迁移命令

```bash
# 在仓库根目录（C:\Users\admin\Desktop\Lila-Server\qy_lty）
python manage.py makemigrations aiapp
# 期望输出：Migrations for 'aiapp':
#   aiapp/migrations/000X_credentialslot.py
#     - Create model CredentialSlot

python manage.py migrate aiapp
# 期望输出：Operations to perform:
#   Apply all migrations: aiapp
# Running migrations:
#   Applying aiapp.000X_credentialslot... OK
```

### 验证 success criteria 的 Django shell 脚本

```python
# python manage.py shell 中执行

from aiapp.models import CredentialSlot

# Criterion #2 / #3：首次 get_or_create(pk=1) 拿空记录
obj, created = CredentialSlot.objects.get_or_create(pk=1)
assert created is True, "首次访问应该 created=True"
assert obj.app_id == '', f"期望 app_id='', 实际 {obj.app_id!r}"
assert obj.access_token == '', f"期望 access_token='', 实际 {obj.access_token!r}"
assert obj.pk == 1

# Criterion #1：尝试创建第二条记录会被 save() 钩子重定向
obj2 = CredentialSlot(app_id='test_app', access_token='secret123456')
obj2.save()
assert CredentialSlot.objects.count() == 1, f"期望 1 条记录, 实际 {CredentialSlot.objects.count()}"
assert obj2.pk == 1, f"期望被重定向到 pk=1, 实际 pk={obj2.pk}"

# 验证 mask_token 在 Admin 中的呈现
from common.utils import mask_token
assert mask_token('sk-abcdef1234') == '*********1234'
assert mask_token('') == ''
assert mask_token(None) == ''
assert mask_token('abc') == '***'

print("All assertions passed.")
```

---

## State of the Art

| 旧 / 备选方式 | 当前推荐方式 | 何时切换 | 影响 |
|---------------|--------------|----------|------|
| 单字段唯一约束（如 `is_singleton = BooleanField(unique=True, default=True)`）| `pk=1` + `get_or_create` + `save()` 钩子 | CONTEXT.md 锁定 | 灵活：所有字段都可空；劣势：Python 层约束，绕 ORM 失守 |
| `django-solo` 第三方包 | 自实现单例（10 行）| CONTEXT.md "不引入新依赖" | 自实现 ≈ django-solo 的核心，无版本耦合 |
| `django-encrypted-model-fields` 加密 access_token | 明文存 + Admin 展示态脱敏 + Phase 3 日志脱敏 | CONTEXT.md 推迟 at-rest 加密 | 简化 Phase 1；at-rest 加密留待未来评估 |
| `gettext_lazy` 真双语 | 中文字面量与仓库一致（实操推荐） | 见 Open Questions | 与既有 14 个模型一致；未来若启动 i18n milestone 再批量回改 |

**已弃用 / 不适合**：

- **`OneToOneField + primary_key=True` 锚到某父对象**：本场景无父对象，不适用
- **DB 触发器 / `CHECK (id = 1)` 约束**：PostgreSQL 可做但 Django 迁移层不友好，且 CONTEXT.md 已锁 pk=1 钩子方案

---

## 假设记录

| # | 假设 / 推断 | 章节 | 风险若错 |
|---|-------------|------|----------|
| A1 | "凭据槽位"指 AI 服务商凭据，因此放 `aiapp` 最合适 | 问题 1 | 若用户指意更通用（管理后台自身凭据 / 数据库凭据 / 短信凭据 / 跨 app 共用），可能更适合放 `userapp` 或新 app。建议 plan 阶段向用户确认意图。 [ASSUMED — ROADMAP / REQUIREMENTS / CONTEXT 均未明示具体服务商] |
| A2 | i18n 实操推荐用中文字面量（与仓库一致），与 CONTEXT.md 字面"用 `_()` 标记"约束**冲突** | 问题 3 | 若 planner 严守 CONTEXT.md 字面规定，本 phase 会引入 7 处新的 `_()` 但仓库其它部分 152 处仍是字面量，i18n 工作半推半就。已在 Open Questions 标注须用户决断。 [ASSUMED — 基于 grep 159 vs 7 的强一致性证据，但仍属于"挑战 CONTEXT.md"，需用户确认] |
| A3 | `mask_token` 抽到 `common/utils.py`（新建文件） | 问题 4 | 若仓库有未发现的工具文件命名约定，可能应放 `common/security.py` 或 `common/string_utils.py`。grep 全仓未见此类文件。 [VERIFIED via Glob common/*.py：无相关文件] |
| A4 | 迁移文件命名 `0001_initial.py`：实际取决于 aiapp 现有迁移序号，应当是 `000X_credentialslot.py`（X 为下一个序号） | 代码示例 | makemigrations 自动决定，不影响代码质量；planner 不必在 plan 中预测精确 X 值 | [VERIFIED — Django 自动逻辑] |
| A5 | Python 3.8 兼容性：所有上述代码（f-string、`@classmethod`、`models.CharField` 默认参数）在 Python 3.8 下均合法 | 全文 | f-string、关键字参数、`@classmethod` 都是 Python 3.6+ 特性，在 3.8 下完全可用 | [CITED: PEP 498 / Python 3.8 release notes] |

**用户应在 plan 阶段确认 A1 与 A2**。其余假设 A3-A5 是技术细节，不需要用户介入。

---

## Open Questions

1. **CredentialSlot 真实业务语义：是 AI 服务商凭据，还是更通用的"管理后台中转凭据"？**
   - 已知：CONTEXT.md / ROADMAP / REQUIREMENTS 均未明示具体服务商
   - 不清楚：未来是否会扩展为"按服务商分组（Kimi 一组、阿里云一组）"
   - 建议：plan 写者向用户确认意图。如果是 AI 服务商凭据 → `aiapp`（推荐）；如果是通用槽位 → 可考虑 `userapp` 或新建 `credentials_app`。**预期答案**：通用单例槽位（CONTEXT.md 用"通用凭据"措辞），放 `aiapp` 是 pragmatic 选择，不冲突。

2. **i18n 写法：CONTEXT.md 锁定 `_()`，仓库实操是中文字面量，怎么取舍？**
   - 已知：`LANGUAGES` / `LOCALE_PATHS` 在 settings.py 被注释；159 处中文字面量 vs 7 处 `_()`；4 个 admin.py 全部是字面量
   - 不清楚：CONTEXT.md 写者是否知道这个事实？还是在期望"借本 phase 启动 i18n 改造"？
   - 建议：plan 第一个 task 显式问用户，给三个选项（A 字面量 / B 引 `_()` / C 立 i18n milestone），强烈推荐 A。

3. **修改记录条数：1 条还是 2 条？**
   - 已知：CRED-01 + CRED-02 是同一 phase；既有 P1 范例（修改记录.md:54 行起的好感度 P1 阶段）用 1 条主条目 + 多子段
   - 推荐：planner 自决；若按主条目 + `#### CRED-01` / `#### CRED-02` 子段写 1 条也可接受。本研究示例给的是 2 条，更利于回溯 commit 边界。

---

## 环境可用性

> 本 phase 是纯 Python / Django 代码改动，**无新增**外部工具 / 服务依赖；所列项均为现有项目已部署的依赖。

| 依赖 | 本 phase 用于 | 可用性 | 版本 | 备用 |
|------|---------------|--------|------|------|
| Python 解释器 | 全部 | ✓（项目已运行） | 3.8 | — |
| Django 框架 | ORM、Admin、迁移 | ✓ | 4.2.13 | — |
| PostgreSQL | 单例表存储 | ✓（settings.py 已配置） | （由 env 决定） | 可临时用 SQLite 做 dev 验证 |
| `python manage.py makemigrations` / `migrate` | 迁移 | ✓ | 内置 | — |
| `python manage.py shell` | 验证 success criteria | ✓ | 内置 | — |
| django-simpleui | Admin 主题渲染 | ✓ | （未锁版本，已部署） | 即使 SimpleUI 未生效，原生 Admin 也能跑 |

**无任何阻塞性依赖缺失。**

---

## 验证架构

> `.planning/config.json` 中 `workflow.nyquist_validation` 字段未设置；按规范默认启用此节。但需注意本仓库**实质性的自动化测试基础设施缺失**（详见 `.planning/codebase/TESTING.md`、`CONCERNS.md`）—— `qy_lty` 中各 app 的 `tests.py` 多为空文件或占位。

### 测试框架

| 属性 | 值 |
|------|-----|
| 框架 | Django 内置 `TestCase` + `python manage.py test`（**未引入 pytest**） |
| 配置文件 | 无（沿用 `manage.py` 默认行为） |
| 快速运行命令 | `python manage.py test aiapp.tests.CredentialSlotTests --verbosity=1` |
| 全套命令 | `python manage.py test`（耗时长且大量测试是空 placeholder） |

⚠ 配套 codebase TESTING.md（已读）明确"测试基础设施搭建（pytest + pytest-django + 关键路径测试）"是 HIGH 优先级 deferred 项 —— **本 phase 不在该 deferred 范围内**，因此**只交付能用 Django TestCase 跑的最小测试**，不要引入 pytest。

### Phase 需求 → 测试映射

| 需求 ID | 行为 | 测试类型 | 自动化命令 | 测试文件存在？ |
|---------|------|----------|------------|----------------|
| CRED-01 | 模型层强制最多 1 条记录 | 单元 | `python manage.py test aiapp.tests.CredentialSlotTests.test_singleton_enforcement` | ❌ Wave 0（aiapp/tests.py 当前是占位） |
| CRED-01 | `get_or_create(pk=1)` 首访拿空记录、第二次拿同条 | 单元 | `python manage.py test aiapp.tests.CredentialSlotTests.test_get_solo_idempotent` | ❌ Wave 0 |
| CRED-01 | save() 钩子把新对象 pk 重定向到已存在那条 | 单元 | `python manage.py test aiapp.tests.CredentialSlotTests.test_save_redirects_to_existing_pk` | ❌ Wave 0 |
| CRED-01 | 迁移落地 + schema 字段齐全 | 集成 | `python manage.py migrate aiapp --plan` 退出码 0 + `python manage.py dbshell -c "\d aiapp_credentialslot"`（Postgres） | 自动测试不易（依赖真实 DB），**人工验证** |
| CRED-02 | Admin 列表态显示脱敏 access_token | E2E（手动）| 浏览器登录 admin 看 `/admin/aiapp/credentialslot/` 列表页 | 仅人工 |
| CRED-02 | Admin 编辑态 access_token 明文 | E2E（手动）| 浏览器进入编辑页看 input | 仅人工 |
| CRED-02 | 列表页不显示「新增」按钮（已有 1 条时） | 单元 | `python manage.py test aiapp.tests.CredentialSlotAdminTests.test_has_add_permission_blocks_when_exists` | ❌ Wave 0 |
| CRED-02 | 编辑页底部无「Delete」按钮 | 单元 | `python manage.py test aiapp.tests.CredentialSlotAdminTests.test_has_delete_permission_always_false` | ❌ Wave 0 |
| - | mask_token 各边界值 | 单元 | `python manage.py test common.tests.MaskTokenTests`（**common 不是 app**，需挂到 aiapp.tests 或类似处） | ❌ Wave 0 |

### 采样率

- **每个 task commit**：跑 `python manage.py test aiapp.tests.CredentialSlotTests aiapp.tests.CredentialSlotAdminTests aiapp.tests.MaskTokenTests`（< 5 秒）
- **每波合并**：同上 + `python manage.py migrate --plan` 验证迁移
- **Phase 关：** Django shell 手动跑代码示例段的 assertions 脚本 + 浏览器 admin 走查 success criterion 4/5/6

### Wave 0 缺口

- [ ] `aiapp/tests.py` —— 当前为占位，需新增 `CredentialSlotTests`、`CredentialSlotAdminTests`、`MaskTokenTests` 三个 TestCase 类（覆盖上表"❌ Wave 0"项）
- [ ] **`common` 不是 app**，无法直接 `python manage.py test common.tests` —— 把 `MaskTokenTests` 挂到 `aiapp.tests` 即可（`from common.utils import mask_token` 然后测）
- [ ] **不要**引入 pytest / pytest-django；保持现有 Django TestCase 风格

⚠ 重要：**单元测试中需要"重置 admin 模型对应的 site"或使用 `RequestFactory` 调 `has_add_permission`**，因为 ModelAdmin 不能离开 site 上下文调用。建议测试范式：
```python
from django.contrib.admin.sites import AdminSite
from django.test import RequestFactory, TestCase
from aiapp.admin import CredentialSlotAdmin
from aiapp.models import CredentialSlot

class CredentialSlotAdminTests(TestCase):
    def setUp(self):
        self.admin = CredentialSlotAdmin(CredentialSlot, AdminSite())
        self.factory = RequestFactory()

    def test_has_add_permission_blocks_when_exists(self):
        request = self.factory.get('/admin/aiapp/credentialslot/')
        # 空表：可以新增
        self.assertTrue(self.admin.has_add_permission(request))
        # 创建一条
        CredentialSlot.objects.create(app_id='x', access_token='y')
        # 已存在：不可新增
        self.assertFalse(self.admin.has_add_permission(request))

    def test_has_delete_permission_always_false(self):
        request = self.factory.get('/admin/aiapp/credentialslot/')
        self.assertFalse(self.admin.has_delete_permission(request))
        obj = CredentialSlot.objects.create()
        self.assertFalse(self.admin.has_delete_permission(request, obj))
```

[CITED: Django 4.2 admin testing patterns]

---

## 安全域

> `.planning/config.json` 中 `security_enforcement` 未显式设置；按规范默认启用此节。

### 适用 ASVS 类目

| ASVS 类目 | 是否适用 | 标准管控 |
|-----------|----------|----------|
| V2 认证 | 否（Phase 1 不出 REST 接口；管理员通过 Django session 登录 admin，已由 `django.contrib.auth` 处理） | — |
| V3 会话管理 | 否（同上） | — |
| V4 访问控制 | **是** | `has_add_permission` / `has_delete_permission` 控制单例语义；Django Admin staff session 控制写入入口 |
| V5 输入校验 | **是**（轻度） | Django ModelForm 自动校验 `max_length`；`app_id` / `access_token` 是 free-form 文本，运营信任边界内 |
| V6 加密 | **不适用 Phase 1**（CONTEXT.md 锁定 at-rest 加密推迟） | DB 明文 + 后续 phase TLS 已由 `qy_lty/settings.py` 与部署层（Daphne / Nginx）保证 |
| V7 错误处理与日志 | **是** | **关键**：Phase 1 不直接产生日志，但**埋下 Phase 3 日志脱敏的语义入口** —— 必须保证 `__str__` / `__repr__` 不暴露明文 access_token（当前 `__str__` 返回的是 `f"凭据槽位 (updated ...)"`，不含敏感字段，✓ 安全） |

### 已知威胁模式（针对本仓库栈）

| 模式 | STRIDE | 标准缓解 |
|------|--------|----------|
| 运营误删 → 单例语义丢失 → 客户端拿空凭据失败 | Denial of Service | `has_delete_permission` 永远 `False` + `get_or_create(pk=1)` 自愈 |
| 运营创建第二条记录 → 不确定客户端拿哪条 | Tampering | `has_add_permission` 已存在时 False + `save()` 钩子重定向 + `get_solo()` 单一访问入口 |
| Admin 列表页截屏泄露明文 token | Information Disclosure | `access_token_masked` 计算字段（末 4 位） |
| Phase 3 日志暴露 access_token | Information Disclosure | **本 phase 不直接处理**；`mask_token` 工具函数预留 |
| `bulk_create` / 原始 SQL 绕过 save() 钩子 | Tampering | 文档明确 + 代码审查；DB 层无强约束（已知接受局限性） |
| Admin 表单 CSRF | Tampering | Django 内置 CsrfViewMiddleware 已生效（settings.py middleware 链） |
| 非 staff 用户访问 `/admin/...` | Elevation of Privilege | Django Admin 内置 `is_staff=True` 校验，本 phase 无需特殊处理 |

---

## 来源

### 一手（HIGH 置信度）—— 仓库内验证

- `userapp/models.py:247-314` —— `AffinitySetting` 单例范本（pk=1 + save 钩子 + get_solo）
- `aiapp/models.py:1-51` —— Bot / ChatMessage 现有结构（确认追加位置）
- `aiapp/admin.py:1-15` —— Bot / ChatMessage 现有 admin 注册风格
- `userapp/admin.py:1-31` —— ParadiseUserAdmin 字段命名 + fieldsets 风格
- `card/admin.py:262-263` —— `has_add_permission(self, request) -> False` 现有写法
- `card/admin.py:114-124` —— `gettext_lazy` 在 admin 中的极少数使用案例（CardUsageLogInline）
- `qy_lty/settings.py:43-74` —— INSTALLED_APPS（确认 `common` 不在内、aiapp / userapp / rosetta / simpleui 在内）
- `qy_lty/settings.py:199-218` —— LANGUAGE_CODE='zh-hans'、LANGUAGES 和 LOCALE_PATHS 被注释
- `qy_lty/settings.py:462-498` —— SimpleUI 配置段
- `docs/修改记录.md:1-150` —— 修改记录格式 + 既有 P1 范例
- `common/__init__.py` —— 1 行（确认 common 是空 namespace）
- `common/responses.py` —— 现有工具函数风格（确认 `common/utils.py` 新建是合理位置）
- 全仓 grep `verbose_name=_(` (7 处) vs `verbose_name='` (159 处) —— i18n 现状定量证据
- 全仓 grep `mask|脱敏|redact` —— 0 处工具实现（仅 .planning/ 文档命中）
- `CLAUDE.md:256-281` —— 修改记录强制规则
- `.planning/codebase/CONVENTIONS.md` —— 命名约定汇总

### 二手（MEDIUM 置信度）—— 官方文档

- [Django 4.2 ModelAdmin 文档](https://docs.djangoproject.com/en/4.2/ref/contrib/admin/#django.contrib.admin.ModelAdmin.has_add_permission) — `has_add_permission` / `has_delete_permission` 签名
- [Django Admin Cookbook §4: How to remove the 'Add'/'Delete' button](https://books.agiliq.com/projects/django-admin-cookbook/en/latest/remove_add_delete.html)
- [How to solve the singleton problem in Django ModelAdmin (DEV.to)](https://dev.to/danilovmy/how-to-solve-the-singleton-problem-in-django-modeladmin-g42)

### 三手（LOW 置信度，仅作辅助）

- WebSearch 结果中 GeeksforGeeks / wagtail issue 等 —— 与上面官方文档观点一致，未引入新结论

---

## 元数据

**置信度细分：**

- **app 归属决策（aiapp）**：HIGH — 三个备选都已读过对应 models.py + apps.py，证据充分；唯一 risk 是 A1 业务语义误判（已在 Open Questions 标注）
- **单例模型实现**：HIGH — 1:1 复刻 `userapp.AffinitySetting` 已有生产实现
- **Admin 注册写法**：HIGH — 模板 A 与 `card/admin.py:CardUsageLogAdmin` 风格一致；`has_add_permission`/`has_delete_permission` API 签名与 Django 4.2 文档一致
- **mask_token 实现**：HIGH — 4 行代码，边界用例已枚举
- **i18n 写法**：MEDIUM — 实操推荐与 CONTEXT.md 字面冲突，需用户决断（A2 假设）
- **修改记录格式**：HIGH — 直接来自仓库文件头原文
- **测试映射**：MEDIUM — Wave 0 缺口在仓库 `tests.py` 多数为空的现状下不可避免
- **字段命名约定**：HIGH — 159 处样本统计强证据

**研究日期**：2026-05-07
**有效期至**：~2026-06-07（30 天稳定预期 — 单例模型 + Admin 是 Django LTS 稳定 API，仓库代码风格在好感度 P1 落地后高度一致）

---

*Phase 1 调研完成；planner 可基于此 RESEARCH.md 生成 PLAN 文件，无需重新做 codebase walk。*
