From 57199483f78960a1956a00c6620c68c79cf9f794 Mon Sep 17 00:00:00 2001
From: pmc <740076875@qq.com>
Date: Thu, 7 May 2026 18:34:21 +0800
Subject: [PATCH] =?UTF-8?q?docs(02):=20Phase=202=20PLAN.md=20=C3=972?=
=?UTF-8?q?=EF=BC=8802-01=20view+serializer+url+swagger=20/=2002-02=20?=
=?UTF-8?q?=E5=8F=8C=E5=86=99=E4=BA=92=E5=BC=95=E4=BF=AE=E6=94=B9=E8=AE=B0?=
=?UTF-8?q?=E5=BD=95=20+=20=E7=AB=AF=E5=88=B0=E7=AB=AF=20verify=EF=BC=89?=
=?UTF-8?q?=EF=BC=8Cplan-checker=20PASS=EF=BC=88=E6=97=A0=20BLOCKER?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
qy_lty/.planning/ROADMAP.md | 6 +-
.../phases/02-admin-rest/02-01-PLAN.md | 560 ++++++++++++++++++
.../phases/02-admin-rest/02-02-PLAN.md | 464 +++++++++++++++
3 files changed, 1028 insertions(+), 2 deletions(-)
create mode 100644 qy_lty/.planning/phases/02-admin-rest/02-01-PLAN.md
create mode 100644 qy_lty/.planning/phases/02-admin-rest/02-02-PLAN.md
diff --git a/qy_lty/.planning/ROADMAP.md b/qy_lty/.planning/ROADMAP.md
index e97e3da..5835c28 100644
--- a/qy_lty/.planning/ROADMAP.md
+++ b/qy_lty/.planning/ROADMAP.md
@@ -44,7 +44,9 @@
2. 携带有效 `admin_token:{token}` 调用 `PUT /api/v1/admin/credential-slot/` 提交 `{ app_id, access_token }`,记录被全字段覆写、`updated_at` 自动刷新;空记录场景自动 `get_or_create`,不报 404
3. 不携带 admin token、或仅携带普通 user token 调用上述两个端点均被拒绝(401 / 403),错误响应同样符合 `StandardResponseMiddleware` 壳层
4. 接口出现在 `/swagger/` 与 `/redoc/` 中,请求/响应 schema 与实际行为一致(drf-yasg 自动生成)
-**Plans**: TBD
+**Plans:** 2 plans
+ - [ ] 02-01-PLAN.md — CredentialSlot serializer + view(GET 脱敏 / PUT 覆写 + admin 二次校验)+ admin_urls 路由注册(CRED-03 + CRED-04)
+ - [ ] 02-02-PLAN.md — 端到端 curl + Django shell 验收 8 条 success criteria + qy_lty / qy-lty-admin 两端修改记录互引(CRED-03 + CRED-04)
### Phase 3: 客户端读取与日志脱敏
**Goal**: 手机端(LTY_App_Project_URP)和设备端(LTY_Project)能通过 `/api/credential-slot/` 拿到**明文** APP ID + Access Token 去调用第三方服务;同时确保 Access Token 在阿里云日志中始终脱敏,不论是 PUT 请求体还是管理端 GET 响应体
@@ -65,7 +67,7 @@ Phase 按数值顺序执行:1 → 2 → 3(如出现紧急插入,记为 1.1
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. 凭据槽位数据层 | 2/2 | ✓ Complete | 2026-05-07 |
-| 2. 管理端读写接口 | 0/TBD | Not started | - |
+| 2. 管理端读写接口 | 0/2 | Planned | - |
| 3. 客户端读取与日志脱敏 | 0/TBD | Not started | - |
---
diff --git a/qy_lty/.planning/phases/02-admin-rest/02-01-PLAN.md b/qy_lty/.planning/phases/02-admin-rest/02-01-PLAN.md
new file mode 100644
index 0000000..92a7dda
--- /dev/null
+++ b/qy_lty/.planning/phases/02-admin-rest/02-01-PLAN.md
@@ -0,0 +1,560 @@
+---
+phase: 02-admin-rest
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - qy_lty/aiapp/serializers.py
+ - qy_lty/aiapp/views.py
+ - qy_lty/userapp/admin_urls.py
+autonomous: true
+requirements:
+ - CRED-03
+ - CRED-04
+must_haves:
+ truths:
+ - "携 admin token 调用 GET /api/v1/admin/credential-slot/ 返回 200 + 标准壳层 + access_token 末 4 位脱敏"
+ - "携 admin token 调用 PUT /api/v1/admin/credential-slot/ 写入后 DB 被全字段覆写、updated_at 自动刷新、响应中 access_token 同样脱敏"
+ - "DB 不存在 pk=1 记录时(人为删除场景)PUT 仍能 get_or_create 成功创建并写入"
+ - "无 Authorization 头返回 401(DRF NotAuthenticated → middleware 兜底 success:false 标准壳层)"
+ - "携普通 user token(非 staff)返回 403 + 标准壳层 + message='需要管理员权限'"
+ - "/swagger.json 包含 /api/v1/admin/credential-slot/ 路径条目,含 GET 与 PUT 两个 method 及 access_token 脱敏掩码描述"
+ artifacts:
+ - path: "qy_lty/aiapp/serializers.py"
+ provides: "CredentialSlotSerializer ModelSerializer 类"
+ contains: "class CredentialSlotSerializer"
+ - path: "qy_lty/aiapp/views.py"
+ provides: "CredentialSlotAdminView APIView 类(含 GET/PUT 两个方法 + swagger 装饰器)"
+ contains: "class CredentialSlotAdminView"
+ - path: "qy_lty/userapp/admin_urls.py"
+ provides: "/api/v1/admin/credential-slot/ URL 注册"
+ contains: "admin_credential_slot"
+ key_links:
+ - from: "qy_lty/userapp/admin_urls.py"
+ to: "qy_lty/aiapp/views.py:CredentialSlotAdminView"
+ via: "from aiapp.views import CredentialSlotAdminView"
+ pattern: "credential-slot"
+ - from: "qy_lty/aiapp/views.py:CredentialSlotAdminView"
+ to: "qy_lty/aiapp/models.py:CredentialSlot.get_solo()"
+ via: "instance = CredentialSlot.get_solo()"
+ pattern: "CredentialSlot\\.get_solo"
+ - from: "qy_lty/aiapp/views.py:CredentialSlotAdminView"
+ to: "qy_lty/common/utils.py:mask_token"
+ via: "data['access_token'] = mask_token(instance.access_token)"
+ pattern: "mask_token\\(instance\\.access_token"
+ - from: "qy_lty/aiapp/views.py:CredentialSlotAdminView (is_staff 校验)"
+ to: "PUT/GET 早返回 403"
+ via: "if not request.user.is_staff: return error_response(... code=403 ...)"
+ pattern: "is_staff"
+---
+
+
+本 plan 在 `/api/v1/admin/credential-slot/` 暴露 GET(脱敏)+ PUT(全字段覆写)两个端点,覆盖 CRED-03 + CRED-04。
+
+Purpose:
+- CRED-03:管理后台读取脱敏后的凭据槽位(仅末 4 位明文,避免运营在 admin UI 看到完整明文)
+- CRED-04:管理后台以全字段覆写方式更新凭据槽位,PUT 响应同样走脱敏避免明文回显
+
+Output:
+- 新增 `CredentialSlotSerializer`(ModelSerializer,3 字段 + read_only_fields + extra_kwargs allow_blank)
+- 新增 `CredentialSlotAdminView`(自定义 APIView + 手写 GET/PUT,1:1 复刻 RTCChatHistoryAPIView 风格)
+- 在 `userapp/admin_urls.py` 追加 `path('credential-slot/', ...)` 注册
+- View 方法挂 `@swagger_auto_schema` 装饰器,响应 schema 显式标注 access_token 末 4 位脱敏掩码语义
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/REQUIREMENTS.md
+@.planning/phases/02-admin-rest/02-CONTEXT.md
+@.planning/phases/02-admin-rest/02-RESEARCH.md
+
+# Phase 1 落地决策(为何模型走 pk=1 + get_solo + mask_token)
+@.planning/phases/01-credential-data-layer/01-01-SUMMARY.md
+@.planning/phases/01-credential-data-layer/01-02-SUMMARY.md
+
+# 1:1 复刻样板(必读)
+@qy_lty/aiapp/views.py
+@qy_lty/aiapp/serializers.py
+@qy_lty/aiapp/models.py
+@qy_lty/aiapp/urls.py
+@qy_lty/userapp/views.py
+@qy_lty/userapp/admin_urls.py
+@qy_lty/userapp/authentication.py
+@qy_lty/userapp/utils.py
+@qy_lty/common/responses.py
+@qy_lty/common/middleware.py
+@qy_lty/common/swagger_utils.py
+@qy_lty/common/utils.py
+@qy_lty/qy_lty/urls.py
+
+
+
+
+【已存在 — 复用】Model: qy_lty/aiapp/models.py
+```python
+class CredentialSlot(models.Model):
+ app_id = models.CharField(max_length=128, blank=True, default='')
+ access_token = models.CharField(max_length=512, blank=True, default='')
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def save(self, *args, **kwargs):
+ # pk=1 单例钩子:已有记录时把 pk 重定向,DB 永远只有一行
+ ...
+
+ @classmethod
+ def get_solo(cls):
+ instance, _created = cls.objects.get_or_create(pk=1)
+ return instance
+```
+
+【已存在 — 复用】Auth: qy_lty/userapp/authentication.py
+```python
+class RedisTokenAuthentication(BaseAuthentication):
+ # 读取 Authorization: Bearer ,调 get_user_id_from_token:
+ # 优先查 admin_token:{token},否则 token:{token};都查不到 → AuthenticationFailed (401)
+ # 命中 → 返回 (ParadiseUser, None)
+ def authenticate(self, request): ...
+```
+注意:本类**不区分** admin / user token。区分点是 `request.user.is_staff`(只有走 AdminEmailLoginView 的用户才会是 staff)。
+
+【已存在 — 复用】Helpers: qy_lty/common/responses.py
+```python
+def success_response(data=None, message="操作成功", code=200, **kwargs) -> Response # 200
+def error_response(message="操作失败", code=400, status_code=400, **kwargs) -> Response
+```
+两者已构造壳层四字段(success / code / message / data),与 StandardResponseMiddleware 协同(middleware.py:53-55 检查 success+code 二者皆在则不二次包装)。
+
+【已存在 — 复用】Tool: qy_lty/common/utils.py
+```python
+def mask_token(token: str, visible_tail: int = 4, mask_char: str = '*') -> str:
+ # 'sk-abcdef1234' -> '*********1234'
+ # '' -> ''
+ # 'abc' -> '***' # 短于 visible_tail 时全脱敏
+```
+
+【已存在 — 复用】Swagger: qy_lty/common/swagger_utils.py
+```python
+def get_standardized_response_schema(data_schema=None) -> openapi.Schema
+# 返回 OpenAPI Schema(type=OBJECT,properties=success/code/message + 可选 data)
+```
+
+【新增 — 本 plan 落地】Serializer: qy_lty/aiapp/serializers.py
+```python
+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},
+ }
+```
+
+【新增 — 本 plan 落地】View: qy_lty/aiapp/views.py 末尾
+```python
+class CredentialSlotAdminView(APIView):
+ authentication_classes = [RedisTokenAuthentication]
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request): ... # 返回脱敏 data
+ def put(self, request): ... # 全字段覆写 + 脱敏响应
+```
+
+
+
+
+
+
+ Task 1:新增 CredentialSlotSerializer(aiapp/serializers.py)
+ qy_lty/aiapp/serializers.py
+
+ - 必读 qy_lty/aiapp/serializers.py 全文(仅 9 行;现有 ChatMessageSerializer 是同款 ModelSerializer 模板)
+ - 必读 qy_lty/aiapp/models.py 中 CredentialSlot 模型定义(验证字段名 app_id / access_token / updated_at)
+ - 必读 02-RESEARCH.md `Pattern 2:DRF ModelSerializer 写法` 段落(含完整骨架)
+
+
+在 `qy_lty/aiapp/serializers.py` 顶部 import 行追加 `CredentialSlot`,文件末尾追加 `CredentialSlotSerializer` 类。
+
+**完整修改后文件内容(直接覆写)**:
+
+```python
+from rest_framework import serializers
+from .models import ChatMessage, CredentialSlot
+
+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']
+
+
+class CredentialSlotSerializer(serializers.ModelSerializer):
+ """通用凭据槽位序列化器(明文存储,脱敏由 view 层完成)。
+
+ 设计动机(per CONTEXT.md D-Serializer):
+ - 脱敏放 view 层不放 serializer:PUT 路径需要明文走 is_valid + save,serializer
+ 不应承担"既要明文又要脱敏"的双重责任。
+ - app_id / access_token 在模型层 blank=True, default='',对应 serializer 配
+ allow_blank=True, allow_null=False, required=False;既允许空字符串覆写、又
+ 拒绝 None;缺字段时由 ModelSerializer 默认行为(用现有值兜底)。
+ - updated_at 由模型层 auto_now=True 自动维护,read_only 双重保险。
+ """
+
+ 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},
+ }
+```
+
+**注意**:
+- 不要发明 `CredentialSlotReadSerializer` / `CredentialSlotWriteSerializer` 拆分两个类(CONTEXT.md / RESEARCH.md "Alternatives Considered" 已锁定单一 serializer + view 层脱敏)
+- 不要在 serializer 内写 `to_representation` 覆写做脱敏(同上锁定)
+- 不要给 `app_id` / `access_token` 加 `allow_null=True`(与模型层 blank=True/default='' 不一致)
+
+
+
+ cd qy_lty && python -c "from aiapp.serializers import CredentialSlotSerializer; from aiapp.models import CredentialSlot; s = CredentialSlotSerializer(CredentialSlot.get_solo()); assert set(s.data.keys()) == {'app_id', 'access_token', 'updated_at'}, s.data.keys(); print('OK fields=', list(s.data.keys()))"
+
+
+
+ - `python -c "from aiapp.serializers import CredentialSlotSerializer"` 无 ImportError
+ - serializer 实例化已有的 pk=1 记录后 `.data` 三键齐全:`app_id` / `access_token` / `updated_at`
+ - serializer.fields['updated_at'].read_only == True
+ - serializer.fields['app_id'].allow_blank == True 且 allow_null == False
+ - serializer.fields['access_token'].allow_blank == True 且 allow_null == False
+ - 文件不再含其它 serializer 类(仅追加 CredentialSlotSerializer)
+
+
+ `aiapp/serializers.py` 含 CredentialSlotSerializer 类,三字段对齐模型,`updated_at` read-only;GET 用它返回 .data 含明文(view 层稍后脱敏),PUT 用它做 is_valid + save。
+
+
+
+
+ Task 2:新增 CredentialSlotAdminView(aiapp/views.py 末尾追加)
+ qy_lty/aiapp/views.py
+
+ - 必读 qy_lty/aiapp/views.py:1-18(顶部 import 区,确认现有 import 结构)
+ - 必读 qy_lty/aiapp/views.py:434-555(RTCChatHistoryAPIView 完整段;本 view 1:1 复刻其单 URL 多方法骨架)
+ - 必读 qy_lty/userapp/views.py:705-823(AdminEmailLoginView + AdminLogoutView;admin-only 二次校验 `is_staff` 模板:line 748-754)
+ - 必读 qy_lty/userapp/views.py:722-730(method-level @swagger_auto_schema 简洁样板)
+ - 必读 02-RESEARCH.md `Pattern 1` + `Pattern 4` + `Pitfall 1/2/3/5`
+ - 必读 qy_lty/common/swagger_utils.py 全文(确认 `get_standardized_response_schema` 签名)
+ - 必读 qy_lty/common/responses.py 全文(确认 `success_response` / `error_response` 签名)
+
+
+**Step 1:在 `qy_lty/aiapp/views.py` 顶部 import 区追加 / 修改三处**:
+
+修改第 5 行(追加 `CredentialSlot`):
+```python
+# 修改前:
+from .models import ChatMessage, Bot
+# 修改后:
+from .models import ChatMessage, Bot, CredentialSlot
+```
+
+修改第 7 行(追加 `CredentialSlotSerializer`):
+```python
+# 修改前:
+from .serializers import ChatMessageSerializer
+# 修改后:
+from .serializers import ChatMessageSerializer, CredentialSlotSerializer
+```
+
+在第 13 行(`from drf_yasg.utils import swagger_auto_schema` 已存在)之后追加两行(如果文件中已存在则跳过该行;只追加缺失的):
+```python
+from common.utils import mask_token
+from common.swagger_utils import get_standardized_response_schema
+```
+
+**Step 2:在 `qy_lty/aiapp/views.py` 文件末尾(紧跟 `RTCChatHistoryAPIView.delete` 之后)追加完整代码块**:
+
+```python
+
+
+# ======================================================================
+# Phase 2 — 通用凭据槽位管理端读写接口(CRED-03 + CRED-04)
+# 1:1 复刻 RTCChatHistoryAPIView 的单 URL 多方法 APIView 风格
+# ======================================================================
+
+class CredentialSlotPutRequestSchema(serializers.Serializer):
+ """drf-yasg 专用 — PUT 请求体 schema(不参与实际写入校验,仅给 swagger 看)。
+
+ 实际写入校验由 CredentialSlotSerializer.is_valid 执行。
+ """
+ app_id = serializers.CharField(
+ required=False, allow_blank=True,
+ help_text="第三方服务商分配的 APP ID(明文写入;缺省时保留原值)"
+ )
+ access_token = serializers.CharField(
+ required=False, allow_blank=True,
+ help_text="第三方服务商访问令牌(明文写入;响应阶段会脱敏返回末 4 位)"
+ )
+
+
+# 响应 data 子 schema:access_token 字段 description 显式标注脱敏掩码语义
+_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",前缀字符数 = 原长 - 4)',
+ ),
+ 'updated_at': openapi.Schema(
+ type=openapi.TYPE_STRING,
+ format='date-time',
+ description='最近一次更新时间(ISO 8601)',
+ ),
+ },
+)
+
+
+class CredentialSlotAdminView(APIView):
+ """通用凭据槽位管理端读写接口(admin token 鉴权)。
+
+ GET: 返回 access_token 末 4 位脱敏后的凭据槽位
+ PUT: 全字段覆写凭据槽位(空记录场景自动 get_or_create),响应同样脱敏
+ """
+ authentication_classes = [RedisTokenAuthentication]
+ permission_classes = [IsAuthenticated]
+ tags = ['通用凭据槽位(管理端)']
+
+ def _ensure_admin(self, request):
+ """admin-only 二次校验:拒绝非 staff 用户(含普通 user token 持有者)。
+
+ per RESEARCH.md:仓库零处 IsAdminTokenAuthenticated permission 类;
+ 现有 AdminEmailLoginView (userapp/views.py:748-754) / AdminLogoutView 一律走
+ 视图内 is_staff 检查。统一沿用此模式,不发明新 permission 类。
+ """
+ if not request.user.is_staff:
+ logger.warning(
+ f"Non-admin user attempted CredentialSlot admin endpoint: user_id={request.user.id}"
+ )
+ return error_response(
+ message="需要管理员权限",
+ code=403,
+ status_code=status.HTTP_403_FORBIDDEN,
+ )
+ return None
+
+ def _build_response_data(self, instance):
+ """构造脱敏后的响应 data 字典。
+
+ per CONTEXT.md:GET 与 PUT 响应都必须脱敏 access_token,避免运营在
+ admin UI 看到自己刚提交的明文回显(CONTEXT.md 决策"PUT 响应也走脱敏")。
+ """
+ serializer = CredentialSlotSerializer(instance)
+ data = dict(serializer.data)
+ # 关键脱敏点:用 instance.access_token(明文)走 mask_token,覆盖 serializer.data 里的明文
+ data['access_token'] = mask_token(instance.access_token)
+ return data
+
+ @swagger_auto_schema(
+ operation_description="读取通用凭据槽位(access_token 末 4 位脱敏返回,admin token 鉴权)",
+ 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': []}],
+ tags=['通用凭据槽位(管理端)'],
+ )
+ def get(self, request):
+ forbidden = self._ensure_admin(request)
+ if forbidden:
+ return forbidden
+ instance = CredentialSlot.get_solo()
+ data = self._build_response_data(instance)
+ return success_response(data=data, message="读取成功")
+
+ @swagger_auto_schema(
+ request_body=CredentialSlotPutRequestSchema,
+ operation_description="全字段覆写通用凭据槽位(admin token 鉴权;写入后响应脱敏返回)",
+ 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': []}],
+ tags=['通用凭据槽位(管理端)'],
+ )
+ def put(self, request):
+ forbidden = self._ensure_admin(request)
+ if forbidden:
+ return forbidden
+ instance = CredentialSlot.get_solo() # 空记录场景自动 get_or_create
+ serializer = CredentialSlotSerializer(instance, data=request.data)
+ if not serializer.is_valid():
+ return error_response(
+ message="参数无效",
+ code=400,
+ status_code=status.HTTP_400_BAD_REQUEST,
+ data=serializer.errors,
+ )
+ serializer.save() # auto_now 自动刷 updated_at
+ # 重新读取 instance.access_token:serializer.save() 后 instance 已被同步刷新;
+ # _build_response_data 内部会再次 dict(serializer.data) 拿最新 OrderedDict。
+ data = self._build_response_data(instance)
+ return success_response(data=data, message="凭据已更新")
+```
+
+**关键约束(不要违反)**:
+- 不要把 view 写成 `RetrieveUpdateAPIView` 子类(仓库零先例,per RESEARCH.md "Alternatives Considered")
+- 不要直接 `return Response({...}, status=200)`;一律走 `success_response` / `error_response`(避免 middleware 二次包装的不确定行为;per Pitfall 2)
+- 不要在 PUT 路径忘记脱敏:`return success_response(data=serializer.data)` 直接返回是 BUG —— `serializer.data` 含明文 access_token(per Pitfall 3)
+- 不要新增 `IsAdminTokenAuthenticated` permission 类(仓库零先例,与现有约定相悖;per RESEARCH.md "Anti-Patterns")
+- 不要把 `_ensure_admin` 改成 `permission_classes = [IsAdminUser]`(DRF 自带 IsAdminUser 也是 is_staff,但与本仓库"视图内手写 is_staff"统一约定不一致)
+
+
+
+ cd qy_lty && python -c "from aiapp.views import CredentialSlotAdminView; v = CredentialSlotAdminView(); assert hasattr(v, 'get') and hasattr(v, 'put'); from userapp.authentication import RedisTokenAuthentication; assert RedisTokenAuthentication in CredentialSlotAdminView.authentication_classes; print('OK view loaded with auth')"
+
+
+
+ - `python -c "from aiapp.views import CredentialSlotAdminView"` 无 ImportError
+ - View 类拥有 `get` / `put` / `_ensure_admin` / `_build_response_data` 方法
+ - `CredentialSlotAdminView.authentication_classes` 含 `RedisTokenAuthentication`
+ - `CredentialSlotAdminView.permission_classes` 含 `IsAuthenticated`(不是 IsAdminUser、不是自定义 admin permission)
+ - grep `qy_lty/aiapp/views.py` 含 `mask_token(instance.access_token)` 至少一次(脱敏调用点)
+ - grep `qy_lty/aiapp/views.py` 含 `if not request.user.is_staff` 至少一次(admin 二次校验)
+ - grep `qy_lty/aiapp/views.py` 不含 `RetrieveUpdateAPIView`(不准走 DRF 通用 view)
+ - grep `qy_lty/aiapp/views.py` 不含 `IsAdminTokenAuthenticated`(不准发明新 permission 类)
+ - swagger 装饰器在 GET 与 PUT 各挂一份(grep `@swagger_auto_schema` 出现次数比改动前多 2)
+
+
+ `aiapp/views.py` 末尾追加 `CredentialSlotAdminView` 类(含 GET/PUT 两方法 + admin 二次校验 + 脱敏 helper);顶部 import 已对齐;view 可被 import 不报错。
+
+
+
+
+ Task 3:注册 URL(userapp/admin_urls.py 追加 path)
+ qy_lty/userapp/admin_urls.py
+
+ - 必读 qy_lty/userapp/admin_urls.py 全文(11 行;现有 login/logout 两条 path)
+ - 必读 qy_lty/qy_lty/urls.py:59(`path('v1/admin/', include('userapp.admin_urls'))` — 确认 prefix 拼接:`/api/v1/admin/credential-slot/`)
+ - 必读 02-RESEARCH.md `Pitfall 4`(admin_urls.py 漏注册导致 404)
+
+
+**完整修改后 `qy_lty/userapp/admin_urls.py` 内容(直接覆写)**:
+
+```python
+from django.urls import path
+from .views import AdminEmailLoginView, AdminLogoutView
+# Phase 2 — 通用凭据槽位管理端读写接口(CRED-03 + CRED-04)
+from aiapp.views import CredentialSlotAdminView
+
+# 管理员专用API路径
+urlpatterns = [
+ # 管理员登录
+ path('login/', AdminEmailLoginView.as_view(), name='admin_login'),
+ # 管理员登出
+ path('logout/', AdminLogoutView.as_view(), name='admin_logout'),
+ # 通用凭据槽位(GET 脱敏读取 / PUT 全字段覆写;admin token 鉴权)
+ path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot'),
+ # 后续可以添加更多管理员专用接口
+]
+```
+
+**关键约束**:
+- import 必须从 `aiapp.views` 引入(凭据槽位 view 落地于 aiapp,不是 userapp)
+- 路径必须是 `'credential-slot/'`(trailing slash + 中划线,对齐 CONTEXT.md "trailing slash 沿用 Django 默认风格")
+- name 必须是 `'admin_credential_slot'`(reverse 时使用)
+- 不要在 `aiapp/urls.py` 重复注册(CONTEXT.md 锁定路由汇总点是 userapp/admin_urls.py)
+
+
+
+ cd qy_lty && python -c "from django.urls import reverse; from django.conf import settings; import django; django.setup() if not settings.configured else None; from django.urls import get_resolver; r = get_resolver(); url = reverse('admin_credential_slot'); assert url == '/api/v1/admin/credential-slot/', f'got {url}'; print('OK url=', url)" 2>&1 || (cd qy_lty && DJANGO_SETTINGS_MODULE=qy_lty.settings python -c "import django; django.setup(); from django.urls import reverse; print('url=', reverse('admin_credential_slot'))")
+
+
+
+ - `python manage.py check` 无 URL 注册类报错
+ - `reverse('admin_credential_slot')` 返回 `/api/v1/admin/credential-slot/`
+ - `qy_lty/userapp/admin_urls.py` 含 `from aiapp.views import CredentialSlotAdminView`
+ - `qy_lty/userapp/admin_urls.py` 不含将凭据槽位重复注册到 `aiapp/urls.py` 的逻辑
+ - `qy_lty/aiapp/urls.py` 内**未**新增 credential-slot 注册(汇总点单一)
+
+
+ `/api/v1/admin/credential-slot/` URL 可被 reverse 解析到 `CredentialSlotAdminView`;Django check 通过;与 login/logout 在同一 admin namespace 注册块内。
+
+
+
+
+
+
+本 plan 三个 task 完成后,做一轮 plan 内自验(不替代 02-02-PLAN 的端到端 verify):
+
+1. **import 链路完整**:`python -c "from aiapp.views import CredentialSlotAdminView; from aiapp.serializers import CredentialSlotSerializer"` 同行无 ImportError
+2. **URL 路由生效**:`python manage.py shell -c "from django.urls import reverse; print(reverse('admin_credential_slot'))"` 输出 `/api/v1/admin/credential-slot/`
+3. **Django check 通过**:`python manage.py check` 无 ERRORS / WARNINGS(W 级 noise 可忽略)
+4. **Swagger schema 暴露(运行时验证留 02-02)**:本 plan 不启动 daphne,但通过 import 检查保证 `@swagger_auto_schema` 装饰器无语法错误
+
+**reachability self-check**(goal-backward):
+- truth #1(GET 脱敏 200)→ artifact: views.py CredentialSlotAdminView.get + serializers.py CredentialSlotSerializer + admin_urls.py path → reachable ✓
+- truth #2(PUT 全字段覆写 + updated_at 刷新 + 响应脱敏)→ artifact: views.py CredentialSlotAdminView.put(serializer.save + _build_response_data)→ reachable ✓
+- truth #3(PUT 在空记录场景 get_or_create)→ artifact: CredentialSlot.get_solo()(Phase 1 落地,本 plan 调用)→ reachable ✓
+- truth #4(无 token → 401)→ artifact: RedisTokenAuthentication(Phase 0 已在)+ DRF NotAuthenticated → reachable ✓
+- truth #5(user token → 403)→ artifact: views.py `_ensure_admin` is_staff 校验 → reachable ✓
+- truth #6(swagger 路径条目 + access_token 脱敏 description)→ artifact: views.py @swagger_auto_schema + _credential_slot_data_schema description → reachable ✓
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| 公网 → /api/v1/admin/credential-slot/ | 来自 qy-lty-admin Web UI 的 HTTP 请求;untrusted Authorization header;untrusted PUT body |
+| view → DB(CredentialSlot 表) | 内部信任边界;写入前必须经过 serializer.is_valid + admin 校验 |
+| view → 响应回 admin 端 | 出站脱敏边界;access_token 必须经 mask_token 才能离开后端 |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-01 | Spoofing | Authorization header | mitigate | RedisTokenAuthentication 走 Redis admin_token:{token} key 验证;TTL 30 天;token 验证失败抛 AuthenticationFailed → 401 |
+| T-02-02 | Elevation of Privilege | user token 持有者调 admin 端点 | mitigate | view 内 `_ensure_admin` 早返回 403;CONTEXT.md success #5 的核心防线 |
+| T-02-03 | Information Disclosure | GET 响应明文 access_token | mitigate | `_build_response_data` 强制走 `mask_token(instance.access_token)`;serializer 不在 to_representation 内做脱敏避免责任分散 |
+| T-02-04 | Information Disclosure | PUT 响应回显明文 access_token | mitigate | PUT 路径 save() 后同样调 `_build_response_data`;不直接 return success_response(data=serializer.data) — 该写法会回显明文(per Pitfall 3) |
+| T-02-05 | Tampering | mass assignment(未声明字段) | mitigate | DRF ModelSerializer 默认拒绝未在 Meta.fields 声明的字段;fields=['app_id', 'access_token', 'updated_at'] 三字段封死;updated_at read_only 双重保险 |
+| T-02-06 | Tampering | PUT 重放 / 幂等性 | accept | 全字段覆写本身幂等(重放结果一致);不需要 ETag / If-Match |
+| T-02-07 | Denial of Service | 暴力 PUT 消耗 DB | accept | 现有架构无限流(PROJECT.md candidate priorities #2 跟踪);本 phase 不引入新依赖;deferred ideas 已明确 |
+| T-02-08 | Information Disclosure | access_token 写入 access log(请求体 / 响应体) | accept(本 phase)+ transfer(Phase 3) | 本 phase 仅做响应脱敏;阿里云 access log 链路过滤由 Phase 3 落地(CRED-06);Phase 3 完成后此项关闭 |
+
+
+
+本 plan 落地完成的标志(plan 内自验):
+
+- [ ] `qy_lty/aiapp/serializers.py` 顶部 `from .models import ChatMessage, CredentialSlot`,文件内含 `class CredentialSlotSerializer(serializers.ModelSerializer):`
+- [ ] `qy_lty/aiapp/views.py` 顶部 import 区含 `CredentialSlot` / `CredentialSlotSerializer` / `mask_token` / `get_standardized_response_schema`
+- [ ] `qy_lty/aiapp/views.py` 文件末尾含 `class CredentialSlotAdminView(APIView):`,含 GET/PUT 两个方法及各自 `@swagger_auto_schema` 装饰器
+- [ ] `qy_lty/userapp/admin_urls.py` 含 `path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot')`
+- [ ] `python manage.py check` 通过
+- [ ] `reverse('admin_credential_slot')` 解析到 `/api/v1/admin/credential-slot/`
+- [ ] grep `aiapp/views.py` 内 `mask_token(instance.access_token)` ≥ 1 次(脱敏调用点)
+- [ ] grep `aiapp/views.py` 内 `if not request.user.is_staff` ≥ 1 次(admin 二次校验)
+- [ ] grep `aiapp/views.py` 不含 `RetrieveUpdateAPIView`(确认未走通用 view)
+
+
+
diff --git a/qy_lty/.planning/phases/02-admin-rest/02-02-PLAN.md b/qy_lty/.planning/phases/02-admin-rest/02-02-PLAN.md
new file mode 100644
index 0000000..6b4cb9a
--- /dev/null
+++ b/qy_lty/.planning/phases/02-admin-rest/02-02-PLAN.md
@@ -0,0 +1,464 @@
+---
+phase: 02-admin-rest
+plan: 02
+type: execute
+wave: 2
+depends_on:
+ - "02-01"
+files_modified:
+ - qy_lty/docs/修改记录.md
+ - qy-lty-admin/docs/修改记录.md
+autonomous: true
+requirements:
+ - CRED-03
+ - CRED-04
+must_haves:
+ truths:
+ - "qy_lty/docs/修改记录.md 顶部新增一条 Phase 2 条目,跨项目联动字段引用 qy-lty-admin/docs/修改记录.md 的同期条目"
+ - "qy-lty-admin/docs/修改记录.md 顶部新增一条 Phase 2 条目,服务端联动字段引用 qy_lty/docs/修改记录.md 的同期条目(互引闭环)"
+ - "端到端 curl 验收:GET 携 admin token 返回 200 + 标准壳层 + access_token 脱敏(末 4 位明文 + 前缀 *)"
+ - "端到端 curl 验收:PUT 携 admin token + body 后 DB 全字段覆写 + updated_at 刷新 + 响应 access_token 脱敏"
+ - "端到端 curl 验收:无 token → 401 + 标准壳层;user token → 403 + message=需要管理员权限 + 标准壳层"
+ - "Swagger schema 暴露:/swagger.json 含 /api/v1/admin/credential-slot/ 路径条目,含 GET/PUT 两 method,access_token 字段 description 标注脱敏掩码语义"
+ artifacts:
+ - path: "qy_lty/docs/修改记录.md"
+ provides: "Phase 2 修改记录条目(含跨项目联动字段)"
+ contains: "Phase 2"
+ - path: "qy-lty-admin/docs/修改记录.md"
+ provides: "Phase 2 互引条目(含服务端联动字段)"
+ contains: "Phase 2"
+ key_links:
+ - from: "qy_lty/docs/修改记录.md (Phase 2 条目的跨项目联动字段)"
+ to: "qy-lty-admin/docs/修改记录.md (Phase 2 同期条目)"
+ via: "条目内文字引用 ../qy-lty-admin/docs/修改记录.md 同期条目"
+ pattern: "qy-lty-admin/docs/修改记录"
+ - from: "qy-lty-admin/docs/修改记录.md (Phase 2 条目的服务端联动字段)"
+ to: "qy_lty/docs/修改记录.md (Phase 2 同期条目)"
+ via: "条目内文字引用 ../qy_lty/docs/修改记录.md 同期条目"
+ pattern: "qy_lty/docs/修改记录"
+---
+
+
+本 plan 完成 Phase 2 收尾:
+1. 端到端验收 02-01 落地的 GET/PUT 接口(curl + Django shell test client 双层验证 8 条 success criteria)
+2. 在 qy_lty + qy-lty-admin 两端 `docs/修改记录.md` 顶部各写一条 Phase 2 条目,且**跨项目联动字段相互引用**(CLAUDE.md 强制规则;Phase 2 是首次跨项目接口契约落地)
+
+Purpose:
+- 端到端验收:把 02-01 写出来的 view + serializer + URL 真正跑起来,证明三处 success criteria(GET 脱敏 / PUT 覆写 + 脱敏响应 / 鉴权拒绝矩阵)在生产路径成立
+- 跨项目互引:给后续在 qy-lty-admin 起 CRED-FE-01 phase 时,前端能从修改记录直接定位到本 phase 的接口契约文档;给本仓库未来回查"这个接口什么时候上的、谁在消费"留一个反向锚点
+
+Output:
+- `qy_lty/docs/修改记录.md` 顶部新增一条 Phase 2 条目(5 字段 + 跨项目联动字段)
+- `qy-lty-admin/docs/修改记录.md` 顶部新增一条 Phase 2 条目(5 字段 + 服务端联动字段)
+- VERIFICATION.md 补充端到端 curl + Django shell 验收记录(在 SUMMARY 中归档)
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/02-admin-rest/02-CONTEXT.md
+@.planning/phases/02-admin-rest/02-RESEARCH.md
+@.planning/phases/02-admin-rest/02-01-PLAN.md
+
+# 02-01 落地后的 SUMMARY(含端到端 verify hook)
+@.planning/phases/02-admin-rest/02-01-SUMMARY.md
+
+# 修改记录格式 + 已落地条目模板
+@qy_lty/docs/修改记录.md
+@qy-lty-admin/docs/修改记录.md
+
+# 项目宪法(修改记录强制 + 跨项目互引规则)
+@qy_lty/CLAUDE.md
+
+# 鉴权 / 工具入口(用于端到端验收脚本)
+@qy_lty/userapp/utils.py
+@qy_lty/userapp/authentication.py
+@qy_lty/aiapp/models.py
+@qy_lty/common/utils.py
+
+
+
+
+
+ Task 1:端到端 curl + Django shell 验收(8 条 success criteria)
+ qy_lty/.planning/phases/02-admin-rest/02-VERIFICATION.md
+
+ - 必读 .planning/phases/02-admin-rest/02-01-SUMMARY.md(02-01 留给本 task 的 hook)
+ - 必读 .planning/phases/02-admin-rest/02-CONTEXT.md `` 段(8 条验收点表格)
+ - 必读 .planning/phases/02-admin-rest/02-RESEARCH.md `Validation Architecture` 段(test client 程序化验收脚本片段 + 采样频率)
+ - 必读 qy_lty/userapp/utils.py:33-37(generate_token 签发 admin / user token 的方式)
+ - 必读 qy_lty/aiapp/models.py 的 CredentialSlot.get_solo(验证 PUT 在空记录场景的 get_or_create)
+ - 必读 .planning/phases/01-credential-data-layer/01-VERIFICATION.md(Phase 1 程序化验收风格,本 task 1:1 沿用)
+
+
+**目标**:在 Django shell + Daphne / runserver 两条路径都验完 8 条 success criteria,留一份证据归档进 02-VERIFICATION.md。
+
+**Step 1:准备测试 token(一次性 Django shell 脚本)**
+
+```bash
+cd qy_lty && python manage.py shell <<'EOF'
+from userapp.models import ParadiseUser
+from userapp.utils import generate_token
+import json
+
+# 取一个已有 staff 用户(必须 is_staff=True;如全库无 staff,先 createsuperuser)
+admin_user = ParadiseUser.objects.filter(is_staff=True).first()
+assert admin_user is not None, "需要至少一个 is_staff=True 用户;运行 python manage.py createsuperuser"
+admin_token = generate_token(admin_user.id, is_admin=True)
+
+# 取一个普通用户(is_staff=False;如不存在可创建一个 phone-only 用户用于本验收)
+user = ParadiseUser.objects.filter(is_staff=False).first()
+if user is None:
+ user = ParadiseUser.objects.create(username='phase2_verify_user_temp', is_staff=False)
+user_token = generate_token(user.id, is_admin=False)
+
+print(json.dumps({
+ 'admin_user_id': admin_user.id,
+ 'admin_token': admin_token,
+ 'user_id': user.id,
+ 'user_token': user_token,
+}, ensure_ascii=False))
+EOF
+```
+
+记录输出的 `admin_token` / `user_token` 备用。**注意**:token 写入 Redis 后 30 天内有效;本 task 验完无需清理(可让其自然过期)。
+
+**Step 2:Django test client 程序化验收(无需启动服务进程,全部在 shell 内跑)**
+
+```bash
+cd qy_lty && python manage.py shell <<'EOF'
+from django.test import Client
+from aiapp.models import CredentialSlot
+from common.utils import mask_token
+import json
+
+ADMIN_TOKEN = "<填入 Step 1 拿到的 admin_token>"
+USER_TOKEN = "<填入 Step 1 拿到的 user_token>"
+URL = "/api/v1/admin/credential-slot/"
+
+c = Client()
+
+# === 验收点 1:GET 携 admin token 返回脱敏 ===
+r = c.get(URL, HTTP_AUTHORIZATION=f"Bearer {ADMIN_TOKEN}")
+body = r.json()
+assert r.status_code == 200, f"#1 status={r.status_code} body={body}"
+assert body['success'] is True
+assert body['code'] == 200
+assert 'data' in body
+assert {'app_id', 'access_token', 'updated_at'} <= set(body['data'].keys())
+# access_token 必须脱敏:要么是空(DB 为空时),要么以 * 开头且末 4 位为原 token 末 4 位
+slot = CredentialSlot.get_solo()
+expected_masked = mask_token(slot.access_token)
+assert body['data']['access_token'] == expected_masked, f"#1 mask mismatch: got={body['data']['access_token']!r} expected={expected_masked!r}"
+print("#1 PASS GET admin -> 200 + masked access_token")
+
+# === 验收点 2:PUT 携 admin token 全字段覆写 + 响应脱敏 ===
+new_token = "sk-phase2_verify_secret_ABCD1234"
+r = c.put(URL, data=json.dumps({"app_id": "phase2_app", "access_token": new_token}),
+ content_type="application/json", HTTP_AUTHORIZATION=f"Bearer {ADMIN_TOKEN}")
+body = r.json()
+assert r.status_code == 200, f"#2 status={r.status_code} body={body}"
+assert body['success'] is True
+slot = CredentialSlot.get_solo()
+assert slot.app_id == "phase2_app"
+assert slot.access_token == new_token # DB 中明文存储
+# 响应 access_token 必须脱敏:'sk-phase2_verify_secret_ABCD1234' -> '*****************************1234'
+assert body['data']['access_token'] == mask_token(new_token), f"#2 PUT response not masked: {body['data']['access_token']!r}"
+assert body['data']['access_token'].endswith('1234'), "末 4 位必须为 1234"
+assert body['data']['access_token'].startswith('*'), "脱敏前缀必须以 * 开头"
+print("#2 PASS PUT admin -> 200 + DB updated + response masked")
+
+# === 验收点 3:PUT 在空记录场景自动 get_or_create ===
+CredentialSlot.objects.all().delete()
+r = c.put(URL, data=json.dumps({"app_id": "after_delete", "access_token": "tok-XYZ9"}),
+ content_type="application/json", HTTP_AUTHORIZATION=f"Bearer {ADMIN_TOKEN}")
+body = r.json()
+assert r.status_code == 200, f"#3 status={r.status_code} body={body}"
+slot = CredentialSlot.get_solo()
+assert slot.app_id == "after_delete"
+assert slot.access_token == "tok-XYZ9"
+print("#3 PASS PUT 空记录场景 -> 200 + get_or_create 创建并写入")
+
+# === 验收点 4:无 Authorization 头 -> 401 + 标准壳层 ===
+r = c.get(URL)
+body = r.json()
+assert r.status_code == 401, f"#4 status={r.status_code} body={body}"
+assert body['success'] is False
+assert body['code'] == 401
+assert 'message' in body # 中间件 / DRF 至少要给一个 message 字段
+print("#4 PASS no token -> 401 + 标准壳层")
+
+# === 验收点 5:携普通 user token -> 403 + 标准壳层 + message 含管理员关键字 ===
+r = c.get(URL, HTTP_AUTHORIZATION=f"Bearer {USER_TOKEN}")
+body = r.json()
+assert r.status_code == 403, f"#5 status={r.status_code} body={body}"
+assert body['success'] is False
+assert body['code'] == 403
+assert "管理员" in body['message'], f"#5 message 不含'管理员'关键字: {body['message']!r}"
+print("#5 PASS user token -> 403 + 需要管理员权限")
+
+# === 验收点 6:PUT 携 user token -> 403(同 #5 路径,验证 PUT 也走 _ensure_admin) ===
+r = c.put(URL, data=json.dumps({"app_id": "should_fail"}),
+ content_type="application/json", HTTP_AUTHORIZATION=f"Bearer {USER_TOKEN}")
+body = r.json()
+assert r.status_code == 403, f"#6 PUT user token status={r.status_code}"
+print("#6 PASS PUT user token -> 403")
+
+print("\n========== 全部 6 条 test client 验收通过 ==========")
+EOF
+```
+
+**Step 3:Swagger schema 验收(启动 daphne 后做一次 curl)**
+
+```bash
+# 启动服务(如已运行可跳过)
+# cd qy_lty && daphne -b 0.0.0.0 -p 8000 qy_lty.asgi:application &
+
+curl -s http://localhost:8000/swagger.json | python -c "
+import json, sys
+schema = json.load(sys.stdin)
+paths = schema.get('paths', {})
+key = '/api/v1/admin/credential-slot/'
+assert key in paths, f'#7 {key} 未出现在 swagger paths 中'
+methods = paths[key]
+assert 'get' in methods, '#7 GET 方法缺失'
+assert 'put' in methods, '#7 PUT 方法缺失'
+# 验证 access_token description 含脱敏关键字
+get_resp = methods['get'].get('responses', {}).get('200', {})
+schema_str = json.dumps(get_resp, ensure_ascii=False)
+assert '脱敏' in schema_str or '末 4 位' in schema_str or 'mask' in schema_str.lower(), '#7 access_token description 不含脱敏掩码语义'
+print('#7 PASS swagger paths 含 GET/PUT 两 method + access_token 脱敏描述')
+"
+```
+
+**Step 4:把 8 条验收结果写入 `.planning/phases/02-admin-rest/02-VERIFICATION.md`**
+
+文件全文模板(直接 Write):
+
+```markdown
+# Phase 2 Verification — 管理端读写接口端到端验收
+
+**Verified**: 2026-05-07
+**Phase**: 02-admin-rest
+**Plan**: 02-02
+**Coverage**: CRED-03 + CRED-04(ROADMAP Phase 2 全部 4 条 success criteria)
+
+---
+
+## 验收摘要
+
+| # | 验收点 | 方法 | 结果 |
+|---|--------|------|------|
+| 1 | GET 携 admin token 返回脱敏壳层 | Django test client | ✓ PASS |
+| 2 | PUT 携 admin token 全字段覆写 + 响应脱敏 | Django test client | ✓ PASS |
+| 3 | PUT 在空记录场景自动 get_or_create | Django test client(手动 delete + PUT) | ✓ PASS |
+| 4 | 无 Authorization 头 → 401 + 标准壳层 | Django test client | ✓ PASS |
+| 5 | 携普通 user token → 403 + 需要管理员权限 | Django test client | ✓ PASS |
+| 6 | PUT 携 user token → 403(验证 PUT 也走 _ensure_admin) | Django test client | ✓ PASS |
+| 7 | /swagger.json 含路径条目 + GET/PUT 两 method + 脱敏 description | curl | ✓ PASS |
+| 8 | 修改记录两端互引(02-02 Task 2 落地后追加) | 文件 grep | ⏳ 待 02-02 Task 2 |
+
+---
+
+## 证据片段
+
+[黏贴 Step 1 / Step 2 / Step 3 输出的关键行]
+
+---
+
+*由 02-02-PLAN.md Task 1 生成*
+```
+
+**关键约束**:
+- 6 条 test client 验收必须**全部 PASS** 才能进 02-02 Task 2;任何一条 FAIL 则回到 02-01 修 view / serializer / URL
+- Swagger 验收(#7)若 daphne 未启动可暂时记 "deferred to integration",但必须在 phase gate 前补做
+- #8 在本 task 仅占位(标 ⏳),Task 2 落地修改记录后**回写**为 ✓ PASS(02-VERIFICATION.md 在 Task 2 末尾再 Edit 一次)
+- 不要把 admin / user token 明文写进 02-VERIFICATION.md(Redis 30 天 TTL,落进 git 的 token 在 30 天内仍有效,是新的泄露面)
+
+
+
+ cd qy_lty && python manage.py shell -c "from aiapp.models import CredentialSlot; from common.utils import mask_token; slot = CredentialSlot.get_solo(); print('DB state ok:', slot.app_id, mask_token(slot.access_token))" && test -f .planning/phases/02-admin-rest/02-VERIFICATION.md && grep -q "PASS" .planning/phases/02-admin-rest/02-VERIFICATION.md && echo OK
+
+
+
+ - Step 2 脚本输出"========== 全部 6 条 test client 验收通过 =========="
+ - Step 3 输出 "#7 PASS"(如 daphne 未启动则记 deferred 但必须在 SUMMARY 中列出补做计划)
+ - 文件 `.planning/phases/02-admin-rest/02-VERIFICATION.md` 存在
+ - 文件含至少 6 处 "PASS"(验收点 1-6)
+ - 文件不含 admin / user token 明文(grep `Bearer ` 应只出现在脚本模板段落、不含具体 UUID 字串)
+ - DB 状态符合预期:`CredentialSlot.get_solo()` 返回的 app_id 与 access_token 是 Step 2 写入的最新值
+
+
+ 8 条 success criteria 中 6 条 test client + 1 条 swagger 已验完并归档;DB 状态稳定;02-VERIFICATION.md 是 Phase 2 收尾时 ROADMAP 标记 ✓ Complete 的证据来源。
+
+
+
+
+ Task 2:两端修改记录互引条目(qy_lty + qy-lty-admin)
+ qy_lty/docs/修改记录.md, qy-lty-admin/docs/修改记录.md
+
+ - 必读 qy_lty/docs/修改记录.md:1-90(确认头部「修改格式说明」+ 第 24 行 `` 注释 + Phase 1 已有两条条目作模板)
+ - 必读 qy-lty-admin/docs/修改记录.md:1-58(确认头部「修改格式说明」+ 第 26 行 `` + 第 28-44 行 NEXT_PUBLIC_API_BASE_URL 修复条目作"含跨项目说明的"模板)
+ - 必读 qy_lty/CLAUDE.md `## 项目修改记录规则(重要 — 自动执行)` 段(5 字段格式 + 跨项目联动两端各一条互引规则)
+ - 必读 .planning/phases/02-admin-rest/02-01-SUMMARY.md(Phase 2 实际改动文件清单 + 接口契约)
+ - 必读 02-CONTEXT.md `` 跨项目联动段(互引文案要点)
+
+
+**Step 1:在 `qy_lty/docs/修改记录.md` 顶部(紧跟第 24 行注释 `` 之下、Phase 1 第一条 `### [2026-05-07] Phase 1 — Django Admin 注册凭据槽位...` 之上)插入条目**:
+
+```markdown
+### [2026-05-07] Phase 2 — 管理端通用凭据槽位 REST 接口(GET 脱敏 / PUT 覆写)
+
+配套 Phase:[.planning/phases/02-admin-rest/](.planning/phases/02-admin-rest/)
+覆盖需求:CRED-03 + CRED-04
+设计参考:1:1 复刻 `aiapp.views.RTCChatHistoryAPIView`(`aiapp/views.py:434-555`)的单 URL 多方法 APIView 风格
+
+- **文件路径**:
+ - `aiapp/serializers.py`(修改 — 顶部 import 追加 `CredentialSlot`,文件末尾追加 `CredentialSlotSerializer` ModelSerializer 类)
+ - `aiapp/views.py`(修改 — 顶部 import 追加 `CredentialSlot` / `CredentialSlotSerializer` / `mask_token` / `get_standardized_response_schema`;文件末尾追加 `CredentialSlotPutRequestSchema` swagger 请求体 + `_credential_slot_data_schema` 响应 data schema + `CredentialSlotAdminView` APIView 类)
+ - `userapp/admin_urls.py`(修改 — 追加 `from aiapp.views import CredentialSlotAdminView` 与 `path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot')`)
+- **修改类型**: 新增
+- **修改内容**:
+ - 暴露 `GET /api/v1/admin/credential-slot/`:admin token 鉴权(`RedisTokenAuthentication` + 视图内 `is_staff` 二次校验,不发明 admin-only permission 类);返回 `{ success, code, message, data: { app_id, access_token: <末 4 位脱敏掩码>, updated_at } }`,脱敏由 view 层调 `common.utils.mask_token` 完成(serializer 不参与脱敏,避免双重责任)
+ - 暴露 `PUT /api/v1/admin/credential-slot/`:admin token 鉴权;接受 `{ app_id, access_token }` 全字段覆写;空记录场景自动走 `CredentialSlot.get_solo()` 的 `get_or_create(pk=1)`;写入后 `updated_at` 由 `auto_now=True` 自动刷新;响应同样脱敏 access_token(避免运营在 admin UI 看到自己刚提交的明文回显)
+ - 鉴权拒绝矩阵:无 token → 401(DRF NotAuthenticated → middleware 兜底标准壳层);持普通 user token(非 staff)→ 403 + `message="需要管理员权限"`
+ - Swagger / ReDoc 自动暴露:method-level `@swagger_auto_schema` 装饰器;响应 schema 配 `common.swagger_utils.get_standardized_response_schema()`;access_token 字段 description 显式标注「Access Token 末 4 位脱敏掩码(如 "*********1234")」,避免前端误解为明文
+ - 不引入新依赖(沿用 Django 4.2.13 + DRF + drf-yasg + Phase 1 落地的 `CredentialSlot.get_solo` / `mask_token`)
+- **修改原因**: Milestone v1.0「通用凭据槽位(APP ID + Access Token)」Phase 2 — 给管理后台前端(qy-lty-admin)暴露受控的凭据读写入口,让运营无需进 Django Admin 也能管理凭据;GET 与 PUT 响应均脱敏,避免明文经管理端 UI / 浏览器 devtools / 阿里云日志(GET 响应体路径)泄露;为 Phase 3 客户端明文 GET 接口 + 阿里云日志 formatter 提供"接口已上线、凭据可写入"的稳定起点
+- **跨项目联动**: 前端联动条目 [qy-lty-admin/docs/修改记录.md](../../qy-lty-admin/docs/修改记录.md) 同期 `[2026-05-07] Phase 2 — 锁定后端通用凭据槽位 REST 接口契约(消费方文档化)`。本 phase 是 Milestone v1.0 首次跨项目接口契约落地:本仓库(服务端)暴露 `/api/v1/admin/credential-slot/` GET/PUT,前端 `qy-lty-admin` 后续 phase 将基于该契约写 API client(含 React Hooks 调用 + 表单录入 UI)。前后端各自维护独立修改记录,本条与对方条目互相引用,便于未来回查接口的双向上下游
+```
+
+**Step 2:在 `qy-lty-admin/docs/修改记录.md` 顶部(紧跟第 26 行注释 `` 之下、第 28 行 `### [2026-05-07] 修复 NEXT_PUBLIC_API_BASE_URL...` 之上)插入条目**:
+
+```markdown
+### [2026-05-07] Phase 2 — 锁定后端通用凭据槽位 REST 接口契约(消费方文档化)
+
+配套服务端 Phase:[../qy_lty/.planning/phases/02-admin-rest/](../../qy_lty/.planning/phases/02-admin-rest/)
+覆盖服务端需求:CRED-03 + CRED-04(本仓库消费方)
+
+- **文件路径**: `docs/修改记录.md`(仅文档新增条目;本仓库代码未改)
+- **修改类型**: 新增
+- **修改内容**:
+ - 文档化服务端在本日落地的 `/api/v1/admin/credential-slot/` REST 接口契约(GET 脱敏读取 + PUT 全字段覆写 + admin token 鉴权),为后续 Web 管理后台前端(本仓库)的 CRED-FE-* phase 写 API client 与表单 UI 留下契约锚点
+ - 接口契约要点(消费方视角):
+ - URL:`{NEXT_PUBLIC_API_BASE_URL}/v1/admin/credential-slot/`
+ - 鉴权:`Authorization: Bearer `(来源于 `/api/v1/admin/login/` 的现有 admin 登录返回值)
+ - GET 响应:`{ success: boolean, code: number, message: string, data: { app_id: string, access_token: string /* 末 4 位脱敏掩码,前缀 * */, updated_at: string /* ISO 8601 */ } }`
+ - PUT 请求体:`{ app_id?: string, access_token?: string }`(任一字段缺省时由后端兜底保留原值;写入是全字段覆写语义,建议前端 UI 始终提交两字段全集以避免歧义)
+ - PUT 响应同 GET 形态(access_token 同样脱敏返回,前端**不应**用响应值回填明文输入框)
+ - 错误矩阵:401(无 token / token 失效)、403(持非 admin token,message 含"需要管理员权限")、400(参数无效)
+- **修改原因**:
+ - 服务端首次为本管理后台暴露受控的凭据读写接口;本仓库即将启动 CRED-FE-01(API client) + CRED-FE-02(表单录入页面)等 phase,先把后端契约固化进本仓库修改记录便于反查
+ - 文档化"GET 与 PUT 响应均脱敏 access_token"避免前端工程师误以为可以从响应回填明文表单(实际明文仅存于 DB;任何回填只能保留掩码或要求运营重新输入)
+- **服务端联动**: 后端联动条目 [../qy_lty/docs/修改记录.md](../../qy_lty/docs/修改记录.md) 同期 `[2026-05-07] Phase 2 — 管理端通用凭据槽位 REST 接口(GET 脱敏 / PUT 覆写)`。本仓库代码未改,仅文档侧做契约固化;待本仓库 CRED-FE-01 phase 启动落地 API client + Hook 时再补一条独立条目并互引
+```
+
+**Step 3:互引校验(写完后立即 grep)**
+
+```bash
+# 验证两端互引字段都在
+grep -n "qy-lty-admin/docs/修改记录" qy_lty/docs/修改记录.md | head -3
+grep -n "qy_lty/docs/修改记录" qy-lty-admin/docs/修改记录.md | head -3
+# 验证两边都标了 Phase 2 + 2026-05-07
+grep -n "Phase 2.*2026-05-07\|2026-05-07.*Phase 2" qy_lty/docs/修改记录.md qy-lty-admin/docs/修改记录.md
+```
+
+**Step 4:回写 02-VERIFICATION.md 验收点 #8**
+
+把 Task 1 写入的 02-VERIFICATION.md 中验收点 #8 的状态从 `⏳ 待 02-02 Task 2` 改为 `✓ PASS`,并追加证据:
+```
+| 8 | 修改记录两端互引 | grep `qy-lty-admin/docs/修改记录` qy_lty/docs/修改记录.md ✓;grep `qy_lty/docs/修改记录` qy-lty-admin/docs/修改记录.md ✓ | ✓ PASS |
+```
+
+**关键约束**:
+- 两条条目必须用日期 `2026-05-07`(与 Phase 1 / 当前 currentDate 一致;从 system context 得知)
+- 两条条目都必须含**跨项目联动 / 服务端联动**字段,且字段值必须**指向对方文件路径**(不能写"无",Phase 1 那样的"暂无前端"逻辑在 Phase 2 不成立 —— 本 phase 就是首次跨项目接口契约落地)
+- 服务端条目中的相对路径用 `../../qy-lty-admin/docs/修改记录.md`(位于 `qy_lty/docs/`,跳到 `qy_lty/` 上级 `Lila-Server/` 再进 `qy-lty-admin/docs/`)
+- 前端条目中的相对路径用 `../../qy_lty/docs/修改记录.md`(同理对称)
+- 两条条目都要插在各自文件的「修改历史」段顶部(最新在最前),不要追加到末尾
+- **不要**修改 Phase 1 已有的两条条目(Phase 1 当时纯服务端、无前端互引是合理的,不要回头改成"互引"破坏历史归档)
+- **不要**在 `qy-lty-admin` 仓库改任何代码(本 task 仅在 qy-lty-admin 仓库内动 docs 一个文件)
+
+
+
+ grep -c "Phase 2 — 管理端通用凭据槽位 REST 接口" "C:\Users\admin\Desktop\Lila-Server\qy_lty\docs\修改记录.md" && grep -c "Phase 2 — 锁定后端通用凭据槽位 REST 接口契约" "C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\docs\修改记录.md" && grep -q "qy-lty-admin/docs/修改记录" "C:\Users\admin\Desktop\Lila-Server\qy_lty\docs\修改记录.md" && grep -q "qy_lty/docs/修改记录" "C:\Users\admin\Desktop\Lila-Server\qy-lty-admin\docs\修改记录.md" && echo OK
+
+
+
+ - `qy_lty/docs/修改记录.md` 顶部「修改历史」段第一条标题为 `### [2026-05-07] Phase 2 — 管理端通用凭据槽位 REST 接口(GET 脱敏 / PUT 覆写)`
+ - 该条目含 5 个标准字段(文件路径 / 修改类型 / 修改内容 / 修改原因 / 跨项目联动)
+ - 该条目"跨项目联动"字段含字串 `qy-lty-admin/docs/修改记录.md`(指向前端互引)
+ - `qy-lty-admin/docs/修改记录.md` 顶部「修改历史」段第一条标题为 `### [2026-05-07] Phase 2 — 锁定后端通用凭据槽位 REST 接口契约(消费方文档化)`
+ - 该条目含 5 个标准字段(文件路径 / 修改类型 / 修改内容 / 修改原因 / 服务端联动)
+ - 该条目"服务端联动"字段含字串 `qy_lty/docs/修改记录.md`(指向后端互引)
+ - 两条条目互相引用对方(grep 双向均命中 → 闭环)
+ - Phase 1 的两条条目位置不变(位于本次新增条目之下)
+ - 02-VERIFICATION.md 中验收点 #8 已改为 ✓ PASS
+
+
+ `qy_lty` + `qy-lty-admin` 两端 `docs/修改记录.md` 顶部各有一条 Phase 2 条目,互相引用对方文件路径;CLAUDE.md "qy_lty 与 qy-lty-admin 是独立项目,跨项目联动两端各写一条互相引用对方"规则在本 phase 闭环;02-VERIFICATION.md 8 条全 PASS。
+
+
+
+
+
+
+本 plan 完成后做一轮 phase-gate 自验:
+
+1. **8 条 success criteria 全 PASS**:02-VERIFICATION.md 表格全部 ✓
+2. **互引闭环**:
+ - `grep "qy-lty-admin" qy_lty/docs/修改记录.md` ≥ 1 hit(在 Phase 2 条目内)
+ - `grep "qy_lty" qy-lty-admin/docs/修改记录.md` ≥ 1 hit(在 Phase 2 条目内)
+3. **ROADMAP Phase 2 4 条 success criteria 已覆盖**:
+ - SC#1 GET 脱敏 ← 验收点 #1 ✓
+ - SC#2 PUT 全字段覆写 + get_or_create ← 验收点 #2 + #3 ✓
+ - SC#3 鉴权拒绝矩阵 ← 验收点 #4 + #5 + #6 ✓
+ - SC#4 Swagger / ReDoc schema 一致 ← 验收点 #7 ✓
+4. **Phase 1 条目未被改动**:`git diff qy_lty/docs/修改记录.md` 仅显示新增条目(在文件顶部追加),Phase 1 已有的两条 `[2026-05-07] Phase 1 — ...` 标题在 diff 内位置应该是"unchanged"
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| 端到端 verify 测试 token | 临时 admin / user token,30 天 TTL;不允许写入仓库 |
+| 跨项目修改记录文件 | 文档边界;不修改对方仓库代码,仅在对方 docs/ 下追加一条文档 |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02P2-01 | Information Disclosure | 验收脚本中的 admin / user token 误入 git | mitigate | 02-VERIFICATION.md 仅记录脚本**模板**与"PASS"判定;不黏贴具体 token UUID;脚本输出在 SUMMARY 中也只摘要"6/6 PASS"不贴 token |
+| T-02P2-02 | Information Disclosure | 验收用真实凭据写入 DB(覆盖 Phase 1 探针) | accept | DB 中 access_token 本来就是明文存储(Phase 1 落地决策);PUT 写入的 `sk-phase2_verify_secret_ABCD1234` 不是真实第三方凭据,只是测试串;Task 1 Step 2 #3 还会再 delete + 重建覆盖一次 |
+| T-02P2-03 | Tampering | 误修改 Phase 1 修改记录条目 | mitigate | Task 2 关键约束第 7 条明确"不要修改 Phase 1 已有的两条条目";verify 步骤 #4 用 git diff 校验 Phase 1 条目位置不变 |
+| T-02P2-04 | Information Disclosure | 互引文档泄露内部路径 | accept | `.planning/` 已是仓库内文档,路径暴露与本仓库 README 同等;不引入新泄露面 |
+
+
+
+本 plan 落地完成的标志(phase 收尾标志):
+
+- [ ] `.planning/phases/02-admin-rest/02-VERIFICATION.md` 存在,8 条验收点 6+1+1 = 8 全 ✓
+- [ ] `qy_lty/docs/修改记录.md` 顶部含 `### [2026-05-07] Phase 2 — 管理端通用凭据槽位 REST 接口(GET 脱敏 / PUT 覆写)`
+- [ ] `qy-lty-admin/docs/修改记录.md` 顶部含 `### [2026-05-07] Phase 2 — 锁定后端通用凭据槽位 REST 接口契约(消费方文档化)`
+- [ ] 两条互引字段双向命中:`grep "qy-lty-admin/docs/修改记录" qy_lty/docs/修改记录.md` ≥ 1;`grep "qy_lty/docs/修改记录" qy-lty-admin/docs/修改记录.md` ≥ 1
+- [ ] DB 中 CredentialSlot 单例存在且 access_token 是 Task 1 #3 写入的最终值(`tok-XYZ9` 或之后被 #3 覆盖的某个值)
+- [ ] Phase 1 两条修改记录条目未被改动(git diff 确认)
+- [ ] ROADMAP Phase 2 4 条 success criteria 在 02-VERIFICATION.md 中均找到对应的 ✓ 验收点映射
+
+
+