---
phase: 03-client-and-log-mask
plan: 02
type: execute
wave: 2
depends_on:
  - 03-01
files_modified:
  - common/logging/__init__.py
  - common/logging/filters.py
  - qy_lty/settings.py
  - docs/修改记录.md
autonomous: true
requirements:
  - CRED-06
must_haves:
  truths:
    - "AccessTokenMaskFilter 类存在于 common/logging/filters.py，是 logging.Filter 子类"
    - "LOGGING.filters 中注册 access_token_mask；LOGGING.handlers.aliyun 与 LOGGING.handlers.console 各引用 access_token_mask"
    - "filter 处理伪 LogRecord（msg 含 access_token 明文）后 record.getMessage() 不含完整明文，含末 4 位脱敏掩码"
    - "filter 在 4 种序列化形态（JSON / Python dict repr / URL query / 等号或冒号兜底）下均能脱敏"
    - "filter 不误伤 Authorization header / Bearer token 等非 access_token 字段"
    - "Django 启动 + LOGGING dictConfig 加载无 ValueError（filter 工厂语法 () 写正确）"
    - "docs/修改记录.md 顶部追加 [2026-05-08] Phase 3 条目，覆盖 client view + filter + LOGGING 三处改动"
  artifacts:
    - path: "common/logging/__init__.py"
      provides: "package marker（空文件，让 common.logging 成为可 import 的包）"
    - path: "common/logging/filters.py"
      provides: "AccessTokenMaskFilter(logging.Filter) + 4 个 regex 模式 + filter() 方法"
      contains: "class AccessTokenMaskFilter"
    - path: "qy_lty/settings.py"
      provides: "LOGGING.filters 段 + handlers.aliyun.filters + handlers.console.filters"
      contains: "access_token_mask"
    - path: "docs/修改记录.md"
      provides: "顶部追加 [2026-05-08] Phase 3 条目"
      contains: "[2026-05-08] Phase 3"
  key_links:
    - from: "qy_lty/settings.py:LOGGING.filters"
      to: "common.logging.filters.AccessTokenMaskFilter"
      via: "() 工厂语法（dictConfig 标准）"
      pattern: "common\\.logging\\.filters\\.AccessTokenMaskFilter"
    - from: "LOGGING.handlers.aliyun"
      to: "filter 实例"
      via: "filters: ['access_token_mask']"
      pattern: "filters.*access_token_mask"
    - from: "LOGGING.handlers.console"
      to: "filter 实例"
      via: "filters: ['access_token_mask']"
      pattern: "filters.*access_token_mask"
    - from: "AccessTokenMaskFilter._mask_in_text"
      to: "common.utils.mask_token"
      via: "from common.utils import mask_token"
      pattern: "mask_token\\("
---

<objective>
落地 CRED-06：在 Python `logging` 链路新增 `AccessTokenMaskFilter`，挂到 LOGGING.handlers.aliyun / console 上，确保**任何**未来开发者写 `logger.info(f"PUT body: {request.data}")` 类型代码时 `access_token` 字段值会被自动脱敏（保留末 4 位），覆盖 4 种序列化形态：JSON 字符串 / Python dict repr / URL query / 等号或冒号兜底。

**Purpose**: Milestone v1.0「通用凭据槽位」Phase 3 第二阶段（防御性兜底）。RESEARCH 已实证当前仓库**没有**任何代码 logger 输出 `CredentialSlot.access_token` 明文（StandardResponseMiddleware 不打日志、view 不显式 logger 字段），所以 CRED-06 的端到端验证靠**单元测试**伪造 LogRecord 验证 filter 行为，不靠端到端找泄露路径。这是 CRED-06 的真实价值 —— 防御性兜底。

**Output**:
- `common/logging/__init__.py`（**新建**空文件）
- `common/logging/filters.py`（**新建**，含 `AccessTokenMaskFilter` 类 + 4 个 regex + `filter()` 方法）
- `qy_lty/settings.py`：`LOGGING` 字典追加 `filters` 段；`handlers.aliyun` / `handlers.console` 各加 `'filters': ['access_token_mask']`
- `docs/修改记录.md` 顶部追加 `[2026-05-08] Phase 3 — 客户端凭据槽位 GET 接口 + 阿里云日志脱敏` 条目
- 临时验收脚本 `_phase3_02_verify.py` 跑完即删 + 还原 DB 探针 + 删除 plan 03-01 的 `_phase3_01_verify.py`
</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/03-client-and-log-mask/03-01-SUMMARY.md

# 直接复用件
@common/utils.py
@common/aliyun_logging.py
@common/middleware.py
@qy_lty/settings.py
@CLAUDE.md
@docs/修改记录.md

<interfaces>
<!-- 直接复用的现有契约。 -->

From `common/utils.py:10-32`（**直接复用**，filter 内调用）：
```python
def mask_token(token: str, visible_tail: int = 4, mask_char: str = '*') -> str:
    if not token:
        return ''
    if len(token) <= visible_tail:
        return mask_char * len(token)
    return mask_char * (len(token) - visible_tail) + token[-visible_tail:]
```

`mask_token('abcdefgh1234', visible_tail=4)` → `'********1234'`（**8** 颗 `*` + `1234`，共 12 字符；输入 12 字符长度保持）。

From `common/aliyun_logging.py:16-30`（emit 用 `record.getMessage()`，所以 filter 改 `record.msg` + `record.args` 后 emit 自然拿到脱敏后的字符串）：
```python
class AliyunLogHandler(logging.Handler):
    def emit(self, record):
        log_item = LogItem()
        log_item.set_time(record.created)
        log_item.set_contents([
            ('level', record.levelname),
            ('message', record.getMessage()),  # ← 此处实时拼出脱敏后字符串
            ...
        ])
```

From `qy_lty/settings.py:372-412`（当前 LOGGING 全貌，本 plan 改动点已在 RESEARCH Example 2 给出 diff）。

From `docs/修改记录.md` 头部「修改格式说明」段（**严格遵守的格式约束**）：
```
### [日期] 修改简述

- **文件路径**: 相对于项目根目录的文件路径
- **修改类型**: 新增 / 修改 / 删除 / 重构 / 修复Bug
- **修改内容**: 具体修改了什么
- **修改原因**: 为什么要做这个修改
```
新条目追加在「修改历史」段最顶部（最新在最前），紧邻 `[2026-05-07] Phase 2` 条目**之上**。

From CLAUDE.md「项目修改记录规则」：
- `qy_lty/docs/修改记录.md` 仅记录服务端改动；qy-lty-admin 各自维护
- 跨项目联动：两端各写一条，相互引用；本 phase **无跨项目联动**（CONTEXT 显式声明）—— Unity 客户端在 LTY_Project / LTY_App_Project_URP 独立 repo，不在 qy-lty-admin 范畴
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: 新建 common/logging/ 包目录 + AccessTokenMaskFilter 类（4 regex + filter 方法）</name>
  <files>common/logging/__init__.py, common/logging/filters.py, _phase3_02_unit_test.py</files>

  <read_first>
    1. **完整读 `common/utils.py:10-32`** —— `mask_token` 行为契约（空输入返回 ''、短于 4 全脱、其余末 4 位明文）。注意 `mask_token('abcdefgh1234')` 输出 `'********1234'`（8 星 + 4 位），共 12 字符
    2. 读 `common/aliyun_logging.py:16-30` —— `AliyunLogHandler.emit` 用 `record.getMessage()`，确认 filter 改 `record.msg` 后 emit 自然生效
    3. 读 RESEARCH Pattern 3（`AccessTokenMaskFilter` 完整骨架，4 个 regex + filter 方法）+ Pitfall 1-5（filter 挂错位置 / dictConfig 工厂语法等）
    4. 读 RESEARCH 「附录：access_token 实际泄露路径详细分析」段 —— 确认本 filter 是「防御性兜底」，不是「修补现有泄露」
    5. 验证 `common/` 是 utility 命名空间（已读 `common/utils.py:1-7` 注释「不是 Django app」），新建 `common/logging/` 子包不破坏此约定
    6. **检查现有目录**：`ls common/` 应不含 `logging/` 子目录（确认本 task 是纯新建）
  </read_first>

  <action>

**新建文件 1**：`common/logging/__init__.py`（**空文件**，让 `common.logging` 成为可 import 的 Python 包）。

**严格要求**：内容**完全为空**（0 字节或仅 1 个换行）。不要写任何代码、注释、`__all__`、版本号 —— 任何内容都可能在未来引入意外副作用。

**新建文件 2**：`common/logging/filters.py`，**完整**写入以下内容（不能简化、不能省略 docstring、不能删 4 个 regex 中任何一个）：

```python
"""通用日志脱敏 Filter 集合。

本模块挂载到 settings.LOGGING.filters，由 LOGGING.handlers.{aliyun|console} 引用，
覆盖所有 logger → handler 路径，对 record 内 access_token 字段值脱敏（保留末 4 位）。

设计动机（per CONTEXT.md / RESEARCH.md）：
- 当前仓库代码没有 logger 输出 CredentialSlot.access_token 明文的路径（已实证）
- 但 Phase 1 + Phase 2 + Phase 3 的 view 已让 access_token 进入「内存中可被随手 dump」的状态
- 任何后续开发者写 logger.info(f"PUT body: {request.data}") 类型代码就会泄露
- 本 filter 是「防御性兜底」：在 handler 层面统一兜住，不依赖每个 view 自律
"""
import logging
import re

from common.utils import mask_token


class AccessTokenMaskFilter(logging.Filter):
    """识别日志记录中的 access_token 明文值并脱敏（保留末 4 位）。

    覆盖 4 种序列化形态：
      1. JSON 字符串（双引号）：    '"access_token": "VALUE"'
      2. Python dict repr（单引号）："'access_token': 'VALUE'"
      3. URL query：               'access_token=VALUE&...'
      4. 兜底（等号或冒号 + 空格）： 'access_token: VALUE' / 'access_token = VALUE'

    挂载点：
        settings.LOGGING.filters['access_token_mask']
        → settings.LOGGING.handlers.{aliyun|console}.filters

    设计要点：
    - 仅识别 access_token 字段名为前缀锚点；不脱敏裸 token / Bearer / Authorization header
      （那是另一类敏感数据，留 v2.x 候选优先级 #1 / #3 处理；详见 RESEARCH Pitfall 3）
    - 同时改 record.msg 与 record.args，避免 Formatter 阶段再用 % 拼接出明文
      （详见 RESEARCH Pitfall 2）
    - filter() 永远 return True，不丢弃 record（详见 RESEARCH Pitfall 1）
    """

    # 4 种序列化形态对应的正则模式
    _PATTERNS = (
        # 1) JSON 字符串：双引号；group 顺序 (前缀, 值, 后缀)
        re.compile(r'("access_token"\s*:\s*")([^"]+)(")'),
        # 2) Python dict repr：单引号；group 顺序 (前缀, 值, 后缀)
        re.compile(r"('access_token'\s*:\s*')([^']+)(')"),
        # 3) URL query：以 & / 空格 / 引号结尾；group 顺序 (前缀, 值)
        re.compile(r'(access_token=)([^&\s"\']+)'),
        # 4) 兜底：等号 / 冒号 + 可选空格；group 顺序 (前缀, 值)
        #    用 [^\s,;)\]\}"\']+ 作为终止符以避免吃到下一个字段
        re.compile(r'(access_token\s*[:=]\s*)([^\s,;)\]\}"\']+)'),
    )

    # 快速短路：record.msg / args 中没有 access_token 字面量时直接返回，避免 4 次正则扫描
    _NEEDLE = 'access_token'

    def _sub(self, match: 're.Match') -> str:
        """根据 group 数（2 或 3）调用 mask_token 重组匹配段。"""
        groups = match.groups()
        if len(groups) == 3:
            # 模式 1 / 2：('"access_token":"', VALUE, '"')
            return groups[0] + mask_token(groups[1]) + groups[2]
        if len(groups) == 2:
            # 模式 3 / 4：('access_token=', VALUE)
            return groups[0] + mask_token(groups[1])
        return match.group(0)  # 防御：未来若加新模式 group 数变了，原样返回避免崩溃

    def _mask_in_text(self, text):
        """对单个字符串依次应用 4 个正则；非字符串原样返回。"""
        if not isinstance(text, str):
            return text
        if self._NEEDLE not in text.lower():
            return text
        for pattern in self._PATTERNS:
            text = pattern.sub(self._sub, text)
        return text

    def filter(self, record: logging.LogRecord) -> bool:
        # 1. record.msg 字符串脱敏（最常见的 logger.info("xxx %s", x) 之 "xxx %s" 部分；以及 logger.info(plain_str) 整串）
        if isinstance(record.msg, str):
            record.msg = self._mask_in_text(record.msg)

        # 2. record.args 中的元素脱敏
        #    - dict 形态（logger.info("k=%(access_token)s", {'access_token': '...'})）：按 key 名直接脱敏值
        #    - tuple 形态（logger.info("token=%s", token_str)）：按字符串内容脱敏
        if record.args:
            if isinstance(record.args, dict):
                new_args = {}
                for k, v in record.args.items():
                    if k == 'access_token' and isinstance(v, str):
                        new_args[k] = mask_token(v)
                    elif isinstance(v, str):
                        new_args[k] = self._mask_in_text(v)
                    else:
                        new_args[k] = v
                record.args = new_args
            elif isinstance(record.args, tuple):
                record.args = tuple(
                    self._mask_in_text(a) if isinstance(a, str) else a
                    for a in record.args
                )

        # 永远不丢弃 record；filter 仅做改写
        return True
```

**新建文件 3（临时单元测试，verify 用，跑完不入 git）**：`_phase3_02_unit_test.py`（落地到仓库根，让 `python _phase3_02_unit_test.py` 直接跑；避免在 PowerShell 下用多行 `python -c` 引号转义易碎）：

```python
"""Phase 3 Plan 02 Task 1 单元测试（filter 行为）。

跑：
    python _phase3_02_unit_test.py

验：
    1. import 链 OK + 继承 logging.Filter
    2. _PATTERNS 长度 = 4
    3. 4 种序列化形态（JSON / Pyrepr / Query / Fallback）下伪 LogRecord 处理后含末 4 位、不含完整明文
    4. 不误伤 Authorization header / Bearer 字段
    5. tuple 形态 record.args 脱敏
    6. 短 token（< 4 字符）全脱，不暴露长度
    7. filter() 永远 return True

跑完即删（由 Plan 03-02 Task 3 / Task 4 负责清理）。
"""
import logging

from common.logging.filters import AccessTokenMaskFilter

# 1. 继承关系
assert AccessTokenMaskFilter.__bases__[0].__name__ == 'Filter', '应继承 logging.Filter'
print('OK: import + 继承 logging.Filter')

# 2. 4 模式
assert len(AccessTokenMaskFilter._PATTERNS) == 4, f'_PATTERNS 长度应 = 4，实际 {len(AccessTokenMaskFilter._PATTERNS)}'
print('OK: 4 patterns')

f = AccessTokenMaskFilter()

# 3. 4 种形态 —— 用宽松断言（与 Plan 02 Task 3 T6 风格一致），避开 mask_token 长度计算细节
fixtures = [
    ('JSON',     '{"access_token": "abcdefgh1234"}'),
    ('Pyrepr',   "{'access_token': 'abcdefgh1234'}"),
    ('Query',    'GET /x?access_token=abcdefgh1234&u=1'),
    ('Fallback', 'access_token: abcdefgh1234'),
]
for name, msg in fixtures:
    rec = logging.LogRecord('t', logging.INFO, '', 0, msg, None, None)
    ret = f.filter(rec)
    assert ret is True, f'{name}: filter() 应永远返回 True，实际 {ret!r}'
    out = rec.getMessage()
    assert '1234' in out,         f'{name}: 末 4 位丢失 - {out!r}'
    assert 'abcdefgh' not in out, f'{name}: 明文未脱敏 - {out!r}'
    print(f'OK [{name}]: {out}')

# 4. 不误伤 Authorization header / Bearer
for msg in ['Authorization header: bearer_user_token_xxxxxxx', 'Bearer raw_token_zzz']:
    rec = logging.LogRecord('t', logging.INFO, '', 0, msg, None, None)
    f.filter(rec)
    out = rec.getMessage()
    assert out == msg, f'误伤了非 access_token 字段：原={msg!r} 出={out!r}'
    print(f'OK 不误伤: {msg}')

# 5. tuple 形态 record.args 脱敏（logger.info('access_token=%s', token_str)）
rec = logging.LogRecord('t', logging.INFO, '', 0, 'access_token=%s', ('abcdefgh1234',), None)
f.filter(rec)
out = rec.getMessage()
assert 'abcdefgh' not in out, f'tuple args 未脱敏: {out!r}'
print(f'OK tuple args: {out}')

# 6. 短 token 全脱
rec = logging.LogRecord('t', logging.INFO, '', 0, '{"access_token": "abc"}', None, None)
f.filter(rec)
out = rec.getMessage()
assert 'abc' not in out, f'短 token 未脱敏: {out!r}'
assert '***' in out,    f'短 token 应全部用 * 替换: {out!r}'
print(f'OK 短 token: {out}')

print('=' * 60)
print('ALL UNIT TESTS PASS')
```

**严格禁止做的事**：
- ✗ 不要把 `mask_token` 改成局部 lambda（保持 import 复用 Phase 1 的实现，避免双源漂移）
- ✗ 不要在 filter 里用 `print()` 调试（filter 在 logging 链路内，print 会触发递归）
- ✗ 不要让 filter 返回 `False`（False 会丢弃 record，破坏其它日志输出）
- ✗ 不要用 `re.sub` 时把 `mask_token` 直接传入 —— 必须通过 `self._sub` 拿 `match.groups()` 处理
- ✗ 不要把 4 个 regex 合并成 1 个大 regex（可读性 + group 数变量化会变脆）
- ✗ 不要把模式扩展到 `Authorization` / `Bearer` / `token` 裸字段（违反 Pitfall 3）
- ✗ 不要让 `_NEEDLE` 大小写敏感失效（用户大概率写 `'access_token'` 小写；若需大小写不敏感可未来迭代）
- ✗ `_phase3_02_unit_test.py` 不要 git add（验完由 Plan 03-02 Task 3 / Task 4 清理）
  </action>

  <verify>
    <automated>
# 1. 文件存在 + 是包
python -c "import os; assert os.path.isfile('common/logging/__init__.py'); assert os.path.isfile('common/logging/filters.py'); print('OK: package files exist')"

# 2. __init__.py 是空文件（≤ 2 字节，允许结尾换行）
python -c "import os; sz = os.path.getsize('common/logging/__init__.py'); assert sz <= 2, f'__init__.py 应为空文件，实际 {sz} 字节'; print('OK: __init__.py empty')"

# 3. 跑临时单元测试脚本（覆盖：import + 继承、4 模式、4 形态脱敏、不误伤、tuple args、短 token、filter return True）
#    用临时脚本而非多行 python -c，避免 PowerShell 嵌套引号转义问题
python _phase3_02_unit_test.py

# 4. settings 没在本 task 改动（Task 2 才改）—— 防御性快速 grep
python -c "src=open('qy_lty/settings.py',encoding='utf-8').read(); assert 'access_token_mask' not in src, 'Task 1 不应改 settings.py，那是 Task 2 的工作'; print('OK: Task 1 不动 settings.py')"
    </automated>
  </verify>

  <acceptance_criteria>
    - `common/logging/__init__.py` 存在且为空文件（≤ 2 字节）
    - `common/logging/filters.py` 存在，含 `class AccessTokenMaskFilter(logging.Filter)`
    - `from common.logging.filters import AccessTokenMaskFilter` 不报错
    - `AccessTokenMaskFilter._PATTERNS` 长度 = 4
    - `python _phase3_02_unit_test.py` 输出 `ALL UNIT TESTS PASS`
    - 4 种形态（JSON / Pyrepr / Query / Fallback）下伪 LogRecord 处理后 `record.getMessage()` 含 `1234` 末 4 位、不含完整明文 `abcdefgh`
    - `Authorization header:` 和 `Bearer` 等非 access_token 字段值未被脱敏
    - tuple 形态 `record.args` 中的 access_token 值被脱敏
    - 短于 4 字符的 token 全部脱敏（不暴露长度）
    - filter 返回 `True`（不丢弃 record）
    - `qy_lty/settings.py` 未被本 task 改动（settings 改动归 Task 2）
  </acceptance_criteria>

  <done>
    `common/logging/__init__.py` 与 `common/logging/filters.py` 已创建；`_phase3_02_unit_test.py` 输出 `ALL UNIT TESTS PASS`，覆盖 import / 4 模式 / 4 形态 / 不误伤 / tuple args / 短 token / filter() return True。
  </done>
</task>

<task type="auto">
  <name>Task 2: 在 qy_lty/settings.py 的 LOGGING 字典注册 filter（新增 filters 段 + handlers.aliyun/console 各加 filters 引用）</name>
  <files>qy_lty/settings.py</files>

  <read_first>
    1. **完整读 `qy_lty/settings.py:370-412`** —— 当前 LOGGING 字典全貌，确认无 `filters` 段，handlers 仅 `aliyun` / `console` 两个，loggers 有 5 条
    2. 读 RESEARCH Example 2 —— 完整 diff（修改前 / 修改后）+ Pitfall 5（dictConfig 工厂语法 `()` 而非 `class`）
    3. 读 RESEARCH Pitfall 1 —— filter 必须挂在 handlers 段，**不**挂在 loggers 段
    4. 确认 `setup_logging()` 在 LOGGING 字典 dictConfig **之前**调用（`settings.py:371`）—— `setup_logging` 直接 addHandler 给 root logger，不走 dictConfig，本 task 改动不影响它
  </read_first>

  <action>

**修改 `qy_lty/settings.py`**，定位在第 372-412 行的 `LOGGING = {...}` 块。改动有 3 处。

**修改前**（verbatim，行 372-412 当前实测）：
```python
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'handlers': {
        'aliyun': {
            'level': 'INFO',
            'class': 'common.aliyun_logging.AliyunLogHandler',
        },
        'console': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
        },
    },
    'loggers': {
        'django': {
            'handlers': ['aliyun', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
        'django.request': {
            'handlers': ['console'],
            'level': 'ERROR',
            'propagate': False,
        },
        'aiapp': {
            'handlers': ['aliyun', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
        'common': {
            'handlers': ['aliyun', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
        'userapp': {
            'handlers': ['aliyun', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
    },
}
```

**修改后**（**整段替换**为以下内容，3 处 diff：① 在 `'disable_existing_loggers': False,` 与 `'handlers': {` 之间插入 `'filters'` 段；② `'aliyun'` handler 加 `'filters': ['access_token_mask'],`；③ `'console'` handler 加 `'filters': ['access_token_mask'],`；loggers 段**完全不动**）：

```python
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    # Phase 3 — Access Token 日志脱敏 filter（CRED-06）
    # 挂载策略：filter 注册在 LOGGING.filters，再由 LOGGING.handlers 引用；
    # 不挂在 loggers 段（per RESEARCH Pitfall 1：挂 logger 仅过滤直接通过该 logger 的 record，
    # 挂 handler 才统一覆盖所有 logger → handler 路径）
    'filters': {
        'access_token_mask': {
            '()': 'common.logging.filters.AccessTokenMaskFilter',
        },
    },
    'handlers': {
        'aliyun': {
            'level': 'INFO',
            'class': 'common.aliyun_logging.AliyunLogHandler',
            'filters': ['access_token_mask'],
        },
        'console': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
            'filters': ['access_token_mask'],
        },
    },
    'loggers': {
        'django': {
            'handlers': ['aliyun', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
        'django.request': {
            'handlers': ['console'],
            'level': 'ERROR',
            'propagate': False,
        },
        'aiapp': {
            'handlers': ['aliyun', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
        'common': {
            'handlers': ['aliyun', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
        'userapp': {
            'handlers': ['aliyun', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
    },
}
```

**严格禁止做的事**：
- ✗ 不要在 filters 段写 `'class': 'common.logging.filters.AccessTokenMaskFilter'` —— 必须用 `'()': '...'`（dictConfig 标准；handler 用 `'class'` 但 filter 用 `'()'`，两者语法不互通）
- ✗ 不要在 loggers 段加 filters 引用（违反 Pitfall 1）
- ✗ 不要去掉 `setup_logging()` 调用（行 371，那是阿里云 root handler 的独立路径，与 LOGGING dictConfig 互补）
- ✗ 不要改 handlers 的 `level` / `class` 字段
- ✗ 不要改 loggers 的任何字段（5 条 logger 完全保持原样）
- ✗ 不要把 `disable_existing_loggers` 改成 `True`
  </action>

  <verify>
    <automated>
# 1. settings.py 文件含新内容
python -c "src=open('qy_lty/settings.py',encoding='utf-8').read(); assert \"'access_token_mask'\" in src; assert \"'()': 'common.logging.filters.AccessTokenMaskFilter'\" in src; assert src.count(\"['access_token_mask']\") >= 2, 'aliyun + console 两个 handler 都要挂 filter'; print('OK: settings 改动落地')"

# 2. Django 启动 + LOGGING dictConfig 加载无 ValueError
python -c "import django; import os; os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'qy_lty.settings'); django.setup(); print('OK: Django 启动，LOGGING dictConfig 加载成功')"

# 3. 实际拿到 filter 实例（验证 dictConfig 工厂语法 () 写正确）
python -c "
import django, os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'qy_lty.settings')
django.setup()
import logging
aliyun_handler  = next(h for h in logging.getLogger('aiapp').handlers + logging.getLogger().handlers if 'AliyunLogHandler' in type(h).__name__)
console_handler = next(h for h in logging.getLogger('aiapp').handlers if 'StreamHandler' in type(h).__name__ and 'Aliyun' not in type(h).__name__)
for name, h in [('aliyun', aliyun_handler), ('console', console_handler)]:
    fnames = [type(f).__name__ for f in h.filters]
    assert 'AccessTokenMaskFilter' in fnames, f'{name} handler 缺 AccessTokenMaskFilter (got {fnames})'
    print(f'OK [{name}] filters: {fnames}')
"

# 4. 端到端：在 aiapp logger 上 logger.info('access_token=secret_zzz') 后 console 输出脱敏
python -c "
import django, os, io, contextlib, logging
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'qy_lty.settings')
django.setup()
log = logging.getLogger('aiapp')
buf = io.StringIO()
# 找 console handler 并临时把 stream 接到 buf
console = next(h for h in log.handlers if 'StreamHandler' in type(h).__name__ and 'Aliyun' not in type(h).__name__)
orig_stream = console.stream
console.stream = buf
try:
    log.info('access_token=secret_zzz_ABCD')
finally:
    console.stream = orig_stream
out = buf.getvalue()
assert 'secret_zzz_' not in out, f'明文泄露到 stderr: {out!r}'
assert 'ABCD' in out,            f'末 4 位丢失: {out!r}'
print(f'OK 端到端：{out.strip()}')
"

# 5. 既有 logger 配置未动
python -c "src=open('qy_lty/settings.py',encoding='utf-8').read(); assert src.count(\"'handlers': ['aliyun', 'console']\") == 4, '应有 4 条 logger 用 [aliyun, console] handler 列表'; assert \"'level': 'INFO'\" in src; print('OK: loggers 段保持原状')"
    </automated>
  </verify>

  <acceptance_criteria>
    - `qy_lty/settings.py` 的 `LOGGING` 字典含 `'filters'` 段，且 `'access_token_mask'` 用 `'()': 'common.logging.filters.AccessTokenMaskFilter'` 工厂语法
    - `LOGGING.handlers.aliyun.filters` = `['access_token_mask']`
    - `LOGGING.handlers.console.filters` = `['access_token_mask']`
    - `loggers` 段 5 条 logger 完全未动
    - `django.setup()` 不报 `ValueError: Unable to configure filter ...`
    - `logging.getLogger('aiapp').handlers` 中两个 handler 的 `filters` 列表均含 `AccessTokenMaskFilter` 实例
    - 端到端：`logger.info('access_token=secret_zzz_ABCD')` 后 console 输出含 `ABCD` 不含 `secret_zzz_`
  </acceptance_criteria>

  <done>
    LOGGING dictConfig 已扩展 filters 段并由 aliyun/console handler 引用；Django 启动无 ValueError；filter 实例已挂载，端到端输出验证脱敏生效。
  </done>
</task>

<task type="auto">
  <name>Task 3: 端到端验收脚本（CRED-05 + CRED-06 整合验证 + 还原 DB + 删除临时文件）</name>
  <files>_phase3_02_verify.py</files>

  <read_first>
    1. 完整读 `_phase3_01_verify.py`（plan 03-01 创建的临时脚本，仍在 working tree）
    2. 读 `.planning/phases/02-admin-rest/02-VERIFICATION.md`（如存在）—— 参考端到端脚本的「证据落地 → 脚本删除」流程
    3. 读 `userapp/utils.py:generate_token` —— Redis cache key 体系
    4. 读 `aiapp/views.py:600-687`（`CredentialSlotAdminView`）—— 端到端 PUT roundtrip 测试需要走 admin token
  </read_first>

  <action>

在仓库根创建 `_phase3_02_verify.py`，跑完即删（**本 task 包含删除步骤**）。脚本验收 9 条 success criteria（覆盖 Plan 03-01 的 5 条 + Plan 03-02 的 3 条 filter 单元测试 + 1 条端到端 logger 真实输出）：

```python
"""Phase 3 Plan 02 整合验收脚本（CRED-05 + CRED-06 端到端）。

9 条 truth：
  T1: client GET 携 user token → 200 + 明文（覆盖 plan 03-01 已验项目）
  T2: client GET 携 admin token → 200 + 明文（不区分）
  T3: client GET 无 token → 401
  T4: client GET 伪造 token → 401
  T5: /swagger.json/ 含 /credential-slot/ 路径
  T6: AccessTokenMaskFilter 对 4 种形态（JSON / Pyrepr / Query / Fallback）伪 LogRecord 全脱敏
  T7: AccessTokenMaskFilter 不误伤 Authorization header / Bearer 字段
  T8: 端到端 admin PUT roundtrip → client GET 拿到一致明文（往返一致）
  T9: 端到端 logger.info 真打印一条含 access_token 的消息 → console 输出脱敏（防御性兜底真实生效）

执行：
  python manage.py shell < _phase3_02_verify.py
"""
import io
import json
import logging
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 ok(name, cond, hint=''):
    (PASS if cond else FAIL).append(f'{name}{": " + hint if hint else ""}')

def reset_probe():
    """还原 DB 探针态：app_id=probe_app, access_token=probe_secret_xxxx"""
    slot, _ = CredentialSlot.objects.get_or_create(pk=1)
    slot.app_id, slot.access_token = 'probe_app', 'probe_secret_xxxx'
    slot.save()
    return slot

# 准备：DB 探针 + 两个 token
slot = reset_probe()
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')

admin_token = secrets.token_hex(18)
user_token  = secrets.token_hex(18)
cache.set(f'admin_token:{admin_token}', staff.id,  timeout=600)
cache.set(f'token:{user_token}',        normal.id, timeout=600)

c = Client()

# T1: user token → 200 + 明文
r = c.get('/api/credential-slot/', HTTP_AUTHORIZATION=f'Bearer {user_token}')
b = r.json()
ok('T1.user_token_200',       r.status_code == 200,                          f'sc={r.status_code}')
ok('T1.success_true',         b.get('success') is True)
ok('T1.app_id',               b['data'].get('app_id') == 'probe_app')
ok('T1.access_token_plain',   b['data'].get('access_token') == 'probe_secret_xxxx', 'should be plaintext')
ok('T1.no_mask_in_response',  '*' not in (b['data'].get('access_token') or ''))

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

# T3: 无 token → 401
r = c.get('/api/credential-slot/')
ok('T3.no_token_401',         r.status_code == 401)
ok('T3.success_false',        r.json().get('success') is False)

# T4: 伪造 token → 401
r = c.get('/api/credential-slot/', HTTP_AUTHORIZATION='Bearer fake_token_zzz_NOT_IN_REDIS')
ok('T4.fake_token_401',       r.status_code == 401)

# T5: swagger 暴露
r = c.get('/swagger.json/')
ok('T5.swagger_200',          r.status_code == 200)
schema = r.json()
if 'paths' not in schema and 'data' in schema:
    schema = schema['data']
paths = schema.get('paths', {})
ok('T5.path_in_schema',       '/credential-slot/' in paths,                 f'keys={list(paths.keys())[:5]}...')
if '/credential-slot/' in paths:
    ops = paths['/credential-slot/']
    ok('T5.has_get',          'get' in ops)

# T6: filter 4 种形态（伪 LogRecord）—— 用宽松断言
from common.logging.filters import AccessTokenMaskFilter
f = AccessTokenMaskFilter()
fixtures = {
    'JSON':     '{"access_token": "abcdefgh1234"}',
    'Pyrepr':   "{'access_token': 'abcdefgh1234'}",
    'Query':    'GET /x?access_token=abcdefgh1234&u=1',
    'Fallback': 'access_token: abcdefgh1234',
}
for name, msg in fixtures.items():
    rec = logging.LogRecord('t', logging.INFO, '', 0, msg, None, None)
    f.filter(rec)
    out = rec.getMessage()
    ok(f'T6.{name}.no_plain', 'abcdefgh' not in out, f'out={out!r}')
    ok(f'T6.{name}.has_tail', '1234' in out)

# T7: 不误伤 Authorization header / Bearer
for msg in ['Authorization header: bearer_user_token_xxxxxxx', 'Bearer raw_token_zzz']:
    rec = logging.LogRecord('t', logging.INFO, '', 0, msg, None, None)
    f.filter(rec)
    ok(f'T7.unmodified_{msg[:15]}', rec.getMessage() == msg, f'out={rec.getMessage()!r}')

# T8: 端到端 admin PUT roundtrip → client GET 一致
roundtrip_token = 'rt_secret_RT99'
r = c.put(
    '/api/v1/admin/credential-slot/',
    data=json.dumps({'app_id': 'roundtrip_test', 'access_token': roundtrip_token}),
    content_type='application/json',
    HTTP_AUTHORIZATION=f'Bearer {admin_token}',
)
ok('T8.put_200',             r.status_code == 200, f'sc={r.status_code}')
# admin GET 应脱敏
r = c.get('/api/v1/admin/credential-slot/', HTTP_AUTHORIZATION=f'Bearer {admin_token}')
b = r.json()
ok('T8.admin_get_masked',    b['data'].get('access_token') != roundtrip_token,
   f'admin GET 应脱敏 got={b["data"].get("access_token")!r}')
ok('T8.admin_get_tail_RT99', b['data'].get('access_token', '').endswith('RT99'))
# client GET 应明文
r = c.get('/api/credential-slot/', HTTP_AUTHORIZATION=f'Bearer {user_token}')
b = r.json()
ok('T8.client_get_plain',    b['data'].get('access_token') == roundtrip_token,
   f'client GET 应明文 got={b["data"].get("access_token")!r}')
ok('T8.client_app_id',       b['data'].get('app_id') == 'roundtrip_test')

# T9: 端到端 logger.info 真打印 → console 脱敏（防御性兜底真实生效）
log = logging.getLogger('aiapp')
console = next((h for h in log.handlers if 'StreamHandler' in type(h).__name__ and 'Aliyun' not in type(h).__name__), None)
if console is None:
    FAIL.append('T9.no_console_handler 找不到 console handler')
else:
    buf = io.StringIO()
    orig = console.stream
    console.stream = buf
    try:
        log.info('防御性测试：access_token=defensive_secret_DEFC')
    finally:
        console.stream = orig
    out = buf.getvalue()
    ok('T9.logger_info_no_plain', 'defensive_secret_' not in out, f'out={out.strip()!r}')
    ok('T9.logger_info_tail',     'DEFC' in out)

# 还原 + 清理
slot = reset_probe()
ok('T_FINAL.db_restored.app_id',       slot.app_id == 'probe_app')
ok('T_FINAL.db_restored.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 x in FAIL:
        print(f'  ✗ {x}')
    raise SystemExit(1)
print('ALL PASS')
```

**执行 + 落地证据 + 删除**：

```bash
# 1. 执行验收
python manage.py shell < _phase3_02_verify.py

# 2. 把 stdout 复制粘贴进 SUMMARY.md（PASS 列表作为证据）
#    建议用 PowerShell：
#    python manage.py shell < _phase3_02_verify.py 2>&1 | Tee-Object -FilePath .planning/phases/03-client-and-log-mask/03-VERIFICATION.md.tmp

# 3. 验收完毕删除三个临时脚本（_phase3_01_verify.py + _phase3_02_unit_test.py + _phase3_02_verify.py）
rm _phase3_01_verify.py
rm _phase3_02_unit_test.py
rm _phase3_02_verify.py
```

**严格禁止做的事**：
- ✗ 不要把 `_phase3_*.py`（含 `_phase3_01_verify.py` / `_phase3_02_unit_test.py` / `_phase3_02_verify.py`）git add（验收一次性产物，不入仓库）
- ✗ 不要把 `roundtrip_token = 'rt_secret_RT99'` / `defensive_secret_DEFC` 等测试值写到 SUMMARY 之外的可被搜索的文档（只在脚本中即时生成、cache 自动 release）
- ✗ 不要在脚本里依赖 daphne / runserver 进程（用 `django.test.Client` in-process）
- ✗ 不要跳过「还原 DB 探针态」步骤 —— 必须在脚本结束前 `app_id='probe_app' / access_token='probe_secret_xxxx'`，给后续工作留稳定起点
- ✗ 不要忘了 cache.delete 清理临时 token key
  </action>

  <verify>
    <automated>
# 1. 跑端到端验收（脚本退出码即结果）
python manage.py shell < _phase3_02_verify.py

# 2. 验收完毕删除三个临时脚本
python -c "import os; [os.remove(f) for f in ['_phase3_01_verify.py', '_phase3_02_unit_test.py', '_phase3_02_verify.py'] if os.path.exists(f)]; print('OK: 临时脚本已删除')"

# 3. 确认 working tree 中无 _phase3_*.py 残留
python -c "import os, glob; assert not glob.glob('_phase3_*.py'), '仍有残留临时脚本'; print('OK: working tree 清洁')"

# 4. 确认 DB 探针态保持
python manage.py shell -c "from aiapp.models import CredentialSlot; s = CredentialSlot.objects.get(pk=1); assert s.app_id == 'probe_app' and s.access_token == 'probe_secret_xxxx', f'DB 探针未还原 {s.app_id}/{s.access_token}'; print('OK: DB probe restored')"
    </automated>
  </verify>

  <acceptance_criteria>
    - `_phase3_02_verify.py` 输出 `ALL PASS`，PASS 行数 ≥ 25（9 truth × 平均 2-3 断言）
    - FAIL 行数 = 0
    - DB 探针态：`pk=1 / app_id='probe_app' / access_token='probe_secret_xxxx'`
    - Redis 中 `admin_token:*` / `token:*` 测试 key 已 cache.delete
    - working tree 中无 `_phase3_*.py` 残留（三个临时脚本均已删除）
    - 验收输出已被人工复制粘贴到 SUMMARY.md（task 4 会引用）
  </acceptance_criteria>

  <done>
    9 条端到端 truth 全 PASS；DB 探针还原；临时 token 清理；三个临时验收脚本（`_phase3_01_verify.py` / `_phase3_02_unit_test.py` / `_phase3_02_verify.py`）已删除。
  </done>
</task>

<task type="auto">
  <name>Task 4: 顶部追加 docs/修改记录.md 条目（覆盖 client view + filter + LOGGING 三处改动）</name>
  <files>docs/修改记录.md</files>

  <read_first>
    1. **完整读 `docs/修改记录.md` 行 1-80**（已读）—— 确认头部「修改格式说明」段 + 现有 3 条 Phase 1/2 条目；新条目追加在「修改历史」段最顶部，紧邻 `[2026-05-07] Phase 2` 之上
    2. 读 CLAUDE.md「项目修改记录规则」+「`qy_lty` 与 `qy-lty-admin` 是独立项目，各自维护」段 —— 确认本 phase 不写 qy-lty-admin 互引（CONTEXT 锁定决策 + RESEARCH 实证）
    3. 读 RESEARCH「附录：access_token 实际泄露路径详细分析」—— 把「防御性兜底」语义写入修改原因
    4. 读 `.planning/phases/03-client-and-log-mask/03-01-SUMMARY.md`（plan 03-01 写的）—— 确认 client view 的实际行号 / 文件
    5. **执行前**记录 phase 启动时点：把 `qy-lty-admin/docs/修改记录.md` 的 `os.path.getmtime(...)` 写入临时变量（用于本 task verify #4 的 mtime 比较，替代 git diff cwd='..'）。命令：`python -c "import os, json, pathlib; mt = os.path.getmtime('../qy-lty-admin/docs/修改记录.md') if os.path.exists('../qy-lty-admin/docs/修改记录.md') else None; pathlib.Path('_phase3_admin_mtime.txt').write_text(str(mt)); print(mt)"`（**在改 docs/修改记录.md 之前**跑这一行；mtime 落在 `_phase3_admin_mtime.txt`，验收完一并删）
  </read_first>

  <action>

在 `docs/修改记录.md` 行 25 附近（紧邻 `### [2026-05-07] Phase 2 — 管理端通用凭据槽位 REST 接口（GET 脱敏 / PUT 覆写）` **之上**、`<!-- 新的修改记录添加在此处下方，最新的在最前面 -->` **之下**）插入以下条目（保持「最新在最前」顺序）：

```markdown
### [2026-05-08] Phase 3 — 客户端凭据槽位 GET 接口 + 阿里云日志 access_token 脱敏

配套 Phase：[.planning/phases/03-client-and-log-mask/](.planning/phases/03-client-and-log-mask/)
覆盖需求：CRED-05 + CRED-06
设计参考：1:1 复刻 `aiapp.views.CredentialSlotAdminView` 的 GET 部分（删 `_ensure_admin` / `_build_response_data` / PUT 三处），实现明文返回客户端 view；新建 `common/logging/filters.py:AccessTokenMaskFilter` 作为 LOGGING.handlers 层防御性兜底

- **文件路径**:
  - `aiapp/views.py`（修改 — 文件末尾追加 `_credential_slot_client_data_schema` 客户端响应 schema + `CredentialSlotClientView` APIView 类，仅 GET，明文返回；imports 段未动；Phase 2 既有 `CredentialSlotAdminView` 未动）
  - `qy_lty/urls.py`（修改 — imports 段追加 `from aiapp.views import CredentialSlotClientView`；`api_urlpatterns` 列表中追加 `path('credential-slot/', CredentialSlotClientView.as_view(), name='client_credential_slot')`，注册位置：`common/upload/` 之后、`v1/admin/` 之前）
  - `common/logging/__init__.py`（**新建** — 空文件，让 `common.logging` 成为可 import 的 Python 包）
  - `common/logging/filters.py`（**新建** — `AccessTokenMaskFilter(logging.Filter)` 类 + 4 个 regex 模式（JSON / Python dict repr / URL query / 等号或冒号兜底）+ `filter()` 方法重写 `record.msg` 与 `record.args` 中的 access_token 字段值为 `mask_token(value)` 输出）
  - `qy_lty/settings.py`（修改 — `LOGGING` 字典新增 `'filters'` 段（用 `'()': 'common.logging.filters.AccessTokenMaskFilter'` dictConfig 工厂语法）；`'handlers'.aliyun` 与 `'handlers'.console` 各追加 `'filters': ['access_token_mask']`；loggers 段 5 条 logger 完全未动）
- **修改类型**: 新增
- **修改内容**:
  - 暴露 `GET /api/credential-slot/`（路径与管理端 `/api/v1/admin/credential-slot/` **完全分开**，客户端走 `/api/` 一级命名空间不进 `v1/admin/` 子路径）：`RedisTokenAuthentication` + `IsAuthenticated`，**不**做 is_staff 二次校验（admin / user token 都允许；admin 用户是手机用户超集，CONTEXT 锁定决策）；返回 `{ success, code, message, data: { app_id, access_token: <**明文**>, updated_at } }`，Access Token 直接返回 `serializer.data`（不调 `mask_token`），供手机端（LTY_App_Project_URP）/ 设备端（LTY_Project）实际调用阿里云 / 火山 / 腾讯第三方服务
  - 新建 `AccessTokenMaskFilter`：4 个正则模式覆盖 JSON 字符串（`"access_token":"VALUE"`）、Python dict repr（`'access_token':'VALUE'`）、URL query（`access_token=VALUE`）、等号或冒号兜底（`access_token: VALUE`）共 4 种序列化形态；filter 同时改 `record.msg` 与 `record.args`（避免 Formatter 阶段再用 `%` 拼接出明文，per RESEARCH Pitfall 2）；只匹配 `access_token` 字段名为前缀锚点，**不**误伤 `Authorization header:` / `Bearer` / 裸 user token（per RESEARCH Pitfall 3）；filter 永远 `return True` 不丢弃 record（per RESEARCH Pitfall 1）
  - LOGGING dictConfig 注册：filter 段用 `'()': '...'` 工厂语法（不是 `'class'`，per RESEARCH Pitfall 5）；filter 挂在 `handlers.aliyun` / `handlers.console` 两个 handler 上（**不**挂 loggers 段，per RESEARCH Pitfall 1 — 挂 logger 仅过滤直接通过该 logger 的 record，挂 handler 才统一覆盖所有 logger → handler 路径）；既有 5 条 logger 配置完全未动
  - Swagger / ReDoc 自动暴露：method-level `@swagger_auto_schema` 装饰器；响应 data schema 用独立 `_credential_slot_client_data_schema`，access_token 字段 description 显式标注「明文 Access Token，供手机/设备端实际调用第三方服务（管理端同接口会脱敏返回末 4 位）」，避免前端误解明文 / 脱敏
  - 不引入新依赖（沿用 Django 4.2.13 + DRF + drf-yasg + Phase 1/2 落地的 `CredentialSlot.get_solo` / `CredentialSlotSerializer` / `mask_token`）
- **修改原因**: Milestone v1.0「通用凭据槽位（APP ID + Access Token）」Phase 3 收尾 phase — 同时落地客户端读取（CRED-05）与日志脱敏（CRED-06）。客户端读取需要明文（手机/设备端 Unity 调阿里云 / 火山 / 腾讯 SDK 时第三方 API 校验 token 字符级一致），所以 view 层不脱敏；但「明文走 view」会让任何后续开发者写 `logger.info(f"PUT body: {request.data}")` 类代码立即把 access_token 打到阿里云日志服务，所以新增 LOGGING.handlers 层 filter 作为防御性兜底。RESEARCH 已实证：当前仓库**没有**任何代码 logger 输出 `CredentialSlot.access_token` 明文（`StandardResponseMiddleware` 不打日志、view 不显式 logger 字段、Django 默认 access log 不含 body），所以 CRED-06 的端到端验证靠**单元测试**伪造 LogRecord 验证 filter 行为（4 种序列化形态 + 不误伤 Authorization 字段）+ 1 条端到端 logger.info 真实输出脱敏验证，不靠端到端找泄露路径。这是 CRED-06 的真实价值 — 防御性兜底，让未来代码改动天然安全
- **跨项目联动**: 无 — 客户端 GET `/api/credential-slot/` 给 Unity 客户端（`LTY_Project` / `LTY_App_Project_URP`）使用，那两个 repo 各自维护修改记录，不在本仓库范畴；`qy-lty-admin`（Web 管理后台前端）**不消费**此接口（管理端走 Phase 2 落地的 `/api/v1/admin/credential-slot/`，由 admin token 鉴权 + 脱敏返回）。CLAUDE.md 跨项目规则下：本 phase 既不影响 qy-lty-admin 也不与 Unity 客户端在同一仓库，故不在 qy-lty-admin/docs/修改记录.md 写互引条目；Unity 客户端改动由 LTY_Project / LTY_App_Project_URP 在自身仓库各自记录
- **后续动作**: Milestone v1.0 至此完成；下一周期 milestone 候选见 `.planning/REQUIREMENTS.md` 「候选优先级」段（HIGH：ACH-02 / SMS 频率限制 / DEBUG 收紧 / 测试基础设施 / 测试 MAC 硬编码；MEDIUM：好感度 P2-P4 / Python 版本升级 / device_interaction 拆分）

```

**严格禁止做的事**：
- ✗ 不要在 `qy-lty-admin/docs/修改记录.md` 写互引条目（CONTEXT 锁定 + RESEARCH 实证；本 phase 与 qy-lty-admin 无 API 联动）
- ✗ 不要写「跨项目联动: 待补充」（明确写「无 —— 客户端给 Unity 用，不在 qy-lty-admin 范畴」）
- ✗ 不要把 4 处文件改动拆成 4 条独立条目（一个 phase = 一条修改记录条目，覆盖所有改动文件）
- ✗ 不要修改既有 Phase 1 / Phase 2 的 3 条条目（仅追加新条目）
- ✗ 不要把日期写成 `[2026-05-07]`（今天是 2026-05-08）
- ✗ 不要把临时脚本 `_phase3_*.py` 写进修改记录（不属于代码改动，已删除）
  </action>

  <verify>
    <automated>
# 1. 顶部已追加 Phase 3 条目
python -c "src=open('docs/修改记录.md',encoding='utf-8').read(); idx_p3 = src.find('[2026-05-08] Phase 3'); idx_p2 = src.find('[2026-05-07] Phase 2'); assert 0 < idx_p3 < idx_p2, f'Phase 3 条目位置错误：p3={idx_p3} p2={idx_p2}'; print('OK: Phase 3 条目在 Phase 2 之上（最新在最前）')"

# 2. 5 处文件改动都被记录
python -c "src=open('docs/修改记录.md',encoding='utf-8').read(); ph3 = src[src.find('[2026-05-08] Phase 3'):src.find('[2026-05-07] Phase 2')]; required = ['aiapp/views.py', 'qy_lty/urls.py', 'common/logging/__init__.py', 'common/logging/filters.py', 'qy_lty/settings.py']; missing = [f for f in required if f not in ph3]; assert not missing, f'缺文件: {missing}'; print('OK: 5 处文件改动均已记录')"

# 3. 跨项目联动字段存在且明确写「无」
python -c "src=open('docs/修改记录.md',encoding='utf-8').read(); ph3 = src[src.find('[2026-05-08] Phase 3'):src.find('[2026-05-07] Phase 2')]; assert '**跨项目联动**' in ph3, '缺跨项目联动字段'; assert ('无 ' in ph3 or '无—' in ph3 or '无—' in ph3), '跨项目联动应明确写「无」'; print('OK: 跨项目联动字段已明确')"

# 4. 不写互引：用 mtime 比对替代 git diff（qy_lty 与 qy-lty-admin 是独立 repo，git diff cwd='..' 在父目录非 repo 时会 fatal 被 || 兜掉等于空检查）。
#    比较 qy-lty-admin/docs/修改记录.md 的 mtime 与 phase 启动时记录的 mtime；若一致则未被本 phase 改动。
#    若 _phase3_admin_mtime.txt 缺失（read_first 漏跑），降级为「acceptance_criteria 人工核对」（非 BLOCK）。
python -c "
import os, pathlib
admin_md = '../qy-lty-admin/docs/修改记录.md'
mtime_file = '_phase3_admin_mtime.txt'
if not os.path.exists(admin_md):
    print('OK: qy-lty-admin/docs/修改记录.md 不存在（独立 repo 未克隆）—— 自然不可能被改')
elif not os.path.exists(mtime_file):
    print('SKIP: _phase3_admin_mtime.txt 缺失，降级为人工核对（见 acceptance_criteria）')
else:
    expected = pathlib.Path(mtime_file).read_text().strip()
    actual = str(os.path.getmtime(admin_md))
    assert expected == actual, f'qy-lty-admin/docs/修改记录.md 在本 phase 中被改动（启动 mtime={expected} 当前 mtime={actual}）'
    print(f'OK: qy-lty-admin/docs/修改记录.md mtime 未变（{actual}）')
"

# 5. CRED-05 + CRED-06 都被显式提及
python -c "src=open('docs/修改记录.md',encoding='utf-8').read(); ph3 = src[src.find('[2026-05-08] Phase 3'):src.find('[2026-05-07] Phase 2')]; assert 'CRED-05' in ph3 and 'CRED-06' in ph3; print('OK: CRED-05 + CRED-06 均显式标注')"

# 6. 清理 phase 启动时记录的 mtime 临时文件
python -c "import os; [os.remove(f) for f in ['_phase3_admin_mtime.txt'] if os.path.exists(f)]; print('OK: _phase3_admin_mtime.txt 已删除')"
    </automated>
  </verify>

  <acceptance_criteria>
    - `docs/修改记录.md` 顶部含 `### [2026-05-08] Phase 3 — 客户端凭据槽位 GET 接口 + 阿里云日志 access_token 脱敏` 条目
    - 条目位置：在 `<!-- 新的修改记录添加在此处下方，最新的在最前面 -->` 之下、`### [2026-05-07] Phase 2 ...` 之上
    - 5 处文件路径全部列出（`aiapp/views.py` / `qy_lty/urls.py` / `common/logging/__init__.py` / `common/logging/filters.py` / `qy_lty/settings.py`）
    - 修改类型 / 修改内容 / 修改原因 / 跨项目联动 4 段齐全（按 CLAUDE.md 规定格式）
    - 跨项目联动字段明确写「无 —— 客户端给 Unity (LTY_Project / LTY_App_Project_URP) 用」
    - 显式提及 CRED-05 + CRED-06 两个需求 ID
    - **人工核对**：在另一个终端 `cd ..\qy-lty-admin && git status` 应显示 `docs/修改记录.md` 未在 unstaged / staged 列表中（qy-lty-admin 与 qy_lty 是独立 repo，本 phase 不应触碰对方 docs；mtime 自动检查作为兜底但人工核对是权威）
    - 既有 Phase 1 / Phase 2 三条条目原文未动
    - phase 启动时点的 `_phase3_admin_mtime.txt` 已删除
  </acceptance_criteria>

  <done>
    Phase 3 修改记录条目已追加到 `docs/修改记录.md` 顶部；5 处文件改动 + CRED-05/06 + 跨项目联动「无」均已记录；qy-lty-admin/docs/修改记录.md 的 mtime 未变（自动 + 人工双重验收）；`_phase3_admin_mtime.txt` 临时文件清理。
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| logger 调用方 → handler | 任何 view / middleware / 第三方库可能 `logger.info(包含明文 access_token 的字符串)`；filter 在 handler 入口拦截脱敏 |
| handler → 阿里云日志服务 | `AliyunLogHandler.emit` 调 `record.getMessage()`，filter 已重写 record.msg 后 emit 自然拿脱敏值 |
| handler → stderr | `console` handler 的 stream 输出，filter 同样兜底 |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-08 | Information Disclosure | 未来开发者 `logger.info(f"PUT body: {request.data}")` 把 access_token 明文打到阿里云日志 | mitigate | `AccessTokenMaskFilter` 在 LOGGING.handlers.aliyun 兜底；4 种序列化形态全覆盖（JSON / Pyrepr / Query / Fallback）|
| T-03-09 | Information Disclosure | 误把 Authorization Bearer token / 裸 user-token 当 access_token 脱敏 | mitigate | filter 4 个 regex 全部以 `access_token` 字段名为前缀锚点；测试 T7 显式验证 `Authorization header:` / `Bearer` 字段未被改写 |
| T-03-10 | Tampering | filter 写错丢弃 record | mitigate | filter() 永远 return True；单元测试验证 |
| T-03-11 | DoS | 正则匹配在超长字符串上耗 CPU | accept | 4 个正则均用限定终止符（`[^"]+` / `[^']+` / `[^&\s"\']+`），不用贪婪 `.+`；`_NEEDLE` 短路在大多数无 `access_token` 的 record 上 0 成本 |
| T-03-12 | Repudiation | filter 改写后无法回溯原始 record | accept | 改写仅针对 access_token 字段值，原始 logger 调用栈、级别、文件名、行号全部保留；阿里云日志的 levelno / pathname / filename / funcName / lineno 字段不动 |
| T-03-13 | Elevation of Privilege | filter 类被恶意修改 | accept | `common/logging/filters.py` 在仓库内，受 git + code review 保护；与其它仓库代码同等信任级别 |
| T-03-14 | Spoofing | dictConfig 工厂语法写错让 filter 不加载 | mitigate | Task 2 验收 step 2-3 显式断言 `Django setup() 不报 ValueError` + filter 实例真实挂载到 handler.filters 列表 |
</threat_model>

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

```bash
# Task 1: 包 + filter 类 + 单元测试脚本
python -c "from common.logging.filters import AccessTokenMaskFilter; assert AccessTokenMaskFilter.__bases__[0].__name__ == 'Filter'; assert len(AccessTokenMaskFilter._PATTERNS) == 4; print('OK: filter 类 + 4 模式')"
python _phase3_02_unit_test.py  # task 3 后此文件已删，仅 task 1 执行后可跑

# Task 2: settings.py + Django 启动
python -c "src=open('qy_lty/settings.py',encoding='utf-8').read(); assert 'access_token_mask' in src; assert src.count(\"['access_token_mask']\") >= 2; print('OK: settings filters 段 + 2 handler 引用')"
python -c "import django, os; os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'qy_lty.settings'); django.setup(); print('OK: dictConfig 加载无 ValueError')"

# Task 3: 端到端 9 truth + 临时脚本删除
python manage.py shell < _phase3_02_verify.py  # 此前 task 3 已跑过；此处复跑前需先重写脚本
ls _phase3_*.py 2>/dev/null && echo "残留脚本！" || echo "OK: 无残留"

# Task 4: 修改记录条目
python -c "src=open('docs/修改记录.md',encoding='utf-8').read(); assert '[2026-05-08] Phase 3' in src; assert 'CRED-05' in src and 'CRED-06' in src; print('OK: 修改记录条目')"
```
</verification>

<success_criteria>
- [ ] `common/logging/__init__.py`（空文件）+ `common/logging/filters.py`（含 `AccessTokenMaskFilter` 类 + 4 regex + `filter()` 方法）已创建
- [ ] `_phase3_02_unit_test.py` 输出 `ALL UNIT TESTS PASS`，覆盖 4 形态脱敏 + 不误伤 Authorization / Bearer + tuple args 脱敏 + 短 token 全脱 + filter() return True
- [ ] `qy_lty/settings.py:LOGGING` 字典含 `'filters'` 段用 `'()': 'common.logging.filters.AccessTokenMaskFilter'` 工厂语法
- [ ] `LOGGING.handlers.aliyun` / `LOGGING.handlers.console` 各引用 `filters: ['access_token_mask']`，loggers 段 5 条 logger 完全未动
- [ ] `django.setup()` 不报 `ValueError: Unable to configure filter ...`
- [ ] 端到端：`logger.info('access_token=secret_xxxx_ABCD')` 后 console 输出含 `ABCD` 不含 `secret_xxxx_`
- [ ] `_phase3_02_verify.py` 输出 `ALL PASS`，9 条 truth（含 plan 03-01 的 5 条 client view + plan 03-02 的 3 条 filter 单元 + 1 条端到端 logger 真打印）全过
- [ ] `_phase3_01_verify.py` / `_phase3_02_unit_test.py` / `_phase3_02_verify.py` / `_phase3_admin_mtime.txt` 临时文件均已删除（git status 干净）
- [ ] DB 探针态 `pk=1 / app_id='probe_app' / access_token='probe_secret_xxxx'` 保持
- [ ] `docs/修改记录.md` 顶部含 `[2026-05-08] Phase 3` 条目；5 处文件 + CRED-05/06 + 跨项目联动「无」全标注
- [ ] `qy-lty-admin/docs/修改记录.md` 未被本 phase 改动（mtime 自动 + 人工 `cd ..\qy-lty-admin && git status` 双重验收，CONTEXT 锁定 + RESEARCH 实证）
- [ ] Phase 1 / Phase 2 既有代码与既有修改记录条目原文未动
</success_criteria>

<output>
完成后创建 `.planning/phases/03-client-and-log-mask/03-02-SUMMARY.md`，记录：
- 改动 5 处文件的实际行号区间（含 `__init__.py` 空文件大小、`filters.py` 总行数、settings.py LOGGING 块前后行差）
- `_phase3_02_verify.py` 输出（PASS 列表完整粘贴；FAIL 必须为 0）
- 防御性兜底语义说明（RESEARCH 已实证当前仓库无 access_token 泄露路径，filter 是为未来代码改动留的安全网）
- Milestone v1.0 完结声明：CRED-01 至 CRED-06 全部 Done，REQUIREMENTS.md 同期更新
- 同步更新 `.planning/STATE.md` 与 `.planning/ROADMAP.md`：Phase 3 标记 ✓ 完成，progress 100%，next 步骤为下一周期 milestone 候选评估
</output>
</content>
