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