--- 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) 完成后创建 `.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 程序化验收脚本片段)