561 lines
29 KiB
Markdown
561 lines
29 KiB
Markdown
---
|
||
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"
|
||
---
|
||
|
||
<objective>
|
||
本 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 位脱敏掩码语义
|
||
</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 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): ... # 全字段覆写 + 脱敏响应
|
||
```
|
||
</interfaces>
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 1:新增 CredentialSlotSerializer(aiapp/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 2:DRF 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 层不放 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='' 不一致)
|
||
</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-only;GET 用它返回 .data 含明文(view 层稍后脱敏),PUT 用它做 is_valid + save。
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 2:新增 CredentialSlotAdminView(aiapp/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-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` 签名)
|
||
</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 子 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"统一约定不一致)
|
||
</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:注册 URL(userapp/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 / 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 ✓
|
||
</verification>
|
||
|
||
<threat_model>
|
||
## 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 完成后此项关闭 |
|
||
</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 hook(curl 命令模板 + Django shell 程序化验收脚本片段)
|
||
</output>
|