{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-ui/dr-card-ui/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-ui/dr-card-ui/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-ui/dr-card-ui/tests/e2e",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/dangjingtao/dr-card-ui/commit/cbc0ac9ac7ea3e49c0514ff017614c71203b45b6",
        "commitHash": "cbc0ac9ac7ea3e49c0514ff017614c71203b45b6",
        "prHref": "https://github.com/dangjingtao/dr-card-ui/pull/202",
        "prTitle": "chore(release): promote dev to test, preserve OSS upload fixes",
        "prBaseHash": "b4a96d6cd4b8a547c8d7f4adac945b319e4a26f3",
        "buildHref": "https://github.com/dangjingtao/dr-card-ui/actions/runs/38216972724"
      },
      "gitCommit": {
        "shortHash": "cbc0ac9",
        "hash": "cbc0ac9ac7ea3e49c0514ff017614c71203b45b6",
        "subject": "Merge 28bdb2e1d5ce14425954362c975c34903ebc983c into b4a96d6cd4b8a547c8d7f4adac945b319e4a26f3",
        "body": "Merge 28bdb2e1d5ce14425954362c975c34903ebc983c into b4a96d6cd4b8a547c8d7f4adac945b319e4a26f3\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1791789485000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1791789485000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/docs/engineering/auth-boundary.md b/docs/engineering/auth-boundary.md\nindex 6764bc0..5b2afe9 100644\n--- a/docs/engineering/auth-boundary.md\n+++ b/docs/engineering/auth-boundary.md\n@@ -14,7 +14,7 @@\n - H5 统一通过 `src/services/nativeBridge.ts` 获取并验证结果，业务页面不得直接访问宿主对象。\n - OAuth 开发登录：`POST {VITE_API_BASE_URL}/api/oauth/login`，请求体 `{ salt, token }`；成功响应兼容旧的 `{ userInfo, accessToken }`，以及当前后台返回的 `{ code: 0, data: { userInfo, accessToken, ... } }`；后续同源 API 请求使用 `Authorization: Bearer <accessToken>`。\n - 正式 H5 根首页在 `VITE_DATA_MODE=api` 时先完成登录；仅 `accessToken` 暂存于 `sessionStorage`，`userInfo` 保留在当前文档内存。Mock 模式保留独立浏览器预览，不模拟真实登录。\n-- HTTP 401 或响应顶层 `code: 401` 触发一次单飞重登；原请求最多重放一次。重登失败进入 `/error?reason=auth`，不循环。\n+- HTTP 401 或响应顶层 `code: 401` 触发一次单飞重登；原请求最多重放一次。首次打开文档时，如果初始 `/api/oauth/login` 自身返回 401，同样只允许再登录一次。若这次重登/重试登录再次返回 401，或重登成功后重放原请求仍返回 401，则判定当前用户会话已失效：H5 弹出「用户信息过期」Toast，并调用 Native `closeWebView()` 关闭当前 WebView，不再继续重登或停留在当前业务页。重登过程中的非 401 失败仍进入 `/error?reason=auth`，不循环。\n - 2026-09-28 后端统一响应协议：成功码 `code: 0`（兼容原生；`GET /api/user/detail` 此前 2026-09-24 实测的 `code: 200` 已由后端统一改回 0）；失败为非 0 code + `message` 字段（无 `msg` / `status`）。正式接口共用 `parseApiEnvelope` 解析。\n \n 旧 iOS `webkit.messageHandlers.getAuthorizationInfo.postMessage({}) → window.onToken(token)` 仅作为历史联调 / Bridge Lab Raw Probe 证据保留，不再是 production auth contract。\ndiff --git a/docs/engineering/card-code-redeem-contract.md b/docs/engineering/card-code-redeem-contract.md\nnew file mode 100644\nindex 0000000..4950923\n--- /dev/null\n+++ b/docs/engineering/card-code-redeem-contract.md\n@@ -0,0 +1,67 @@\n+# 兑换卡券｜H5 ↔ 后台正式协议对照\n+\n+> 来源：2026-10-12 后台同事交接的「兑换码核兑接口」，由用户在本线程提供。此文档记录**已确认的对接合同**，不宣称已部署、已完成数据库联调或真机验收。H5 修改仅在 GitHub `dr-card-ui` 进行，本地 API 项目只读。\n+\n+## 1. 用户兑换码核兑\n+\n+```http\n+POST /api/cardredemptioncodes/redeem\n+Authorization: Bearer <accessToken>\n+Content-Type: application/json\n+```\n+\n+```json\n+{ \"code\": \"ABC123456789\" }\n+```\n+\n+- 仅提交 `code`；`user_id`、`card_id`、`status` 都由服务端确定。\n+- **不少于 10 位**；不额外限制输入字符类型、大小写或前端最大长度，也不自动删除空格、转换大写。\n+- 后端当前数据库表中 `code` 字段长度为 64；这是存储约束，不是 H5 要主动增加的校验规则。\n+- 服务端核对状态 10 未兑换、20 已兑换、30 已过期、40 已禁用，验证有效期和卡模板，采用事务核兑发券。\n+- 客户端用标准 `code === 0` 判断业务成功，HTTP 200 + `code:500` 是失败，展示 `message`；HTTP 401 交给现有登录态处理。\n+\n+### 成功响应\n+\n+```json\n+{\n+  \"code\": 0,\n+  \"msg\": \"success\",\n+  \"status\": \"succ\",\n+  \"data\": {\n+    \"user_card_id\": 21,\n+    \"card_id\": 4,\n+    \"card_name\": \"洗护体验卡\",\n+    \"card_sn\": \"RC00000000000014\"\n+  }\n+}\n+```\n+\n+H5 校验四个回执字段后显示成功页；任一关键字段缺失视为协议错误，不把未确认的发券结果当作成功。\n+\n+### 失败响应示例\n+\n+```json\n+{ \"code\": 500, \"message\": \"该兑换码已被使用\", \"data\": [] }\n+```\n+\n+包括格式不足、兑换码无效／已使用／已过期／已禁用／尚未生效、模板不可用等；错误文本由后台提供。\n+\n+## 2. 兑换成功后的卡包展示\n+\n+**必须同时区分两种券来源，不混淆券 ID：**\n+\n+| 来源 | 读取接口 | 用途 |\n+|---|---|---|\n+| 本地用户卡包 | `GET /api/usercards/index` | 兑换码核兑券，`source=60`，`source_id` 对应兑换码 ID |\n+| 第三方优惠卡 | `POST /api/coupons/MyCoupons` | 既有第三方优惠卡列表；不会包含本地兑换码券 |\n+\n+- 本地列表读取当前登录用户的分页数据，**完整分页后**筛选 `source===60`，防止在单页过滤遗漏后续兑换券。\n+- 本地卡券依据已返回的 `status=10/20/30` 对应「可用／已使用／已过期」；不将第三方记录的字段硬套成本地券。\n+- 后端没有在此协议里提供本地券使用／转赠业务入口，因此 H5 **不把本地 ID 当作第三方 MyCoupons 的核销或转赠 ID**，相应按钮不可用。\n+- 本地卡包与第三方列表各自加载与报错，不能因为一个来源失败就伪造另一个来源的数据。\n+\n+## 3. 后端归属与验收边界\n+\n+- 后端管理接口 `/admin/cardredemptioncodes/*` 不由 H5 调用；用户端旧 CRUD `/api/cardredemptioncodes/{index,add,update,delete,detail,select}` 已关闭。\n+- H5 只调用 `redeem` 与已确认的本地卡包读取接口，不修改后台实现。\n+- 真实 API 的环境部署、DB 联通、授权、发券到账和 App WebView 仍需在 test 环境验证；Mock 成功不是业务验收。\ndiff --git a/docs/workbench/decisions/README.md b/docs/workbench/decisions/README.md\nindex 654b4cd..1701b8c 100644\n--- a/docs/workbench/decisions/README.md\n+++ b/docs/workbench/decisions/README.md\n@@ -23,7 +23,7 @@\n | D-014 | 卡包采用 reference 卡包标准页的 可用/已使用/已过期 三态 Tab，不引入原型文档写的第 4 个「全部」Tab 与右上角「使用记录」入口（用户已关闭 B-016，定案「卡包以 `reference/卡包.html` 为准，只保留三个 Tab」） | PASS | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #54 #62 #63 #64 |\n | D-015 | `#63 卡包-已使用` 保持标准页设计的空态，不新增 `status: 'used'` 卡券夹具填充画面；核销成功跳 `/card?state=used` 落到空态属预期 | PASS | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #63 #67 |\n | D-016 | 分享为 单选＋搜索＋「下一步」→ 成功页 的前端闭环；对象列表按原型做「搭子列表」夹具，不做对方接受、次数限制、时效限制和持久化，分享后原卡包状态不变（用户已关闭 B-015 并定案该范围） | PASS | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #65 #66 |\n-| D-017 | 兑换码规则集中在夹具 `REDEEM_CODE_RULE`（含 `pending`/`blocker`/`source`），页面不硬编码位数；提交仅在空输入时禁用，不用位数拦提交 | PASS | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #68 #69 |\n+| D-017 | **历史原型决策；已被 D-083 覆盖**：兑换码规则曾含 `pending`/`blocker`/`source`、仅空输入禁用；正式后台合同按至少 10 位执行 | Superseded by D-083 | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #68 #69 |\n | D-018 | 核销链路收口为 确认 → 结果反馈 Toast → 回 `/card?state=used`，并补 `?state=repeat` 重复核销态 | PASS | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #67 |\n | D-019 | #65/#66 以用户当轮提供的原型为准还原：#65 为「搜索 + 搭子列表单选 + 底部主按钮」，#66 为「成功 hero + 副文案「已成功分享给好友，邀请他也来一起玩吧」+ 分享的商品卡 + 查看我的卡包/返回 双按钮」；`/card/share` 路由标题统一为「分享」，卡包入口按钮保留原型的「转赠」 | PASS | [分享.html](../../../reference/分享.html)、[分享成功.html](../../../reference/分享成功.html)、[CardShare.tsx](../../../src/pages/CardShare.tsx) | T009 #65 #66 |\n | D-020 | 状态验收统一由 `DebugPanel` 承载：胶囊只读 `routes.ts` 已登记的 `states`/`overlays`，切换仅改 `?state=`/`?overlay=`（`replace`），未登记状态的路由渲染 null；面板为验收工具，不属业务 UI，`z-[60]` 高于弹层/Toast（z-50）与 Tabbar（z-40） | PASS | [DebugPanel.tsx](../../../src/components/mobile/DebugPanel.tsx)、[routes.ts](../../../src/app/router/routes.ts) | T009 #62 #63 #64 #65 #66 #67 #68 #69 |\n@@ -90,6 +90,7 @@\n | D-080 | 金刚区删除（D-072）带来一处连带脚本失效：`capture-t005.mjs` 原先点击首页「诗得丽品牌专栏」卡片进入 `/dearseed?overlay=reminder`，而该卡片正是金刚区的一部分（已由 `git show HEAD:src/pages/Home.tsx` 核实旧版存在），故这不是弹窗遮挡、加 `?newcomer=off` 无效。改为直连 `/dearseed?overlay=reminder`，该脚本只继续验证专栏侧提示层与关闭出口；首页与专栏的新入口关系由 `capture-t021.mjs` 覆盖。**T005 历史验收结论不回改** | PASS | [capture-t005.mjs](../../../scripts/capture-t005.mjs)、[T005-entry-home-onboarding.md](T005-entry-home-onboarding.md) | T021 T005 #2 |\n | D-081 | 首页「公益板块」和「极地种子品牌故事」均为可点击入口，分别进入 `/cause` 与 `/brand-culture`；两个详情页使用共享空富文本承载组件，当前不请求接口、不填充假内容，未来由统一数据层提供富文本。两个路由沿用 App 壳层标题栏，返回各自来源页；`/cause` 为新增空节点，`/brand-culture` 保留原路径和来源入口 | Implemented | [Home.tsx](../../../src/pages/Home.tsx)、[RichTextPlaceholder.tsx](../../../src/pages/RichTextPlaceholder.tsx)、[routes.ts](../../../src/app/router/routes.ts) | T021 |\n \n+| D-083 | **2026-10-12 用户确认后台兑换码合同**：兑换码至少 10 位，不自动去空格/大写、不限定字符类型或 H5 最大长度；输入不足 10 位禁用提交；真实 API `/api/cardredemptioncodes/redeem`；成功返回卡包券凭证；本地卡包 source=60，第三方 MyCoupons 独立读取。覆盖历史 D-004/D-008/D-017 中与兑换码格式及提交门槛相冲突的规则，原型历史保留不篡改 | Implementation in PR #195 / API validation pending | [card-code-redeem-contract.md](../../engineering/card-code-redeem-contract.md) | T009 #68 #69 |\n | D-082 | **2026-09-29 Maintainer 定案商城主入口**：`/mall` 使用 `MallWebView` 在现有 H5 壳层内 iframe 承载 `http://www.3-wins.cn/`；HTTP scheme 为当前明确输入，不自动替换。该决策覆盖 D-031 对 `/mall` 主入口的占位口径；商品详情/购物车边界继续保留。真实 App WebView 的 cleartext/mixed-content、iframe policy 与鉴权仍需联调证据 | Implemented / validation pending | [T008-mall-exchange.md](T008-mall-exchange.md)、[MallWebView.tsx](../../../src/pages/MallWebView.tsx) | T008 #17 |\n \n ## 2. 待确认/阻塞项（施工前必须决策）\n@@ -124,7 +125,7 @@\n | 编号 | 阻塞项 | 关闭方式 | 关闭记录 |\n | --- | --- | --- | --- |\n | B-001 | 品牌文化视觉方向（深绿深色长页 + 浮动 CTA 是否保留） | **用户定案（2026-08-24）**：#16 只铺摹客原型长图、不加浮动 CTA，T13 深绿深色稿与右下浮动按钮均不继承；实现按 `D1gOOzGEq` 真值（全页 0 button、单张长图铺满 artboard），返回栏沿用 MobileLayout 统一提供（D-051） | [T005-entry-home-onboarding.md](T005-entry-home-onboarding.md) |\n-| B-009 | 兑换码长度/字符集/错误态/成功态（原型 8 位、11 位 vs 历史 12 位） | D-008/D-017：位数按 `reference/兑换卡券.html` 取 12，规则集中在夹具 `REDEEM_CODE_RULE` 且保留 `pending`，页面不硬编码 | [T009-wallet-redemption.md](T009-wallet-redemption.md) |\n+| B-009 | 兑换码长度/字符集/错误态/成功态（原型 8 位、11 位 vs 历史 12 位） | **历史** D-008/D-017 曾按 reference 取 12；**2026-10-12 D-083 新合同覆盖**：至少 10 位，原样提交，不限制字符集/前端最大长度；此处仅保留决策演变 | [T009-wallet-redemption.md](T009-wallet-redemption.md) |\n | B-007 | H5 商城承载方式（本地高保真 / WebView / 外链占位） | 2026-08-22 的三路由边界定案 D-031 仍保留历史；**2026-09-29 Maintainer 以 D-082 覆盖 `/mall` 主入口**，改用 `MallWebView` iframe `http://www.3-wins.cn/`，商品详情/购物车仍保留边界 | [T008-mall-exchange.md](T008-mall-exchange.md) §3.1 / §7 |\n | B-008 | 洗护兑换专区主视觉与搜索/排序；T11 深绿 KV 不自动生效 | **用户定案（2026-08-22）**：兑换专区在本仓库本地实现（搜索/三维度排序/商品卡/兑换确认/余额不足/售罄/成功存入卡包）；禁止照搬历史 T11 深绿金 KV 与私有配色，只消费已注册语义 Token（D-032、D-033） | [T008-mall-exchange.md](T008-mall-exchange.md) §7 |\n | B-010 | 通知 Tab 结构：原型「全部/未读」两类 vs 历史 4 Tab | D-010：按 reference 通知标准页采用 4 Tab | [T012-notifications.md](T012-notifications.md) |\ndiff --git a/docs/workbench/decisions/T009-wallet-redemption.md b/docs/workbench/decisions/T009-wallet-redemption.md\nindex d1a3deb..9410c84 100644\n--- a/docs/workbench/decisions/T009-wallet-redemption.md\n+++ b/docs/workbench/decisions/T009-wallet-redemption.md\n@@ -1,5 +1,7 @@\n # 决策记录｜T009 卡包三态、分享与兑换码失败态\n \n+> **2026-10-12 补充（D-083）**：本页下方 D-017／B-009、自动转大写、12 位等是 2026-08 的设计历史；最新正式后台协议已由用户确认，覆盖这些旧规则。当前 H5 校验至少 10 位、保持原始码（含大小写与空格）、不施加额外最大长度/字符集限制，业务成功由 `/api/cardredemptioncodes/redeem` 的真实回执决定，本地券 source=60 与第三方 MyCoupons 分别读取。详见 [最新接口合同](../../engineering/card-code-redeem-contract.md)。历史决定保留供审计，不再用于当前施工。\n+\n > 决策日期：2026-08-22\n > 决策卡：[T009-wallet-redemption.md](../tasks/T009-wallet-redemption.md)\n > 决策类型：UI / Flow\ndiff --git a/docs/workbench/decisions/T013-customer-service.md b/docs/workbench/decisions/T013-customer-service.md\nindex 3dd65b0..5c92100 100644\n--- a/docs/workbench/decisions/T013-customer-service.md\n+++ b/docs/workbench/decisions/T013-customer-service.md\n@@ -94,3 +94,11 @@ capture 脚本报告的 20 条 warning 全部为 React Router v6→v7 future fla\n | `queueDynamics` | B-014 | 队列人数、等待时长与推进规则 |\n | `humanQueueEntry` | B-014 | #71 → #70 的前进动作（原型 §10 弹层只有「取消」，未给前进入口）；未确认故不实现 |\n | `agentReplies` | B-014 | 已接入后坐席的后续问答行为 |\n+\n+\n+## 2026-10-12｜企业微信入口兼容调整（覆盖当前可见入口，不撤销历史决策）\n+\n+- 在 `/service/chat` 隐藏 TitleBar 右侧「企微客服」按钮，不修改其它页面的标题栏动作。\n+- 底部已存在的「人工」入口复用原 `#wecom` 弹层动作，沿用原有二维码数据与取消/返回行为；在真实 API 模式下，不因 Socket.IO 尚未连接而禁用企微入口。\n+- **保留**原应用内人工客服转接 `useHumanChat()/transfer()`、Socket.IO 监听、排队/接入历史逻辑及相关独立测试；此次仅改变用户可见按钮绑定，不再由该按钮触发应用内转接。Mock 人工状态机实现同样保留。\n+- `#wecom` 深链仍有效。正式后端联调与原人工转接链路治理另行验收，本改动不声称将企微二维码当成已连接的人工坐席。\ndiff --git a/docs/workbench/evidence-matrix.md b/docs/workbench/evidence-matrix.md\nindex 88b2f79..27d0804 100644\n--- a/docs/workbench/evidence-matrix.md\n+++ b/docs/workbench/evidence-matrix.md\n@@ -182,7 +182,7 @@ T008 本轮证据（2026-08-22）：`BASE_URL=http://127.0.0.1:5173 node scripts\n | 65 | 分享 | 04 | T009 | `/card/share?coupon=c1` | Implemented | 按 `reference/分享.html` 还原：搜索框 + 「搭子列表」单选（默认选中首位）+ 底部主按钮，路由标题统一用「分享」（D-019）；B-015 已由用户定案关闭——搭子列表仅作夹具，不做对方接受/次数/时效/持久化 | 未验收 |\n | 66 | 分享成功 | 04 | T009 | `/card/share?coupon=c1&state=success` | Implemented | 按 `reference/分享成功.html` 还原：成功 hero + 原型副文案「已成功分享给好友，邀请他也来一起玩吧」+ 「分享的商品」卡 + 「查看我的卡包」/「返回」双按钮（D-019）；B-017 已由用户定案关闭——「洗发试用装 / 已发货 / 单次使用」仅展示夹具，不实现物流规则 | 未验收 |\n | 67 | 核销 | 04 | T009 | `/card/verify/confirm`、`?state=done`、`?state=repeat` | Implemented | 凭证明细 + 防误触确认 + 已核销反馈（Toast 后回 `/card?state=used`）+ 重复核销态 | 未验收 |\n-| 68 | 兑换卡券 | 04 | T009 | `/redeem`、`?state=format/invalid/used/network` | Implemented | 12 位规则按 `reference/兑换卡券.html`（D-008 关闭 B-009），规则集中在 `REDEEM_CODE_RULE` 且标 `pending`；覆盖格式错误/无效/已使用/网络异常 + 提交中 | 未验收 |\n+| 68 | 兑换卡券 | 04 | T009 | `/redeem`、`?state=format/invalid/used/network` | Implemented / real API pending verification | 2026-10-12 D-083：后台合同至少 10 位，原样提交，不限制字符集与前端上限；API `/api/cardredemptioncodes/redeem` 已接入 H5；旧 reference 12 位规则已被覆盖；覆盖格式错误/无效/已使用/网络异常 + 提交中 | 未验收 |\n | 69 | 兑换成功 | 04 | T009 | `/redeem?state=success` | Implemented | 成功入包 + 兑换码回显 + 「查看我的卡包」/「再兑换一张」 | 未验收 |\n \n T009 状态验收统一走页面右下角的「调试」胶囊（[DebugPanel.tsx](../../src/components/mobile/DebugPanel.tsx)，D-020）：胶囊只读 `routes.ts` 已登记的 `states`/`overlays`，点选后改写 `?state=` / `?overlay=`，上表所有 URL 都可以不手改地址栏直达。面板展开态证据：[t009-debug-panel-wallet.png](./evidence/screenshots/t009-debug-panel-wallet.png)、[t009-debug-panel-wallet-expired.png](./evidence/screenshots/t009-debug-panel-wallet-expired.png)、[t009-debug-panel-wallet-overlay.png](./evidence/screenshots/t009-debug-panel-wallet-overlay.png)、[t009-debug-panel-redeem.png](./evidence/screenshots/t009-debug-panel-redeem.png)、[t009-debug-panel-redeem-invalid.png](./evidence/screenshots/t009-debug-panel-redeem-invalid.png)。\ndiff --git a/docs/workbench/route-table.md b/docs/workbench/route-table.md\nindex a554004..12e8367 100644\n--- a/docs/workbench/route-table.md\n+++ b/docs/workbench/route-table.md\n@@ -73,7 +73,7 @@\n | `/card/share` | #65、#66 | 卡包-转赠 | 卡包 | `?coupon=`；`?state=success` |\n | `/card/verify` | #67 | 卡包-使用 | 卡包 | — |\n | `/card/verify/confirm` | #67 | 扫码页 / 使用弹层 | 卡包 | `?state=done/repeat`；确认后回 `/card?state=used` |\n-| `/redeem` | #68、#69 | 我的-卡券兑换 | 我的 / 卡包 | `?state=success/format/invalid/used/network`（12 位规则按 reference 标准页确认，B-009 已关闭） |\n+| `/redeem` | #68、#69 | 我的-卡券兑换 | 我的 / 卡包 | `?state=success/format/invalid/used/network`（2026-10-12 D-083 合同：至少 10 位，原值核兑；原型 12 位为历史） |\n \n ### T010 地址与订单\n \n@@ -170,4 +170,4 @@ T013: 57,58,71,70                                = 4\n - T001 当前五项 Tab 的 375 × 812 种籽页截图命名：`docs/workbench/evidence/screenshots/t001-seed-{home,points,scan,mall,profile}.png`（T023 起「服务」Tab 为 `/mall`）。`t001-seed-checkin.png`、`t001-seed-membership.png` 与 `t004-*.png` 保留为历史证据。\n - 运行断言：`BASE_URL=http://127.0.0.1:<port> node scripts/verify-t004.mjs`（11 项路由用例 + Tab 高亮 + `/draw-success`、`/membership` 两项重定向，全部 PASS）。\n - 工程检查：`npm run typecheck`、`npm run build` 通过。\n-- 已知差异与未决：`/draw-success` 旧路由移除；三档签运规则仍待 T006；兑换码位数仍待 T009。\n+- 已知差异与未决：`/draw-success` 旧路由移除；三档签运规则仍待 T006；兑换码位数已由 2026-10-12 后台合同��为至少 10 位（D-083）。\ndiff --git a/scripts/capture-t009.mjs b/scripts/capture-t009.mjs\nindex d69e0cb..cbb7eb1 100644\n--- a/scripts/capture-t009.mjs\n+++ b/scripts/capture-t009.mjs\n@@ -181,8 +181,8 @@ assert(submitDisabled, '空输入时提交按钮未禁用')\n await main().getByRole('textbox', { name: '兑换码' }).fill('drcard000888')\n await page.waitForTimeout(150)\n const typed = await main().getByRole('textbox', { name: '兑换码' }).inputValue()\n-console.log(`  #68 输入自动转大写: ${typed}`)\n-assert(typed === 'DRCARD000888', `输入未转大写: ${typed}`)\n+console.log(`  #68 原样保留大小写: ${typed}`)\n+assert(typed === 'drcard000888', `输入不应自动转大写: ${typed}`)\n await shot('68-redeem-typing')\n \n await main().getByRole('button', { name: '确认兑换' }).click()\ndiff --git a/scripts/capture-t013.mjs b/scripts/capture-t013.mjs\nindex 57f8850..1bea241 100644\n--- a/scripts/capture-t013.mjs\n+++ b/scripts/capture-t013.mjs\n@@ -39,7 +39,7 @@ const assertText = async (text, label) => {\n // —— 节点 #58 欢迎态：只有小诗欢迎语，不出现未确认的热门问题 ——\n await go('/service/chat')\n await assertText('你好，我是诗得丽 AI 客服小诗', '#58 欢迎态')\n-await assertText('企微客服', '#58 右上角入口')\n+if (await page.locator('[data-chat-wecom-entry]').count()) problems.push('[assert] 已隐藏的右上企微入口仍存在')\n const welcomeCount = await page.locator('[data-chat-message]').count()\n console.log(`  #58 欢迎态消息数: ${welcomeCount}`)\n if (welcomeCount !== 1) problems.push(`[assert] 欢迎态应只有 1 条消息，实际 ${welcomeCount}`)\n@@ -84,9 +84,9 @@ console.log(`  含失败关键词的发送失败气泡: ${keywordFailed}（预\n if (keywordFailed !== 1) problems.push(`[assert] 含「断网」的输入应稳定失败，实际失败 ${keywordFailed} 条`)\n await shot('58-keyword-failed')\n \n-// —— 节点 #71 弹层：右上角「企微客服」入口 ——\n+// —— 节点 #71 弹层：仅通过底部「人工」入口打开，右上企微入口隐藏 ——\n await go('/service/chat')\n-await page.locator('[data-chat-wecom-entry]').click()\n+await page.locator('[data-chat-human-entry]').click()\n await page.waitForTimeout(250)\n await assertText('请添加我们的企业微信福利官获取人工客服服务', '#71 弹层标题')\n await assertText('取消', '#71 取消')\n@@ -101,7 +101,7 @@ if (sheetGone !== 0) problems.push('[assert] 点击取消后弹层未关闭')\n console.log(`  取消后 URL: ${new URL(page.url()).search || '(无 query)'}`)\n await shot('71-after-cancel')\n \n-// —— #71 由页内「人工客服」入口打开 ——\n+// —— #71 底部「人工」入口再次打开同一原企微弹层（取消后可重试）——\n await page.locator('[data-chat-human-entry]').click()\n await page.waitForTimeout(250)\n await assertText('请添加我们的企业微信福利官获取人工客服服务', '#71 页内入口')\ndiff --git a/scripts/verify-h014-mock-scenarios.mjs b/scripts/verify-h014-mock-scenarios.mjs\nindex 60ed662..e0227e1 100644\n--- a/scripts/verify-h014-mock-scenarios.mjs\n+++ b/scripts/verify-h014-mock-scenarios.mjs\n@@ -77,7 +77,7 @@ try {\n   assert.equal((await searchBuddyByPhone(H014_BUDDY_PHONE_SCENARIOS.incoming)).outcome, 'already-buddies')\n   assert.equal(await acceptBuddyPhoneInvitation(incoming.invitationId), 'already-buddies')\n \n-  await redeemExchangeProduct('e1')\n+  assert.deepEqual(await redeemExchangeProduct(1), { couponId: 1, points: 1080 })\n \n   const buddyPageSource = fs.readFileSync('src/pages/BuddyPhoneInvite.tsx', 'utf8')\n   assert.doesNotMatch(buddyPageSource, /setTimeout|resolveBuddyPhoneOutcome|markPhoneInvited/)\n@@ -94,7 +94,7 @@ try {\n   assert.doesNotMatch(exchangeServiceSource, /runtimePolicy|VITE_DATA_MODE|mockScenario/)\n \n   console.log(\n-    'H014 PASS: BuddyPhoneInvite and Exchange use service → HTTP → MSW; buddy success/empty/business error/4xx/5xx/slow/network failure and invite state progression are deterministic.',\n+    'H014 PASS: BuddyPhoneInvite keeps deterministic H014 scenarios; Exchange uses its production /api/coupons/exchange contract through service → HTTP → MSW.',\n   )\n } finally {\n   mockServer?.close()\ndiff --git a/scripts/verify-reference-pages.mjs b/scripts/verify-reference-pages.mjs\nindex f89f492..2f436dd 100644\n--- a/scripts/verify-reference-pages.mjs\n+++ b/scripts/verify-reference-pages.mjs\n@@ -9,13 +9,13 @@ const cases = [\n   ['ref-confirm', '/card/verify/confirm', ['即将核销此券', '泡泡洗护 · 朝阳大悦城店', '确认核销']],\n   ['ref-password', '/card/verify/password', ['请输入消费密码', '确认核销']],\n   ['ref-levels', '/membership/levels', ['会员等级参考', 'LV.4', '会员卡面']],\n-  ['ref-redeem', '/redeem', ['兑换卡券', '12 位字母与数字组合', '确认兑换']],\n+  ['ref-redeem', '/redeem', ['兑换卡券', '兑换码不少于 10 位', '确认兑换']],\n   ['ref-points', '/points', ['泡泡值余额', '1,280', '泡泡任务', '泡泡值兑换']],\n   ['ref-points-detail', '/points/detail', ['泡泡值明细', '全部', '收入', '消耗']],\n   ['ref-settings', '/settings', ['资料设置', '消费密码', '确认修改']],\n   ['ref-onboarding', '/onboarding', ['完善信息', '消费密码', '确认信息']],\n   ['ref-officer', '/service/welfare-officer', ['品牌福利官', '吴哥', '人工客服', '活动咨询', '福利抽奖']],\n-  ['ref-chat', '/service/chat', ['智能客服', 'AI 客服 · 小诗', '你好，我是诗得丽 AI 客服小诗', '企微客服', '人工']],\n+  ['ref-chat', '/service/chat', ['智能客服', 'AI 客服 · 小诗', '你好，我是诗得丽 AI 客服小诗', '人工']],\n   ['ref-chat-conversation', '/service/chat?state=conversation', ['我的洗发水体验券怎么用？', '到店出示二维码由门店扫码核销']],\n   ['ref-chat-failed', '/service/chat?state=failed', ['门店周末营业到几点？', '发送失败，请检查网络后重试', '重试']],\n   ['ref-chat-sheet', '/service/chat?overlay=request-human', ['请添加我们的企业微信福利官获取人工客服服务', '长按或扫描识别二维码', '取消']],\ndiff --git a/src/app/auth/HomeAuthGate.tsx b/src/app/auth/HomeAuthGate.tsx\nindex ae7015b..2bb951d 100644\n--- a/src/app/auth/HomeAuthGate.tsx\n+++ b/src/app/auth/HomeAuthGate.tsx\n@@ -2,6 +2,7 @@ import { useEffect, useState, type ReactNode } from 'react'\n import { useNavigate } from 'react-router-dom'\n import { runtimePolicy } from '../config/runtime'\n import { bootstrapAuthSession, setAuthFlowEnabled } from '../../services/auth/session'\n+import { isAppError } from '../../services/http/appError'\n \n export default function HomeAuthGate({ children }: { children: ReactNode }) {\n   const navigate = useNavigate()\n@@ -18,8 +19,10 @@ export default function HomeAuthGate({ children }: { children: ReactNode }) {\n         setAuthFlowEnabled(true)\n         setReady(true)\n       },\n-      () => {\n-        if (active) navigate('/error?reason=auth', { replace: true })\n+      (error) => {\n+        if (!active) return\n+        if (isAppError(error) && error.status === 401) return\n+        navigate('/error?reason=auth', { replace: true })\n       },\n     )\n \ndiff --git a/src/app/fixtures/cards.ts b/src/app/fixtures/cards.ts\nindex e7d8c1b..09e55e8 100644\n--- a/src/app/fixtures/cards.ts\n+++ b/src/app/fixtures/cards.ts\n@@ -126,31 +126,15 @@ export const VERIFY_FEEDBACK = {\n   repeat: { title: '该券已核销', desc: '这张券已经完成核销，无法重复使用' },\n } as const\n \n-/**\n- * 兑换码规则配置（T009 #68）\n- * ⚠️ 未定稿：摹客原型出现 8 位与 11 位，reference 标准页与历史稿为 12 位（阻塞项 B-009）。\n- *    这里把「位数 / 字符集 / 提示文案」收成唯一配置源，页面只读不写，决策落地时只改这一处。\n- *    当前取值来自 reference/兑换卡券.html（maxlength=12、/^[A-Za-z0-9]{12}$/），仅作为夹具呈现值，不等于定稿规则。\n- */\n+/** 兑换码唯一前端校验：至少 10 个字符，不限制字符类型或最大长度。 */\n export interface RedeemCodeRule {\n-  length: number\n-  pattern: RegExp\n+  minLength: number\n   hint: string\n-  /** 规则是否仍未定稿；为 true 时页面不得表现为最终校验规则 */\n-  pending: boolean\n-  /** 关联阻塞项编号，便于反查决策记录 */\n-  blocker: string\n-  /** 当前取值来源 */\n-  source: string\n }\n \n export const REDEEM_CODE_RULE: RedeemCodeRule = {\n-  length: 12,\n-  pattern: /^[A-Za-z0-9]{12}$/,\n-  hint: '兑换码为 12 位字母与数字组合，不区分大小写',\n-  pending: true,\n-  blocker: 'B-009',\n-  source: 'reference/兑换卡券.html',\n+  minLength: 10,\n+  hint: '兑换码不少于 10 位',\n }\n \n /** 兑换结果分支（#68 失败态 / #69 成功态） */\n@@ -165,7 +149,7 @@ export interface RedeemFeedbackFixture {\n /** 反馈文案：成功/格式错误照抄 reference 标准页；其余分支为服务端失败态的中性提示，不含未确认的业务规则 */\n export const REDEEM_FEEDBACK: Record<RedeemOutcome, RedeemFeedbackFixture> = {\n   success: { outcome: 'success', ok: true, text: '兑换成功，已存入卡包' },\n-  format: { outcome: 'format', ok: false, text: '兑换码格式错误' },\n+  format: { outcome: 'format', ok: false, text: '兑换码不能少于 10 位' },\n   invalid: { outcome: 'invalid', ok: false, text: '兑换码无效，请核对后重试' },\n   used: { outcome: 'used', ok: false, text: '该兑换码已被使用' },\n   network: { outcome: 'network', ok: false, text: '网络异常，请稍后重试' },\n@@ -173,9 +157,11 @@ export const REDEEM_FEEDBACK: Record<RedeemOutcome, RedeemFeedbackFixture> = {\n \n /**\n  * 确定性兑换码 → 结果映射（禁止随机）\n- * 格式正确但需要落在不同服务端分支的验收码在此登记；未登记且格式正确的码统一走成功分支。\n+ * 仅登记演示专用码：真实 API 绝不调用这个函数。\n+ * 未登记的任意字符串按“无效码”演示，不用默认成功掩盖异常。\n  */\n export const REDEEM_CODE_OUTCOMES: Record<string, RedeemOutcome> = {\n+  DRCARD000888: 'success',\n   DRCARD000404: 'invalid',\n   DRCARD000USE: 'used',\n   DRCARD000NET: 'network',\n@@ -195,7 +181,7 @@ export const REDEEM_SAMPLE_CODES: Record<RedeemOutcome, string> = {\n \n /** 判定兑换结果：先按配置规则校验格式，再查确定性映射 */\n export function resolveRedeemOutcome(input: string): RedeemOutcome {\n-  const code = input.replace(/\\s/g, '')\n-  if (!REDEEM_CODE_RULE.pattern.test(code)) return 'format'\n-  return REDEEM_CODE_OUTCOMES[code.toUpperCase()] ?? 'success'\n+  const code = input\n+  if (code.length < REDEEM_CODE_RULE.minLength) return 'format'\n+  return REDEEM_CODE_OUTCOMES[code.toUpperCase()] ?? 'invalid'\n }\ndiff --git a/src/app/router/routes.ts b/src/app/router/routes.ts\nindex ffbd00e..05bc2fb 100644\n--- a/src/app/router/routes.ts\n+++ b/src/app/router/routes.ts\n@@ -923,7 +923,7 @@ export const ROUTES: RouteMeta[] = [\n     label: '商城',\n     icon: Headset,\n     title: '商城',\n-    titleBar: 'plain',\n+    titleBar: 'hidden',\n     titleBarTitle: '商城',\n     nodes: [17],\n     task: 'T008',\n@@ -976,7 +976,7 @@ export const ROUTES: RouteMeta[] = [\n       { key: 'used', node: 68, label: '兑换失败-已被使用' },\n       { key: 'network', node: 68, label: '兑换失败-网络异常' },\n     ],\n-    owner: '兑换码输入（T009 施工；8/11/12 位规则 ⚠️ 待确认 B-009，规则收在 REDEEM_CODE_RULE）',\n+    owner: '兑换码输入（T009；2026-10-12 后台合同至少 10 位，原值提交，规则收在 REDEEM_CODE_RULE）',\n   },\n   {\n     path: '/card/share',\ndiff --git a/src/components/mobile/ChatMessageList.tsx b/src/components/mobile/ChatMessageList.tsx\nindex 22ab6f5..180ad80 100644\n--- a/src/components/mobile/ChatMessageList.tsx\n+++ b/src/components/mobile/ChatMessageList.tsx\n@@ -1,5 +1,6 @@\n import { AlertCircle, RotateCcw } from 'lucide-react'\n import { CHAT_BOT, CHAT_RETRY_LABEL, CHAT_SEND_FAILED_HINT, type ChatMessage } from '../../app/fixtures'\n+import { hasChatRichText, sanitizeChatRichText } from './safeRichText'\n \n export interface ChatMessageListProps {\n   messages: ChatMessage[]\n@@ -49,7 +50,15 @@ export default function ChatMessageList({\n                     : `bg-primary text-text-inverse ${message.status === 'sending' ? 'opacity-70' : ''}`\n                 }`}\n               >\n-                {message.text}\n+                {hasChatRichText(message.text) ? (\n+                  <div\n+                    data-chat-rich-text\n+                    className=\"whitespace-normal [&_blockquote]:border-l-2 [&_blockquote]:pl-2 [&_code]:font-mono [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\"\n+                    dangerouslySetInnerHTML={{ __html: sanitizeChatRichText(message.text) }}\n+                  />\n+                ) : (\n+                  message.text\n+                )}\n               </div>\n \n               {message.status === 'sending' && (\ndiff --git a/src/components/mobile/EmptyStateIllustration.test.tsx b/src/components/mobile/EmptyStateIllustration.test.tsx\nindex ec272ae..c1a9298 100644\n--- a/src/components/mobile/EmptyStateIllustration.test.tsx\n+++ b/src/components/mobile/EmptyStateIllustration.test.tsx\n@@ -26,11 +26,24 @@ describe('EmptyStateIllustration', () => {\n     expect(getByRole('img', { name: '暂无记录' })).toBeTruthy()\n   })\n \n-  it('merges the caller className without changing the fixed size', () => {\n+  it('uses the 168px standard size by default and retains the caller className', () => {\n     const { container } = render(<EmptyStateIllustration className=\"mb-4\" />)\n \n     const wrapper = container.querySelector('[data-empty-state-illustration]')\n     expect(wrapper?.className).toContain('mb-4')\n     expect(wrapper?.className).toContain('flex')\n+    const image = container.querySelector('img')\n+    expect(image?.className).toContain('h-[168px]')\n+    expect(image?.className).toContain('w-[168px]')\n+  })\n+\n+  it('supports the 128px compact size without changing decorative accessibility', () => {\n+    const { container } = render(<EmptyStateIllustration size=\"sm\" />)\n+\n+    const image = container.querySelector('img')\n+    expect(image?.className).toContain('h-32')\n+    expect(image?.className).toContain('w-32')\n+    expect(image?.getAttribute('alt')).toBe('')\n+    expect(image?.getAttribute('aria-hidden')).toBe('true')\n   })\n })\ndiff --git a/src/components/mobile/EmptyStateIllustration.tsx b/src/components/mobile/EmptyStateIllustration.tsx\nindex 860a583..83caede 100644\n--- a/src/components/mobile/EmptyStateIllustration.tsx\n+++ b/src/components/mobile/EmptyStateIllustration.tsx\n@@ -3,23 +3,24 @@ import emptyIllustration from '../../assets/brand/empty/empty-illustration.webp'\n export interface EmptyStateIllustrationProps {\n   label?: string\n   className?: string\n+  /** 整页空状态使用 md（168px）；卡片、小区域使用 sm（128px）。 */\n+  size?: 'sm' | 'md'\n+}\n+\n+const IMAGE_SIZE_CLASS: Record<NonNullable<EmptyStateIllustrationProps['size']>, string> = {\n+  sm: 'h-32 w-32',\n+  md: 'h-[168px] w-[168px]',\n }\n \n /**\n- * 品牌插画版区块空态视觉件。\n- * -------------------------------------------------------------\n- * 与 `mobile/EmptyStateIcon`（96px 柔和圆形 + Lucide 图标）同为 ui/EmptyState 的\n- * visual 插槽素材，两者只是视觉档位不同，都只负责「画什么」，不携带任何业务空态\n- * 判定、文案或主操作 —— 那些留在消费页与 ui/EmptyState 里。\n- *\n- * - 尺寸走 h-28 w-28（112px）：比 EmptyStateIcon 的 96px 略重，在纯页面背景上\n- *   不至于喧宾夺主；插画自带大量留白，有效图形比 112px 视觉更小，比 160px 更贴合\n- *   单行文案的空态节奏。需要更强纵深时由调用方用 className 覆盖尺寸。\n- * - 插画为纯装饰：调用方不传 label 时 alt 置空并 aria-hidden，不占用无障碍名称。\n+ * 品牌空状态插画，只负责视觉，不负责业务状态、文案或操作。\n+ * 默认 md：整页空状态 168px；sm：卡片及小区域 128px。\n+ * 调用方保留 className 布局能力；不修改原素材及无障碍语义。\n  */\n export default function EmptyStateIllustration({\n   label,\n   className = '',\n+  size = 'md',\n }: EmptyStateIllustrationProps) {\n   return (\n     <span data-empty-state-illustration className={`flex items-center justify-center ${className}`}>\n@@ -29,7 +30,7 @@ export default function EmptyStateIllustration({\n         aria-hidden={label ? undefined : true}\n         role={label ? 'img' : undefined}\n         draggable={false}\n-        className=\"h-28 w-28 select-none object-contain\"\n+        className={`${IMAGE_SIZE_CLASS[size]} select-none object-contain`}\n       />\n     </span>\n   )\ndiff --git a/src/components/mobile/safeRichText.test.ts b/src/components/mobile/safeRichText.test.ts\nnew file mode 100644\nindex 0000000..10a1089\n--- /dev/null\n+++ b/src/components/mobile/safeRichText.test.ts\n@@ -0,0 +1,31 @@\n+// @vitest-environment jsdom\n+\n+import { describe, expect, it } from 'vitest'\n+import { hasChatRichText, sanitizeChatRichText } from './safeRichText'\n+\n+describe('safeRichText', () => {\n+  it('keeps the supported formatting subset', () => {\n+    const html = sanitizeChatRichText('<p>第一段<br><strong>重点</strong></p><ul><li>一项</li></ul>')\n+\n+    expect(html).toContain('<p>第一段<br><strong>重点</strong></p>')\n+    expect(html).toContain('<ul><li>一项</li></ul>')\n+  })\n+\n+  it('removes active content, embeds and attributes', () => {\n+    const html = sanitizeChatRichText(\n+      '<p onclick=\"alert(1)\" style=\"color:red\">安全文字<img src=x onerror=\"alert(2)\"><script>alert(3)</script></p><iframe src=\"https://example.com\"></iframe>',\n+    )\n+\n+    expect(html).toContain('安全文字')\n+    expect(html).not.toMatch(/onclick|onerror|style=|<img|<script|<iframe/i)\n+  })\n+\n+  it('does not treat ordinary angle brackets as html', () => {\n+    expect(hasChatRichText('1 < 2，3 > 2')).toBe(false)\n+    expect(hasChatRichText('<p>富文本</p>')).toBe(true)\n+  })\n+\n+  it('drops unsupported link markup while keeping its text', () => {\n+    expect(sanitizeChatRichText('<a href=\"javascript:alert(1)\">点这里</a>')).toBe('点这里')\n+  })\n+})\ndiff --git a/src/components/mobile/safeRichText.ts b/src/components/mobile/safeRichText.ts\nnew file mode 100644\nindex 0000000..8bcc7f2\n--- /dev/null\n+++ b/src/components/mobile/safeRichText.ts\n@@ -0,0 +1,38 @@\n+import DOMPurify from 'dompurify'\n+\n+const CHAT_RICH_TEXT_TAGS = [\n+  'p',\n+  'br',\n+  'strong',\n+  'b',\n+  'em',\n+  'i',\n+  'u',\n+  's',\n+  'ul',\n+  'ol',\n+  'li',\n+  'blockquote',\n+  'code',\n+]\n+\n+const HTML_TAG_PATTERN = /<\\/?[a-z][^>]*>/i\n+\n+export function hasChatRichText(value: string): boolean {\n+  return HTML_TAG_PATTERN.test(value)\n+}\n+\n+/**\n+ * Render backend rich text as a deliberately tiny, formatting-only HTML subset.\n+ *\n+ * No attributes are allowed, so links, inline styles, event handlers, images,\n+ * iframes and other active/embed content cannot survive sanitization.\n+ */\n+export function sanitizeChatRichText(value: string): string {\n+  return DOMPurify.sanitize(value, {\n+    ALLOWED_TAGS: CHAT_RICH_TEXT_TAGS,\n+    ALLOWED_ATTR: [],\n+    ALLOW_ARIA_ATTR: false,\n+    ALLOW_DATA_ATTR: false,\n+  })\n+}\ndiff --git a/src/layouts/MobileLayout.tsx b/src/layouts/MobileLayout.tsx\nindex 2578478..0983975 100644\n--- a/src/layouts/MobileLayout.tsx\n+++ b/src/layouts/MobileLayout.tsx\n@@ -1,4 +1,4 @@\n-import { useEffect, useLayoutEffect, useRef } from 'react'\n+import { useEffect, useLayoutEffect, useRef, useState } from 'react'\n import {\n   Navigate,\n   Outlet,\n@@ -7,7 +7,7 @@ import {\n   useNavigationType,\n   useViewTransitionState,\n } from 'react-router-dom'\n-import { Bell, MessageSquare, Settings } from 'lucide-react'\n+import { Bell, Settings } from 'lucide-react'\n import BottomNav from '../components/mobile/BottomNav'\n import TitleBar from '../components/mobile/TitleBar'\n import { findRouteByPathname, isLegacyTabPath } from '../app/router/routes'\n@@ -16,7 +16,9 @@ import { navigateWithH5ViewTransition } from '../app/router/h5Transition'\n import { useNotifications } from '../app/state/notifications'\n import { protectedFixtureRedirect, useOverlay } from '../app/fixtures/useFixture'\n import H5ScrollRestoration from '../components/mobile/H5ScrollRestoration'\n-import { AUTH_FAILURE_EVENT, setAuthFlowEnabled } from '../services/auth/session'\n+import Toast from '../components/ui/Toast'\n+import { closeWebView } from '../services/nativeBridge'\n+import { AUTH_EXPIRED_EVENT, AUTH_FAILURE_EVENT, setAuthFlowEnabled } from '../services/auth/session'\n \n type H5RouteTransitionKind = 'none' | 'tab' | 'forward' | 'back'\n \n@@ -111,6 +113,8 @@ export default function MobileLayout() {\n   const scrollSourcePathname = previousShellPathname.current\n   const { unreadCount, countLoaded } = useNotifications()\n   const { open: openOverlay } = useOverlay()\n+  const [authExpired, setAuthExpired] = useState(false)\n+  const authExpiredHandledRef = useRef(false)\n   const showLegacyNav = isLegacyTabPath(location.pathname)\n   const showNav = showLegacyNav || isFormalH5TabPath(location.pathname)\n   const route = findRouteByPathname(location.pathname)\n@@ -122,9 +126,22 @@ export default function MobileLayout() {\n     if (!activeFormalH5) return\n \n     const handleAuthFailure = () => navigate('/error?reason=auth', { replace: true })\n+    const handleAuthExpired = () => {\n+      if (authExpiredHandledRef.current) return\n+      authExpiredHandledRef.current = true\n+      setAuthExpired(true)\n+      window.setTimeout(() => {\n+        void closeWebView().catch((error) => {\n+          console.error('[auth-expired-close]', error)\n+        })\n+      }, 800)\n+    }\n+\n     window.addEventListener(AUTH_FAILURE_EVENT, handleAuthFailure)\n+    window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired)\n     return () => {\n       window.removeEventListener(AUTH_FAILURE_EVENT, handleAuthFailure)\n+      window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired)\n       setAuthFlowEnabled(false)\n     }\n   }, [activeFormalH5, navigate])\n@@ -168,17 +185,6 @@ export default function MobileLayout() {\n     openOverlay('clear')\n   }\n \n-  const openWecom = () => {\n-    navigate(\n-      {\n-        pathname: location.pathname,\n-        search: location.search,\n-        hash: '#wecom',\n-      },\n-      { replace: true, state: location.state },\n-    )\n-  }\n-\n   const titleAction = isNotificationsPage\n     ? (\n       <button\n@@ -202,45 +208,37 @@ export default function MobileLayout() {\n             <Bell className=\"h-[22px] w-[22px]\" />\n           </button>\n         )\n-        : location.pathname === '/service/chat'\n-          ? (\n-            /* T013R5+R6：「企微客服」pill 回到壳层 TitleBar 右侧（与「< 智能客服」同右侧）；\n-              * H016：hash 只表示当前页弹层状态，使用 replace 避免新增历史项和触发页面级滚动/过渡。\n-              * T013R6：pill 不限制宽度、whitespace-nowrap，保证「企微客服」四个字自然横向不被换行或裁剪。 */\n-            <button\n-              type=\"button\"\n-              data-chat-wecom-entry\n-              onClick={openWecom}\n-              className=\"inline-flex min-h-9 items-center gap-1 whitespace-nowrap rounded-pill bg-surface px-3 text-[13px] font-medium text-text-brand shadow-sm active:bg-surface-selected\"\n-            >\n-              <MessageSquare className=\"h-3.5 w-3.5\" aria-hidden />\n-              企微客服\n-            </button>\n-          )\n-          : undefined\n+        : undefined\n \n   return (\n-    <div className=\"app-background flex h-dvh flex-col overflow-hidden pt-[env(safe-area-inset-top)] text-text-primary\">\n-      {/* T013R7：min-w-0 防止 TitleBar 第三列 action 被撑大撑出页面右侧 */}\n-      <div className=\"min-w-0 shrink-0\">\n-        {titleBarMode !== 'hidden' && (\n-          <TitleBar\n-            title={title}\n-            leadingAction={leadingAction}\n-            onBack={route?.backTo ? () => navigateShell(route.backTo as string) : undefined}\n-            action={titleAction}\n-            actionWide={isNotificationsPage || location.pathname === '/service/chat'}\n-            fullWidth={activeFormalH5}\n-          />\n-        )}\n-      </div>\n-      <div className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain\" data-page-scroll>\n-        {isActiveFormalH5Route(route) && (\n-          <H5ScrollRestoration previousPathname={scrollSourcePathname} />\n-        )}\n-        <H5RouteOutlet />\n+    <>\n+      {authExpired && (\n+        <div className=\"pointer-events-none fixed inset-x-0 bottom-24 z-[100] flex justify-center px-6\">\n+          <Toast message=\"用户信息过期\" />\n+        </div>\n+      )}\n+      <div className=\"app-background flex h-dvh flex-col overflow-hidden pt-[env(safe-area-inset-top)] text-text-primary\">\n+        {/* T013R7：min-w-0 防止 TitleBar 第三列 action 被撑大撑出页面右侧 */}\n+        <div className=\"min-w-0 shrink-0\">\n+          {titleBarMode !== 'hidden' && (\n+            <TitleBar\n+              title={title}\n+              leadingAction={leadingAction}\n+              onBack={route?.backTo ? () => navigateShell(route.backTo as string) : undefined}\n+              action={titleAction}\n+              actionWide={isNotificationsPage}\n+              fullWidth={activeFormalH5}\n+            />\n+          )}\n+        </div>\n+        <div className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain\" data-page-scroll>\n+          {isActiveFormalH5Route(route) && (\n+            <H5ScrollRestoration previousPathname={scrollSourcePathname} />\n+          )}\n+          <H5RouteOutlet />\n+        </div>\n+        {showNav && <BottomNav variant={showLegacyNav ? 'legacy' : 'main'} />}\n       </div>\n-      {showNav && <BottomNav variant={showLegacyNav ? 'legacy' : 'main'} />}\n-    </div>\n+    </>\n   )\n }\ndiff --git a/src/mocks/fixtures/h014Scenarios.ts b/src/mocks/fixtures/h014Scenarios.ts\nindex 99f0f90..9a15814 100644\n--- a/src/mocks/fixtures/h014Scenarios.ts\n+++ b/src/mocks/fixtures/h014Scenarios.ts\n@@ -14,4 +14,3 @@ export const H014_BUDDY_PHONE_SCENARIOS = {\n \n export const H014_SLOW_RESPONSE_MS = 1_500\n \n-export const H014_EXCHANGE_REDEEM_SUCCESS = { ok: true } as const\ndiff --git a/src/mocks/handlers/exchange.ts b/src/mocks/handlers/exchange.ts\nindex 55dc644..9046e87 100644\n--- a/src/mocks/handlers/exchange.ts\n+++ b/src/mocks/handlers/exchange.ts\n@@ -1,24 +1,35 @@\n import { http, HttpResponse } from 'msw'\n \n-import { H014_EXCHANGE_REDEEM_PATH } from '../../services/exchange'\n-import { H014_EXCHANGE_REDEEM_SUCCESS } from '../fixtures/h014Scenarios'\n+import { EXCHANGE_REDEEM_PATH } from '../../services/exchange'\n \n-async function readProductId(request: Request) {\n+async function readCouponId(request: Request) {\n   const body: unknown = await request.json().catch(() => null)\n-  if (!body || typeof body !== 'object') return ''\n-  const productId = (body as { productId?: unknown }).productId\n-  return typeof productId === 'string' ? productId.trim() : ''\n+  if (!body || typeof body !== 'object') return null\n+  const id = (body as { id?: unknown }).id\n+  return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null\n }\n \n export const exchangeHandlers = [\n-  http.post(`*${H014_EXCHANGE_REDEEM_PATH}`, async ({ request }) => {\n-    const productId = await readProductId(request)\n-    if (!productId) {\n-      return HttpResponse.json({ message: 'productId is required' }, { status: 400 })\n+  http.post(`*${EXCHANGE_REDEEM_PATH}`, async ({ request }) => {\n+    const couponId = await readCouponId(request)\n+    if (couponId === null) {\n+      return HttpResponse.json({\n+        code: 500,\n+        message: '\"优惠券ID\" is required',\n+        data: [],\n+      })\n     }\n \n-    // B-026 settlement rules are still unconfirmed. H014 only migrates the existing successful\n-    // request lifecycle to the network boundary; it does not invent stock/balance settlement rules.\n-    return HttpResponse.json(H014_EXCHANGE_REDEEM_SUCCESS)\n+    // Preview/dev mock intentionally uses the exact production endpoint and envelope.\n+    // It only simulates the confirmed contract; it does not claim a real deduction/issue.\n+    return HttpResponse.json({\n+      code: 0,\n+      msg: 'success',\n+      status: 'succ',\n+      data: {\n+        coupon_id: couponId,\n+        points: 1080,\n+      },\n+    })\n   }),\n ]\ndiff --git a/src/pages/ApiServiceChat.test.tsx b/src/pages/ApiServiceChat.test.tsx\nindex 900ab18..ca18bcb 100644\n--- a/src/pages/ApiServiceChat.test.tsx\n+++ b/src/pages/ApiServiceChat.test.tsx\n@@ -36,7 +36,7 @@ vi.mock('./serviceChat/ServiceChatWecomQr', () => ({\n }))\n vi.mock('../components/ui', async original => {\n   const real = await original<typeof import('../components/ui')>()\n-  return { ...real, BottomSheet: ({ open, children }: { open: boolean; children: ReactNode }) => open ? <div>{children}</div> : null }\n+  return { ...real, BottomSheet: ({ open, children, actions, title }: { open: boolean; children: ReactNode; actions: ReactNode; title: string }) => open ? <div role=\"dialog\" aria-label={title}>{children}{actions}</div> : null }\n })\n import ApiServiceChat from './ApiServiceChat'\n \n@@ -64,7 +64,7 @@ describe('#135 real API chat page', () => {\n     expect(screen.queryByText('前面还有 2 位')).toBeNull()\n     expect(screen.queryByText('已收到你的问题，我先为你查询。')).toBeNull()\n     expect((screen.getByRole('button', { name: '发送' }) as HTMLButtonElement).disabled).toBe(true)\n-    expect((screen.getByRole('button', { name: '申请转人工客服' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(false)\n     expect(screen.getByText('AI 可用；人工消息通道尚未连通')).toBeTruthy()\n   })\n \n@@ -132,22 +132,32 @@ describe('#135 real API chat page', () => {\n     expect(mock.reload).toHaveBeenCalledOnce()\n   })\n \n-  it('allows real manual transfer only after socket connect, without fake agent queue', () => {\n+  it('compatibility: human entry opens the original WeCom QR while keeping real transfer uninvoked', () => {\n     mock.human = { status: 'connected', canTransfer: true, transferring: false, active: false, error: null, needsHistoryCheck: false }\n     mount()\n-    const button = screen.getByRole('button', { name: '申请转人工客服' }) as HTMLButtonElement\n+    const button = screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement\n     expect(button.disabled).toBe(false)\n     fireEvent.click(button)\n-    expect(mock.transfer).toHaveBeenCalledOnce()\n+    expect(screen.getByRole('img', { name: '企业微信福利官二维码' }).getAttribute('src')).toContain('/real-qr.png')\n+    expect(mock.transfer).not.toHaveBeenCalled()\n     expect(screen.getByText('AI 可用；可申请转人工，接入坐席状态未知')).toBeTruthy()\n-    expect(screen.queryByText('前面还有 2 位')).toBeNull()\n+    fireEvent.click(screen.getByRole('button', { name: '取消' }))\n+    expect(screen.queryByRole('img', { name: '企业微信福利官二维码' })).toBeNull()\n+  })\n+\n+  it('does not gate WeCom access on Socket.IO or AI history readiness', () => {\n+    mock.state = { status: 'error', message: '客服历史加载失败', messages: [], hasMore: false, loadingMore: false, moreError: null }\n+    mount()\n+    fireEvent.click(screen.getByRole('button', { name: '人工客服' }))\n+    expect(screen.getByRole('img', { name: '企业微信福利官二维码' })).toBeTruthy()\n+    expect(mock.transfer).not.toHaveBeenCalled()\n   })\n \n   it('requires explicit latest-history inspection after uncertain human transfer', () => {\n     mock.human = { status: 'connected', canTransfer: false, transferring: false, active: false,\n       error: '登录状态已变化，人工转接结果尚未确认，请先检查聊天历史', needsHistoryCheck: true }\n     mount()\n-    expect((screen.getByRole('button', { name: '申请转人工客服' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(false)\n     fireEvent.click(screen.getByRole('button', { name: '核对人工转接记录' }))\n     expect(mock.reload).toHaveBeenCalledOnce()\n     expect(mock.acknowledge).toHaveBeenCalledOnce()\ndiff --git a/src/pages/ApiServiceChat.tsx b/src/pages/ApiServiceChat.tsx\nindex 6ec17a7..045b61b 100644\n--- a/src/pages/ApiServiceChat.tsx\n+++ b/src/pages/ApiServiceChat.tsx\n@@ -41,6 +41,13 @@ export default function ApiServiceChat() {\n   const navigate = useNavigate()\n   const [wecomOpen, setWecomOpen] = useState(false)\n \n+  // Compatibility: reuse the exact #wecom action previously owned by the titlebar.\n+  // useHumanChat()/human.transfer() and its real Socket.IO flow are deliberately retained.\n+  const openWecom = () => {\n+    navigate({ pathname: location.pathname, search: location.search, hash: '#wecom' },\n+      { replace: true, state: location.state })\n+  }\n+\n   const closeWecom = () => {\n     setWecomOpen(false)\n     if (location.hash === '#wecom') {\n@@ -119,10 +126,8 @@ export default function ApiServiceChat() {\n         </div>}\n         <div className=\"flex items-end gap-2\">\n           <button type=\"button\" data-chat-human-entry\n-            onClick={() => { void human.transfer() }}\n-            disabled={history.status !== 'ready' || !human.canTransfer ||\n-              chat.busy || chat.blocked || chat.humanAwait}\n-            aria-label=\"申请转人工客服\"\n+            onClick={openWecom}\n+            aria-label=\"人工客服\"\n             className=\"flex h-11 w-11 flex-none flex-col items-center justify-center rounded-container bg-surface text-[10px] font-medium text-text-brand shadow-sm disabled:opacity-50\">\n             <Headset className=\"h-4 w-4\" aria-hidden />人工\n           </button>\n@@ -150,7 +155,7 @@ export default function ApiServiceChat() {\n           )}\n         </div>\n       </div>\n-      {/* TitleBar's #wecom QR is a separate action, never a fake transfer to a human agent. */}\n+      {/* 「人工」入口现复用原 #wecom 弹层；真实 human.transfer()/Socket.IO 链路保持原样。 */}\n       <BottomSheet\n         open={wecomOpen}\n         title={CHAT_HUMAN_PROMPT.title}\ndiff --git a/src/pages/Buddy.test.tsx b/src/pages/Buddy.test.tsx\nindex 12e4794..ae39ef6 100644\n--- a/src/pages/Buddy.test.tsx\n+++ b/src/pages/Buddy.test.tsx\n@@ -1,5 +1,5 @@\n import { afterEach, describe, expect, it, vi } from 'vitest'\n-import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'\n import { MemoryRouter } from 'react-router-dom'\n \n const mocks = vi.hoisted(() => ({\n@@ -34,11 +34,27 @@ describe('buddy list backend truth', () => {\n     expect(screen.getByLabelText('我的洗头搭子')).toBeTruthy()\n   })\n \n+  it('keeps refresh as a light inline action in the buddy section header', async () => {\n+    mocks.list.mockResolvedValue([{ id: 'server-1', nickname: 'HuangChao', avatarUrl: null }])\n+    mount()\n+    await waitFor(() => expect(screen.getByText('HuangChao')).toBeTruthy())\n+    const toolbar = screen.getByLabelText('搭子列表操作')\n+    expect(within(toolbar).getByRole('heading', { name: '我的搭子' })).toBeTruthy()\n+    const refresh = within(toolbar).getByRole('button', { name: '刷新搭子列表' })\n+    expect(refresh.textContent).toBe('刷新')\n+    expect(refresh.className).toContain('bg-transparent')\n+    expect(refresh.classList.contains('border')).toBe(false)\n+    expect(toolbar.nextElementSibling?.getAttribute('aria-label')).toBe('我的洗头搭子')\n+    expect(screen.getAllByRole('button', { name: '刷新搭子列表' })).toHaveLength(1)\n+  })\n+\n   it('treats server empty relations as an actual empty state', async () => {\n     mocks.list.mockResolvedValue([])\n     mount()\n     await waitFor(() => expect(screen.getByText('还没有洗头搭子噢～')).toBeTruthy())\n     expect(screen.queryByText('小美')).toBeNull()\n+    expect(screen.getByLabelText('搭子列表操作')).toBeTruthy()\n+    expect(screen.getByRole('button', { name: '刷新搭子列表' })).toBeTruthy()\n   })\n \n   it('refreshes real list after an accepted relation without local fixtures', async () => {\n@@ -70,6 +86,7 @@ describe('buddy list backend truth', () => {\n     mount()\n     expect(screen.getByText('演示搭子资料，非真实账号关系')).toBeTruthy()\n     expect(screen.getByText('小美')).toBeTruthy()\n+    expect(screen.queryByLabelText('搭子列表操作')).toBeNull()\n     expect(mocks.list).not.toHaveBeenCalled()\n   })\n })\ndiff --git a/src/pages/Buddy.tsx b/src/pages/Buddy.tsx\nindex 14a54da..0e0cc31 100644\n--- a/src/pages/Buddy.tsx\n+++ b/src/pages/Buddy.tsx\n@@ -92,6 +92,22 @@ export default function Buddy() {\n           演示搭子资料，非真实账号关系\n         </p>\n       )}\n+      {!fixtureMode && remote.status === 'ready' && (\n+        <div className=\"flex min-h-10 items-center justify-between px-5 pt-2\" aria-label=\"搭子列表操作\">\n+          <h2 className=\"text-sm font-medium text-buddy-text\">我的搭子</h2>\n+          <Button\n+            variant=\"ghost\"\n+            size=\"regular\"\n+            leadingIcon={RefreshCw}\n+            aria-label=\"刷新搭子列表\"\n+            className=\"gap-1.5 px-2 text-[13px]\"\n+            onClick={() => setRetry(value => value + 1)}\n+          >\n+            刷新\n+          </Button>\n+        </div>\n+      )}\n+\n       {!fixtureMode && remote.status !== 'ready' ? (\n         <section role=\"status\" className=\"mx-4 mt-4 rounded-container bg-surface px-4 py-10 text-center shadow-card\">\n           {remote.status === 'loading' ? (\n@@ -132,7 +148,7 @@ export default function Buddy() {\n           }\n         />\n       ) : (\n-        <section className=\"px-4 pt-3\" aria-label=\"我的洗头搭子\">\n+        <section className={`px-4 ${fixtureMode ? 'pt-3' : 'pt-1'}`} aria-label=\"我的洗头搭子\">\n           <ul className=\"space-y-2.5\">\n             {displayed.map((buddy) => (\n               <li key={buddy.id}>\n@@ -155,15 +171,6 @@ export default function Buddy() {\n         </section>\n       )}\n \n-      {!fixtureMode && remote.status === 'ready' && (\n-        <div className=\"mt-3 px-4 text-right\">\n-          <Button variant=\"outline\" size=\"regular\" leadingIcon={RefreshCw}\n-            onClick={() => setRetry(value => value + 1)}>\n-            刷新搭子列表\n-          </Button>\n-        </div>\n-      )}\n-\n       <section className=\"mt-4 px-4\" aria-label={BUDDY_FEATURE_INTRO.title}>\n         <p className=\"px-1 text-sm font-medium text-buddy-text\">{BUDDY_FEATURE_INTRO.title}</p>\n         <ul className=\"mt-2 overflow-hidden rounded-container bg-surface shadow-card\">\ndiff --git a/src/pages/Card.test.tsx b/src/pages/Card.test.tsx\nindex 0b70dd8..b43147e 100644\n--- a/src/pages/Card.test.tsx\n+++ b/src/pages/Card.test.tsx\n@@ -1,10 +1,11 @@\n import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n import { MemoryRouter } from 'react-router-dom'\n import type { ReactNode } from 'react'\n-import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n \n const mocks = vi.hoisted(() => ({\n   fetchMyCoupons: vi.fn(),\n+  fetchRedeemedLocalCards: vi.fn(),\n   overlay: null as string | null,\n   close: vi.fn(),\n }))\n@@ -14,6 +15,10 @@ vi.mock('../services/myCoupons', async (importOriginal) => {\n   return { ...actual, fetchMyCoupons: mocks.fetchMyCoupons }\n })\n \n+vi.mock('../services/myRedeemedCards', () => ({\n+  fetchRedeemedLocalCards: mocks.fetchRedeemedLocalCards,\n+}))\n+\n vi.mock('../services/cardPackageProbe', () => ({\n   probeDiscountCardList: vi.fn(),\n }))\n@@ -108,8 +113,13 @@ function renderCard(entry = '/card') {\n   )\n }\n \n+beforeEach(() => {\n+  mocks.fetchRedeemedLocalCards.mockResolvedValue([])\n+})\n+\n afterEach(() => {\n   mocks.fetchMyCoupons.mockReset()\n+  mocks.fetchRedeemedLocalCards.mockReset()\n   mocks.overlay = null\n   mocks.close.mockReset()\n })\n@@ -296,3 +306,37 @@ describe('Card MyCoupons integration', () => {\n     expect(screen.queryByText('核心洗发水体验券')).toBeNull()\n   })\n })\n+\n+describe('Card redeemed local source=60 integration', () => {\n+  it('shows local redeemed cards alongside third-party cards in the same tab without enabling third-party actions', async () => {\n+    mocks.fetchMyCoupons.mockResolvedValue({\n+      data: [{ id: 5, active_name: '第三方体验券', enable_amount: '12.00' }],\n+      current_page: 1, per_page: 100, total: 1, last_page: 1,\n+    })\n+    mocks.fetchRedeemedLocalCards.mockResolvedValue([\n+      { id: 21, source: 60, status: 10, card_name: '洗护体验卡', card_sn: 'RC00000000000014', card_type: 10 },\n+      { id: 22, source: 60, status: 20, card_name: '已使用兑换券', card_type: 10 },\n+    ])\n+    renderCard()\n+    expect(await screen.findByText('洗护体验卡')).toBeTruthy()\n+    expect(screen.getByText('第三方体验券')).toBeTruthy()\n+    expect(screen.getByText('兑换码领取 · RC00000000000014')).toBeTruthy()\n+    const localCard = screen.getByText('洗护体验卡').closest('article')!\n+    expect(localCard.querySelector<HTMLButtonElement>('button')?.disabled).toBe(true)\n+    expect(localCard.querySelectorAll<HTMLButtonElement>('button')[1]?.disabled).toBe(true)\n+    await waitFor(() => expect(screen.getByRole('tab', { name: /可用/ }).textContent).toContain('2'))\n+    fireEvent.click(screen.getByRole('tab', { name: /已使用/ }))\n+    expect(await screen.findByText('已使用兑换券')).toBeTruthy()\n+  })\n+\n+  it('keeps the third-party list visible when local source fails', async () => {\n+    mocks.fetchMyCoupons.mockResolvedValue({\n+      data: [{ id: 5, active_name: '第三方体验券' }],\n+      current_page: 1, per_page: 100, total: 1, last_page: 1,\n+    })\n+    mocks.fetchRedeemedLocalCards.mockRejectedValue(new Error('本地卡包网络失败'))\n+    renderCard()\n+    expect(await screen.findByText('第三方体验券')).toBeTruthy()\n+    expect(await screen.findByText(/本地卡包网络失败/)).toBeTruthy()\n+  })\n+})\ndiff --git a/src/pages/Card.tsx b/src/pages/Card.tsx\nindex 5d75768..80032af 100644\n--- a/src/pages/Card.tsx\n+++ b/src/pages/Card.tsx\n@@ -22,6 +22,7 @@ import {\n   type MyCouponRecord,\n   type MyCouponType,\n } from '../services/myCoupons'\n+import { fetchRedeemedLocalCards, type LocalUserCard } from '../services/myRedeemedCards'\n \n /**\n  * 电影票样式卡券（自营页面专用 - 第一版）\n@@ -36,11 +37,12 @@ import {\n  * - 不引入 CSS 变量，固定值先用 Tailwind class 表达；金色渐变通过 inline style 注入。\n  */\n /**\n- * 票券视图模型：正式数据来自 MyCoupons 用户持券记录。\n+ * 票券视图模型：分别映射第三方 MyCoupons 与本地 source=60 兑换码卡券。\n  * 原始金额与完整有效期仍保留在 service/domain 数据中；这里仅做展示映射。\n  */\n interface CouponTicketView {\n   id: string | null\n+  origin?: 'local'\n   name: string\n   amountLabel?: string\n   validityLabel?: string\n@@ -74,12 +76,39 @@ function toCouponTicketView(coupon: MyCouponRecord): CouponTicketView {\n   }\n }\n \n+/** 本地卡券只按后台状态展示，不借用第三方 MyCoupons 的券 ID 和使用/转赠入口。 */\n+function toLocalCardView(card: LocalUserCard): CouponTicketView {\n+  const epoch = Number(card.valid_end_time)\n+  const validityLabel = card.valid_end_time != null && Number.isFinite(epoch) && epoch > 0\n+    ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(epoch * 1000)) + ' 到期'\n+    : undefined\n+  return {\n+    origin: 'local',\n+    id: 'local-' + String(card.id),\n+    name: card.card_name,\n+    amountLabel: card.card_type === 20 ? toAmountLabel(card.face_value) : undefined,\n+    validityLabel,\n+    limitNote: card.card_sn ? `兑换码领取 · ${card.card_sn}` : '兑换码领取',\n+  }\n+}\n+\n+const LOCAL_CARD_STATUS_BY_TAB: Record<CardCouponStatus, number> = {\n+  available: 10,\n+  used: 20,\n+  expired: 30,\n+}\n+\n const MY_COUPON_TYPE_BY_STATUS: Record<CardCouponStatus, MyCouponType> = {\n   available: 'unused',\n   used: 'used',\n   expired: 'out_of_date',\n }\n \n+type LocalCardsRemote =\n+  | { state: 'loading' }\n+  | { state: 'success'; cards: LocalUserCard[] }\n+  | { state: 'error'; message: string }\n+\n type CouponRemote =\n   | { state: 'loading' }\n   | { state: 'success'; coupons: MyCouponRecord[]; total: number }\n@@ -103,7 +132,7 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n     : 'linear-gradient(135deg, var(--color-reward) 0%, var(--color-reward-strong) 100%)'\n   /** 操作按钮可用性 */\n   // A third-party record without a log ID must never trigger use/transfer.\n-  const canAction = !isInactive && coupon.id !== null\n+  const canAction = !isInactive && coupon.id !== null && coupon.origin !== 'local'\n   return (\n     <article\n       className=\"relative\"\n@@ -275,6 +304,28 @@ export default function Card() {\n     createInitialCouponLists,\n   )\n   const [retryVersion, setRetryVersion] = useState(0)\n+  const [localCardsRemote, setLocalCardsRemote] = useState<LocalCardsRemote>({ state: 'loading' })\n+\n+  /** 本地兑换码卡券独立加载，不把它混作第三方优惠卡记录。 */\n+  useEffect(() => {\n+    let active = true\n+    if (runtimePolicy.dataMode === 'mock') {\n+      setLocalCardsRemote({ state: 'success', cards: [] })\n+      return () => { active = false }\n+    }\n+    setLocalCardsRemote({ state: 'loading' })\n+    void fetchRedeemedLocalCards()\n+      .then((cards) => { if (active) setLocalCardsRemote({ state: 'success', cards }) })\n+      .catch((error: unknown) => {\n+        if (active) setLocalCardsRemote({\n+          state: 'error',\n+          message: isAppError(error) && error.kind === 'contract'\n+            ? '兑换码卡券数据暂时无法展示，请稍后重试'\n+            : error instanceof Error ? error.message : '网络请求失败',\n+        })\n+      })\n+    return () => { active = false }\n+  }, [retryVersion])\n \n   /**\n    * MyCoupons 没有分页 UI，因此每个分类按服务端允许的最大 pageSize 拉取，\n@@ -328,13 +379,19 @@ export default function Card() {\n   }, [retryVersion])\n \n   const retryCouponList = () => {\n+    setLocalCardsRemote({ state: 'loading' })\n     setCouponLists((current) => ({ ...current, [tab]: { state: 'loading' } }))\n     setRetryVersion((version) => version + 1)\n   }\n \n   const currentRemote = couponLists[tab]\n-  const list =\n-    currentRemote.state === 'success' ? currentRemote.coupons.map(toCouponTicketView) : []\n+  const localCards = localCardsRemote.state === 'success'\n+    ? localCardsRemote.cards.filter((card) => card.status === LOCAL_CARD_STATUS_BY_TAB[tab]).map(toLocalCardView)\n+    : []\n+  const list = [\n+    ...(currentRemote.state === 'success' ? currentRemote.coupons.map(toCouponTicketView) : []),\n+    ...localCards,\n+  ]\n \n   const availableCoupons =\n     couponLists.available.state === 'success'\n@@ -364,7 +421,11 @@ export default function Card() {\n \n   const couponCount = (key: CardCouponStatus) => {\n     const remote = couponLists[key]\n-    return remote.state === 'success' ? remote.total : 0\n+    const externalCount = remote.state === 'success' ? remote.total : 0\n+    const localCount = localCardsRemote.state === 'success'\n+      ? localCardsRemote.cards.filter((card) => card.status === LOCAL_CARD_STATUS_BY_TAB[key]).length\n+      : 0\n+    return externalCount + localCount\n   }\n \n   useEffect(() => {\n@@ -470,22 +531,21 @@ export default function Card() {\n         ))}\n       </div>\n \n-      <div className=\"mt-4 space-y-4\" aria-live=\"polite\" aria-busy={currentRemote.state === 'loading'}>\n-        {currentRemote.state === 'success' &&\n-          list.map((coupon, index) => (\n+      <div className=\"mt-4 space-y-4\" aria-live=\"polite\" aria-busy={currentRemote.state === 'loading' || localCardsRemote.state === 'loading'}>\n+        {list.map((coupon, index) => (\n             <MovieTicket\n-              key={coupon.id ?? ('missing-id-' + index)}\n+              key={(coupon.origin ?? 'external') + '-' + (coupon.id ?? ('missing-id-' + index))}\n               coupon={coupon}\n               used={tab === 'used'}\n               expired={tab === 'expired'}\n               onUse={() => openUseSheet(coupon)}\n               onShare={() => {\n-                if (coupon.id) navigate('/card/share?coupon=' + encodeURIComponent(coupon.id))\n+                if (coupon.origin !== 'local' && coupon.id) navigate('/card/share?coupon=' + encodeURIComponent(coupon.id))\n               }}\n             />\n           ))}\n \n-        {currentRemote.state === 'loading' && <CouponTicketSkeleton />}\n+        {list.length === 0 && (currentRemote.state === 'loading' || localCardsRemote.state === 'loading') && <CouponTicketSkeleton />}\n \n         {currentRemote.state === 'error' && (\n           <EmptyState\n@@ -499,7 +559,14 @@ export default function Card() {\n           />\n         )}\n \n-        {currentRemote.state === 'success' && list.length === 0 && (\n+        {localCardsRemote.state === 'error' && (\n+          <div role=\"alert\" className=\"rounded-xl bg-surface p-3 text-sm text-text-secondary\">\n+            <p>兑换码卡券加载失败：{localCardsRemote.message}</p>\n+            <button type=\"button\" className=\"mt-2 text-text-brand\" onClick={retryCouponList}>重试兑换码卡券</button>\n+          </div>\n+        )}\n+\n+        {currentRemote.state === 'success' && localCardsRemote.state === 'success' && list.length === 0 && (\n           <EmptyState\n             variant=\"no-data\"\n             className=\"min-h-[180px]\"\ndiff --git a/src/pages/Exchange.test.tsx b/src/pages/Exchange.test.tsx\nindex 999de54..f71c6ea 100644\n--- a/src/pages/Exchange.test.tsx\n+++ b/src/pages/Exchange.test.tsx\n@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({\n   redeem: vi.fn(),\n   useExchangeCoupons: vi.fn(),\n   reloadCoupons: vi.fn(),\n+  reloadPoints: vi.fn(),\n   categoryRemote: { state: 'success', data: [\n     { key: '2', label: '洗发体验' }, { key: '3', label: '护发体验' },\n   ] } as { state: string; data?: Array<{ key: string; label: string }>; message?: string },\n@@ -23,14 +24,13 @@ vi.mock('../app/config/runtime', () => ({\n }))\n vi.mock('../services/exchange', () => ({\n   redeemExchangeProduct: mocks.redeem,\n-  H014_EXCHANGE_REDEEM_UNAVAILABLE_COPY: '兑换服务尚未接通，请稍后再试',\n }))\n vi.mock('./exchange/useExchangeFeed', () => ({\n   useExchangeCoupons: mocks.useExchangeCoupons,\n   useExchangeCategories: () => ({ remote: mocks.categoryRemote, reload: mocks.categoryReload }),\n }))\n vi.mock('./points/usePointsFeed', () => ({\n-  useUserPointsStat: () => ({ remote: mocks.pointsRemote, reload: vi.fn() }),\n+  useUserPointsStat: () => ({ remote: mocks.pointsRemote, reload: mocks.reloadPoints }),\n }))\n vi.mock('../app/router/routes', () => ({\n   findRouteByPathname: () => ({ path: '/exchange', task: 'T008', nodes: [18], states: [], overlays: [] }),\n@@ -45,6 +45,7 @@ vi.mock('../components/card/BubbleValueRedeemCard', () => ({\n   ),\n }))\n \n+import { EXCHANGE_COPY } from '../app/fixtures'\n import Exchange from './Exchange'\n import ExchangeResult from './ExchangeResult'\n \n@@ -115,6 +116,7 @@ afterEach(() => {\n   mocks.redeem.mockReset()\n   mocks.useExchangeCoupons.mockReset()\n   mocks.reloadCoupons.mockReset()\n+  mocks.reloadPoints.mockReset()\n   mocks.mode = 'mock'\n   mocks.pointsRemote = { state: 'success', data: { points: 500, income: 0, expense: 0 } }\n })\n@@ -232,7 +234,9 @@ describe('UX-E: page-local exchange flow', () => {\n \n   it('preserves request failure in the same sheet and permits an explicit retry', async () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n-    mocks.redeem.mockRejectedValueOnce(new Error('后端拒绝兑换')).mockResolvedValueOnce(undefined)\n+    mocks.redeem\n+      .mockRejectedValueOnce(new Error('后端拒绝兑换'))\n+      .mockResolvedValueOnce({ couponId: 1, points: 300 })\n     renderExchange()\n     let sheet = openCoupon()\n     fireEvent.click(within(sheet).getByRole('button', { name: '立即兑换' }))\n@@ -248,7 +252,9 @@ describe('UX-E: page-local exchange flow', () => {\n   it('guards against rapid double submission until pending request settles', async () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n     let resolveRequest!: () => void\n-    mocks.redeem.mockReturnValue(new Promise<void>(resolve => { resolveRequest = resolve }))\n+    mocks.redeem.mockReturnValue(new Promise(resolve => {\n+      resolveRequest = () => resolve({ couponId: 1, points: 300 })\n+    }))\n     renderExchange()\n     const sheet = openCoupon()\n     const submit = within(sheet).getByRole('button', { name: '立即兑换' })\n@@ -263,12 +269,14 @@ describe('UX-E: page-local exchange flow', () => {\n \n   it('shows mock success as simulation only and only navigates by explicit card-pack action', async () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n-    mocks.redeem.mockResolvedValue(undefined)\n+    mocks.redeem.mockResolvedValue({ couponId: 1, points: 300 })\n     renderExchange()\n     const sheet = openCoupon()\n     fireEvent.click(within(sheet).getByRole('button', { name: '立即兑换' }))\n     const result = await screen.findByRole('dialog', { name: '模拟兑换结果' })\n-    expect(within(result).getByText('未实际扣除泡泡值，也未发放卡券。', { exact: false })).toBeTruthy()\n+    expect(within(result).getByText('当前为 Mock 预览，仅验证正式兑换协议与页面状态，不代表真实扣值或发券。')).toBeTruthy()\n+    expect(mocks.reloadPoints).toHaveBeenCalledOnce()\n+    expect(mocks.reloadCoupons).toHaveBeenCalledOnce()\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n     fireEvent.click(within(result).getByRole('button', { name: '关闭' }))\n     expect(screen.queryByRole('dialog')).toBeNull()\n@@ -281,15 +289,23 @@ describe('UX-E: page-local exchange flow', () => {\n     expect(screen.getByText('卡包页面')).toBeTruthy()\n   })\n \n-  it('never exposes fake H014 redemption success in API/test/prod mode', () => {\n+  it('uses the real exchange service in API mode and refreshes authoritative reads after success', async () => {\n     mocks.mode = 'api'\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n+    mocks.redeem.mockResolvedValue({ couponId: 1, points: 300 })\n     renderExchange()\n+\n     const sheet = openCoupon()\n-    expect(within(sheet).getByText('兑换服务尚未接通，请稍后再试')).toBeTruthy()\n-    expect((within(sheet).getByRole('button', { name: '立即兑换' }) as HTMLButtonElement).disabled).toBe(true)\n-    expect(mocks.redeem).not.toHaveBeenCalled()\n-    expect(screen.queryByRole('dialog', { name: '模拟兑换结果' })).toBeNull()\n+    const submit = within(sheet).getByRole('button', { name: '立即兑换' }) as HTMLButtonElement\n+    expect(submit.disabled).toBe(false)\n+    fireEvent.click(submit)\n+\n+    const result = await screen.findByRole('dialog', { name: '兑换结果' })\n+    expect(within(result).getByText('兑换完成，剩余泡泡值 300。可前往卡包查看新券。')).toBeTruthy()\n+    expect(mocks.redeem).toHaveBeenCalledWith(1)\n+    expect(mocks.reloadPoints).toHaveBeenCalledOnce()\n+    expect(mocks.reloadCoupons).toHaveBeenCalledOnce()\n+    expect(screen.getByTestId('route').textContent).toBe('/exchange')\n   })\n \n   it('does not convert legacy /exchange/result deep links into a success state', async () => {\n@@ -309,6 +325,22 @@ describe('UX-E: page-local exchange flow', () => {\n     view.unmount()\n     mocks.useExchangeCoupons.mockReturnValue({ remote: { state: 'success', data: [] }, reload: mocks.reloadCoupons })\n     renderExchange()\n+    const emptyTitle = screen.getByText('没有找到相关体验券')\n+    const emptySection = emptyTitle.closest('section')\n+    expect(emptySection).toBeTruthy()\n+    const illustration = emptySection?.querySelector('[data-empty-state-illustration] img')\n+    expect(illustration).toBeTruthy()\n+    expect(illustration?.className).toContain('w-[168px]')\n+    expect(emptySection?.parentElement?.className).not.toContain('bg-surface')\n+  })\n+\n+  it('retains the category reset action in the illustrated empty state', () => {\n+    mocks.useExchangeCoupons.mockReturnValue({ remote: { state: 'success', data: [] }, reload: mocks.reloadCoupons })\n+    renderExchange()\n+    fireEvent.click(screen.getByRole('tab', { name: '洗发体验' }))\n     expect(screen.getByText('没有找到相关体验券')).toBeTruthy()\n+    expect(document.querySelector('[data-empty-state-illustration]')).toBeTruthy()\n+    fireEvent.click(screen.getByRole('button', { name: EXCHANGE_COPY.emptyAction }))\n+    expect(screen.getByRole('tab', { name: '全部' }).getAttribute('aria-selected')).toBe('true')\n   })\n })\ndiff --git a/src/pages/Exchange.tsx b/src/pages/Exchange.tsx\nindex a2c1c16..fb7ed5b 100644\n--- a/src/pages/Exchange.tsx\n+++ b/src/pages/Exchange.tsx\n@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'\n import { useNavigate } from 'react-router-dom'\n import { ChevronRight, Droplets, PartyPopper, Ticket } from 'lucide-react'\n import DebugPanel from '../components/mobile/DebugPanel'\n+import EmptyStateIllustration from '../components/mobile/EmptyStateIllustration'\n import PageContainer from '../components/mobile/PageContainer'\n import PromptOverlay from '../components/mobile/PromptOverlay'\n import BubbleValueRedeemCard from '../components/card/BubbleValueRedeemCard'\n@@ -12,7 +13,7 @@ import { useUserPointsStat } from './points/usePointsFeed'\n import { useExchangeCategories, useExchangeCoupons } from './exchange/useExchangeFeed'\n import { EXCHANGE_COPY } from '../app/fixtures'\n import type { CouponRedeemView } from '../services/coupons'\n-import { redeemExchangeProduct, H014_EXCHANGE_REDEEM_UNAVAILABLE_COPY } from '../services/exchange'\n+import { redeemExchangeProduct, type ExchangeRedeemResult } from '../services/exchange'\n import kitThumb from '../assets/brand/member/checkin-dearseed-kit.webp'\n \n /** 接口 `image` 为空时的券图兜底（沿用已验收品牌图，不新增假字段）。 */\n@@ -63,7 +64,7 @@ const availabilityButtonLabel = (state: ExchangeAvailability): string =>\n  * ⚠️ B-024 / B-025 / B-026：排序方向、完整 SKU 清单与泡泡值扣减 / 卡包写入均为服务端规则，\n  *    页面只展示接口结果，不做持久化扣减。\n  * UX-E（2026-10-09）：分类 / 券选择 / 确认 / 兑换结果只用组件状态，绝不通过 URL 驱动。\n- * H014 占位仅允许 Mock 演示；正式 API 未接通之前禁止假成功或模拟扣点入包。\n+ * #74：兑换动作统一走 POST /api/coupons/exchange；库存、扣泡泡值、流水、发券及失败补偿均由后端负责。\n  */\n export default function Exchange() {\n   const navigate = useNavigate()\n@@ -71,7 +72,7 @@ export default function Exchange() {\n   /** UX-E 分类为页面本地状态；真实分类 key 来自后台，绝不靠 URL/硬编码维护。 */\n   const [category, setCategory] = useState<string>('all')\n   const [activeProductId, setActiveProductId] = useState<string | null>(null)\n-  const [simulationComplete, setSimulationComplete] = useState(false)\n+  const [redeemResult, setRedeemResult] = useState<ExchangeRedeemResult | null>(null)\n   const [submitting, setSubmitting] = useState(false)\n   const [submitError, setSubmitError] = useState<string | null>(null)\n   const submissionLock = useRef(false)\n@@ -97,7 +98,7 @@ export default function Exchange() {\n   }, [categoriesRemote.state, validCategory])\n \n   /** GET /api/userpoints/stat：余额严格取 points。兑换结果不在页面伪扣款。 */\n-  const { remote: statRemote } = useUserPointsStat()\n+  const { remote: statRemote, reload: reloadPoints } = useUserPointsStat()\n   const balance = statRemote.state === 'success' ? statRemote.data.points : null\n \n   // Resolve against current list to prevent a stale coupon selection after a reload.\n@@ -121,13 +122,13 @@ export default function Exchange() {\n   }\n \n   const openRedeem = (product: CouponRedeemView) => {\n-    if (submissionLock.current || simulationComplete) return\n+    if (submissionLock.current || redeemResult) return\n     setActiveProductId(String(product.id))\n     setSubmitError(null)\n   }\n \n   const closeResult = () => {\n-    setSimulationComplete(false)\n+    setRedeemResult(null)\n     setActiveProductId(null)\n     setSubmitError(null)\n   }\n@@ -144,22 +145,19 @@ export default function Exchange() {\n \n   const submit = async () => {\n     if (!activeProduct || availability !== 'redeemable' || submissionLock.current) return\n-    // #74: No confirmed production endpoint exists. H014 mock is strictly a dev/preview\n-    // simulation and must not emit a real issue/settlement success in API/test/prod.\n-    if (runtimePolicy.dataMode !== 'mock') {\n-      setSubmitError(H014_EXCHANGE_REDEEM_UNAVAILABLE_COPY)\n-      return\n-    }\n \n     submissionLock.current = true\n     setSubmitting(true)\n     setSubmitError(null)\n     try {\n-      await redeemExchangeProduct(String(activeProduct.id))\n+      const result = await redeemExchangeProduct(activeProduct.id)\n       setActiveProductId(null)\n-      setSimulationComplete(true)\n-      // A mock response does NOT decrement points, issue a coupon or prove real settlement.\n-      // #74 owns the eventual real endpoint and authoritative refresh rules.\n+      setRedeemResult(result)\n+\n+      // Backend response is authoritative for success. Refresh the two page-level reads\n+      // that can change after settlement: remaining points and coupon stock/redeemed count.\n+      reloadPoints()\n+      reloadCoupons()\n     } catch (error) {\n       setSubmitError(error instanceof Error && error.message ? error.message : EXCHANGE_REQUEST_ERROR_COPY)\n     } finally {\n@@ -225,20 +223,19 @@ export default function Exchange() {\n             />\n           </div>\n         ) : list.length === 0 ? (\n-          <div className=\"rounded-container bg-surface py-2 shadow-sm\">\n-            <EmptyState\n-              variant=\"no-results\"\n-              title={EXCHANGE_COPY.emptyTitle}\n-              supportingText={EXCHANGE_COPY.emptyDesc}\n-              primaryAction={\n-                category === 'all' ? undefined : (\n-                  <Button variant=\"outline\" onClick={() => changeCategory('all')}>\n-                    {EXCHANGE_COPY.emptyAction}\n-                  </Button>\n-                )\n-              }\n-            />\n-          </div>\n+          <EmptyState\n+            variant=\"no-results\"\n+            visual={<EmptyStateIllustration size=\"md\" />}\n+            title={EXCHANGE_COPY.emptyTitle}\n+            supportingText={EXCHANGE_COPY.emptyDesc}\n+            primaryAction={\n+              category === 'all' ? undefined : (\n+                <Button variant=\"outline\" onClick={() => changeCategory('all')}>\n+                  {EXCHANGE_COPY.emptyAction}\n+                </Button>\n+              )\n+            }\n+          />\n         ) : (\n           <ul className=\"grid grid-cols-2 gap-3\" aria-live=\"polite\">\n             {list.map((product) => {\n@@ -346,12 +343,6 @@ export default function Exchange() {\n               </p>\n             )}\n \n-            {runtimePolicy.dataMode !== 'mock' && (\n-              <p className=\"mt-2 text-xs text-text-secondary\" role=\"status\">\n-                {H014_EXCHANGE_REDEEM_UNAVAILABLE_COPY}\n-              </p>\n-            )}\n-\n             {submitError && (\n               <p className=\"mt-2 text-xs text-danger-text\" role=\"alert\">\n                 {submitError}\n@@ -362,7 +353,7 @@ export default function Exchange() {\n               size=\"large\"\n               className=\"mt-4 w-full rounded-full\"\n               loading={submitting}\n-              disabled={availability !== 'redeemable' || runtimePolicy.dataMode !== 'mock'}\n+              disabled={availability !== 'redeemable' || submitting}\n               onClick={() => void submit()}\n             >\n               {submitting ? EXCHANGE_COPY.submitting : availabilityButtonLabel(availability)}\n@@ -372,8 +363,8 @@ export default function Exchange() {\n       </BottomSheet>\n \n       <PromptOverlay\n-        open={simulationComplete}\n-        label=\"模拟兑换结果\"\n+        open={redeemResult !== null}\n+        label={runtimePolicy.dataMode === 'mock' ? '模拟兑换结果' : '兑换结果'}\n         onDismiss={closeResult}\n         className=\"bg-surface px-6 pb-6 pt-7 text-center\"\n       >\n@@ -385,10 +376,12 @@ export default function Exchange() {\n           <PartyPopper className=\"h-9 w-9 text-claim-text\" />\n         </div>\n         <h2 className=\"mt-4 text-lg font-bold leading-6 text-text-primary\">\n-          兑换成功演示\n+          {runtimePolicy.dataMode === 'mock' ? '兑换成功演示' : '兑换成功'}\n         </h2>\n         <p className=\"mt-2 text-sm leading-6 text-text-secondary\">\n-          仅供 H5 预览。未实际扣除泡泡值，也未发放卡券。\n+          {runtimePolicy.dataMode === 'mock'\n+            ? '当前为 Mock 预览，仅验证正式兑换协议与页面状态，不代表真实扣值或发券。'\n+            : `兑换完成，剩余泡泡值 ${redeemResult?.points.toLocaleString() ?? '--'}。可前往卡包查看新券。`}\n         </p>\n         <Button size=\"large\" className=\"mt-6 h-11 w-full rounded-full\" onClick={openCardPack}>\n           {EXCHANGE_COPY.successAction}\ndiff --git a/src/pages/MallWebView.tsx b/src/pages/MallWebView.tsx\nindex 4738d27..341b6d6 100644\n--- a/src/pages/MallWebView.tsx\n+++ b/src/pages/MallWebView.tsx\n@@ -2,14 +2,14 @@\n  * 商城 H5 承载页\n  * -------------------------------------------------------------\n  * 底部 Tab「商城」(/mall) 不再做本地原生还原，改为在当前手机界面框架内\n- * 以 iframe 加载商城 H5（www.3-wins.cn），使底栏与手机壳层保持可见，\n+ * 以 iframe 加载商城 H5，使底栏与手机壳层保持可见，\n  * 不跳出 App / H5 外层界面。\n  *\n- * 2026-09-29 Maintainer 明确定案：当前联调地址固定为 http://www.3-wins.cn/，\n- * 不自动替换 scheme。鉴权透传、cleartext/mixed-content 以及目标站 iframe 策略\n- * （X-Frame-Options / CSP frame-ancestors）仍需在真实 App WebView 内验证。\n+ * 2026-10-12：当前地址仅用于效果预览，不代表正式商城接入方案。\n+ * 保留用户提供的 http scheme，不自动改写为 https；鉴权透传、cleartext/mixed-content\n+ * 及目标站 iframe 策略仍需在真实 App WebView 内单独验证。\n  */\n-const MALL_H5_URL = 'http://www.3-wins.cn/'\n+const MALL_H5_URL = 'http://scmzhds07.m.demo.webportal.top/?_sc=1'\n \n export default function MallWebView() {\n   return (\ndiff --git a/src/pages/PointsDetail.tsx b/src/pages/PointsDetail.tsx\nindex 63ee8fe..af99c72 100644\n--- a/src/pages/PointsDetail.tsx\n+++ b/src/pages/PointsDetail.tsx\n@@ -117,7 +117,7 @@ export default function PointsDetail() {\n              视觉统一使用品牌插画空态，直接落在页面背景上，不再套白卡片容器。 */\n           <EmptyState\n             variant=\"no-data\"\n-            visual={<EmptyStateIllustration />}\n+            visual={<EmptyStateIllustration size=\"md\" />}\n             title={BUBBLE_LIST_END}\n           />\n         ) : (\ndiff --git a/src/pages/Redeem.test.tsx b/src/pages/Redeem.test.tsx\nnew file mode 100644\nindex 0000000..e4e75ab\n--- /dev/null\n+++ b/src/pages/Redeem.test.tsx\n@@ -0,0 +1,94 @@\n+import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n+import { MemoryRouter } from 'react-router-dom'\n+import type { ReactNode } from 'react'\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+\n+const mocks = vi.hoisted(() => ({ redeemCardCode: vi.fn() }))\n+\n+vi.mock('../services/redeemCode', () => ({\n+  redeemCardCode: mocks.redeemCardCode,\n+}))\n+\n+vi.mock('../app/config/runtime', async (importOriginal) => {\n+  const original = await importOriginal<typeof import('../app/config/runtime')>()\n+  return { ...original, runtimePolicy: { ...original.runtimePolicy, dataMode: 'api' } }\n+})\n+\n+vi.mock('../app/fixtures/useFixture', () => ({\n+  useFixtureState: () => ({ state: null }),\n+}))\n+\n+vi.mock('../app/router/routes', () => ({\n+  findRouteByPathname: () => ({ path: '/redeem' }),\n+}))\n+\n+vi.mock('../components/mobile/DebugPanel', () => ({ default: () => null }))\n+vi.mock('../components/mobile/PageContainer', () => ({\n+  default: ({ children }: { children: ReactNode }) => <div>{children}</div>,\n+}))\n+\n+import Redeem from './Redeem'\n+import { AppError } from '../lib/appError'\n+\n+const receipt = {\n+  user_card_id: 21, card_id: 4,\n+  card_name: '洗护体验卡',\n+  card_sn: 'RC00000000000014',\n+}\n+\n+const renderRedeem = () => render(\n+  <MemoryRouter initialEntries={['/redeem']}><Redeem /></MemoryRouter>,\n+)\n+\n+afterEach(() => mocks.redeemCardCode.mockReset())\n+\n+describe('Redeem confirmed backend integration', () => {\n+  it('preserves exact code and displays actual issued card receipt only after backend success', async () => {\n+    mocks.redeemCardCode.mockResolvedValue(receipt)\n+    renderRedeem()\n+    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n+      target: { value: 'Abc- 123456' },\n+    })\n+    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n+    await waitFor(() => expect(mocks.redeemCardCode).toHaveBeenCalledWith('Abc- 123456'))\n+    expect(await screen.findByText('洗护体验卡')).toBeTruthy()\n+    expect(screen.getByText('卡券编号：RC00000000000014')).toBeTruthy()\n+    expect(screen.getByRole('button', { name: '查看我的卡包' })).toBeTruthy()\n+  })\n+\n+  it('keeps the form for backend business errors and displays its error message', async () => {\n+    mocks.redeemCardCode.mockRejectedValue(new Error('该兑换码已被使用'))\n+    renderRedeem()\n+    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n+      target: { value: 'ABC123456789' },\n+    })\n+    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n+    expect(await screen.findByText('该兑换码已被使用')).toBeTruthy()\n+    expect(screen.queryByText('兑换成功')).toBeNull()\n+    expect(screen.getByRole('button', { name: '确认兑换' })).toBeTruthy()\n+  })\n+\n+  it('shows neutral user-facing text when backend receipt fails contract validation', async () => {\n+    mocks.redeemCardCode.mockRejectedValue(new AppError({\n+      kind: 'contract', message: '外部数据不符合 cardredemptioncodes.redeem.data 契约',\n+    }))\n+    renderRedeem()\n+    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n+      target: { value: 'ABC123456789' },\n+    })\n+    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n+    expect(await screen.findByText('兑换结果暂时无法确认，请稍后重试')).toBeTruthy()\n+    expect(screen.queryByText(/cardredemptioncodes.redeem.data/)).toBeNull()\n+    expect(screen.queryByText('兑换成功')).toBeNull()\n+  })\n+\n+  it('disables submitting fewer than ten characters, but does not alter or uppercase content', () => {\n+    renderRedeem()\n+    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n+      target: { value: 'a1-b2+3' },\n+    })\n+    expect((screen.getByRole('textbox', { name: '兑换码' }) as HTMLInputElement).value).toBe('a1-b2+3')\n+    expect((screen.getByRole('button', { name: '确认兑换' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect(mocks.redeemCardCode).not.toHaveBeenCalled()\n+  })\n+})\ndiff --git a/src/pages/Redeem.tsx b/src/pages/Redeem.tsx\nindex af73d85..7aae9f5 100644\n--- a/src/pages/Redeem.tsx\n+++ b/src/pages/Redeem.tsx\n@@ -2,6 +2,9 @@ import { useEffect, useRef, useState } from 'react'\n import { useNavigate } from 'react-router-dom'\n import { AlertCircle, CheckCircle2, Loader2, Ticket, XCircle } from 'lucide-react'\n import DebugPanel from '../components/mobile/DebugPanel'\n+import { isAppError } from '../services/http'\n+import { runtimePolicy } from '../app/config/runtime'\n+import { redeemCardCode, type RedemptionReceipt } from '../services/redeemCode'\n import PageContainer from '../components/mobile/PageContainer'\n import { useFixtureState } from '../app/fixtures/useFixture'\n import { findRouteByPathname } from '../app/router/routes'\n@@ -18,12 +21,8 @@ import {\n  * -------------------------------------------------------------\n  * 视觉与交互取自 reference/兑换卡券.html：ticket 前缀图标、56 高输入框、聚焦描边、\n  * 清除按钮仅在有输入时出现、48 高圆角主按钮、居中 toast（成功绿 / 失败红）。\n- * ⚠️ 未定稿（B-009）：摹客原型分别出现「8 位」「11 位」，reference 与历史稿为 12 位。\n- *    页面不自持任何位数常量，位数 / 字符集 / 提示文案全部读 REDEEM_CODE_RULE，\n- *    定稿时只改夹具一处。规则未定期间提交按钮只在「空输入」时禁用（照 reference syncState），\n- *    不用位数提前拦截，避免把未确认的位数表现成最终校验规则。\n- * 失败分支（无效 / 已使用 / 网络异常）通过 REDEEM_CODE_OUTCOMES 的确定性演示码触发，\n- * 也可用 `?state=invalid` 等直���截图。\n+ * 兑换码至少 10 位，不修改用户输入字符；API 模式走后台正式核兑接口。\n+ * 只有 preview/dev Mock 模式使用固定演示码，API/test/prod 以后台发券回执为准。\n  */\n export default function Redeem() {\n   const navigate = useNavigate()\n@@ -33,10 +32,12 @@ export default function Redeem() {\n   const [code, setCode] = useState('')\n   const [submitting, setSubmitting] = useState(false)\n   const [outcome, setOutcome] = useState<RedeemOutcome | null>(null)\n+  const [errorMessage, setErrorMessage] = useState<string | null>(null)\n+  const [receipt, setReceipt] = useState<RedemptionReceipt | null>(null)\n   const timers = useRef<number[]>([])\n \n-  const trimmed = code.replace(/\\s/g, '')\n-  const feedback = outcome ? REDEEM_FEEDBACK[outcome] : null\n+  const trimmed = code\n+  const feedback = errorMessage ? { ok: false, text: errorMessage } : outcome ? REDEEM_FEEDBACK[outcome] : null\n   const done = outcome === 'success'\n \n   /** `?state=` 直达：回填该分支的演示码并落到对应反馈，便于逐节点截图 */\n@@ -44,7 +45,9 @@ export default function Redeem() {\n     const key = state?.key as RedeemOutcome | undefined\n     if (!key || !(key in REDEEM_SAMPLE_CODES)) return\n     setCode(REDEEM_SAMPLE_CODES[key])\n+    setReceipt(null)\n     setOutcome(key)\n+    setErrorMessage(null)\n     setSubmitting(false)\n   }, [state?.key])\n \n@@ -55,21 +58,43 @@ export default function Redeem() {\n   }\n \n   const clear = () => {\n+    setReceipt(null)\n     setCode('')\n     setOutcome(null)\n+    setErrorMessage(null)\n   }\n \n-  const submit = () => {\n-    if (!trimmed || submitting) return\n+  const submit = async () => {\n+    if (trimmed.length < REDEEM_CODE_RULE.minLength || submitting) return\n     setOutcome(null)\n+    setReceipt(null)\n+    setErrorMessage(null)\n     setSubmitting(true)\n-    /** 提交中态：夹具环境下用固定时长模拟一次请求往返，非随机 */\n-    track(() => {\n+    if (runtimePolicy.dataMode === 'mock') {\n+      /** Mock 只在预览环境产生确定性演示结果，API 模式绝不模拟成功。 */\n+      track(() => {\n+        setSubmitting(false)\n+        const next = resolveRedeemOutcome(trimmed)\n+        setOutcome(next)\n+        if (next !== 'success') track(() => setOutcome(null), 2000)\n+      }, 700)\n+      return\n+    }\n+    try {\n+      const nextReceipt = await redeemCardCode(trimmed)\n+      setReceipt(nextReceipt)\n+      setOutcome('success')\n+    } catch (error) {\n+      if (isAppError(error) && error.kind === 'contract') {\n+        setErrorMessage('兑换结果暂时无法确认，请稍后重试')\n+      } else if (isAppError(error) && (error.kind === 'network' || error.kind === 'timeout')) {\n+        setErrorMessage('网络异常，请稍后重试')\n+      } else {\n+        setErrorMessage(error instanceof Error ? error.message : '兑换失败，请稍后重试')\n+      }\n+    } finally {\n       setSubmitting(false)\n-      const next = resolveRedeemOutcome(trimmed)\n-      setOutcome(next)\n-      if (next !== 'success') track(() => setOutcome(null), 2000)\n-    }, 700)\n+    }\n   }\n \n   if (done) {\n@@ -81,6 +106,7 @@ export default function Redeem() {\n           </span>\n           <h1 className=\"text-lg font-semibold text-text-primary\">兑换成功</h1>\n           <p className=\"max-w-[260px] text-sm leading-6 text-text-secondary\">{REDEEM_FEEDBACK.success.text}</p>\n+          {receipt && <p className=\"max-w-[260px] text-sm font-medium text-text-primary\">{receipt.card_name}</p>}\n         </div>\n \n         <section className=\"mx-4 mt-5 flex items-center gap-3 rounded-container bg-surface p-3 shadow-sm\">\n@@ -93,6 +119,7 @@ export default function Redeem() {\n           <div className=\"min-w-0 flex-1\">\n             <p className=\"text-xs text-text-tertiary\">兑换码</p>\n             <p className=\"mt-0.5 truncate text-base font-semibold tracking-[0.08em] text-text-primary\">{trimmed}</p>\n+            {receipt && <p className=\"mt-1 truncate text-xs text-text-secondary\">卡券编号：{receipt.card_sn}</p>}\n           </div>\n         </section>\n \n@@ -122,13 +149,12 @@ export default function Redeem() {\n           type=\"text\"\n           inputMode=\"text\"\n           autoComplete=\"off\"\n-          autoCapitalize=\"characters\"\n           spellCheck={false}\n-          maxLength={REDEEM_CODE_RULE.length}\n           value={code}\n           onChange={(event) => {\n-            setCode(event.target.value.toUpperCase())\n+            setCode(event.target.value)\n             setOutcome(null)\n+            setErrorMessage(null)\n           }}\n           placeholder=\"请输入兑换码\"\n           aria-label=\"兑换码\"\n@@ -145,7 +171,7 @@ export default function Redeem() {\n \n       <button\n         type=\"button\"\n-        disabled={!trimmed || submitting}\n+        disabled={trimmed.length < REDEEM_CODE_RULE.minLength || submitting}\n         onClick={submit}\n         className=\"mt-6 flex h-12 w-full items-center justify-center gap-2 rounded-full bg-primary text-base font-medium text-text-inverse active:bg-primary-pressed disabled:bg-disabled disabled:text-text-disabled\"\n       >\ndiff --git a/src/pages/ServiceChat.compat.test.tsx b/src/pages/ServiceChat.compat.test.tsx\nnew file mode 100644\nindex 0000000..9b9d57a\n--- /dev/null\n+++ b/src/pages/ServiceChat.compat.test.tsx\n@@ -0,0 +1,71 @@\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { fireEvent, render, screen } from '@testing-library/react'\n+import { MemoryRouter } from 'react-router-dom'\n+import type { ReactNode } from 'react'\n+\n+vi.mock('../app/config/runtime', async original => {\n+  const actual = await original<typeof import('../app/config/runtime')>()\n+  return { ...actual, runtimePolicy: { ...actual.runtimePolicy, dataMode: 'mock' } }\n+})\n+vi.mock('../app/fixtures/useFixture', () => ({\n+  useFixtureState: () => ({ state: null }),\n+}))\n+vi.mock('../app/router/routes', () => ({\n+  findRouteByPathname: () => ({ path: '/service/chat' }),\n+}))\n+vi.mock('../components/mobile/DebugPanel', () => ({ default: () => null }))\n+vi.mock('../components/mobile/PageContainer', () => ({\n+  default: ({ children }: { children: ReactNode }) => <div>{children}</div>,\n+}))\n+vi.mock('../components/mobile/ChatMessageList', () => ({\n+  default: () => <div>欢迎聊天</div>,\n+}))\n+vi.mock('../components/mobile/WecomQrPlaceholder', () => ({\n+  default: () => <div>原企微二维码区域</div>,\n+}))\n+vi.mock('../components/ui', async original => {\n+  const actual = await original<typeof import('../components/ui')>()\n+  return { ...actual, BottomSheet: ({ open, children, actions }: {\n+    open: boolean; children: ReactNode; actions: ReactNode\n+  }) => open ? <div role=\"dialog\">{children}{actions}</div> : null }\n+})\n+\n+import ServiceChat from './ServiceChat'\n+\n+afterEach(() => {\n+  vi.restoreAllMocks()\n+})\n+\n+describe('legacy/mock ServiceChat compatibility', () => {\n+  it('opens the old WeCom sheet from 人工 without starting the preserved local queue', () => {\n+    render(<MemoryRouter initialEntries={['/service/chat']}><ServiceChat /></MemoryRouter>)\n+    const button = screen.getByRole('button', { name: '人工' })\n+    expect(button.hasAttribute('disabled')).toBe(false)\n+    fireEvent.click(button)\n+    expect(screen.getByText('原企微二维码区域')).toBeTruthy()\n+    expect(document.querySelector('[data-human-stage]')?.getAttribute('data-human-stage')).toBe('idle')\n+    fireEvent.click(screen.getByRole('button', { name: '取消' }))\n+    expect(screen.queryByText('原企微二维码区域')).toBeNull()\n+    fireEvent.click(button)\n+    expect(screen.getByText('原企微二维码区域')).toBeTruthy()\n+  })\n+\n+  it('keeps mock AI input available and original typed 人工 transfer reachable', () => {\n+    render(<MemoryRouter initialEntries={['/service/chat']}><ServiceChat /></MemoryRouter>)\n+    const input = screen.getByRole('textbox', { name: '输入你的问题' }) as HTMLInputElement\n+    expect(input.disabled).toBe(false)\n+    fireEvent.change(input, { target: { value: '你好' } })\n+    fireEvent.click(screen.getByRole('button', { name: '发送' }))\n+    expect(document.querySelector('[data-human-stage]')?.getAttribute('data-human-stage')).toBe('idle')\n+\n+    fireEvent.change(input, { target: { value: '人工' } })\n+    fireEvent.click(screen.getByRole('button', { name: '发送' }))\n+    expect(document.querySelector('[data-human-stage]')?.getAttribute('data-human-stage')).toBe('queuing')\n+    expect(input.disabled).toBe(true)\n+  })\n+\n+  it('keeps the previous #wecom deep link working', () => {\n+    render(<MemoryRouter initialEntries={['/service/chat#wecom']}><ServiceChat /></MemoryRouter>)\n+    expect(screen.getByText('原企微二维码区域')).toBeTruthy()\n+  })\n+})\ndiff --git a/src/pages/ServiceChat.tsx b/src/pages/ServiceChat.tsx\nindex d7d1a35..b258929 100644\n--- a/src/pages/ServiceChat.tsx\n+++ b/src/pages/ServiceChat.tsx\n@@ -50,6 +50,14 @@ function MockServiceChat() {\n \n   /** T013R5：企微二维码弹层 —— 与壳层 TitleBar 联动，靠 `#wecom` hash 触发 */\n   const [wecomOpen, setWecomOpen] = useState(false)\n+  // Compatibility: the visible \"人工\" entry opens the original #wecom sheet.\n+  // Keep requestHuman() and the existing human-stage implementation unchanged.\n+  const openWecom = () => {\n+    navigate(\n+      { pathname: location.pathname, search: location.search, hash: '#wecom' },\n+      { replace: true, state: location.state },\n+    )\n+  }\n   const closeWecom = () => {\n     setWecomOpen(false)\n     /* H016：通过 Router replace 清 hash，保留当前 history entry/state，不绕过 React Router。 */\n@@ -164,9 +172,11 @@ function MockServiceChat() {\n     settle(id, target.text)\n   }\n \n-  /** T013R4：人工坐席已接入后输入区可用；否则禁用并显示「正在接入...」 */\n+  /** 兼容处理：保留原「人工」关键词触发 requestHuman() 的入口。\n+   * idle 时允许正常 AI 输入或输入「人工」进入原模拟转接状态；\n+   * 仅排队中禁用输入，接入后继续可发送。 */\n   const humanActive = humanStage === 'connected'\n-  const inputDisabled = !humanActive\n+  const inputDisabled = humanStage === 'queuing'\n   const inputPlaceholder = humanActive\n     ? `与 ${CHAT_QUEUE.connected.agentName} 对话中…`\n     : humanStage === 'queuing'\n@@ -191,8 +201,7 @@ function MockServiceChat() {\n           <button\n             type=\"button\"\n             data-chat-human-entry\n-            onClick={requestHuman}\n-            disabled={humanStage !== 'idle'}\n+            onClick={openWecom}\n             className=\"flex h-11 w-11 flex-none flex-col items-center justify-center rounded-container bg-surface text-[10px] font-medium text-text-brand shadow-sm active:bg-surface-selected disabled:opacity-50\"\n           >\n             <Headset className=\"h-4 w-4\" aria-hidden />\n@@ -232,10 +241,8 @@ function MockServiceChat() {\n         </div>\n       </div>\n \n-      {/* T013R4：企微二维码弹层恢复（R1+R2 撤掉的 #71 重新启用，但仅承担\"企微客服\"入口）。\n-        * 触发：点击壳层 TitleBar 右侧「企微客服」pill（data-chat-wecom-entry）。\n-        * 行为：仅展示福利官二维码 + 「取消」按钮，不承担\"转人工\"职责 ——「人工」走 requestHuman。\n-        * T013R5：「取消」按钮居中（外层 flex justify-center）。 */}\n+      {/* 兼容性处理：右上企微入口隐藏；底部「人工」复用原 #wecom 弹层。\n+        * 既有 requestHuman()／人工状态机保留，单独治理，不在此变更删除。 */}\n       <BottomSheet\n         open={wecomOpen}\n         title={CHAT_HUMAN_PROMPT.title}\ndiff --git a/src/services/auth/session.bootstrap.test.ts b/src/services/auth/session.bootstrap.test.ts\nnew file mode 100644\nindex 0000000..239bf60\n--- /dev/null\n+++ b/src/services/auth/session.bootstrap.test.ts\n@@ -0,0 +1,145 @@\n+import { beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+const mocks = vi.hoisted(() => ({\n+  request: vi.fn(),\n+  getLoginToken: vi.fn(),\n+  setAuthHeadersProvider: vi.fn(),\n+  setUnauthorizedHandler: vi.fn(),\n+  setAuthFailureHandler: vi.fn(),\n+}))\n+\n+vi.mock('../../app/config/runtime', () => ({\n+  runtimePolicy: {\n+    apiBaseUrl: 'https://api.example.test',\n+    dataMode: 'api',\n+  },\n+}))\n+\n+vi.mock('../nativeBridge', () => ({\n+  getLoginToken: mocks.getLoginToken,\n+}))\n+\n+vi.mock('../http/httpClient', () => ({\n+  createHttpClient: () => ({\n+    request: mocks.request,\n+  }),\n+  setHttpAuthHeadersProvider: mocks.setAuthHeadersProvider,\n+  setHttpUnauthorizedHandler: mocks.setUnauthorizedHandler,\n+  setHttpAuthFailureHandler: mocks.setAuthFailureHandler,\n+}))\n+\n+describe('bootstrap auth 401 handling', () => {\n+  beforeEach(() => {\n+    vi.resetModules()\n+    mocks.request.mockReset()\n+    mocks.getLoginToken.mockReset()\n+    mocks.setAuthHeadersProvider.mockReset()\n+    mocks.setUnauthorizedHandler.mockReset()\n+    mocks.setAuthFailureHandler.mockReset()\n+    mocks.getLoginToken.mockResolvedValue({ token: 'native-token', salt: 'native-salt' })\n+  })\n+\n+  it('retries the initial login once after a 401 and succeeds without expiring the WebView', async () => {\n+    const { bootstrapAuthSession, AUTH_EXPIRED_EVENT } = await import('./session')\n+    const { AppError } = await import('../http/appError')\n+    const expired = vi.fn()\n+    window.addEventListener(AUTH_EXPIRED_EVENT, expired)\n+\n+    mocks.request\n+      .mockRejectedValueOnce(\n+        new AppError({ kind: 'business', message: 'unauthorized', status: 401, code: '401' }),\n+      )\n+      .mockResolvedValueOnce({\n+        code: 0,\n+        data: { userInfo: { id: 'user-1' }, accessToken: 'access-1' },\n+      })\n+\n+    await expect(bootstrapAuthSession()).resolves.toEqual({\n+      userInfo: { id: 'user-1' },\n+      accessToken: 'access-1',\n+    })\n+    expect(mocks.request).toHaveBeenCalledTimes(2)\n+    expect(expired).not.toHaveBeenCalled()\n+\n+    window.removeEventListener(AUTH_EXPIRED_EVENT, expired)\n+  })\n+\n+  it('emits auth-expired when the initial login and its single retry both return 401', async () => {\n+    const { bootstrapAuthSession, AUTH_EXPIRED_EVENT } = await import('./session')\n+    const { AppError } = await import('../http/appError')\n+    const expired = vi.fn()\n+    window.addEventListener(AUTH_EXPIRED_EVENT, expired)\n+\n+    mocks.request\n+      .mockRejectedValueOnce(\n+        new AppError({ kind: 'business', message: 'unauthorized', status: 401, code: '401' }),\n+      )\n+      .mockRejectedValueOnce(\n+        new AppError({ kind: 'business', message: 'unauthorized', status: 401, code: '401' }),\n+      )\n+\n+    await expect(bootstrapAuthSession()).rejects.toMatchObject({ status: 401 })\n+    expect(mocks.request).toHaveBeenCalledTimes(2)\n+    expect(expired).toHaveBeenCalledTimes(1)\n+\n+    window.removeEventListener(AUTH_EXPIRED_EVENT, expired)\n+  })\n+\n+  it('shares the whole initial retry flow with concurrent bootstrap callers', async () => {\n+    const { bootstrapAuthSession, AUTH_EXPIRED_EVENT } = await import('./session')\n+    const { AppError } = await import('../http/appError')\n+    const expired = vi.fn()\n+    window.addEventListener(AUTH_EXPIRED_EVENT, expired)\n+\n+    let rejectFirst!: (error: unknown) => void\n+    const firstAttempt = new Promise<never>((_, reject) => {\n+      rejectFirst = reject\n+    })\n+\n+    mocks.request\n+      .mockReturnValueOnce(firstAttempt)\n+      .mockResolvedValueOnce({\n+        code: 0,\n+        data: { userInfo: { id: 'user-1' }, accessToken: 'access-1' },\n+      })\n+\n+    const first = bootstrapAuthSession()\n+    const concurrent = bootstrapAuthSession()\n+\n+    expect(concurrent).toBe(first)\n+\n+    rejectFirst(\n+      new AppError({ kind: 'business', message: 'unauthorized', status: 401, code: '401' }),\n+    )\n+\n+    await expect(first).resolves.toEqual({\n+      userInfo: { id: 'user-1' },\n+      accessToken: 'access-1',\n+    })\n+    await expect(concurrent).resolves.toEqual({\n+      userInfo: { id: 'user-1' },\n+      accessToken: 'access-1',\n+    })\n+    expect(mocks.request).toHaveBeenCalledTimes(2)\n+    expect(expired).not.toHaveBeenCalled()\n+\n+    window.removeEventListener(AUTH_EXPIRED_EVENT, expired)\n+  })\n+\n+  it('does not relabel a non-401 bootstrap failure as expired', async () => {\n+    const { bootstrapAuthSession, AUTH_EXPIRED_EVENT } = await import('./session')\n+    const { AppError } = await import('../http/appError')\n+    const expired = vi.fn()\n+    window.addEventListener(AUTH_EXPIRED_EVENT, expired)\n+\n+    mocks.request.mockRejectedValueOnce(\n+      new AppError({ kind: 'network', message: 'network failed' }),\n+    )\n+\n+    await expect(bootstrapAuthSession()).rejects.toMatchObject({ kind: 'network' })\n+    expect(mocks.request).toHaveBeenCalledTimes(1)\n+    expect(expired).not.toHaveBeenCalled()\n+\n+    window.removeEventListener(AUTH_EXPIRED_EVENT, expired)\n+  })\n+})\ndiff --git a/src/services/auth/session.ts b/src/services/auth/session.ts\nindex 2bf7570..db73016 100644\n--- a/src/services/auth/session.ts\n+++ b/src/services/auth/session.ts\n@@ -1,6 +1,6 @@\n import { runtimePolicy } from '../../app/config/runtime'\n import { getLoginToken } from '../nativeBridge'\n-import { createContractError } from '../http/appError'\n+import { createContractError, isAppError } from '../http/appError'\n import { STORAGE_KEYS, storage } from '../../storage'\n import {\n   createHttpClient,\n@@ -10,6 +10,7 @@ import {\n } from '../http/httpClient'\n \n const AUTH_FAILURE_EVENT = 'dr-card-ui:auth-failure'\n+const AUTH_EXPIRED_EVENT = 'dr-card-ui:auth-expired'\n const LOGIN_PATH = '/api/oauth/login'\n \n export interface AuthSession {\n@@ -33,6 +34,7 @@ const loginClient = createHttpClient({\n })\n \n let inFlightLogin: Promise<AuthSession> | undefined\n+let initialBootstrap: Promise<AuthSession> | undefined\n let authFlowEnabled = false\n let initializedForDocument = false\n let volatileSession: AuthSession | undefined\n@@ -123,11 +125,39 @@ export function authenticate(force = false): Promise<AuthSession> {\n   return inFlightLogin\n }\n \n+function isUnauthorizedAuthError(error: unknown) {\n+  return isAppError(error) && error.status === 401\n+}\n+\n+function dispatchAuthEvent(eventName: string) {\n+  if (typeof window !== 'undefined') window.dispatchEvent(new Event(eventName))\n+}\n+\n+async function authenticateInitialDocument(): Promise<AuthSession> {\n+  try {\n+    return await authenticate(true)\n+  } catch (error) {\n+    if (!isUnauthorizedAuthError(error)) throw error\n+  }\n+\n+  try {\n+    return await authenticate(true)\n+  } catch (error) {\n+    clearAuthSession()\n+    if (isUnauthorizedAuthError(error)) dispatchAuthEvent(AUTH_EXPIRED_EVENT)\n+    throw error\n+  }\n+}\n+\n export function bootstrapAuthSession(): Promise<AuthSession> {\n   if (!initializedForDocument) {\n     initializedForDocument = true\n-    return authenticate(true)\n+    initialBootstrap = authenticateInitialDocument().finally(() => {\n+      initialBootstrap = undefined\n+    })\n+    return initialBootstrap\n   }\n+  if (initialBootstrap) return initialBootstrap\n   return authenticate()\n }\n \n@@ -136,7 +166,9 @@ async function reauthenticateAfterUnauthorized() {\n     await authenticate(true)\n   } catch (error) {\n     clearAuthSession()\n-    if (typeof window !== 'undefined') window.dispatchEvent(new Event(AUTH_FAILURE_EVENT))\n+    dispatchAuthEvent(\n+      isUnauthorizedAuthError(error) ? AUTH_EXPIRED_EVENT : AUTH_FAILURE_EVENT,\n+    )\n     throw error\n   }\n }\n@@ -156,10 +188,10 @@ setHttpUnauthorizedHandler(() => {\n   if (authFlowEnabled) return reauthenticateAfterUnauthorized()\n })\n setHttpAuthFailureHandler(() => {\n-  if (authFlowEnabled && typeof window !== 'undefined') {\n+  if (authFlowEnabled) {\n     clearAuthSession()\n-    window.dispatchEvent(new Event(AUTH_FAILURE_EVENT))\n+    dispatchAuthEvent(AUTH_EXPIRED_EVENT)\n   }\n })\n \n-export { AUTH_FAILURE_EVENT }\n+export { AUTH_EXPIRED_EVENT, AUTH_FAILURE_EVENT }\ndiff --git a/src/services/exchange.test.ts b/src/services/exchange.test.ts\nindex 1012c8b..d559d90 100644\n--- a/src/services/exchange.test.ts\n+++ b/src/services/exchange.test.ts\n@@ -7,27 +7,56 @@ vi.mock('./http', () => ({\n   httpClient: { request: mocks.request },\n   createBusinessError: (message: string) => new Error(message),\n }))\n-import {\n-  redeemExchangeProduct, H014_EXCHANGE_REDEEM_PATH,\n-} from './exchange'\n+\n+import { EXCHANGE_REDEEM_PATH, redeemExchangeProduct } from './exchange'\n \n afterEach(() => {\n   mocks.request.mockReset()\n })\n \n-describe('H014 isolated transport contract pending #74', () => {\n-  it('parses a simulated succes",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-ui/dr-card-ui/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/dangjingtao/dr-card-ui/commit/cbc0ac9ac7ea3e49c0514ff017614c71203b45b6",
            "commitHash": "cbc0ac9ac7ea3e49c0514ff017614c71203b45b6",
            "prHref": "https://github.com/dangjingtao/dr-card-ui/pull/202",
            "prTitle": "chore(release): promote dev to test, preserve OSS upload fixes",
            "prBaseHash": "b4a96d6cd4b8a547c8d7f4adac945b319e4a26f3",
            "buildHref": "https://github.com/dangjingtao/dr-card-ui/actions/runs/38216972724"
          },
          "gitCommit": {
            "shortHash": "cbc0ac9",
            "hash": "cbc0ac9ac7ea3e49c0514ff017614c71203b45b6",
            "subject": "Merge 28bdb2e1d5ce14425954362c975c34903ebc983c into b4a96d6cd4b8a547c8d7f4adac945b319e4a26f3",
            "body": "Merge 28bdb2e1d5ce14425954362c975c34903ebc983c into b4a96d6cd4b8a547c8d7f4adac945b319e4a26f3\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1791789485000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1791789485000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/docs/engineering/auth-boundary.md b/docs/engineering/auth-boundary.md\nindex 6764bc0..5b2afe9 100644\n--- a/docs/engineering/auth-boundary.md\n+++ b/docs/engineering/auth-boundary.md\n@@ -14,7 +14,7 @@\n - H5 统一通过 `src/services/nativeBridge.ts` 获取并验证结果，业务页面不得直接访问宿主对象。\n - OAuth 开发登录：`POST {VITE_API_BASE_URL}/api/oauth/login`，请求体 `{ salt, token }`；成功响应兼容旧的 `{ userInfo, accessToken }`，以及当前后台返回的 `{ code: 0, data: { userInfo, accessToken, ... } }`；后续同源 API 请求使用 `Authorization: Bearer <accessToken>`。\n - 正式 H5 根首页在 `VITE_DATA_MODE=api` 时先完成登录；仅 `accessToken` 暂存于 `sessionStorage`，`userInfo` 保留在当前文档内存。Mock 模式保留独立浏览器预览，不模拟真实登录。\n-- HTTP 401 或响应顶层 `code: 401` 触发一次单飞重登；原请求最多重放一次。重登失败进入 `/error?reason=auth`，不循环。\n+- HTTP 401 或响应顶层 `code: 401` 触发一次单飞重登；原请求最多重放一次。首次打开文档时，如果初始 `/api/oauth/login` 自身返回 401，同样只允许再登录一次。若这次重登/重试登录再次返回 401，或重登成功后重放原请求仍返回 401，则判定当前用户会话已失效：H5 弹出「用户信息过期」Toast，并调用 Native `closeWebView()` 关闭当前 WebView，不再继续重登或停留在当前业务页。重登过程中的非 401 失败仍进入 `/error?reason=auth`，不循环。\n - 2026-09-28 后端统一响应协议：成功码 `code: 0`（兼容原生；`GET /api/user/detail` 此前 2026-09-24 实测的 `code: 200` 已由后端统一改回 0）；失败为非 0 code + `message` 字段（无 `msg` / `status`）。正式接口共用 `parseApiEnvelope` 解析。\n \n 旧 iOS `webkit.messageHandlers.getAuthorizationInfo.postMessage({}) → window.onToken(token)` 仅作为历史联调 / Bridge Lab Raw Probe 证据保留，不再是 production auth contract。\ndiff --git a/docs/engineering/card-code-redeem-contract.md b/docs/engineering/card-code-redeem-contract.md\nnew file mode 100644\nindex 0000000..4950923\n--- /dev/null\n+++ b/docs/engineering/card-code-redeem-contract.md\n@@ -0,0 +1,67 @@\n+# 兑换卡券｜H5 ↔ 后台正式协议对照\n+\n+> 来源：2026-10-12 后台同事交接的「兑换码核兑接口」，由用户在本线程提供。此文档记录**已确认的对接合同**，不宣称已部署、已完成数据库联调或真机验收。H5 修改仅在 GitHub `dr-card-ui` 进行，本地 API 项目只读。\n+\n+## 1. 用户兑换码核兑\n+\n+```http\n+POST /api/cardredemptioncodes/redeem\n+Authorization: Bearer <accessToken>\n+Content-Type: application/json\n+```\n+\n+```json\n+{ \"code\": \"ABC123456789\" }\n+```\n+\n+- 仅提交 `code`；`user_id`、`card_id`、`status` 都由服务端确定。\n+- **不少于 10 位**；不额外限制输入字符类型、大小写或前端最大长度，也不自动删除空格、转换大写。\n+- 后端当前数据库表中 `code` 字段长度为 64；这是存储约束，不是 H5 要主动增加的校验规则。\n+- 服务端核对状态 10 未兑换、20 已兑换、30 已过期、40 已禁用，验证有效期和卡模板，采用事务核兑发券。\n+- 客户端用标准 `code === 0` 判断业务成功，HTTP 200 + `code:500` 是失败，展示 `message`；HTTP 401 交给现有登录态处理。\n+\n+### 成功响应\n+\n+```json\n+{\n+  \"code\": 0,\n+  \"msg\": \"success\",\n+  \"status\": \"succ\",\n+  \"data\": {\n+    \"user_card_id\": 21,\n+    \"card_id\": 4,\n+    \"card_name\": \"洗护体验卡\",\n+    \"card_sn\": \"RC00000000000014\"\n+  }\n+}\n+```\n+\n+H5 校验四个回执字段后显示成功页；任一关键字段缺失视为协议错误，不把未确认的发券结果当作成功。\n+\n+### 失败响应示例\n+\n+```json\n+{ \"code\": 500, \"message\": \"该兑换码已被使用\", \"data\": [] }\n+```\n+\n+包括格式不足、兑换码无效／已使用／已过期／已禁用／尚未生效、模板不可用等；错误文本由后台提供。\n+\n+## 2. 兑换成功后的卡包展示\n+\n+**必须同时区分两种券来源，不混淆券 ID：**\n+\n+| 来源 | 读取接口 | 用途 |\n+|---|---|---|\n+| 本地用户卡包 | `GET /api/usercards/index` | 兑换码核兑券，`source=60`，`source_id` 对应兑换码 ID |\n+| 第三方优惠卡 | `POST /api/coupons/MyCoupons` | 既有第三方优惠卡列表；不会包含本地兑换码券 |\n+\n+- 本地列表读取当前登录用户的分页数据，**完整分页后**筛选 `source===60`，防止在单页过滤遗漏后续兑换券。\n+- 本地卡券依据已返回的 `status=10/20/30` 对应「可用／已使用／已过期」；不将第三方记录的字段硬套成本地券。\n+- 后端没有在此协议里提供本地券使用／转赠业务入口，因此 H5 **不把本地 ID 当作第三方 MyCoupons 的核销或转赠 ID**，相应按钮不可用。\n+- 本地卡包与第三方列表各自加载与报错，不能因为一个来源失败就伪造另一个来源的数据。\n+\n+## 3. 后端归属与验收边界\n+\n+- 后端管理接口 `/admin/cardredemptioncodes/*` 不由 H5 调用；用户端旧 CRUD `/api/cardredemptioncodes/{index,add,update,delete,detail,select}` 已关闭。\n+- H5 只调用 `redeem` 与已确认的本地卡包读取接口，不修改后台实现。\n+- 真实 API 的环境部署、DB 联通、授权、发券到账和 App WebView 仍需在 test 环境验证；Mock 成功不是业务验收。\ndiff --git a/docs/workbench/decisions/README.md b/docs/workbench/decisions/README.md\nindex 654b4cd..1701b8c 100644\n--- a/docs/workbench/decisions/README.md\n+++ b/docs/workbench/decisions/README.md\n@@ -23,7 +23,7 @@\n | D-014 | 卡包采用 reference 卡包标准页的 可用/已使用/已过期 三态 Tab，不引入原型文档写的第 4 个「全部」Tab 与右上角「使用记录」入口（用户已关闭 B-016，定案「卡包以 `reference/卡包.html` 为准，只保留三个 Tab」） | PASS | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #54 #62 #63 #64 |\n | D-015 | `#63 卡包-已使用` 保持标准页设计的空态，不新增 `status: 'used'` 卡券夹具填充画面；核销成功跳 `/card?state=used` 落到空态属预期 | PASS | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #63 #67 |\n | D-016 | 分享为 单选＋搜索＋「下一步」→ 成功页 的前端闭环；对象列表按原型做「搭子列表」夹具，不做对方接受、次数限制、时效限制和持久化，分享后原卡包状态不变（用户已关闭 B-015 并定案该范围） | PASS | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #65 #66 |\n-| D-017 | 兑换码规则集中在夹具 `REDEEM_CODE_RULE`（含 `pending`/`blocker`/`source`），页面不硬编码位数；提交仅在空输入时禁用，不用位数拦提交 | PASS | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #68 #69 |\n+| D-017 | **历史原型决策；已被 D-083 覆盖**：兑换码规则曾含 `pending`/`blocker`/`source`、仅空输入禁用；正式后台合同按至少 10 位执行 | Superseded by D-083 | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #68 #69 |\n | D-018 | 核销链路收口为 确认 → 结果反馈 Toast → 回 `/card?state=used`，并补 `?state=repeat` 重复核销态 | PASS | [T009-wallet-redemption.md](T009-wallet-redemption.md) | T009 #67 |\n | D-019 | #65/#66 以用户当轮提供的原型为准还原：#65 为「搜索 + 搭子列表单选 + 底部主按钮」，#66 为「成功 hero + 副文案「已成功分享给好友，邀请他也来一起玩吧」+ 分享的商品卡 + 查看我的卡包/返回 双按钮」；`/card/share` 路由标题统一为「分享」，卡包入口按钮保留原型的「转赠」 | PASS | [分享.html](../../../reference/分享.html)、[分享成功.html](../../../reference/分享成功.html)、[CardShare.tsx](../../../src/pages/CardShare.tsx) | T009 #65 #66 |\n | D-020 | 状态验收统一由 `DebugPanel` 承载：胶囊只读 `routes.ts` 已登记的 `states`/`overlays`，切换仅改 `?state=`/`?overlay=`（`replace`），未登记状态的路由渲染 null；面板为验收工具，不属业务 UI，`z-[60]` 高于弹层/Toast（z-50）与 Tabbar（z-40） | PASS | [DebugPanel.tsx](../../../src/components/mobile/DebugPanel.tsx)、[routes.ts](../../../src/app/router/routes.ts) | T009 #62 #63 #64 #65 #66 #67 #68 #69 |\n@@ -90,6 +90,7 @@\n | D-080 | 金刚区删除（D-072）带来一处连带脚本失效：`capture-t005.mjs` 原先点击首页「诗得丽品牌专栏」卡片进入 `/dearseed?overlay=reminder`，而该卡片正是金刚区的一部分（已由 `git show HEAD:src/pages/Home.tsx` 核实旧版存在），故这不是弹窗遮挡、加 `?newcomer=off` 无效。改为直连 `/dearseed?overlay=reminder`，该脚本只继续验证专栏侧提示层与关闭出口；首页与专栏的新入口关系由 `capture-t021.mjs` 覆盖。**T005 历史验收结论不回改** | PASS | [capture-t005.mjs](../../../scripts/capture-t005.mjs)、[T005-entry-home-onboarding.md](T005-entry-home-onboarding.md) | T021 T005 #2 |\n | D-081 | 首页「公益板块」和「极地种子品牌故事」均为可点击入口，分别进入 `/cause` 与 `/brand-culture`；两个详情页使用共享空富文本承载组件，当前不请求接口、不填充假内容，未来由统一数据层提供富文本。两个路由沿用 App 壳层标题栏，返回各自来源页；`/cause` 为新增空节点，`/brand-culture` 保留原路径和来源入口 | Implemented | [Home.tsx](../../../src/pages/Home.tsx)、[RichTextPlaceholder.tsx](../../../src/pages/RichTextPlaceholder.tsx)、[routes.ts](../../../src/app/router/routes.ts) | T021 |\n \n+| D-083 | **2026-10-12 用户确认后台兑换码合同**：兑换码至少 10 位，不自动去空格/大写、不限定字符类型或 H5 最大长度；输入不足 10 位禁用提交；真实 API `/api/cardredemptioncodes/redeem`；成功返回卡包券凭证；本地卡包 source=60，第三方 MyCoupons 独立读取。覆盖历史 D-004/D-008/D-017 中与兑换码格式及提交门槛相冲突的规则，原型历史保留不篡改 | Implementation in PR #195 / API validation pending | [card-code-redeem-contract.md](../../engineering/card-code-redeem-contract.md) | T009 #68 #69 |\n | D-082 | **2026-09-29 Maintainer 定案商城主入口**：`/mall` 使用 `MallWebView` 在现有 H5 壳层内 iframe 承载 `http://www.3-wins.cn/`；HTTP scheme 为当前明确输入，不自动替换。该决策覆盖 D-031 对 `/mall` 主入口的占位口径；商品详情/购物车边界继续保留。真实 App WebView 的 cleartext/mixed-content、iframe policy 与鉴权仍需联调证据 | Implemented / validation pending | [T008-mall-exchange.md](T008-mall-exchange.md)、[MallWebView.tsx](../../../src/pages/MallWebView.tsx) | T008 #17 |\n \n ## 2. 待确认/阻塞项（施工前必须决策）\n@@ -124,7 +125,7 @@\n | 编号 | 阻塞项 | 关闭方式 | 关闭记录 |\n | --- | --- | --- | --- |\n | B-001 | 品牌文化视觉方向（深绿深色长页 + 浮动 CTA 是否保留） | **用户定案（2026-08-24）**：#16 只铺摹客原型长图、不加浮动 CTA，T13 深绿深色稿与右下浮动按钮均不继承；实现按 `D1gOOzGEq` 真值（全页 0 button、单张长图铺满 artboard），返回栏沿用 MobileLayout 统一提供（D-051） | [T005-entry-home-onboarding.md](T005-entry-home-onboarding.md) |\n-| B-009 | 兑换码长度/字符集/错误态/成功态（原型 8 位、11 位 vs 历史 12 位） | D-008/D-017：位数按 `reference/兑换卡券.html` 取 12，规则集中在夹具 `REDEEM_CODE_RULE` 且保留 `pending`，页面不硬编码 | [T009-wallet-redemption.md](T009-wallet-redemption.md) |\n+| B-009 | 兑换码长度/字符集/错误态/成功态（原型 8 位、11 位 vs 历史 12 位） | **历史** D-008/D-017 曾按 reference 取 12；**2026-10-12 D-083 新合同覆盖**：至少 10 位，原样提交，不限制字符集/前端最大长度；此处仅保留决策演变 | [T009-wallet-redemption.md](T009-wallet-redemption.md) |\n | B-007 | H5 商城承载方式（本地高保真 / WebView / 外链占位） | 2026-08-22 的三路由边界定案 D-031 仍保留历史；**2026-09-29 Maintainer 以 D-082 覆盖 `/mall` 主入口**，改用 `MallWebView` iframe `http://www.3-wins.cn/`，商品详情/购物车仍保留边界 | [T008-mall-exchange.md](T008-mall-exchange.md) §3.1 / §7 |\n | B-008 | 洗护兑换专区主视觉与搜索/排序；T11 深绿 KV 不自动生效 | **用户定案（2026-08-22）**：兑换专区在本仓库本地实现（搜索/三维度排序/商品卡/兑换确认/余额不足/售罄/成功存入卡包）；禁止照搬历史 T11 深绿金 KV 与私有配色，只消费已注册语义 Token（D-032、D-033） | [T008-mall-exchange.md](T008-mall-exchange.md) §7 |\n | B-010 | 通知 Tab 结构：原型「全部/未读」两类 vs 历史 4 Tab | D-010：按 reference 通知标准页采用 4 Tab | [T012-notifications.md](T012-notifications.md) |\ndiff --git a/docs/workbench/decisions/T009-wallet-redemption.md b/docs/workbench/decisions/T009-wallet-redemption.md\nindex d1a3deb..9410c84 100644\n--- a/docs/workbench/decisions/T009-wallet-redemption.md\n+++ b/docs/workbench/decisions/T009-wallet-redemption.md\n@@ -1,5 +1,7 @@\n # 决策记录｜T009 卡包三态、分享与兑换码失败态\n \n+> **2026-10-12 补充（D-083）**：本页下方 D-017／B-009、自动转大写、12 位等是 2026-08 的设计历史；最新正式后台协议已由用户确认，覆盖这些旧规则。当前 H5 校验至少 10 位、保持原始码（含大小写与空格）、不施加额外最大长度/字符集限制，业务成功由 `/api/cardredemptioncodes/redeem` 的真实回执决定，本地券 source=60 与第三方 MyCoupons 分别读取。详见 [最新接口合同](../../engineering/card-code-redeem-contract.md)。历史决定保留供审计，不再用于当前施工。\n+\n > 决策日期：2026-08-22\n > 决策卡：[T009-wallet-redemption.md](../tasks/T009-wallet-redemption.md)\n > 决策类型：UI / Flow\ndiff --git a/docs/workbench/decisions/T013-customer-service.md b/docs/workbench/decisions/T013-customer-service.md\nindex 3dd65b0..5c92100 100644\n--- a/docs/workbench/decisions/T013-customer-service.md\n+++ b/docs/workbench/decisions/T013-customer-service.md\n@@ -94,3 +94,11 @@ capture 脚本报告的 20 条 warning 全部为 React Router v6→v7 future fla\n | `queueDynamics` | B-014 | 队列人数、等待时长与推进规则 |\n | `humanQueueEntry` | B-014 | #71 → #70 的前进动作（原型 §10 弹层只有「取消」，未给前进入口）；未确认故不实现 |\n | `agentReplies` | B-014 | 已接入后坐席的后续问答行为 |\n+\n+\n+## 2026-10-12｜企业微信入口兼容调整（覆盖当前可见入口，不撤销历史决策）\n+\n+- 在 `/service/chat` 隐藏 TitleBar 右侧「企微客服」按钮，不修改其它页面的标题栏动作。\n+- 底部已存在的「人工」入口复用原 `#wecom` 弹层动作，沿用原有二维码数据与取消/返回行为；在真实 API 模式下，不因 Socket.IO 尚未连接而禁用企微入口。\n+- **保留**原应用内人工客服转接 `useHumanChat()/transfer()`、Socket.IO 监听、排队/接入历史逻辑及相关独立测试；此次仅改变用户可见按钮绑定，不再由该按钮触发应用内转接。Mock 人工状态机实现同样保留。\n+- `#wecom` 深链仍有效。正式后端联调与原人工转接链路治理另行验收，本改动不声称将企微二维码当成已连接的人工坐席。\ndiff --git a/docs/workbench/evidence-matrix.md b/docs/workbench/evidence-matrix.md\nindex 88b2f79..27d0804 100644\n--- a/docs/workbench/evidence-matrix.md\n+++ b/docs/workbench/evidence-matrix.md\n@@ -182,7 +182,7 @@ T008 本轮证据（2026-08-22）：`BASE_URL=http://127.0.0.1:5173 node scripts\n | 65 | 分享 | 04 | T009 | `/card/share?coupon=c1` | Implemented | 按 `reference/分享.html` 还原：搜索框 + 「搭子列表」单选（默认选中首位）+ 底部主按钮，路由标题统一用「分享」（D-019）；B-015 已由用户定案关闭——搭子列表仅作夹具，不做对方接受/次数/时效/持久化 | 未验收 |\n | 66 | 分享成功 | 04 | T009 | `/card/share?coupon=c1&state=success` | Implemented | 按 `reference/分享成功.html` 还原：成功 hero + 原型副文案「已成功分享给好友，邀请他也来一起玩吧」+ 「分享的商品」卡 + 「查看我的卡包」/「返回」双按钮（D-019）；B-017 已由用户定案关闭——「洗发试用装 / 已发货 / 单次使用」仅展示夹具，不实现物流规则 | 未验收 |\n | 67 | 核销 | 04 | T009 | `/card/verify/confirm`、`?state=done`、`?state=repeat` | Implemented | 凭证明细 + 防误触确认 + 已核销反馈（Toast 后回 `/card?state=used`）+ 重复核销态 | 未验收 |\n-| 68 | 兑换卡券 | 04 | T009 | `/redeem`、`?state=format/invalid/used/network` | Implemented | 12 位规则按 `reference/兑换卡券.html`（D-008 关闭 B-009），规则集中在 `REDEEM_CODE_RULE` 且标 `pending`；覆盖格式错误/无效/已使用/网络异常 + 提交中 | 未验收 |\n+| 68 | 兑换卡券 | 04 | T009 | `/redeem`、`?state=format/invalid/used/network` | Implemented / real API pending verification | 2026-10-12 D-083：后台合同至少 10 位，原样提交，不限制字符集与前端上限；API `/api/cardredemptioncodes/redeem` 已接入 H5；旧 reference 12 位规则已被覆盖；覆盖格式错误/无效/已使用/网络异常 + 提交中 | 未验收 |\n | 69 | 兑换成功 | 04 | T009 | `/redeem?state=success` | Implemented | 成功入包 + 兑换码回显 + 「查看我的卡包」/「再兑换一张」 | 未验收 |\n \n T009 状态验收统一走页面右下角的「调试」胶囊（[DebugPanel.tsx](../../src/components/mobile/DebugPanel.tsx)，D-020）：胶囊只读 `routes.ts` 已登记的 `states`/`overlays`，点选后改写 `?state=` / `?overlay=`，上表所有 URL 都可以不手改地址栏直达。面板展开态证据：[t009-debug-panel-wallet.png](./evidence/screenshots/t009-debug-panel-wallet.png)、[t009-debug-panel-wallet-expired.png](./evidence/screenshots/t009-debug-panel-wallet-expired.png)、[t009-debug-panel-wallet-overlay.png](./evidence/screenshots/t009-debug-panel-wallet-overlay.png)、[t009-debug-panel-redeem.png](./evidence/screenshots/t009-debug-panel-redeem.png)、[t009-debug-panel-redeem-invalid.png](./evidence/screenshots/t009-debug-panel-redeem-invalid.png)。\ndiff --git a/docs/workbench/route-table.md b/docs/workbench/route-table.md\nindex a554004..12e8367 100644\n--- a/docs/workbench/route-table.md\n+++ b/docs/workbench/route-table.md\n@@ -73,7 +73,7 @@\n | `/card/share` | #65、#66 | 卡包-转赠 | 卡包 | `?coupon=`；`?state=success` |\n | `/card/verify` | #67 | 卡包-使用 | 卡包 | — |\n | `/card/verify/confirm` | #67 | 扫码页 / 使用弹层 | 卡包 | `?state=done/repeat`；确认后回 `/card?state=used` |\n-| `/redeem` | #68、#69 | 我的-卡券兑换 | 我的 / 卡包 | `?state=success/format/invalid/used/network`（12 位规则按 reference 标准页确认，B-009 已关闭） |\n+| `/redeem` | #68、#69 | 我的-卡券兑换 | 我的 / 卡包 | `?state=success/format/invalid/used/network`（2026-10-12 D-083 合同：至少 10 位，原值核兑；原型 12 位为历史） |\n \n ### T010 地址与订单\n \n@@ -170,4 +170,4 @@ T013: 57,58,71,70                                = 4\n - T001 当前五项 Tab 的 375 × 812 种籽页截图命名：`docs/workbench/evidence/screenshots/t001-seed-{home,points,scan,mall,profile}.png`（T023 起「服务」Tab 为 `/mall`）。`t001-seed-checkin.png`、`t001-seed-membership.png` 与 `t004-*.png` 保留为历史证据。\n - 运行断言：`BASE_URL=http://127.0.0.1:<port> node scripts/verify-t004.mjs`（11 项路由用例 + Tab 高亮 + `/draw-success`、`/membership` 两项重定向，全部 PASS）。\n - 工程检查：`npm run typecheck`、`npm run build` 通过。\n-- 已知差异与未决：`/draw-success` 旧路由移除；三档签运规则仍待 T006；兑换码位数仍待 T009。\n+- 已知差异与未决：`/draw-success` 旧路由移除；三档签运规则仍待 T006；兑换码位数已由 2026-10-12 后台合同��为至少 10 位（D-083）。\ndiff --git a/scripts/capture-t009.mjs b/scripts/capture-t009.mjs\nindex d69e0cb..cbb7eb1 100644\n--- a/scripts/capture-t009.mjs\n+++ b/scripts/capture-t009.mjs\n@@ -181,8 +181,8 @@ assert(submitDisabled, '空输入时提交按钮未禁用')\n await main().getByRole('textbox', { name: '兑换码' }).fill('drcard000888')\n await page.waitForTimeout(150)\n const typed = await main().getByRole('textbox', { name: '兑换码' }).inputValue()\n-console.log(`  #68 输入自动转大写: ${typed}`)\n-assert(typed === 'DRCARD000888', `输入未转大写: ${typed}`)\n+console.log(`  #68 原样保留大小写: ${typed}`)\n+assert(typed === 'drcard000888', `输入不应自动转大写: ${typed}`)\n await shot('68-redeem-typing')\n \n await main().getByRole('button', { name: '确认兑换' }).click()\ndiff --git a/scripts/capture-t013.mjs b/scripts/capture-t013.mjs\nindex 57f8850..1bea241 100644\n--- a/scripts/capture-t013.mjs\n+++ b/scripts/capture-t013.mjs\n@@ -39,7 +39,7 @@ const assertText = async (text, label) => {\n // —— 节点 #58 欢迎态：只有小诗欢迎语，不出现未确认的热门问题 ——\n await go('/service/chat')\n await assertText('你好，我是诗得丽 AI 客服小诗', '#58 欢迎态')\n-await assertText('企微客服', '#58 右上角入口')\n+if (await page.locator('[data-chat-wecom-entry]').count()) problems.push('[assert] 已隐藏的右上企微入口仍存在')\n const welcomeCount = await page.locator('[data-chat-message]').count()\n console.log(`  #58 欢迎态消息数: ${welcomeCount}`)\n if (welcomeCount !== 1) problems.push(`[assert] 欢迎态应只有 1 条消息，实际 ${welcomeCount}`)\n@@ -84,9 +84,9 @@ console.log(`  含失败关键词的发送失败气泡: ${keywordFailed}（预\n if (keywordFailed !== 1) problems.push(`[assert] 含「断网」的输入应稳定失败，实际失败 ${keywordFailed} 条`)\n await shot('58-keyword-failed')\n \n-// —— 节点 #71 弹层：右上角「企微客服」入口 ——\n+// —— 节点 #71 弹层：仅通过底部「人工」入口打开，右上企微入口隐藏 ——\n await go('/service/chat')\n-await page.locator('[data-chat-wecom-entry]').click()\n+await page.locator('[data-chat-human-entry]').click()\n await page.waitForTimeout(250)\n await assertText('请添加我们的企业微信福利官获取人工客服服务', '#71 弹层标题')\n await assertText('取消', '#71 取消')\n@@ -101,7 +101,7 @@ if (sheetGone !== 0) problems.push('[assert] 点击取消后弹层未关闭')\n console.log(`  取消后 URL: ${new URL(page.url()).search || '(无 query)'}`)\n await shot('71-after-cancel')\n \n-// —— #71 由页内「人工客服」入口打开 ——\n+// —— #71 底部「人工」入口再次打开同一原企微弹层（取消后可重试）——\n await page.locator('[data-chat-human-entry]').click()\n await page.waitForTimeout(250)\n await assertText('请添加我们的企业微信福利官获取人工客服服务', '#71 页内入口')\ndiff --git a/scripts/verify-h014-mock-scenarios.mjs b/scripts/verify-h014-mock-scenarios.mjs\nindex 60ed662..e0227e1 100644\n--- a/scripts/verify-h014-mock-scenarios.mjs\n+++ b/scripts/verify-h014-mock-scenarios.mjs\n@@ -77,7 +77,7 @@ try {\n   assert.equal((await searchBuddyByPhone(H014_BUDDY_PHONE_SCENARIOS.incoming)).outcome, 'already-buddies')\n   assert.equal(await acceptBuddyPhoneInvitation(incoming.invitationId), 'already-buddies')\n \n-  await redeemExchangeProduct('e1')\n+  assert.deepEqual(await redeemExchangeProduct(1), { couponId: 1, points: 1080 })\n \n   const buddyPageSource = fs.readFileSync('src/pages/BuddyPhoneInvite.tsx', 'utf8')\n   assert.doesNotMatch(buddyPageSource, /setTimeout|resolveBuddyPhoneOutcome|markPhoneInvited/)\n@@ -94,7 +94,7 @@ try {\n   assert.doesNotMatch(exchangeServiceSource, /runtimePolicy|VITE_DATA_MODE|mockScenario/)\n \n   console.log(\n-    'H014 PASS: BuddyPhoneInvite and Exchange use service → HTTP → MSW; buddy success/empty/business error/4xx/5xx/slow/network failure and invite state progression are deterministic.',\n+    'H014 PASS: BuddyPhoneInvite keeps deterministic H014 scenarios; Exchange uses its production /api/coupons/exchange contract through service → HTTP → MSW.',\n   )\n } finally {\n   mockServer?.close()\ndiff --git a/scripts/verify-reference-pages.mjs b/scripts/verify-reference-pages.mjs\nindex f89f492..2f436dd 100644\n--- a/scripts/verify-reference-pages.mjs\n+++ b/scripts/verify-reference-pages.mjs\n@@ -9,13 +9,13 @@ const cases = [\n   ['ref-confirm', '/card/verify/confirm', ['即将核销此券', '泡泡洗护 · 朝阳大悦城店', '确认核销']],\n   ['ref-password', '/card/verify/password', ['请输入消费密码', '确认核销']],\n   ['ref-levels', '/membership/levels', ['会员等级参考', 'LV.4', '会员卡面']],\n-  ['ref-redeem', '/redeem', ['兑换卡券', '12 位字母与数字组合', '确认兑换']],\n+  ['ref-redeem', '/redeem', ['兑换卡券', '兑换码不少于 10 位', '确认兑换']],\n   ['ref-points', '/points', ['泡泡值余额', '1,280', '泡泡任务', '泡泡值兑换']],\n   ['ref-points-detail', '/points/detail', ['泡泡值明细', '全部', '收入', '消耗']],\n   ['ref-settings', '/settings', ['资料设置', '消费密码', '确认修改']],\n   ['ref-onboarding', '/onboarding', ['完善信息', '消费密码', '确认信息']],\n   ['ref-officer', '/service/welfare-officer', ['品牌福利官', '吴哥', '人工客服', '活动咨询', '福利抽奖']],\n-  ['ref-chat', '/service/chat', ['智能客服', 'AI 客服 · 小诗', '你好，我是诗得丽 AI 客服小诗', '企微客服', '人工']],\n+  ['ref-chat', '/service/chat', ['智能客服', 'AI 客服 · 小诗', '你好，我是诗得丽 AI 客服小诗', '人工']],\n   ['ref-chat-conversation', '/service/chat?state=conversation', ['我的洗发水体验券怎么用？', '到店出示二维码由门店扫码核销']],\n   ['ref-chat-failed', '/service/chat?state=failed', ['门店周末营业到几点？', '发送失败，请检查网络后重试', '重试']],\n   ['ref-chat-sheet', '/service/chat?overlay=request-human', ['请添加我们的企业微信福利官获取人工客服服务', '长按或扫描识别二维码', '取消']],\ndiff --git a/src/app/auth/HomeAuthGate.tsx b/src/app/auth/HomeAuthGate.tsx\nindex ae7015b..2bb951d 100644\n--- a/src/app/auth/HomeAuthGate.tsx\n+++ b/src/app/auth/HomeAuthGate.tsx\n@@ -2,6 +2,7 @@ import { useEffect, useState, type ReactNode } from 'react'\n import { useNavigate } from 'react-router-dom'\n import { runtimePolicy } from '../config/runtime'\n import { bootstrapAuthSession, setAuthFlowEnabled } from '../../services/auth/session'\n+import { isAppError } from '../../services/http/appError'\n \n export default function HomeAuthGate({ children }: { children: ReactNode }) {\n   const navigate = useNavigate()\n@@ -18,8 +19,10 @@ export default function HomeAuthGate({ children }: { children: ReactNode }) {\n         setAuthFlowEnabled(true)\n         setReady(true)\n       },\n-      () => {\n-        if (active) navigate('/error?reason=auth', { replace: true })\n+      (error) => {\n+        if (!active) return\n+        if (isAppError(error) && error.status === 401) return\n+        navigate('/error?reason=auth', { replace: true })\n       },\n     )\n \ndiff --git a/src/app/fixtures/cards.ts b/src/app/fixtures/cards.ts\nindex e7d8c1b..09e55e8 100644\n--- a/src/app/fixtures/cards.ts\n+++ b/src/app/fixtures/cards.ts\n@@ -126,31 +126,15 @@ export const VERIFY_FEEDBACK = {\n   repeat: { title: '该券已核销', desc: '这张券已经完成核销，无法重复使用' },\n } as const\n \n-/**\n- * 兑换码规则配置（T009 #68）\n- * ⚠️ 未定稿：摹客原型出现 8 位与 11 位，reference 标准页与历史稿为 12 位（阻塞项 B-009）。\n- *    这里把「位数 / 字符集 / 提示文案」收成唯一配置源，页面只读不写，决策落地时只改这一处。\n- *    当前取值来自 reference/兑换卡券.html（maxlength=12、/^[A-Za-z0-9]{12}$/），仅作为夹具呈现值，不等于定稿规则。\n- */\n+/** 兑换码唯一前端校验：至少 10 个字符，不限制字符类型或最大长度。 */\n export interface RedeemCodeRule {\n-  length: number\n-  pattern: RegExp\n+  minLength: number\n   hint: string\n-  /** 规则是否仍未定稿；为 true 时页面不得表现为最终校验规则 */\n-  pending: boolean\n-  /** 关联阻塞项编号，便于反查决策记录 */\n-  blocker: string\n-  /** 当前取值来源 */\n-  source: string\n }\n \n export const REDEEM_CODE_RULE: RedeemCodeRule = {\n-  length: 12,\n-  pattern: /^[A-Za-z0-9]{12}$/,\n-  hint: '兑换码为 12 位字母与数字组合，不区分大小写',\n-  pending: true,\n-  blocker: 'B-009',\n-  source: 'reference/兑换卡券.html',\n+  minLength: 10,\n+  hint: '兑换码不少于 10 位',\n }\n \n /** 兑换结果分支（#68 失败态 / #69 成功态） */\n@@ -165,7 +149,7 @@ export interface RedeemFeedbackFixture {\n /** 反馈文案：成功/格式错误照抄 reference 标准页；其余分支为服务端失败态的中性提示，不含未确认的业务规则 */\n export const REDEEM_FEEDBACK: Record<RedeemOutcome, RedeemFeedbackFixture> = {\n   success: { outcome: 'success', ok: true, text: '兑换成功，已存入卡包' },\n-  format: { outcome: 'format', ok: false, text: '兑换码格式错误' },\n+  format: { outcome: 'format', ok: false, text: '兑换码不能少于 10 位' },\n   invalid: { outcome: 'invalid', ok: false, text: '兑换码无效，请核对后重试' },\n   used: { outcome: 'used', ok: false, text: '该兑换码已被使用' },\n   network: { outcome: 'network', ok: false, text: '网络异常，请稍后重试' },\n@@ -173,9 +157,11 @@ export const REDEEM_FEEDBACK: Record<RedeemOutcome, RedeemFeedbackFixture> = {\n \n /**\n  * 确定性兑换码 → 结果映射（禁止随机）\n- * 格式正确但需要落在不同服务端分支的验收码在此登记；未登记且格式正确的码统一走成功分支。\n+ * 仅登记演示专用码：真实 API 绝不调用这个函数。\n+ * 未登记的任意字符串按“无效码”演示，不用默认成功掩盖异常。\n  */\n export const REDEEM_CODE_OUTCOMES: Record<string, RedeemOutcome> = {\n+  DRCARD000888: 'success',\n   DRCARD000404: 'invalid',\n   DRCARD000USE: 'used',\n   DRCARD000NET: 'network',\n@@ -195,7 +181,7 @@ export const REDEEM_SAMPLE_CODES: Record<RedeemOutcome, string> = {\n \n /** 判定兑换结果：先按配置规则校验格式，再查确定性映射 */\n export function resolveRedeemOutcome(input: string): RedeemOutcome {\n-  const code = input.replace(/\\s/g, '')\n-  if (!REDEEM_CODE_RULE.pattern.test(code)) return 'format'\n-  return REDEEM_CODE_OUTCOMES[code.toUpperCase()] ?? 'success'\n+  const code = input\n+  if (code.length < REDEEM_CODE_RULE.minLength) return 'format'\n+  return REDEEM_CODE_OUTCOMES[code.toUpperCase()] ?? 'invalid'\n }\ndiff --git a/src/app/router/routes.ts b/src/app/router/routes.ts\nindex ffbd00e..05bc2fb 100644\n--- a/src/app/router/routes.ts\n+++ b/src/app/router/routes.ts\n@@ -923,7 +923,7 @@ export const ROUTES: RouteMeta[] = [\n     label: '商城',\n     icon: Headset,\n     title: '商城',\n-    titleBar: 'plain',\n+    titleBar: 'hidden',\n     titleBarTitle: '商城',\n     nodes: [17],\n     task: 'T008',\n@@ -976,7 +976,7 @@ export const ROUTES: RouteMeta[] = [\n       { key: 'used', node: 68, label: '兑换失败-已被使用' },\n       { key: 'network', node: 68, label: '兑换失败-网络异常' },\n     ],\n-    owner: '兑换码输入（T009 施工；8/11/12 位规则 ⚠️ 待确认 B-009，规则收在 REDEEM_CODE_RULE）',\n+    owner: '兑换码输入（T009；2026-10-12 后台合同至少 10 位，原值提交，规则收在 REDEEM_CODE_RULE）',\n   },\n   {\n     path: '/card/share',\ndiff --git a/src/components/mobile/ChatMessageList.tsx b/src/components/mobile/ChatMessageList.tsx\nindex 22ab6f5..180ad80 100644\n--- a/src/components/mobile/ChatMessageList.tsx\n+++ b/src/components/mobile/ChatMessageList.tsx\n@@ -1,5 +1,6 @@\n import { AlertCircle, RotateCcw } from 'lucide-react'\n import { CHAT_BOT, CHAT_RETRY_LABEL, CHAT_SEND_FAILED_HINT, type ChatMessage } from '../../app/fixtures'\n+import { hasChatRichText, sanitizeChatRichText } from './safeRichText'\n \n export interface ChatMessageListProps {\n   messages: ChatMessage[]\n@@ -49,7 +50,15 @@ export default function ChatMessageList({\n                     : `bg-primary text-text-inverse ${message.status === 'sending' ? 'opacity-70' : ''}`\n                 }`}\n               >\n-                {message.text}\n+                {hasChatRichText(message.text) ? (\n+                  <div\n+                    data-chat-rich-text\n+                    className=\"whitespace-normal [&_blockquote]:border-l-2 [&_blockquote]:pl-2 [&_code]:font-mono [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\"\n+                    dangerouslySetInnerHTML={{ __html: sanitizeChatRichText(message.text) }}\n+                  />\n+                ) : (\n+                  message.text\n+                )}\n               </div>\n \n               {message.status === 'sending' && (\ndiff --git a/src/components/mobile/EmptyStateIllustration.test.tsx b/src/components/mobile/EmptyStateIllustration.test.tsx\nindex ec272ae..c1a9298 100644\n--- a/src/components/mobile/EmptyStateIllustration.test.tsx\n+++ b/src/components/mobile/EmptyStateIllustration.test.tsx\n@@ -26,11 +26,24 @@ describe('EmptyStateIllustration', () => {\n     expect(getByRole('img', { name: '暂无记录' })).toBeTruthy()\n   })\n \n-  it('merges the caller className without changing the fixed size', () => {\n+  it('uses the 168px standard size by default and retains the caller className', () => {\n     const { container } = render(<EmptyStateIllustration className=\"mb-4\" />)\n \n     const wrapper = container.querySelector('[data-empty-state-illustration]')\n     expect(wrapper?.className).toContain('mb-4')\n     expect(wrapper?.className).toContain('flex')\n+    const image = container.querySelector('img')\n+    expect(image?.className).toContain('h-[168px]')\n+    expect(image?.className).toContain('w-[168px]')\n+  })\n+\n+  it('supports the 128px compact size without changing decorative accessibility', () => {\n+    const { container } = render(<EmptyStateIllustration size=\"sm\" />)\n+\n+    const image = container.querySelector('img')\n+    expect(image?.className).toContain('h-32')\n+    expect(image?.className).toContain('w-32')\n+    expect(image?.getAttribute('alt')).toBe('')\n+    expect(image?.getAttribute('aria-hidden')).toBe('true')\n   })\n })\ndiff --git a/src/components/mobile/EmptyStateIllustration.tsx b/src/components/mobile/EmptyStateIllustration.tsx\nindex 860a583..83caede 100644\n--- a/src/components/mobile/EmptyStateIllustration.tsx\n+++ b/src/components/mobile/EmptyStateIllustration.tsx\n@@ -3,23 +3,24 @@ import emptyIllustration from '../../assets/brand/empty/empty-illustration.webp'\n export interface EmptyStateIllustrationProps {\n   label?: string\n   className?: string\n+  /** 整页空状态使用 md（168px）；卡片、小区域使用 sm（128px）。 */\n+  size?: 'sm' | 'md'\n+}\n+\n+const IMAGE_SIZE_CLASS: Record<NonNullable<EmptyStateIllustrationProps['size']>, string> = {\n+  sm: 'h-32 w-32',\n+  md: 'h-[168px] w-[168px]',\n }\n \n /**\n- * 品牌插画版区块空态视觉件。\n- * -------------------------------------------------------------\n- * 与 `mobile/EmptyStateIcon`（96px 柔和圆形 + Lucide 图标）同为 ui/EmptyState 的\n- * visual 插槽素材，两者只是视觉档位不同，都只负责「画什么」，不携带任何业务空态\n- * 判定、文案或主操作 —— 那些留在消费页与 ui/EmptyState 里。\n- *\n- * - 尺寸走 h-28 w-28（112px）：比 EmptyStateIcon 的 96px 略重，在纯页面背景上\n- *   不至于喧宾夺主；插画自带大量留白，有效图形比 112px 视觉更小，比 160px 更贴合\n- *   单行文案的空态节奏。需要更强纵深时由调用方用 className 覆盖尺寸。\n- * - 插画为纯装饰：调用方不传 label 时 alt 置空并 aria-hidden，不占用无障碍名称。\n+ * 品牌空状态插画，只负责视觉，不负责业务状态、文案或操作。\n+ * 默认 md：整页空状态 168px；sm：卡片及小区域 128px。\n+ * 调用方保留 className 布局能力；不修改原素材及无障碍语义。\n  */\n export default function EmptyStateIllustration({\n   label,\n   className = '',\n+  size = 'md',\n }: EmptyStateIllustrationProps) {\n   return (\n     <span data-empty-state-illustration className={`flex items-center justify-center ${className}`}>\n@@ -29,7 +30,7 @@ export default function EmptyStateIllustration({\n         aria-hidden={label ? undefined : true}\n         role={label ? 'img' : undefined}\n         draggable={false}\n-        className=\"h-28 w-28 select-none object-contain\"\n+        className={`${IMAGE_SIZE_CLASS[size]} select-none object-contain`}\n       />\n     </span>\n   )\ndiff --git a/src/components/mobile/safeRichText.test.ts b/src/components/mobile/safeRichText.test.ts\nnew file mode 100644\nindex 0000000..10a1089\n--- /dev/null\n+++ b/src/components/mobile/safeRichText.test.ts\n@@ -0,0 +1,31 @@\n+// @vitest-environment jsdom\n+\n+import { describe, expect, it } from 'vitest'\n+import { hasChatRichText, sanitizeChatRichText } from './safeRichText'\n+\n+describe('safeRichText', () => {\n+  it('keeps the supported formatting subset', () => {\n+    const html = sanitizeChatRichText('<p>第一段<br><strong>重点</strong></p><ul><li>一项</li></ul>')\n+\n+    expect(html).toContain('<p>第一段<br><strong>重点</strong></p>')\n+    expect(html).toContain('<ul><li>一项</li></ul>')\n+  })\n+\n+  it('removes active content, embeds and attributes', () => {\n+    const html = sanitizeChatRichText(\n+      '<p onclick=\"alert(1)\" style=\"color:red\">安全文字<img src=x onerror=\"alert(2)\"><script>alert(3)</script></p><iframe src=\"https://example.com\"></iframe>',\n+    )\n+\n+    expect(html).toContain('安全文字')\n+    expect(html).not.toMatch(/onclick|onerror|style=|<img|<script|<iframe/i)\n+  })\n+\n+  it('does not treat ordinary angle brackets as html', () => {\n+    expect(hasChatRichText('1 < 2，3 > 2')).toBe(false)\n+    expect(hasChatRichText('<p>富文本</p>')).toBe(true)\n+  })\n+\n+  it('drops unsupported link markup while keeping its text', () => {\n+    expect(sanitizeChatRichText('<a href=\"javascript:alert(1)\">点这里</a>')).toBe('点这里')\n+  })\n+})\ndiff --git a/src/components/mobile/safeRichText.ts b/src/components/mobile/safeRichText.ts\nnew file mode 100644\nindex 0000000..8bcc7f2\n--- /dev/null\n+++ b/src/components/mobile/safeRichText.ts\n@@ -0,0 +1,38 @@\n+import DOMPurify from 'dompurify'\n+\n+const CHAT_RICH_TEXT_TAGS = [\n+  'p',\n+  'br',\n+  'strong',\n+  'b',\n+  'em',\n+  'i',\n+  'u',\n+  's',\n+  'ul',\n+  'ol',\n+  'li',\n+  'blockquote',\n+  'code',\n+]\n+\n+const HTML_TAG_PATTERN = /<\\/?[a-z][^>]*>/i\n+\n+export function hasChatRichText(value: string): boolean {\n+  return HTML_TAG_PATTERN.test(value)\n+}\n+\n+/**\n+ * Render backend rich text as a deliberately tiny, formatting-only HTML subset.\n+ *\n+ * No attributes are allowed, so links, inline styles, event handlers, images,\n+ * iframes and other active/embed content cannot survive sanitization.\n+ */\n+export function sanitizeChatRichText(value: string): string {\n+  return DOMPurify.sanitize(value, {\n+    ALLOWED_TAGS: CHAT_RICH_TEXT_TAGS,\n+    ALLOWED_ATTR: [],\n+    ALLOW_ARIA_ATTR: false,\n+    ALLOW_DATA_ATTR: false,\n+  })\n+}\ndiff --git a/src/layouts/MobileLayout.tsx b/src/layouts/MobileLayout.tsx\nindex 2578478..0983975 100644\n--- a/src/layouts/MobileLayout.tsx\n+++ b/src/layouts/MobileLayout.tsx\n@@ -1,4 +1,4 @@\n-import { useEffect, useLayoutEffect, useRef } from 'react'\n+import { useEffect, useLayoutEffect, useRef, useState } from 'react'\n import {\n   Navigate,\n   Outlet,\n@@ -7,7 +7,7 @@ import {\n   useNavigationType,\n   useViewTransitionState,\n } from 'react-router-dom'\n-import { Bell, MessageSquare, Settings } from 'lucide-react'\n+import { Bell, Settings } from 'lucide-react'\n import BottomNav from '../components/mobile/BottomNav'\n import TitleBar from '../components/mobile/TitleBar'\n import { findRouteByPathname, isLegacyTabPath } from '../app/router/routes'\n@@ -16,7 +16,9 @@ import { navigateWithH5ViewTransition } from '../app/router/h5Transition'\n import { useNotifications } from '../app/state/notifications'\n import { protectedFixtureRedirect, useOverlay } from '../app/fixtures/useFixture'\n import H5ScrollRestoration from '../components/mobile/H5ScrollRestoration'\n-import { AUTH_FAILURE_EVENT, setAuthFlowEnabled } from '../services/auth/session'\n+import Toast from '../components/ui/Toast'\n+import { closeWebView } from '../services/nativeBridge'\n+import { AUTH_EXPIRED_EVENT, AUTH_FAILURE_EVENT, setAuthFlowEnabled } from '../services/auth/session'\n \n type H5RouteTransitionKind = 'none' | 'tab' | 'forward' | 'back'\n \n@@ -111,6 +113,8 @@ export default function MobileLayout() {\n   const scrollSourcePathname = previousShellPathname.current\n   const { unreadCount, countLoaded } = useNotifications()\n   const { open: openOverlay } = useOverlay()\n+  const [authExpired, setAuthExpired] = useState(false)\n+  const authExpiredHandledRef = useRef(false)\n   const showLegacyNav = isLegacyTabPath(location.pathname)\n   const showNav = showLegacyNav || isFormalH5TabPath(location.pathname)\n   const route = findRouteByPathname(location.pathname)\n@@ -122,9 +126,22 @@ export default function MobileLayout() {\n     if (!activeFormalH5) return\n \n     const handleAuthFailure = () => navigate('/error?reason=auth', { replace: true })\n+    const handleAuthExpired = () => {\n+      if (authExpiredHandledRef.current) return\n+      authExpiredHandledRef.current = true\n+      setAuthExpired(true)\n+      window.setTimeout(() => {\n+        void closeWebView().catch((error) => {\n+          console.error('[auth-expired-close]', error)\n+        })\n+      }, 800)\n+    }\n+\n     window.addEventListener(AUTH_FAILURE_EVENT, handleAuthFailure)\n+    window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired)\n     return () => {\n       window.removeEventListener(AUTH_FAILURE_EVENT, handleAuthFailure)\n+      window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired)\n       setAuthFlowEnabled(false)\n     }\n   }, [activeFormalH5, navigate])\n@@ -168,17 +185,6 @@ export default function MobileLayout() {\n     openOverlay('clear')\n   }\n \n-  const openWecom = () => {\n-    navigate(\n-      {\n-        pathname: location.pathname,\n-        search: location.search,\n-        hash: '#wecom',\n-      },\n-      { replace: true, state: location.state },\n-    )\n-  }\n-\n   const titleAction = isNotificationsPage\n     ? (\n       <button\n@@ -202,45 +208,37 @@ export default function MobileLayout() {\n             <Bell className=\"h-[22px] w-[22px]\" />\n           </button>\n         )\n-        : location.pathname === '/service/chat'\n-          ? (\n-            /* T013R5+R6：「企微客服」pill 回到壳层 TitleBar 右侧（与「< 智能客服」同右侧）；\n-              * H016：hash 只表示当前页弹层状态，使用 replace 避免新增历史项和触发页面级滚动/过渡。\n-              * T013R6：pill 不限制宽度、whitespace-nowrap，保证「企微客服」四个字自然横向不被换行或裁剪。 */\n-            <button\n-              type=\"button\"\n-              data-chat-wecom-entry\n-              onClick={openWecom}\n-              className=\"inline-flex min-h-9 items-center gap-1 whitespace-nowrap rounded-pill bg-surface px-3 text-[13px] font-medium text-text-brand shadow-sm active:bg-surface-selected\"\n-            >\n-              <MessageSquare className=\"h-3.5 w-3.5\" aria-hidden />\n-              企微客服\n-            </button>\n-          )\n-          : undefined\n+        : undefined\n \n   return (\n-    <div className=\"app-background flex h-dvh flex-col overflow-hidden pt-[env(safe-area-inset-top)] text-text-primary\">\n-      {/* T013R7：min-w-0 防止 TitleBar 第三列 action 被撑大撑出页面右侧 */}\n-      <div className=\"min-w-0 shrink-0\">\n-        {titleBarMode !== 'hidden' && (\n-          <TitleBar\n-            title={title}\n-            leadingAction={leadingAction}\n-            onBack={route?.backTo ? () => navigateShell(route.backTo as string) : undefined}\n-            action={titleAction}\n-            actionWide={isNotificationsPage || location.pathname === '/service/chat'}\n-            fullWidth={activeFormalH5}\n-          />\n-        )}\n-      </div>\n-      <div className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain\" data-page-scroll>\n-        {isActiveFormalH5Route(route) && (\n-          <H5ScrollRestoration previousPathname={scrollSourcePathname} />\n-        )}\n-        <H5RouteOutlet />\n+    <>\n+      {authExpired && (\n+        <div className=\"pointer-events-none fixed inset-x-0 bottom-24 z-[100] flex justify-center px-6\">\n+          <Toast message=\"用户信息过期\" />\n+        </div>\n+      )}\n+      <div className=\"app-background flex h-dvh flex-col overflow-hidden pt-[env(safe-area-inset-top)] text-text-primary\">\n+        {/* T013R7：min-w-0 防止 TitleBar 第三列 action 被撑大撑出页面右侧 */}\n+        <div className=\"min-w-0 shrink-0\">\n+          {titleBarMode !== 'hidden' && (\n+            <TitleBar\n+              title={title}\n+              leadingAction={leadingAction}\n+              onBack={route?.backTo ? () => navigateShell(route.backTo as string) : undefined}\n+              action={titleAction}\n+              actionWide={isNotificationsPage}\n+              fullWidth={activeFormalH5}\n+            />\n+          )}\n+        </div>\n+        <div className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain\" data-page-scroll>\n+          {isActiveFormalH5Route(route) && (\n+            <H5ScrollRestoration previousPathname={scrollSourcePathname} />\n+          )}\n+          <H5RouteOutlet />\n+        </div>\n+        {showNav && <BottomNav variant={showLegacyNav ? 'legacy' : 'main'} />}\n       </div>\n-      {showNav && <BottomNav variant={showLegacyNav ? 'legacy' : 'main'} />}\n-    </div>\n+    </>\n   )\n }\ndiff --git a/src/mocks/fixtures/h014Scenarios.ts b/src/mocks/fixtures/h014Scenarios.ts\nindex 99f0f90..9a15814 100644\n--- a/src/mocks/fixtures/h014Scenarios.ts\n+++ b/src/mocks/fixtures/h014Scenarios.ts\n@@ -14,4 +14,3 @@ export const H014_BUDDY_PHONE_SCENARIOS = {\n \n export const H014_SLOW_RESPONSE_MS = 1_500\n \n-export const H014_EXCHANGE_REDEEM_SUCCESS = { ok: true } as const\ndiff --git a/src/mocks/handlers/exchange.ts b/src/mocks/handlers/exchange.ts\nindex 55dc644..9046e87 100644\n--- a/src/mocks/handlers/exchange.ts\n+++ b/src/mocks/handlers/exchange.ts\n@@ -1,24 +1,35 @@\n import { http, HttpResponse } from 'msw'\n \n-import { H014_EXCHANGE_REDEEM_PATH } from '../../services/exchange'\n-import { H014_EXCHANGE_REDEEM_SUCCESS } from '../fixtures/h014Scenarios'\n+import { EXCHANGE_REDEEM_PATH } from '../../services/exchange'\n \n-async function readProductId(request: Request) {\n+async function readCouponId(request: Request) {\n   const body: unknown = await request.json().catch(() => null)\n-  if (!body || typeof body !== 'object') return ''\n-  const productId = (body as { productId?: unknown }).productId\n-  return typeof productId === 'string' ? productId.trim() : ''\n+  if (!body || typeof body !== 'object') return null\n+  const id = (body as { id?: unknown }).id\n+  return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null\n }\n \n export const exchangeHandlers = [\n-  http.post(`*${H014_EXCHANGE_REDEEM_PATH}`, async ({ request }) => {\n-    const productId = await readProductId(request)\n-    if (!productId) {\n-      return HttpResponse.json({ message: 'productId is required' }, { status: 400 })\n+  http.post(`*${EXCHANGE_REDEEM_PATH}`, async ({ request }) => {\n+    const couponId = await readCouponId(request)\n+    if (couponId === null) {\n+      return HttpResponse.json({\n+        code: 500,\n+        message: '\"优惠券ID\" is required',\n+        data: [],\n+      })\n     }\n \n-    // B-026 settlement rules are still unconfirmed. H014 only migrates the existing successful\n-    // request lifecycle to the network boundary; it does not invent stock/balance settlement rules.\n-    return HttpResponse.json(H014_EXCHANGE_REDEEM_SUCCESS)\n+    // Preview/dev mock intentionally uses the exact production endpoint and envelope.\n+    // It only simulates the confirmed contract; it does not claim a real deduction/issue.\n+    return HttpResponse.json({\n+      code: 0,\n+      msg: 'success',\n+      status: 'succ',\n+      data: {\n+        coupon_id: couponId,\n+        points: 1080,\n+      },\n+    })\n   }),\n ]\ndiff --git a/src/pages/ApiServiceChat.test.tsx b/src/pages/ApiServiceChat.test.tsx\nindex 900ab18..ca18bcb 100644\n--- a/src/pages/ApiServiceChat.test.tsx\n+++ b/src/pages/ApiServiceChat.test.tsx\n@@ -36,7 +36,7 @@ vi.mock('./serviceChat/ServiceChatWecomQr', () => ({\n }))\n vi.mock('../components/ui', async original => {\n   const real = await original<typeof import('../components/ui')>()\n-  return { ...real, BottomSheet: ({ open, children }: { open: boolean; children: ReactNode }) => open ? <div>{children}</div> : null }\n+  return { ...real, BottomSheet: ({ open, children, actions, title }: { open: boolean; children: ReactNode; actions: ReactNode; title: string }) => open ? <div role=\"dialog\" aria-label={title}>{children}{actions}</div> : null }\n })\n import ApiServiceChat from './ApiServiceChat'\n \n@@ -64,7 +64,7 @@ describe('#135 real API chat page', () => {\n     expect(screen.queryByText('前面还有 2 位')).toBeNull()\n     expect(screen.queryByText('已收到你的问题，我先为你查询。')).toBeNull()\n     expect((screen.getByRole('button', { name: '发送' }) as HTMLButtonElement).disabled).toBe(true)\n-    expect((screen.getByRole('button', { name: '申请转人工客服' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(false)\n     expect(screen.getByText('AI 可用；人工消息通道尚未连通')).toBeTruthy()\n   })\n \n@@ -132,22 +132,32 @@ describe('#135 real API chat page', () => {\n     expect(mock.reload).toHaveBeenCalledOnce()\n   })\n \n-  it('allows real manual transfer only after socket connect, without fake agent queue', () => {\n+  it('compatibility: human entry opens the original WeCom QR while keeping real transfer uninvoked', () => {\n     mock.human = { status: 'connected', canTransfer: true, transferring: false, active: false, error: null, needsHistoryCheck: false }\n     mount()\n-    const button = screen.getByRole('button', { name: '申请转人工客服' }) as HTMLButtonElement\n+    const button = screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement\n     expect(button.disabled).toBe(false)\n     fireEvent.click(button)\n-    expect(mock.transfer).toHaveBeenCalledOnce()\n+    expect(screen.getByRole('img', { name: '企业微信福利官二维码' }).getAttribute('src')).toContain('/real-qr.png')\n+    expect(mock.transfer).not.toHaveBeenCalled()\n     expect(screen.getByText('AI 可用；可申请转人工，接入坐席状态未知')).toBeTruthy()\n-    expect(screen.queryByText('前面还有 2 位')).toBeNull()\n+    fireEvent.click(screen.getByRole('button', { name: '取消' }))\n+    expect(screen.queryByRole('img', { name: '企业微信福利官二维码' })).toBeNull()\n+  })\n+\n+  it('does not gate WeCom access on Socket.IO or AI history readiness', () => {\n+    mock.state = { status: 'error', message: '客服历史加载失败', messages: [], hasMore: false, loadingMore: false, moreError: null }\n+    mount()\n+    fireEvent.click(screen.getByRole('button', { name: '人工客服' }))\n+    expect(screen.getByRole('img', { name: '企业微信福利官二维码' })).toBeTruthy()\n+    expect(mock.transfer).not.toHaveBeenCalled()\n   })\n \n   it('requires explicit latest-history inspection after uncertain human transfer', () => {\n     mock.human = { status: 'connected', canTransfer: false, transferring: false, active: false,\n       error: '登录状态已变化，人工转接结果尚未确认，请先检查聊天历史', needsHistoryCheck: true }\n     mount()\n-    expect((screen.getByRole('button', { name: '申请转人工客服' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect((screen.getByRole('button', { name: '人工客服' }) as HTMLButtonElement).disabled).toBe(false)\n     fireEvent.click(screen.getByRole('button', { name: '核对人工转接记录' }))\n     expect(mock.reload).toHaveBeenCalledOnce()\n     expect(mock.acknowledge).toHaveBeenCalledOnce()\ndiff --git a/src/pages/ApiServiceChat.tsx b/src/pages/ApiServiceChat.tsx\nindex 6ec17a7..045b61b 100644\n--- a/src/pages/ApiServiceChat.tsx\n+++ b/src/pages/ApiServiceChat.tsx\n@@ -41,6 +41,13 @@ export default function ApiServiceChat() {\n   const navigate = useNavigate()\n   const [wecomOpen, setWecomOpen] = useState(false)\n \n+  // Compatibility: reuse the exact #wecom action previously owned by the titlebar.\n+  // useHumanChat()/human.transfer() and its real Socket.IO flow are deliberately retained.\n+  const openWecom = () => {\n+    navigate({ pathname: location.pathname, search: location.search, hash: '#wecom' },\n+      { replace: true, state: location.state })\n+  }\n+\n   const closeWecom = () => {\n     setWecomOpen(false)\n     if (location.hash === '#wecom') {\n@@ -119,10 +126,8 @@ export default function ApiServiceChat() {\n         </div>}\n         <div className=\"flex items-end gap-2\">\n           <button type=\"button\" data-chat-human-entry\n-            onClick={() => { void human.transfer() }}\n-            disabled={history.status !== 'ready' || !human.canTransfer ||\n-              chat.busy || chat.blocked || chat.humanAwait}\n-            aria-label=\"申请转人工客服\"\n+            onClick={openWecom}\n+            aria-label=\"人工客服\"\n             className=\"flex h-11 w-11 flex-none flex-col items-center justify-center rounded-container bg-surface text-[10px] font-medium text-text-brand shadow-sm disabled:opacity-50\">\n             <Headset className=\"h-4 w-4\" aria-hidden />人工\n           </button>\n@@ -150,7 +155,7 @@ export default function ApiServiceChat() {\n           )}\n         </div>\n       </div>\n-      {/* TitleBar's #wecom QR is a separate action, never a fake transfer to a human agent. */}\n+      {/* 「人工」入口现复用原 #wecom 弹层；真实 human.transfer()/Socket.IO 链路保持原样。 */}\n       <BottomSheet\n         open={wecomOpen}\n         title={CHAT_HUMAN_PROMPT.title}\ndiff --git a/src/pages/Buddy.test.tsx b/src/pages/Buddy.test.tsx\nindex 12e4794..ae39ef6 100644\n--- a/src/pages/Buddy.test.tsx\n+++ b/src/pages/Buddy.test.tsx\n@@ -1,5 +1,5 @@\n import { afterEach, describe, expect, it, vi } from 'vitest'\n-import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'\n import { MemoryRouter } from 'react-router-dom'\n \n const mocks = vi.hoisted(() => ({\n@@ -34,11 +34,27 @@ describe('buddy list backend truth', () => {\n     expect(screen.getByLabelText('我的洗头搭子')).toBeTruthy()\n   })\n \n+  it('keeps refresh as a light inline action in the buddy section header', async () => {\n+    mocks.list.mockResolvedValue([{ id: 'server-1', nickname: 'HuangChao', avatarUrl: null }])\n+    mount()\n+    await waitFor(() => expect(screen.getByText('HuangChao')).toBeTruthy())\n+    const toolbar = screen.getByLabelText('搭子列表操作')\n+    expect(within(toolbar).getByRole('heading', { name: '我的搭子' })).toBeTruthy()\n+    const refresh = within(toolbar).getByRole('button', { name: '刷新搭子列表' })\n+    expect(refresh.textContent).toBe('刷新')\n+    expect(refresh.className).toContain('bg-transparent')\n+    expect(refresh.classList.contains('border')).toBe(false)\n+    expect(toolbar.nextElementSibling?.getAttribute('aria-label')).toBe('我的洗头搭子')\n+    expect(screen.getAllByRole('button', { name: '刷新搭子列表' })).toHaveLength(1)\n+  })\n+\n   it('treats server empty relations as an actual empty state', async () => {\n     mocks.list.mockResolvedValue([])\n     mount()\n     await waitFor(() => expect(screen.getByText('还没有洗头搭子噢～')).toBeTruthy())\n     expect(screen.queryByText('小美')).toBeNull()\n+    expect(screen.getByLabelText('搭子列表操作')).toBeTruthy()\n+    expect(screen.getByRole('button', { name: '刷新搭子列表' })).toBeTruthy()\n   })\n \n   it('refreshes real list after an accepted relation without local fixtures', async () => {\n@@ -70,6 +86,7 @@ describe('buddy list backend truth', () => {\n     mount()\n     expect(screen.getByText('演示搭子资料，非真实账号关系')).toBeTruthy()\n     expect(screen.getByText('小美')).toBeTruthy()\n+    expect(screen.queryByLabelText('搭子列表操作')).toBeNull()\n     expect(mocks.list).not.toHaveBeenCalled()\n   })\n })\ndiff --git a/src/pages/Buddy.tsx b/src/pages/Buddy.tsx\nindex 14a54da..0e0cc31 100644\n--- a/src/pages/Buddy.tsx\n+++ b/src/pages/Buddy.tsx\n@@ -92,6 +92,22 @@ export default function Buddy() {\n           演示搭子资料，非真实账号关系\n         </p>\n       )}\n+      {!fixtureMode && remote.status === 'ready' && (\n+        <div className=\"flex min-h-10 items-center justify-between px-5 pt-2\" aria-label=\"搭子列表操作\">\n+          <h2 className=\"text-sm font-medium text-buddy-text\">我的搭子</h2>\n+          <Button\n+            variant=\"ghost\"\n+            size=\"regular\"\n+            leadingIcon={RefreshCw}\n+            aria-label=\"刷新搭子列表\"\n+            className=\"gap-1.5 px-2 text-[13px]\"\n+            onClick={() => setRetry(value => value + 1)}\n+          >\n+            刷新\n+          </Button>\n+        </div>\n+      )}\n+\n       {!fixtureMode && remote.status !== 'ready' ? (\n         <section role=\"status\" className=\"mx-4 mt-4 rounded-container bg-surface px-4 py-10 text-center shadow-card\">\n           {remote.status === 'loading' ? (\n@@ -132,7 +148,7 @@ export default function Buddy() {\n           }\n         />\n       ) : (\n-        <section className=\"px-4 pt-3\" aria-label=\"我的洗头搭子\">\n+        <section className={`px-4 ${fixtureMode ? 'pt-3' : 'pt-1'}`} aria-label=\"我的洗头搭子\">\n           <ul className=\"space-y-2.5\">\n             {displayed.map((buddy) => (\n               <li key={buddy.id}>\n@@ -155,15 +171,6 @@ export default function Buddy() {\n         </section>\n       )}\n \n-      {!fixtureMode && remote.status === 'ready' && (\n-        <div className=\"mt-3 px-4 text-right\">\n-          <Button variant=\"outline\" size=\"regular\" leadingIcon={RefreshCw}\n-            onClick={() => setRetry(value => value + 1)}>\n-            刷新搭子列表\n-          </Button>\n-        </div>\n-      )}\n-\n       <section className=\"mt-4 px-4\" aria-label={BUDDY_FEATURE_INTRO.title}>\n         <p className=\"px-1 text-sm font-medium text-buddy-text\">{BUDDY_FEATURE_INTRO.title}</p>\n         <ul className=\"mt-2 overflow-hidden rounded-container bg-surface shadow-card\">\ndiff --git a/src/pages/Card.test.tsx b/src/pages/Card.test.tsx\nindex 0b70dd8..b43147e 100644\n--- a/src/pages/Card.test.tsx\n+++ b/src/pages/Card.test.tsx\n@@ -1,10 +1,11 @@\n import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n import { MemoryRouter } from 'react-router-dom'\n import type { ReactNode } from 'react'\n-import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n \n const mocks = vi.hoisted(() => ({\n   fetchMyCoupons: vi.fn(),\n+  fetchRedeemedLocalCards: vi.fn(),\n   overlay: null as string | null,\n   close: vi.fn(),\n }))\n@@ -14,6 +15,10 @@ vi.mock('../services/myCoupons', async (importOriginal) => {\n   return { ...actual, fetchMyCoupons: mocks.fetchMyCoupons }\n })\n \n+vi.mock('../services/myRedeemedCards', () => ({\n+  fetchRedeemedLocalCards: mocks.fetchRedeemedLocalCards,\n+}))\n+\n vi.mock('../services/cardPackageProbe', () => ({\n   probeDiscountCardList: vi.fn(),\n }))\n@@ -108,8 +113,13 @@ function renderCard(entry = '/card') {\n   )\n }\n \n+beforeEach(() => {\n+  mocks.fetchRedeemedLocalCards.mockResolvedValue([])\n+})\n+\n afterEach(() => {\n   mocks.fetchMyCoupons.mockReset()\n+  mocks.fetchRedeemedLocalCards.mockReset()\n   mocks.overlay = null\n   mocks.close.mockReset()\n })\n@@ -296,3 +306,37 @@ describe('Card MyCoupons integration', () => {\n     expect(screen.queryByText('核心洗发水体验券')).toBeNull()\n   })\n })\n+\n+describe('Card redeemed local source=60 integration', () => {\n+  it('shows local redeemed cards alongside third-party cards in the same tab without enabling third-party actions', async () => {\n+    mocks.fetchMyCoupons.mockResolvedValue({\n+      data: [{ id: 5, active_name: '第三方体验券', enable_amount: '12.00' }],\n+      current_page: 1, per_page: 100, total: 1, last_page: 1,\n+    })\n+    mocks.fetchRedeemedLocalCards.mockResolvedValue([\n+      { id: 21, source: 60, status: 10, card_name: '洗护体验卡', card_sn: 'RC00000000000014', card_type: 10 },\n+      { id: 22, source: 60, status: 20, card_name: '已使用兑换券', card_type: 10 },\n+    ])\n+    renderCard()\n+    expect(await screen.findByText('洗护体验卡')).toBeTruthy()\n+    expect(screen.getByText('第三方体验券')).toBeTruthy()\n+    expect(screen.getByText('兑换码领取 · RC00000000000014')).toBeTruthy()\n+    const localCard = screen.getByText('洗护体验卡').closest('article')!\n+    expect(localCard.querySelector<HTMLButtonElement>('button')?.disabled).toBe(true)\n+    expect(localCard.querySelectorAll<HTMLButtonElement>('button')[1]?.disabled).toBe(true)\n+    await waitFor(() => expect(screen.getByRole('tab', { name: /可用/ }).textContent).toContain('2'))\n+    fireEvent.click(screen.getByRole('tab', { name: /已使用/ }))\n+    expect(await screen.findByText('已使用兑换券')).toBeTruthy()\n+  })\n+\n+  it('keeps the third-party list visible when local source fails', async () => {\n+    mocks.fetchMyCoupons.mockResolvedValue({\n+      data: [{ id: 5, active_name: '第三方体验券' }],\n+      current_page: 1, per_page: 100, total: 1, last_page: 1,\n+    })\n+    mocks.fetchRedeemedLocalCards.mockRejectedValue(new Error('本地卡包网络失败'))\n+    renderCard()\n+    expect(await screen.findByText('第三方体验券')).toBeTruthy()\n+    expect(await screen.findByText(/本地卡包网络失败/)).toBeTruthy()\n+  })\n+})\ndiff --git a/src/pages/Card.tsx b/src/pages/Card.tsx\nindex 5d75768..80032af 100644\n--- a/src/pages/Card.tsx\n+++ b/src/pages/Card.tsx\n@@ -22,6 +22,7 @@ import {\n   type MyCouponRecord,\n   type MyCouponType,\n } from '../services/myCoupons'\n+import { fetchRedeemedLocalCards, type LocalUserCard } from '../services/myRedeemedCards'\n \n /**\n  * 电影票样式卡券（自营页面专用 - 第一版）\n@@ -36,11 +37,12 @@ import {\n  * - 不引入 CSS 变量，固定值先用 Tailwind class 表达；金色渐变通过 inline style 注入。\n  */\n /**\n- * 票券视图模型：正式数据来自 MyCoupons 用户持券记录。\n+ * 票券视图模型：分别映射第三方 MyCoupons 与本地 source=60 兑换码卡券。\n  * 原始金额与完整有效期仍保留在 service/domain 数据中；这里仅做展示映射。\n  */\n interface CouponTicketView {\n   id: string | null\n+  origin?: 'local'\n   name: string\n   amountLabel?: string\n   validityLabel?: string\n@@ -74,12 +76,39 @@ function toCouponTicketView(coupon: MyCouponRecord): CouponTicketView {\n   }\n }\n \n+/** 本地卡券只按后台状态展示，不借用第三方 MyCoupons 的券 ID 和使用/转赠入口。 */\n+function toLocalCardView(card: LocalUserCard): CouponTicketView {\n+  const epoch = Number(card.valid_end_time)\n+  const validityLabel = card.valid_end_time != null && Number.isFinite(epoch) && epoch > 0\n+    ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(epoch * 1000)) + ' 到期'\n+    : undefined\n+  return {\n+    origin: 'local',\n+    id: 'local-' + String(card.id),\n+    name: card.card_name,\n+    amountLabel: card.card_type === 20 ? toAmountLabel(card.face_value) : undefined,\n+    validityLabel,\n+    limitNote: card.card_sn ? `兑换码领取 · ${card.card_sn}` : '兑换码领取',\n+  }\n+}\n+\n+const LOCAL_CARD_STATUS_BY_TAB: Record<CardCouponStatus, number> = {\n+  available: 10,\n+  used: 20,\n+  expired: 30,\n+}\n+\n const MY_COUPON_TYPE_BY_STATUS: Record<CardCouponStatus, MyCouponType> = {\n   available: 'unused',\n   used: 'used',\n   expired: 'out_of_date',\n }\n \n+type LocalCardsRemote =\n+  | { state: 'loading' }\n+  | { state: 'success'; cards: LocalUserCard[] }\n+  | { state: 'error'; message: string }\n+\n type CouponRemote =\n   | { state: 'loading' }\n   | { state: 'success'; coupons: MyCouponRecord[]; total: number }\n@@ -103,7 +132,7 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n     : 'linear-gradient(135deg, var(--color-reward) 0%, var(--color-reward-strong) 100%)'\n   /** 操作按钮可用性 */\n   // A third-party record without a log ID must never trigger use/transfer.\n-  const canAction = !isInactive && coupon.id !== null\n+  const canAction = !isInactive && coupon.id !== null && coupon.origin !== 'local'\n   return (\n     <article\n       className=\"relative\"\n@@ -275,6 +304,28 @@ export default function Card() {\n     createInitialCouponLists,\n   )\n   const [retryVersion, setRetryVersion] = useState(0)\n+  const [localCardsRemote, setLocalCardsRemote] = useState<LocalCardsRemote>({ state: 'loading' })\n+\n+  /** 本地兑换码卡券独立加载，不把它混作第三方优惠卡记录。 */\n+  useEffect(() => {\n+    let active = true\n+    if (runtimePolicy.dataMode === 'mock') {\n+      setLocalCardsRemote({ state: 'success', cards: [] })\n+      return () => { active = false }\n+    }\n+    setLocalCardsRemote({ state: 'loading' })\n+    void fetchRedeemedLocalCards()\n+      .then((cards) => { if (active) setLocalCardsRemote({ state: 'success', cards }) })\n+      .catch((error: unknown) => {\n+        if (active) setLocalCardsRemote({\n+          state: 'error',\n+          message: isAppError(error) && error.kind === 'contract'\n+            ? '兑换码卡券数据暂时无法展示，请稍后重试'\n+            : error instanceof Error ? error.message : '网络请求失败',\n+        })\n+      })\n+    return () => { active = false }\n+  }, [retryVersion])\n \n   /**\n    * MyCoupons 没有分页 UI，因此每个分类按服务端允许的最大 pageSize 拉取，\n@@ -328,13 +379,19 @@ export default function Card() {\n   }, [retryVersion])\n \n   const retryCouponList = () => {\n+    setLocalCardsRemote({ state: 'loading' })\n     setCouponLists((current) => ({ ...current, [tab]: { state: 'loading' } }))\n     setRetryVersion((version) => version + 1)\n   }\n \n   const currentRemote = couponLists[tab]\n-  const list =\n-    currentRemote.state === 'success' ? currentRemote.coupons.map(toCouponTicketView) : []\n+  const localCards = localCardsRemote.state === 'success'\n+    ? localCardsRemote.cards.filter((card) => card.status === LOCAL_CARD_STATUS_BY_TAB[tab]).map(toLocalCardView)\n+    : []\n+  const list = [\n+    ...(currentRemote.state === 'success' ? currentRemote.coupons.map(toCouponTicketView) : []),\n+    ...localCards,\n+  ]\n \n   const availableCoupons =\n     couponLists.available.state === 'success'\n@@ -364,7 +421,11 @@ export default function Card() {\n \n   const couponCount = (key: CardCouponStatus) => {\n     const remote = couponLists[key]\n-    return remote.state === 'success' ? remote.total : 0\n+    const externalCount = remote.state === 'success' ? remote.total : 0\n+    const localCount = localCardsRemote.state === 'success'\n+      ? localCardsRemote.cards.filter((card) => card.status === LOCAL_CARD_STATUS_BY_TAB[key]).length\n+      : 0\n+    return externalCount + localCount\n   }\n \n   useEffect(() => {\n@@ -470,22 +531,21 @@ export default function Card() {\n         ))}\n       </div>\n \n-      <div className=\"mt-4 space-y-4\" aria-live=\"polite\" aria-busy={currentRemote.state === 'loading'}>\n-        {currentRemote.state === 'success' &&\n-          list.map((coupon, index) => (\n+      <div className=\"mt-4 space-y-4\" aria-live=\"polite\" aria-busy={currentRemote.state === 'loading' || localCardsRemote.state === 'loading'}>\n+        {list.map((coupon, index) => (\n             <MovieTicket\n-              key={coupon.id ?? ('missing-id-' + index)}\n+              key={(coupon.origin ?? 'external') + '-' + (coupon.id ?? ('missing-id-' + index))}\n               coupon={coupon}\n               used={tab === 'used'}\n               expired={tab === 'expired'}\n               onUse={() => openUseSheet(coupon)}\n               onShare={() => {\n-                if (coupon.id) navigate('/card/share?coupon=' + encodeURIComponent(coupon.id))\n+                if (coupon.origin !== 'local' && coupon.id) navigate('/card/share?coupon=' + encodeURIComponent(coupon.id))\n               }}\n             />\n           ))}\n \n-        {currentRemote.state === 'loading' && <CouponTicketSkeleton />}\n+        {list.length === 0 && (currentRemote.state === 'loading' || localCardsRemote.state === 'loading') && <CouponTicketSkeleton />}\n \n         {currentRemote.state === 'error' && (\n           <EmptyState\n@@ -499,7 +559,14 @@ export default function Card() {\n           />\n         )}\n \n-        {currentRemote.state === 'success' && list.length === 0 && (\n+        {localCardsRemote.state === 'error' && (\n+          <div role=\"alert\" className=\"rounded-xl bg-surface p-3 text-sm text-text-secondary\">\n+            <p>兑换码卡券加载失败：{localCardsRemote.message}</p>\n+            <button type=\"button\" className=\"mt-2 text-text-brand\" onClick={retryCouponList}>重试兑换码卡券</button>\n+          </div>\n+        )}\n+\n+        {currentRemote.state === 'success' && localCardsRemote.state === 'success' && list.length === 0 && (\n           <EmptyState\n             variant=\"no-data\"\n             className=\"min-h-[180px]\"\ndiff --git a/src/pages/Exchange.test.tsx b/src/pages/Exchange.test.tsx\nindex 999de54..f71c6ea 100644\n--- a/src/pages/Exchange.test.tsx\n+++ b/src/pages/Exchange.test.tsx\n@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({\n   redeem: vi.fn(),\n   useExchangeCoupons: vi.fn(),\n   reloadCoupons: vi.fn(),\n+  reloadPoints: vi.fn(),\n   categoryRemote: { state: 'success', data: [\n     { key: '2', label: '洗发体验' }, { key: '3', label: '护发体验' },\n   ] } as { state: string; data?: Array<{ key: string; label: string }>; message?: string },\n@@ -23,14 +24,13 @@ vi.mock('../app/config/runtime', () => ({\n }))\n vi.mock('../services/exchange', () => ({\n   redeemExchangeProduct: mocks.redeem,\n-  H014_EXCHANGE_REDEEM_UNAVAILABLE_COPY: '兑换服务尚未接通，请稍后再试',\n }))\n vi.mock('./exchange/useExchangeFeed', () => ({\n   useExchangeCoupons: mocks.useExchangeCoupons,\n   useExchangeCategories: () => ({ remote: mocks.categoryRemote, reload: mocks.categoryReload }),\n }))\n vi.mock('./points/usePointsFeed', () => ({\n-  useUserPointsStat: () => ({ remote: mocks.pointsRemote, reload: vi.fn() }),\n+  useUserPointsStat: () => ({ remote: mocks.pointsRemote, reload: mocks.reloadPoints }),\n }))\n vi.mock('../app/router/routes', () => ({\n   findRouteByPathname: () => ({ path: '/exchange', task: 'T008', nodes: [18], states: [], overlays: [] }),\n@@ -45,6 +45,7 @@ vi.mock('../components/card/BubbleValueRedeemCard', () => ({\n   ),\n }))\n \n+import { EXCHANGE_COPY } from '../app/fixtures'\n import Exchange from './Exchange'\n import ExchangeResult from './ExchangeResult'\n \n@@ -115,6 +116,7 @@ afterEach(() => {\n   mocks.redeem.mockReset()\n   mocks.useExchangeCoupons.mockReset()\n   mocks.reloadCoupons.mockReset()\n+  mocks.reloadPoints.mockReset()\n   mocks.mode = 'mock'\n   mocks.pointsRemote = { state: 'success', data: { points: 500, income: 0, expense: 0 } }\n })\n@@ -232,7 +234,9 @@ describe('UX-E: page-local exchange flow', () => {\n \n   it('preserves request failure in the same sheet and permits an explicit retry', async () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n-    mocks.redeem.mockRejectedValueOnce(new Error('后端拒绝兑换')).mockResolvedValueOnce(undefined)\n+    mocks.redeem\n+      .mockRejectedValueOnce(new Error('后端拒绝兑换'))\n+      .mockResolvedValueOnce({ couponId: 1, points: 300 })\n     renderExchange()\n     let sheet = openCoupon()\n     fireEvent.click(within(sheet).getByRole('button', { name: '立即兑换' }))\n@@ -248,7 +252,9 @@ describe('UX-E: page-local exchange flow', () => {\n   it('guards against rapid double submission until pending request settles', async () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n     let resolveRequest!: () => void\n-    mocks.redeem.mockReturnValue(new Promise<void>(resolve => { resolveRequest = resolve }))\n+    mocks.redeem.mockReturnValue(new Promise(resolve => {\n+      resolveRequest = () => resolve({ couponId: 1, points: 300 })\n+    }))\n     renderExchange()\n     const sheet = openCoupon()\n     const submit = within(sheet).getByRole('button', { name: '立即兑换' })\n@@ -263,12 +269,14 @@ describe('UX-E: page-local exchange flow', () => {\n \n   it('shows mock success as simulation only and only navigates by explicit card-pack action', async () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n-    mocks.redeem.mockResolvedValue(undefined)\n+    mocks.redeem.mockResolvedValue({ couponId: 1, points: 300 })\n     renderExchange()\n     const sheet = openCoupon()\n     fireEvent.click(within(sheet).getByRole('button', { name: '立即兑换' }))\n     const result = await screen.findByRole('dialog', { name: '模拟兑换结果' })\n-    expect(within(result).getByText('未实际扣除泡泡值，也未发放卡券。', { exact: false })).toBeTruthy()\n+    expect(within(result).getByText('当前为 Mock 预览，仅验证正式兑换协议与页面状态，不代表真实扣值或发券。')).toBeTruthy()\n+    expect(mocks.reloadPoints).toHaveBeenCalledOnce()\n+    expect(mocks.reloadCoupons).toHaveBeenCalledOnce()\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n     fireEvent.click(within(result).getByRole('button', { name: '关闭' }))\n     expect(screen.queryByRole('dialog')).toBeNull()\n@@ -281,15 +289,23 @@ describe('UX-E: page-local exchange flow', () => {\n     expect(screen.getByText('卡包页面')).toBeTruthy()\n   })\n \n-  it('never exposes fake H014 redemption success in API/test/prod mode', () => {\n+  it('uses the real exchange service in API mode and refreshes authoritative reads after success', async () => {\n     mocks.mode = 'api'\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n+    mocks.redeem.mockResolvedValue({ couponId: 1, points: 300 })\n     renderExchange()\n+\n     const sheet = openCoupon()\n-    expect(within(sheet).getByText('兑换服务尚未接通，请稍后再试')).toBeTruthy()\n-    expect((within(sheet).getByRole('button', { name: '立即兑换' }) as HTMLButtonElement).disabled).toBe(true)\n-    expect(mocks.redeem).not.toHaveBeenCalled()\n-    expect(screen.queryByRole('dialog', { name: '模拟兑换结果' })).toBeNull()\n+    const submit = within(sheet).getByRole('button', { name: '立即兑换' }) as HTMLButtonElement\n+    expect(submit.disabled).toBe(false)\n+    fireEvent.click(submit)\n+\n+    const result = await screen.findByRole('dialog', { name: '兑换结果' })\n+    expect(within(result).getByText('兑换完成，剩余泡泡值 300。可前往卡包查看新券。')).toBeTruthy()\n+    expect(mocks.redeem).toHaveBeenCalledWith(1)\n+    expect(mocks.reloadPoints).toHaveBeenCalledOnce()\n+    expect(mocks.reloadCoupons).toHaveBeenCalledOnce()\n+    expect(screen.getByTestId('route').textContent).toBe('/exchange')\n   })\n \n   it('does not convert legacy /exchange/result deep links into a success state', async () => {\n@@ -309,6 +325,22 @@ describe('UX-E: page-local exchange flow', () => {\n     view.unmount()\n     mocks.useExchangeCoupons.mockReturnValue({ remote: { state: 'success', data: [] }, reload: mocks.reloadCoupons })\n     renderExchange()\n+    const emptyTitle = screen.getByText('没有找到相关体验券')\n+    const emptySection = emptyTitle.closest('section')\n+    expect(emptySection).toBeTruthy()\n+    const illustration = emptySection?.querySelector('[data-empty-state-illustration] img')\n+    expect(illustration).toBeTruthy()\n+    expect(illustration?.className).toContain('w-[168px]')\n+    expect(emptySection?.parentElement?.className).not.toContain('bg-surface')\n+  })\n+\n+  it('retains the category reset action in the illustrated empty state', () => {\n+    mocks.useExchangeCoupons.mockReturnValue({ remote: { state: 'success', data: [] }, reload: mocks.reloadCoupons })\n+    renderExchange()\n+    fireEvent.click(screen.getByRole('tab', { name: '洗发体验' }))\n     expect(screen.getByText('没有找到相关体验券')).toBeTruthy()\n+    expect(document.querySelector('[data-empty-state-illustration]')).toBeTruthy()\n+    fireEvent.click(screen.getByRole('button', { name: EXCHANGE_COPY.emptyAction }))\n+    expect(screen.getByRole('tab', { name: '全部' }).getAttribute('aria-selected')).toBe('true')\n   })\n })\ndiff --git a/src/pages/Exchange.tsx b/src/pages/Exchange.tsx\nindex a2c1c16..fb7ed5b 100644\n--- a/src/pages/Exchange.tsx\n+++ b/src/pages/Exchange.tsx\n@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'\n import { useNavigate } from 'react-router-dom'\n import { ChevronRight, Droplets, PartyPopper, Ticket } from 'lucide-react'\n import DebugPanel from '../components/mobile/DebugPanel'\n+import EmptyStateIllustration from '../components/mobile/EmptyStateIllustration'\n import PageContainer from '../components/mobile/PageContainer'\n import PromptOverlay from '../components/mobile/PromptOverlay'\n import BubbleValueRedeemCard from '../components/card/BubbleValueRedeemCard'\n@@ -12,7 +13,7 @@ import { useUserPointsStat } from './points/usePointsFeed'\n import { useExchangeCategories, useExchangeCoupons } from './exchange/useExchangeFeed'\n import { EXCHANGE_COPY } from '../app/fixtures'\n import type { CouponRedeemView } from '../services/coupons'\n-import { redeemExchangeProduct, H014_EXCHANGE_REDEEM_UNAVAILABLE_COPY } from '../services/exchange'\n+import { redeemExchangeProduct, type ExchangeRedeemResult } from '../services/exchange'\n import kitThumb from '../assets/brand/member/checkin-dearseed-kit.webp'\n \n /** 接口 `image` 为空时的券图兜底（沿用已验收品牌图，不新增假字段）。 */\n@@ -63,7 +64,7 @@ const availabilityButtonLabel = (state: ExchangeAvailability): string =>\n  * ⚠️ B-024 / B-025 / B-026：排序方向、完整 SKU 清单与泡泡值扣减 / 卡包写入均为服务端规则，\n  *    页面只展示接口结果，不做持久化扣减。\n  * UX-E（2026-10-09）：分类 / 券选择 / 确认 / 兑换结果只用组件状态，绝不通过 URL 驱动。\n- * H014 占位仅允许 Mock 演示；正式 API 未接通之前禁止假成功或模拟扣点入包。\n+ * #74：兑换动作统一走 POST /api/coupons/exchange；库存、扣泡泡值、流水、发券及失败补偿均由后端负责。\n  */\n export default function Exchange() {\n   const navigate = useNavigate()\n@@ -71,7 +72,7 @@ export default function Exchange() {\n   /** UX-E 分类为页面本地状态；真实分类 key 来自后台，绝不靠 URL/硬编码维护。 */\n   const [category, setCategory] = useState<string>('all')\n   const [activeProductId, setActiveProductId] = useState<string | null>(null)\n-  const [simulationComplete, setSimulationComplete] = useState(false)\n+  const [redeemResult, setRedeemResult] = useState<ExchangeRedeemResult | null>(null)\n   const [submitting, setSubmitting] = useState(false)\n   const [submitError, setSubmitError] = useState<string | null>(null)\n   const submissionLock = useRef(false)\n@@ -97,7 +98,7 @@ export default function Exchange() {\n   }, [categoriesRemote.state, validCategory])\n \n   /** GET /api/userpoints/stat：余额严格取 points。兑换结果不在页面伪扣款。 */\n-  const { remote: statRemote } = useUserPointsStat()\n+  const { remote: statRemote, reload: reloadPoints } = useUserPointsStat()\n   const balance = statRemote.state === 'success' ? statRemote.data.points : null\n \n   // Resolve against current list to prevent a stale coupon selection after a reload.\n@@ -121,13 +122,13 @@ export default function Exchange() {\n   }\n \n   const openRedeem = (product: CouponRedeemView) => {\n-    if (submissionLock.current || simulationComplete) return\n+    if (submissionLock.current || redeemResult) return\n     setActiveProductId(String(product.id))\n     setSubmitError(null)\n   }\n \n   const closeResult = () => {\n-    setSimulationComplete(false)\n+    setRedeemResult(null)\n     setActiveProductId(null)\n     setSubmitError(null)\n   }\n@@ -144,22 +145,19 @@ export default function Exchange() {\n \n   const submit = async () => {\n     if (!activeProduct || availability !== 'redeemable' || submissionLock.current) return\n-    // #74: No confirmed production endpoint exists. H014 mock is strictly a dev/preview\n-    // simulation and must not emit a real issue/settlement success in API/test/prod.\n-    if (runtimePolicy.dataMode !== 'mock') {\n-      setSubmitError(H014_EXCHANGE_REDEEM_UNAVAILABLE_COPY)\n-      return\n-    }\n \n     submissionLock.current = true\n     setSubmitting(true)\n     setSubmitError(null)\n     try {\n-      await redeemExchangeProduct(String(activeProduct.id))\n+      const result = await redeemExchangeProduct(activeProduct.id)\n       setActiveProductId(null)\n-      setSimulationComplete(true)\n-      // A mock response does NOT decrement points, issue a coupon or prove real settlement.\n-      // #74 owns the eventual real endpoint and authoritative refresh rules.\n+      setRedeemResult(result)\n+\n+      // Backend response is authoritative for success. Refresh the two page-level reads\n+      // that can change after settlement: remaining points and coupon stock/redeemed count.\n+      reloadPoints()\n+      reloadCoupons()\n     } catch (error) {\n       setSubmitError(error instanceof Error && error.message ? error.message : EXCHANGE_REQUEST_ERROR_COPY)\n     } finally {\n@@ -225,20 +223,19 @@ export default function Exchange() {\n             />\n           </div>\n         ) : list.length === 0 ? (\n-          <div className=\"rounded-container bg-surface py-2 shadow-sm\">\n-            <EmptyState\n-              variant=\"no-results\"\n-              title={EXCHANGE_COPY.emptyTitle}\n-              supportingText={EXCHANGE_COPY.emptyDesc}\n-              primaryAction={\n-                category === 'all' ? undefined : (\n-                  <Button variant=\"outline\" onClick={() => changeCategory('all')}>\n-                    {EXCHANGE_COPY.emptyAction}\n-                  </Button>\n-                )\n-              }\n-            />\n-          </div>\n+          <EmptyState\n+            variant=\"no-results\"\n+            visual={<EmptyStateIllustration size=\"md\" />}\n+            title={EXCHANGE_COPY.emptyTitle}\n+            supportingText={EXCHANGE_COPY.emptyDesc}\n+            primaryAction={\n+              category === 'all' ? undefined : (\n+                <Button variant=\"outline\" onClick={() => changeCategory('all')}>\n+                  {EXCHANGE_COPY.emptyAction}\n+                </Button>\n+              )\n+            }\n+          />\n         ) : (\n           <ul className=\"grid grid-cols-2 gap-3\" aria-live=\"polite\">\n             {list.map((product) => {\n@@ -346,12 +343,6 @@ export default function Exchange() {\n               </p>\n             )}\n \n-            {runtimePolicy.dataMode !== 'mock' && (\n-              <p className=\"mt-2 text-xs text-text-secondary\" role=\"status\">\n-                {H014_EXCHANGE_REDEEM_UNAVAILABLE_COPY}\n-              </p>\n-            )}\n-\n             {submitError && (\n               <p className=\"mt-2 text-xs text-danger-text\" role=\"alert\">\n                 {submitError}\n@@ -362,7 +353,7 @@ export default function Exchange() {\n               size=\"large\"\n               className=\"mt-4 w-full rounded-full\"\n               loading={submitting}\n-              disabled={availability !== 'redeemable' || runtimePolicy.dataMode !== 'mock'}\n+              disabled={availability !== 'redeemable' || submitting}\n               onClick={() => void submit()}\n             >\n               {submitting ? EXCHANGE_COPY.submitting : availabilityButtonLabel(availability)}\n@@ -372,8 +363,8 @@ export default function Exchange() {\n       </BottomSheet>\n \n       <PromptOverlay\n-        open={simulationComplete}\n-        label=\"模拟兑换结果\"\n+        open={redeemResult !== null}\n+        label={runtimePolicy.dataMode === 'mock' ? '模拟兑换结果' : '兑换结果'}\n         onDismiss={closeResult}\n         className=\"bg-surface px-6 pb-6 pt-7 text-center\"\n       >\n@@ -385,10 +376,12 @@ export default function Exchange() {\n           <PartyPopper className=\"h-9 w-9 text-claim-text\" />\n         </div>\n         <h2 className=\"mt-4 text-lg font-bold leading-6 text-text-primary\">\n-          兑换成功演示\n+          {runtimePolicy.dataMode === 'mock' ? '兑换成功演示' : '兑换成功'}\n         </h2>\n         <p className=\"mt-2 text-sm leading-6 text-text-secondary\">\n-          仅供 H5 预览。未实际扣除泡泡值，也未发放卡券。\n+          {runtimePolicy.dataMode === 'mock'\n+            ? '当前为 Mock 预览，仅验证正式兑换协议与页面状态，不代表真实扣值或发券。'\n+            : `兑换完成，剩余泡泡值 ${redeemResult?.points.toLocaleString() ?? '--'}。可前往卡包查看新券。`}\n         </p>\n         <Button size=\"large\" className=\"mt-6 h-11 w-full rounded-full\" onClick={openCardPack}>\n           {EXCHANGE_COPY.successAction}\ndiff --git a/src/pages/MallWebView.tsx b/src/pages/MallWebView.tsx\nindex 4738d27..341b6d6 100644\n--- a/src/pages/MallWebView.tsx\n+++ b/src/pages/MallWebView.tsx\n@@ -2,14 +2,14 @@\n  * 商城 H5 承载页\n  * -------------------------------------------------------------\n  * 底部 Tab「商城」(/mall) 不再做本地原生还原，改为在当前手机界面框架内\n- * 以 iframe 加载商城 H5（www.3-wins.cn），使底栏与手机壳层保持可见，\n+ * 以 iframe 加载商城 H5，使底栏与手机壳层保持可见，\n  * 不跳出 App / H5 外层界面。\n  *\n- * 2026-09-29 Maintainer 明确定案：当前联调地址固定为 http://www.3-wins.cn/，\n- * 不自动替换 scheme。鉴权透传、cleartext/mixed-content 以及目标站 iframe 策略\n- * （X-Frame-Options / CSP frame-ancestors）仍需在真实 App WebView 内验证。\n+ * 2026-10-12：当前地址仅用于效果预览，不代表正式商城接入方案。\n+ * 保留用户提供的 http scheme，不自动改写为 https；鉴权透传、cleartext/mixed-content\n+ * 及目标站 iframe 策略仍需在真实 App WebView 内单独验证。\n  */\n-const MALL_H5_URL = 'http://www.3-wins.cn/'\n+const MALL_H5_URL = 'http://scmzhds07.m.demo.webportal.top/?_sc=1'\n \n export default function MallWebView() {\n   return (\ndiff --git a/src/pages/PointsDetail.tsx b/src/pages/PointsDetail.tsx\nindex 63ee8fe..af99c72 100644\n--- a/src/pages/PointsDetail.tsx\n+++ b/src/pages/PointsDetail.tsx\n@@ -117,7 +117,7 @@ export default function PointsDetail() {\n              视觉统一使用品牌插画空态，直接落在页面背景上，不再套白卡片容器。 */\n           <EmptyState\n             variant=\"no-data\"\n-            visual={<EmptyStateIllustration />}\n+            visual={<EmptyStateIllustration size=\"md\" />}\n             title={BUBBLE_LIST_END}\n           />\n         ) : (\ndiff --git a/src/pages/Redeem.test.tsx b/src/pages/Redeem.test.tsx\nnew file mode 100644\nindex 0000000..e4e75ab\n--- /dev/null\n+++ b/src/pages/Redeem.test.tsx\n@@ -0,0 +1,94 @@\n+import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n+import { MemoryRouter } from 'react-router-dom'\n+import type { ReactNode } from 'react'\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+\n+const mocks = vi.hoisted(() => ({ redeemCardCode: vi.fn() }))\n+\n+vi.mock('../services/redeemCode', () => ({\n+  redeemCardCode: mocks.redeemCardCode,\n+}))\n+\n+vi.mock('../app/config/runtime', async (importOriginal) => {\n+  const original = await importOriginal<typeof import('../app/config/runtime')>()\n+  return { ...original, runtimePolicy: { ...original.runtimePolicy, dataMode: 'api' } }\n+})\n+\n+vi.mock('../app/fixtures/useFixture', () => ({\n+  useFixtureState: () => ({ state: null }),\n+}))\n+\n+vi.mock('../app/router/routes', () => ({\n+  findRouteByPathname: () => ({ path: '/redeem' }),\n+}))\n+\n+vi.mock('../components/mobile/DebugPanel', () => ({ default: () => null }))\n+vi.mock('../components/mobile/PageContainer', () => ({\n+  default: ({ children }: { children: ReactNode }) => <div>{children}</div>,\n+}))\n+\n+import Redeem from './Redeem'\n+import { AppError } from '../lib/appError'\n+\n+const receipt = {\n+  user_card_id: 21, card_id: 4,\n+  card_name: '洗护体验卡',\n+  card_sn: 'RC00000000000014',\n+}\n+\n+const renderRedeem = () => render(\n+  <MemoryRouter initialEntries={['/redeem']}><Redeem /></MemoryRouter>,\n+)\n+\n+afterEach(() => mocks.redeemCardCode.mockReset())\n+\n+describe('Redeem confirmed backend integration', () => {\n+  it('preserves exact code and displays actual issued card receipt only after backend success', async () => {\n+    mocks.redeemCardCode.mockResolvedValue(receipt)\n+    renderRedeem()\n+    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n+      target: { value: 'Abc- 123456' },\n+    })\n+    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n+    await waitFor(() => expect(mocks.redeemCardCode).toHaveBeenCalledWith('Abc- 123456'))\n+    expect(await screen.findByText('洗护体验卡')).toBeTruthy()\n+    expect(screen.getByText('卡券编号：RC00000000000014')).toBeTruthy()\n+    expect(screen.getByRole('button', { name: '查看我的卡包' })).toBeTruthy()\n+  })\n+\n+  it('keeps the form for backend business errors and displays its error message', async () => {\n+    mocks.redeemCardCode.mockRejectedValue(new Error('该兑换码已被使用'))\n+    renderRedeem()\n+    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n+      target: { value: 'ABC123456789' },\n+    })\n+    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n+    expect(await screen.findByText('该兑换码已被使用')).toBeTruthy()\n+    expect(screen.queryByText('兑换成功')).toBeNull()\n+    expect(screen.getByRole('button', { name: '确认兑换' })).toBeTruthy()\n+  })\n+\n+  it('shows neutral user-facing text when backend receipt fails contract validation', async () => {\n+    mocks.redeemCardCode.mockRejectedValue(new AppError({\n+      kind: 'contract', message: '外部数据不符合 cardredemptioncodes.redeem.data 契约',\n+    }))\n+    renderRedeem()\n+    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n+      target: { value: 'ABC123456789' },\n+    })\n+    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n+    expect(await screen.findByText('兑换结果暂时无法确认，请稍后重试')).toBeTruthy()\n+    expect(screen.queryByText(/cardredemptioncodes.redeem.data/)).toBeNull()\n+    expect(screen.queryByText('兑换成功')).toBeNull()\n+  })\n+\n+  it('disables submitting fewer than ten characters, but does not alter or uppercase content', () => {\n+    renderRedeem()\n+    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n+      target: { value: 'a1-b2+3' },\n+    })\n+    expect((screen.getByRole('textbox', { name: '兑换码' }) as HTMLInputElement).value).toBe('a1-b2+3')\n+    expect((screen.getByRole('button', { name: '确认兑换' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect(mocks.redeemCardCode).not.toHaveBeenCalled()\n+  })\n+})\ndiff --git a/src/pages/Redeem.tsx b/src/pages/Redeem.tsx\nindex af73d85..7aae9f5 100644\n--- a/src/pages/Redeem.tsx\n+++ b/src/pages/Redeem.tsx\n@@ -2,6 +2,9 @@ import { useEffect, useRef, useState } from 'react'\n import { useNavigate } from 'react-router-dom'\n import { AlertCircle, CheckCircle2, Loader2, Ticket, XCircle } from 'lucide-react'\n import DebugPanel from '../components/mobile/DebugPanel'\n+import { isAppError } from '../services/http'\n+import { runtimePolicy } from '../app/config/runtime'\n+import { redeemCardCode, type RedemptionReceipt } from '../services/redeemCode'\n import PageContainer from '../components/mobile/PageContainer'\n import { useFixtureState } from '../app/fixtures/useFixture'\n import { findRouteByPathname } from '../app/router/routes'\n@@ -18,12 +21,8 @@ import {\n  * -------------------------------------------------------------\n  * 视觉与交互取自 reference/兑换卡券.html：ticket 前缀图标、56 高输入框、聚焦描边、\n  * 清除按钮仅在有输入时出现、48 高圆角主按钮、居中 toast（成功绿 / 失败红）。\n- * ⚠️ 未定稿（B-009）：摹客原型分别出现「8 位」「11 位」，reference 与历史稿为 12 位。\n- *    页面不自持任何位数常量，位数 / 字符集 / 提示文案全部读 REDEEM_CODE_RULE，\n- *    定稿时只改夹具一处。规则未定期间提交按钮只在「空输入」时禁用（照 reference syncState），\n- *    不用位数提前拦截，避免把未确认的位数表现成最终校验规则。\n- * 失败分支（无效 / 已使用 / 网络异常）通过 REDEEM_CODE_OUTCOMES 的确定性演示码触发，\n- * 也可用 `?state=invalid` 等直���截图。\n+ * 兑换码至少 10 位，不修改用户输入字符；API 模式走后台正式核兑接口。\n+ * 只有 preview/dev Mock 模式使用固定演示码，API/test/prod 以后台发券回执为准。\n  */\n export default function Redeem() {\n   const navigate = useNavigate()\n@@ -33,10 +32,12 @@ export default function Redeem() {\n   const [code, setCode] = useState('')\n   const [submitting, setSubmitting] = useState(false)\n   const [outcome, setOutcome] = useState<RedeemOutcome | null>(null)\n+  const [errorMessage, setErrorMessage] = useState<string | null>(null)\n+  const [receipt, setReceipt] = useState<RedemptionReceipt | null>(null)\n   const timers = useRef<number[]>([])\n \n-  const trimmed = code.replace(/\\s/g, '')\n-  const feedback = outcome ? REDEEM_FEEDBACK[outcome] : null\n+  const trimmed = code\n+  const feedback = errorMessage ? { ok: false, text: errorMessage } : outcome ? REDEEM_FEEDBACK[outcome] : null\n   const done = outcome === 'success'\n \n   /** `?state=` 直达：回填该分支的演示码并落到对应反馈，便于逐节点截图 */\n@@ -44,7 +45,9 @@ export default function Redeem() {\n     const key = state?.key as RedeemOutcome | undefined\n     if (!key || !(key in REDEEM_SAMPLE_CODES)) return\n     setCode(REDEEM_SAMPLE_CODES[key])\n+    setReceipt(null)\n     setOutcome(key)\n+    setErrorMessage(null)\n     setSubmitting(false)\n   }, [state?.key])\n \n@@ -55,21 +58,43 @@ export default function Redeem() {\n   }\n \n   const clear = () => {\n+    setReceipt(null)\n     setCode('')\n     setOutcome(null)\n+    setErrorMessage(null)\n   }\n \n-  const submit = () => {\n-    if (!trimmed || submitting) return\n+  const submit = async () => {\n+    if (trimmed.length < REDEEM_CODE_RULE.minLength || submitting) return\n     setOutcome(null)\n+    setReceipt(null)\n+    setErrorMessage(null)\n     setSubmitting(true)\n-    /** 提交中态：夹具环境下用固定时长模拟一次请求往返，非随机 */\n-    track(() => {\n+    if (runtimePolicy.dataMode === 'mock') {\n+      /** Mock 只在预览环境产生确定性演示结果，API 模式绝不模拟成功。 */\n+      track(() => {\n+        setSubmitting(false)\n+        const next = resolveRedeemOutcome(trimmed)\n+        setOutcome(next)\n+        if (next !== 'success') track(() => setOutcome(null), 2000)\n+      }, 700)\n+      return\n+    }\n+    try {\n+      const nextReceipt = await redeemCardCode(trimmed)\n+      setReceipt(nextReceipt)\n+      setOutcome('success')\n+    } catch (error) {\n+      if (isAppError(error) && error.kind === 'contract') {\n+        setErrorMessage('兑换结果暂时无法确认，请稍后重试')\n+      } else if (isAppError(error) && (error.kind === 'network' || error.kind === 'timeout')) {\n+        setErrorMessage('网络异常，请稍后重试')\n+      } else {\n+        setErrorMessage(error instanceof Error ? error.message : '兑换失败，请稍后重试')\n+      }\n+    } finally {\n       setSubmitting(false)\n-      const next = resolveRedeemOutcome(trimmed)\n-      setOutcome(next)\n-      if (next !== 'success') track(() => setOutcome(null), 2000)\n-    }, 700)\n+    }\n   }\n \n   if (done) {\n@@ -81,6 +106,7 @@ export default function Redeem() {\n           </span>\n           <h1 className=\"text-lg font-semibold text-text-primary\">兑换成功</h1>\n           <p className=\"max-w-[260px] text-sm leading-6 text-text-secondary\">{REDEEM_FEEDBACK.success.text}</p>\n+          {receipt && <p className=\"max-w-[260px] text-sm font-medium text-text-primary\">{receipt.card_name}</p>}\n         </div>\n \n         <section className=\"mx-4 mt-5 flex items-center gap-3 rounded-container bg-surface p-3 shadow-sm\">\n@@ -93,6 +119,7 @@ export default function Redeem() {\n           <div className=\"min-w-0 flex-1\">\n             <p className=\"text-xs text-text-tertiary\">兑换码</p>\n             <p className=\"mt-0.5 truncate text-base font-semibold tracking-[0.08em] text-text-primary\">{trimmed}</p>\n+            {receipt && <p className=\"mt-1 truncate text-xs text-text-secondary\">卡券编号：{receipt.card_sn}</p>}\n           </div>\n         </section>\n \n@@ -122,13 +149,12 @@ export default function Redeem() {\n           type=\"text\"\n           inputMode=\"text\"\n           autoComplete=\"off\"\n-          autoCapitalize=\"characters\"\n           spellCheck={false}\n-          maxLength={REDEEM_CODE_RULE.length}\n           value={code}\n           onChange={(event) => {\n-            setCode(event.target.value.toUpperCase())\n+            setCode(event.target.value)\n             setOutcome(null)\n+            setErrorMessage(null)\n           }}\n           placeholder=\"请输入兑换码\"\n           aria-label=\"兑换码\"\n@@ -145,7 +171,7 @@ export default function Redeem() {\n \n       <button\n         type=\"button\"\n-        disabled={!trimmed || submitting}\n+        disabled={trimmed.length < REDEEM_CODE_RULE.minLength || submitting}\n         onClick={submit}\n         className=\"mt-6 flex h-12 w-full items-center justify-center gap-2 rounded-full bg-primary text-base font-medium text-text-inverse active:bg-primary-pressed disabled:bg-disabled disabled:text-text-disabled\"\n       >\ndiff --git a/src/pages/ServiceChat.compat.test.tsx b/src/pages/ServiceChat.compat.test.tsx\nnew file mode 100644\nindex 0000000..9b9d57a\n--- /dev/null\n+++ b/src/pages/ServiceChat.compat.test.tsx\n@@ -0,0 +1,71 @@\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { fireEvent, render, screen } from '@testing-library/react'\n+import { MemoryRouter } from 'react-router-dom'\n+import type { ReactNode } from 'react'\n+\n+vi.mock('../app/config/runtime', async original => {\n+  const actual = await original<typeof import('../app/config/runtime')>()\n+  return { ...actual, runtimePolicy: { ...actual.runtimePolicy, dataMode: 'mock' } }\n+})\n+vi.mock('../app/fixtures/useFixture', () => ({\n+  useFixtureState: () => ({ state: null }),\n+}))\n+vi.mock('../app/router/routes', () => ({\n+  findRouteByPathname: () => ({ path: '/service/chat' }),\n+}))\n+vi.mock('../components/mobile/DebugPanel', () => ({ default: () => null }))\n+vi.mock('../components/mobile/PageContainer', () => ({\n+  default: ({ children }: { children: ReactNode }) => <div>{children}</div>,\n+}))\n+vi.mock('../components/mobile/ChatMessageList', () => ({\n+  default: () => <div>欢迎聊天</div>,\n+}))\n+vi.mock('../components/mobile/WecomQrPlaceholder', () => ({\n+  default: () => <div>原企微二维码区域</div>,\n+}))\n+vi.mock('../components/ui', async original => {\n+  const actual = await original<typeof import('../components/ui')>()\n+  return { ...actual, BottomSheet: ({ open, children, actions }: {\n+    open: boolean; children: ReactNode; actions: ReactNode\n+  }) => open ? <div role=\"dialog\">{children}{actions}</div> : null }\n+})\n+\n+import ServiceChat from './ServiceChat'\n+\n+afterEach(() => {\n+  vi.restoreAllMocks()\n+})\n+\n+describe('legacy/mock ServiceChat compatibility', () => {\n+  it('opens the old WeCom sheet from 人工 without starting the preserved local queue', () => {\n+    render(<MemoryRouter initialEntries={['/service/chat']}><ServiceChat /></MemoryRouter>)\n+    const button = screen.getByRole('button', { name: '人工' })\n+    expect(button.hasAttribute('disabled')).toBe(false)\n+    fireEvent.click(button)\n+    expect(screen.getByText('原企微二维码区域')).toBeTruthy()\n+    expect(document.querySelector('[data-human-stage]')?.getAttribute('data-human-stage')).toBe('idle')\n+    fireEvent.click(screen.getByRole('button', { name: '取消' }))\n+    expect(screen.queryByText('原企微二维码区域')).toBeNull()\n+    fireEvent.click(button)\n+    expect(screen.getByText('原企微二维码区域')).toBeTruthy()\n+  })\n+\n+  it('keeps mock AI input available and original typed 人工 transfer reachable', () => {\n+    render(<MemoryRouter initialEntries={['/service/chat']}><ServiceChat /></MemoryRouter>)\n+    const input = screen.getByRole('textbox', { name: '输入你的问题' }) as HTMLInputElement\n+    expect(input.disabled).toBe(false)\n+    fireEvent.change(input, { target: { value: '你好' } })\n+    fireEvent.click(screen.getByRole('button', { name: '发送' }))\n+    expect(document.querySelector('[data-human-stage]')?.getAttribute('data-human-stage')).toBe('idle')\n+\n+    fireEvent.change(input, { target: { value: '人工' } })\n+    fireEvent.click(screen.getByRole('button', { name: '发送' }))\n+    expect(document.querySelector('[data-human-stage]')?.getAttribute('data-human-stage')).toBe('queuing')\n+    expect(input.disabled).toBe(true)\n+  })\n+\n+  it('keeps the previous #wecom deep link working', () => {\n+    render(<MemoryRouter initialEntries={['/service/chat#wecom']}><ServiceChat /></MemoryRouter>)\n+    expect(screen.getByText('原企微二维码区域')).toBeTruthy()\n+  })\n+})\ndiff --git a/src/pages/ServiceChat.tsx b/src/pages/ServiceChat.tsx\nindex d7d1a35..b258929 100644\n--- a/src/pages/ServiceChat.tsx\n+++ b/src/pages/ServiceChat.tsx\n@@ -50,6 +50,14 @@ function MockServiceChat() {\n \n   /** T013R5：企微二维码弹层 —— 与壳层 TitleBar 联动，靠 `#wecom` hash 触发 */\n   const [wecomOpen, setWecomOpen] = useState(false)\n+  // Compatibility: the visible \"人工\" entry opens the original #wecom sheet.\n+  // Keep requestHuman() and the existing human-stage implementation unchanged.\n+  const openWecom = () => {\n+    navigate(\n+      { pathname: location.pathname, search: location.search, hash: '#wecom' },\n+      { replace: true, state: location.state },\n+    )\n+  }\n   const closeWecom = () => {\n     setWecomOpen(false)\n     /* H016：通过 Router replace 清 hash，保留当前 history entry/state，不绕过 React Router。 */\n@@ -164,9 +172,11 @@ function MockServiceChat() {\n     settle(id, target.text)\n   }\n \n-  /** T013R4：人工坐席已接入后输入区可用；否则禁用并显示「正在接入...」 */\n+  /** 兼容处理：保留原「人工」关键词触发 requestHuman() 的入口。\n+   * idle 时允许正常 AI 输入或输入「人工」进入原模拟转接状态；\n+   * 仅排队中禁用输入，接入后继续可发送。 */\n   const humanActive = humanStage === 'connected'\n-  const inputDisabled = !humanActive\n+  const inputDisabled = humanStage === 'queuing'\n   const inputPlaceholder = humanActive\n     ? `与 ${CHAT_QUEUE.connected.agentName} 对话中…`\n     : humanStage === 'queuing'\n@@ -191,8 +201,7 @@ function MockServiceChat() {\n           <button\n             type=\"button\"\n             data-chat-human-entry\n-            onClick={requestHuman}\n-            disabled={humanStage !== 'idle'}\n+            onClick={openWecom}\n             className=\"flex h-11 w-11 flex-none flex-col items-center justify-center rounded-container bg-surface text-[10px] font-medium text-text-brand shadow-sm active:bg-surface-selected disabled:opacity-50\"\n           >\n             <Headset className=\"h-4 w-4\" aria-hidden />\n@@ -232,10 +241,8 @@ function MockServiceChat() {\n         </div>\n       </div>\n \n-      {/* T013R4：企微二维码弹层恢复（R1+R2 撤掉的 #71 重新启用，但仅承担\"企微客服\"入口）。\n-        * 触发：点击壳层 TitleBar 右侧「企微客服」pill（data-chat-wecom-entry）。\n-        * 行为：仅展示福利官二维码 + 「取消」按钮，不承担\"转人工\"职责 ——「人工」走 requestHuman。\n-        * T013R5：「取消」按钮居中（外层 flex justify-center）。 */}\n+      {/* 兼容性处理：右上企微入口隐藏；底部「人工」复用原 #wecom 弹层。\n+        * 既有 requestHuman()／人工状态机保留，单独治理，不在此变更删除。 */}\n       <BottomSheet\n         open={wecomOpen}\n         title={CHAT_HUMAN_PROMPT.title}\ndiff --git a/src/services/auth/session.bootstrap.test.ts b/src/services/auth/session.bootstrap.test.ts\nnew file mode 100644\nindex 0000000..239bf60\n--- /dev/null\n+++ b/src/services/auth/session.bootstrap.test.ts\n@@ -0,0 +1,145 @@\n+import { beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+const mocks = vi.hoisted(() => ({\n+  request: vi.fn(),\n+  getLoginToken: vi.fn(),\n+  setAuthHeadersProvider: vi.fn(),\n+  setUnauthorizedHandler: vi.fn(),\n+  setAuthFailureHandler: vi.fn(),\n+}))\n+\n+vi.mock('../../app/config/runtime', () => ({\n+  runtimePolicy: {\n+    apiBaseUrl: 'https://api.example.test',\n+    dataMode: 'api',\n+  },\n+}))\n+\n+vi.mock('../nativeBridge', () => ({\n+  getLoginToken: mocks.getLoginToken,\n+}))\n+\n+vi.mock('../http/httpClient', () => ({\n+  createHttpClient: () => ({\n+    request: mocks.request,\n+  }),\n+  setHttpAuthHeadersProvider: mocks.setAuthHeadersProvider,\n+  setHttpUnauthorizedHandler: mocks.setUnauthorizedHandler,\n+  setHttpAuthFailureHandler: mocks.setAuthFailureHandler,\n+}))\n+\n+describe('bootstrap auth 401 handling', () => {\n+  beforeEach(() => {\n+    vi.resetModules()\n+    mocks.request.mockReset()\n+    mocks.getLoginToken.mockReset()\n+    mocks.setAuthHeadersProvider.mockReset()\n+    mocks.setUnauthorizedHandler.mockReset()\n+    mocks.setAuthFailureHandler.mockReset()\n+    mocks.getLoginToken.mockResolvedValue({ token: 'native-token', salt: 'native-salt' })\n+  })\n+\n+  it('retries the initial login once after a 401 and succeeds without expiring the WebView', async () => {\n+    const { bootstrapAuthSession, AUTH_EXPIRED_EVENT } = await import('./session')\n+    const { AppError } = await import('../http/appError')\n+    const expired = vi.fn()\n+    window.addEventListener(AUTH_EXPIRED_EVENT, expired)\n+\n+    mocks.request\n+      .mockRejectedValueOnce(\n+        new AppError({ kind: 'business', message: 'unauthorized', status: 401, code: '401' }),\n+      )\n+      .mockResolvedValueOnce({\n+        code: 0,\n+        data: { userInfo: { id: 'user-1' }, accessToken: 'access-1' },\n+      })\n+\n+    await expect(bootstrapAuthSession()).resolves.toEqual({\n+      userInfo: { id: 'user-1' },\n+      accessToken: 'access-1',\n+    })\n+    expect(mocks.request).toHaveBeenCalledTimes(2)\n+    expect(expired).not.toHaveBeenCalled()\n+\n+    window.removeEventListener(AUTH_EXPIRED_EVENT, expired)\n+  })\n+\n+  it('emits auth-expired when the initial login and its single retry both return 401', async () => {\n+    const { bootstrapAuthSession, AUTH_EXPIRED_EVENT } = await import('./session')\n+    const { AppError } = await import('../http/appError')\n+    const expired = vi.fn()\n+    window.addEventListener(AUTH_EXPIRED_EVENT, expired)\n+\n+    mocks.request\n+      .mockRejectedValueOnce(\n+        new AppError({ kind: 'business', message: 'unauthorized', status: 401, code: '401' }),\n+      )\n+      .mockRejectedValueOnce(\n+        new AppError({ kind: 'business', message: 'unauthorized', status: 401, code: '401' }),\n+      )\n+\n+    await expect(bootstrapAuthSession()).rejects.toMatchObject({ status: 401 })\n+    expect(mocks.request).toHaveBeenCalledTimes(2)\n+    expect(expired).toHaveBeenCalledTimes(1)\n+\n+    window.removeEventListener(AUTH_EXPIRED_EVENT, expired)\n+  })\n+\n+  it('shares the whole initial retry flow with concurrent bootstrap callers', async () => {\n+    const { bootstrapAuthSession, AUTH_EXPIRED_EVENT } = await import('./session')\n+    const { AppError } = await import('../http/appError')\n+    const expired = vi.fn()\n+    window.addEventListener(AUTH_EXPIRED_EVENT, expired)\n+\n+    let rejectFirst!: (error: unknown) => void\n+    const firstAttempt = new Promise<never>((_, reject) => {\n+      rejectFirst = reject\n+    })\n+\n+    mocks.request\n+      .mockReturnValueOnce(firstAttempt)\n+      .mockResolvedValueOnce({\n+        code: 0,\n+        data: { userInfo: { id: 'user-1' }, accessToken: 'access-1' },\n+      })\n+\n+    const first = bootstrapAuthSession()\n+    const concurrent = bootstrapAuthSession()\n+\n+    expect(concurrent).toBe(first)\n+\n+    rejectFirst(\n+      new AppError({ kind: 'business', message: 'unauthorized', status: 401, code: '401' }),\n+    )\n+\n+    await expect(first).resolves.toEqual({\n+      userInfo: { id: 'user-1' },\n+      accessToken: 'access-1',\n+    })\n+    await expect(concurrent).resolves.toEqual({\n+      userInfo: { id: 'user-1' },\n+      accessToken: 'access-1',\n+    })\n+    expect(mocks.request).toHaveBeenCalledTimes(2)\n+    expect(expired).not.toHaveBeenCalled()\n+\n+    window.removeEventListener(AUTH_EXPIRED_EVENT, expired)\n+  })\n+\n+  it('does not relabel a non-401 bootstrap failure as expired', async () => {\n+    const { bootstrapAuthSession, AUTH_EXPIRED_EVENT } = await import('./session')\n+    const { AppError } = await import('../http/appError')\n+    const expired = vi.fn()\n+    window.addEventListener(AUTH_EXPIRED_EVENT, expired)\n+\n+    mocks.request.mockRejectedValueOnce(\n+      new AppError({ kind: 'network', message: 'network failed' }),\n+    )\n+\n+    await expect(bootstrapAuthSession()).rejects.toMatchObject({ kind: 'network' })\n+    expect(mocks.request).toHaveBeenCalledTimes(1)\n+    expect(expired).not.toHaveBeenCalled()\n+\n+    window.removeEventListener(AUTH_EXPIRED_EVENT, expired)\n+  })\n+})\ndiff --git a/src/services/auth/session.ts b/src/services/auth/session.ts\nindex 2bf7570..db73016 100644\n--- a/src/services/auth/session.ts\n+++ b/src/services/auth/session.ts\n@@ -1,6 +1,6 @@\n import { runtimePolicy } from '../../app/config/runtime'\n import { getLoginToken } from '../nativeBridge'\n-import { createContractError } from '../http/appError'\n+import { createContractError, isAppError } from '../http/appError'\n import { STORAGE_KEYS, storage } from '../../storage'\n import {\n   createHttpClient,\n@@ -10,6 +10,7 @@ import {\n } from '../http/httpClient'\n \n const AUTH_FAILURE_EVENT = 'dr-card-ui:auth-failure'\n+const AUTH_EXPIRED_EVENT = 'dr-card-ui:auth-expired'\n const LOGIN_PATH = '/api/oauth/login'\n \n export interface AuthSession {\n@@ -33,6 +34,7 @@ const loginClient = createHttpClient({\n })\n \n let inFlightLogin: Promise<AuthSession> | undefined\n+let initialBootstrap: Promise<AuthSession> | undefined\n let authFlowEnabled = false\n let initializedForDocument = false\n let volatileSession: AuthSession | undefined\n@@ -123,11 +125,39 @@ export function authenticate(force = false): Promise<AuthSession> {\n   return inFlightLogin\n }\n \n+function isUnauthorizedAuthError(error: unknown) {\n+  return isAppError(error) && error.status === 401\n+}\n+\n+function dispatchAuthEvent(eventName: string) {\n+  if (typeof window !== 'undefined') window.dispatchEvent(new Event(eventName))\n+}\n+\n+async function authenticateInitialDocument(): Promise<AuthSession> {\n+  try {\n+    return await authenticate(true)\n+  } catch (error) {\n+    if (!isUnauthorizedAuthError(error)) throw error\n+  }\n+\n+  try {\n+    return await authenticate(true)\n+  } catch (error) {\n+    clearAuthSession()\n+    if (isUnauthorizedAuthError(error)) dispatchAuthEvent(AUTH_EXPIRED_EVENT)\n+    throw error\n+  }\n+}\n+\n export function bootstrapAuthSession(): Promise<AuthSession> {\n   if (!initializedForDocument) {\n     initializedForDocument = true\n-    return authenticate(true)\n+    initialBootstrap = authenticateInitialDocument().finally(() => {\n+      initialBootstrap = undefined\n+    })\n+    return initialBootstrap\n   }\n+  if (initialBootstrap) return initialBootstrap\n   return authenticate()\n }\n \n@@ -136,7 +166,9 @@ async function reauthenticateAfterUnauthorized() {\n     await authenticate(true)\n   } catch (error) {\n     clearAuthSession()\n-    if (typeof window !== 'undefined') window.dispatchEvent(new Event(AUTH_FAILURE_EVENT))\n+    dispatchAuthEvent(\n+      isUnauthorizedAuthError(error) ? AUTH_EXPIRED_EVENT : AUTH_FAILURE_EVENT,\n+    )\n     throw error\n   }\n }\n@@ -156,10 +188,10 @@ setHttpUnauthorizedHandler(() => {\n   if (authFlowEnabled) return reauthenticateAfterUnauthorized()\n })\n setHttpAuthFailureHandler(() => {\n-  if (authFlowEnabled && typeof window !== 'undefined') {\n+  if (authFlowEnabled) {\n     clearAuthSession()\n-    window.dispatchEvent(new Event(AUTH_FAILURE_EVENT))\n+    dispatchAuthEvent(AUTH_EXPIRED_EVENT)\n   }\n })\n \n-export { AUTH_FAILURE_EVENT }\n+export { AUTH_EXPIRED_EVENT, AUTH_FAILURE_EVENT }\ndiff --git a/src/services/exchange.test.ts b/src/services/exchange.test.ts\nindex 1012c8b..d559d90 100644\n--- a/src/services/exchange.test.ts\n+++ b/src/services/exchange.test.ts\n@@ -7,27 +7,56 @@ vi.mock('./http', () => ({\n   httpClient: { request: mocks.request },\n   createBusinessError: (message: string) => new Error(message),\n }))\n-import {\n-  redeemExchangeProduct, H014_EXCHANGE_REDEEM_PATH,\n-} from './exchange'\n+\n+import { EXCHANGE_REDEEM_PATH, redeemExchangeProduct } from './exchange'\n \n afterEach(() => {\n   mocks.request.mockReset()\n })\n \n-describe('H014 isolated transport contract pending #74', () => {\n-  it('parses a simulated succes",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-ui/dr-card-ui/tests/e2e",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "line",
        null
      ],
      [
        "html",
        {
          "outputFolder": "playwright-report",
          "open": "never"
        }
      ],
      [
        "json",
        {
          "outputFile": "test-results/playwright-results.json"
        }
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.63.0",
    "workers": 2,
    "webServer": null
  },
  "suites": [
    {
      "title": "test-gate.spec.ts",
      "file": "test-gate.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "@test-gate build identity matches the production-like test policy",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 32,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-12T07:19:30.112Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-c3b6e0ce5d8c5d8734e5",
          "file": "test-gate.spec.ts",
          "line": 31,
          "column": 1
        },
        {
          "title": "@test-gate browser entry does not start the app router",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 912,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-12T07:19:30.101Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8ed37c8041402a2a6b34",
          "file": "test-gate.spec.ts",
          "line": 40,
          "column": 1
        },
        {
          "title": "@test-gate / shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 965,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-12T07:19:30.185Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-b030d57053896c93a550",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /profile shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 968,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-12T07:19:35.624Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8605535a2807e17fcccc",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /settings shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 969,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-12T07:19:35.667Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-4a2fdc9884c1725de4f8",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate gated browser entry issues no business API request",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 903,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-12T07:19:36.619Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-09f38a2b8ed735e192d8",
          "file": "test-gate.spec.ts",
          "line": 65,
          "column": 1
        },
        {
          "title": "@test-gate injected native host bypasses the notice and starts the app",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 950,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-12T07:19:36.651Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-12T07:19:29.105Z",
    "duration": 8547.972,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}