61 KiB
Raw Permalink Blame History

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 现状是"宣称双语,实际中文硬编码" —— LANGUAGESLOCALE_PATHSsettings.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:262CardUsageLogAdmin)有 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_idCharField(max_length=128, blank=True, default='')
    • access_tokenCharField(max_length=512, blank=True, default='')
    • updated_atDateTimeField(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_idaccess_tokenupdated_at 字段 研究问题 #1app 归属决策)+ #2AffinitySetting 单例范本)+ #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.pyINSTALLED_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 Tokenaiapp 的"AI 对话与多服务商语音抽象"主题强相关ROADMAP / REQUIREMENTS / CONTEXT 也未明示具体服务商,意味着这是个"AI 服务接入凭据"槽位。 [VERIFIED: 读 aiapp/apps.py 第 7 行 verbose_name = 'AI'、CLAUDE.md 102 行 aiapp 描述]
  • aiapp/models.py 体积合适:仅 51 行、2 个模型(BotChatMessage),追加 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.AffinitySettinguserapp/models.py 第 247-314 行。Phase 1 应直接 1:1 复刻它的单例三件套,不要自创。

AffinitySetting 的单例三件套VERIFIED直接读源码摘抄

# 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 的复刻方案(推荐字面照搬)

# 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 Truesettings.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.pyaiapp/admin.pydevice_interaction/admin.py 是否使用 _() 均不使用admin 文件是纯中文字面量风格

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

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

模板 A保持仓库一致性中文字面量强烈推荐

# 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 起点时采用)

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 的另外两个模型(BotChatMessage)仍是中文字面量;未来若想真正双语,需要批量回改 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

# 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.pypagination.pyswagger_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.pydevice_interaction/models.pycard/models.py 直接观察):

维度 约定 证据
字段名 snake_case phone_numberzodiac_signmac_addressdevice_codeis_primarybound_atunique_idbatch_number
布尔字段命名 is_*has_* 前缀 is_activeis_primaryis_enabledis_deletedis_negativemanufactured(反例:少数动作过去式)
时间戳字段 *_at 后缀 created_atupdated_atbound_atactivated_atpublished_atused_atmanufactured_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(不是 TextFieldmax_length=512 是合理上限(覆盖常见 JWT 当前仓库无类似字段CONTEXT.md 锁定 512description = models.TextField(max_length=2048)aiapp/models.py:39保持数量级一致
允许空字符串字段 blank=True, default=''(不用 null=True ParadiseUser.phone_numbernull=True, blank=True —— 反例,因为加了 unique 约束);多数文本字段是 blank=True, default=''card/models.py 大量例子)

CredentialSlot 字段写法的推论

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 #3obj.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 条)

### [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 列表 / 查看态希望仅展示部分。

实现

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单例约束

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]

反模式(避免)

  • MetaUniqueConstraint(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.pyPhase 3 的全局日志过滤器在 common/aliyun_logging.py,跨 app 引 aiapp 不合适;放 common/utils.py 是唯一对的位置。
  • 重新实现单例语义而不复用 AffinitySetting 的写法:会让仓库出现两套不一样的单例约定,未来维护成本翻倍。

Don't Hand-Roll

问题 不要造 改用 原因
单例模型 自己写 if exists: raise 校验 1:1 复刻 userapp.AffinitySettingsave() + 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 CardTemplateAdminUserDeviceAdmin 等本仓库其它 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两个权限方法都重写:

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.AffinitySettinguserapp/models.py:247-314+ 套用 CONTEXT.md 字段集合。 [VERIFIED]

# 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

# 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

# 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:]

迁移命令

# 在仓库根目录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 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.pycommon/string_utils.py。grep 全仓未见此类文件。 [VERIFIED via Glob common/*.py无相关文件]
A4 迁移文件命名 0001_initial.py:实际取决于 aiapp 现有迁移序号,应当是 000X_credentialslot.pyX 为下一个序号) 代码示例 makemigrations 自动决定不影响代码质量planner 不必在 plan 中预测精确 X 值
A5 Python 3.8 兼容性所有上述代码f-string、@classmethodmodels.CharField 默认参数)在 Python 3.8 下均合法 全文 f-string、关键字参数、@classmethod 都是 Python 3.6+ 特性,在 3.8 下完全可用

用户应在 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.jsonworkflow.nyquist_validation 字段未设置;按规范默认启用此节。但需注意本仓库实质性的自动化测试基础设施缺失(详见 .planning/codebase/TESTING.mdCONCERNS.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.MaskTokenTestscommon 不是 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 —— 当前为占位,需新增 CredentialSlotTestsCredentialSlotAdminTestsMaskTokenTests 三个 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"或使用 RequestFactoryhas_add_permission,因为 ModelAdmin 不能离开 site 上下文调用。建议测试范式:

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.jsonsecurity_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_lengthapp_id / access_token 是 free-form 文本,运营信任边界内
V6 加密 不适用 Phase 1CONTEXT.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 置信度)—— 官方文档

三手LOW 置信度,仅作辅助)

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

元数据

置信度细分:

  • app 归属决策aiappHIGH — 三个备选都已读过对应 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。