---
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>
