769 lines
47 KiB
Markdown
Raw 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 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_view`qy_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 后选择
- 候选 A`aiapp/urls.py`(凭据槽位语义偏向 AI
- 候选 B仓库现有的 admin namespace `/api/v1/admin/` 在哪个 urls 文件汇总
- **不**新建 `credential` app
**View 实现**
- **不用** `ModelViewSet`
- **用** `RetrieveUpdateAPIView`DRF 提供)或自定义 `APIView` + 手写 `get` / `put`
- **推荐**自定义 `APIView`——单例语义不走 `lookup_field`/`pk`
- View 类命名:`CredentialSlotAdminView`,放 `aiapp/views.py`
**Serializer**
- **DRF ModelSerializer**`CredentialSlotSerializer`,放 `aiapp/serializers.py`
- 字段:`app_id``access_token``updated_at`
- `updated_at` 在 GET 响应里 read_onlyauto_now 自动维护)
- **Access Token 脱敏在 view 层处理,不在 serializer 层**
- 写入校验:`app_id``access_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_lty``qy-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 class`Authorization: Bearer <token>` 解析 token`get_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 并写 Redis`is_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`/`RetrieveAPIView`grep 实证);单例不走 `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 端点(`AdminEmailLoginView``AdminLogoutView``IsAdminOrReadOnly`)一律走 `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": {...}}
```
### Recommended Project Structure增量改动
```
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 套用骨架):
```python
# 来源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 复刻骨架**
```python
# 拟落地于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.fields``Meta.read_only_fields`
**Example**(来源 `qy_lty/aiapp/serializers.py:1-9`
```python
# 现有代码 - 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 新增骨架**(追加到同文件):
```python
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=False`PUT body 缺字段会用现有值兜底
- "全字段覆写"语义在 PUT body 既给两个字段时成立;缺字段时由 ModelSerializer 默认行为回填
- 如果"全字段覆写"严格要求两字段都必须出现,可改 `'required': True`——但 CONTEXT.md 没硬性要求,按 PUT 习惯(部分缺失允许)即可
### Pattern 3标准响应封装三种调用法
**What**:所有 view 都通过 `common.responses` 的 helper 返回。
**Examples**grep 实证 — 全仓所有 admin-related view 都用这套):
```python
# 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_schema`method-level
**What**:在 APIView 的方法上挂装饰器drf-yasg 自动生成 OpenAPI schema。
**Example**(来源 `qy_lty/userapp/views.py:92-100`,最简洁的样板):
```python
# 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 装饰器骨架**
```python
# 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 view`AdminEmailLoginView``AdminLogoutView`)从不直接构造,全部用 helper直接构造也能跑middleware 会包装),但与项目约定相悖
- **试图给 view 同时加 `IsAdminUser` permission**`IsAdminUser`DRF 内置)依赖 `request.user.is_staff`——能用,但与本仓库已有的"`IsAuthenticated` + 视图内 `is_staff` 检查"模式不一致;统一走视图内检查更可读
- **在 serializer 内做脱敏**(如 `to_representation` 覆写CONTEXT.md 已锁定"脱敏放 view 层";混着写会让 PUT 写入路径需要二次序列化
- **试图从 token 字符串本身判断 admin**`token` 是 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_token`Phase 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 wrong**CONTEXT.md 决策 #5 要求"携带 user token 调用本接口必须返 403"。如果只配 `permission_classes=[IsAuthenticated]` 而不在 view 内 check `is_staff`user token 持有者会被放行200 OK + 数据返回)。
**Why it happens**`RedisTokenAuthentication.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 2`StandardResponseMiddleware` 二次包装
**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 happens**middleware 通过 "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 wrong**CONTEXT.md 决策"PUT 响应也走脱敏(写入成功后用脱敏值返回)"。如果 view PUT 路径用 `return success_response(data=serializer.data)` 直接返回serializer.data 含**明文** access_token因为 serializer 未做脱敏),就会把刚刚写入的明文回显给运营。
**Why it happens**:脱敏放 view 层serializer 本身不脱敏;忘记脱敏 = 明文泄露。
**How to avoid**PUT 路径与 GET 路径都必须执行:
```python
data = dict(serializer.data)
data['access_token'] = mask_token(instance.access_token)
return success_response(data=data)
```
`dict(serializer.data)` 是因为 `serializer.data``OrderedDict`,可以直接修改;安全起见 cast 一下避免不可变问题)
**Warning signs**
- Verify 阶段用 curl PUT 后看到响应 `data.access_token` 是明文 → 漏脱敏
- 单元测试只断言"access_token 字段存在"没断言"是脱敏掩码格式" → 漏断言
### Pitfall 4admin_urls.py 漏注册
**What goes wrong**`userapp/admin_urls.py` 只有 `login/` + `logout/` 两个 path。如果只在 `aiapp/views.py` 写了 view 但忘了在 `userapp/admin_urls.py` 加 path()URL 不存在curl 返 404`StandardResponseMiddleware` 处理后是 `{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 5`auto_now` 字段被写入策略覆盖
**What goes wrong**`updated_at` 字段配 `auto_now=True``aiapp/models.py:73`DRF ModelSerializer 默认会把这个字段当作 read-only因为 `editable=False`),但显式写 `Meta.read_only_fields = ['updated_at']` 是双重保险。如果忘了 `read_only_fields`DRF 在 PUT 时仍然不会写它auto_now 优先),但 swagger schema 可能错误地把它当作可写字段。
**How to avoid**:显式写 `read_only_fields = ['updated_at']`(见 Pattern 2 骨架)
**Warning signs**swagger UI 上 PUT body schema 出现 `updated_at` 字段 → 漏配 read_only
## Code Examples
### 完整 PLAN 落地后的 view 与 serializer 示意
**`aiapp/serializers.py`** —— 在现有 `ChatMessageSerializer` 之后追加:
```python
# 来源参考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/', ...)` 之后追加:
```python
# 来源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
```python
# 已在 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:59``userapp/admin_urls.py`
2. ✅ View 模板 — `aiapp/views.py:434-555 RTCChatHistoryAPIView`(同 app、单 URL 多方法、与本 phase 形态最贴近);次选 `userapp/views.py:778-823 AdminLogoutView`admin 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-100`method-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 处使用 `RetrieveUpdateAPIView`1: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 token`generate_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:1`force_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 存储于 Redis`generate_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 | 是 | `CredentialSlotSerializer``is_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:59``path('v1/admin/', include('userapp.admin_urls'))` 路由汇总点
- `userapp/admin_urls.py` — admin namespace urlconf 全文(仅 login/logout 两条)
- `userapp/authentication.py:10-34``RedisTokenAuthentication` 完整实现
- `userapp/utils.py:33-45``generate_token` + `get_user_id_from_token` 实现
- `userapp/views.py:705-823``AdminEmailLoginView` + `AdminLogoutView`admin-only 二次校验模板)
- `aiapp/views.py:434-555``RTCChatHistoryAPIView`(单 URL 多方法 APIView 模板,本 phase 1:1 复刻)
- `aiapp/views.py:80-114``ChatBotAPIView`method-level `@swagger_auto_schema` 简洁样板)
- `aiapp/serializers.py` 全文 — `ChatMessageSerializer`ModelSerializer 模板)
- `aiapp/models.py:55-93``CredentialSlot` 模型 + `get_solo()` + save 钩子
- `aiapp/admin.py:18-53``CredentialSlotAdmin` Phase 1 落地的脱敏样板
- `common/middleware.py:6-145``StandardResponseMiddleware` 完整实现(含二次包装规避逻辑 line 53-55
- `common/responses.py` 全文 — `success_response` / `error_response` / `api_response`
- `common/utils.py:10-32``mask_token` 工具函数
- `common/swagger_utils.py` 全文 — `get_standardized_response_schema()` + `swagger_schema()` 装饰器
- `qy_lty/settings.py:82-95``MIDDLEWARE` 注册顺序(含 `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*