docs(02): Phase 2 RESEARCH.md(路由汇总点 userapp/admin_urls + 复刻 RTCChatHistoryAPIView 模板 + 仓库零 IsAdminTokenAuthenticated)
This commit is contained in:
parent
172ab321c1
commit
7452b35a0f
768
qy_lty/.planning/phases/02-admin-rest/02-RESEARCH.md
Normal file
768
qy_lty/.planning/phases/02-admin-rest/02-RESEARCH.md
Normal file
@ -0,0 +1,768 @@
|
|||||||
|
# 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_only(auto_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)或 403(user 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 已锁定不用 ModelViewSet;ModelViewSet 默认带 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 class(grep 实证);现有所有 admin-only 端点(`AdminEmailLoginView`、`AdminLogoutView`、`IsAdminOrReadOnly`)一律走 `is_staff` 视图内检查;新发明 permission class 与现有约定相悖 |
|
||||||
|
| `success_response()` / `error_response()` 调用 | 直接 `Response({...}, status=200)` | 项目约定全部 view 走 `common.responses.*` helper(grep `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-498(RTCChatHistoryAPIView 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 2:DRF 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 命中但用户非 staff(user 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 4:drf-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 schema(access_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 token)后,token 持有者本身才会是 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 3:PUT 写入后忘记脱敏返回
|
||||||
|
|
||||||
|
**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 4:admin_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` 两个 serializer:CONTEXT.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,再查 token;admin-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 | smoke(curl) | `curl -H "Authorization: Bearer <admin_token>" http://localhost:8000/api/v1/admin/credential-slot/` | ❌ Wave 0 需先签发 admin token(Django shell + `generate_token(user_id, is_admin=True)`) |
|
||||||
|
| CRED-04 PUT 写入 | curl PUT 全字段 → 200 + DB 更新 + updated_at 刷新 | smoke(curl) | `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 页面含路径条目 | smoke(HTML 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 token;30 天 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_length(128 / 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
|
||||||
|
|
||||||
|
### Primary(HIGH 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 程序化验收)
|
||||||
|
|
||||||
|
### Secondary(MEDIUM confidence)
|
||||||
|
|
||||||
|
无需要——本 phase 全部决策都在 primary 一手代码内可证。
|
||||||
|
|
||||||
|
### Tertiary(LOW 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-07(30 天 — 仓库结构稳定,无外部 API 依赖,时效较长)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 02-admin-rest*
|
||||||
|
*Researched: 2026-05-07 by gsd-researcher*
|
||||||
Loading…
x
Reference in New Issue
Block a user