--- phase: 02-admin-rest plan: 01 subsystem: aiapp + userapp(admin namespace 路由) tags: [credential-slot, admin-api, drf, mask, swagger, rest] requirements_completed: - CRED-03 - CRED-04 dependency_graph: requires: - aiapp.models.CredentialSlot(Phase 1 / Plan 01-01 落地) - aiapp.models.CredentialSlot.get_solo()(Phase 1 / Plan 01-01 落地) - common.utils.mask_token(Phase 1 / Plan 01-01 落地) - common.responses.success_response / error_response(已存在) - common.swagger_utils.get_standardized_response_schema(已存在) - userapp.authentication.RedisTokenAuthentication(已存在) provides: - aiapp.serializers.CredentialSlotSerializer(DRF ModelSerializer,3 字段) - aiapp.views.CredentialSlotAdminView(APIView,GET/PUT 两端点) - URL: /api/v1/admin/credential-slot/(name='admin_credential_slot') affects: - 下一 plan:02-02-PLAN(端到端 verify + 修改记录两端互引) tech_stack: added: [] patterns: - DRF 自定义 APIView 单 URL 多方法(1:1 复刻 RTCChatHistoryAPIView) - permission_classes=[IsAuthenticated] + view 内 _ensure_admin 二次校验 is_staff (沿用 AdminEmailLoginView / AdminLogoutView 模式,不发明 IsAdminTokenAuthenticated) - 脱敏在 view 层 _build_response_data helper 完成,不在 serializer 层 - GET 与 PUT 响应都走 _build_response_data,避免 PUT 明文回显 - method-level @swagger_auto_schema 装饰器 + access_token 字段 description 显式标注脱敏掩码语义 key_files: created: [] modified: - qy_lty/aiapp/serializers.py - qy_lty/aiapp/views.py - qy_lty/userapp/admin_urls.py decisions: - "View 1:1 复刻 RTCChatHistoryAPIView 风格:不走 RetrieveUpdateAPIView(仓库零先例)" - "permission_classes=[IsAuthenticated] + 视图内 _ensure_admin 二次校验:与 AdminEmailLoginView/AdminLogoutView 一致;不发明 IsAdminTokenAuthenticated permission 类" - "脱敏放 view 层不放 serializer:PUT 路径需明文走 is_valid + save,serializer 只做字段校验,避免双重责任" - "GET 与 PUT 响应都强制走 _build_response_data:避免 PUT 直接 return success_response(data=serializer.data) 导致刚提交的明文回显(Pitfall 3)" - "drf-yasg request body 用独立 CredentialSlotPutRequestSchema serializer 类(与 userapp/views.py:705-708 AdminEmailLoginRequestSchema 模式一致):与实际写入校验的 CredentialSlotSerializer 解耦" - "method-level @swagger_auto_schema:在 GET 与 PUT 各挂一份;access_token 响应字段 description 明示末 4 位脱敏掩码" metrics: duration_seconds: 216 tasks_completed: 3 files_modified: 3 commits: - 6820fe7 feat(02-01): 新增 CredentialSlotSerializer - 192d0a1 feat(02-01): 新增 CredentialSlotAdminView(GET 脱敏 / PUT 全字段覆写) - 9d02021 feat(02-01): 注册 /api/v1/admin/credential-slot/ 路由 completed_date: 2026-05-07 --- # Phase 2 Plan 02-01:管理端 REST 接口(serializer + view + URL + Swagger)Summary 在 `/api/v1/admin/credential-slot/` 暴露 GET(脱敏读取)+ PUT(全字段覆写)两个 admin token 鉴权端点,全部沿用仓库现有 RTCChatHistoryAPIView / AdminEmailLoginView 模式,零新依赖。 ## 一句话概述 新增 `CredentialSlotSerializer` + `CredentialSlotAdminView`(GET/PUT),在 `userapp/admin_urls.py` 注册到 `/api/v1/admin/credential-slot/`,view 层 `mask_token` 脱敏 access_token,覆盖 CRED-03 / CRED-04。 ## 改动文件清单 | 文件 | 类型 | 描述 | |------|------|------| | `qy_lty/aiapp/serializers.py` | 修改 | import 区追加 `CredentialSlot`;文件末尾追加 `CredentialSlotSerializer`(ModelSerializer,3 字段) | | `qy_lty/aiapp/views.py` | 修改 | import 区追加 `CredentialSlot` / `CredentialSlotSerializer` / `mask_token` / `get_standardized_response_schema`;文件末尾追加 `CredentialSlotPutRequestSchema`(drf-yasg 请求体 schema)+ `_credential_slot_data_schema`(响应 data 子 schema)+ `CredentialSlotAdminView`(含 `_ensure_admin` / `_build_response_data` / GET / PUT 4 个方法) | | `qy_lty/userapp/admin_urls.py` | 修改 | 顶部 import `CredentialSlotAdminView`;urlpatterns 追加 `path('credential-slot/', ..., name='admin_credential_slot')` | ## 实际落地的关键产物 ### Serializer ```python # qy_lty/aiapp/serializers.py class CredentialSlotSerializer(serializers.ModelSerializer): 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}, } ``` ### View `qy_lty/aiapp/views.py` 文件末尾: - `CredentialSlotPutRequestSchema(serializers.Serializer)` — drf-yasg 请求体 schema - `_credential_slot_data_schema = openapi.Schema(...)` — 响应 data 子 schema(access_token description 明示末 4 位脱敏掩码) - `CredentialSlotAdminView(APIView)`: - `authentication_classes = [RedisTokenAuthentication]` - `permission_classes = [IsAuthenticated]` - `_ensure_admin(request)` — `if not request.user.is_staff: return error_response(code=403, status_code=403)` - `_build_response_data(instance)` — 调 `mask_token(instance.access_token)` 覆盖明文 - `get(request)` / `put(request)` — 各挂 `@swagger_auto_schema` ### URL ```python # qy_lty/userapp/admin_urls.py from aiapp.views import CredentialSlotAdminView urlpatterns = [ ..., path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot'), ] ``` 完整路径:`/api/v1/admin/credential-slot/`(拼接自 `qy_lty/urls.py:59` 的 `path('v1/admin/', include('userapp.admin_urls'))` + `path('credential-slot/', ...)`)。 ### 调用 mask_token 的位置(脱敏调用点) `qy_lty/aiapp/views.py:637`: ```python data['access_token'] = mask_token(instance.access_token) ``` `_build_response_data` 是 GET 与 PUT 唯一的响应数据构造点,强制脱敏。 ## Plan 内自验证据 | 验证点 | 命令 | 结果 | |--------|------|------| | import 链路 | `from aiapp.views import CredentialSlotAdminView; from aiapp.serializers import CredentialSlotSerializer` | OK 无 ImportError | | URL 解析 | `reverse('admin_credential_slot')` | `/api/v1/admin/credential-slot/` | | Django check | `python manage.py check` | 仅 1 条 W004(STATICFILES_DIRS)— 预先存在,与本 plan 无关 | | Serializer 字段 | 实例化 `pk=1` 后 `.data.keys()` | `['app_id', 'access_token', 'updated_at']` | | Serializer read_only_fields | `.fields['updated_at'].read_only` | `True` | | Serializer allow_null/allow_blank | `.fields['app_id'/'access_token']` | `allow_blank=True, allow_null=False` | | View 鉴权 / 权限链 | `CredentialSlotAdminView.authentication_classes / permission_classes` | `[RedisTokenAuthentication]` / `[IsAuthenticated]` | | View 4 个 method 完整性 | `hasattr(v, 'get'/'put'/'_ensure_admin'/'_build_response_data')` | 全部 True | | Swagger 装饰器 | `hasattr(get_method/put_method, '_swagger_auto_schema')` | 全部 True | | 探针脱敏 | `mask_token('probe_secret_xxxx')` | `*************xxxx`(13 stars + xxxx;len=17) | ### Goal-backward reachability self-check - truth #1(GET 脱敏 200)→ `views.py CredentialSlotAdminView.get` + `serializers.py CredentialSlotSerializer` + `admin_urls.py path` → reachable ✓ - truth #2(PUT 全字段覆写 + updated_at 刷新 + 响应脱敏)→ `views.py CredentialSlotAdminView.put`(`serializer.save` + `_build_response_data`)→ reachable ✓ - truth #3(PUT 在空记录场景 get_or_create)→ `CredentialSlot.get_solo()`(Phase 1 已在)→ reachable ✓ - truth #4(无 token → 401)→ `RedisTokenAuthentication` + DRF `NotAuthenticated` → reachable ✓ - truth #5(user token → 403)→ `_ensure_admin` `is_staff` 校验 → reachable ✓ - truth #6(swagger 路径条目 + access_token 脱敏 description)→ `@swagger_auto_schema` + `_credential_slot_data_schema` description → reachable ✓ 端到端 curl 验收(含 admin token 签发 / user token 拒绝 / swagger.json 校验)由 Plan 02-02 完成。 ## Deviations from Plan ### 偏差 1:Plan import 行号偏移(Rule 1 等价 — 现实修正) - **Found during:** Task 2 - **Plan 假设:** `from drf_yasg.utils import swagger_auto_schema` 在第 13 行 - **实际仓库状态:** 该 import 实际位于第 14 行(第 11 行已是 `from common.swagger_utils import swagger_schema`) - **Adjustment:** 不按行号定位,按字符串精确 Edit 替换 8 行 import 块(包含 `.models` / `.serializers` / `RedisTokenAuthentication` / `serializers` / `swagger_utils` / `responses`),追加 `mask_token` 1 行;新增 `get_standardized_response_schema` 通过扩展现有 `from common.swagger_utils import swagger_schema` 一行完成 - **Reason:** Plan 行号是参考值,不是契约;本仓库 import 区结构与 plan 描述完全等价(仅个别行偏移),按精确 token 串替换更安全 - **Files modified:** `qy_lty/aiapp/views.py` - **Commit:** `192d0a1` ### 偏差 2:Task 1 自动化校验命令补 Django setup(Rule 3 — 阻塞修复) - **Found during:** Task 1 verify - **Issue:** Plan 提供的 `python -c "from aiapp.serializers import CredentialSlotSerializer..."` 命令在 windows 命令行下直接 import 会触发 `ImproperlyConfigured: Requested setting INSTALLED_APPS, but settings are not configured` - **Fix:** 在 verify 命令内显式 `os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'qy_lty.settings'); django.setup()`;功能等价但兼容裸 python -c 调用 - **Reason:** Plan 假设的 verify 命令行假设了某个隐式环境(如 `python manage.py shell -c`),裸 python 解释器需显式 setup;不影响功能正确性 - **Files modified:** 无(仅 verify 命令) - **Commit:** 无(不涉及代码改动) ### 偏差 3:未写 docs/修改记录.md(per execution_context 强制约束) - **Reason:** 用户在 `` 显式声明 "本 plan 不写 docs/修改记录.md(Plan 02-02 Task 2 一并写两端互引)" - **Status:** 故意不写,由 Plan 02-02 一次性补完两端互引条目(CLAUDE.md 跨项目联动规则) - **Risk:** 在 Plan 02-02 落地前,本仓库 `docs/修改记录.md` 不含 Phase 2 条目;可接受,因为 Plan 02-01 + 02-02 是同 session 同 phase 的连续动作 ### 其余偏差 无。Plan 三个 task 的代码内容、acceptance criteria、anti-pattern 约束、reachability 验证全部按 Plan 1:1 落地。 ## 留给 Plan 02-02 的端到端 verify hook ### Setup(Wave 0 — 签发测试 token) ```python # Django shell:python manage.py shell from userapp.models import ParadiseUser from userapp.utils import generate_token # 找一个 staff 用户(或现成的 admin) admin_user = ParadiseUser.objects.filter(is_staff=True).first() admin_token = generate_token(admin_user.id, is_admin=True) print('ADMIN_TOKEN=', admin_token) # 找一个普通用户 normal_user = ParadiseUser.objects.filter(is_staff=False).first() user_token = generate_token(normal_user.id, is_admin=False) print('USER_TOKEN=', user_token) ``` ### Curl 矩阵 ```bash # 1. 无 token → 401 curl -i http://localhost:8000/api/v1/admin/credential-slot/ # 2. user token → 403 + "需要管理员权限" curl -i -H "Authorization: Bearer ${USER_TOKEN}" http://localhost:8000/api/v1/admin/credential-slot/ # 3. admin token GET → 200 + access_token 脱敏(probe_secret_xxxx → *************xxxx) curl -i -H "Authorization: Bearer ${ADMIN_TOKEN}" http://localhost:8000/api/v1/admin/credential-slot/ # 4. admin token PUT → 200 + DB 写入 + 响应同样脱敏 curl -i -X PUT -H "Authorization: Bearer ${ADMIN_TOKEN}" -H "Content-Type: application/json" \ -d '{"app_id":"new_app","access_token":"new_secret_token_5678"}' \ http://localhost:8000/api/v1/admin/credential-slot/ # 期望响应 data.access_token = "****************5678",updated_at 刷新 # 5. swagger.json 含 credential-slot 路径条目 + access_token 脱敏 description curl -s http://localhost:8000/swagger.json | python -c "import json,sys; d=json.load(sys.stdin); p='/api/v1/admin/credential-slot/'; assert p in d['paths'], p; assert 'GET' in [m.upper() for m in d['paths'][p].keys()]; assert 'PUT' in [m.upper() for m in d['paths'][p].keys()]; print('OK swagger')" ``` ### Django shell 程序化验收(test client) ```python # python manage.py shell from rest_framework.test import APIClient from userapp.models import ParadiseUser from userapp.utils import generate_token c = APIClient() # admin token GET admin_user = ParadiseUser.objects.filter(is_staff=True).first() admin_token = generate_token(admin_user.id, is_admin=True) c.credentials(HTTP_AUTHORIZATION=f'Bearer {admin_token}') resp = c.get('/api/v1/admin/credential-slot/') print(resp.status_code, resp.json()) assert resp.status_code == 200 assert resp.json()['success'] == True assert '*' in resp.json()['data']['access_token'] # 脱敏特征 # admin token PUT resp2 = c.put('/api/v1/admin/credential-slot/', data={'app_id': 'put_app', 'access_token': 'put_secret_5678'}, format='json') print(resp2.status_code, resp2.json()) assert resp2.status_code == 200 assert resp2.json()['data']['access_token'].endswith('5678') # 末 4 位 assert '*' in resp2.json()['data']['access_token'] # 脱敏 # user token → 403 normal_user = ParadiseUser.objects.filter(is_staff=False).first() user_token = generate_token(normal_user.id, is_admin=False) c.credentials(HTTP_AUTHORIZATION=f'Bearer {user_token}') resp3 = c.get('/api/v1/admin/credential-slot/') print(resp3.status_code, resp3.json()) assert resp3.status_code == 403 assert resp3.json()['success'] == False # 无 token → 401 c.credentials() resp4 = c.get('/api/v1/admin/credential-slot/') print(resp4.status_code, resp4.json()) assert resp4.status_code == 401 ``` ## Threat Flags 无。本 plan 改动严格落在 02-01-PLAN 的 `` 8 条已声明威胁内(T-02-01 ~ T-02-08),未引入新 trust boundary 或新攻击面。 ## Self-Check: PASSED ### Files - FOUND: `qy_lty/aiapp/serializers.py`(已修改,含 CredentialSlotSerializer) - FOUND: `qy_lty/aiapp/views.py`(已修改,含 CredentialSlotAdminView) - FOUND: `qy_lty/userapp/admin_urls.py`(已修改,含 admin_credential_slot URL) - FOUND: `.planning/phases/02-admin-rest/02-01-SUMMARY.md`(本文件) ### Commits - FOUND: `6820fe7` feat(02-01): 新增 CredentialSlotSerializer - FOUND: `192d0a1` feat(02-01): 新增 CredentialSlotAdminView(GET 脱敏 / PUT 全字段覆写) - FOUND: `9d02021` feat(02-01): 注册 /api/v1/admin/credential-slot/ 路由 ### Imports - VERIFIED: `from aiapp.views import CredentialSlotAdminView; from aiapp.serializers import CredentialSlotSerializer` 同行无 ImportError - VERIFIED: `reverse('admin_credential_slot')` = `/api/v1/admin/credential-slot/` - VERIFIED: `mask_token('probe_secret_xxxx')` = `*************xxxx` --- *Phase: 02-admin-rest / Plan: 01* *Executed: 2026-05-07 by gsd-executor(顺序执行模式,无 worktree 隔离)*