47 KiB
Raw Blame History

Phase 2管理端读写接口 - Research

Researched: 2026-05-07 Domain: Django REST Framework 单例资源 + admin token 鉴权 + drf-yasg schema Confidence: HIGH全部结论基于仓库代码 grep + 文件 read 实证;无 [ASSUMED] 标签)

Summary

本 phase 在已经成熟的项目上加两条 REST 端点GET / PUT 同一 URL所有需要的"积木"都已存在路由汇总点、鉴权类、响应壳层、ModelSerializer 模板、@swagger_auto_schema 用法、单例模型与 get_solo() 入口、mask_token 工具——没有任何新依赖、没有任何新基础设施需要落地

唯一需要 planner 显式决策的两个事项是:

  1. /api/v1/admin/ 命名空间的注册位置:已在 qy_lty/urls.py:59 通过 path('v1/admin/', include('userapp.admin_urls')) 汇总,对应文件是 userapp/admin_urls.py。本 phase 在 userapp/admin_urls.py 添加一行 path('credential-slot/', CredentialSlotAdminView.as_view(), ...) 并 import 视图即可。
  2. admin-only 权限的实现方式:仓库中没有 IsAdminTokenAuthenticated 这种 admin-only permission 类。现有 admin 端点(AdminEmailLoginView / AdminLogoutView / 各 IsAdminOrReadOnly 复刻)一律走 permission_classes = [IsAuthenticated] + 视图内 if not request.user.is_staff: return error_response(..., code=403, status_code=403)。本 phase 必须沿用这个 pattern不要发明新 permission class)。

Primary recommendation:自定义 APIView + 手写 get / put 方法(不走 RetrieveUpdateAPIView,仓库 0 处使用1:1 复刻 aiapp.views.RTCChatHistoryAPIView(同 app结构 GET/POST/DELETE最贴近本 phase 的"单 URL 多方法"形态)。响应一律用 common.responses.success_response / error_response不要直接写 Response({...})——前者已经构造了壳层四字段,后者会被中间件二次包装为非预期形态。

Architectural Responsibility Map

能力 Primary Tier Secondary Tier Rationale
HTTP 路由 /api/v1/admin/credential-slot/ 的注册 API 层(userapp/admin_urls.py qy_lty/urls.py:59 已把 /api/v1/admin/ 转给 userapp.admin_urls
GET / PUT 业务逻辑(脱敏读取 / 全字段覆写) API 层(aiapp/views.py 末尾追加 CredentialSlotAdminView 凭据槽位是 aiapp 的子能力CONTEXT.md 决策已锁定不新建 app与 RTCChatHistoryAPIView 同 app 同风格
请求体校验(app_id / access_token 字段类型 + 长度) Serializer 层(新建 aiapp/serializers.py:CredentialSlotSerializer aiapp/serializers.py 已存在,追加新类即可
单例数据访问 Model 层(aiapp.models.CredentialSlot.get_solo() Phase 1 已落地 get_solo()view 直接调用
Access Token 脱敏 工具层(common.utils.mask_token View 层调用 Phase 1 已落地,本 phase view 在返回前手动调一次
admin token 鉴权 认证层(userapp.authentication.RedisTokenAuthentication View 层 is_staff 检查 RedisTokenAuthentication 不区分 admin/user token必须配合 is_staff 二次判断
标准响应壳层 中间件层(common.middleware.StandardResponseMiddleware View 层调用 success_response/error_response Middleware 已注册view 用现成 helper
Swagger / ReDoc 暴露 View 层装饰器(@swagger_auto_schema 全局 schema_viewqy_lty/urls.py:41 drf-yasg 自动扫描,零配置接入

User Constraints (from CONTEXT.md)

Locked Decisions

URL 与路由

  • 路径:/api/v1/admin/credential-slot/trailing slash 沿用 Django 默认风格)
  • HTTP 方法GET 和 PUT 在同一 URL不分两个 endpoint符合 RESTful 单例资源约定)
  • 路由注册位置:决策由 planner 基于 read_first 后选择
    • 候选 Aaiapp/urls.py(凭据槽位语义偏向 AI
    • 候选 B仓库现有的 admin namespace /api/v1/admin/ 在哪个 urls 文件汇总
  • 新建 credential app

View 实现

  • 不用 ModelViewSet
  • RetrieveUpdateAPIViewDRF 提供)或自定义 APIView + 手写 get / put
  • 推荐自定义 APIView——单例语义不走 lookup_field/pk
  • View 类命名:CredentialSlotAdminView,放 aiapp/views.py

Serializer

  • DRF ModelSerializerCredentialSlotSerializer,放 aiapp/serializers.py
  • 字段:app_idaccess_tokenupdated_at
  • updated_at 在 GET 响应里 read_onlyauto_now 自动维护)
  • Access Token 脱敏在 view 层处理,不在 serializer 层
  • 写入校验:app_idaccess_token 都允许空字符串,但不允许 None

鉴权

  • 直接复用 RedisTokenAuthentication
  • View 类上配 authentication_classes = [RedisTokenAuthentication] + permission_classes = [IsAuthenticated](如果项目已有 admin-only permission否则用 IsAuthenticated + 在 view 里加 request.user.is_staff 检查)
  • 拒绝时返回 401无 token或 403user token 但非 admin错误响应必须经过 StandardResponseMiddleware

Swagger / ReDoc

  • 接口必须出现在 /swagger/ + /redoc/
  • View 类上加 @swagger_auto_schema 装饰器method-level给 GET / PUT 各写一份)
  • response schema 显式标注 access_token 字段语义为「末 4 位脱敏掩码」

跨项目联动(修改记录互引)

  • qy_lty/docs/修改记录.md 顶部写一条:"新增 Phase 2 管理端 REST 接口"
  • qy-lty-admin/docs/修改记录.md 顶部写一条:"锁定 Phase 2 后端 API 契约(消费方文档化)"

兼容性

  • 沿用 Django 4.2.13、DRF 3.x、Python 3.8
  • 不引入新依赖

Claude's Discretion

  • 序列化器是否拆 CredentialSlotReadSerializer + CredentialSlotWriteSerializer 两个类
  • view 是放 aiapp/views.py 末尾,还是新建 aiapp/views/credential_slot.py 子文件
  • 错误响应的具体 message 文案(中文)
  • View 里如何确保 get_or_create(pk=1) 在并发请求下不出竞态

Deferred Ideas (OUT OF SCOPE)

  • 客户端 GET /api/credential-slot/ — Phase 3
  • 阿里云日志脱敏过滤器 — Phase 3
  • PUT 写入时旧 access_token 的审计日志 — 不在 v1.0 milestone 范畴
  • token 鉴权失败时尝试用其它 token 类型重试 / fallback — 当前架构禁止此类降级
  • API 限流(防暴力 PUT — 现有架构未上限流
  • DB at-rest 加密 — 未来评估

Phase Requirements

ID 描述 Research Support
CRED-03 管理端 GET /api/v1/admin/credential-slot/admin token 鉴权(admin_token:{token} Redis key 体系);返回 { app_id, access_token: <masked>, updated_at }Access Token 仅返回末 4 位脱敏掩码 View 模板:RTCChatHistoryAPIView.get;鉴权:RedisTokenAuthentication + is_staff;脱敏:common.utils.mask_token;序列化:新建 CredentialSlotSerializer + view 层覆写 access_token 字段
CRED-04 管理端 PUT /api/v1/admin/credential-slot/admin token 鉴权;接受 { app_id, access_token } 全字段覆写更新;空记录场景自动 get_or_create;变更写入 updated_at 数据访问:CredentialSlot.get_solo()Phase 1 落地);写入:serializer.is_valid() + serializer.save();并发:单例 save 钩子已在 model 层兜底

Project Constraints (from CLAUDE.md)

强制规则 出处 影响本 phase
所有面向用户的回复使用中文 CLAUDE.md ## 沟通语言 本 RESEARCH.md / 后续 PLAN.md / SUMMARY.md / VERIFICATION.md 全部中文
修改记录追加到 docs/修改记录.md 顶部,最新在最前 CLAUDE.md ## 项目修改记录规则 落地后必须追加一条;格式见现有第 26、42 行模板
qy_ltyqy-lty-admin 各自维护独立修改记录;跨项目联动两端各写一条互相引用对方条目 CLAUDE.md ### qy_lty 与 qy-lty-admin 是独立项目 Phase 2 是首次跨项目接口契约落地,两端必须互引
修改记录条目五字段:文件路径 / 修改类型 / 修改内容 / 修改原因(+ 跨项目联动) qy_lty/docs/修改记录.md 第 11-19 行 PLAN 的最后一个 task 必须按此格式写两端条目

Standard Stack

Core已在仓库内、无需新增

已有版本 用途 Why Standard
Django 4.2.13 Web 框架 项目锁定 [VERIFIED: qy_lty/settings.py:4 + STACK.md]
Django REST Framework 未锁定requirements.txt 不固定版本) REST API 实现 全仓使用,所有 API 走 DRF [VERIFIED: requirements.txt + 多文件 grep]
drf-yasg 未锁定 Swagger / OpenAPI schema 生成 全局 schema_view 已配在 qy_lty/urls.py:41/swagger//redoc/ 已暴露 [VERIFIED: qy_lty/urls.py:70-72]

Supporting已在仓库内、复用即可

模块 路径 用途
RedisTokenAuthentication userapp/authentication.py:10-34 DRF auth classAuthorization: Bearer <token> 解析 tokenget_user_id_from_token 查 Redis返回 (ParadiseUser, None)
get_user_id_from_token userapp/utils.py:39-45 优先admin_token:{token},未命中再查 token:{token};两者都查不到返 None
generate_token(user_id, is_admin=False) userapp/utils.py:33-37 生成 token 并写 Redisis_admin=True 时 key 前缀为 admin_token:,否则 token:TTL 30 天
mask_token common/utils.py:10-32 脱敏工具;空输入返 '';短于 visible_tail 全脱敏;保留末 N 位明文
success_response / error_response / api_response common/responses.py 构造已带壳层四字段(success / code / message / data)的 DRF Response
StandardResponseMiddleware common/middleware.py:6-145 全局响应壳层;已注册在 settings.py:94
CredentialSlot.get_solo() aiapp/models.py:89-92 单例访问入口,get_or_create(pk=1)
CredentialSlot.save() 钩子 aiapp/models.py:82-87 任何 save() 在已有记录时把新对象 pk 重定向DB 永远只有一行
@swagger_auto_schema drf-yasg 提供 view 方法装饰器,定义 request_body + responses + operation_description
get_standardized_response_schema() common/swagger_utils.py:44-66 返回 OpenAPI Schema包含 success / code / message;可选追加 data 子 schema

Alternatives Considered

锁定方案 候选替代 为什么不选
自定义 APIView + 手写 get / put RetrieveUpdateAPIView 仓库零处使用 RetrieveUpdateAPIView/UpdateAPIView/RetrieveAPIViewgrep 实证);单例不走 pk lookup需要重写 get_object() 反而绕路;自定义 APIView 与 RTCChatHistoryAPIView 风格一致
自定义 APIView + 手写 get / put ModelViewSet CONTEXT.md 已锁定不用 ModelViewSetModelViewSet 默认带 list/create/destroy单例不需要
单一 CredentialSlotSerializer同一个类view 层脱敏) Read / Write 两个 serializer CONTEXT.md 决策"脱敏放 view 层不放 serializer",序列化器只做字段校验;拆两个类反而引入"哪个 view 用哪个"的认知负担
permission_classes = [IsAuthenticated] + 视图内 if not request.user.is_staff 自定义 IsAdminTokenAuthenticated permission class 仓库没有 admin-only permission classgrep 实证);现有所有 admin-only 端点(AdminEmailLoginViewAdminLogoutViewIsAdminOrReadOnly)一律走 is_staff 视图内检查;新发明 permission class 与现有约定相悖
success_response() / error_response() 调用 直接 Response({...}, status=200) 项目约定全部 view 走 common.responses.* helpergrep device_interaction/views.py 即返回 0 处直接用 Response 构造、全是 success_response/error_response);中间件对二者都能处理,但用 helper 更对齐项目风格

安装:无任何新依赖。所有积木都在仓库 requirements.txt 中。

版本验证:跳过——requirements.txt 未锁定版本,运行版本由 Docker 镜像决定Python 3.8 + Django 4.2.13。drf-yasg @swagger_auto_schema 在 4.2 + DRF 3.x 已稳定多年,无版本风险。

Architecture Patterns

System Architecture Diagram

HTTP 请求
   │
   ▼  PUT/GET /api/v1/admin/credential-slot/  +  Header: Authorization: Bearer <admin_token>
qy_lty/urls.py:66 (api_urlpatterns include)
   │
   ▼  match prefix "v1/admin/" -> userapp.admin_urls
qy_lty/urls.py:59
   │
   ▼  match path "credential-slot/" (新增)
userapp/admin_urls.py
   │
   ▼  CredentialSlotAdminView.as_view()(request)
DRF dispatch:
   │
   ├─→ RedisTokenAuthentication.authenticate(request)
   │     │
   │     ▼  get_user_id_from_token(token):
   │         先查 admin_token:{token}(命中即 admin token
   │         否则查 token:{token}(命中即 user token
   │         都查不到 → AuthenticationFailed → 401
   │     │
   │     ▼  ParadiseUser.objects.get(id=user_id) → request.user
   │
   ├─→ permission_classes = [IsAuthenticated] 通过(已认证)
   │
   ├─→ View 内手写if not request.user.is_staff:
   │     return error_response("需要管理员权限", code=403, status_code=403)
   │   (这一步把 user token 当作非 admin 拦下CONTEXT.md success #5
   │
   ├─→ GET 路径:
   │     instance = CredentialSlot.get_solo()
   │     serializer = CredentialSlotSerializer(instance)
   │     data = dict(serializer.data)
   │     data['access_token'] = mask_token(instance.access_token)
   │     return success_response(data=data)
   │
   └─→ PUT 路径:
         instance = CredentialSlot.get_solo()
         serializer = CredentialSlotSerializer(instance, data=request.data)
         if not serializer.is_valid(): return error_response(..., 400)
         serializer.save()                  # auto_now 刷 updated_at
         data = dict(serializer.data)
         data['access_token'] = mask_token(instance.access_token)
         return success_response(data=data)
   │
   ▼  DRF Response 对象
StandardResponseMiddleware.process_response (settings.py:94)
   │
   ▼  检查 response.data 已带 success/code → 不再二次包装middleware.py:53-55
HTTP 响应:{"success": ..., "code": ..., "message": ..., "data": {...}}
qy_lty/
├── userapp/
│   └── admin_urls.py           # ← 追加 1 行 path() + 1 行 import
├── aiapp/
│   ├── views.py                # ← 文件末尾追加 CredentialSlotAdminView
│   └── serializers.py          # ← 追加 CredentialSlotSerializer 类
└── docs/
    └── 修改记录.md              # ← 顶部追加一条 Phase 2 条目(含跨项目联动)

../qy-lty-admin/
└── docs/
    └── 修改记录.md              # ← 顶部追加一条互引条目

Pattern 1单 URL 多方法 APIView参考 RTCChatHistoryAPIView

What:一个 URL、多个 HTTP 方法GET / PUT每个方法用自己的实例方法实现方法各自挂 @swagger_auto_schema

When to use:单例资源 + 不需要 list/create/delete 的场景。

Example(直接来源仓库代码 aiapp/views.py:434-555,可 1:1 套用骨架):

# 来源qy_lty/aiapp/views.py:434-498RTCChatHistoryAPIView GET 方法)
class RTCChatHistoryAPIView(APIView):
    """
    RTC 语音智能体聊天记录接口

    GET: 获取当前用户的 RTC 聊天历史
    POST: 保存一条 RTC 聊天消息
    DELETE: 清空当前用户的 RTC 聊天记录
    """
    authentication_classes = [RedisTokenAuthentication]
    permission_classes = [IsAuthenticated]

    def get(self, request):
        # ... 业务逻辑 ...
        return success_response(data=data)

    def post(self, request):
        # ... 业务逻辑 ...
        return created_response(data={...})

Phase 2 复刻骨架

# 拟落地于qy_lty/aiapp/views.py 文件末尾
from .models import CredentialSlot          # 已在 import 区
from .serializers import CredentialSlotSerializer  # 新增
from common.utils import mask_token          # 新增 import

class CredentialSlotAdminView(APIView):
    """
    通用凭据槽位管理端读写接口admin token 鉴权)

    GET: 返回脱敏后的凭据槽位
    PUT: 全字段覆写凭据槽位
    """
    authentication_classes = [RedisTokenAuthentication]
    permission_classes = [IsAuthenticated]

    def _ensure_admin(self, request):
        """admin-only 二次校验:拒绝非 staff 用户(含 user token 持有者)"""
        if not request.user.is_staff:
            return error_response(
                message="需要管理员权限",
                code=403,
                status_code=status.HTTP_403_FORBIDDEN,
            )
        return None

    @swagger_auto_schema(...)  # 详见 Pattern 4
    def get(self, request):
        forbidden = self._ensure_admin(request)
        if forbidden:
            return forbidden
        instance = CredentialSlot.get_solo()
        serializer = CredentialSlotSerializer(instance)
        data = dict(serializer.data)
        data['access_token'] = mask_token(instance.access_token)
        return success_response(data=data)

    @swagger_auto_schema(...)  # 详见 Pattern 4
    def put(self, request):
        forbidden = self._ensure_admin(request)
        if forbidden:
            return forbidden
        instance = CredentialSlot.get_solo()
        serializer = CredentialSlotSerializer(instance, data=request.data)
        if not serializer.is_valid():
            return error_response(message="参数无效", data=serializer.errors, code=400)
        serializer.save()  # auto_now 自动刷 updated_at
        data = dict(serializer.data)
        data['access_token'] = mask_token(instance.access_token)
        return success_response(data=data, message="凭据已更新")

Pattern 2DRF ModelSerializer 写法

What:基于现有 Model 自动生成 serializer声明 Meta.fieldsMeta.read_only_fields

Example(来源 qy_lty/aiapp/serializers.py:1-9

# 现有代码 - aiapp/serializers.py 全文
from rest_framework import serializers
from .models import ChatMessage

class ChatMessageSerializer(serializers.ModelSerializer):
    class Meta:
        model = ChatMessage
        fields = ['id', 'user', 'bot', 'message', 'timestamp', 'sender', 'message_type', 'message_audio_url', 'message_video_url']
        read_only_fields = ['id', 'timestamp', 'sender']

Phase 2 新增骨架(追加到同文件):

from .models import ChatMessage, CredentialSlot

class CredentialSlotSerializer(serializers.ModelSerializer):
    """通用凭据槽位序列化器(明文)

    GET 时 view 层会用 mask_token 把 access_token 替换为掩码后再返回;
    PUT 时直接以明文进入 is_valid + save由 view 层在响应阶段脱敏。
    """

    class Meta:
        model = CredentialSlot
        fields = ['app_id', 'access_token', 'updated_at']
        read_only_fields = ['updated_at']
        extra_kwargs = {
            # 与模型 blank=True / default='' 一致:允许空字符串、不允许 None
            'app_id': {'allow_blank': True, 'allow_null': False, 'required': False},
            'access_token': {'allow_blank': True, 'allow_null': False, 'required': False},
        }

注意事项

  • 模型字段 blank=True, default=''aiapp/models.py:65-72),因此 required=FalsePUT body 缺字段会用现有值兜底
  • "全字段覆写"语义在 PUT body 既给两个字段时成立;缺字段时由 ModelSerializer 默认行为回填
  • 如果"全字段覆写"严格要求两字段都必须出现,可改 'required': True——但 CONTEXT.md 没硬性要求,按 PUT 习惯(部分缺失允许)即可

Pattern 3标准响应封装三种调用法

What:所有 view 都通过 common.responses 的 helper 返回。

Examplesgrep 实证 — 全仓所有 admin-related view 都用这套):

# qy_lty/userapp/views.py:146-155 (success)
return success_response(
    data={'token': token, 'user_id': ...},
    message="登录成功"
)

# qy_lty/userapp/views.py:750-754 (forbidden — admin 校验失败)
return error_response(
    message="Access denied. Admin privileges required.",
    code=403,
    status_code=status.HTTP_403_FORBIDDEN
)

# qy_lty/aiapp/views.py:478-479 (validation error)
return error_response(message='since_id 必须是整数')  # 默认 code=400, status=400

Phase 2 错误响应矩阵CONTEXT.md success criteria #1-#5 对应):

场景 HTTP 状态 code message建议中文文案 helper 调用
GET / PUT 成功 200 200 "操作成功"(默认)/ "凭据已更新" success_response(data=...)
无 Authorization 头 401 401 DRF 默认 "Authentication credentials were not provided." → 由 middleware process_exception 包装 DRF 自动(NotAuthenticated
Authorization 头存在但 token 在 Redis 查不到 401 401 "Invalid token"DRF AuthenticationFailed DRF 自动
Token 命中但用户非 staffuser token 持有者) 403 403 "需要管理员权限" error_response(code=403, status_code=status.HTTP_403_FORBIDDEN)
PUT body 字段类型错误(如非字符串) 400 400 "参数无效" + serializer.errors as data error_response(code=400, data=serializer.errors)

Pattern 4drf-yasg @swagger_auto_schemamethod-level

What:在 APIView 的方法上挂装饰器drf-yasg 自动生成 OpenAPI schema。

Example(来源 qy_lty/userapp/views.py:92-100,最简洁的样板):

# AdminEmailLoginView.post —— 同样 admin namespace、同样 IsAuthenticated/AllowAny 风格
@swagger_auto_schema(
    request_body=AdminEmailLoginRequestSchema,
    responses={
        200: openapi.Response('登录成功', get_standardized_response_schema()),
        400: openapi.Response('请求参数错误', get_standardized_response_schema()),
        403: openapi.Response('权限不足', get_standardized_response_schema())
    },
    operation_description="专用于管理员通过邮箱和密码登录..."
)
def post(self, request):
    ...

关键约定

  • request_body= 接受 serializers.Serializer 子类(不是 ModelSerializer是手写的 schema 类——见 userapp/views.py:38-78,把请求/响应字段建模为独立的 serializers.Serializer 子类专门给 swagger 用)
  • responses=openapi.Response('描述文案', schema_obj) 字典schema_obj 推荐用 common.swagger_utils.get_standardized_response_schema(),自动产出符合壳层四字段的 OpenAPI Schema
  • operation_description= 中文文案
  • security=[{'Bearer': []}] 用于声明该接口需 Bearer token非必需但已存在的 admin 接口有用,建议加)

Phase 2 装饰器骨架

# request body schema追加到 aiapp/views.py 顶部 schema 区,类似 userapp/views.py:38-78
class CredentialSlotPutRequestSchema(serializers.Serializer):
    app_id = serializers.CharField(required=False, allow_blank=True, help_text="第三方服务商分配的 APP ID")
    access_token = serializers.CharField(required=False, allow_blank=True, help_text="第三方服务商访问令牌(明文写入)")

# response data schemaaccess_token 显式标注脱敏掩码语义)
credential_slot_data_schema = openapi.Schema(
    type=openapi.TYPE_OBJECT,
    properties={
        'app_id': openapi.Schema(type=openapi.TYPE_STRING, description='第三方服务商分配的 APP ID'),
        'access_token': openapi.Schema(
            type=openapi.TYPE_STRING,
            description='Access Token 末 4 位脱敏掩码(如 "*********1234"',
        ),
        'updated_at': openapi.Schema(type=openapi.TYPE_STRING, format='date-time', description='最近一次更新时间'),
    },
)

# 在 view 方法上挂:
@swagger_auto_schema(
    operation_description="读取通用凭据槽位access_token 末 4 位脱敏返回)",
    responses={
        200: openapi.Response('读取成功', get_standardized_response_schema(credential_slot_data_schema)),
        401: openapi.Response('未提供有效 token', get_standardized_response_schema()),
        403: openapi.Response('需要管理员权限', get_standardized_response_schema()),
    },
    security=[{'Bearer': []}],
)
def get(self, request):
    ...

@swagger_auto_schema(
    request_body=CredentialSlotPutRequestSchema,
    operation_description="全字段覆写通用凭据槽位(写入后返回脱敏值)",
    responses={
        200: openapi.Response('更新成功', get_standardized_response_schema(credential_slot_data_schema)),
        400: openapi.Response('参数无效', get_standardized_response_schema()),
        401: openapi.Response('未提供有效 token', get_standardized_response_schema()),
        403: openapi.Response('需要管理员权限', get_standardized_response_schema()),
    },
    security=[{'Bearer': []}],
)
def put(self, request):
    ...

Anti-Patterns to Avoid

  • 直接构造 Response({...}, status=200):仓库其它 admin viewAdminEmailLoginViewAdminLogoutView)从不直接构造,全部用 helper直接构造也能跑middleware 会包装),但与项目约定相悖
  • 试图给 view 同时加 IsAdminUser permissionIsAdminUserDRF 内置)依赖 request.user.is_staff——能用,但与本仓库已有的"IsAuthenticated + 视图内 is_staff 检查"模式不一致;统一走视图内检查更可读
  • 在 serializer 内做脱敏(如 to_representation 覆写CONTEXT.md 已锁定"脱敏放 view 层";混着写会让 PUT 写入路径需要二次序列化
  • 试图从 token 字符串本身判断 admintoken 是 UUID没有载荷admin/user 区分只能通过 Redis key 前缀(已由 RedisTokenAuthentication 透明处理,且后续 request.user.is_staff 才是真正的判定)
  • 新建 IsAdminTokenAuthenticated permission class:仓库零先例;如果坚持封装,会引入"为什么和 IsAdminOrReadOnly 不一样"的认知负担

Don't Hand-Roll

问题 不要自己写 用现成的 理由
Admin token 鉴权 自己写 BaseAuthentication 子类 userapp.authentication.RedisTokenAuthentication(已存在) 全项目统一鉴权类,绑定到 ParadiseUser model自己写会脱节
Admin / User token 区分 自己解析 Redis key 前缀 间接通过 request.user.is_staff 判断 get_user_id_from_token 已优先匹配 admin_token 前缀user model 的 is_staff 字段才是权限来源
单例数据访问 自己写 get_or_create 包装 CredentialSlot.get_solo()Phase 1 已落地) Phase 1 已确立 single source of truth重复实现会脱钩
Token 脱敏 自己写字符串切片 common.utils.mask_tokenPhase 1 已落地) 已含边界处理(空 / 短于 visible_tail有 7 个表驱动用例的可信度Phase 3 阿里云日志 formatter 也将复用同一函数
标准响应壳层 自己写 Response({"success": True, ...}) common.responses.success_response/error_response/api_response 中间件已注册helper 是最安全的入口(与 middleware 约定的"已含 success/code 字段不再二次包装"语义对齐)
OpenAPI Schema 类型 自己写裸 openapi.Schema(...) common.swagger_utils.get_standardized_response_schema(data_schema=...) 自动产出含壳层四字段的 schema前端直接基于 swagger 生成 client 时拿到正确签名
Swagger 装饰器组合 抽样照抄/拼装 common.swagger_utils.swagger_schema(...) 装饰器 已合并默认 401/403 响应;但仓库内同时存在直接用 @swagger_auto_schema 的写法(userapp/views.py 各处),二者均可,保持单一文件内一致即可——本 phase 的 aiapp/views.py 现有代码两种都用过,任选其一无伤大雅

核心洞见:本 phase 没有任何"复杂度藏在哪"的隐患——所有可能"自己写出问题"的环节都已有标准实现。如果出现"我想自己实现 XX"的冲动,先回头查 common/userapp/ 是不是已经有了。

Runtime State Inventory

本 phase 是新增端点,涉及 rename / refactor / migration。但仍简短列出检查结果以避免遗漏

类别 检查项 结果
数据存储 DB 是否已存在 CredentialSlot 单例记录 已存在Phase 1 探针 pk=1, app_id='probe_app', access_token='probe_secret_xxxx' 已写入;本 phase PUT 第一次调用会原地覆写这条而非创建新记录)
服务运行时配置 现有 admin 端点是否登记到 userapp.admin_urls 是(仅 login/logout/ 两个;本 phase 追加 credential-slot/ 是第三个)
OS 注册状态 不涉及
密钥 / 环境变量 无新增 不涉及Phase 2 只读写 DB不读环境变量
构建产物 不涉及Python 解释执行,无 build step

未发现遗留状态需要清理

Common Pitfalls

Pitfall 1把 user token 当 admin token 错放进去

What goes wrongCONTEXT.md 决策 #5 要求"携带 user token 调用本接口必须返 403"。如果只配 permission_classes=[IsAuthenticated] 而不在 view 内 check is_staffuser token 持有者会被放行200 OK + 数据返回)。

Why it happensRedisTokenAuthentication.authenticate 不区分 admin/user token两类 token 都能通过 IsAuthenticated 这一关。区分点是 ParadiseUser.is_staff 字段——但仅当用户主动调过 AdminEmailLoginView(生成 admin tokentoken 持有者本身才会是 staff 用户user token 持有者一般是手机号/MAC 登录的普通用户,is_staff=False

How to avoid

  • 在每个方法体最开始调用 _ensure_admin(request) helper见 Pattern 1 骨架)
  • 1:1 抄 AdminEmailLoginView.post 第 748-754 行的 if not user.is_staff: return error_response(..., code=403, status_code=status.HTTP_403_FORBIDDEN) 模式

Warning signs

  • 用户用手机端 token 调 GET 返回 200 + 真实数据 → bug
  • 单元测试只测了 admin token 路径没测 user token 路径 → 漏测

Pitfall 2StandardResponseMiddleware 二次包装

What goes wrong:如果 view 直接 return Response({'success': True, 'data': {...}})middleware 看到 response.data 已含 success/code 字段middleware.py:53-55不会二次包装;但如果你只写 Response({'foo': 'bar'})middleware 会把整个 dict 当 data 包进去,最终得到 {success: True, code: 200, data: {foo: 'bar'}}——这通常是想要的行为,但容易误判。

Why it happensmiddleware 通过 "data 中是否同时存在 success 和 code 两个 key" 判断"已经是标准格式"。success_response helper 主动放入这两个字段;裸 Response 不会。

How to avoid

  • 一律用 common.responses.success_response / error_response / api_response / not_found_response 等 helper
  • 不要为了追求最小 diff 写 Response({'app_id': ..., 'access_token': ..., 'updated_at': ...})——middleware 会按照 dict-not-yet-wrapped 路径处理middleware.py:71-98把整个 dict 当 data 包进去;功能上 OK但与项目风格不符

Warning signs

  • 响应里 data 字段嵌套了一层 data → 双重包装
  • 响应里没有 success 字段 → middleware 没识别成 DRF response不太可能但要注意

Pitfall 3PUT 写入后忘记脱敏返回

What goes wrongCONTEXT.md 决策"PUT 响应也走脱敏(写入成功后用脱敏值返回)"。如果 view PUT 路径用 return success_response(data=serializer.data) 直接返回serializer.data 含明文 access_token因为 serializer 未做脱敏),就会把刚刚写入的明文回显给运营。

Why it happens:脱敏放 view 层serializer 本身不脱敏;忘记脱敏 = 明文泄露。

How to avoidPUT 路径与 GET 路径都必须执行:

data = dict(serializer.data)
data['access_token'] = mask_token(instance.access_token)
return success_response(data=data)

dict(serializer.data) 是因为 serializer.dataOrderedDict,可以直接修改;安全起见 cast 一下避免不可变问题)

Warning signs

  • Verify 阶段用 curl PUT 后看到响应 data.access_token 是明文 → 漏脱敏
  • 单元测试只断言"access_token 字段存在"没断言"是脱敏掩码格式" → 漏断言

Pitfall 4admin_urls.py 漏注册

What goes wronguserapp/admin_urls.py 只有 login/ + logout/ 两个 path。如果只在 aiapp/views.py 写了 view 但忘了在 userapp/admin_urls.py 加 path()URL 不存在curl 返 404StandardResponseMiddleware 处理后是 {success: false, code: 404, ...},但与 401/403 含义不同)。

How to avoid

  • PLAN 中显式拆出"修改 userapp/admin_urls.py"作为独立 task
  • 在该 task 的 verify 步骤里 curl 一次,确认从 router 层就能命中

Warning signs

  • 401/403/200 全都不返回,返回 404 → 路由注册漏了

Pitfall 5auto_now 字段被写入策略覆盖

What goes wrongupdated_at 字段配 auto_now=Trueaiapp/models.py:73DRF ModelSerializer 默认会把这个字段当作 read-only因为 editable=False),但显式写 Meta.read_only_fields = ['updated_at'] 是双重保险。如果忘了 read_only_fieldsDRF 在 PUT 时仍然不会写它auto_now 优先),但 swagger schema 可能错误地把它当作可写字段。

How to avoid:显式写 read_only_fields = ['updated_at'](见 Pattern 2 骨架)

Warning signsswagger UI 上 PUT body schema 出现 updated_at 字段 → 漏配 read_only

Code Examples

完整 PLAN 落地后的 view 与 serializer 示意

aiapp/serializers.py —— 在现有 ChatMessageSerializer 之后追加:

# 来源参考qy_lty/aiapp/serializers.py 现有结构
from .models import ChatMessage, CredentialSlot

class CredentialSlotSerializer(serializers.ModelSerializer):
    """通用凭据槽位序列化器明文存储view 层脱敏)"""

    class Meta:
        model = CredentialSlot
        fields = ['app_id', 'access_token', 'updated_at']
        read_only_fields = ['updated_at']
        extra_kwargs = {
            'app_id': {'allow_blank': True, 'allow_null': False, 'required': False},
            'access_token': {'allow_blank': True, 'allow_null': False, 'required': False},
        }

userapp/admin_urls.py —— 在现有 path('logout/', ...) 之后追加:

# 来源qy_lty/userapp/admin_urls.py 现有结构
from django.urls import path
from .views import AdminEmailLoginView, AdminLogoutView
from aiapp.views import CredentialSlotAdminView   # 新增 import

urlpatterns = [
    path('login/', AdminEmailLoginView.as_view(), name='admin_login'),
    path('logout/', AdminLogoutView.as_view(), name='admin_logout'),
    path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot'),  # 新增
]

aiapp/views.py —— 在文件末尾追加(紧跟 RTCChatHistoryAPIView 之后):

完整骨架已在 Pattern 1 + Pattern 4 给出,此处不重复。需要新增的顶部 import

# 已在 import 区
from .models import ChatMessage, Bot   # 改为 from .models import ChatMessage, Bot, CredentialSlot
from .serializers import ChatMessageSerializer  # 改为 from .serializers import ChatMessageSerializer, CredentialSlotSerializer
# 新增
from common.utils import mask_token
from common.swagger_utils import get_standardized_response_schema

State of the Art

本仓库基于 Django 4.2.13 + DRF 3.x + drf-yasg 都是稳定栈,本 phase 不涉及新技术或废弃 pattern 切换。

已废弃 / 不推荐

  • Read / Write 两个 serializerCONTEXT.md 已锁定单一 serializer + view 层脱敏(避免 serializer 双重责任)
  • RetrieveUpdateAPIView:仓库零先例,单例资源不走 pk lookup 反而绕路
  • 自定义 admin permission class仓库零先例统一走视图内 is_staff 检查

Assumptions Log

列出所有 [ASSUMED] 标签的声明。本 phase 的所有结论都通过 grep / read 在仓库内实证。

# 声明 章节 风险

本表为空:所有声明都来自代码 grep / 文件 read 实证DRF / drf-yasg / Django 的 API 行为无 [ASSUMED] 依赖(仓库内已有大量样板,足以验证)。

Open Questions

无。研究问题的 8 个全部解决:

  1. /api/v1/admin/ 汇总点 — qy_lty/urls.py:59userapp/admin_urls.py
  2. View 模板 — aiapp/views.py:434-555 RTCChatHistoryAPIView(同 app、单 URL 多方法、与本 phase 形态最贴近);次选 userapp/views.py:778-823 AdminLogoutViewadmin namespace 内、含 admin 校验)
  3. admin/user token 区分 — 在 RedisTokenAuthentication 内不区分;get_user_id_from_token 优先查 admin_token再查 tokenadmin-only 没有现成 permission 类,本 phase 必须在 view 内自加 is_staff 检查
  4. StandardResponseMiddleware 与 DRF Response 兼容 — middleware 检查 response.data 是否含 success + code,已含则不二次包装;用 success_response/error_response helper 总是正确路径
  5. DRF Serializer 模式 — aiapp/serializers.py 存在,含 ChatMessageSerializer 模板(Meta.fields + Meta.read_only_fields);新增 CredentialSlotSerializer 追加即可
  6. @swagger_auto_schema 用法 — 最佳样板:userapp/views.py:92-100method-level + request_body + responses + operation_description + security);与 common.swagger_utils.get_standardized_response_schema() 配合
  7. qy-lty-admin/docs/修改记录.md 状态 — 已存在qy-lty-admin/docs/修改记录.md),头部含完整"修改格式说明"段(同款骨架),第 24 行 ## 修改历史 之下追加新条目即可(最新在最前)
  8. DRF 单例资源模式 — 自定义 APIView + 手写 get / put(仓库 0 处使用 RetrieveUpdateAPIView1:1 复刻 RTCChatHistoryAPIView 风格

Environment Availability

本 phase 不引入任何新外部依赖运行时依赖PostgreSQL / Redis / Django 4.2.13 / DRF / drf-yasg已被 Phase 1 验证通过。

依赖 用途 是否可用 版本 备用
PostgreSQL DB 存储CredentialSlot 表) Phase 1 已迁移 0004_credentialslot 生效
Redis admin token 查询 + cache backend 已运行Phase 1 verify 已实证)
Django 4.2.13 + DRF view / serializer / urlconf Docker 镜像 / requirements.txt
drf-yasg swagger schema 生成 qy_lty/urls.py:41-46 已配 schema_view

无缺失依赖

Validation Architecture

.planning/config.json 未显式设 workflow.nyquist_validation,按缺省=启用处理。但本仓库无 pytest / unittest 基础设施grep tests.py 都是空文件骨架REQUIREMENTS.md 候选优先级 #5 明确"测试基础设施搭建"在下个 milestone

Test Framework

属性
框架 无单元测试框架Django 自带 TestCase 可即时使用,但项目无现成测试套件)
配置文件 pytest.ini / pyproject.toml 测试段;conftest.py 不存在
快速验证命令 python manage.py shell + Django test client沿用 Phase 1 的 verify 风格)
完整套件命令 python manage.py test —— 但仓库 tests.py 全空,跑了等于啥都没测

Phase 需求 → 验证手段映射

Req Behavior 类型 自动化命令 现成 fixtures
CRED-03 GET 脱敏 curl 携 admin token → 200 + masked smokecurl curl -H "Authorization: Bearer <admin_token>" http://localhost:8000/api/v1/admin/credential-slot/ Wave 0 需先签发 admin tokenDjango shell + generate_token(user_id, is_admin=True)
CRED-04 PUT 写入 curl PUT 全字段 → 200 + DB 更新 + updated_at 刷新 smokecurl curl -X PUT -H "Authorization: Bearer <admin_token>" -H "Content-Type: application/json" -d '{"app_id":"x","access_token":"y"}' .../credential-slot/ Wave 0 同上
401 拒绝 不带 token smoke curl http://localhost:8000/api/v1/admin/credential-slot/ ✓(无需 fixture
403 拒绝 携 user token非 admin smoke curl -H "Authorization: Bearer <user_token>" .../credential-slot/ Wave 0 需先签发 user tokengenerate_token(user_id) 不带 is_admin
Swagger 暴露 /swagger/ HTML 页面含路径条目 smokeHTML grep curl http://localhost:8000/swagger.json | python -c "import json,sys; print('credential-slot' in json.dumps(json.load(sys.stdin)))" ✓(无需 fixture
修改记录两端互引 文件 grep static check grep -l "Phase 2" qy_lty/docs/修改记录.md qy-lty-admin/docs/修改记录.md

采样频率

  • 任务 commit 后Django shell + test client 调 Client(...).get/.put('/api/v1/admin/credential-slot/', HTTP_AUTHORIZATION='Bearer ...') 跑 5 个判据200/401/403 + GET 脱敏 + PUT 回显脱敏);这与 Phase 1 verify 风格 1:1force_login 风格的程序化验收)
  • Wave 合并后:跑一次 full curl 套件(含 swagger.json 校验)
  • Phase gate/gsd-verify-work 用 Django test client + curl 两层都跑

Wave 0 缺口

  • 测试用 admin token 与 user token 的签发脚本(一次性 Django shell 命令,写在 PLAN 的 setup task
  • 不需要新建 test 文件Phase 1 已确立用 Django test client 程序化验收的模式,无需框架)
  • 不需要装新依赖

Security Domain

CLAUDE.md 与 PROJECT.md 未明确启用 security_enforcement flag但本 phase 涉及凭据存储 + 鉴权 + 脱敏,仍按基础 ASVS 类别梳理。

Applicable ASVS Categories

ASVS Category 适用 标准控制
V2 Authentication RedisTokenAuthentication Bearer token30 天 TTL
V3 Session Management Token 存储于 Redisgenerate_token 创建、get_user_id_from_token 验证;登出走 cache.delete(f"admin_token:{token}")userapp/views.py:819
V4 Access Control View 层 is_staff 二次校验admin token 来自 AdminEmailLoginView 路径,普通用户 token 来自 MacAddressLoginView/PhoneLoginView
V5 Input Validation CredentialSlotSerializeris_valid() 校验CharField 类型 + max_length128 / 512 + allow_null=False
V6 Cryptography 部分 DB at-rest 加密 不在本 phase 范畴CONTEXT.md deferred ideas 明确);access_token 在 DB 中明文但响应脱敏transport 加密由生产环境的 Nginx HTTPS 反代提供(与本 phase 无关)
V7 Error Handling DRF 的 AuthenticationFailed / PermissionDenied 异常被 StandardResponseMiddleware.process_exception 接住,转 JSON 壳层
V14 Configuration 部分 .env 不入版本库;本 phase 不读环境变量,配置全在 DB

本 stack 的已知威胁模式

模式 STRIDE 标准缓解
user token 被滥用调 admin 端点 Elevation of Privilege View 层 is_staff 二次校验CONTEXT.md success #5
Access Token 明文落入日志 Information Disclosure 本 phase view 层脱敏返回GET 与 PUT 响应都脱敏Phase 3 阿里云日志 formatter 进一步过滤请求体里的 access_token
PUT 重放攻击(无幂等性保护) Tampering 全字段覆写本身就是幂等的;写两次同样 body 结果一致;不需额外保护
Token 在 URL 中泄露access log Information Disclosure 本 phase 强制 Header Authorization: Bearer ...支持 URL 携 token与 WebSocket 路径下的 /ws/device/token/{token}/ 不同
暴力 PUT消耗 DB Denial of Service CONTEXT.md deferred ideas 明确"API 限流不在本 phase 范畴";现有架构无限流(这是已知风险,由 PROJECT.md candidate priorities #2 跟踪)
序列化器接受未声明字段mass assignment Tampering DRF ModelSerializer 默认拒绝未在 fields 中声明的字段;Meta.fields = ['app_id', 'access_token', 'updated_at'] 三字段封死,无 mass assignment 风险

Sources

PrimaryHIGH confidence

仓库内代码 grep + 文件 read全部一手实证

  • qy_lty/urls.py:59path('v1/admin/', include('userapp.admin_urls')) 路由汇总点
  • userapp/admin_urls.py — admin namespace urlconf 全文(仅 login/logout 两条)
  • userapp/authentication.py:10-34RedisTokenAuthentication 完整实现
  • userapp/utils.py:33-45generate_token + get_user_id_from_token 实现
  • userapp/views.py:705-823AdminEmailLoginView + AdminLogoutViewadmin-only 二次校验模板)
  • aiapp/views.py:434-555RTCChatHistoryAPIView(单 URL 多方法 APIView 模板,本 phase 1:1 复刻)
  • aiapp/views.py:80-114ChatBotAPIViewmethod-level @swagger_auto_schema 简洁样板)
  • aiapp/serializers.py 全文 — ChatMessageSerializerModelSerializer 模板)
  • aiapp/models.py:55-93CredentialSlot 模型 + get_solo() + save 钩子
  • aiapp/admin.py:18-53CredentialSlotAdmin Phase 1 落地的脱敏样板
  • common/middleware.py:6-145StandardResponseMiddleware 完整实现(含二次包装规避逻辑 line 53-55
  • common/responses.py 全文 — success_response / error_response / api_response
  • common/utils.py:10-32mask_token 工具函数
  • common/swagger_utils.py 全文 — get_standardized_response_schema() + swagger_schema() 装饰器
  • qy_lty/settings.py:82-95MIDDLEWARE 注册顺序(含 StandardResponseMiddleware
  • qy_lty/docs/修改记录.md:1-90 — 修改记录格式 + Phase 1 已落地两条样板
  • qy-lty-admin/docs/修改记录.md:1-58 — 已存在的修改记录格式 + 已有跨项目联动条目样板
  • requirements.txt — 确认无新依赖drf-yasg / djangorestframework / django 都已在)
  • .planning/phases/01-credential-data-layer/01-VERIFICATION.md — Phase 1 verify 模式test client 程序化验收)

SecondaryMEDIUM confidence

无需要——本 phase 全部决策都在 primary 一手代码内可证。

TertiaryLOW confidence

无。

Metadata

Confidence breakdown:

  • Standard stack: HIGH — 全部组件在仓库内已用过,无版本风险
  • Architecture: HIGH — RTCChatHistoryAPIView 是 1:1 模板,单例 + 鉴权 + middleware 链路全部已在仓库实证
  • Pitfalls: HIGH — 5 个 pitfall 都基于现有代码 + middleware 行为推导(特别是 Pitfall 2 关于 middleware 二次包装的逻辑直接来自 middleware.py:53-55

Research date: 2026-05-07 Valid until: 2026-06-0730 天 — 仓库结构稳定,无外部 API 依赖,时效较长)


Phase: 02-admin-rest Researched: 2026-05-07 by gsd-researcher