1060 lines
61 KiB
Markdown
Raw Permalink 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 1凭据槽位数据层 — 研究
**调研日期**2026-05-07
**领域**Django 4.2 单例模型 + Django AdminSimpleUI 主题)+ 中文 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 Discretionplanner / 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 IdeasOUT 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` 字段 | 研究问题 #1app 归属决策)+ #2`AffinitySetting` 单例范本)+ #5(字段命名约定) |
| **CRED-02** | Django Admin 注册:列表/查看态对 `access_token` 脱敏;编辑态明文;隐藏"新增"按钮(强制单例语义);禁止删除 | 研究问题 #3Admin 注册模板)+ #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 AdminHTML 表单)| `auto_now=True` 字段 | 运营手动录入Phase 1 不出 REST |
| 脱敏(展示态)| Admin ModelAdmin 计算字段 | `common/utils.py` 工具函数 | 复用同一脱敏逻辑给 Phase 3 日志过滤器 |
| 国际化 | Django i18n`_()` + locale/| Meta.verbose_name 字面量 | 见 Open Questions本仓库实际是中文字面量为主 |
---
## 研究问题逐条回答
### 问题 1CredentialSlot 单例模型放在哪个 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]
### 问题 3SimpleUI + 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 工具吗?
**答****不存在**。需要新建。 [VERIFIEDgrep `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 app3 字段(`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/PUTPhase 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-02Admin虽属同一 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
```
[运营浏览器]
│ HTTPadmin 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。
### 模式 2Admin 计算字段脱敏
**何时用**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=512TextField 无长度上限会让 Admin form 默认渲染成 textareaUX 不符。
- **❌ 把 `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 varDB 凭据沿用 `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 != 1Phase 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"。
### 陷阱 4i18n 半推半就
**会发生什么**planner 严守 CONTEXT.md 字面" `_()` 标记"指令结果只有 CredentialSlot 字符串包了 `_()`仓库其它 14 个模型仍是中文字面量未来翻译者抓 `.po` 文件时只看到 1 个模型的字符串"翻译了等于没翻译"。
**如何避免**planner 在生成 plan 之前**显式问用户**见问题 3 "实操建议")。如果用户允许 discretion**与仓库一致用中文字面量**。
**预警信号**plan 文档里同时出现 `_('凭据槽位')` "沿用本仓库其它 admin 的中文字面量风格"两种自相矛盾的描述
### 陷阱 5忘了写修改记录
**会发生什么**CLAUDE.md 256-281 行的"修改记录强制规则"是该项目的**承重约定**多次提交不写记录会让维护者无法追踪变更链路这条规则比代码质量门槛还高)。
**如何避免**plan **显式列出**两条修改记录条目作为可执行 taskverify-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 1at-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 框架 | ORMAdmin迁移 | | 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 0aiapp/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-0730 天稳定预期 单例模型 + Admin Django LTS 稳定 API仓库代码风格在好感度 P1 落地后高度一致
---
*Phase 1 调研完成planner 可基于此 RESEARCH.md 生成 PLAN 文件,无需重新做 codebase walk。*