docs(02): Phase 2 PLAN.md ×2(02-01 view+serializer+url+swagger / 02-02 双写互引修改记录 + 端到端 verify),plan-checker PASS(无 BLOCKER)

This commit is contained in:
pmc 2026-05-07 18:34:21 +08:00
parent 7452b35a0f
commit 57199483f7
3 changed files with 1028 additions and 2 deletions

View File

@ -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 + viewGET 脱敏 / 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 | - |
---

View File

@ -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 头返回 401DRF 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"
---
<objective>
本 plan 在 `/api/v1/admin/credential-slot/` 暴露 GET脱敏+ PUT全字段覆写两个端点覆盖 CRED-03 + CRED-04。
Purpose
- CRED-03管理后台读取脱敏后的凭据槽位仅末 4 位明文,避免运营在 admin UI 看到完整明文)
- CRED-04管理后台以全字段覆写方式更新凭据槽位PUT 响应同样走脱敏避免明文回显
Output
- 新增 `CredentialSlotSerializer`ModelSerializer3 字段 + read_only_fields + extra_kwargs allow_blank
- 新增 `CredentialSlotAdminView`(自定义 APIView + 手写 GET/PUT1:1 复刻 RTCChatHistoryAPIView 风格)
- 在 `userapp/admin_urls.py` 追加 `path('credential-slot/', ...)` 注册
- View 方法挂 `@swagger_auto_schema` 装饰器,响应 schema 显式标注 access_token 末 4 位脱敏掩码语义
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- 关键契约。Executor 直接照用,不要再去 grep。 -->
【已存在 — 复用】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 <token>,调 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 Schematype=OBJECTproperties=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): ... # 全字段覆写 + 脱敏响应
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1新增 CredentialSlotSerializeraiapp/serializers.py</name>
<files>qy_lty/aiapp/serializers.py</files>
<read_first>
- 必读 qy_lty/aiapp/serializers.py 全文(仅 9 行;现有 ChatMessageSerializer 是同款 ModelSerializer 模板)
- 必读 qy_lty/aiapp/models.py 中 CredentialSlot 模型定义(验证字段名 app_id / access_token / updated_at
- 必读 02-RESEARCH.md `Pattern 2DRF ModelSerializer 写法` 段落(含完整骨架)
</read_first>
<action>
`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 层不放 serializerPUT 路径需要明文走 is_valid + saveserializer
不应承担"既要明文又要脱敏"的双重责任。
- 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='' 不一致)
</action>
<verify>
<automated>
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()))"
</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
`aiapp/serializers.py` 含 CredentialSlotSerializer 类,三字段对齐模型,`updated_at` read-onlyGET 用它返回 .data 含明文view 层稍后脱敏PUT 用它做 is_valid + save。
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2新增 CredentialSlotAdminViewaiapp/views.py 末尾追加)</name>
<files>qy_lty/aiapp/views.py</files>
<read_first>
- 必读 qy_lty/aiapp/views.py:1-18顶部 import 区,确认现有 import 结构)
- 必读 qy_lty/aiapp/views.py:434-555RTCChatHistoryAPIView 完整段;本 view 1:1 复刻其单 URL 多方法骨架)
- 必读 qy_lty/userapp/views.py:705-823AdminEmailLoginView + AdminLogoutViewadmin-only 二次校验 `is_staff` 模板line 748-754
- 必读 qy_lty/userapp/views.py:722-730method-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` 签名)
</read_first>
<action>
**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 子 schemaaccess_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.mdGET 与 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_tokenserializer.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_tokenper Pitfall 3
- 不要新增 `IsAdminTokenAuthenticated` permission 类仓库零先例与现有约定相悖per RESEARCH.md "Anti-Patterns"
- 不要把 `_ensure_admin` 改成 `permission_classes = [IsAdminUser]`DRF 自带 IsAdminUser 也是 is_staff但与本仓库"视图内手写 is_staff"统一约定不一致)
</action>
<verify>
<automated>
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')"
</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
`aiapp/views.py` 末尾追加 `CredentialSlotAdminView` 类(含 GET/PUT 两方法 + admin 二次校验 + 脱敏 helper顶部 import 已对齐view 可被 import 不报错。
</done>
</task>
<task type="auto" tdd="false">
<name>Task 3注册 URLuserapp/admin_urls.py 追加 path</name>
<files>qy_lty/userapp/admin_urls.py</files>
<read_first>
- 必读 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
</read_first>
<action>
**完整修改后 `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
</action>
<verify>
<automated>
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'))")
</automated>
</verify>
<acceptance_criteria>
- `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 注册(汇总点单一)
</acceptance_criteria>
<done>
`/api/v1/admin/credential-slot/` URL 可被 reverse 解析到 `CredentialSlotAdminView`Django check 通过;与 login/logout 在同一 admin namespace 注册块内。
</done>
</task>
</tasks>
<verification>
本 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 / WARNINGSW 级 noise 可忽略)
4. **Swagger schema 暴露(运行时验证留 02-02**:本 plan 不启动 daphne但通过 import 检查保证 `@swagger_auto_schema` 装饰器无语法错误
**reachability self-check**goal-backward
- truth #1GET 脱敏 200→ artifact: views.py CredentialSlotAdminView.get + serializers.py CredentialSlotSerializer + admin_urls.py path → reachable ✓
- truth #2PUT 全字段覆写 + updated_at 刷新 + 响应脱敏)→ artifact: views.py CredentialSlotAdminView.putserializer.save + _build_response_data→ reachable ✓
- truth #3PUT 在空记录场景 get_or_create→ artifact: CredentialSlot.get_solo()Phase 1 落地,本 plan 调用)→ reachable ✓
- truth #4(无 token → 401→ artifact: RedisTokenAuthenticationPhase 0 已在)+ DRF NotAuthenticated → reachable ✓
- truth #5user token → 403→ artifact: views.py `_ensure_admin` is_staff 校验 → reachable ✓
- truth #6swagger 路径条目 + access_token 脱敏 description→ artifact: views.py @swagger_auto_schema + _credential_slot_data_schema description → reachable ✓
</verification>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 公网 → /api/v1/admin/credential-slot/ | 来自 qy-lty-admin Web UI 的 HTTP 请求untrusted Authorization headeruntrusted PUT body |
| view → DBCredentialSlot 表) | 内部信任边界;写入前必须经过 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` 早返回 403CONTEXT.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+ transferPhase 3 | 本 phase 仅做响应脱敏;阿里云 access log 链路过滤由 Phase 3 落地CRED-06Phase 3 完成后此项关闭 |
</threat_model>
<success_criteria>
本 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
</success_criteria>
<output>
完成后创建 `.planning/phases/02-admin-rest/02-01-SUMMARY.md`,记录:
- 改动文件清单aiapp/serializers.py / aiapp/views.py / userapp/admin_urls.py
- 实际落地的 view 类全名 / 路由 path / 调用 mask_token 的位置
- 任何与本 PLAN 不一致的偏离(应为零;如有偏离说明原因)
- 留给 02-02 的端到端 verify hookcurl 命令模板 + Django shell 程序化验收脚本片段)
</output>

View File

@ -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 两 methodaccess_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/修改记录"
---
<objective>
本 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 criteriaGET 脱敏 / 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 中归档)
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1端到端 curl + Django shell 验收8 条 success criteria</name>
<files>qy_lty/.planning/phases/02-admin-rest/02-VERIFICATION.md</files>
<read_first>
- 必读 .planning/phases/02-admin-rest/02-01-SUMMARY.md02-01 留给本 task 的 hook
- 必读 .planning/phases/02-admin-rest/02-CONTEXT.md `<specifics>`8 条验收点表格)
- 必读 .planning/phases/02-admin-rest/02-RESEARCH.md `Validation Architecture`test client 程序化验收脚本片段 + 采样频率)
- 必读 qy_lty/userapp/utils.py:33-37generate_token 签发 admin / user token 的方式)
- 必读 qy_lty/aiapp/models.py 的 CredentialSlot.get_solo验证 PUT 在空记录场景的 get_or_create
- 必读 .planning/phases/01-credential-data-layer/01-VERIFICATION.mdPhase 1 程序化验收风格,本 task 1:1 沿用)
</read_first>
<action>
**目标**:在 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 2Django 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()
# === 验收点 1GET 携 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")
# === 验收点 2PUT 携 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")
# === 验收点 3PUT 在空记录场景自动 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 + 需要管理员权限")
# === 验收点 6PUT 携 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 3Swagger 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-04ROADMAP 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 落地修改记录后**回写**为 ✓ PASS02-VERIFICATION.md 在 Task 2 末尾再 Edit 一次)
- 不要把 admin / user token 明文写进 02-VERIFICATION.mdRedis 30 天 TTL落进 git 的 token 在 30 天内仍有效,是新的泄露面)
</action>
<verify>
<automated>
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
</automated>
</verify>
<acceptance_criteria>
- 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 写入的最新值
</acceptance_criteria>
<done>
8 条 success criteria 中 6 条 test client + 1 条 swagger 已验完并归档DB 状态稳定02-VERIFICATION.md 是 Phase 2 收尾时 ROADMAP 标记 ✓ Complete 的证据来源。
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2两端修改记录互引条目qy_lty + qy-lty-admin</name>
<files>qy_lty/docs/修改记录.md, qy-lty-admin/docs/修改记录.md</files>
<read_first>
- 必读 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.mdPhase 2 实际改动文件清单 + 接口契约)
- 必读 02-CONTEXT.md `<decisions>` 跨项目联动段(互引文案要点)
</read_first>
<action>
**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 → 401DRF 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 <admin_token>`(来源于 `/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 tokenmessage 含"需要管理员权限"、400参数无效
- **修改原因**:
- 服务端首次为本管理后台暴露受控的凭据读写接口;本仓库即将启动 CRED-FE-01API 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 一个文件)
</action>
<verify>
<automated>
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
</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
`qy_lty` + `qy-lty-admin` 两端 `docs/修改记录.md` 顶部各有一条 Phase 2 条目互相引用对方文件路径CLAUDE.md "qy_lty 与 qy-lty-admin 是独立项目,跨项目联动两端各写一条互相引用对方"规则在本 phase 闭环02-VERIFICATION.md 8 条全 PASS。
</done>
</task>
</tasks>
<verification>
本 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"
</verification>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 端到端 verify 测试 token | 临时 admin / user token30 天 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 同等;不引入新泄露面 |
</threat_model>
<success_criteria>
本 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 中均找到对应的 ✓ 验收点映射
</success_criteria>
<output>
完成后创建 `.planning/phases/02-admin-rest/02-02-SUMMARY.md`,记录:
- 端到端验收 8 条结果PASS / FAIL 计数)
- 两端修改记录条目的最终位置(行号 / 标题)
- 与 02-01-SUMMARY 合并形成 Phase 2 完整交付证据
- DB 状态最终值app_id / access_token 末 4 位 / updated_at
- 任何与本 PLAN 不一致的偏离(应为零)
- 标识 Phase 2 整体 Complete下一步进入 Phase 3CRED-05 + CRED-06
</output>