Files
wecom_it_smart_desk/docs/03-技术架构/01-OTP首次绑定与重置-系统设计.md
Simon 400ce3ddcb feat: OTP首次绑定 + 三端登录修复 + 管理端权限修复 (2026-07-08)
OTP首次绑定:
- 新增统一 OTP 路由 /auth/otp-* (otp.py + router.py)
- 坐席端 OTP 绑定面板 (OtpBindPanel.vue)
- 管理端 OTP 管理列表 (MfaManage.vue)
- agent_login 签发半认证 token 支持首次绑定流程

三端登录修复:
- 坐席/管理端去掉'返回扫码登录'按钮
- 管理端改为二维码始终可见+轮询扫码状态
- 员工端 /itdesk/ 改为 alias 直接服务 H5 (不再301重定向)
- docker-compose 添加 h5 volume 挂载

管理端权限修复:
- 扫码登录改用 get_user_roles() 替代写死 roles=['agent']
- get_user_roles() 增加 agents.role 回退
- 新增 GET /admin/roles/user-roles 端点
- 角色管理页加载用户角色分配数据

文档更新:
- OTP PRD + 系统设计文档
- 故障排查手册 v1.1 (新增6案例)
- nginx 生产基准配置
2026-07-08 21:54:57 +08:00

24 KiB
Raw Permalink Blame History

系统架构设计 — OTP 首次绑定与重置

文档版本: v1.0
创建日期: 2026-07-06
架构师: Bob(高见远)
关联 PRD: docs/02-产品需求/05-增量PRD-OTP首次绑定与重置.md
关联母文档: docs/02-产品需求/04-增量PRD-三端认证重构.md


Part A: 系统设计

1. 实现方案与框架选型

1.1 核心技术挑战

挑战 描述 策略
登录态区分 后端需区分"需要 OTP 验证"(已绑定)和"需要 OTP 绑定"(未绑定)两种状态 agents.py 新增 else 分支,返回 require_otp_bind: true
绑定即登录 OTP 首次绑定成功后,用户不应重新输入账密 otp-verify 绑定场景下直接签发 token 返回
前端 API 迁移 现有坐席/管理端 API 适配层使用旧端点 /mfa/*/admin/mfa/*,需迁移到统一 /auth/otp-* 逐步迁移,保留旧端点兼容过渡期
绑定面板嵌入 PRD 要求绑定面板嵌入登录卡片内(非独立页面) 新建 OtpBindPanel.vue 组件,在 Login.vuev-if="requireOtpBind" 条件渲染

1.2 框架与库(沿用现有栈,无新增)

层级 技术选型 说明
后端框架 FastAPI + SQLAlchemy Async 已有,不新增
OTP 服务 pyotp + qrcode 已有 MFAService,不新增
前端坐席端 Vite + Vue 3 + Element Plus + Pinia 已有,不新增
前端管理端 Vite + Vue 3 + Element Plus + Pinia 已有,不新增
Redis redis.asyncio 已有,用于 MFA 验证状态缓存

1.3 架构模式

┌─────────────────────────────────────────────────────────┐
│                     nginx (反向代理)                      │
│              /api/auth/otp-* → backend                   │
└────────────┬────────────────────────────┬────────────────┘
             │                            │
    ┌────────▼────────┐          ┌────────▼────────┐
    │  frontend-agent │          │  frontend-admin  │
    │  (坐席端 SPA)    │          │  (管理端 SPA)     │
    │                 │          │                  │
    │ Login.vue       │          │ MfaManage.vue    │
    │  ├─ 账密表单     │          │  ├─ 列表+搜索     │
    │  ├─ OTP 输入框   │          │  ├─ 清除绑定按钮  │
    │  └─ OtpBindPanel │          │  └─ 确认弹窗      │
    │     (新增组件)   │          │                  │
    └────────┬─────────┘          └────────┬─────────┘
             │                             │
             └──────────┬──────────────────┘
                        │
              ┌─────────▼──────────┐
              │   backend (FastAPI) │
              │                    │
              │ agents.py          │
              │  └─ agent_login:   │
              │     mfa_enabled?    │
              │     ├─ True+无码    │
              │     │  → require_otp│
              │     ├─ True+有码    │
              │     │  → 验证→token │
              │     └─ False        │
              │        → require_   │
              │        otp_bind ★   │
              │                    │
              │ otp.py             │
              │  ├─ otp-bind       │
              │  ├─ otp-verify ★   │
              │  │  (绑定场景返token)│
              │  ├─ otp-unbind     │
              │  ├─ otp-status     │
              │  ├─ otp-admin-users│
              │  └─ otp-admin-reset│
              └────────────────────┘

2. 文件列表

2.1 后端

相对路径 操作 说明
backend/app/api/agents.py 【修改】 agent_login 增加 else 分支:mfa_enabled=Falserequire_otp_bind: true
backend/app/api/otp.py 【修改】 verify_otp 绑定场景(mfa_enabled=False)校验成功后设置 mfa_enabled=Truemfa_bound_at=now,并签发返回 token
backend/app/schemas/mfa.py 【修改】 新增 MFAVerifyBindResponse(含 token 字段)

2.2 前端 — 坐席端

相对路径 操作 说明
frontend-agent/src/api/otp.ts 【新增】 统一 OTP API 适配层(bind / verify / unbind / status),使用新端点 /auth/otp-*
frontend-agent/src/stores/agent.ts 【修改】 login() 方法处理 require_otp_bind 响应(不再 throw Error,返回标记供 UI 消费)
frontend-agent/src/views/Login.vue 【修改】 新增 requireOtpBind 状态 + OtpBindPanel 组件渲染
frontend-agent/src/components/OtpBindPanel.vue 【新增】 可复用 OTP 绑定面板(二维码 + secret + 验证码输入 + 确认按钮 + "暂不绑定"按钮)
frontend-agent/src/views/Settings.vue 【新增】 坐席设置页,含 OTP 管理面板(状态显示 + 解绑 + 重新绑定)— P1
frontend-agent/src/router/index.ts 【修改】 新增 /settings 路由 — P1

2.3 前端 — 管理端

相对路径 操作 说明
frontend-admin/src/api/mfa.ts 【修改】 切换 API 路径到 /auth/otp-admin-users/auth/otp-admin-reset,更新 TS 类型字段名
frontend-admin/src/views/MfaManage.vue 【修改】 更新字段映射(mfa_enabled/mfa_bound_at/mfa_last_verified_at),"重置 MFA"→"清除绑定",更新确认弹窗文案
frontend-admin/src/components/Sidebar.vue 【修改】 在"运营管理"分区下新增「OTP 管理」菜单项
frontend-admin/src/router/index.ts 【修改】 路由标题更新为「OTP 管理」

3. 数据结构与接口

3.1 后端 Schema 变更

classDiagram
    class MFAVerifyRequest {
        +str otp_code
    }

    class MFAVerifyResponse {
        +bool verified
        +int expires_in
    }

    class MFAVerifyBindResponse {
        +bool verified
        +int expires_in
        +str token
        +str user_id
        +str name
        +str role
    }

    class MFABindStartResponse {
        +str secret
        +str otpauth_url
        +str qr_code_base64
    }

    class MFAStatusResponse {
        +bool bound
        +bool enabled
        +datetime last_verified_at
    }

    MFAVerifyRequest --> MFAVerifyResponse : "mfa_enabled=True → 仅验证"
    MFAVerifyRequest --> MFAVerifyBindResponse : "mfa_enabled=False → 绑定+签发token"

3.2 核心 API 契约

端点 方法 鉴权 说明 变更
/api/auth/otp-status GET get_current_user 查询绑定状态 无变更
/api/auth/otp-bind POST get_current_user 生成 secret + QR 无变更
/api/auth/otp-verify POST get_current_user 校验 OTP → 绑定场景额外返回 token ★ 行为变更
/api/auth/otp-unbind POST get_current_user 用户主动解绑 无变更
/api/auth/otp-admin-users GET require_role("admin") 管理员查看全量坐席 OTP 状态 无变更
/api/auth/otp-admin-reset/{employee_id} POST require_role("admin") 管理员清除指定坐席 OTP 无变更
/api/agents/login POST 无(登录接口) mfa_enabled=False → require_otp_bind ★ 行为变更

3.3 POST /api/agents/login 响应契约变更

变更前(当前 agents.py:274):

// mfa_enabled=True 且未传 otp_code
{ "code": 0, "data": { "require_otp": true, "message": "请输入OTP动态码", "user_id": "...", "name": "...", "role": "..." } }

// mfa_enabled=False → 直接签发 token(问题)
{ "code": 0, "data": { "token": "...", "user_id": "...", ... } }

变更后

// mfa_enabled=True 且未传 otp_code — 不变
{ "code": 0, "data": { "require_otp": true, "message": "请输入OTP动态码", "user_id": "...", "name": "...", "role": "..." } }

// mfa_enabled=False — 新增 ★
{ "code": 0, "data": { "require_otp_bind": true, "message": "首次登录请先绑定OTP二次验证", "user_id": "...", "name": "...", "role": "..." } }

3.4 POST /api/auth/otp-verify 响应契约变更

变更前(当前 otp.py:verify_otp):

// mfa_enabled=False → 直接返回 verified=false
{ "code": 0, "data": { "verified": false, "expires_in": 0 } }

变更后

// mfa_enabled=False + 校验通过 → 绑定并签发 token ★
{ "code": 0, "data": { "verified": true, "expires_in": 1800, "token": "...", "user_id": "...", "name": "...", "role": "..." } }

// mfa_enabled=True + 校验通过 → 不变
{ "code": 0, "data": { "verified": true, "expires_in": 1800 } }

4. 程序调用流程

4.1 首次登录 OTP 绑定流程(P0 核心)

sequenceDiagram
    participant User as 坐席
    participant LoginVue as Login.vue
    participant AgentStore as agentStore
    participant OtpBind as OtpBindPanel.vue
    participant API as apiClient
    participant Backend as FastAPI Backend
    participant Redis as Redis

    User->>LoginVue: 输入账号密码 → 点击登录
    LoginVue->>AgentStore: login(userId, password, undefined)
    AgentStore->>API: POST /api/agents/login {user_id, password}
    API->>Backend: agent_login()
    
    Note over Backend: agent.mfa_enabled == False
    Backend-->>API: { require_otp_bind: true, user_id, name, role }
    API-->>AgentStore: { require_otp_bind: true, ... }
    AgentStore-->>LoginVue: return { require_otp_bind: true }

    Note over LoginVue: 隐藏账密表单<br/>显示 OtpBindPanel
    LoginVue->>OtpBind: requireOtpBind = true

    OtpBind->>API: POST /api/auth/otp-bind
    API->>Backend: bind_otp()
    Note over Backend: 生成 secret + QR
    Backend-->>API: { secret, otpauth_url, qr_code_base64 }
    API-->>OtpBind: { secret, otpauth_url, qr_code_base64 }

    Note over OtpBind: 渲染二维码 + secret 明文
    User->>User: 用 Authenticator 扫码(或手动输入 secret
    User->>OtpBind: 输入 6 位验证码 → 点击"验证并完成绑定"

    OtpBind->>API: POST /api/auth/otp-verify { otp_code }
    API->>Backend: verify_otp()

    Note over Backend: mfa_enabled=False (绑定场景)<br/>校验 TOTP → 通过<br/>设置 mfa_enabled=True<br/>设置 mfa_bound_at=now

    Backend->>Redis: mark_verified(employee_id, TTL=1800)
    Backend->>Backend: 签发 JWT token
    Backend-->>API: { verified: true, token, user_id, name, role }
    API-->>OtpBind: { verified: true, token, ... }

    OtpBind-->>LoginVue: emit('bind-success', { token, user_id, name })
    LoginVue->>AgentStore: 保存 token → 设置 agentInfo
    LoginVue->>LoginVue: router.push('/workspace')

4.2 管理后台清除 OTP 绑定流程

sequenceDiagram
    participant Admin as 管理员
    participant MfaVue as MfaManage.vue
    participant API as apiClient
    participant Backend as FastAPI Backend
    participant DB as PostgreSQL
    participant Redis as Redis

    Admin->>MfaVue: 访问 OTP 管理页面
    MfaVue->>API: GET /api/auth/otp-admin-users
    API->>Backend: admin_list_otp_users()
    Backend->>DB: SELECT * FROM agents
    DB-->>Backend: agent rows
    Backend-->>API: [{employee_id, name, mfa_enabled, mfa_bound_at, ...}]
    API-->>MfaVue: 渲染表格

    Admin->>MfaVue: 点击某坐席的「清除绑定」
    MfaVue->>MfaVue: ElMessageBox 二次确认弹窗
    Admin->>MfaVue: 确认清除

    MfaVue->>API: POST /api/auth/otp-admin-reset/{employee_id}
    API->>Backend: admin_reset_otp(employee_id)
    Backend->>DB: UPDATE agents SET mfa_secret=NULL, mfa_enabled=False, mfa_bound_at=NULL
    Backend->>Redis: clear_verified(employee_id)
    Backend-->>API: { success: true }
    API-->>MfaVue: ElMessage.success('已清除')

    MfaVue->>API: 重新加载列表

4.3 坐席自助解绑流程(P1

sequenceDiagram
    participant User as 坐席
    participant Settings as Settings.vue
    participant API as apiClient
    participant Backend as FastAPI Backend
    participant DB as PostgreSQL
    participant Redis as Redis

    User->>Settings: 打开设置页 → OTP 管理面板
    Settings->>API: GET /api/auth/otp-status
    API->>Backend: get_otp_status()
    Backend-->>API: { bound: true, enabled: true, last_verified_at }
    API-->>Settings: 显示「✅ 已绑定」

    User->>Settings: 点击「解绑 OTP」
    Settings->>Settings: 弹窗:输入当前 OTP 验证码
    User->>Settings: 输入 6 位码 → 确认

    Settings->>API: POST /api/auth/otp-unbind { otp_code }
    API->>Backend: unbind_otp()
    Backend->>Backend: MFAService.verify_code() → 通过
    Backend->>DB: UPDATE SET mfa_secret=NULL, mfa_enabled=False, mfa_bound_at=NULL
    Backend->>Redis: clear_verified(employee_id)
    Backend-->>API: { success: true }
    API-->>Settings: ElMessage.success('OTP 已解绑')
    Settings->>Settings: 刷新状态 → 显示「⚠️ 未绑定 — 立即绑定」

5. 待确认问题与假设

# 问题 假设 影响
Q1 首次绑定能否"跳过" P0 阶段不允许跳过。简化首版逻辑,堵死无 OTP 直通漏洞。P1 阶段再引入"暂不绑定"按钮。 首次绑定向导无跳过按钮
Q2 扫码登录的坐席是否需要 OTP P0 阶段暂不处理。扫码登录已有企微身份强验证,且当前 agent_login 中对已绑定 OTP 的坐席仍要求 OTP。本增量聚焦账密登录的绑定引导。 扫码登录不做额外改动
Q3 OTP verify 绑定场景是否直接返回 token? 。修改 otp.py:verify_otp,绑定场景额外返回 token。避免前端二次 login。 后端改动量小,前端只需一次请求
Q4 管理后台旧端点 /admin/mfa/* 是否需要保留? 保留兼容过渡期。前端切换到新端点,旧端点暂时保留不删,待下个迭代清理。 无风险
Q5 OtpBindPanel.vue 与已有 MfaBind.vue 的关系? OtpBindPanel 是轻量嵌入组件(嵌入登录卡片内),MfaBind.vue 是独立全页组件(/mfa-bind 路由)。绑定流程 UI 逻辑可复用,但布局不同。建议新建 OtpBindPanel 组件,从 MfaBind.vue 提取共享的二维码渲染 + 验证逻辑 两个组件共存,按场景使用

Part B: 任务分解

6. 所需依赖包

无新增依赖。所有功能基于已有技术栈:

- Vue 3 + Element Plus(前端已有)
- Pinia(状态管理已有)
- pyotp + qrcode(后端已有 MFAService
- FastAPI + SQLAlchemy Async(后端已有)
- redis.asyncioRedis 客户端已有)

7. 任务列表(按依赖关系排序)

Task ID 任务名称 源文件 依赖 优先级
T01 后端 OTP 绑定流程改造 backend/app/api/agents.py【修改】
backend/app/api/otp.py【修改】
backend/app/schemas/mfa.py【修改】
P0
T02 管理后台 OTP 管理页完整更新 frontend-admin/src/api/mfa.ts【修改】
frontend-admin/src/views/MfaManage.vue【修改】
frontend-admin/src/components/Sidebar.vue【修改】
frontend-admin/src/router/index.ts【修改】
T01(需后端端点就绪) P0
T03 坐席端登录页 OTP 绑定面板 frontend-agent/src/api/otp.ts【新增】
frontend-agent/src/stores/agent.ts【修改】
frontend-agent/src/views/Login.vue【修改】
frontend-agent/src/components/OtpBindPanel.vue【新增】
T01(需后端端点就绪) P0
T04 坐席端设置页 OTP 管理 frontend-agent/src/views/Settings.vue【新增】
frontend-agent/src/router/index.ts【修改】
frontend-agent/src/api/otp.ts【修改】
T03(依赖 otp.ts API 适配层) P1

T01 详细说明 — 后端 OTP 绑定流程改造

修改点 1backend/app/api/agents.py:274agent_login 函数):

# 当前:只有 if agent.mfa_enabled: ...
# 改造后:添加 else 分支
if agent.mfa_enabled:
    if not body.otp_code:
        return success_response(data={
            "require_otp": True,
            "message": "请输入OTP动态码",
            "user_id": agent.user_id,
            "name": agent.name,
            "role": agent.role,
        })
    else:
        if not MFAService.verify_code(agent.mfa_secret, body.otp_code, valid_window=1):
            raise AppException(1006, "OTP验证码错误,请重新输入")
else:
    # ★ 新增:未绑定 OTP → 引导绑定
    return success_response(data={
        "require_otp_bind": True,
        "message": "首次登录请先绑定OTP二次验证",
        "user_id": agent.user_id,
        "name": agent.name,
        "role": agent.role,
    })

修改点 2backend/app/api/otp.py:verify_otp(第 213-218 行):

# 当前:mfa_enabled=False → 直接返回 verified=false
# 改造后:mfa_enabled=False 且 mfa_secret 存在(已调用过 otp-bind)→ 走绑定校验逻辑
if not agent.mfa_enabled and agent.mfa_secret:
    # ★ 绑定场景:校验 OTP → 设置 enabled + bound_at → 签发 token
    if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
        logger.warning(f"OTP bind verify 验证码错误: agent={agent.user_id}")
        return success_response(data=MFAVerifyResponse(verified=False, expires_in=0).model_dump())
    
    now = datetime.now()
    agent.mfa_enabled = True
    agent.mfa_bound_at = now
    agent.mfa_last_verified_at = now
    db.add(agent)
    await db.flush()
    
    await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
    
    # 签发 token
    from app.services.token_service import TokenService
    from app.dependencies import get_redis as _get_redis_dep
    redis_client = await _get_redis_dep()
    token_service = TokenService(redis_client)
    token = await token_service.create_token(
        employee_id=agent.user_id,
        name=getattr(agent, 'name', '') or '',
        roles=[agent.role] if agent.role else [],
        login_source="agent",
    )
    
    logger.info(f"OTP bind+verify 成功: agent={agent.user_id}")
    return success_response(data={
        "verified": True,
        "expires_in": MFA_VERIFIED_TTL_SECONDS,
        "token": token,
        "user_id": agent.user_id,
        "name": getattr(agent, 'name', '') or '',
        "role": agent.role or '',
    })

# 原有逻辑:已绑定场景保持不变
if not agent.mfa_enabled or not agent.mfa_secret:
    return success_response(data=MFAVerifyResponse(verified=False, expires_in=0).model_dump())
# ... 后续不变

修改点 3backend/app/schemas/mfa.py:新增 Schema(可选,也可用 dict 直接返回)。

T02 详细说明 — 管理后台 OTP 管理页完整更新

  1. mfa.tsAPI 路径从 /admin/mfa/users/auth/otp-admin-users/admin/mfa/reset/{id}/auth/otp-admin-reset/{id}。TS 类型字段名从 bound/bound_at 改为 mfa_enabled/mfa_bound_at
  2. MfaManage.vue:字段映射 row.boundrow.mfa_enabledrow.bound_atrow.mfa_bound_at;按钮文案"重置 MFA"→"清除绑定";确认弹窗文案按 PRD §4.3 更新。
  3. Sidebar.vue:在"📋 运营管理"分组添加 <el-menu-item index="/mfa-manage"> 菜单项。
  4. router/index.tsmeta.title 更新为 'OTP 管理'

T03 详细说明 — 坐席端登录页 OTP 绑定面板

  1. otp.ts:新建 API 适配层,封装 bindOtp() / verifyOtp() / getOtpStatus() / unbindOtp(),使用新端点 /auth/otp-*
  2. agent.ts:login():处理 require_otp_bind 响应——不再 throw Error,返回 { require_otp_bind: true, user_id, name, role } 供 Login.vue 消费。
  3. Login.vue:新增 requireOtpBind ref;当 result.require_otp_bind 为 true 时,隐藏账密表单 + OTP 输入框,显示 <OtpBindPanel>;监听 bind-success 事件保存 token 并跳转。
  4. OtpBindPanel.vue:新建可复用组件,包含:二维码渲染(<img :src="data:image/png;base64,...">)、secret 明文展示 + 复制按钮、6 位验证码输入框、"验证并完成绑定"主按钮、"暂不绑定,稍后设置"次要按钮(P0 阶段隐藏)。

T04 详细说明 — 坐席端设置页 OTP 管理(P1)

  1. Settings.vue:新建设置页面,含「OTP 二次验证」面板:已绑定时显示状态+解绑按钮;未绑定时显示警告+绑定按钮。解绑流程:弹窗输入 OTP → POST /auth/otp-unbind
  2. router/index.ts:新增路由 { path: '/settings', component: () => import('@/views/Settings.vue'), meta: { title: '个人设置', requiresAuth: true } }
  3. otp.ts:补充 unbindOtp(otpCode)getOtpStatus() 的完整调用。

8. 共享知识

以下约定适用于所有任务、所有文件:

## 响应契约
- 所有 API 响应统一格式:{ code: 0, data: {...}, message: "success" }
- apiClient 拦截器已自动解包 data 层(Scheme A),前端直接消费 inner data
- 错误:{ code: 非0, message: "错误描述" } → 前端 catch 中取 error.message

## OTP 状态常量
- require_otp: true        → 已绑定 OTP,需要输入 6 位码验证
- require_otp_bind: true   → 未绑定 OTP,需要展示绑定面板(二维码 + 输入验证码)
- mfa_enabled: true/false  → 数据库字段,标记 OTP 是否已启用
- mfa_bound_at: datetime   → 首次绑定成功时间(NULL 表示从未绑定)
- mfa_secret: string/NULL  → TOTP 密钥(仅绑定过程中临时存储,解绑后清空)

## 端点路径规范
- 坐席端/管理端统一使用 /auth/otp-* 前缀(新端点)
- 旧端点 /mfa/* 、/admin/mfa/* 保留兼容但前端不再调用
- nginx 剥离 /api 前缀后,对外即 /api/auth/otp-*

## 前端命名约定
- OtpBindPanel.vue: 嵌入登录卡片内的轻量绑定面板(P0)
- MfaBind.vue: 独立全页绑定向导(已有,保留用于 /mfa-bind 路由)
- 按钮文案:后端用"清除绑定",坐席端用"解绑 OTP"

## 错误处理
- OTP 验证码错误 → 返回 verified=false(不抛异常),前端显示红色提示,允许重试
- 解绑时 OTP 错误 → 后端抛 AppException(INVALID_PARAMETER),前端显示错误消息
- 已绑定用户重复调用 otp-bind → 后端拒绝,前端需先判断状态

## Redis Key
- mfa:verified:{employee_id} = "1"TTL = 1800s30 分钟验证窗口)
- 绑定成功后写该 key(等同于已验证)
- 解绑/管理员清除时删除该 key

9. 任务依赖图

graph TD
    T01["T01: 后端 OTP 绑定流程改造<br/>(agents.py + otp.py + schemas/mfa.py)<br/>P0"]
    T02["T02: 管理后台 OTP 管理页<br/>(mfa.ts + MfaManage.vue + Sidebar.vue + router)<br/>P0"]
    T03["T03: 坐席端登录 OTP 绑定面板<br/>(otp.ts + agent.ts + Login.vue + OtpBindPanel.vue)<br/>P0"]
    T04["T04: 坐席端设置页 OTP 管理<br/>(Settings.vue + router + otp.ts)<br/>P1"]

    T01 --> T02
    T01 --> T03
    T03 --> T04

说明:T02 和 T03 可并行开发(均依赖 T01,互不依赖)。T04 依赖 T03(需要 otp.ts API 适配层)。


文档结束 — 请工程师按 T01 → T02/T03(并行) → T04 顺序实施。