---
phase: 02-admin-rest
plan: 01
subsystem: aiapp + userapp（admin namespace 路由）
tags: [credential-slot, admin-api, drf, mask, swagger, rest]
requirements_completed:
  - CRED-03
  - CRED-04
dependency_graph:
  requires:
    - aiapp.models.CredentialSlot（Phase 1 / Plan 01-01 落地）
    - aiapp.models.CredentialSlot.get_solo()（Phase 1 / Plan 01-01 落地）
    - common.utils.mask_token（Phase 1 / Plan 01-01 落地）
    - common.responses.success_response / error_response（已存在）
    - common.swagger_utils.get_standardized_response_schema（已存在）
    - userapp.authentication.RedisTokenAuthentication（已存在）
  provides:
    - aiapp.serializers.CredentialSlotSerializer（DRF ModelSerializer，3 字段）
    - aiapp.views.CredentialSlotAdminView（APIView，GET/PUT 两端点）
    - URL: /api/v1/admin/credential-slot/（name='admin_credential_slot'）
  affects:
    - 下一 plan：02-02-PLAN（端到端 verify + 修改记录两端互引）
tech_stack:
  added: []
  patterns:
    - DRF 自定义 APIView 单 URL 多方法（1:1 复刻 RTCChatHistoryAPIView）
    - permission_classes=[IsAuthenticated] + view 内 _ensure_admin 二次校验 is_staff
      （沿用 AdminEmailLoginView / AdminLogoutView 模式，不发明 IsAdminTokenAuthenticated）
    - 脱敏在 view 层 _build_response_data helper 完成，不在 serializer 层
    - GET 与 PUT 响应都走 _build_response_data，避免 PUT 明文回显
    - method-level @swagger_auto_schema 装饰器 + access_token 字段 description
      显式标注脱敏掩码语义
key_files:
  created: []
  modified:
    - qy_lty/aiapp/serializers.py
    - qy_lty/aiapp/views.py
    - qy_lty/userapp/admin_urls.py
decisions:
  - "View 1:1 复刻 RTCChatHistoryAPIView 风格：不走 RetrieveUpdateAPIView（仓库零先例）"
  - "permission_classes=[IsAuthenticated] + 视图内 _ensure_admin 二次校验：与 AdminEmailLoginView/AdminLogoutView 一致；不发明 IsAdminTokenAuthenticated permission 类"
  - "脱敏放 view 层不放 serializer：PUT 路径需明文走 is_valid + save，serializer 只做字段校验，避免双重责任"
  - "GET 与 PUT 响应都强制走 _build_response_data：避免 PUT 直接 return success_response(data=serializer.data) 导致刚提交的明文回显（Pitfall 3）"
  - "drf-yasg request body 用独立 CredentialSlotPutRequestSchema serializer 类（与 userapp/views.py:705-708 AdminEmailLoginRequestSchema 模式一致）：与实际写入校验的 CredentialSlotSerializer 解耦"
  - "method-level @swagger_auto_schema：在 GET 与 PUT 各挂一份；access_token 响应字段 description 明示末 4 位脱敏掩码"
metrics:
  duration_seconds: 216
  tasks_completed: 3
  files_modified: 3
  commits:
    - 6820fe7 feat(02-01): 新增 CredentialSlotSerializer
    - 192d0a1 feat(02-01): 新增 CredentialSlotAdminView（GET 脱敏 / PUT 全字段覆写）
    - 9d02021 feat(02-01): 注册 /api/v1/admin/credential-slot/ 路由
  completed_date: 2026-05-07
---

# Phase 2 Plan 02-01：管理端 REST 接口（serializer + view + URL + Swagger）Summary

在 `/api/v1/admin/credential-slot/` 暴露 GET（脱敏读取）+ PUT（全字段覆写）两个 admin token 鉴权端点，全部沿用仓库现有 RTCChatHistoryAPIView / AdminEmailLoginView 模式，零新依赖。

## 一句话概述

新增 `CredentialSlotSerializer` + `CredentialSlotAdminView`（GET/PUT），在 `userapp/admin_urls.py` 注册到 `/api/v1/admin/credential-slot/`，view 层 `mask_token` 脱敏 access_token，覆盖 CRED-03 / CRED-04。

## 改动文件清单

| 文件 | 类型 | 描述 |
|------|------|------|
| `qy_lty/aiapp/serializers.py` | 修改 | import 区追加 `CredentialSlot`；文件末尾追加 `CredentialSlotSerializer`（ModelSerializer，3 字段） |
| `qy_lty/aiapp/views.py` | 修改 | import 区追加 `CredentialSlot` / `CredentialSlotSerializer` / `mask_token` / `get_standardized_response_schema`；文件末尾追加 `CredentialSlotPutRequestSchema`（drf-yasg 请求体 schema）+ `_credential_slot_data_schema`（响应 data 子 schema）+ `CredentialSlotAdminView`（含 `_ensure_admin` / `_build_response_data` / GET / PUT 4 个方法） |
| `qy_lty/userapp/admin_urls.py` | 修改 | 顶部 import `CredentialSlotAdminView`；urlpatterns 追加 `path('credential-slot/', ..., name='admin_credential_slot')` |

## 实际落地的关键产物

### Serializer

```python
# qy_lty/aiapp/serializers.py
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},
        }
```

### View

`qy_lty/aiapp/views.py` 文件末尾：

- `CredentialSlotPutRequestSchema(serializers.Serializer)` — drf-yasg 请求体 schema
- `_credential_slot_data_schema = openapi.Schema(...)` — 响应 data 子 schema（access_token description 明示末 4 位脱敏掩码）
- `CredentialSlotAdminView(APIView)`：
  - `authentication_classes = [RedisTokenAuthentication]`
  - `permission_classes = [IsAuthenticated]`
  - `_ensure_admin(request)` — `if not request.user.is_staff: return error_response(code=403, status_code=403)`
  - `_build_response_data(instance)` — 调 `mask_token(instance.access_token)` 覆盖明文
  - `get(request)` / `put(request)` — 各挂 `@swagger_auto_schema`

### URL

```python
# qy_lty/userapp/admin_urls.py
from aiapp.views import CredentialSlotAdminView
urlpatterns = [
    ...,
    path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot'),
]
```

完整路径：`/api/v1/admin/credential-slot/`（拼接自 `qy_lty/urls.py:59` 的 `path('v1/admin/', include('userapp.admin_urls'))` + `path('credential-slot/', ...)`）。

### 调用 mask_token 的位置（脱敏调用点）

`qy_lty/aiapp/views.py:637`：

```python
data['access_token'] = mask_token(instance.access_token)
```

`_build_response_data` 是 GET 与 PUT 唯一的响应数据构造点，强制脱敏。

## Plan 内自验证据

| 验证点 | 命令 | 结果 |
|--------|------|------|
| import 链路 | `from aiapp.views import CredentialSlotAdminView; from aiapp.serializers import CredentialSlotSerializer` | OK 无 ImportError |
| URL 解析 | `reverse('admin_credential_slot')` | `/api/v1/admin/credential-slot/` |
| Django check | `python manage.py check` | 仅 1 条 W004（STATICFILES_DIRS）— 预先存在，与本 plan 无关 |
| Serializer 字段 | 实例化 `pk=1` 后 `.data.keys()` | `['app_id', 'access_token', 'updated_at']` |
| Serializer read_only_fields | `.fields['updated_at'].read_only` | `True` |
| Serializer allow_null/allow_blank | `.fields['app_id'/'access_token']` | `allow_blank=True, allow_null=False` |
| View 鉴权 / 权限链 | `CredentialSlotAdminView.authentication_classes / permission_classes` | `[RedisTokenAuthentication]` / `[IsAuthenticated]` |
| View 4 个 method 完整性 | `hasattr(v, 'get'/'put'/'_ensure_admin'/'_build_response_data')` | 全部 True |
| Swagger 装饰器 | `hasattr(get_method/put_method, '_swagger_auto_schema')` | 全部 True |
| 探针脱敏 | `mask_token('probe_secret_xxxx')` | `*************xxxx`（13 stars + xxxx；len=17） |

### Goal-backward reachability self-check

- truth #1（GET 脱敏 200）→ `views.py CredentialSlotAdminView.get` + `serializers.py CredentialSlotSerializer` + `admin_urls.py path` → reachable ✓
- truth #2（PUT 全字段覆写 + updated_at 刷新 + 响应脱敏）→ `views.py CredentialSlotAdminView.put`（`serializer.save` + `_build_response_data`）→ reachable ✓
- truth #3（PUT 在空记录场景 get_or_create）→ `CredentialSlot.get_solo()`（Phase 1 已在）→ reachable ✓
- truth #4（无 token → 401）→ `RedisTokenAuthentication` + DRF `NotAuthenticated` → reachable ✓
- truth #5（user token → 403）→ `_ensure_admin` `is_staff` 校验 → reachable ✓
- truth #6（swagger 路径条目 + access_token 脱敏 description）→ `@swagger_auto_schema` + `_credential_slot_data_schema` description → reachable ✓

端到端 curl 验收（含 admin token 签发 / user token 拒绝 / swagger.json 校验）由 Plan 02-02 完成。

## Deviations from Plan

### 偏差 1：Plan import 行号偏移（Rule 1 等价 — 现实修正）

- **Found during:** Task 2
- **Plan 假设:** `from drf_yasg.utils import swagger_auto_schema` 在第 13 行
- **实际仓库状态:** 该 import 实际位于第 14 行（第 11 行已是 `from common.swagger_utils import swagger_schema`）
- **Adjustment:** 不按行号定位，按字符串精确 Edit 替换 8 行 import 块（包含 `.models` / `.serializers` / `RedisTokenAuthentication` / `serializers` / `swagger_utils` / `responses`），追加 `mask_token` 1 行；新增 `get_standardized_response_schema` 通过扩展现有 `from common.swagger_utils import swagger_schema` 一行完成
- **Reason:** Plan 行号是参考值，不是契约；本仓库 import 区结构与 plan 描述完全等价（仅个别行偏移），按精确 token 串替换更安全
- **Files modified:** `qy_lty/aiapp/views.py`
- **Commit:** `192d0a1`

### 偏差 2：Task 1 自动化校验命令补 Django setup（Rule 3 — 阻塞修复）

- **Found during:** Task 1 verify
- **Issue:** Plan 提供的 `python -c "from aiapp.serializers import CredentialSlotSerializer..."` 命令在 windows 命令行下直接 import 会触发 `ImproperlyConfigured: Requested setting INSTALLED_APPS, but settings are not configured`
- **Fix:** 在 verify 命令内显式 `os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'qy_lty.settings'); django.setup()`；功能等价但兼容裸 python -c 调用
- **Reason:** Plan 假设的 verify 命令行假设了某个隐式环境（如 `python manage.py shell -c`），裸 python 解释器需显式 setup；不影响功能正确性
- **Files modified:** 无（仅 verify 命令）
- **Commit:** 无（不涉及代码改动）

### 偏差 3：未写 docs/修改记录.md（per execution_context 强制约束）

- **Reason:** 用户在 `<sequential_execution>` 显式声明 "本 plan 不写 docs/修改记录.md（Plan 02-02 Task 2 一并写两端互引）"
- **Status:** 故意不写，由 Plan 02-02 一次性补完两端互引条目（CLAUDE.md 跨项目联动规则）
- **Risk:** 在 Plan 02-02 落地前，本仓库 `docs/修改记录.md` 不含 Phase 2 条目；可接受，因为 Plan 02-01 + 02-02 是同 session 同 phase 的连续动作

### 其余偏差

无。Plan 三个 task 的代码内容、acceptance criteria、anti-pattern 约束、reachability 验证全部按 Plan 1:1 落地。

## 留给 Plan 02-02 的端到端 verify hook

### Setup（Wave 0 — 签发测试 token）

```python
# Django shell：python manage.py shell
from userapp.models import ParadiseUser
from userapp.utils import generate_token

# 找一个 staff 用户（或现成的 admin）
admin_user = ParadiseUser.objects.filter(is_staff=True).first()
admin_token = generate_token(admin_user.id, is_admin=True)
print('ADMIN_TOKEN=', admin_token)

# 找一个普通用户
normal_user = ParadiseUser.objects.filter(is_staff=False).first()
user_token = generate_token(normal_user.id, is_admin=False)
print('USER_TOKEN=', user_token)
```

### Curl 矩阵

```bash
# 1. 无 token → 401
curl -i http://localhost:8000/api/v1/admin/credential-slot/

# 2. user token → 403 + "需要管理员权限"
curl -i -H "Authorization: Bearer ${USER_TOKEN}" http://localhost:8000/api/v1/admin/credential-slot/

# 3. admin token GET → 200 + access_token 脱敏（probe_secret_xxxx → *************xxxx）
curl -i -H "Authorization: Bearer ${ADMIN_TOKEN}" http://localhost:8000/api/v1/admin/credential-slot/

# 4. admin token PUT → 200 + DB 写入 + 响应同样脱敏
curl -i -X PUT -H "Authorization: Bearer ${ADMIN_TOKEN}" -H "Content-Type: application/json" \
  -d '{"app_id":"new_app","access_token":"new_secret_token_5678"}' \
  http://localhost:8000/api/v1/admin/credential-slot/
# 期望响应 data.access_token = "****************5678"，updated_at 刷新

# 5. swagger.json 含 credential-slot 路径条目 + access_token 脱敏 description
curl -s http://localhost:8000/swagger.json | python -c "import json,sys; d=json.load(sys.stdin); p='/api/v1/admin/credential-slot/'; assert p in d['paths'], p; assert 'GET' in [m.upper() for m in d['paths'][p].keys()]; assert 'PUT' in [m.upper() for m in d['paths'][p].keys()]; print('OK swagger')"
```

### Django shell 程序化验收（test client）

```python
# python manage.py shell
from rest_framework.test import APIClient
from userapp.models import ParadiseUser
from userapp.utils import generate_token

c = APIClient()

# admin token GET
admin_user = ParadiseUser.objects.filter(is_staff=True).first()
admin_token = generate_token(admin_user.id, is_admin=True)
c.credentials(HTTP_AUTHORIZATION=f'Bearer {admin_token}')
resp = c.get('/api/v1/admin/credential-slot/')
print(resp.status_code, resp.json())
assert resp.status_code == 200
assert resp.json()['success'] == True
assert '*' in resp.json()['data']['access_token']  # 脱敏特征

# admin token PUT
resp2 = c.put('/api/v1/admin/credential-slot/',
              data={'app_id': 'put_app', 'access_token': 'put_secret_5678'},
              format='json')
print(resp2.status_code, resp2.json())
assert resp2.status_code == 200
assert resp2.json()['data']['access_token'].endswith('5678')  # 末 4 位
assert '*' in resp2.json()['data']['access_token']  # 脱敏

# user token → 403
normal_user = ParadiseUser.objects.filter(is_staff=False).first()
user_token = generate_token(normal_user.id, is_admin=False)
c.credentials(HTTP_AUTHORIZATION=f'Bearer {user_token}')
resp3 = c.get('/api/v1/admin/credential-slot/')
print(resp3.status_code, resp3.json())
assert resp3.status_code == 403
assert resp3.json()['success'] == False

# 无 token → 401
c.credentials()
resp4 = c.get('/api/v1/admin/credential-slot/')
print(resp4.status_code, resp4.json())
assert resp4.status_code == 401
```

## Threat Flags

无。本 plan 改动严格落在 02-01-PLAN 的 `<threat_model>` 8 条已声明威胁内（T-02-01 ~ T-02-08），未引入新 trust boundary 或新攻击面。

## Self-Check: PASSED

### Files

- FOUND: `qy_lty/aiapp/serializers.py`（已修改，含 CredentialSlotSerializer）
- FOUND: `qy_lty/aiapp/views.py`（已修改，含 CredentialSlotAdminView）
- FOUND: `qy_lty/userapp/admin_urls.py`（已修改，含 admin_credential_slot URL）
- FOUND: `.planning/phases/02-admin-rest/02-01-SUMMARY.md`（本文件）

### Commits

- FOUND: `6820fe7` feat(02-01): 新增 CredentialSlotSerializer
- FOUND: `192d0a1` feat(02-01): 新增 CredentialSlotAdminView（GET 脱敏 / PUT 全字段覆写）
- FOUND: `9d02021` feat(02-01): 注册 /api/v1/admin/credential-slot/ 路由

### Imports

- VERIFIED: `from aiapp.views import CredentialSlotAdminView; from aiapp.serializers import CredentialSlotSerializer` 同行无 ImportError
- VERIFIED: `reverse('admin_credential_slot')` = `/api/v1/admin/credential-slot/`
- VERIFIED: `mask_token('probe_secret_xxxx')` = `*************xxxx`

---

*Phase: 02-admin-rest / Plan: 01*
*Executed: 2026-05-07 by gsd-executor（顺序执行模式，无 worktree 隔离）*
