---
phase: 03-client-and-log-mask
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - aiapp/views.py
  - qy_lty/urls.py
autonomous: true
requirements:
  - CRED-05
must_haves:
  truths:
    - "持有效 user token 调用 GET /api/credential-slot/ 返回 200 + 标准壳层 + 明文 access_token"
    - "持有效 admin token 调用同接口同样返回 200 + 明文（不区分 admin/user token）"
    - "无 token 调用返回 401，标准壳层 success=false"
    - "持伪造 / 过期 token 调用返回 401（RedisTokenAuthentication 拒绝）"
    - "/swagger.json/ 含 /api/credential-slot/ 路径条目，GET 方法 description 标注「明文返回」"
  artifacts:
    - path: "aiapp/views.py"
      provides: "CredentialSlotClientView APIView 类（仅 GET 明文）+ _credential_slot_client_data_schema swagger schema"
      contains: "class CredentialSlotClientView"
    - path: "qy_lty/urls.py"
      provides: "/api/credential-slot/ 路由注册（顶层 api_urlpatterns）"
      contains: "client_credential_slot"
  key_links:
    - from: "qy_lty/urls.py:api_urlpatterns"
      to: "aiapp.views.CredentialSlotClientView"
      via: "from aiapp.views import CredentialSlotClientView"
      pattern: "path\\('credential-slot/', CredentialSlotClientView"
    - from: "CredentialSlotClientView.get"
      to: "CredentialSlot.get_solo() + CredentialSlotSerializer + success_response"
      via: "1:1 复刻 CredentialSlotAdminView.get 但不调 _ensure_admin / _build_response_data / mask_token"
      pattern: "success_response\\(data=serializer\\.data"
---

<objective>
落地 CRED-05：在 `/api/credential-slot/` 暴露**客户端**通用凭据槽位 GET 接口，user / admin token 鉴权（`RedisTokenAuthentication` + `IsAuthenticated`），**明文**返回 `{ app_id, access_token, updated_at }`，供手机端（LTY_App_Project_URP）与设备端（LTY_Project）实际调用第三方服务（阿里云 / 火山 / 腾讯）。

**Purpose**: Milestone v1.0「通用凭据槽位」Phase 3 第一阶段。Phase 2 已让管理端读 / 写脱敏后的槽位；本 plan 让客户端读到明文。view 行为与 Phase 2 admin view 严格反向（明文返回、不调 mask_token、不做 is_staff 二次校验），路由在顶层 `api_urlpatterns` 而非 `userapp/admin_urls.py`。

**Output**:
- `aiapp/views.py` 末尾新增 `CredentialSlotClientView` 类（仅 GET）+ `_credential_slot_client_data_schema` 客户端响应 schema
- `qy_lty/urls.py` 顶部 imports 追加 `from aiapp.views import CredentialSlotClientView`、`api_urlpatterns` 列表中追加路由
- 新接口 `/api/credential-slot/` 通 swagger 自动暴露
</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/phases/03-client-and-log-mask/03-CONTEXT.md
@.planning/phases/03-client-and-log-mask/03-RESEARCH.md
@.planning/phases/02-admin-rest/02-01-SUMMARY.md
@.planning/phases/02-admin-rest/02-02-SUMMARY.md

# 1:1 模板源 + 直接复用件
@aiapp/views.py
@aiapp/serializers.py
@aiapp/models.py
@common/utils.py
@common/responses.py
@common/swagger_utils.py
@userapp/authentication.py
@userapp/utils.py
@qy_lty/urls.py

<interfaces>
<!-- 直接复用的现有契约。executor 不需要再去 grep / 探索 codebase。 -->

From `aiapp/views.py:1-19`（imports 段，**全部已就位**，本 plan 不需要再加任何 import）：
```python
from rest_framework.views import APIView
from rest_framework import status
from .models import ChatMessage, Bot, CredentialSlot       # CredentialSlot 已 import
from .serializers import ChatMessageSerializer, CredentialSlotSerializer  # 已 import
from rest_framework.permissions import IsAuthenticated     # 已 import
from userapp.authentication import RedisTokenAuthentication  # 已 import
from common.swagger_utils import swagger_schema, get_standardized_response_schema  # 已 import
from common.responses import success_response, created_response, error_response  # 已 import
from common.utils import mask_token                        # 已 import（本 plan 不调用，但 import 已存在无影响）
from drf_yasg import openapi                               # 已 import
from drf_yasg.utils import swagger_auto_schema             # 已 import
import logging
logger = logging.getLogger(__name__)
```

From `aiapp/models.py`（CredentialSlot 单例契约，Phase 1 已交付）：
```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)

    @classmethod
    def get_solo(cls):
        # get_or_create(pk=1) 单一入口
        ...
```

From `aiapp/serializers.py:11-30`（Phase 2 已交付，**直接复用**，不新建客户端专用 serializer）：
```python
class CredentialSlotSerializer(serializers.ModelSerializer):
    class Meta:
        model = CredentialSlot
        fields = ['app_id', 'access_token', 'updated_at']
        read_only_fields = ['updated_at']
```

From `common/responses.py`：`success_response(data=None, message="操作成功", code=200)` → DRF Response 200 + `{success, code, message, data}` 标准壳层（StandardResponseMiddleware 不会二次包装）。

From `userapp/authentication.py:14-34` + `userapp/utils.py:39-45`：`RedisTokenAuthentication` 双查 `admin_token:{token}` → `token:{token}`，admin / user 都返回 `is_authenticated=True` 的 ParadiseUser → `IsAuthenticated` 对两种 token 都通过。**本 view 不调 is_staff 二次校验**（CONTEXT 锁定决策）。

From `aiapp/views.py:600-687`（Phase 2 模板，**1:1 复刻 GET 部分**，删 `_ensure_admin` / `_build_response_data` / PUT 三处）。

From `qy_lty/urls.py:48-60`（当前 api_urlpatterns 实测，本 plan 在 `common/upload/` 之后、`v1/admin/` 之前插入新行）：
```python
api_urlpatterns = [
    path('user/', include('userapp.urls')),
    path('ai/', include('aiapp.urls')),
    path('device/', include('device_interaction.urls')),
    path('card/', include('card.urls')),
    path('achievement/', include('achievement_app.urls')),
    path('food/', include('food_app.urls')),
    path('common/upload/', upload_file, name='file-upload'),
    # ↓ 在此处插入新行 ↓
    path('v1/admin/', include('userapp.admin_urls')),
]
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: 在 aiapp/views.py 末尾追加 CredentialSlotClientView + 客户端响应 schema</name>
  <files>aiapp/views.py</files>

  <read_first>
    1. **完整读 `aiapp/views.py:560-687`** —— Phase 2 `CredentialSlotPutRequestSchema` + `_credential_slot_data_schema` + `CredentialSlotAdminView` 完整段，作为 1:1 模板（删 PUT、删 `_ensure_admin`、删 `_build_response_data` / `mask_token` 调用）
    2. **完整读 `aiapp/views.py:1-19`** —— imports 段，确认 `CredentialSlot` / `CredentialSlotSerializer` / `RedisTokenAuthentication` / `IsAuthenticated` / `success_response` / `get_standardized_response_schema` / `openapi` / `swagger_auto_schema` 全部已 import，**本 task 不要再加任何 import**
    3. 读 `aiapp/serializers.py:11-30` 确认 `CredentialSlotSerializer` 字段集 = `['app_id', 'access_token', 'updated_at']`，明文存储（脱敏由 view 层负责，admin 层调 mask_token，client 层不调）
    4. 读 `common/responses.py:41-52` 确认 `success_response(data=..., message="...")` 调用契约
    5. 读 `userapp/authentication.py` + `userapp/utils.py:39-45` 确认 admin/user token 都通过 `IsAuthenticated`
  </read_first>

  <action>
**位置**：`aiapp/views.py` 末尾（紧邻 `CredentialSlotAdminView` 类的最后一行 `return success_response(data=data, message="凭据已更新")` 之后），追加以下 **两块代码**：

**第 1 块：客户端响应 data 子 schema**（紧邻文件末尾，放在新 view 类**之前**，与 `_credential_slot_data_schema` 形成对称命名）：

```python
# ======================================================================
# Phase 3 — 通用凭据槽位客户端读取接口（CRED-05）
# 1:1 复刻 CredentialSlotAdminView 的 GET 部分，删 _ensure_admin / _build_response_data / PUT
# 关键差异：明文返回 access_token，不调 mask_token，不做 is_staff 二次校验
# ======================================================================

# 客户端响应 data 子 schema：access_token 字段 description 显式标注「明文」
_credential_slot_client_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 位）',
        ),
        'updated_at': openapi.Schema(
            type=openapi.TYPE_STRING,
            format='date-time',
            description='最近一次更新时间（ISO 8601）',
        ),
    },
)
```

**第 2 块：CredentialSlotClientView 类**（紧接上面 schema 之后）：

```python
class CredentialSlotClientView(APIView):
    """通用凭据槽位客户端读取接口（user / admin token 鉴权，明文返回）。

    GET: 返回明文 app_id + access_token，供手机/设备端实际调用第三方服务。

    与管理端 CredentialSlotAdminView 的关键差异（per CONTEXT.md D-Client-View）：
    - 删 _ensure_admin：不做 is_staff 二次校验，admin / user token 都允许（admin 用户是手机用户超集）
    - 删 _build_response_data：直接返回 serializer.data（不调 mask_token，明文返回）
    - 删 PUT 方法：客户端只读，不能写入
    """
    authentication_classes = [RedisTokenAuthentication]
    permission_classes = [IsAuthenticated]
    tags = ['通用凭据槽位（客户端）']

    @swagger_auto_schema(
        operation_description="读取通用凭据槽位（明文 access_token，供手机/设备端实际调用第三方服务）",
        responses={
            200: openapi.Response(
                '读取成功',
                get_standardized_response_schema(_credential_slot_client_data_schema),
            ),
            401: openapi.Response('未提供有效 token', get_standardized_response_schema()),
        },
        security=[{'Bearer': []}],
        tags=['通用凭据槽位（客户端）'],
    )
    def get(self, request):
        instance = CredentialSlot.get_solo()
        serializer = CredentialSlotSerializer(instance)
        return success_response(data=serializer.data, message="读取成功")
```

**严格禁止做的事**：
- ✗ 不要新增任何 import（imports 段已全部就位）
- ✗ 不要新建 `CredentialSlotClientSerializer`（CONTEXT 锁定复用 Phase 2 serializer）
- ✗ 不要在 view 内调 `mask_token`（CONTEXT 锁定明文返回）
- ✗ 不要在 view 内做 `request.user.is_staff` 校验（CONTEXT 锁定 admin/user 都允许）
- ✗ 不要新增 PUT method（客户端只读）
- ✗ 不要在 view 内打 `logger.info(serializer.data)` 或 `logger.info(request.data)` 类型语句（避免 access_token 进日志；plan 03-02 的 filter 会兜底，但 plan 03-01 不引入泄露源）
- ✗ 不要修改 Phase 2 的 `CredentialSlotAdminView` / `_credential_slot_data_schema` / `CredentialSlotPutRequestSchema`（Phase 2 已验收 PASS，本 plan 仅追加，不动既有）
  </action>

  <verify>
    <automated>
# 1. 文件含新类 + schema（不再用 _ensure_admin 计数断言：admin view 实际含 1 处方法定义 + 2 处调用 = 3+ 处，
#    而客户端 view 类体内 0 处的判断由下面 #2 的 AST-equivalent 检查保证）
python -c "src=open('aiapp/views.py',encoding='utf-8').read(); assert 'class CredentialSlotClientView(APIView):' in src, '缺 CredentialSlotClientView 类'; assert '_credential_slot_client_data_schema' in src, '缺客户端 schema'; print('OK: view 类 + schema 已落地')"

# 2. 客户端 view 类体不调 mask_token / _ensure_admin / _build_response_data / PUT
#    注意：生产 Docker 是 Python 3.8（ast.unparse 是 3.9+ 新增），所以用 re.search 切类体 + grep 校验，
#    AST-equivalent 但兼容 3.8。
python -c "import re, pathlib; src=pathlib.Path('aiapp/views.py').read_text(encoding='utf-8'); m=re.search(r'^class CredentialSlotClientView.*?(?=^class |\Z)', src, re.S | re.M); assert m, 'CredentialSlotClientView 未找到'; body=m.group(0); [None for forbidden in ['_ensure_admin', '_build_response_data', 'def put', 'mask_token'] if (lambda f: (_ for _ in ()).throw(AssertionError(f'客户端 view 类体不应含 {f}')) if f in body else None)(forbidden)]; print('OK: 客户端 view 类体不含 _ensure_admin / _build_response_data / def put / mask_token')"

# 3. Django 系统检查 + URLConf 加载（在 plan 03-01 完成 task 2 后才会通过；本 task 仅校验 import 不报错）
python manage.py check aiapp
    </automated>
  </verify>

  <acceptance_criteria>
    - aiapp/views.py 文件大小相比 Phase 2 收尾态增加 ~40-60 行
    - grep `class CredentialSlotClientView` 命中 1 次
    - grep `_credential_slot_client_data_schema` 命中 ≥ 2 次（schema 定义 + swagger_auto_schema 引用）
    - 客户端 view 类体中 `mask_token` / `_ensure_admin` / `def put` / `_build_response_data` 0 命中
    - 客户端 view 类体中 `success_response(data=serializer.data` 命中 1 次（不是 `data=data`）
    - `python manage.py check aiapp` 0 errors / 0 warnings
  </acceptance_criteria>

  <done>
    `CredentialSlotClientView` 类与 `_credential_slot_client_data_schema` 已追加到 `aiapp/views.py` 末尾；imports 未变；Phase 2 既有代码未动；`python manage.py check aiapp` 通过。
  </done>
</task>

<task type="auto">
  <name>Task 2: 在 qy_lty/urls.py 注册 /api/credential-slot/ 路由 + import CredentialSlotClientView</name>
  <files>qy_lty/urls.py</files>

  <read_first>
    1. **完整读 `qy_lty/urls.py:1-83`** —— 确认现有 imports 段（行 17-26）+ `api_urlpatterns` 列表（行 49-60）+ 顶层 `urlpatterns`（行 63-73）
    2. 注意行 26 已有 `from common.views import upload_file`，本 task 在该行**之后**追加 `from aiapp.views import CredentialSlotClientView`
    3. 注意行 57 是 `path('common/upload/', upload_file, name='file-upload'),`，行 59 是 `path('v1/admin/', include('userapp.admin_urls')),`，本 task 在这两行**之间**插入新路由
    4. 读 `aiapp/views.py` 末尾（Task 1 完成后），确认 `CredentialSlotClientView` 已存在
  </read_first>

  <action>
**修改 1：顶部 imports 段追加**

`qy_lty/urls.py` 行 26（`from common.views import upload_file` 之后）追加 1 行：

```python
from aiapp.views import CredentialSlotClientView
```

**修改 2：api_urlpatterns 列表中追加路由**

`qy_lty/urls.py` 行 57-59 段，在 `path('common/upload/', upload_file, name='file-upload'),` **之后**、`path('v1/admin/', include('userapp.admin_urls')),` **之前**追加 1 行（保持「客户端散点路径 → admin 命名空间」的视觉分组）：

修改前（行 49-60，verbatim）：
```python
api_urlpatterns = [
    path('user/', include('userapp.urls')),
    path('ai/', include('aiapp.urls')),
    # path('ali/vi/api/', include('ali_vi_app.urls')),
    path('device/', include('device_interaction.urls')),
    path('card/', include('card.urls')),
    path('achievement/', include('achievement_app.urls')),  # 成就系统API
    path('food/', include('food_app.urls')),  # 食物管理API
    path('common/upload/', upload_file, name='file-upload'),
    # 管理员API接口路径（v1版本）
    path('v1/admin/', include('userapp.admin_urls')),
]
```

修改后：
```python
api_urlpatterns = [
    path('user/', include('userapp.urls')),
    path('ai/', include('aiapp.urls')),
    # path('ali/vi/api/', include('ali_vi_app.urls')),
    path('device/', include('device_interaction.urls')),
    path('card/', include('card.urls')),
    path('achievement/', include('achievement_app.urls')),  # 成就系统API
    path('food/', include('food_app.urls')),  # 食物管理API
    path('common/upload/', upload_file, name='file-upload'),
    # Phase 3 — 客户端通用凭据槽位读取接口（CRED-05，明文返回）
    path('credential-slot/', CredentialSlotClientView.as_view(), name='client_credential_slot'),
    # 管理员API接口路径（v1版本）
    path('v1/admin/', include('userapp.admin_urls')),
]
```

**严格禁止做的事**：
- ✗ 不要把路由放进 `aiapp/urls.py`（CONTEXT 锁定路径 `/api/credential-slot/`，不要 `/api/ai/credential-slot/`）
- ✗ 不要放进 `userapp/admin_urls.py`（那是 admin 命名空间，给 Phase 2 用）
- ✗ 不要改 `path('v1/admin/', include('userapp.admin_urls'))` 等任何既有行
- ✗ 不要改 `urlpatterns`（行 63-73）—— 那是顶层包装，不是新增点
  </action>

  <verify>
    <automated>
# 1. import + path 行存在
python -c "src=open('qy_lty/urls.py',encoding='utf-8').read(); assert 'from aiapp.views import CredentialSlotClientView' in src, '缺 CredentialSlotClientView import'; assert \"path('credential-slot/', CredentialSlotClientView.as_view(), name='client_credential_slot')\" in src, '缺路由注册'; print('OK: imports + path 已落地')"

# 2. URL 解析正确（应返回 CredentialSlotClientView）
python manage.py shell -c "from django.urls import resolve; m = resolve('/api/credential-slot/'); print('OK: resolved to', m.func.view_class.__name__); assert m.func.view_class.__name__ == 'CredentialSlotClientView'; assert m.url_name == 'client_credential_slot'"

# 3. 反向解析
python manage.py shell -c "from django.urls import reverse; u = reverse('client_credential_slot'); print('OK: reverse =', u); assert u == '/api/credential-slot/'"

# 4. Django 系统检查全通过
python manage.py check
    </automated>
  </verify>

  <acceptance_criteria>
    - `qy_lty/urls.py` 第 27 行附近含 `from aiapp.views import CredentialSlotClientView`
    - `api_urlpatterns` 列表的 `common/upload/` 与 `v1/admin/` 之间含新行 `path('credential-slot/', CredentialSlotClientView.as_view(), name='client_credential_slot')`
    - `resolve('/api/credential-slot/')` 返回 `CredentialSlotClientView` 视图，url_name = `client_credential_slot`
    - `reverse('client_credential_slot')` 返回 `/api/credential-slot/`
    - `python manage.py check` 0 errors / 0 warnings
    - `aiapp/urls.py` / `userapp/admin_urls.py` 完全未动
  </acceptance_criteria>

  <done>
    `/api/credential-slot/` 已注册到顶层 `api_urlpatterns`，指向 `CredentialSlotClientView`；URL 解析与反向解析均通过；`python manage.py check` 全绿。
  </done>
</task>

<task type="auto">
  <name>Task 3: 启动验证 + Django test client 端到端验收（CRED-05 五条 truth）</name>
  <files>_phase3_01_verify.py</files>

  <read_first>
    1. 完整读 `.planning/phases/02-admin-rest/02-02-SUMMARY.md`（或 `02-VERIFICATION.md`），确认 Phase 2 已落地的 DB 探针态：`pk=1` 的 `CredentialSlot.app_id='probe_app'` / `access_token='probe_secret_xxxx'`
    2. 读 `userapp/utils.py:generate_token` —— Redis key 体系：`admin_token:{token}` / `token:{token}`，TTL 30 天
    3. 读 Phase 2 验收脚本风格（`_phase2_verify.py` 已删，可参考 `02-VERIFICATION.md` 中保留的命令清单）
    4. 读 `qy_lty/settings.py:LOGGING` —— 客户端 logger 走 `aiapp` / `userapp` / `common` 三个 logger，level=INFO
  </read_first>

  <action>
在仓库根目录创建 `_phase3_01_verify.py`（**临时验收脚本，phase end 由 plan 03-02 负责删除**），内容如下：

```python
"""Phase 3 Plan 01 验收脚本（CRED-05 客户端 GET 端到端 + Swagger）。

验收 6 条 truth：
  T1: 持 user token GET → 200 + 标准壳层 + 明文 access_token
  T2: 持 admin token GET → 200 + 明文（不区分）
  T3: 无 token GET → 401 + 标准壳层 success=false
  T4: 持伪造 token GET → 401
  T5: /swagger.json/ 含 /credential-slot/（注：basePath=/api，所以 schema paths key 不带 /api 前缀）
  T6: 响应 access_token 与 DB instance.access_token 字符级一致（明文，不脱敏）

执行：
  cd <repo_root>
  python manage.py shell < _phase3_01_verify.py

验收完毕由 Plan 03-02 Task 4 删除本文件 + 还原 DB 探针态（保持与 Phase 2 探针一致）。
"""
import json
import secrets
import django

django.setup() if not django.apps.apps.ready else None

from django.test import Client
from django.core.cache import cache
from aiapp.models import CredentialSlot
from userapp.models import ParadiseUser

PASS = []
FAIL = []

def assert_eq(name, got, expected):
    if got == expected:
        PASS.append(f'{name}: got={got!r}')
    else:
        FAIL.append(f'{name}: expected={expected!r} got={got!r}')

def assert_in(name, needle, haystack):
    if needle in haystack:
        PASS.append(f'{name}: {needle!r} in haystack')
    else:
        FAIL.append(f'{name}: {needle!r} NOT in haystack={haystack!r}')

# 0) 准备：DB 探针 + 两个 token（admin / user）
slot, _ = CredentialSlot.objects.get_or_create(pk=1)
slot.app_id = 'probe_app'
slot.access_token = 'probe_secret_xxxx'
slot.save()

# 拿一个 staff user + 一个普通 user
staff = ParadiseUser.objects.filter(is_staff=True).first()
normal = ParadiseUser.objects.filter(is_staff=False).first()
if not staff or not normal:
    FAIL.append('环境缺 staff/normal user，请先 createsuperuser 或注册一个 normal user')
else:
    admin_token = secrets.token_hex(18)
    user_token  = secrets.token_hex(18)
    cache.set(f'admin_token:{admin_token}', staff.id,  timeout=300)
    cache.set(f'token:{user_token}',        normal.id, timeout=300)

    client = Client()

    # T1: user token → 200 + 明文
    r = client.get('/api/credential-slot/', HTTP_AUTHORIZATION=f'Bearer {user_token}')
    assert_eq('T1.status_code', r.status_code, 200)
    body = r.json()
    assert_eq('T1.success',    body.get('success'),                True)
    assert_eq('T1.data.app_id', body['data'].get('app_id'),        'probe_app')
    assert_eq('T1.data.access_token (plaintext)', body['data'].get('access_token'), 'probe_secret_xxxx')
    # 明文断言 —— 必须等于原 DB 值，不能含 *
    if '*' in (body['data'].get('access_token') or ''):
        FAIL.append('T1.no_mask: 客户端响应不应含 *（应明文返回）')
    else:
        PASS.append('T1.no_mask: 客户端响应未脱敏 ✓')

    # T2: admin token → 200 + 明文（不区分）
    r = client.get('/api/credential-slot/', HTTP_AUTHORIZATION=f'Bearer {admin_token}')
    assert_eq('T2.status_code', r.status_code, 200)
    body = r.json()
    assert_eq('T2.data.access_token (plaintext)', body['data'].get('access_token'), 'probe_secret_xxxx')

    # T3: 无 token → 401
    r = client.get('/api/credential-slot/')
    assert_eq('T3.status_code', r.status_code, 401)
    body = r.json()
    assert_eq('T3.success', body.get('success'), False)

    # T4: 伪造 token → 401
    r = client.get('/api/credential-slot/', HTTP_AUTHORIZATION='Bearer fake_token_zzz_not_in_redis')
    assert_eq('T4.status_code', r.status_code, 401)

    # T5: swagger 暴露
    r = client.get('/swagger.json/')
    assert_eq('T5.swagger.status_code', r.status_code, 200)
    schema = r.json()
    # StandardResponseMiddleware 也会包 OpenAPI schema → unwrap data 字段
    if 'paths' not in schema and 'data' in schema:
        schema = schema['data']
    paths = schema.get('paths', {})
    assert_in('T5.swagger.paths',     '/credential-slot/', list(paths.keys()))
    if '/credential-slot/' in paths:
        ops = paths['/credential-slot/']
        assert_in('T5.swagger.has_get',  'get', ops)

    # 还原 + 清理
    slot.refresh_from_db()
    assert_eq('T6.db_unchanged.app_id',       slot.app_id,       'probe_app')
    assert_eq('T6.db_unchanged.access_token', slot.access_token, 'probe_secret_xxxx')
    cache.delete(f'admin_token:{admin_token}')
    cache.delete(f'token:{user_token}')

print('=' * 70)
print(f'PASS ({len(PASS)}):')
for p in PASS:
    print(f'  ✓ {p}')
if FAIL:
    print(f'FAIL ({len(FAIL)}):')
    for f in FAIL:
        print(f'  ✗ {f}')
    raise SystemExit(1)
else:
    print('ALL PASS')
```

**执行**：
```bash
python manage.py shell < _phase3_01_verify.py
```

**严格禁止做的事**：
- ✗ 不要在脚本里写明文 user-token / admin-token 进 git（用 `secrets.token_hex(18)` 即时生成，验完 cache.delete）
- ✗ 不要改 DB 探针的最终值（验收前后 `app_id='probe_app'` / `access_token='probe_secret_xxxx'` 保持不变）
- ✗ 不要把脚本提交到 git（Plan 03-02 Task 4 会负责删除；可加 `_phase3_*.py` 到 `.gitignore` 或直接不 add）
- ✗ 不要让脚本启 daphne / runserver（用 `django.test.Client` in-process）
  </action>

  <verify>
    <automated>
# 跑端到端脚本
python manage.py shell < _phase3_01_verify.py

# 脚本本身的退出码即验收结果（FAIL 时 exit 1）
    </automated>
  </verify>

  <acceptance_criteria>
    - 脚本运行最后输出 `ALL PASS`
    - PASS 行数 ≥ 12（5 条 truth × 平均 2-3 个独立断言）
    - FAIL 行数 = 0
    - DB 状态在脚本结束后保持 `app_id='probe_app'` / `access_token='probe_secret_xxxx'` 探针态
    - Redis cache 中临时 token key 已清理（cache.delete 调用过）
    - 脚本**不**进 git（保留在 working tree 供 Plan 03-02 Task 4 删除）
  </acceptance_criteria>

  <done>
    `_phase3_01_verify.py` 输出 `ALL PASS`，CRED-05 五条 success criteria 全部通过；DB 还原；临时 token 清理。
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| 客户端 → API | Unity 设备 / 手机端通过 HTTP + Bearer token 调用 `/api/credential-slot/`；token 明文走 Authorization header |
| API → Redis | `RedisTokenAuthentication` 双查 `admin_token:{token}` / `token:{token}` 的 user_id |
| API → DB | `CredentialSlot.get_solo()` 查 PostgreSQL 单例记录 |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Spoofing | `RedisTokenAuthentication` | mitigate | 复用 Phase 1/2 已验收的 Bearer token 双查机制；token 由 `userapp.utils.generate_token` 生成 30 天 TTL，伪造概率 = 1/256^36 |
| T-03-02 | Information Disclosure | 客户端 GET 响应明文 access_token | accept | CONTEXT 已论证：客户端必须拿到明文才能调第三方服务；HTTPS 由 Nginx 反代兜底（CLAUDE.md 部署段）；不在 Phase 3 范围 |
| T-03-03 | Information Disclosure | 客户端 GET 响应明文 access_token 经 logger 打到生产日志 | mitigate | **plan 03-02 引入 `AccessTokenMaskFilter`** 在 LOGGING.handlers 层兜底；本 plan 不引入新 logger.info 调用，确保 plan 03-01 不创造新泄露源 |
| T-03-04 | Elevation of Privilege | 普通 user token 持有者通过 client view 读取明文 access_token | accept | CONTEXT 已论证：admin user 是手机用户超集；明文 access_token 是「第三方服务的口令」非「平台用户的凭证」，user 持有它仅能调阿里云 / 火山等第三方 API（这正是设计意图） |
| T-03-05 | Tampering | 攻击者修改路由让 client view 走入 admin 路径 | mitigate | 路由放 `qy_lty/urls.py:api_urlpatterns` 顶层（非 sub-include），不被任何 app urls.py 覆盖 |
| T-03-06 | Repudiation | 客户端调用未留审计日志 | accept | 当前架构无审计需求（候选优先级 #5 待 pytest 体系落地后再做） |
| T-03-07 | DoS | 高频 GET 调用 | accept | 候选优先级未列「客户端调用频率限流」；Nginx 反代可手动加 rate limit；不在 Phase 3 范围 |
</threat_model>

<verification>
**Plan 整体验收（汇总 task 1/2/3 的自动化检查）**：

```bash
# 1. 静态：view + schema 已追加
python -c "src=open('aiapp/views.py',encoding='utf-8').read(); assert 'class CredentialSlotClientView(APIView):' in src; assert '_credential_slot_client_data_schema' in src; print('OK: view + schema')"

# 2. 静态：路由已注册
python -c "src=open('qy_lty/urls.py',encoding='utf-8').read(); assert 'from aiapp.views import CredentialSlotClientView' in src; assert \"path('credential-slot/', CredentialSlotClientView.as_view()\" in src; print('OK: route')"

# 3. 动态：Django check + URL 解析
python manage.py check
python manage.py shell -c "from django.urls import resolve; assert resolve('/api/credential-slot/').func.view_class.__name__ == 'CredentialSlotClientView'; print('OK: resolve')"

# 4. 动态：端到端 5 条 truth
python manage.py shell < _phase3_01_verify.py
```
</verification>

<success_criteria>
- [ ] `aiapp/views.py` 末尾追加 `CredentialSlotClientView` 类与 `_credential_slot_client_data_schema`，imports 段未变
- [ ] `qy_lty/urls.py` imports 段追加 `from aiapp.views import CredentialSlotClientView`，`api_urlpatterns` 列表中含 `path('credential-slot/', CredentialSlotClientView.as_view(), name='client_credential_slot')`
- [ ] `python manage.py check` 0 errors / 0 warnings
- [ ] `resolve('/api/credential-slot/')` 返回 `CredentialSlotClientView`
- [ ] `_phase3_01_verify.py` 输出 `ALL PASS`，5 条 truth（user/admin token 200 + 明文、无 token 401、伪造 token 401、swagger 暴露）全过
- [ ] DB 探针态 `pk=1 / app_id='probe_app' / access_token='probe_secret_xxxx'` 保持
- [ ] `aiapp/urls.py` / `userapp/admin_urls.py` / `aiapp/serializers.py` / `aiapp/models.py` 等其他文件**未改动**
- [ ] Phase 2 既有 `CredentialSlotAdminView` 行为未变（Phase 2 验收脚本若重跑应仍 PASS — 本 plan 不影响）
</success_criteria>

<output>
完成后创建 `.planning/phases/03-client-and-log-mask/03-01-SUMMARY.md`，记录：
- 实际改动文件 + 行号区间
- `_phase3_01_verify.py` 输出截图（PASS 列表）
- 与 Phase 2 admin view 的对照差异（删 `_ensure_admin` / 删 `_build_response_data` / 删 PUT / 不调 mask_token）
- 留给 Plan 03-02 的 hand-off：DB 探针态保持、`_phase3_01_verify.py` 待 Plan 03-02 Task 4 删除
</output>
</content>
