{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-web/dr-card-web/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-web/dr-card-web/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-web/dr-card-web/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/sanchuang-dev/dr-card-web/commit/0b224fa1a0bc5ded9f896253bdbd2b4b15d5dc3b",
        "commitHash": "0b224fa1a0bc5ded9f896253bdbd2b4b15d5dc3b",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/255",
        "prTitle": "chore: promote dev to test",
        "prBaseHash": "b1140418230ca462af59a2c44a7851525abfe8ae",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/39578653577"
      },
      "gitCommit": {
        "shortHash": "0b224fa",
        "hash": "0b224fa1a0bc5ded9f896253bdbd2b4b15d5dc3b",
        "subject": "Merge d185462253b5e7ef8e8f9beeb2b041bc78cfaff5 into b1140418230ca462af59a2c44a7851525abfe8ae",
        "body": "Merge d185462253b5e7ef8e8f9beeb2b041bc78cfaff5 into b1140418230ca462af59a2c44a7851525abfe8ae\n",
        "author": {
          "name": "SINGING-AKIBA",
          "email": "980325684@qq.com",
          "time": 1792575567000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1792575567000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/.env.example b/.env.example\nindex 0e39ccb..2c61493 100644\n--- a/.env.example\n+++ b/.env.example\n@@ -23,6 +23,10 @@ VITE_CARD_API_SALT=\n # test/prod 禁止 bridge mock。\n VITE_BRIDGE_MODE=disabled\n \n+# 商城 iframe 目标：kbs_shop（https://kbs-shop.3cgroup.cn/）或 kbs_shop2（https://kbs-shop2.3cgroup.cn/h5）。\n+# 未填写时默认 kbs_shop；非法值会让构建失败 / 商城页显式报错，避免悄悄加载错误商城。\n+# VITE_MALL_TARGET=kbs_shop\n+\n # 通常不要手工设置以下变量；build-h5 会注入当前构建事实。\n # VITE_APP_ENV=dev\n # VITE_BUILD_SHA=local\ndiff --git a/docs/engineering/ai-review.md b/docs/engineering/ai-review.md\nindex 6d82ce8..6e5b5de 100644\n--- a/docs/engineering/ai-review.md\n+++ b/docs/engineering/ai-review.md\n@@ -63,7 +63,7 @@ OpenCode 必须先遵守根目录 `AGENTS.md`，并重点检查：\n - 单纯代码风格意见；\n - 无收益的大规模重构；\n - legacy / Native reference 内部卫生（除非本 PR 修改它或污染 formal H5）；\n-- 商城（当前 deferred，除非 PR 明确施工）；\n+- 商城运行行为（仅当 PR 明确施工时 review；`/mall` 已是 active formal-H5 路由，但非商城行为改动不因此扩大审查范围）；\n - 为覆盖率数字补测试；\n - 重新验收历史 UI；\n - 自行发明 backend / auth / JSBridge 协议。\ndiff --git a/docs/engineering/native-capability-inventory.md b/docs/engineering/native-capability-inventory.md\nindex 21dfebf..14b6432 100644\n--- a/docs/engineering/native-capability-inventory.md\n+++ b/docs/engineering/native-capability-inventory.md\n@@ -33,9 +33,9 @@ window.webkit.messageHandlers.getAuthorizationInfo.postMessage({})\n \n 本次共读取路由注册表 87 条：\n \n-- active formal H5：**39 条**，全部纳入扫描；\n+- active formal H5：**39 条**，全部纳入本次扫描；\n - Native reference：**45 条**，排除；\n-- deferred formal H5：**3 条商城路由**，排除；\n+- deferred formal H5：当时为 **3 条商城路由**，排除；后续 `/mall` 主入口已转为 active，商品详情与购物车仍 deferred。\n - `/__debug/bridge-lab`、`/tokens` 等调试/工程路由不属于产品路由扫描面。\n \n ### 2.1 本次扫描的 39 条 active formal H5\n@@ -86,7 +86,7 @@ window.webkit.messageHandlers.getAuthorizationInfo.postMessage({})\n \n - `/legacy-home*`、`/legacy-service*`、`/legacy-profile*`；\n - 当前 `/device/*`、`/vending/*`、`/signin*` Native reference；\n-- `/mall`、`/mall/goods/:id`、`/mall/cart`：当前商城 deferred；\n+- `/mall`：当前为 active formal-H5 iframe + SSO 容器，但不是本次 Native capability inventory 的扫码/宿主能力征集对象；`/mall/goods/:id`、`/mall/cart` 仍 deferred；\n - 健康管理、数字空间、搭子默契值等原型已明确暂缓内容；\n - Bridge Lab Raw Probe：只用于探测宿主，不自动形成 production capability。\n \n@@ -185,7 +185,7 @@ H028 只征集真实能力，不改变当前页面行为。\n - 消费密码输入、昵称/生日/年级表单：纯 H5 交互。\n - H5 内二维码占位图：展示二维码本身不等于扫码能力。\n - Native reference 页面中出现的扫码、相册、设备等能力：不纳入本 H028 formal H5 征集范围。\n-- 商城：当前 deferred，不借 H028 偷偷转正。\n+- 商城 iframe + SSO 属正式 H5 集成但不属于本次 Native capability inventory 的宿主能力征集范围；本清单不据此推断或确认任何 Native 能力。\n \n ## 6. Native 团队回填模板\n \n@@ -235,7 +235,7 @@ iOS:\n - 以真实源码/原型边界识别 9 类宿主/设备能力需求；\n - 覆盖 Issue #46 指定的关闭 WebView、扫码、拍照、相册选图、保存图片/海报、剪贴板、激励广告、登录凭证、APP 唤起/应用商店边界；\n - 明确区分 Android/iOS 已确认鉴权事实、其余未确认协议、历史 debug 证据和候选实现方式；\n-- 明确排除 Native reference、商城 deferred 与无需宿主能力的 H5 行为；\n+- 明确排除 Native reference、商城 SSO 集成与无需宿主能力的 H5 行为；\n - 给出可直接交给 Native 团队回填的字段模板；\n - 未新增或虚构任何未知 Native 方法名、callback 名或 payload schema。\n \ndiff --git a/docs/engineering/route-ownership.md b/docs/engineering/route-ownership.md\nindex bed9b53..52a0c91 100644\n--- a/docs/engineering/route-ownership.md\n+++ b/docs/engineering/route-ownership.md\n@@ -60,15 +60,14 @@ engineeringScope = deferred\n \n ## 4. 当前 deferred formal-H5 范围\n \n-用户已明确本轮暂不处理商城，因此：\n+`/mall` 主入口已由 D-082/D-084 正式启用为 iframe + SSO 容器，并纳入 active formal-H5 smoke；这不代表商城页面内容、SSO 或 App WebView 已验收。当前仍 deferred 的只有：\n \n-- `/mall`\n - `/mall/goods/:id`\n - `/mall/cart`\n \n-标记为 `formal-h5 + deferred`。\n+以上子路由标记为 `formal-h5 + deferred`。\n \n-现有主导航仍可保留商城入口；`deferred` 只约束当前工程施工、调试和后续自动化枚举，不改产品信息架构。\n+`deferred` 只约束当前工程施工、调试和后续自动化枚举，不改产品信息架构。\n \n ## 5. 可复用 API\n \ndiff --git a/docs/workbench/README.md b/docs/workbench/README.md\nindex 5a1fc16..8aa53e5 100644\n--- a/docs/workbench/README.md\n+++ b/docs/workbench/README.md\n@@ -79,5 +79,5 @@ workbench/\n \n - `tasks/` 只承载当前 App 内嵌 H5 工程任务；\n - `legacy` / Native reference 页面默认不进入正式 H5 施工、重构与业务验收；\n-- 商城当前暂不纳入本轮 H5 编码任务；\n+- `/mall` 当前作为 iframe + SSO 容器纳入 active formal-H5 工程范围；商城页面内容、SSO 与 App WebView 仍待联调验收。`/mall/goods/:id`、`/mall/cart` 子路由仍 deferred；\n - 新任务不得依赖历史 UI 卡的编号状态作为当前产品事实源。\ndiff --git a/docs/workbench/decisions/README.md b/docs/workbench/decisions/README.md\nindex 1701b8c..3192a4e 100644\n--- a/docs/workbench/decisions/README.md\n+++ b/docs/workbench/decisions/README.md\n@@ -91,7 +91,8 @@\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+| D-082 | **2026-09-29 Maintainer 定案商城主入口**：`/mall` 使用 `MallWebView` 在现有 H5 壳层内 iframe 承载商城，覆盖 D-031 对 `/mall` 主入口的占位口径，商品详情/购物车边界继续保留；当时的 `http://www.3-wins.cn/` 为历史联调地址，入口 URL 由 D-084 管理。真实 App WebView 的 iframe policy 与鉴权仍需联调证据 | Implemented / URL superseded by D-084 | [T008-mall-exchange.md](T008-mall-exchange.md)、[MallWebView.tsx](../../../src/pages/MallWebView.tsx) | T008 #17 |\n+| D-084 | **2026-10-21 商城 iframe 入口调整**：`kbs_shop` 使用 `https://kbs-shop.3cgroup.cn/`，`kbs_shop2` 仍使用 `https://kbs-shop2.3cgroup.cn/h5`。依据已有探测记录，`kbs_shop` 的 `/h5` 返回 404、根路径返回 200；可达性不代表页面内容或 SSO 已验收，仍需商城方和 App WebView 联调确认 | Implemented / validation pending；入口调整见 PR #254 | [T008-mall-exchange.md](T008-mall-exchange.md)、[MALL_SHOPS](../../../src/services/sso/mallSso.ts) | T008 #17 |\n \n ## 2. 待确认/阻塞项（施工前必须决策）\n \n@@ -126,7 +127,7 @@\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 取 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-007 | H5 商城承载方式（本地高保真 / WebView / 外链占位） | 2026-08-22 的三路由边界定案 D-031 仍保留历史；**2026-09-29 Maintainer 以 D-082 覆盖 `/mall` 主入口**，改为 iframe 承载商城；2026-10-21 D-084 将 `kbs_shop` 入口调整为根路径，页面内容与 SSO 仍待验收；商品详情/购物车仍保留边界 | [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) |\n | B-011 | 消息详情：是否自动已读、CTA 类型、分类 Tab | D-011：按 `:id` 渲染真实单条正文、进入即已读、CTA 仅活动类 | [T012-notifications.md](T012-notifications.md) |\ndiff --git a/docs/workbench/decisions/T008-mall-exchange.md b/docs/workbench/decisions/T008-mall-exchange.md\nindex 78fedf1..a6049be 100644\n--- a/docs/workbench/decisions/T008-mall-exchange.md\n+++ b/docs/workbench/decisions/T008-mall-exchange.md\n@@ -7,6 +7,7 @@\n > 决策人：智能体按 `AGENTS.md` §11 证据阶梯裁定；B-007 / B-008 由用户当轮指令定案关闭，未新增产品规则\n > 状态：Accepted（B-007 / B-008 已关闭；新增 B-024 / B-025 / B-026 未决，见 §8）\n > 2026-09-29 更新：Maintainer 明确定案 `/mall` 主入口改为在现有 H5 壳层内 iframe 承载 `http://www.3-wins.cn/`，由 D-082 覆盖 D-031 对 `/mall` 主入口的旧边界占位口径；商品详情/购物车边界未随本次改动扩展。\n+> 2026-10-21 更新：PR #254 将 `kbs_shop` 的 iframe 入口从 `/h5` 改为站点根路径 `/`。依据已有联调记录 `/h5` 返回 404、根路径返回 200；这仅说明入口探测结果，不代表商城页面内容或 SSO 已完成验收。`kbs_shop2` 仍使用 `/h5`。PR #254 另为 formal-H5 E2E stub 第三方 iframe 响应，不改变商城运行行为。具体记录见 D-084。\n \n ## 1. 决策目的\n \n@@ -28,16 +29,18 @@ T008 原状态是 `Draft / Blocked`，两个阻塞项挡在施工前：\n | D-035 | **#39 兑换确认为弹层而非页面**：`?overlay=redeem&product=<id>`，弹窗内呈现商品/数量/说明/消耗泡泡值/当前余额；余额不足与售罄时主按钮 `disabled` 且按钮文案换为「泡泡值不足」/「已售罄」，同时在列表卡上以 Tag / 蒙层同步标记 | T008 卡实施要求「兑换确认显示商品、消耗、余额和不足/不可兑换状态」；不可兑换必须在列表与弹窗双处可见，才不是静态文案 | #39 #18 |\n | D-036 | **#40 存入卡包沿用兑换专区作为背景层** + `PromptOverlay` 成功提示，双 CTA：「查看我的卡包」→ `/card`、「关闭」→ `/exchange`；提交中态为固定 700ms，不引入随机 | 原型 §4 成功态是覆盖在专区之上的提示层；固定时长保证截图可复现（与 T009 提交中态同款处理） | #40 |\n | D-037 | 兑换未决业务规则集中在夹具 `EXCHANGE_RULE_STATUS`（`confirmed` / `blocker` / `note`），页面与组件不得硬编码，也不得把夹具当成已确认规则 | T009 D-017 已确立该模式；本轮三条规则（排序方向 / SKU 清单 / 结算写入）均未确认 | #18 #37 #38 #39 #40 |\n-| D-082 | **/mall 主入口改为真实商城 iframe 承载**：`router/index.tsx` 对 `/mall` 使用 `MallWebView`，在现有手机壳层与底部导航内嵌 `http://www.3-wins.cn/`。HTTP scheme 为 2026-09-29 Maintainer 明确指定的当前联调口径，不自动升级或替换；真实 App WebView 的 cleartext/mixed-content、鉴权及目标站 iframe 策略仍需真机验证。D-031 对 `/mall` 主入口的占位口径由本决策覆盖，`/mall/goods/:id`、`/mall/cart` 仍沿用既有边界页，除非另有定案。 | Maintainer 2026-09-29 当轮明确要求；当前实现 `MallWebView.tsx` | #17 |\n+| D-082 | **/mall 主入口改为商城 iframe 承载**：`router/index.tsx` 对 `/mall` 使用 `MallWebView`，在现有手机壳层与底部导航内嵌商城。2026-09-29 Maintainer 当时指定的 `http://www.3-wins.cn/` 是历史联调地址，2026-10-21 起入口配置由 D-084 管理。D-031 对 `/mall` 主入口的占位口径由本决策覆盖，`/mall/goods/:id`、`/mall/cart` 仍沿用既有边界页，除非另有定案。 | Maintainer 2026-09-29 当轮明确要求；当前实现 `MallWebView.tsx` | #17 |\n+| D-084 | **`kbs_shop` 当前 iframe 入口使用站点根路径** `https://kbs-shop.3cgroup.cn/`；`kbs_shop2` 仍使用 `https://kbs-shop2.3cgroup.cn/h5`。本次调整依据已有探测记录：`kbs-shop` 的 `/h5` 返回 404、根路径返回 200。此证据只确认 HTTP 可达性，不确认根路径实际呈现商城或 SSO 成功；需商城部署方确认页面内容，并完成浏览器及 App WebView 联调。 | PR #254 的入口调整及 2026-10-20 联调探测记录；运行时配置 `MALL_SHOPS` | #17 |\n \n ## 3. 决策细节\n \n ### 3.1 商城承载（D-031 → D-082）\n \n - D-031 是 2026-08-22 的历史事实：三条商城路由最初统一由 `WebViewBoundary` 承载占位。\n-- 2026-09-29 D-082 覆盖其中 **`/mall` 主入口**：`router/index.tsx` 直接渲染 `MallWebView`，iframe 地址固定为 Maintainer 指定的 `http://www.3-wins.cn/`，手机壳层与底部导航继续保留。\n+- 2026-09-29 D-082 覆盖其中 **`/mall` 主入口**：`router/index.tsx` 直接渲染 `MallWebView`，在手机壳层与底部导航内承载商城；当时使用的 `http://www.3-wins.cn/` 已是历史联调地址，不再作为当前入口。\n+- 2026-10-21 D-084 更新 `MallWebView` 的目标配置：`kbs_shop` 使用 `https://kbs-shop.3cgroup.cn/`，`kbs_shop2` 使用 `https://kbs-shop2.3cgroup.cn/h5`。这是基于可达性探测的运行时入口调整，不把 HTTP 200 等同于页面内容正确或 SSO 可用；商城页面内容和真实 WebView/SSO 仍待验收。\n - `/mall/goods/:id`、`/mall/cart` 当前仍按 `routes.ts` 的 `boundary: 'webview'` 落到 `WebViewBoundary`，本次没有擅自把两个子路由改成真实商城 URL。\n-- HTTP scheme 是当前联调输入，不在本仓库自动重写；cleartext / mixed-content、X-Frame-Options / CSP frame-ancestors、鉴权透传均属于 App WebView 联调证据，不因浏览器 preview 构建成功而宣称已通过。\n+- 当前商城入口统一由 `MALL_SHOPS` 配置。cleartext / mixed-content、X-Frame-Options / CSP frame-ancestors、鉴权透传均属于 App WebView 联调证据，不因浏览器 preview 构建成功而宣称已通过。\n - D-021 的业务 `DebugPanel` 规则不变：仍仅由 `?debug=1` 显式开启。\n \n ### 3.2 兑换专区视觉与排序（D-032、D-033）\n@@ -83,7 +86,7 @@ T008 原状态是 `Draft / Blocked`，两个阻塞项挡在施工前：\n | #39 兑换确认（余额不足） | `/exchange?overlay=redeem&product=e5` |\n | #39 兑换确认（售罄） | `/exchange?overlay=redeem&product=e4` |\n | #40 存入卡包成功 | `/exchange/result?product=e1` |\n-| #17 卡博士商城（真实商城 iframe，D-082） | `/mall` |\n+| #17 卡博士商城（商城 iframe，D-082 / D-084；目标 URL 见 `MALL_SHOPS`） | `/mall` |\n | #48 商品详情（WebView 边界） | `/mall/goods/1001` |\n | #49 购物车（WebView 边界） | `/mall/cart` |\n \n@@ -111,7 +114,7 @@ T008 原状态是 `Draft / Blocked`，两个阻塞项挡在施工前：\n \n | 编号 | 定案内容 |\n | --- | --- |\n-| `B-007` | **历史定案（2026-08-22）**：三条商城路由最初统一使用 WebView 边界页（D-031）。**2026-09-29 Maintainer 新定案 D-082 已覆盖 `/mall` 主入口**，改为 iframe 承载 `http://www.3-wins.cn/`；商品详情/购物车仍保留原边界。 |\n+| `B-007` | **历史定案（2026-08-22）**：三条商城路由最初统一使用 WebView 边界页（D-031）。**2026-09-29 Maintainer 新定案 D-082 已覆盖 `/mall` 主入口**，改为 iframe 承载商城；2026-10-21 D-084 将 `kbs_shop` 入口调整为 `https://kbs-shop.3cgroup.cn/`（探测到 `/h5` 为 404、根路径为 200，页面内容与 SSO 仍待验收），`kbs_shop2` 仍为 `/h5`；商品详情/购物车仍保留原边界。 |\n | `B-008` | **用户定案（2026-08-22）**：洗护兑换专区在本仓库本地实现（搜索、综合/兑换量/泡泡值排序、商品卡、兑换确认、余额不足、售罄、成功存入卡包）；禁止照搬历史 T11 稿的深绿金 KV 和私有配色，视觉只消费已注册语义 Token。对应 D-032、D-033。 |\n \n ## 8. 未决与风险\n@@ -127,5 +130,5 @@ T008 原状态是 `Draft / Blocked`，两个阻塞项挡在施工前：\n 其他风险：\n \n - 商品缩略图目前复用 `dearseed-kit` 物料，真实商品图未提供；一旦拿到正式素材需重新评估卡面比例。\n-- `/mall` 当前真实联调地址已由 Maintainer 指定为 `http://www.3-wins.cn/`；scheme 不自动替换。真实 App WebView 的 cleartext/mixed-content、目标站 iframe 策略与鉴权透传仍需联调验证。商品详情/购物车仍是边界占位。\n+- `/mall` 当前按 `VITE_MALL_TARGET` 选择 `MALL_SHOPS` 中的商城地址：`kbs_shop` 为 `https://kbs-shop.3cgroup.cn/`，`kbs_shop2` 为 `https://kbs-shop2.3cgroup.cn/h5`。D-084 的根路径变更基于可达性探测，商城页面内容是否正确、SSO、目标站 iframe 策略及 App WebView 仍需联调验证。商品详情/购物车仍是边界占位。\n - #40 成功态未与 T009 卡包夹具联动（受 B-026 约束），若后续产品要求兑换即写入卡包，需要新决策而非按缺陷修复。\ndiff --git a/docs/workbench/evidence/h002-route-ownership.md b/docs/workbench/evidence/h002-route-ownership.md\nindex 12426dd..9785b60 100644\n--- a/docs/workbench/evidence/h002-route-ownership.md\n+++ b/docs/workbench/evidence/h002-route-ownership.md\n@@ -68,3 +68,7 @@ scope registry 会在运行时代码初始化时校验显式归类的 canonical\n ## 当前结论\n \n H002 在 review 修正完成后继续保持 `Agent Review`。是否标记为 `Accepted` 仍由用户确认。\n+\n+## 后续范围更新（2026-10-21）\n+\n+H002 实施时 `/mall`、`/mall/goods/:id`、`/mall/cart` 均为 deferred。后续 `/mall` 主入口由 D-082/D-084 启用为 iframe + SSO 容器，并在 `routeScope.ts` 中转为 active formal-H5；商品详情与购物车继续 deferred。以上记录保留 H002 当时的历史状态，不代表当前路由 scope。\ndiff --git a/docs/workbench/evidence/h022-route-state-convergence.md b/docs/workbench/evidence/h022-route-state-convergence.md\nindex 6b27279..d8e1924 100644\n--- a/docs/workbench/evidence/h022-route-state-convergence.md\n+++ b/docs/workbench/evidence/h022-route-state-convergence.md\n@@ -2,7 +2,7 @@\n \n ## 结论\n \n-本卡盘点范围限定为 **active formal H5**。Native reference / legacy 与 deferred mall 不参与施工。\n+本卡盘点范围限定为 **active formal H5**。Native reference / legacy 与当时 deferred 的商城路由不参与施工。后续 `/mall` 主入口已通过 D-082/D-084 转为 active；商城详情与购物车仍 deferred。本卡记录保留盘点时范围。\n \n 当前正式 H5 已有大量“状态驱动单实现”的正确做法，本卡不为了减少文件数量强行合并具有不同产品语义的步骤页；只对确有“多 URL / 同实现”关系的入口做显式工程收口，并加回归门禁。\n \ndiff --git a/docs/workbench/route-table.md b/docs/workbench/route-table.md\nindex ed04115..f6d883e 100644\n--- a/docs/workbench/route-table.md\n+++ b/docs/workbench/route-table.md\n@@ -10,7 +10,7 @@\n | --- | --- | --- |\n | D1 | 主 Tab 数量与主导航形态 | 当前实现以注册表为准：保留五项底部 Tab：首页 / 泡泡 / 扫码 / 服务 / 我的。旧的“首页 / 卡包 / 兑换 / 我的”四项快照已被替代；`/card` 与 `/exchange` 保留为二级可达路径。 |\n | D2 | 诗得丽专栏入口形态 | **已被 D-072 覆盖**：根路由 `/` 已按需求 §2.1 改为「诗得丽品牌专栏」首页（金刚区删除、打卡内容迁入）；独立路由 `/dearseed` 及其弹层、领取态保留不动。此前 D-054 的「`/` 仅承载卡博士 APP 首页、首页卡片进入 `/dearseed`」为旧口径。 |\n-| D3 | H5/APP 边界 | #17 `/mall` 已由 D-082 覆盖为 `MallWebView` iframe `http://www.3-wins.cn/`；#48/#49 商品详情/购物车继续由 `WebViewBoundary.tsx` 承载边界占位。 |\n+| D3 | H5/APP 边界 | #17 `/mall` 由 D-082 使用 `MallWebView` iframe 承载商城；入口配置见 D-084 / `MALL_SHOPS`（`kbs_shop` 根路径、`kbs_shop2` `/h5`），页面内容与 SSO 仍待联调验收；#48/#49 商品详情/购物车继续由 `WebViewBoundary.tsx` 承载边界占位。 |\n \n ## 2. 一级 Tab（5 项）\n \n@@ -19,7 +19,7 @@\n | `/` | 首页 | Home | — | T005、T021 | 极地种子品牌专栏首页（T021 按需求 §2.1 改造）：搜索栏 + 头像 + Banner + 迁入的打卡内容 + 公益板块 + 品牌故事；两个板块分别进入 `/cause` 与 `/brand-culture` 富文本占位页；`/dearseed?overlay=reminder` 等专栏原路由保留不动 |\n | `/points` | 泡泡 | CircleDot | #5 | T006、T022 | 泡泡值资产卡 + 泡泡福利入口 + 泡泡任务占位卡；流水明细已按 T022 迁至 `/points/detail`；用户于 2026-08-24 明确纠正原 `/checkin` 映射 |\n | `/card/verify` | 扫码 | QrCode（中间凸起） | #67 | T009 | 扫码核销，点击扫描框进入确认核销 |\n-| `/mall` | 服务 | Headset | #17 | T008、T023 | 卡博士 H5 商城：D-082 以 `MallWebView` iframe 承载 `http://www.3-wins.cn/`；T023 按需求 §6 接管一级 Tab「服务」。真实 App WebView 兼容性仍待联调 |\n+| `/mall` | 服务 | Headset | #17 | T008、T023 | 卡博士 H5 商城：D-082 以 `MallWebView` iframe 承载商城，目标地址按 D-084 / `MALL_SHOPS` 配置；页面内容、SSO 与真实 App WebView 兼容性仍待联调。T023 按需求 §6 接管一级 Tab「服务」 |\n | `/profile` | 我的 | UserRound | #19、#20 | T011 | `?overlay=app-prompt` |\n \n ## 3. 二级页路由\n@@ -61,7 +61,7 @@\n | --- | --- | --- | --- | --- |\n | `/exchange` | #18、#37、#38、#39 | 底部 Tab「兑换」 | 底部 Tab | `?state=sort-exchange/sort-points`（默认＝综合）；`?overlay=redeem&product=` |\n | `/exchange/result` | #40 | 兑换确认成功 | 洗护兑换专区 | `?product=` |\n-| `/mall` | #17 | 一级 Tab「服务」；首页头像；诗得丽专栏-「会员空间」/ 会员卡片；我的-「专属权益」；体验券使用弹窗-商品信息区；`/membership` 重定向落点（T023 需求 §6） | 诗得丽专栏首页 | D-082：真实商城 iframe，当前 URL `http://www.3-wins.cn/` |\n+| `/mall` | #17 | 一级 Tab「服务」；首页头像；诗得丽专栏-「会员空间」/ 会员卡片；我的-「专属权益」；体验券使用弹窗-商品信息区；`/membership` 重定向落点（T023 需求 §6） | 诗得丽专栏首页 | D-082 / D-084：商城 iframe，目标地址由 `MALL_SHOPS` 配置；当前 `kbs_shop` 根路径、`kbs_shop2` `/h5`，页面内容与 SSO 待验收 |\n | `/mall/goods/:id` | #48 | H5 商城-商品 | H5 商城 | WebView 边界页 |\n | `/mall/cart` | #49 | H5 商城-购物车 | H5 商城 | WebView 边界页 |\n \ndiff --git a/docs/workbench/tasks/H002-route-ownership-boundary.md b/docs/workbench/tasks/H002-route-ownership-boundary.md\nindex 3cbdf19..9ab9bba 100644\n--- a/docs/workbench/tasks/H002-route-ownership-boundary.md\n+++ b/docs/workbench/tasks/H002-route-ownership-boundary.md\n@@ -29,7 +29,7 @@\n - ownership 明确为 `formal-h5` / `native-reference`；engineering scope 明确为 `active` / `deferred`。\n - 当前登记 45 条 Native reference canonical route，包括不含 `legacy` 的 `/device/*`、`/vending/*`、`/signin` 与 `/signin/detail`。\n - PR #6 的 Codex Review 指出 `/signin`、`/signin/detail` 漏归类；复核 router 后确认两条路由分别直接渲染 `pages/legacy/SignInPage` 与 `pages/legacy/PointsPage`，修正已补入 Native reference。\n-- 商城三条路由保持 formal-H5 ownership，但按当前决定标记为 `deferred`；现有商城 Tab 不因此消失。\n+- 实施时商城三条路由保持 formal-H5 ownership，但按当时决定标记为 `deferred`；现有商城 Tab 不因此消失。当前 `/mall` 已依据后续 D-082/D-084 转为 active，商城详情与购物车仍 deferred。\n - 导出 `ACTIVE_FORMAL_H5_ROUTES`，供后续 CI / E2E / 体验治理统一枚举。\n - `BottomNav` 主入口、`MobileLayout` 主 Tab 判断、`DebugPanel` 已实际消费统一 scope。\n - reference 路由注册表与页面实现未删除、未重写。\ndiff --git a/docs/workbench/tasks/H018-playwright-ci-baseline.md b/docs/workbench/tasks/H018-playwright-ci-baseline.md\nindex 983d057..c2953d6 100644\n--- a/docs/workbench/tasks/H018-playwright-ci-baseline.md\n+++ b/docs/workbench/tasks/H018-playwright-ci-baseline.md\n@@ -15,7 +15,7 @@\n ## 范围\n \n - 将 `verify-t015` 明确降级为历史证据，不再作为长期产品事实源。\n-- 从 formal-H5 路由归属生成/维护当前 smoke 范围，排除 legacy/Native reference 与当前暂缓商城。\n+- 从 formal-H5 路由归属生成/维护当前 smoke 范围，排除 legacy/Native reference 与当前 deferred 的商城子路由。\n - 建立少量高价值业务 E2E，而不是逐页面像素验收。\n - test branch gate 保留 build、无 Mock 泄漏、浏览器回归等真正工程门槛。\n \n@@ -28,7 +28,7 @@\n \n - CI 不再因过期 UI 节点文案决定正式 H5 是否可晋级。\n - formal-H5 关键路由 smoke、console error、坏图/明显横向溢出等基础回归可自动发现。\n-- legacy/商城暂缓范围不会成为业务 gate。\n+- legacy/Native reference 与 deferred 商城子路由不会成为业务 gate。\n \n ## 证据\n \n@@ -38,7 +38,7 @@\n - merge commit：`d4b273895d96fcc5067006b9f9b961da4e02f004`。\n - 原始实现 head：`09d0819ac3e4edbe7a375350d7199382db35ba8f`。\n - GitHub Actions Build #388：success；保留 H017 4 files / 13 tests、H007–H015 retained verify、dev/preview/test/prod 构��与 smoke 链路，并新增 formal-H5 Playwright regression。\n-- formal-H5 Playwright：38 tests passed；范围由 `ACTIVE_FORMAL_H5_ROUTES` 生成，Native reference 与 deferred mall 不进入业务 gate。\n+- formal-H5 Playwright：38 tests passed；这是 H018 基线建立时的历史范围，按当时的 `ACTIVE_FORMAL_H5_ROUTES` 生成，Native reference 与当时 deferred 的商城路由不进入业务 gate。\n - 新 gate 覆盖浏览器 runtime error、可见坏图、明显横向溢出，并含 `会员中心 → 通知 → 浏览器返回` 代表性导航链路。\n - `test` branch gate 已改为 production-like `test + api` bundle 上的 Playwright gate；H008/H015 未解阻部分仍未伪造成真实 API / Native Bridge 验收。\n - 历史 `verify-t015` 保留为 `verify:legacy:t015` 证据入口，不再决定正式 H5 晋级。\n@@ -65,3 +65,9 @@\n - AI Review 最终通过；独立人工 review 无 blocker。\n \n H018 已由用户明确验收为 `Accepted`。\n+\n+### 路由范围后续更新（2026-10-21）\n+\n+- 后续 D-082/D-084 将 `/mall` 主入口启用为 iframe + SSO 容器，并从 deferred 调整为 active formal-H5；当前 route smoke 会覆盖该入口。\n+- `/mall/goods/:id` 与 `/mall/cart` 仍为 deferred，不进入 active formal-H5 smoke。\n+- PR #254 在 formal-H5 E2E 中 stub 商城第三方 iframe 响应，避免 CI 依赖外部商城站点；这只稳定浏览器自动化，不验证商城内容、SSO 或 App WebView。\ndiff --git a/scripts/build-h5.mjs b/scripts/build-h5.mjs\nindex 8e25a15..157d1b1 100644\n--- a/scripts/build-h5.mjs\n+++ b/scripts/build-h5.mjs\n@@ -99,6 +99,14 @@ if (!BRIDGE_MODES.has(bridgeMode)) {\n   errors.push(`Unknown VITE_BRIDGE_MODE=${bridgeMode}. Expected disabled, mock, or native.`)\n }\n \n+const MALL_TARGETS = new Set(['kbs_shop', 'kbs_shop2'])\n+const mallTarget = readEnv('VITE_MALL_TARGET')?.trim() || 'kbs_shop'\n+if (!MALL_TARGETS.has(mallTarget)) {\n+  errors.push(\n+    `Unknown VITE_MALL_TARGET=${mallTarget}. Expected kbs_shop or kbs_shop2.`,\n+  )\n+}\n+\n const prodLike = target.appEnvironment === 'test' || target.appEnvironment === 'prod'\n if (prodLike && dataMode === 'mock') {\n   errors.push(`${target.appEnvironment} builds forbid VITE_DATA_MODE=mock; Mock fallback is not allowed.`)\n@@ -182,6 +190,7 @@ const runtimeEnv = {\n   VITE_API_BASE_URL: apiBaseUrl,\n   VITE_BUDDY_PUBLIC_ORIGIN: publicOrigin,\n   VITE_BRIDGE_MODE: bridgeMode,\n+  VITE_MALL_TARGET: mallTarget,\n   VITE_BUILD_SHA: buildSha,\n   VITE_BUILD_ID: buildId,\n   VITE_SOURCE_BRANCH: sourceBranch,\n@@ -194,6 +203,7 @@ const metadata = {\n   appEnvironment: target.appEnvironment,\n   dataMode,\n   bridgeMode,\n+  mallTarget,\n   apiBaseConfigured: Boolean(apiBaseUrl),\n   buddyPublicOriginConfigured: Boolean(publicOrigin),\n   ...(ossWebPath ? { ossWeb: { target: ossWebTarget, path: `${ossWebPath}/` } } : {}),\ndiff --git a/src/app/router/routeScope.ts b/src/app/router/routeScope.ts\nindex f5f6770..95b2a08 100644\n--- a/src/app/router/routeScope.ts\n+++ b/src/app/router/routeScope.ts\n@@ -72,11 +72,10 @@ const NATIVE_REFERENCE_ROUTE_PATHS = new Set<string>([\n \n /**\n  * Formal H5 routes that remain part of the product route graph but are intentionally outside\n- * the current engineering round. The user explicitly deferred mall work; this metadata keeps\n- * that decision separate from route ownership, so existing product navigation is unchanged.\n+ * the current engineering round. `/mall` is now an active iframe + SSO container; the goods\n+ * detail and cart boundary pages stay deferred until the mall H5 takes them over.\n  */\n const DEFERRED_FORMAL_H5_ROUTE_PATHS = new Set<string>([\n-  '/mall',\n   '/mall/goods/:id',\n   '/mall/cart',\n ])\ndiff --git a/src/pages/MallWebView.test.tsx b/src/pages/MallWebView.test.tsx\nnew file mode 100644\nindex 0000000..a54cc21\n--- /dev/null\n+++ b/src/pages/MallWebView.test.tsx\n@@ -0,0 +1,80 @@\n+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'\n+import { describe, expect, it, vi } from 'vitest'\n+\n+const mocks = vi.hoisted(() => ({\n+  createBridge: vi.fn(),\n+}))\n+\n+vi.mock('../services/sso/mallSso', async (importOriginal) => {\n+  const actual = await importOriginal<typeof import('../services/sso/mallSso')>()\n+  return { ...actual, createMallSsoBridge: mocks.createBridge }\n+})\n+\n+import MallWebView from './MallWebView'\n+import { MALL_SHOPS } from '../services/sso/mallSso'\n+\n+function setup() {\n+  const dispose = vi.fn()\n+  mocks.createBridge.mockReturnValue({ dispose })\n+  const view = render(<MallWebView target=\"kbs_shop\" />)\n+  const iframe = screen.getByTitle('卡博士商城') as HTMLIFrameElement\n+  return { view, iframe, dispose }\n+}\n+\n+describe('MallWebView', () => {\n+  it('loads the configured shop /h5 entry after attaching the SSO bridge', async () => {\n+    const { iframe } = setup()\n+\n+    await waitFor(() => expect(iframe.src).toBe(MALL_SHOPS.kbs_shop.url))\n+    expect(mocks.createBridge).toHaveBeenCalledWith(\n+      iframe,\n+      'kbs_shop',\n+      expect.objectContaining({\n+        onSsoError: expect.any(Function),\n+        onSsoResult: expect.any(Function),\n+      }),\n+    )\n+    expect(screen.getByText('商城加载中…')).toBeTruthy()\n+    expect(screen.getByText('商城加载中…').closest('[data-mall-target]')).toBeTruthy()\n+  })\n+\n+  it('stops showing the loading state once the shop reports load', async () => {\n+    const { iframe } = setup()\n+    await waitFor(() => expect(iframe.src).toBe(MALL_SHOPS.kbs_shop.url))\n+\n+    fireEvent.load(iframe)\n+\n+    await waitFor(() => expect(screen.queryByText('商城加载中…')).toBeNull())\n+  })\n+\n+  it('shows a retry state when the mall reports an SSO failure', async () => {\n+    const { iframe } = setup()\n+    await waitFor(() => expect(iframe.src).toBe(MALL_SHOPS.kbs_shop.url))\n+\n+    const options = mocks.createBridge.mock.calls[0][2] as {\n+      onSsoError: (error: { code: string; message: string }) => void\n+    }\n+    act(() => {\n+      options.onSsoError({ code: 'SSO_TICKET_EXPIRED', message: '登录凭证已失效' })\n+    })\n+\n+    expect(screen.getByText('商城登录未完成，请重试')).toBeTruthy()\n+\n+    fireEvent.click(screen.getByRole('button', { name: '重试' }))\n+\n+    await waitFor(() => expect(mocks.createBridge).toHaveBeenCalledTimes(2))\n+  })\n+\n+  it('uses the second shop origin and /h5 entry when selected', async () => {\n+    mocks.createBridge.mockReturnValue({ dispose: vi.fn() })\n+    render(<MallWebView target=\"kbs_shop2\" />)\n+\n+    const iframe = screen.getByTitle('卡博士商城') as HTMLIFrameElement\n+    await waitFor(() => expect(iframe.src).toBe(MALL_SHOPS.kbs_shop2.url))\n+    expect(mocks.createBridge).toHaveBeenCalledWith(\n+      iframe,\n+      'kbs_shop2',\n+      expect.any(Object),\n+    )\n+  })\n+})\ndiff --git a/src/pages/MallWebView.tsx b/src/pages/MallWebView.tsx\nindex 3147f75..f819683 100644\n--- a/src/pages/MallWebView.tsx\n+++ b/src/pages/MallWebView.tsx\n@@ -1,23 +1,117 @@\n+import { useCallback, useEffect, useState } from 'react'\n+import { runtimePolicy } from '../app/config/runtime'\n+import { Button, LoadingIndicator } from '../components/ui'\n+import {\n+  MALL_SHOPS,\n+  createMallSsoBridge,\n+  resolveMallTarget,\n+  type MallSsoError,\n+  type MallTarget,\n+} from '../services/sso/mallSso'\n+\n+export interface MallWebViewProps {\n+  /** 仅用于测试或明确的业务分支；默认由 VITE_MALL_TARGET 决定。 */\n+  target?: MallTarget\n+}\n+\n /**\n  * 商城 H5 承载页\n  * -------------------------------------------------------------\n- * 底部 Tab「商城」(/mall) 不再做本地原生还原，改为在当前手机界面框架内\n- * 以 iframe 加载商城 H5，使底栏与手机壳层保持可见，\n- * 不跳出 App / H5 外层界面。\n+ * 底部 Tab「商城」(/mall) 以 iframe 加载商城 B，保留外层 H5 壳层与底部导航。\n  *\n- * 2026-10-12：当前地址仅用于效果预览，不代表正式商城接入方案。\n- * test 页面由 HTTPS 承载，商城效果地址使用已验证可达的 HTTPS，避免混合内容被浏览器拦截。\n- * 鉴权透传、第三方站内混合资源及 iframe 策略仍需真实 App WebView 验收。\n+ * 单点登录协议见 `src/services/sso/mallSso.ts`：\n+ * - iframe 不带 src 先挂载，effect 中先建 SSO 监听、再设置 src，避免漏掉 B 的早期\n+ *   `SSO.REQUEST`；\n+ * - A 只把一次性 Ticket 交给 B，不向 iframe 传 A 的 JWT；\n+ * - 商城换域名/路径时只改 `MALL_SHOPS`，本页不写死地址。\n  */\n-const MALL_H5_URL = 'https://scmzhds07.m.demo.webportal.top/?_sc=1'\n+export default function MallWebView({ target }: MallWebViewProps) {\n+  const mallTarget = target ?? resolveMallTarget()\n+  const shop = MALL_SHOPS[mallTarget]\n+\n+  const [iframe, setIframe] = useState<HTMLIFrameElement | null>(null)\n+  const [loaded, setLoaded] = useState(false)\n+  const [problem, setProblem] = useState<string | null>(null)\n+  const [attempt, setAttempt] = useState(0)\n+\n+  useEffect(() => {\n+    if (!iframe) return\n+\n+    setLoaded(false)\n+    setProblem(null)\n+\n+    const bridge = createMallSsoBridge(iframe, mallTarget, {\n+      onSsoError: (error: MallSsoError) => {\n+        console.warn('[mall-sso] mall reported failure', {\n+          target: mallTarget,\n+          code: error.code,\n+        })\n+        setProblem(\n+          runtimePolicy.dataMode === 'mock'\n+            ? '预览模式不执行商城单点登录'\n+            : '商城登录未完成，请重试',\n+        )\n+      },\n+      onSsoResult: (result) => {\n+        if (result.success) setProblem(null)\n+      },\n+    })\n+\n+    // 监听器就绪后再开始导航，避免 B 的首个 SSO.REQUEST 早于监听器。\n+    // StrictMode 下 effect 会执行两次，已导航到目标地址时不要重复加载商城。\n+    if (iframe.src !== shop.url) iframe.src = shop.url\n+\n+    return () => bridge.dispose()\n+  }, [iframe, mallTarget, shop.url])\n+\n+  useEffect(() => {\n+    if (!iframe || loaded || problem) return\n+    const timer = window.setTimeout(() => {\n+      setProblem('商城加载超时，请重试')\n+    }, 20_000)\n+    return () => window.clearTimeout(timer)\n+  }, [iframe, loaded, problem])\n+\n+  const retry = useCallback(() => {\n+    setProblem(null)\n+    setLoaded(false)\n+    setAttempt((value) => value + 1)\n+  }, [])\n \n-export default function MallWebView() {\n   return (\n-    <div className=\"flex h-full min-h-0 w-full flex-col\">\n+    <div className=\"relative flex h-full min-h-0 w-full flex-col\" data-mall-target={mallTarget}>\n+      {problem && (\n+        <div\n+          role=\"status\"\n+          className=\"absolute inset-x-3 top-3 z-20 flex items-center justify-between gap-3 rounded-container border border-border-subtle bg-surface px-3 py-2 shadow-card\"\n+        >\n+          <span className=\"min-w-0 text-sm text-text-secondary\">{problem}</span>\n+          <Button\n+            type=\"button\"\n+            variant=\"outline\"\n+            onClick={retry}\n+            className=\"shrink-0 px-3 text-sm\"\n+          >\n+            重试\n+          </Button>\n+        </div>\n+      )}\n+\n+      {!loaded && (\n+        <div className=\"pointer-events-none absolute inset-0 z-10 grid place-items-center bg-background/80\">\n+          <LoadingIndicator label=\"商城加载中…\" />\n+        </div>\n+      )}\n+\n       <iframe\n-        src={MALL_H5_URL}\n-        title=\"商城 H5\"\n-        className=\"w-full flex-1 border-0\"\n+        key={`${mallTarget}-${attempt}`}\n+        ref={setIframe}\n+        title=\"卡博士商城\"\n+        className=\"h-full w-full flex-1 border-0\"\n+        onLoad={(event) => {\n+          // 初始 about:blank 也会触发 load，只认商城自己的地址。\n+          if (event.currentTarget.src === shop.url) setLoaded(true)\n+        }}\n       />\n     </div>\n   )\ndiff --git a/src/services/sso/mallSso.test.ts b/src/services/sso/mallSso.test.ts\nnew file mode 100644\nindex 0000000..92f693b\n--- /dev/null\n+++ b/src/services/sso/mallSso.test.ts\n@@ -0,0 +1,309 @@\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import { AppError } from '../http'\n+import {\n+  MALL_SHOPS,\n+  SSO_PROTOCOL,\n+  SSO_VERSION,\n+  createMallSsoBridge,\n+  isSsoMessageFresh,\n+  mapSsoTicketErrorCode,\n+  parseSsoMessage,\n+  resolveMallTarget,\n+  type MallSsoBridge,\n+} from './mallSso'\n+\n+const mocks = vi.hoisted(() => ({\n+  request: vi.fn(),\n+}))\n+\n+vi.mock('../http', async (importOriginal) => {\n+  const actual = await importOriginal<typeof import('../http')>()\n+  return { ...actual, httpClient: { request: mocks.request } }\n+})\n+\n+interface FakeIframe {\n+  iframe: HTMLIFrameElement\n+  frame: Window\n+  postMessage: ReturnType<typeof vi.fn>\n+}\n+\n+function createFakeIframe(): FakeIframe {\n+  const postMessage = vi.fn()\n+  const frame = { postMessage } as unknown as Window\n+  return {\n+    iframe: { contentWindow: frame } as unknown as HTMLIFrameElement,\n+    frame,\n+    postMessage,\n+  }\n+}\n+\n+function requestMessage(overrides: Record<string, unknown> = {}, payload: Record<string, unknown> = {}) {\n+  return {\n+    protocol: SSO_PROTOCOL,\n+    version: SSO_VERSION,\n+    type: 'SSO.REQUEST',\n+    requestId: 'sso_1790000000000_ab12cd34',\n+    timestamp: Date.now(),\n+    payload: { target: 'kbs_shop', ...payload },\n+    ...overrides,\n+  }\n+}\n+\n+function dispatch(data: unknown, origin: string, source: MessageEventSource | null) {\n+  window.dispatchEvent(\n+    new MessageEvent('message', {\n+      data,\n+      origin,\n+      source,\n+    }),\n+  )\n+}\n+\n+const ticketEnvelope = {\n+  code: 0,\n+  msg: 'success',\n+  status: 'succ',\n+  data: { ticket: 't'.repeat(40), expiresIn: 30 },\n+}\n+\n+describe('mall SSO 配置', () => {\n+  it('binds each target to the exact shop origin and configured entry', () => {\n+    expect(MALL_SHOPS.kbs_shop).toEqual({\n+      origin: 'https://kbs-shop.3cgroup.cn',\n+      url: 'https://kbs-shop.3cgroup.cn/',\n+    })\n+    expect(MALL_SHOPS.kbs_shop2).toEqual({\n+      origin: 'https://kbs-shop2.3cgroup.cn',\n+      url: 'https://kbs-shop2.3cgroup.cn/h5',\n+    })\n+  })\n+\n+  it('defaults to kbs_shop and rejects unknown build targets', () => {\n+    expect(resolveMallTarget(undefined)).toBe('kbs_shop')\n+    expect(resolveMallTarget('  ')).toBe('kbs_shop')\n+    expect(resolveMallTarget('kbs_shop2')).toBe('kbs_shop2')\n+    expect(() => resolveMallTarget('kbs_shop3')).toThrow('VITE_MALL_TARGET')\n+  })\n+})\n+\n+describe('mall SSO 消息校验', () => {\n+  it('accepts a complete envelope and rejects malformed ones', () => {\n+    const valid = requestMessage()\n+    expect(parseSsoMessage(valid)).toMatchObject({\n+      protocol: SSO_PROTOCOL,\n+      version: SSO_VERSION,\n+      type: 'SSO.REQUEST',\n+      requestId: valid.requestId,\n+    })\n+\n+    expect(parseSsoMessage({ ...valid, protocol: 'OTHER' })).toBeNull()\n+    expect(parseSsoMessage({ ...valid, version: '2.0' })).toBeNull()\n+    expect(parseSsoMessage({ ...valid, requestId: 'short' })).toBeNull()\n+    expect(parseSsoMessage({ ...valid, requestId: 'bad id with spaces' })).toBeNull()\n+    expect(parseSsoMessage({ ...valid, timestamp: 'now' })).toBeNull()\n+    expect(parseSsoMessage({ ...valid, payload: [] })).toBeNull()\n+    expect(parseSsoMessage(null)).toBeNull()\n+  })\n+\n+  it('only accepts recent messages', () => {\n+    const now = 1_790_000_000_000\n+    expect(isSsoMessageFresh(now, now + 60_000)).toBe(true)\n+    expect(isSsoMessageFresh(now, now + 6 * 60_000)).toBe(false)\n+  })\n+\n+  it('maps A API failures to stable SSO error codes', () => {\n+    expect(\n+      mapSsoTicketErrorCode(\n+        new AppError({ kind: 'http', message: 'HTTP 401', status: 401 }),\n+      ),\n+    ).toBe('SSO_UNAUTHORIZED')\n+    expect(\n+      mapSsoTicketErrorCode(\n+        new AppError({ kind: 'http', message: 'HTTP 403', status: 403 }),\n+      ),\n+    ).toBe('SSO_FORBIDDEN')\n+    expect(\n+      mapSsoTicketErrorCode(new AppError({ kind: 'timeout', message: 'timeout' })),\n+    ).toBe('SSO_TIMEOUT')\n+    expect(mapSsoTicketErrorCode(new Error('boom'))).toBe('SSO_INTERNAL_ERROR')\n+  })\n+})\n+\n+describe('createMallSsoBridge', () => {\n+  let bridge: MallSsoBridge | undefined\n+\n+  beforeEach(() => {\n+    mocks.request.mockReset()\n+    mocks.request.mockResolvedValue(ticketEnvelope)\n+  })\n+\n+  afterEach(() => {\n+    bridge?.dispose()\n+    bridge = undefined\n+  })\n+\n+  it('requests a ticket for the configured target and replies to that iframe only', async () => {\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, fake.frame)\n+\n+    await vi.waitFor(() => expect(fake.postMessage).toHaveBeenCalledTimes(1))\n+\n+    expect(mocks.request).toHaveBeenCalledWith({\n+      method: 'POST',\n+      url: '/api/sso/tickets',\n+      data: {\n+        target: 'kbs_shop',\n+        requestId: 'sso_1790000000000_ab12cd34',\n+      },\n+      timeout: 10_000,\n+    })\n+    expect(fake.postMessage).toHaveBeenCalledWith(\n+      expect.objectContaining({\n+        type: 'SSO.TICKET',\n+        requestId: 'sso_1790000000000_ab12cd34',\n+        payload: { ticket: 't'.repeat(40), expiresIn: 30 },\n+      }),\n+      MALL_SHOPS.kbs_shop.origin,\n+    )\n+  })\n+\n+  it('rejects messages from another origin', () => {\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), 'https://kbs-shop2.3cgroup.cn', fake.frame)\n+\n+    expect(mocks.request).not.toHaveBeenCalled()\n+    expect(fake.postMessage).not.toHaveBeenCalled()\n+  })\n+\n+  it('rejects messages from another window even with the right origin', () => {\n+    const fake = createFakeIframe()\n+    const otherFrame = { postMessage: vi.fn() } as unknown as Window\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, otherFrame)\n+\n+    expect(mocks.request).not.toHaveBeenCalled()\n+    expect(fake.postMessage).not.toHaveBeenCalled()\n+  })\n+\n+  it('rejects a forged target from the wrong shop iframe', () => {\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(\n+      requestMessage({}, { target: 'kbs_shop2' }),\n+      MALL_SHOPS.kbs_shop.origin,\n+      fake.frame,\n+    )\n+\n+    expect(mocks.request).not.toHaveBeenCalled()\n+  })\n+\n+  it('processes a requestId only once', async () => {\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, fake.frame)\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, fake.frame)\n+\n+    await vi.waitFor(() => expect(fake.postMessage).toHaveBeenCalledTimes(1))\n+    expect(mocks.request).toHaveBeenCalledTimes(1)\n+  })\n+\n+  it('drops stale requests without asking the backend', () => {\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(\n+      requestMessage({ timestamp: Date.now() - 6 * 60_000 }),\n+      MALL_SHOPS.kbs_shop.origin,\n+      fake.frame,\n+    )\n+\n+    expect(mocks.request).not.toHaveBeenCalled()\n+    expect(fake.postMessage).not.toHaveBeenCalled()\n+  })\n+\n+  it('returns SSO_UNAUTHORIZED when the A session is rejected', async () => {\n+    mocks.request.mockRejectedValue(\n+      new AppError({ kind: 'http', message: 'HTTP 401', status: 401 }),\n+    )\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, fake.frame)\n+\n+    await vi.waitFor(() =>\n+      expect(fake.postMessage).toHaveBeenCalledWith(\n+        expect.objectContaining({\n+          type: 'SSO.ERROR',\n+          payload: expect.objectContaining({ code: 'SSO_UNAUTHORIZED' }),\n+        }),\n+        MALL_SHOPS.kbs_shop.origin,\n+      ),\n+    )\n+  })\n+\n+  it('does not reply after the bridge is disposed', async () => {\n+    let resolveRequest: ((value: unknown) => void) | undefined\n+    mocks.request.mockReturnValue(\n+      new Promise((resolve) => {\n+        resolveRequest = resolve\n+      }),\n+    )\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, fake.frame)\n+    await vi.waitFor(() => expect(mocks.request).toHaveBeenCalledTimes(1))\n+\n+    bridge.dispose()\n+    resolveRequest?.(ticketEnvelope)\n+    await new Promise((resolve) => setTimeout(resolve, 0))\n+\n+    expect(fake.postMessage).not.toHaveBeenCalled()\n+  })\n+\n+  it('surfaces SSO.RESULT and SSO.ERROR callbacks', () => {\n+    const fake = createFakeIframe()\n+    const onSsoResult = vi.fn()\n+    const onSsoError = vi.fn()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop', { onSsoResult, onSsoError })\n+\n+    dispatch(\n+      {\n+        protocol: SSO_PROTOCOL,\n+        version: SSO_VERSION,\n+        type: 'SSO.RESULT',\n+        requestId: 'sso_1790000000000_ab12cd34',\n+        timestamp: Date.now(),\n+        payload: { success: true, code: 'SSO_SUCCESS' },\n+      },\n+      MALL_SHOPS.kbs_shop.origin,\n+      fake.frame,\n+    )\n+    expect(onSsoResult).toHaveBeenCalledWith({ success: true, code: 'SSO_SUCCESS' })\n+\n+    dispatch(\n+      {\n+        protocol: SSO_PROTOCOL,\n+        version: SSO_VERSION,\n+        type: 'SSO.ERROR',\n+        requestId: 'sso_1790000000000_ab12cd34',\n+        timestamp: Date.now(),\n+        payload: { code: 'SSO_TICKET_EXPIRED', message: '登录凭证已失效' },\n+      },\n+      MALL_SHOPS.kbs_shop.origin,\n+      fake.frame,\n+    )\n+    expect(onSsoError).toHaveBeenCalledWith({\n+      code: 'SSO_TICKET_EXPIRED',\n+      message: '登录凭证已失效',\n+    })\n+  })\n+})\ndiff --git a/src/services/sso/mallSso.ts b/src/services/sso/mallSso.ts\nnew file mode 100644\nindex 0000000..9b6d756\n--- /dev/null\n+++ b/src/services/sso/mallSso.ts\n@@ -0,0 +1,293 @@\n+import { z } from 'zod'\n+\n+import { runtimePolicy } from '../../app/config/runtime'\n+import { parseApiEnvelope } from '../contracts/apiEnvelope'\n+import { AppError, httpClient, isAppError } from '../http'\n+\n+/**\n+ * A 站（https://kbs.3cgroup.cn）嵌入商城 B 的 postMessage SSO 客户端。\n+ *\n+ * 协议事实源：C:\\SINGING\\kbs\\docs\\postMessage登录对接文档-kbs商城.md\n+ * - B 发起 SSO.REQUEST，A 申请一次性 Ticket 后回 SSO.TICKET；\n+ * - A 只传 Ticket，不传 A 的 JWT；\n+ * - target 由当前 iframe 的配置决定，绝不采用消息里的 target 作为实际目标。\n+ */\n+\n+export const MALL_TARGETS = ['kbs_shop', 'kbs_shop2'] as const\n+export type MallTarget = (typeof MALL_TARGETS)[number]\n+\n+export interface MallShop {\n+  /** 精确 Origin，不带路径和末尾斜杠。 */\n+  origin: string\n+  /** 商城正式入口；商城换地址时只改这里。 */\n+  url: string\n+}\n+\n+export const MALL_SHOPS: Record<MallTarget, MallShop> = {\n+  kbs_shop: {\n+    origin: 'https://kbs-shop.3cgroup.cn',\n+    url: 'https://kbs-shop.3cgroup.cn/',\n+  },\n+  kbs_shop2: {\n+    origin: 'https://kbs-shop2.3cgroup.cn',\n+    url: 'https://kbs-shop2.3cgroup.cn/h5',\n+  },\n+}\n+\n+export const DEFAULT_MALL_TARGET: MallTarget = 'kbs_shop'\n+\n+export const SSO_PROTOCOL = 'APP_EMBED_SSO'\n+export const SSO_VERSION = '1.0'\n+export const SSO_REQUEST_MAX_AGE_MS = 5 * 60 * 1000\n+\n+const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/\n+const MAX_HANDLED_REQUEST_IDS = 50\n+\n+export interface SsoMessage {\n+  protocol: typeof SSO_PROTOCOL\n+  version: typeof SSO_VERSION\n+  type: string\n+  requestId: string\n+  timestamp: number\n+  payload: Record<string, unknown>\n+}\n+\n+export interface MallSsoResult {\n+  success: boolean\n+  code: string\n+}\n+\n+export interface MallSsoError {\n+  code: string\n+  message: string\n+}\n+\n+export interface MallSsoBridgeOptions {\n+  onSsoResult?: (result: MallSsoResult) => void\n+  onSsoError?: (error: MallSsoError) => void\n+}\n+\n+export interface MallSsoBridge {\n+  dispose: () => void\n+}\n+\n+const ticketSchema = z.object({\n+  ticket: z.string().min(20).max(2048),\n+  expiresIn: z.number().int().positive(),\n+})\n+\n+export type MallSsoTicket = z.infer<typeof ticketSchema>\n+\n+export function isMallTarget(value: unknown): value is MallTarget {\n+  return typeof value === 'string' && (MALL_TARGETS as readonly string[]).includes(value)\n+}\n+\n+/**\n+ * 商城目标来自部署配置，默认 kbs_shop。\n+ * 非法值直接失败，避免生产构建悄悄落到错误商城。\n+ */\n+export function resolveMallTarget(\n+  raw: string | undefined = import.meta.env.VITE_MALL_TARGET,\n+): MallTarget {\n+  const value = raw?.trim()\n+  if (!value) return DEFAULT_MALL_TARGET\n+  if (isMallTarget(value)) return value\n+  throw new Error(\n+    `Unknown VITE_MALL_TARGET=${value}. Expected one of: ${MALL_TARGETS.join(', ')}.`,\n+  )\n+}\n+\n+/** 校验不可信的 postMessage 数据；结构不合法时不回复、不兑换。 */\n+export function parseSsoMessage(input: unknown): SsoMessage | null {\n+  if (!input || typeof input !== 'object' || Array.isArray(input)) return null\n+\n+  const value = input as Record<string, unknown>\n+  if (value.protocol !== SSO_PROTOCOL) return null\n+  if (value.version !== SSO_VERSION) return null\n+  if (typeof value.type !== 'string' || !value.type) return null\n+  if (typeof value.requestId !== 'string' || !REQUEST_ID_PATTERN.test(value.requestId)) return null\n+  if (typeof value.timestamp !== 'number' || !Number.isFinite(value.timestamp)) return null\n+  if (!value.payload || typeof value.payload !== 'object' || Array.isArray(value.payload)) return null\n+\n+  return {\n+    protocol: SSO_PROTOCOL,\n+    version: SSO_VERSION,\n+    type: value.type,\n+    requestId: value.requestId,\n+    timestamp: value.timestamp,\n+    payload: value.payload as Record<string, unknown>,\n+  }\n+}\n+\n+export function isSsoMessageFresh(timestamp: number, now = Date.now()): boolean {\n+  return Math.abs(now - timestamp) <= SSO_REQUEST_MAX_AGE_MS\n+}\n+\n+/** A 后端 /api 错误体沿用项目业务码，这里按 HTTP 状态映射成 SSO 协议错误码。 */\n+export function mapSsoTicketErrorCode(error: unknown): string {\n+  if (!isAppError(error)) return 'SSO_INTERNAL_ERROR'\n+  if (error.status === 401) return 'SSO_UNAUTHORIZED'\n+  if (error.status === 403) return 'SSO_FORBIDDEN'\n+  if (error.kind === 'timeout') return 'SSO_TIMEOUT'\n+  return 'SSO_INTERNAL_ERROR'\n+}\n+\n+function publicErrorMessage(code: string): string {\n+  switch (code) {\n+    case 'SSO_UNAUTHORIZED':\n+      return '主站登录状态不可用，请重新登录'\n+    case 'SSO_FORBIDDEN':\n+      return '当前账号无权访问该商城'\n+    case 'SSO_TIMEOUT':\n+      return '登录凭证申请超时，请重试'\n+    default:\n+      return '商城登录暂不可用，请稍后重试'\n+  }\n+}\n+\n+/**\n+ * 申请一次性 Ticket。\n+ *\n+ * Mock 预览没有 A 的真实登录态，明确失败而不是伪造 Ticket；\n+ * API 模式下由 httpClient 统一附加 Bearer、timesp/token/secstr。\n+ */\n+export async function requestMallTicket(\n+  target: MallTarget,\n+  requestId: string,\n+): Promise<MallSsoTicket> {\n+  if (runtimePolicy.dataMode === 'mock') {\n+    throw new AppError({\n+      kind: 'configuration',\n+      code: 'SSO_MOCK_MODE',\n+      message: 'Mock 预览模式不签发真实商城 Ticket',\n+    })\n+  }\n+\n+  const payload = await httpClient.request<unknown>({\n+    method: 'POST',\n+    url: '/api/sso/tickets',\n+    data: { target, requestId },\n+    timeout: 10_000,\n+  })\n+\n+  return parseApiEnvelope(payload, ticketSchema, {\n+    contract: 'sso.ticket',\n+    fallbackMessage: '商城登录凭证申请失败',\n+  })\n+}\n+\n+function sendToMall(\n+  iframe: HTMLIFrameElement,\n+  target: MallTarget,\n+  type: string,\n+  requestId: string,\n+  payload: Record<string, unknown>,\n+) {\n+  const frame = iframe.contentWindow\n+  if (!frame) return\n+\n+  frame.postMessage(\n+    {\n+      protocol: SSO_PROTOCOL,\n+      version: SSO_VERSION,\n+      type,\n+      requestId,\n+      timestamp: Date.now(),\n+      payload,\n+    },\n+    MALL_SHOPS[target].origin,\n+  )\n+}\n+\n+/**\n+ * 把某个商城 iframe 接入 SSO。\n+ *\n+ * 必须在 iframe 开始加载前调用（页面先挂载 iframe，再在 effect 中建桥并设置 src），\n+ * 否则 B 初始化后立即发出的 SSO.REQUEST 可能丢失。\n+ */\n+export function createMallSsoBridge(\n+  iframe: HTMLIFrameElement,\n+  target: MallTarget,\n+  options: MallSsoBridgeOptions = {},\n+): MallSsoBridge {\n+  const shop = MALL_SHOPS[target]\n+  const handledRequestIds = new Set<string>()\n+  let disposed = false\n+\n+  const rememberRequestId = (requestId: string) => {\n+    handledRequestIds.add(requestId)\n+    if (handledRequestIds.size <= MAX_HANDLED_REQUEST_IDS) return\n+    const oldest = handledRequestIds.values().next().value\n+    if (oldest) handledRequestIds.delete(oldest)\n+  }\n+\n+  const handleRequest = async (message: SsoMessage) => {\n+    // target 只认当前 iframe 的配置；payload.target 仅用于确认双方认知一致。\n+    if (message.payload.target !== target) return\n+    if (handledRequestIds.has(message.requestId)) return\n+    rememberRequestId(message.requestId)\n+\n+    try {\n+      const ticket = await requestMallTicket(target, message.requestId)\n+      if (disposed) return\n+      sendToMall(iframe, target, 'SSO.TICKET', message.requestId, ticket)\n+    } catch (error) {\n+      if (disposed) return\n+      const code = mapSsoTicketErrorCode(error)\n+      console.warn('[mall-sso] ticket request failed', {\n+        target,\n+        requestId: message.requestId,\n+        code,\n+      })\n+      sendToMall(iframe, target, 'SSO.ERROR', message.requestId, {\n+        code,\n+        message: publicErrorMessage(code),\n+      })\n+    }\n+  }\n+\n+  const onMessage = (event: MessageEvent) => {\n+    // 两个商城同时存在时，origin 与 source 必须同时命中当前 iframe。\n+    if (event.origin !== shop.origin) return\n+    const frame = iframe.contentWindow\n+    if (!frame || event.source !== frame) return\n+\n+    const message = parseSsoMessage(event.data)\n+    if (!message) return\n+    if (!isSsoMessageFresh(message.timestamp)) return\n+\n+    if (message.type === 'SSO.REQUEST') {\n+      void handleRequest(message)\n+      return\n+    }\n+\n+    if (message.type === 'SSO.RESULT') {\n+      if (typeof message.payload.success !== 'boolean') return\n+      options.onSsoResult?.({\n+        success: message.payload.success,\n+        code: typeof message.payload.code === 'string' ? message.payload.code : '',\n+      })\n+      return\n+    }\n+\n+    if (message.type === 'SSO.ERROR') {\n+      if (typeof message.payload.code !== 'string') return\n+      options.onSsoError?.({\n+        code: message.payload.code,\n+        message:\n+          typeof message.payload.message === 'string'\n+            ? message.payload.message\n+            : publicErrorMessage(message.payload.code),\n+      })\n+    }\n+  }\n+\n+  window.addEventListener('message', onMessage)\n+\n+  return {\n+    dispose: () => {\n+      disposed = true\n+      window.removeEventListener('message', onMessage)\n+    },\n+  }\n+}\ndiff --git a/src/vite-env.d.ts b/src/vite-env.d.ts\nindex 535c6de..d9cbb1a 100644\n--- a/src/vite-env.d.ts\n+++ b/src/vite-env.d.ts\n@@ -5,6 +5,7 @@ interface ImportMetaEnv {\n   readonly VITE_DATA_MODE?: 'mock' | 'api'\n   readonly VITE_API_BASE_URL?: string\n   readonly VITE_BRIDGE_MODE?: 'disabled' | 'mock' | 'native'\n+  readonly VITE_MALL_TARGET?: 'kbs_shop' | 'kbs_shop2'\n   readonly VITE_BUILD_SHA?: string\n   readonly VITE_BUILD_ID?: string\n   readonly VITE_SOURCE_BRANCH?: string\ndiff --git a/tests/e2e/formal-h5.spec.ts b/tests/e2e/formal-h5.spec.ts\nindex 384f8b5..b2e88a1 100644\n--- a/tests/e2e/formal-h5.spec.ts\n+++ b/tests/e2e/formal-h5.spec.ts\n@@ -15,6 +15,18 @@ function routeUrl(path: string) {\n   return path === '/' ? '/?newcomer=off' : path\n }\n \n+async function stubMallIframes(page: Page) {\n+  for (const origin of ['https://kbs-shop.3cgroup.cn', 'https://kbs-shop2.3cgroup.cn']) {\n+    await page.route(`${origin}/**`, (route) =>\n+      route.fulfill({\n+        status: 200,\n+        contentType: 'text/html',\n+        body: '<!doctype html><html><body>商城 E2E stub</body></html>',\n+      }),\n+    )\n+  }\n+}\n+\n function collectRuntimeErrors(page: Page) {\n   const errors: string[] = []\n   page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`))\n@@ -80,6 +92,7 @@ test.describe('@formal-h5 active route smoke', () => {\n   for (const route of staticFormalRoutes) {\n     test(`${route.path} · ${route.title}`, async ({ page }) => {\n       const runtimeErrors = collectRuntimeErrors(page)\n+      await stubMallIframes(page)\n \n       await page.goto(routeUrl(route.path), { waitUntil: 'domcontentloaded' })\n       await expectHealthyFormalRoute(page)\n@@ -285,6 +298,7 @@ test('@formal-h5 H021 App title bar stays reachable while content scrolls', asyn\n \n test('@formal-h5 H021 first-level tabs use close while child pages use back', async ({ page }) => {\n   const runtimeErrors = collectRuntimeErrors(page)\n+  await stubMallIframes(page)\n \n   for (const route of activeFormalTabRoutes) {\n     await page.goto(routeUrl(route.path), { waitUntil: 'domcontentloaded' })\n@@ -292,8 +306,14 @@ test('@formal-h5 H021 first-level tabs use close while child pages use back', as\n \n     if (route.titleBar === 'hidden') {\n       const close = page.locator('[data-host-close]')\n-      await expect(close).toHaveCount(1)\n-      await expect(close).toHaveAttribute('data-host-close-supported', 'false')\n+      if (route.path === '/mall') {\n+        // The embedded mall intentionally owns its full-screen surface; keep its current UI\n+        // contract instead of imposing the generic hidden-title-bar close-control expectation.\n+        await expect(close).toHaveCount(0)\n+      } else {\n+        await expect(close).toHaveCount(1)\n+        await expect(close).toHaveAttribute('data-host-close-supported', 'false')\n+      }\n       continue\n     }",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-web/dr-card-web/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/0b224fa1a0bc5ded9f896253bdbd2b4b15d5dc3b",
            "commitHash": "0b224fa1a0bc5ded9f896253bdbd2b4b15d5dc3b",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/255",
            "prTitle": "chore: promote dev to test",
            "prBaseHash": "b1140418230ca462af59a2c44a7851525abfe8ae",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/39578653577"
          },
          "gitCommit": {
            "shortHash": "0b224fa",
            "hash": "0b224fa1a0bc5ded9f896253bdbd2b4b15d5dc3b",
            "subject": "Merge d185462253b5e7ef8e8f9beeb2b041bc78cfaff5 into b1140418230ca462af59a2c44a7851525abfe8ae",
            "body": "Merge d185462253b5e7ef8e8f9beeb2b041bc78cfaff5 into b1140418230ca462af59a2c44a7851525abfe8ae\n",
            "author": {
              "name": "SINGING-AKIBA",
              "email": "980325684@qq.com",
              "time": 1792575567000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1792575567000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/.env.example b/.env.example\nindex 0e39ccb..2c61493 100644\n--- a/.env.example\n+++ b/.env.example\n@@ -23,6 +23,10 @@ VITE_CARD_API_SALT=\n # test/prod 禁止 bridge mock。\n VITE_BRIDGE_MODE=disabled\n \n+# 商城 iframe 目标：kbs_shop（https://kbs-shop.3cgroup.cn/）或 kbs_shop2（https://kbs-shop2.3cgroup.cn/h5）。\n+# 未填写时默认 kbs_shop；非法值会让构建失败 / 商城页显式报错，避免悄悄加载错误商城。\n+# VITE_MALL_TARGET=kbs_shop\n+\n # 通常不要手工设置以下变量；build-h5 会注入当前构建事实。\n # VITE_APP_ENV=dev\n # VITE_BUILD_SHA=local\ndiff --git a/docs/engineering/ai-review.md b/docs/engineering/ai-review.md\nindex 6d82ce8..6e5b5de 100644\n--- a/docs/engineering/ai-review.md\n+++ b/docs/engineering/ai-review.md\n@@ -63,7 +63,7 @@ OpenCode 必须先遵守根目录 `AGENTS.md`，并重点检查：\n - 单纯代码风格意见；\n - 无收益的大规模重构；\n - legacy / Native reference 内部卫生（除非本 PR 修改它或污染 formal H5）；\n-- 商城（当前 deferred，除非 PR 明确施工）；\n+- 商城运行行为（仅当 PR 明确施工时 review；`/mall` 已是 active formal-H5 路由，但非商城行为改动不因此扩大审查范围）；\n - 为覆盖率数字补测试；\n - 重新验收历史 UI；\n - 自行发明 backend / auth / JSBridge 协议。\ndiff --git a/docs/engineering/native-capability-inventory.md b/docs/engineering/native-capability-inventory.md\nindex 21dfebf..14b6432 100644\n--- a/docs/engineering/native-capability-inventory.md\n+++ b/docs/engineering/native-capability-inventory.md\n@@ -33,9 +33,9 @@ window.webkit.messageHandlers.getAuthorizationInfo.postMessage({})\n \n 本次共读取路由注册表 87 条：\n \n-- active formal H5：**39 条**，全部纳入扫描；\n+- active formal H5：**39 条**，全部纳入本次扫描；\n - Native reference：**45 条**，排除；\n-- deferred formal H5：**3 条商城路由**，排除；\n+- deferred formal H5：当时为 **3 条商城路由**，排除；后续 `/mall` 主入口已转为 active，商品详情与购物车仍 deferred。\n - `/__debug/bridge-lab`、`/tokens` 等调试/工程路由不属于产品路由扫描面。\n \n ### 2.1 本次扫描的 39 条 active formal H5\n@@ -86,7 +86,7 @@ window.webkit.messageHandlers.getAuthorizationInfo.postMessage({})\n \n - `/legacy-home*`、`/legacy-service*`、`/legacy-profile*`；\n - 当前 `/device/*`、`/vending/*`、`/signin*` Native reference；\n-- `/mall`、`/mall/goods/:id`、`/mall/cart`：当前商城 deferred；\n+- `/mall`：当前为 active formal-H5 iframe + SSO 容器，但不是本次 Native capability inventory 的扫码/宿主能力征集对象；`/mall/goods/:id`、`/mall/cart` 仍 deferred；\n - 健康管理、数字空间、搭子默契值等原型已明确暂缓内容；\n - Bridge Lab Raw Probe：只用于探测宿主，不自动形成 production capability。\n \n@@ -185,7 +185,7 @@ H028 只征集真实能力，不改变当前页面行为。\n - 消费密码输入、昵称/生日/年级表单：纯 H5 交互。\n - H5 内二维码占位图：展示二维码本身不等于扫码能力。\n - Native reference 页面中出现的扫码、相册、设备等能力：不纳入本 H028 formal H5 征集范围。\n-- 商城：当前 deferred，不借 H028 偷偷转正。\n+- 商城 iframe + SSO 属正式 H5 集成但不属于本次 Native capability inventory 的宿主能力征集范围；本清单不据此推断或确认任何 Native 能力。\n \n ## 6. Native 团队回填模板\n \n@@ -235,7 +235,7 @@ iOS:\n - 以真实源码/原型边界识别 9 类宿主/设备能力需求；\n - 覆盖 Issue #46 指定的关闭 WebView、扫码、拍照、相册选图、保存图片/海报、剪贴板、激励广告、登录凭证、APP 唤起/应用商店边界；\n - 明确区分 Android/iOS 已确认鉴权事实、其余未确认协议、历史 debug 证据和候选实现方式；\n-- 明确排除 Native reference、商城 deferred 与无需宿主能力的 H5 行为；\n+- 明确排除 Native reference、商城 SSO 集成与无需宿主能力的 H5 行为；\n - 给出可直接交给 Native 团队回填的字段模板；\n - 未新增或虚构任何未知 Native 方法名、callback 名或 payload schema。\n \ndiff --git a/docs/engineering/route-ownership.md b/docs/engineering/route-ownership.md\nindex bed9b53..52a0c91 100644\n--- a/docs/engineering/route-ownership.md\n+++ b/docs/engineering/route-ownership.md\n@@ -60,15 +60,14 @@ engineeringScope = deferred\n \n ## 4. 当前 deferred formal-H5 范围\n \n-用户已明确本轮暂不处理商城，因此：\n+`/mall` 主入口已由 D-082/D-084 正式启用为 iframe + SSO 容器，并纳入 active formal-H5 smoke；这不代表商城页面内容、SSO 或 App WebView 已验收。当前仍 deferred 的只有：\n \n-- `/mall`\n - `/mall/goods/:id`\n - `/mall/cart`\n \n-标记为 `formal-h5 + deferred`。\n+以上子路由标记为 `formal-h5 + deferred`。\n \n-现有主导航仍可保留商城入口；`deferred` 只约束当前工程施工、调试和后续自动化枚举，不改产品信息架构。\n+`deferred` 只约束当前工程施工、调试和后续自动化枚举，不改产品信息架构。\n \n ## 5. 可复用 API\n \ndiff --git a/docs/workbench/README.md b/docs/workbench/README.md\nindex 5a1fc16..8aa53e5 100644\n--- a/docs/workbench/README.md\n+++ b/docs/workbench/README.md\n@@ -79,5 +79,5 @@ workbench/\n \n - `tasks/` 只承载当前 App 内嵌 H5 工程任务；\n - `legacy` / Native reference 页面默认不进入正式 H5 施工、重构与业务验收；\n-- 商城当前暂不纳入本轮 H5 编码任务；\n+- `/mall` 当前作为 iframe + SSO 容器纳入 active formal-H5 工程范围；商城页面内容、SSO 与 App WebView 仍待联调验收。`/mall/goods/:id`、`/mall/cart` 子路由仍 deferred；\n - 新任务不得依赖历史 UI 卡的编号状态作为当前产品事实源。\ndiff --git a/docs/workbench/decisions/README.md b/docs/workbench/decisions/README.md\nindex 1701b8c..3192a4e 100644\n--- a/docs/workbench/decisions/README.md\n+++ b/docs/workbench/decisions/README.md\n@@ -91,7 +91,8 @@\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+| D-082 | **2026-09-29 Maintainer 定案商城主入口**：`/mall` 使用 `MallWebView` 在现有 H5 壳层内 iframe 承载商城，覆盖 D-031 对 `/mall` 主入口的占位口径，商品详情/购物车边界继续保留；当时的 `http://www.3-wins.cn/` 为历史联调地址，入口 URL 由 D-084 管理。真实 App WebView 的 iframe policy 与鉴权仍需联调证据 | Implemented / URL superseded by D-084 | [T008-mall-exchange.md](T008-mall-exchange.md)、[MallWebView.tsx](../../../src/pages/MallWebView.tsx) | T008 #17 |\n+| D-084 | **2026-10-21 商城 iframe 入口调整**：`kbs_shop` 使用 `https://kbs-shop.3cgroup.cn/`，`kbs_shop2` 仍使用 `https://kbs-shop2.3cgroup.cn/h5`。依据已有探测记录，`kbs_shop` 的 `/h5` 返回 404、根路径返回 200；可达性不代表页面内容或 SSO 已验收，仍需商城方和 App WebView 联调确认 | Implemented / validation pending；入口调整见 PR #254 | [T008-mall-exchange.md](T008-mall-exchange.md)、[MALL_SHOPS](../../../src/services/sso/mallSso.ts) | T008 #17 |\n \n ## 2. 待确认/阻塞项（施工前必须决策）\n \n@@ -126,7 +127,7 @@\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 取 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-007 | H5 商城承载方式（本地高保真 / WebView / 外链占位） | 2026-08-22 的三路由边界定案 D-031 仍保留历史；**2026-09-29 Maintainer 以 D-082 覆盖 `/mall` 主入口**，改为 iframe 承载商城；2026-10-21 D-084 将 `kbs_shop` 入口调整为根路径，页面内容与 SSO 仍待验收；商品详情/购物车仍保留边界 | [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) |\n | B-011 | 消息详情：是否自动已读、CTA 类型、分类 Tab | D-011：按 `:id` 渲染真实单条正文、进入即已读、CTA 仅活动类 | [T012-notifications.md](T012-notifications.md) |\ndiff --git a/docs/workbench/decisions/T008-mall-exchange.md b/docs/workbench/decisions/T008-mall-exchange.md\nindex 78fedf1..a6049be 100644\n--- a/docs/workbench/decisions/T008-mall-exchange.md\n+++ b/docs/workbench/decisions/T008-mall-exchange.md\n@@ -7,6 +7,7 @@\n > 决策人：智能体按 `AGENTS.md` §11 证据阶梯裁定；B-007 / B-008 由用户当轮指令定案关闭，未新增产品规则\n > 状态：Accepted（B-007 / B-008 已关闭；新增 B-024 / B-025 / B-026 未决，见 §8）\n > 2026-09-29 更新：Maintainer 明确定案 `/mall` 主入口改为在现有 H5 壳层内 iframe 承载 `http://www.3-wins.cn/`，由 D-082 覆盖 D-031 对 `/mall` 主入口的旧边界占位口径；商品详情/购物车边界未随本次改动扩展。\n+> 2026-10-21 更新：PR #254 将 `kbs_shop` 的 iframe 入口从 `/h5` 改为站点根路径 `/`。依据已有联调记录 `/h5` 返回 404、根路径返回 200；这仅说明入口探测结果，不代表商城页面内容或 SSO 已完成验收。`kbs_shop2` 仍使用 `/h5`。PR #254 另为 formal-H5 E2E stub 第三方 iframe 响应，不改变商城运行行为。具体记录见 D-084。\n \n ## 1. 决策目的\n \n@@ -28,16 +29,18 @@ T008 原状态是 `Draft / Blocked`，两个阻塞项挡在施工前：\n | D-035 | **#39 兑换确认为弹层而非页面**：`?overlay=redeem&product=<id>`，弹窗内呈现商品/数量/说明/消耗泡泡值/当前余额；余额不足与售罄时主按钮 `disabled` 且按钮文案换为「泡泡值不足」/「已售罄」，同时在列表卡上以 Tag / 蒙层同步标记 | T008 卡实施要求「兑换确认显示商品、消耗、余额和不足/不可兑换状态」；不可兑换必须在列表与弹窗双处可见，才不是静态文案 | #39 #18 |\n | D-036 | **#40 存入卡包沿用兑换专区作为背景层** + `PromptOverlay` 成功提示，双 CTA：「查看我的卡包」→ `/card`、「关闭」→ `/exchange`；提交中态为固定 700ms，不引入随机 | 原型 §4 成功态是覆盖在专区之上的提示层；固定时长保证截图可复现（与 T009 提交中态同款处理） | #40 |\n | D-037 | 兑换未决业务规则集中在夹具 `EXCHANGE_RULE_STATUS`（`confirmed` / `blocker` / `note`），页面与组件不得硬编码，也不得把夹具当成已确认规则 | T009 D-017 已确立该模式；本轮三条规则（排序方向 / SKU 清单 / 结算写入）均未确认 | #18 #37 #38 #39 #40 |\n-| D-082 | **/mall 主入口改为真实商城 iframe 承载**：`router/index.tsx` 对 `/mall` 使用 `MallWebView`，在现有手机壳层与底部导航内嵌 `http://www.3-wins.cn/`。HTTP scheme 为 2026-09-29 Maintainer 明确指定的当前联调口径，不自动升级或替换；真实 App WebView 的 cleartext/mixed-content、鉴权及目标站 iframe 策略仍需真机验证。D-031 对 `/mall` 主入口的占位口径由本决策覆盖，`/mall/goods/:id`、`/mall/cart` 仍沿用既有边界页，除非另有定案。 | Maintainer 2026-09-29 当轮明确要求；当前实现 `MallWebView.tsx` | #17 |\n+| D-082 | **/mall 主入口改为商城 iframe 承载**：`router/index.tsx` 对 `/mall` 使用 `MallWebView`，在现有手机壳层与底部导航内嵌商城。2026-09-29 Maintainer 当时指定的 `http://www.3-wins.cn/` 是历史联调地址，2026-10-21 起入口配置由 D-084 管理。D-031 对 `/mall` 主入口的占位口径由本决策覆盖，`/mall/goods/:id`、`/mall/cart` 仍沿用既有边界页，除非另有定案。 | Maintainer 2026-09-29 当轮明确要求；当前实现 `MallWebView.tsx` | #17 |\n+| D-084 | **`kbs_shop` 当前 iframe 入口使用站点根路径** `https://kbs-shop.3cgroup.cn/`；`kbs_shop2` 仍使用 `https://kbs-shop2.3cgroup.cn/h5`。本次调整依据已有探测记录：`kbs-shop` 的 `/h5` 返回 404、根路径返回 200。此证据只确认 HTTP 可达性，不确认根路径实际呈现商城或 SSO 成功；需商城部署方确认页面内容，并完成浏览器及 App WebView 联调。 | PR #254 的入口调整及 2026-10-20 联调探测记录；运行时配置 `MALL_SHOPS` | #17 |\n \n ## 3. 决策细节\n \n ### 3.1 商城承载（D-031 → D-082）\n \n - D-031 是 2026-08-22 的历史事实：三条商城路由最初统一由 `WebViewBoundary` 承载占位。\n-- 2026-09-29 D-082 覆盖其中 **`/mall` 主入口**：`router/index.tsx` 直接渲染 `MallWebView`，iframe 地址固定为 Maintainer 指定的 `http://www.3-wins.cn/`，手机壳层与底部导航继续保留。\n+- 2026-09-29 D-082 覆盖其中 **`/mall` 主入口**：`router/index.tsx` 直接渲染 `MallWebView`，在手机壳层与底部导航内承载商城；当时使用的 `http://www.3-wins.cn/` 已是历史联调地址，不再作为当前入口。\n+- 2026-10-21 D-084 更新 `MallWebView` 的目标配置：`kbs_shop` 使用 `https://kbs-shop.3cgroup.cn/`，`kbs_shop2` 使用 `https://kbs-shop2.3cgroup.cn/h5`。这是基于可达性探测的运行时入口调整，不把 HTTP 200 等同于页面内容正确或 SSO 可用；商城页面内容和真实 WebView/SSO 仍待验收。\n - `/mall/goods/:id`、`/mall/cart` 当前仍按 `routes.ts` 的 `boundary: 'webview'` 落到 `WebViewBoundary`，本次没有擅自把两个子路由改成真实商城 URL。\n-- HTTP scheme 是当前联调输入，不在本仓库自动重写；cleartext / mixed-content、X-Frame-Options / CSP frame-ancestors、鉴权透传均属于 App WebView 联调证据，不因浏览器 preview 构建成功而宣称已通过。\n+- 当前商城入口统一由 `MALL_SHOPS` 配置。cleartext / mixed-content、X-Frame-Options / CSP frame-ancestors、鉴权透传均属于 App WebView 联调证据，不因浏览器 preview 构建成功而宣称已通过。\n - D-021 的业务 `DebugPanel` 规则不变：仍仅由 `?debug=1` 显式开启。\n \n ### 3.2 兑换专区视觉与排序（D-032、D-033）\n@@ -83,7 +86,7 @@ T008 原状态是 `Draft / Blocked`，两个阻塞项挡在施工前：\n | #39 兑换确认（余额不足） | `/exchange?overlay=redeem&product=e5` |\n | #39 兑换确认（售罄） | `/exchange?overlay=redeem&product=e4` |\n | #40 存入卡包成功 | `/exchange/result?product=e1` |\n-| #17 卡博士商城（真实商城 iframe，D-082） | `/mall` |\n+| #17 卡博士商城（商城 iframe，D-082 / D-084；目标 URL 见 `MALL_SHOPS`） | `/mall` |\n | #48 商品详情（WebView 边界） | `/mall/goods/1001` |\n | #49 购物车（WebView 边界） | `/mall/cart` |\n \n@@ -111,7 +114,7 @@ T008 原状态是 `Draft / Blocked`，两个阻塞项挡在施工前：\n \n | 编号 | 定案内容 |\n | --- | --- |\n-| `B-007` | **历史定案（2026-08-22）**：三条商城路由最初统一使用 WebView 边界页（D-031）。**2026-09-29 Maintainer 新定案 D-082 已覆盖 `/mall` 主入口**，改为 iframe 承载 `http://www.3-wins.cn/`；商品详情/购物车仍保留原边界。 |\n+| `B-007` | **历史定案（2026-08-22）**：三条商城路由最初统一使用 WebView 边界页（D-031）。**2026-09-29 Maintainer 新定案 D-082 已覆盖 `/mall` 主入口**，改为 iframe 承载商城；2026-10-21 D-084 将 `kbs_shop` 入口调整为 `https://kbs-shop.3cgroup.cn/`（探测到 `/h5` 为 404、根路径为 200，页面内容与 SSO 仍待验收），`kbs_shop2` 仍为 `/h5`；商品详情/购物车仍保留原边界。 |\n | `B-008` | **用户定案（2026-08-22）**：洗护兑换专区在本仓库本地实现（搜索、综合/兑换量/泡泡值排序、商品卡、兑换确认、余额不足、售罄、成功存入卡包）；禁止照搬历史 T11 稿的深绿金 KV 和私有配色，视觉只消费已注册语义 Token。对应 D-032、D-033。 |\n \n ## 8. 未决与风险\n@@ -127,5 +130,5 @@ T008 原状态是 `Draft / Blocked`，两个阻塞项挡在施工前：\n 其他风险：\n \n - 商品缩略图目前复用 `dearseed-kit` 物料，真实商品图未提供；一旦拿到正式素材需重新评估卡面比例。\n-- `/mall` 当前真实联调地址已由 Maintainer 指定为 `http://www.3-wins.cn/`；scheme 不自动替换。真实 App WebView 的 cleartext/mixed-content、目标站 iframe 策略与鉴权透传仍需联调验证。商品详情/购物车仍是边界占位。\n+- `/mall` 当前按 `VITE_MALL_TARGET` 选择 `MALL_SHOPS` 中的商城地址：`kbs_shop` 为 `https://kbs-shop.3cgroup.cn/`，`kbs_shop2` 为 `https://kbs-shop2.3cgroup.cn/h5`。D-084 的根路径变更基于可达性探测，商城页面内容是否正确、SSO、目标站 iframe 策略及 App WebView 仍需联调验证。商品详情/购物车仍是边界占位。\n - #40 成功态未与 T009 卡包夹具联动（受 B-026 约束），若后续产品要求兑换即写入卡包，需要新决策而非按缺陷修复。\ndiff --git a/docs/workbench/evidence/h002-route-ownership.md b/docs/workbench/evidence/h002-route-ownership.md\nindex 12426dd..9785b60 100644\n--- a/docs/workbench/evidence/h002-route-ownership.md\n+++ b/docs/workbench/evidence/h002-route-ownership.md\n@@ -68,3 +68,7 @@ scope registry 会在运行时代码初始化时校验显式归类的 canonical\n ## 当前结论\n \n H002 在 review 修正完成后继续保持 `Agent Review`。是否标记为 `Accepted` 仍由用户确认。\n+\n+## 后续范围更新（2026-10-21）\n+\n+H002 实施时 `/mall`、`/mall/goods/:id`、`/mall/cart` 均为 deferred。后续 `/mall` 主入口由 D-082/D-084 启用为 iframe + SSO 容器，并在 `routeScope.ts` 中转为 active formal-H5；商品详情与购物车继续 deferred。以上记录保留 H002 当时的历史状态，不代表当前路由 scope。\ndiff --git a/docs/workbench/evidence/h022-route-state-convergence.md b/docs/workbench/evidence/h022-route-state-convergence.md\nindex 6b27279..d8e1924 100644\n--- a/docs/workbench/evidence/h022-route-state-convergence.md\n+++ b/docs/workbench/evidence/h022-route-state-convergence.md\n@@ -2,7 +2,7 @@\n \n ## 结论\n \n-本卡盘点范围限定为 **active formal H5**。Native reference / legacy 与 deferred mall 不参与施工。\n+本卡盘点范围限定为 **active formal H5**。Native reference / legacy 与当时 deferred 的商城路由不参与施工。后续 `/mall` 主入口已通过 D-082/D-084 转为 active；商城详情与购物车仍 deferred。本卡记录保留盘点时范围。\n \n 当前正式 H5 已有大量“状态驱动单实现”的正确做法，本卡不为了减少文件数量强行合并具有不同产品语义的步骤页；只对确有“多 URL / 同实现”关系的入口做显式工程收口，并加回归门禁。\n \ndiff --git a/docs/workbench/route-table.md b/docs/workbench/route-table.md\nindex ed04115..f6d883e 100644\n--- a/docs/workbench/route-table.md\n+++ b/docs/workbench/route-table.md\n@@ -10,7 +10,7 @@\n | --- | --- | --- |\n | D1 | 主 Tab 数量与主导航形态 | 当前实现以注册表为准：保留五项底部 Tab：首页 / 泡泡 / 扫码 / 服务 / 我的。旧的“首页 / 卡包 / 兑换 / 我的”四项快照已被替代；`/card` 与 `/exchange` 保留为二级可达路径。 |\n | D2 | 诗得丽专栏入口形态 | **已被 D-072 覆盖**：根路由 `/` 已按需求 §2.1 改为「诗得丽品牌专栏」首页（金刚区删除、打卡内容迁入）；独立路由 `/dearseed` 及其弹层、领取态保留不动。此前 D-054 的「`/` 仅承载卡博士 APP 首页、首页卡片进入 `/dearseed`」为旧口径。 |\n-| D3 | H5/APP 边界 | #17 `/mall` 已由 D-082 覆盖为 `MallWebView` iframe `http://www.3-wins.cn/`；#48/#49 商品详情/购物车继续由 `WebViewBoundary.tsx` 承载边界占位。 |\n+| D3 | H5/APP 边界 | #17 `/mall` 由 D-082 使用 `MallWebView` iframe 承载商城；入口配置见 D-084 / `MALL_SHOPS`（`kbs_shop` 根路径、`kbs_shop2` `/h5`），页面内容与 SSO 仍待联调验收；#48/#49 商品详情/购物车继续由 `WebViewBoundary.tsx` 承载边界占位。 |\n \n ## 2. 一级 Tab（5 项）\n \n@@ -19,7 +19,7 @@\n | `/` | 首页 | Home | — | T005、T021 | 极地种子品牌专栏首页（T021 按需求 §2.1 改造）：搜索栏 + 头像 + Banner + 迁入的打卡内容 + 公益板块 + 品牌故事；两个板块分别进入 `/cause` 与 `/brand-culture` 富文本占位页；`/dearseed?overlay=reminder` 等专栏原路由保留不动 |\n | `/points` | 泡泡 | CircleDot | #5 | T006、T022 | 泡泡值资产卡 + 泡泡福利入口 + 泡泡任务占位卡；流水明细已按 T022 迁至 `/points/detail`；用户于 2026-08-24 明确纠正原 `/checkin` 映射 |\n | `/card/verify` | 扫码 | QrCode（中间凸起） | #67 | T009 | 扫码核销，点击扫描框进入确认核销 |\n-| `/mall` | 服务 | Headset | #17 | T008、T023 | 卡博士 H5 商城：D-082 以 `MallWebView` iframe 承载 `http://www.3-wins.cn/`；T023 按需求 §6 接管一级 Tab「服务」。真实 App WebView 兼容性仍待联调 |\n+| `/mall` | 服务 | Headset | #17 | T008、T023 | 卡博士 H5 商城：D-082 以 `MallWebView` iframe 承载商城，目标地址按 D-084 / `MALL_SHOPS` 配置；页面内容、SSO 与真实 App WebView 兼容性仍待联调。T023 按需求 §6 接管一级 Tab「服务」 |\n | `/profile` | 我的 | UserRound | #19、#20 | T011 | `?overlay=app-prompt` |\n \n ## 3. 二级页路由\n@@ -61,7 +61,7 @@\n | --- | --- | --- | --- | --- |\n | `/exchange` | #18、#37、#38、#39 | 底部 Tab「兑换」 | 底部 Tab | `?state=sort-exchange/sort-points`（默认＝综合）；`?overlay=redeem&product=` |\n | `/exchange/result` | #40 | 兑换确认成功 | 洗护兑换专区 | `?product=` |\n-| `/mall` | #17 | 一级 Tab「服务」；首页头像；诗得丽专栏-「会员空间」/ 会员卡片；我的-「专属权益」；体验券使用弹窗-商品信息区；`/membership` 重定向落点（T023 需求 §6） | 诗得丽专栏首页 | D-082：真实商城 iframe，当前 URL `http://www.3-wins.cn/` |\n+| `/mall` | #17 | 一级 Tab「服务」；首页头像；诗得丽专栏-「会员空间」/ 会员卡片；我的-「专属权益」；体验券使用弹窗-商品信息区；`/membership` 重定向落点（T023 需求 §6） | 诗得丽专栏首页 | D-082 / D-084：商城 iframe，目标地址由 `MALL_SHOPS` 配置；当前 `kbs_shop` 根路径、`kbs_shop2` `/h5`，页面内容与 SSO 待验收 |\n | `/mall/goods/:id` | #48 | H5 商城-商品 | H5 商城 | WebView 边界页 |\n | `/mall/cart` | #49 | H5 商城-购物车 | H5 商城 | WebView 边界页 |\n \ndiff --git a/docs/workbench/tasks/H002-route-ownership-boundary.md b/docs/workbench/tasks/H002-route-ownership-boundary.md\nindex 3cbdf19..9ab9bba 100644\n--- a/docs/workbench/tasks/H002-route-ownership-boundary.md\n+++ b/docs/workbench/tasks/H002-route-ownership-boundary.md\n@@ -29,7 +29,7 @@\n - ownership 明确为 `formal-h5` / `native-reference`；engineering scope 明确为 `active` / `deferred`。\n - 当前登记 45 条 Native reference canonical route，包括不含 `legacy` 的 `/device/*`、`/vending/*`、`/signin` 与 `/signin/detail`。\n - PR #6 的 Codex Review 指出 `/signin`、`/signin/detail` 漏归类；复核 router 后确认两条路由分别直接渲染 `pages/legacy/SignInPage` 与 `pages/legacy/PointsPage`，修正已补入 Native reference。\n-- 商城三条路由保持 formal-H5 ownership，但按当前决定标记为 `deferred`；现有商城 Tab 不因此消失。\n+- 实施时商城三条路由保持 formal-H5 ownership，但按当时决定标记为 `deferred`；现有商城 Tab 不因此消失。当前 `/mall` 已依据后续 D-082/D-084 转为 active，商城详情与购物车仍 deferred。\n - 导出 `ACTIVE_FORMAL_H5_ROUTES`，供后续 CI / E2E / 体验治理统一枚举。\n - `BottomNav` 主入口、`MobileLayout` 主 Tab 判断、`DebugPanel` 已实际消费统一 scope。\n - reference 路由注册表与页面实现未删除、未重写。\ndiff --git a/docs/workbench/tasks/H018-playwright-ci-baseline.md b/docs/workbench/tasks/H018-playwright-ci-baseline.md\nindex 983d057..c2953d6 100644\n--- a/docs/workbench/tasks/H018-playwright-ci-baseline.md\n+++ b/docs/workbench/tasks/H018-playwright-ci-baseline.md\n@@ -15,7 +15,7 @@\n ## 范围\n \n - 将 `verify-t015` 明确降级为历史证据，不再作为长期产品事实源。\n-- 从 formal-H5 路由归属生成/维护当前 smoke 范围，排除 legacy/Native reference 与当前暂缓商城。\n+- 从 formal-H5 路由归属生成/维护当前 smoke 范围，排除 legacy/Native reference 与当前 deferred 的商城子路由。\n - 建立少量高价值业务 E2E，而不是逐页面像素验收。\n - test branch gate 保留 build、无 Mock 泄漏、浏览器回归等真正工程门槛。\n \n@@ -28,7 +28,7 @@\n \n - CI 不再因过期 UI 节点文案决定正式 H5 是否可晋级。\n - formal-H5 关键路由 smoke、console error、坏图/明显横向溢出等基础回归可自动发现。\n-- legacy/商城暂缓范围不会成为业务 gate。\n+- legacy/Native reference 与 deferred 商城子路由不会成为业务 gate。\n \n ## 证据\n \n@@ -38,7 +38,7 @@\n - merge commit：`d4b273895d96fcc5067006b9f9b961da4e02f004`。\n - 原始实现 head：`09d0819ac3e4edbe7a375350d7199382db35ba8f`。\n - GitHub Actions Build #388：success；保留 H017 4 files / 13 tests、H007–H015 retained verify、dev/preview/test/prod 构��与 smoke 链路，并新增 formal-H5 Playwright regression。\n-- formal-H5 Playwright：38 tests passed；范围由 `ACTIVE_FORMAL_H5_ROUTES` 生成，Native reference 与 deferred mall 不进入业务 gate。\n+- formal-H5 Playwright：38 tests passed；这是 H018 基线建立时的历史范围，按当时的 `ACTIVE_FORMAL_H5_ROUTES` 生成，Native reference 与当时 deferred 的商城路由不进入业务 gate。\n - 新 gate 覆盖浏览器 runtime error、可见坏图、明显横向溢出，并含 `会员中心 → 通知 → 浏览器返回` 代表性导航链路。\n - `test` branch gate 已改为 production-like `test + api` bundle 上的 Playwright gate；H008/H015 未解阻部分仍未伪造成真实 API / Native Bridge 验收。\n - 历史 `verify-t015` 保留为 `verify:legacy:t015` 证据入口，不再决定正式 H5 晋级。\n@@ -65,3 +65,9 @@\n - AI Review 最终通过；独立人工 review 无 blocker。\n \n H018 已由用户明确验收为 `Accepted`。\n+\n+### 路由范围后续更新（2026-10-21）\n+\n+- 后续 D-082/D-084 将 `/mall` 主入口启用为 iframe + SSO 容器，并从 deferred 调整为 active formal-H5；当前 route smoke 会覆盖该入口。\n+- `/mall/goods/:id` 与 `/mall/cart` 仍为 deferred，不进入 active formal-H5 smoke。\n+- PR #254 在 formal-H5 E2E 中 stub 商城第三方 iframe 响应，避免 CI 依赖外部商城站点；这只稳定浏览器自动化，不验证商城内容、SSO 或 App WebView。\ndiff --git a/scripts/build-h5.mjs b/scripts/build-h5.mjs\nindex 8e25a15..157d1b1 100644\n--- a/scripts/build-h5.mjs\n+++ b/scripts/build-h5.mjs\n@@ -99,6 +99,14 @@ if (!BRIDGE_MODES.has(bridgeMode)) {\n   errors.push(`Unknown VITE_BRIDGE_MODE=${bridgeMode}. Expected disabled, mock, or native.`)\n }\n \n+const MALL_TARGETS = new Set(['kbs_shop', 'kbs_shop2'])\n+const mallTarget = readEnv('VITE_MALL_TARGET')?.trim() || 'kbs_shop'\n+if (!MALL_TARGETS.has(mallTarget)) {\n+  errors.push(\n+    `Unknown VITE_MALL_TARGET=${mallTarget}. Expected kbs_shop or kbs_shop2.`,\n+  )\n+}\n+\n const prodLike = target.appEnvironment === 'test' || target.appEnvironment === 'prod'\n if (prodLike && dataMode === 'mock') {\n   errors.push(`${target.appEnvironment} builds forbid VITE_DATA_MODE=mock; Mock fallback is not allowed.`)\n@@ -182,6 +190,7 @@ const runtimeEnv = {\n   VITE_API_BASE_URL: apiBaseUrl,\n   VITE_BUDDY_PUBLIC_ORIGIN: publicOrigin,\n   VITE_BRIDGE_MODE: bridgeMode,\n+  VITE_MALL_TARGET: mallTarget,\n   VITE_BUILD_SHA: buildSha,\n   VITE_BUILD_ID: buildId,\n   VITE_SOURCE_BRANCH: sourceBranch,\n@@ -194,6 +203,7 @@ const metadata = {\n   appEnvironment: target.appEnvironment,\n   dataMode,\n   bridgeMode,\n+  mallTarget,\n   apiBaseConfigured: Boolean(apiBaseUrl),\n   buddyPublicOriginConfigured: Boolean(publicOrigin),\n   ...(ossWebPath ? { ossWeb: { target: ossWebTarget, path: `${ossWebPath}/` } } : {}),\ndiff --git a/src/app/router/routeScope.ts b/src/app/router/routeScope.ts\nindex f5f6770..95b2a08 100644\n--- a/src/app/router/routeScope.ts\n+++ b/src/app/router/routeScope.ts\n@@ -72,11 +72,10 @@ const NATIVE_REFERENCE_ROUTE_PATHS = new Set<string>([\n \n /**\n  * Formal H5 routes that remain part of the product route graph but are intentionally outside\n- * the current engineering round. The user explicitly deferred mall work; this metadata keeps\n- * that decision separate from route ownership, so existing product navigation is unchanged.\n+ * the current engineering round. `/mall` is now an active iframe + SSO container; the goods\n+ * detail and cart boundary pages stay deferred until the mall H5 takes them over.\n  */\n const DEFERRED_FORMAL_H5_ROUTE_PATHS = new Set<string>([\n-  '/mall',\n   '/mall/goods/:id',\n   '/mall/cart',\n ])\ndiff --git a/src/pages/MallWebView.test.tsx b/src/pages/MallWebView.test.tsx\nnew file mode 100644\nindex 0000000..a54cc21\n--- /dev/null\n+++ b/src/pages/MallWebView.test.tsx\n@@ -0,0 +1,80 @@\n+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'\n+import { describe, expect, it, vi } from 'vitest'\n+\n+const mocks = vi.hoisted(() => ({\n+  createBridge: vi.fn(),\n+}))\n+\n+vi.mock('../services/sso/mallSso', async (importOriginal) => {\n+  const actual = await importOriginal<typeof import('../services/sso/mallSso')>()\n+  return { ...actual, createMallSsoBridge: mocks.createBridge }\n+})\n+\n+import MallWebView from './MallWebView'\n+import { MALL_SHOPS } from '../services/sso/mallSso'\n+\n+function setup() {\n+  const dispose = vi.fn()\n+  mocks.createBridge.mockReturnValue({ dispose })\n+  const view = render(<MallWebView target=\"kbs_shop\" />)\n+  const iframe = screen.getByTitle('卡博士商城') as HTMLIFrameElement\n+  return { view, iframe, dispose }\n+}\n+\n+describe('MallWebView', () => {\n+  it('loads the configured shop /h5 entry after attaching the SSO bridge', async () => {\n+    const { iframe } = setup()\n+\n+    await waitFor(() => expect(iframe.src).toBe(MALL_SHOPS.kbs_shop.url))\n+    expect(mocks.createBridge).toHaveBeenCalledWith(\n+      iframe,\n+      'kbs_shop',\n+      expect.objectContaining({\n+        onSsoError: expect.any(Function),\n+        onSsoResult: expect.any(Function),\n+      }),\n+    )\n+    expect(screen.getByText('商城加载中…')).toBeTruthy()\n+    expect(screen.getByText('商城加载中…').closest('[data-mall-target]')).toBeTruthy()\n+  })\n+\n+  it('stops showing the loading state once the shop reports load', async () => {\n+    const { iframe } = setup()\n+    await waitFor(() => expect(iframe.src).toBe(MALL_SHOPS.kbs_shop.url))\n+\n+    fireEvent.load(iframe)\n+\n+    await waitFor(() => expect(screen.queryByText('商城加载中…')).toBeNull())\n+  })\n+\n+  it('shows a retry state when the mall reports an SSO failure', async () => {\n+    const { iframe } = setup()\n+    await waitFor(() => expect(iframe.src).toBe(MALL_SHOPS.kbs_shop.url))\n+\n+    const options = mocks.createBridge.mock.calls[0][2] as {\n+      onSsoError: (error: { code: string; message: string }) => void\n+    }\n+    act(() => {\n+      options.onSsoError({ code: 'SSO_TICKET_EXPIRED', message: '登录凭证已失效' })\n+    })\n+\n+    expect(screen.getByText('商城登录未完成，请重试')).toBeTruthy()\n+\n+    fireEvent.click(screen.getByRole('button', { name: '重试' }))\n+\n+    await waitFor(() => expect(mocks.createBridge).toHaveBeenCalledTimes(2))\n+  })\n+\n+  it('uses the second shop origin and /h5 entry when selected', async () => {\n+    mocks.createBridge.mockReturnValue({ dispose: vi.fn() })\n+    render(<MallWebView target=\"kbs_shop2\" />)\n+\n+    const iframe = screen.getByTitle('卡博士商城') as HTMLIFrameElement\n+    await waitFor(() => expect(iframe.src).toBe(MALL_SHOPS.kbs_shop2.url))\n+    expect(mocks.createBridge).toHaveBeenCalledWith(\n+      iframe,\n+      'kbs_shop2',\n+      expect.any(Object),\n+    )\n+  })\n+})\ndiff --git a/src/pages/MallWebView.tsx b/src/pages/MallWebView.tsx\nindex 3147f75..f819683 100644\n--- a/src/pages/MallWebView.tsx\n+++ b/src/pages/MallWebView.tsx\n@@ -1,23 +1,117 @@\n+import { useCallback, useEffect, useState } from 'react'\n+import { runtimePolicy } from '../app/config/runtime'\n+import { Button, LoadingIndicator } from '../components/ui'\n+import {\n+  MALL_SHOPS,\n+  createMallSsoBridge,\n+  resolveMallTarget,\n+  type MallSsoError,\n+  type MallTarget,\n+} from '../services/sso/mallSso'\n+\n+export interface MallWebViewProps {\n+  /** 仅用于测试或明确的业务分支；默认由 VITE_MALL_TARGET 决定。 */\n+  target?: MallTarget\n+}\n+\n /**\n  * 商城 H5 承载页\n  * -------------------------------------------------------------\n- * 底部 Tab「商城」(/mall) 不再做本地原生还原，改为在当前手机界面框架内\n- * 以 iframe 加载商城 H5，使底栏与手机壳层保持可见，\n- * 不跳出 App / H5 外层界面。\n+ * 底部 Tab「商城」(/mall) 以 iframe 加载商城 B，保留外层 H5 壳层与底部导航。\n  *\n- * 2026-10-12：当前地址仅用于效果预览，不代表正式商城接入方案。\n- * test 页面由 HTTPS 承载，商城效果地址使用已验证可达的 HTTPS，避免混合内容被浏览器拦截。\n- * 鉴权透传、第三方站内混合资源及 iframe 策略仍需真实 App WebView 验收。\n+ * 单点登录协议见 `src/services/sso/mallSso.ts`：\n+ * - iframe 不带 src 先挂载，effect 中先建 SSO 监听、再设置 src，避免漏掉 B 的早期\n+ *   `SSO.REQUEST`；\n+ * - A 只把一次性 Ticket 交给 B，不向 iframe 传 A 的 JWT；\n+ * - 商城换域名/路径时只改 `MALL_SHOPS`，本页不写死地址。\n  */\n-const MALL_H5_URL = 'https://scmzhds07.m.demo.webportal.top/?_sc=1'\n+export default function MallWebView({ target }: MallWebViewProps) {\n+  const mallTarget = target ?? resolveMallTarget()\n+  const shop = MALL_SHOPS[mallTarget]\n+\n+  const [iframe, setIframe] = useState<HTMLIFrameElement | null>(null)\n+  const [loaded, setLoaded] = useState(false)\n+  const [problem, setProblem] = useState<string | null>(null)\n+  const [attempt, setAttempt] = useState(0)\n+\n+  useEffect(() => {\n+    if (!iframe) return\n+\n+    setLoaded(false)\n+    setProblem(null)\n+\n+    const bridge = createMallSsoBridge(iframe, mallTarget, {\n+      onSsoError: (error: MallSsoError) => {\n+        console.warn('[mall-sso] mall reported failure', {\n+          target: mallTarget,\n+          code: error.code,\n+        })\n+        setProblem(\n+          runtimePolicy.dataMode === 'mock'\n+            ? '预览模式不执行商城单点登录'\n+            : '商城登录未完成，请重试',\n+        )\n+      },\n+      onSsoResult: (result) => {\n+        if (result.success) setProblem(null)\n+      },\n+    })\n+\n+    // 监听器就绪后再开始导航，避免 B 的首个 SSO.REQUEST 早于监听器。\n+    // StrictMode 下 effect 会执行两次，已导航到目标地址时不要重复加载商城。\n+    if (iframe.src !== shop.url) iframe.src = shop.url\n+\n+    return () => bridge.dispose()\n+  }, [iframe, mallTarget, shop.url])\n+\n+  useEffect(() => {\n+    if (!iframe || loaded || problem) return\n+    const timer = window.setTimeout(() => {\n+      setProblem('商城加载超时，请重试')\n+    }, 20_000)\n+    return () => window.clearTimeout(timer)\n+  }, [iframe, loaded, problem])\n+\n+  const retry = useCallback(() => {\n+    setProblem(null)\n+    setLoaded(false)\n+    setAttempt((value) => value + 1)\n+  }, [])\n \n-export default function MallWebView() {\n   return (\n-    <div className=\"flex h-full min-h-0 w-full flex-col\">\n+    <div className=\"relative flex h-full min-h-0 w-full flex-col\" data-mall-target={mallTarget}>\n+      {problem && (\n+        <div\n+          role=\"status\"\n+          className=\"absolute inset-x-3 top-3 z-20 flex items-center justify-between gap-3 rounded-container border border-border-subtle bg-surface px-3 py-2 shadow-card\"\n+        >\n+          <span className=\"min-w-0 text-sm text-text-secondary\">{problem}</span>\n+          <Button\n+            type=\"button\"\n+            variant=\"outline\"\n+            onClick={retry}\n+            className=\"shrink-0 px-3 text-sm\"\n+          >\n+            重试\n+          </Button>\n+        </div>\n+      )}\n+\n+      {!loaded && (\n+        <div className=\"pointer-events-none absolute inset-0 z-10 grid place-items-center bg-background/80\">\n+          <LoadingIndicator label=\"商城加载中…\" />\n+        </div>\n+      )}\n+\n       <iframe\n-        src={MALL_H5_URL}\n-        title=\"商城 H5\"\n-        className=\"w-full flex-1 border-0\"\n+        key={`${mallTarget}-${attempt}`}\n+        ref={setIframe}\n+        title=\"卡博士商城\"\n+        className=\"h-full w-full flex-1 border-0\"\n+        onLoad={(event) => {\n+          // 初始 about:blank 也会触发 load，只认商城自己的地址。\n+          if (event.currentTarget.src === shop.url) setLoaded(true)\n+        }}\n       />\n     </div>\n   )\ndiff --git a/src/services/sso/mallSso.test.ts b/src/services/sso/mallSso.test.ts\nnew file mode 100644\nindex 0000000..92f693b\n--- /dev/null\n+++ b/src/services/sso/mallSso.test.ts\n@@ -0,0 +1,309 @@\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import { AppError } from '../http'\n+import {\n+  MALL_SHOPS,\n+  SSO_PROTOCOL,\n+  SSO_VERSION,\n+  createMallSsoBridge,\n+  isSsoMessageFresh,\n+  mapSsoTicketErrorCode,\n+  parseSsoMessage,\n+  resolveMallTarget,\n+  type MallSsoBridge,\n+} from './mallSso'\n+\n+const mocks = vi.hoisted(() => ({\n+  request: vi.fn(),\n+}))\n+\n+vi.mock('../http', async (importOriginal) => {\n+  const actual = await importOriginal<typeof import('../http')>()\n+  return { ...actual, httpClient: { request: mocks.request } }\n+})\n+\n+interface FakeIframe {\n+  iframe: HTMLIFrameElement\n+  frame: Window\n+  postMessage: ReturnType<typeof vi.fn>\n+}\n+\n+function createFakeIframe(): FakeIframe {\n+  const postMessage = vi.fn()\n+  const frame = { postMessage } as unknown as Window\n+  return {\n+    iframe: { contentWindow: frame } as unknown as HTMLIFrameElement,\n+    frame,\n+    postMessage,\n+  }\n+}\n+\n+function requestMessage(overrides: Record<string, unknown> = {}, payload: Record<string, unknown> = {}) {\n+  return {\n+    protocol: SSO_PROTOCOL,\n+    version: SSO_VERSION,\n+    type: 'SSO.REQUEST',\n+    requestId: 'sso_1790000000000_ab12cd34',\n+    timestamp: Date.now(),\n+    payload: { target: 'kbs_shop', ...payload },\n+    ...overrides,\n+  }\n+}\n+\n+function dispatch(data: unknown, origin: string, source: MessageEventSource | null) {\n+  window.dispatchEvent(\n+    new MessageEvent('message', {\n+      data,\n+      origin,\n+      source,\n+    }),\n+  )\n+}\n+\n+const ticketEnvelope = {\n+  code: 0,\n+  msg: 'success',\n+  status: 'succ',\n+  data: { ticket: 't'.repeat(40), expiresIn: 30 },\n+}\n+\n+describe('mall SSO 配置', () => {\n+  it('binds each target to the exact shop origin and configured entry', () => {\n+    expect(MALL_SHOPS.kbs_shop).toEqual({\n+      origin: 'https://kbs-shop.3cgroup.cn',\n+      url: 'https://kbs-shop.3cgroup.cn/',\n+    })\n+    expect(MALL_SHOPS.kbs_shop2).toEqual({\n+      origin: 'https://kbs-shop2.3cgroup.cn',\n+      url: 'https://kbs-shop2.3cgroup.cn/h5',\n+    })\n+  })\n+\n+  it('defaults to kbs_shop and rejects unknown build targets', () => {\n+    expect(resolveMallTarget(undefined)).toBe('kbs_shop')\n+    expect(resolveMallTarget('  ')).toBe('kbs_shop')\n+    expect(resolveMallTarget('kbs_shop2')).toBe('kbs_shop2')\n+    expect(() => resolveMallTarget('kbs_shop3')).toThrow('VITE_MALL_TARGET')\n+  })\n+})\n+\n+describe('mall SSO 消息校验', () => {\n+  it('accepts a complete envelope and rejects malformed ones', () => {\n+    const valid = requestMessage()\n+    expect(parseSsoMessage(valid)).toMatchObject({\n+      protocol: SSO_PROTOCOL,\n+      version: SSO_VERSION,\n+      type: 'SSO.REQUEST',\n+      requestId: valid.requestId,\n+    })\n+\n+    expect(parseSsoMessage({ ...valid, protocol: 'OTHER' })).toBeNull()\n+    expect(parseSsoMessage({ ...valid, version: '2.0' })).toBeNull()\n+    expect(parseSsoMessage({ ...valid, requestId: 'short' })).toBeNull()\n+    expect(parseSsoMessage({ ...valid, requestId: 'bad id with spaces' })).toBeNull()\n+    expect(parseSsoMessage({ ...valid, timestamp: 'now' })).toBeNull()\n+    expect(parseSsoMessage({ ...valid, payload: [] })).toBeNull()\n+    expect(parseSsoMessage(null)).toBeNull()\n+  })\n+\n+  it('only accepts recent messages', () => {\n+    const now = 1_790_000_000_000\n+    expect(isSsoMessageFresh(now, now + 60_000)).toBe(true)\n+    expect(isSsoMessageFresh(now, now + 6 * 60_000)).toBe(false)\n+  })\n+\n+  it('maps A API failures to stable SSO error codes', () => {\n+    expect(\n+      mapSsoTicketErrorCode(\n+        new AppError({ kind: 'http', message: 'HTTP 401', status: 401 }),\n+      ),\n+    ).toBe('SSO_UNAUTHORIZED')\n+    expect(\n+      mapSsoTicketErrorCode(\n+        new AppError({ kind: 'http', message: 'HTTP 403', status: 403 }),\n+      ),\n+    ).toBe('SSO_FORBIDDEN')\n+    expect(\n+      mapSsoTicketErrorCode(new AppError({ kind: 'timeout', message: 'timeout' })),\n+    ).toBe('SSO_TIMEOUT')\n+    expect(mapSsoTicketErrorCode(new Error('boom'))).toBe('SSO_INTERNAL_ERROR')\n+  })\n+})\n+\n+describe('createMallSsoBridge', () => {\n+  let bridge: MallSsoBridge | undefined\n+\n+  beforeEach(() => {\n+    mocks.request.mockReset()\n+    mocks.request.mockResolvedValue(ticketEnvelope)\n+  })\n+\n+  afterEach(() => {\n+    bridge?.dispose()\n+    bridge = undefined\n+  })\n+\n+  it('requests a ticket for the configured target and replies to that iframe only', async () => {\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, fake.frame)\n+\n+    await vi.waitFor(() => expect(fake.postMessage).toHaveBeenCalledTimes(1))\n+\n+    expect(mocks.request).toHaveBeenCalledWith({\n+      method: 'POST',\n+      url: '/api/sso/tickets',\n+      data: {\n+        target: 'kbs_shop',\n+        requestId: 'sso_1790000000000_ab12cd34',\n+      },\n+      timeout: 10_000,\n+    })\n+    expect(fake.postMessage).toHaveBeenCalledWith(\n+      expect.objectContaining({\n+        type: 'SSO.TICKET',\n+        requestId: 'sso_1790000000000_ab12cd34',\n+        payload: { ticket: 't'.repeat(40), expiresIn: 30 },\n+      }),\n+      MALL_SHOPS.kbs_shop.origin,\n+    )\n+  })\n+\n+  it('rejects messages from another origin', () => {\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), 'https://kbs-shop2.3cgroup.cn', fake.frame)\n+\n+    expect(mocks.request).not.toHaveBeenCalled()\n+    expect(fake.postMessage).not.toHaveBeenCalled()\n+  })\n+\n+  it('rejects messages from another window even with the right origin', () => {\n+    const fake = createFakeIframe()\n+    const otherFrame = { postMessage: vi.fn() } as unknown as Window\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, otherFrame)\n+\n+    expect(mocks.request).not.toHaveBeenCalled()\n+    expect(fake.postMessage).not.toHaveBeenCalled()\n+  })\n+\n+  it('rejects a forged target from the wrong shop iframe', () => {\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(\n+      requestMessage({}, { target: 'kbs_shop2' }),\n+      MALL_SHOPS.kbs_shop.origin,\n+      fake.frame,\n+    )\n+\n+    expect(mocks.request).not.toHaveBeenCalled()\n+  })\n+\n+  it('processes a requestId only once', async () => {\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, fake.frame)\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, fake.frame)\n+\n+    await vi.waitFor(() => expect(fake.postMessage).toHaveBeenCalledTimes(1))\n+    expect(mocks.request).toHaveBeenCalledTimes(1)\n+  })\n+\n+  it('drops stale requests without asking the backend', () => {\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(\n+      requestMessage({ timestamp: Date.now() - 6 * 60_000 }),\n+      MALL_SHOPS.kbs_shop.origin,\n+      fake.frame,\n+    )\n+\n+    expect(mocks.request).not.toHaveBeenCalled()\n+    expect(fake.postMessage).not.toHaveBeenCalled()\n+  })\n+\n+  it('returns SSO_UNAUTHORIZED when the A session is rejected', async () => {\n+    mocks.request.mockRejectedValue(\n+      new AppError({ kind: 'http', message: 'HTTP 401', status: 401 }),\n+    )\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, fake.frame)\n+\n+    await vi.waitFor(() =>\n+      expect(fake.postMessage).toHaveBeenCalledWith(\n+        expect.objectContaining({\n+          type: 'SSO.ERROR',\n+          payload: expect.objectContaining({ code: 'SSO_UNAUTHORIZED' }),\n+        }),\n+        MALL_SHOPS.kbs_shop.origin,\n+      ),\n+    )\n+  })\n+\n+  it('does not reply after the bridge is disposed', async () => {\n+    let resolveRequest: ((value: unknown) => void) | undefined\n+    mocks.request.mockReturnValue(\n+      new Promise((resolve) => {\n+        resolveRequest = resolve\n+      }),\n+    )\n+    const fake = createFakeIframe()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop')\n+\n+    dispatch(requestMessage(), MALL_SHOPS.kbs_shop.origin, fake.frame)\n+    await vi.waitFor(() => expect(mocks.request).toHaveBeenCalledTimes(1))\n+\n+    bridge.dispose()\n+    resolveRequest?.(ticketEnvelope)\n+    await new Promise((resolve) => setTimeout(resolve, 0))\n+\n+    expect(fake.postMessage).not.toHaveBeenCalled()\n+  })\n+\n+  it('surfaces SSO.RESULT and SSO.ERROR callbacks', () => {\n+    const fake = createFakeIframe()\n+    const onSsoResult = vi.fn()\n+    const onSsoError = vi.fn()\n+    bridge = createMallSsoBridge(fake.iframe, 'kbs_shop', { onSsoResult, onSsoError })\n+\n+    dispatch(\n+      {\n+        protocol: SSO_PROTOCOL,\n+        version: SSO_VERSION,\n+        type: 'SSO.RESULT',\n+        requestId: 'sso_1790000000000_ab12cd34',\n+        timestamp: Date.now(),\n+        payload: { success: true, code: 'SSO_SUCCESS' },\n+      },\n+      MALL_SHOPS.kbs_shop.origin,\n+      fake.frame,\n+    )\n+    expect(onSsoResult).toHaveBeenCalledWith({ success: true, code: 'SSO_SUCCESS' })\n+\n+    dispatch(\n+      {\n+        protocol: SSO_PROTOCOL,\n+        version: SSO_VERSION,\n+        type: 'SSO.ERROR',\n+        requestId: 'sso_1790000000000_ab12cd34',\n+        timestamp: Date.now(),\n+        payload: { code: 'SSO_TICKET_EXPIRED', message: '登录凭证已失效' },\n+      },\n+      MALL_SHOPS.kbs_shop.origin,\n+      fake.frame,\n+    )\n+    expect(onSsoError).toHaveBeenCalledWith({\n+      code: 'SSO_TICKET_EXPIRED',\n+      message: '登录凭证已失效',\n+    })\n+  })\n+})\ndiff --git a/src/services/sso/mallSso.ts b/src/services/sso/mallSso.ts\nnew file mode 100644\nindex 0000000..9b6d756\n--- /dev/null\n+++ b/src/services/sso/mallSso.ts\n@@ -0,0 +1,293 @@\n+import { z } from 'zod'\n+\n+import { runtimePolicy } from '../../app/config/runtime'\n+import { parseApiEnvelope } from '../contracts/apiEnvelope'\n+import { AppError, httpClient, isAppError } from '../http'\n+\n+/**\n+ * A 站（https://kbs.3cgroup.cn）嵌入商城 B 的 postMessage SSO 客户端。\n+ *\n+ * 协议事实源：C:\\SINGING\\kbs\\docs\\postMessage登录对接文档-kbs商城.md\n+ * - B 发起 SSO.REQUEST，A 申请一次性 Ticket 后回 SSO.TICKET；\n+ * - A 只传 Ticket，不传 A 的 JWT；\n+ * - target 由当前 iframe 的配置决定，绝不采用消息里的 target 作为实际目标。\n+ */\n+\n+export const MALL_TARGETS = ['kbs_shop', 'kbs_shop2'] as const\n+export type MallTarget = (typeof MALL_TARGETS)[number]\n+\n+export interface MallShop {\n+  /** 精确 Origin，不带路径和末尾斜杠。 */\n+  origin: string\n+  /** 商城正式入口；商城换地址时只改这里。 */\n+  url: string\n+}\n+\n+export const MALL_SHOPS: Record<MallTarget, MallShop> = {\n+  kbs_shop: {\n+    origin: 'https://kbs-shop.3cgroup.cn',\n+    url: 'https://kbs-shop.3cgroup.cn/',\n+  },\n+  kbs_shop2: {\n+    origin: 'https://kbs-shop2.3cgroup.cn',\n+    url: 'https://kbs-shop2.3cgroup.cn/h5',\n+  },\n+}\n+\n+export const DEFAULT_MALL_TARGET: MallTarget = 'kbs_shop'\n+\n+export const SSO_PROTOCOL = 'APP_EMBED_SSO'\n+export const SSO_VERSION = '1.0'\n+export const SSO_REQUEST_MAX_AGE_MS = 5 * 60 * 1000\n+\n+const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/\n+const MAX_HANDLED_REQUEST_IDS = 50\n+\n+export interface SsoMessage {\n+  protocol: typeof SSO_PROTOCOL\n+  version: typeof SSO_VERSION\n+  type: string\n+  requestId: string\n+  timestamp: number\n+  payload: Record<string, unknown>\n+}\n+\n+export interface MallSsoResult {\n+  success: boolean\n+  code: string\n+}\n+\n+export interface MallSsoError {\n+  code: string\n+  message: string\n+}\n+\n+export interface MallSsoBridgeOptions {\n+  onSsoResult?: (result: MallSsoResult) => void\n+  onSsoError?: (error: MallSsoError) => void\n+}\n+\n+export interface MallSsoBridge {\n+  dispose: () => void\n+}\n+\n+const ticketSchema = z.object({\n+  ticket: z.string().min(20).max(2048),\n+  expiresIn: z.number().int().positive(),\n+})\n+\n+export type MallSsoTicket = z.infer<typeof ticketSchema>\n+\n+export function isMallTarget(value: unknown): value is MallTarget {\n+  return typeof value === 'string' && (MALL_TARGETS as readonly string[]).includes(value)\n+}\n+\n+/**\n+ * 商城目标来自部署配置，默认 kbs_shop。\n+ * 非法值直接失败，避免生产构建悄悄落到错误商城。\n+ */\n+export function resolveMallTarget(\n+  raw: string | undefined = import.meta.env.VITE_MALL_TARGET,\n+): MallTarget {\n+  const value = raw?.trim()\n+  if (!value) return DEFAULT_MALL_TARGET\n+  if (isMallTarget(value)) return value\n+  throw new Error(\n+    `Unknown VITE_MALL_TARGET=${value}. Expected one of: ${MALL_TARGETS.join(', ')}.`,\n+  )\n+}\n+\n+/** 校验不可信的 postMessage 数据；结构不合法时不回复、不兑换。 */\n+export function parseSsoMessage(input: unknown): SsoMessage | null {\n+  if (!input || typeof input !== 'object' || Array.isArray(input)) return null\n+\n+  const value = input as Record<string, unknown>\n+  if (value.protocol !== SSO_PROTOCOL) return null\n+  if (value.version !== SSO_VERSION) return null\n+  if (typeof value.type !== 'string' || !value.type) return null\n+  if (typeof value.requestId !== 'string' || !REQUEST_ID_PATTERN.test(value.requestId)) return null\n+  if (typeof value.timestamp !== 'number' || !Number.isFinite(value.timestamp)) return null\n+  if (!value.payload || typeof value.payload !== 'object' || Array.isArray(value.payload)) return null\n+\n+  return {\n+    protocol: SSO_PROTOCOL,\n+    version: SSO_VERSION,\n+    type: value.type,\n+    requestId: value.requestId,\n+    timestamp: value.timestamp,\n+    payload: value.payload as Record<string, unknown>,\n+  }\n+}\n+\n+export function isSsoMessageFresh(timestamp: number, now = Date.now()): boolean {\n+  return Math.abs(now - timestamp) <= SSO_REQUEST_MAX_AGE_MS\n+}\n+\n+/** A 后端 /api 错误体沿用项目业务码，这里按 HTTP 状态映射成 SSO 协议错误码。 */\n+export function mapSsoTicketErrorCode(error: unknown): string {\n+  if (!isAppError(error)) return 'SSO_INTERNAL_ERROR'\n+  if (error.status === 401) return 'SSO_UNAUTHORIZED'\n+  if (error.status === 403) return 'SSO_FORBIDDEN'\n+  if (error.kind === 'timeout') return 'SSO_TIMEOUT'\n+  return 'SSO_INTERNAL_ERROR'\n+}\n+\n+function publicErrorMessage(code: string): string {\n+  switch (code) {\n+    case 'SSO_UNAUTHORIZED':\n+      return '主站登录状态不可用，请重新登录'\n+    case 'SSO_FORBIDDEN':\n+      return '当前账号无权访问该商城'\n+    case 'SSO_TIMEOUT':\n+      return '登录凭证申请超时，请重试'\n+    default:\n+      return '商城登录暂不可用，请稍后重试'\n+  }\n+}\n+\n+/**\n+ * 申请一次性 Ticket。\n+ *\n+ * Mock 预览没有 A 的真实登录态，明确失败而不是伪造 Ticket；\n+ * API 模式下由 httpClient 统一附加 Bearer、timesp/token/secstr。\n+ */\n+export async function requestMallTicket(\n+  target: MallTarget,\n+  requestId: string,\n+): Promise<MallSsoTicket> {\n+  if (runtimePolicy.dataMode === 'mock') {\n+    throw new AppError({\n+      kind: 'configuration',\n+      code: 'SSO_MOCK_MODE',\n+      message: 'Mock 预览模式不签发真实商城 Ticket',\n+    })\n+  }\n+\n+  const payload = await httpClient.request<unknown>({\n+    method: 'POST',\n+    url: '/api/sso/tickets',\n+    data: { target, requestId },\n+    timeout: 10_000,\n+  })\n+\n+  return parseApiEnvelope(payload, ticketSchema, {\n+    contract: 'sso.ticket',\n+    fallbackMessage: '商城登录凭证申请失败',\n+  })\n+}\n+\n+function sendToMall(\n+  iframe: HTMLIFrameElement,\n+  target: MallTarget,\n+  type: string,\n+  requestId: string,\n+  payload: Record<string, unknown>,\n+) {\n+  const frame = iframe.contentWindow\n+  if (!frame) return\n+\n+  frame.postMessage(\n+    {\n+      protocol: SSO_PROTOCOL,\n+      version: SSO_VERSION,\n+      type,\n+      requestId,\n+      timestamp: Date.now(),\n+      payload,\n+    },\n+    MALL_SHOPS[target].origin,\n+  )\n+}\n+\n+/**\n+ * 把某个商城 iframe 接入 SSO。\n+ *\n+ * 必须在 iframe 开始加载前调用（页面先挂载 iframe，再在 effect 中建桥并设置 src），\n+ * 否则 B 初始化后立即发出的 SSO.REQUEST 可能丢失。\n+ */\n+export function createMallSsoBridge(\n+  iframe: HTMLIFrameElement,\n+  target: MallTarget,\n+  options: MallSsoBridgeOptions = {},\n+): MallSsoBridge {\n+  const shop = MALL_SHOPS[target]\n+  const handledRequestIds = new Set<string>()\n+  let disposed = false\n+\n+  const rememberRequestId = (requestId: string) => {\n+    handledRequestIds.add(requestId)\n+    if (handledRequestIds.size <= MAX_HANDLED_REQUEST_IDS) return\n+    const oldest = handledRequestIds.values().next().value\n+    if (oldest) handledRequestIds.delete(oldest)\n+  }\n+\n+  const handleRequest = async (message: SsoMessage) => {\n+    // target 只认当前 iframe 的配置；payload.target 仅用于确认双方认知一致。\n+    if (message.payload.target !== target) return\n+    if (handledRequestIds.has(message.requestId)) return\n+    rememberRequestId(message.requestId)\n+\n+    try {\n+      const ticket = await requestMallTicket(target, message.requestId)\n+      if (disposed) return\n+      sendToMall(iframe, target, 'SSO.TICKET', message.requestId, ticket)\n+    } catch (error) {\n+      if (disposed) return\n+      const code = mapSsoTicketErrorCode(error)\n+      console.warn('[mall-sso] ticket request failed', {\n+        target,\n+        requestId: message.requestId,\n+        code,\n+      })\n+      sendToMall(iframe, target, 'SSO.ERROR', message.requestId, {\n+        code,\n+        message: publicErrorMessage(code),\n+      })\n+    }\n+  }\n+\n+  const onMessage = (event: MessageEvent) => {\n+    // 两个商城同时存在时，origin 与 source 必须同时命中当前 iframe。\n+    if (event.origin !== shop.origin) return\n+    const frame = iframe.contentWindow\n+    if (!frame || event.source !== frame) return\n+\n+    const message = parseSsoMessage(event.data)\n+    if (!message) return\n+    if (!isSsoMessageFresh(message.timestamp)) return\n+\n+    if (message.type === 'SSO.REQUEST') {\n+      void handleRequest(message)\n+      return\n+    }\n+\n+    if (message.type === 'SSO.RESULT') {\n+      if (typeof message.payload.success !== 'boolean') return\n+      options.onSsoResult?.({\n+        success: message.payload.success,\n+        code: typeof message.payload.code === 'string' ? message.payload.code : '',\n+      })\n+      return\n+    }\n+\n+    if (message.type === 'SSO.ERROR') {\n+      if (typeof message.payload.code !== 'string') return\n+      options.onSsoError?.({\n+        code: message.payload.code,\n+        message:\n+          typeof message.payload.message === 'string'\n+            ? message.payload.message\n+            : publicErrorMessage(message.payload.code),\n+      })\n+    }\n+  }\n+\n+  window.addEventListener('message', onMessage)\n+\n+  return {\n+    dispose: () => {\n+      disposed = true\n+      window.removeEventListener('message', onMessage)\n+    },\n+  }\n+}\ndiff --git a/src/vite-env.d.ts b/src/vite-env.d.ts\nindex 535c6de..d9cbb1a 100644\n--- a/src/vite-env.d.ts\n+++ b/src/vite-env.d.ts\n@@ -5,6 +5,7 @@ interface ImportMetaEnv {\n   readonly VITE_DATA_MODE?: 'mock' | 'api'\n   readonly VITE_API_BASE_URL?: string\n   readonly VITE_BRIDGE_MODE?: 'disabled' | 'mock' | 'native'\n+  readonly VITE_MALL_TARGET?: 'kbs_shop' | 'kbs_shop2'\n   readonly VITE_BUILD_SHA?: string\n   readonly VITE_BUILD_ID?: string\n   readonly VITE_SOURCE_BRANCH?: string\ndiff --git a/tests/e2e/formal-h5.spec.ts b/tests/e2e/formal-h5.spec.ts\nindex 384f8b5..b2e88a1 100644\n--- a/tests/e2e/formal-h5.spec.ts\n+++ b/tests/e2e/formal-h5.spec.ts\n@@ -15,6 +15,18 @@ function routeUrl(path: string) {\n   return path === '/' ? '/?newcomer=off' : path\n }\n \n+async function stubMallIframes(page: Page) {\n+  for (const origin of ['https://kbs-shop.3cgroup.cn', 'https://kbs-shop2.3cgroup.cn']) {\n+    await page.route(`${origin}/**`, (route) =>\n+      route.fulfill({\n+        status: 200,\n+        contentType: 'text/html',\n+        body: '<!doctype html><html><body>商城 E2E stub</body></html>',\n+      }),\n+    )\n+  }\n+}\n+\n function collectRuntimeErrors(page: Page) {\n   const errors: string[] = []\n   page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`))\n@@ -80,6 +92,7 @@ test.describe('@formal-h5 active route smoke', () => {\n   for (const route of staticFormalRoutes) {\n     test(`${route.path} · ${route.title}`, async ({ page }) => {\n       const runtimeErrors = collectRuntimeErrors(page)\n+      await stubMallIframes(page)\n \n       await page.goto(routeUrl(route.path), { waitUntil: 'domcontentloaded' })\n       await expectHealthyFormalRoute(page)\n@@ -285,6 +298,7 @@ test('@formal-h5 H021 App title bar stays reachable while content scrolls', asyn\n \n test('@formal-h5 H021 first-level tabs use close while child pages use back', async ({ page }) => {\n   const runtimeErrors = collectRuntimeErrors(page)\n+  await stubMallIframes(page)\n \n   for (const route of activeFormalTabRoutes) {\n     await page.goto(routeUrl(route.path), { waitUntil: 'domcontentloaded' })\n@@ -292,8 +306,14 @@ test('@formal-h5 H021 first-level tabs use close while child pages use back', as\n \n     if (route.titleBar === 'hidden') {\n       const close = page.locator('[data-host-close]')\n-      await expect(close).toHaveCount(1)\n-      await expect(close).toHaveAttribute('data-host-close-supported', 'false')\n+      if (route.path === '/mall') {\n+        // The embedded mall intentionally owns its full-screen surface; keep its current UI\n+        // contract instead of imposing the generic hidden-title-bar close-control expectation.\n+        await expect(close).toHaveCount(0)\n+      } else {\n+        await expect(close).toHaveCount(1)\n+        await expect(close).toHaveAttribute('data-host-close-supported', 'false')\n+      }\n       continue\n     }",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-web/dr-card-web/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": 60,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-21T09:40:35.436Z",
                  "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": 1327,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-21T09:40:35.462Z",
                  "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": 1408,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-21T09:40:35.568Z",
                  "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": 1073,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-21T09:40:37.189Z",
                  "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": 1043,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-21T09:40:37.255Z",
                  "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": 1153,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-21T09:40:38.280Z",
                  "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": 1174,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-21T09:40:38.322Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-21T09:40:34.498Z",
    "duration": 5063.056,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}