{
  "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/14e184bbd9e9637fa511fd68f7f8ec8d84dcc9e7",
        "commitHash": "14e184bbd9e9637fa511fd68f7f8ec8d84dcc9e7",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/299",
        "prTitle": "promote(dev→test): H045 共用扫码分流与 Android 回调适配",
        "prBaseHash": "3126078bbecd21def20f305f812c28f3c1bda7c5",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/42602034645"
      },
      "gitCommit": {
        "shortHash": "14e184b",
        "hash": "14e184bbd9e9637fa511fd68f7f8ec8d84dcc9e7",
        "subject": "Merge d0683ab0a1fe47f376dc16dd49f1ced829146eb1 into 3126078bbecd21def20f305f812c28f3c1bda7c5",
        "body": "Merge d0683ab0a1fe47f376dc16dd49f1ced829146eb1 into 3126078bbecd21def20f305f812c28f3c1bda7c5\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1794220568000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1794220568000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/docs/workbench/evidence/h037-native-bridge-transaction-alignment.md b/docs/workbench/evidence/h037-native-bridge-transaction-alignment.md\nindex d4cf33b..0473d4f 100644\n--- a/docs/workbench/evidence/h037-native-bridge-transaction-alignment.md\n+++ b/docs/workbench/evidence/h037-native-bridge-transaction-alignment.md\n@@ -1,5 +1,8 @@\n # H037｜真实 Native 事务回调对齐设计证据\n \n+> **历史合同说明（2026-11-09）**：本文记录的是 Android `upstream/gitee/master@a8ac84694d2f69e0c1bfa10619fdc3810845a337` 的旧两阶段扫码事务证据。该证据仍保留为历史事实，但不再代表 Android `feat/1109@b595a72b28d38ef033117ebaa91ff5919a4ef763` 的当前 H5 callback 合同。新分支对非设备码一次回调并显式提供 `data.isDevice=false`；设备 11–14 的启动回调带 `consumeUse`，Native 详情页继续负责后续结算；设备 17 的回调只带 `text/scanType`，不带 `isDevice` 或 `consumeUse`。H045（[#295](https://github.com/sanchuang-dev/dr-card-web/issues/295)）据此收口 H5：只对显式 `isDevice=false` 分流，Native 设备结果不进入 H5 业务。**这不构成 iOS 已实现扫码能力的证据。**\n+\n+\n > 本文件是 H037 的设计依据，不是实现结果。  \n > H037 的代码施工、测试与验收必须能回溯到这里记录的已确认事实；未在本文件确认的 Native 内部行为不得由 H5 自行推断。\n \ndiff --git a/docs/workbench/tasks/H037-native-bridge-transaction-alignment.md b/docs/workbench/tasks/H037-native-bridge-transaction-alignment.md\nindex 0766639..abe1db0 100644\n--- a/docs/workbench/tasks/H037-native-bridge-transaction-alignment.md\n+++ b/docs/workbench/tasks/H037-native-bridge-transaction-alignment.md\n@@ -1,5 +1,8 @@\n # H037｜真实 Native 事务回调对齐\n \n+> **历史合同说明（2026-11-09）**：本文记录的是 Android `upstream/gitee/master@a8ac84694d2f69e0c1bfa10619fdc3810845a337` 的旧两阶段扫码事务证据。该证据仍保留为历史事实，但不再代表 Android `feat/1109@b595a72b28d38ef033117ebaa91ff5919a4ef763` 的当前 H5 callback 合同。新分支对非设备码一次回调并显式提供 `data.isDevice=false`；设备 11–14 的启动回调带 `consumeUse`，Native 详情页继续负责后续结算；设备 17 的回调只带 `text/scanType`，不带 `isDevice` 或 `consumeUse`。H045（[#295](https://github.com/sanchuang-dev/dr-card-web/issues/295)）据此收口 H5：只对显式 `isDevice=false` 分流，Native 设备结果不进入 H5 业务。**这不构成 iOS 已实现扫码能力的证据。**\n+\n+\n **Status:** In Progress  \n **Phase:** Host Integration / Contract Alignment  \n **Depends on:** H029, H031, H033, H036\ndiff --git a/src/components/mobile/BottomNav.test.tsx b/src/components/mobile/BottomNav.test.tsx\nindex 1cbed4d..7c97263 100644\n--- a/src/components/mobile/BottomNav.test.tsx\n+++ b/src/components/mobile/BottomNav.test.tsx\n@@ -1,30 +1,73 @@\n import { afterEach, describe, expect, it, vi } from 'vitest'\n import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'\n-import { MemoryRouter } from 'react-router-dom'\n+import { MemoryRouter, useLocation } from 'react-router-dom'\n+\n+type ScanResult = {\n+  code: string\n+  scanType?: 'qr' | 'bar' | 'all'\n+  isDevice?: boolean\n+  consumeUse?: string\n+}\n \n const bridgeMocks = vi.hoisted(() => ({\n-  scanCode: vi.fn(() => Promise.resolve({ code: 'SAMPLE' })),\n+  scanCode: vi.fn<() => Promise<ScanResult>>(() => Promise.resolve({ code: 'SAMPLE' })),\n   getNativeBridgeDiagnostics: vi.fn(),\n+  getNativeHost: vi.fn(() => 'android'),\n }))\n \n-vi.mock('../../services/nativeBridge', () => bridgeMocks)\n+vi.mock('../../services/nativeBridge', () => {\n+  class NativeBridgeError extends Error {\n+    readonly name = 'NativeBridgeError'\n+\n+    constructor(\n+      readonly code: string,\n+      readonly capability: string,\n+      message: string,\n+    ) {\n+      super(message)\n+    }\n+  }\n+\n+  return { ...bridgeMocks, NativeBridgeError }\n+})\n \n import BottomNav from './BottomNav'\n \n+const buddyQr = 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000'\n+\n+function LocationProbe() {\n+  const location = useLocation()\n+  return (\n+    <output\n+      data-testid=\"current-route\"\n+      data-path={location.pathname}\n+      data-state={JSON.stringify(location.state ?? null)}\n+    />\n+  )\n+}\n+\n afterEach(() => {\n   vi.useRealTimers()\n-  bridgeMocks.scanCode.mockClear()\n+  vi.unstubAllEnvs()\n+  bridgeMocks.scanCode.mockReset()\n   bridgeMocks.getNativeBridgeDiagnostics.mockReset()\n+  bridgeMocks.getNativeHost.mockReset()\n+  bridgeMocks.getNativeHost.mockReturnValue('android')\n })\n \n function renderNav() {\n   return render(\n     <MemoryRouter initialEntries={['/']}>\n       <BottomNav />\n+      <LocationProbe />\n     </MemoryRouter>,\n   )\n }\n \n+function currentPath() {\n+  return screen.getByTestId('current-route').getAttribute('data-path')\n+}\n+\n describe('BottomNav middle scan item', () => {\n   it('disables the scan button in a browser host with no scanCode capability', () => {\n     bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n@@ -40,21 +83,145 @@ describe('BottomNav middle scan item', () => {\n     expect(bridgeMocks.scanCode).not.toHaveBeenCalled()\n   })\n \n-  it('calls the shared scanCode capability when the native host supports it', async () => {\n+  it('routes only a trusted Android buddy QR into the existing invitation preview', async () => {\n+    vi.stubEnv('VITE_BUDDY_PUBLIC_ORIGIN', 'https://kbs.3cgroup.cn')\n     bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n       capabilities: { scanCode: true },\n     })\n+    bridgeMocks.scanCode.mockResolvedValueOnce({\n+      code: buddyQr,\n+      scanType: 'qr',\n+      isDevice: false,\n+    })\n \n     renderNav()\n+    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n \n-    const button = screen.getByRole('button', { name: '扫码' }) as HTMLButtonElement\n-    expect(button.disabled).toBe(false)\n+    await waitFor(() => expect(currentPath()).toBe('/buddy/accept'))\n+    expect(JSON.parse(screen.getByTestId('current-route').getAttribute('data-state') ?? 'null'))\n+      .toEqual({\n+        buddyScan: {\n+          source: 'native-buddy-recognition',\n+          raw: buddyQr,\n+        },\n+      })\n+    expect(bridgeMocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n+  })\n+\n+  it('shows a visible unsupported-code message for unknown non-device QR data', async () => {\n+    vi.stubEnv('VITE_BUDDY_PUBLIC_ORIGIN', 'https://kbs.3cgroup.cn')\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n+    })\n+    bridgeMocks.scanCode.mockResolvedValueOnce({\n+      code: 'https://untrusted.example/anything',\n+      scanType: 'qr',\n+      isDevice: false,\n+    })\n \n+    renderNav()\n+    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n+\n+    expect(await screen.findByText('暂不支持此二维码')).toBeTruthy()\n+    expect(currentPath()).toBe('/')\n+  })\n+\n+  it('leaves Native-owned device results untouched, including vending results without consumeUse', async () => {\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n+    })\n+    renderNav()\n+    const button = screen.getByRole('button', { name: '扫码' })\n+\n+    bridgeMocks.scanCode.mockResolvedValueOnce({\n+      code: 'DEVICE-QR-011',\n+      scanType: 'qr',\n+      consumeUse: '11',\n+    })\n+    fireEvent.click(button)\n+    await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false))\n+    expect(bridgeMocks.scanCode).toHaveBeenCalledTimes(1)\n+    expect(currentPath()).toBe('/')\n+    expect(screen.queryByText('暂不支持此二维码')).toBeNull()\n+\n+    bridgeMocks.scanCode.mockResolvedValueOnce({\n+      code: 'DEVICE-QR-017',\n+      scanType: 'qr',\n+    })\n     fireEvent.click(button)\n+    await waitFor(() => expect(bridgeMocks.scanCode).toHaveBeenCalledTimes(2))\n+    expect(currentPath()).toBe('/')\n+    expect(screen.queryByText('暂不支持此二维码')).toBeNull()\n+  })\n+\n+  it('does not infer a new iOS scan-dispatch contract from a legacy raw result', async () => {\n+    bridgeMocks.getNativeHost.mockReturnValue('ios')\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n+    })\n+    bridgeMocks.scanCode.mockResolvedValueOnce({\n+      code: buddyQr,\n+      isDevice: false,\n+    })\n+\n+    renderNav()\n+    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n+\n+    expect(await screen.findByText('当前 App 尚未提供可验证的扫码分流能力')).toBeTruthy()\n+    expect(currentPath()).toBe('/')\n+  })\n+\n+  it('returns quietly on cancellation', async () => {\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n+    })\n+    const { NativeBridgeError } = await import('../../services/nativeBridge')\n+    bridgeMocks.scanCode.mockRejectedValueOnce(\n+      new NativeBridgeError('native-cancelled', 'scanCode', 'cancelled'),\n+    )\n+\n+    renderNav()\n+    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n+\n+    await waitFor(() => expect(bridgeMocks.scanCode).toHaveBeenCalledTimes(1))\n+    expect(currentPath()).toBe('/')\n+    expect(screen.queryByRole('alert')).toBeNull()\n+  })\n+\n+  it('shows recoverable feedback when camera permission is denied', async () => {\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n+    })\n+    const { NativeBridgeError } = await import('../../services/nativeBridge')\n+    bridgeMocks.scanCode.mockRejectedValueOnce(\n+      new NativeBridgeError('native-permission-denied', 'scanCode', 'permission denied'),\n+    )\n+\n+    renderNav()\n+    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n \n-    await waitFor(() => {\n-      expect(bridgeMocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n+    const alert = await screen.findByRole('alert')\n+    expect(alert.textContent).toContain('请允许相机权限后重试')\n+    expect(currentPath()).toBe('/')\n+  })\n+\n+  it('prevents duplicate Native scan invocations while the first scan is pending', async () => {\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n     })\n+    let resolveScan: ((value: { code: string; isDevice: false }) => void) | undefined\n+    bridgeMocks.scanCode.mockReturnValueOnce(new Promise(resolve => {\n+      resolveScan = resolve\n+    }))\n+\n+    renderNav()\n+    const button = screen.getByRole('button', { name: '扫码' })\n+    fireEvent.click(button)\n+    fireEvent.click(button)\n+\n+    expect(bridgeMocks.scanCode).toHaveBeenCalledTimes(1)\n+    resolveScan?.({ code: 'unknown-code', isDevice: false })\n+    expect(await screen.findByText('暂不支持此二维码')).toBeTruthy()\n   })\n \n   it('stops background bridge polling after the late-injection discovery window', () => {\n@@ -77,19 +244,4 @@ describe('BottomNav middle scan item', () => {\n     expect(bridgeMocks.getNativeBridgeDiagnostics.mock.calls.length).toBe(callsAfterDiscoveryWindow)\n     unmount()\n   })\n-\n-  it('silently swallows scan cancellation without leaving the current page', async () => {\n-    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n-      capabilities: { scanCode: true },\n-    })\n-    bridgeMocks.scanCode.mockRejectedValueOnce(new Error('cancelled'))\n-\n-    renderNav()\n-\n-    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n-\n-    await waitFor(() => {\n-      expect(bridgeMocks.scanCode).toHaveBeenCalledTimes(1)\n-    })\n-  })\n })\ndiff --git a/src/components/mobile/BottomNav.tsx b/src/components/mobile/BottomNav.tsx\nindex 25750c0..94af356 100644\n--- a/src/components/mobile/BottomNav.tsx\n+++ b/src/components/mobile/BottomNav.tsx\n@@ -1,9 +1,15 @@\n-import { useCallback, useEffect, useState } from 'react'\n+import { useCallback, useEffect, useRef, useState } from 'react'\n import { useLocation, useNavigate } from 'react-router-dom'\n-import { BottomNavigation } from '../ui'\n+import { BottomNavigation, Toast } from '../ui'\n import { LEGACY_TAB_ITEMS } from '../../app/router/routes'\n import { FORMAL_H5_TAB_ROUTES } from '../../app/router/routeScope'\n-import { getNativeBridgeDiagnostics, scanCode } from '../../services/nativeBridge'\n+import {\n+  getNativeBridgeDiagnostics,\n+  getNativeHost,\n+  NativeBridgeError,\n+  scanCode,\n+} from '../../services/nativeBridge'\n+import { parseBuddyInviteCode } from '../../services/buddyRelations'\n \n // 主入口只消费 formal-H5 Tab 路由；Native reference 使用独立 legacy 导航。\n const mainItems = FORMAL_H5_TAB_ROUTES.flatMap((route) =>\n@@ -36,6 +42,11 @@ const SCAN_TAB_PATH = mainItems.find((item) => item.fab)?.value\n const BRIDGE_DISCOVERY_INTERVAL_MS = 500\n const BRIDGE_DISCOVERY_MAX_ATTEMPTS = 20\n \n+type ScanFeedback = {\n+  message: string\n+  status: 'warning' | 'danger'\n+}\n+\n export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'legacy' }) {\n   const location = useLocation()\n   const navigate = useNavigate()\n@@ -46,6 +57,9 @@ export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'le\n   const [scanSupported, setScanSupported] = useState(\n     () => getNativeBridgeDiagnostics().capabilities.scanCode,\n   )\n+  const [scanPending, setScanPending] = useState(false)\n+  const [scanFeedback, setScanFeedback] = useState<ScanFeedback | null>(null)\n+  const scanInFlight = useRef(false)\n \n   useEffect(() => {\n     if (isLegacy || scanSupported) return\n@@ -80,6 +94,12 @@ export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'le\n     }\n   }, [isLegacy, scanSupported])\n \n+  useEffect(() => {\n+    if (!scanFeedback) return\n+    const timer = window.setTimeout(() => setScanFeedback(null), 2200)\n+    return () => window.clearTimeout(timer)\n+  }, [scanFeedback])\n+\n   // 主入口：子路径仍高亮所属一级 Tab\n   // 历史入口：根据当前路径匹配对应 Tab key\n   const active = isLegacy\n@@ -90,14 +110,77 @@ export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'le\n \n   const handleScan = useCallback(async () => {\n     // 浏览器 / 当前 App 版本未注入 scanCode 时保持不可用，不臆造 Web camera fallback。\n-    if (!getNativeBridgeDiagnostics().capabilities.scanCode) return\n+    if (!getNativeBridgeDiagnostics().capabilities.scanCode || scanInFlight.current) return\n+\n+    scanInFlight.current = true\n+    setScanPending(true)\n+    setScanFeedback(null)\n \n     try {\n-      await scanCode({ scanType: 'qr' })\n-    } catch {\n-      // 扫码取消 / 失败 / 超时均静默回落到当前页，不新增反馈链路。\n+      const host = getNativeHost()\n+      const result = await scanCode({ scanType: 'qr' })\n+\n+      // 当前 feat/1109 的扫码分流合同已由 Android 明确提供。iOS 当前分支没有\n+      // scanCode Native 实现证据，不根据旧 H5 adapter 的存在推断其支持该能力。\n+      if (host !== 'android') {\n+        setScanFeedback({\n+          message: '当前 App 尚未提供可验证的扫码分流能力',\n+          status: 'warning',\n+        })\n+        return\n+      }\n+\n+      // 设备页与设备启动/取货事务始终由 Native 管理。部分设备（如 use=17）\n+      // 回调不带 consumeUse；缺少 isDevice=false 时绝不能当作 H5 邀请二维码。\n+      if (\n+        result.isDevice === true ||\n+        (typeof result.consumeUse === 'string' && result.consumeUse.length > 0)\n+      ) {\n+        return\n+      }\n+      if (result.isDevice !== false) return\n+\n+      // 只有官方域名下的固定 UUID 邀请 URL 才进入现有搭子预览；其余未知码拒绝执行。\n+      try {\n+        parseBuddyInviteCode(\n+          result.code,\n+          import.meta.env.VITE_BUDDY_PUBLIC_ORIGIN ?? '',\n+        )\n+      } catch {\n+        setScanFeedback({ message: '暂不支持此二维码', status: 'warning' })\n+        return\n+      }\n+\n+      navigate('/buddy/accept', {\n+        state: {\n+          buddyScan: {\n+            source: 'native-buddy-recognition',\n+            raw: result.code,\n+          },\n+        },\n+      })\n+    } catch (error) {\n+      if (error instanceof NativeBridgeError) {\n+        if (error.code === 'native-cancelled') return\n+        if (error.code === 'native-permission-denied') {\n+          setScanFeedback({ message: '请允许相机权限后重试', status: 'danger' })\n+          return\n+        }\n+        if (\n+          error.code === 'bridge-disabled' ||\n+          error.code === 'bridge-unsupported' ||\n+          error.code === 'capability-unsupported'\n+        ) {\n+          setScanFeedback({ message: '当前 App 版本暂不支持扫码', status: 'warning' })\n+          return\n+        }\n+      }\n+      setScanFeedback({ message: '扫码失败，请重试', status: 'danger' })\n+    } finally {\n+      scanInFlight.current = false\n+      setScanPending(false)\n     }\n-  }, [])\n+  }, [navigate])\n \n   const handleChange = (value: string) => {\n     if (isLegacy) {\n@@ -122,15 +205,24 @@ export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'le\n   const renderedItems = isLegacy\n     ? items\n     : items.map((item) =>\n-        item.value === SCAN_TAB_PATH ? { ...item, disabled: !scanSupported } : item,\n+        item.value === SCAN_TAB_PATH\n+          ? { ...item, disabled: !scanSupported || scanPending }\n+          : item,\n       )\n \n   return (\n-    <BottomNavigation\n-      items={renderedItems}\n-      value={active}\n-      onChange={handleChange}\n-      className={`relative z-40 w-full shrink-0 ${isLegacy ? 'mx-auto max-w-legacy-shell' : ''}`}\n-    />\n+    <>\n+      <BottomNavigation\n+        items={renderedItems}\n+        value={active}\n+        onChange={handleChange}\n+        className={`relative z-40 w-full shrink-0 ${isLegacy ? 'mx-auto max-w-legacy-shell' : ''}`}\n+      />\n+      {scanFeedback && (\n+        <div className=\"fixed bottom-[calc(64px+env(safe-area-inset-bottom)+12px)] left-1/2 z-[60] -translate-x-1/2\">\n+          <Toast message={scanFeedback.message} status={scanFeedback.status} />\n+        </div>\n+      )}\n+    </>\n   )\n }\ndiff --git a/src/pages/BridgeLab.test.tsx b/src/pages/BridgeLab.test.tsx\nindex f7b8213..6535e69 100644\n--- a/src/pages/BridgeLab.test.tsx\n+++ b/src/pages/BridgeLab.test.tsx\n@@ -164,7 +164,7 @@ describe('Bridge Lab page', () => {\n     expect(screen.queryByText(/bridge-lab-scan/)).toBeNull()\n   })\n \n-  it('shows scan callback correlation, actual endpoint, both stages, and redacts the QR text', async () => {\n+  it('shows the single terminal scan callback, actual endpoint, and redacts the QR text', async () => {\n     usePlatform('android')\n     let request: { scanType: string; callbackId: string } | undefined\n     labWindow.androidBridge = {\n@@ -194,27 +194,19 @@ describe('Bridge Lab page', () => {\n     labWindow.androidBridgeCallback?.(request!.callbackId, {\n       code: 0,\n       message: 'ok',\n-      data: { text: 'buddy-qr-private-value', scanType: 'qr' },\n-    })\n-\n-    await waitFor(() => expect(logs.textContent).toContain('intermediate'))\n-    expect(logs.textContent).toContain('\"text\": \"[REDACTED]\"')\n-    expect(logs.textContent).not.toContain('buddy-qr-private-value')\n-\n-    labWindow.androidBridgeCallback?.(request!.callbackId, {\n-      code: 0,\n-      message: 'ok',\n-      data: { text: 'buddy-qr-private-value', scanType: 'qr' },\n+      data: { text: 'buddy-qr-private-value', scanType: 'qr', isDevice: false },\n     })\n \n     await waitFor(() => {\n       expect(logs.textContent).toContain('terminal')\n       expect(logs.textContent).toContain('callbackCount')\n     })\n+    expect(logs.textContent).toContain('\"text\": \"[REDACTED]\"')\n+    expect(logs.textContent).toContain('\"isDevice\": false')\n     expect(logs.textContent).not.toContain('buddy-qr-private-value')\n   })\n \n-  it('can invoke a registered H032 capability with editable JSON input', async () => {\n+    it('can invoke a registered H032 capability with editable JSON input', async () => {\n     usePlatform('android')\n     labWindow.androidBridge = {\n       copyText(payload: unknown) {\ndiff --git a/src/pages/ConfirmVerify.nativeResult.test.tsx b/src/pages/ConfirmVerify.nativeResult.test.tsx\nindex e9fc4dc..cf46476 100644\n--- a/src/pages/ConfirmVerify.nativeResult.test.tsx\n+++ b/src/pages/ConfirmVerify.nativeResult.test.tsx\n@@ -1,12 +1,12 @@\n import { describe, expect, it } from 'vitest'\n-import { fireEvent, render, screen } from '@testing-library/react'\n+import { render, screen } from '@testing-library/react'\n import { MemoryRouter, Route, Routes } from 'react-router-dom'\n \n-import { VERIFY_VOUCHER_FIXTURE } from '../app/fixtures'\n+import { VERIFY_FEEDBACK, VERIFY_VOUCHER_FIXTURE } from '../app/fixtures'\n import ConfirmVerify from './ConfirmVerify'\n \n-describe('ConfirmVerify Native transaction result', () => {\n-  it('shows the completed H5 result without presenting fixture store details as real Native data', () => {\n+describe('ConfirmVerify Native scan result boundary', () => {\n+  it('does not treat a legacy Native scan callback as completed card redemption', () => {\n     render(\n       <MemoryRouter\n         initialEntries={[\n@@ -26,12 +26,13 @@ describe('ConfirmVerify Native transaction result', () => {\n       </MemoryRouter>,\n     )\n \n-    const returnButton = screen.getByRole('button', { name: '返回卡包' })\n-    expect(returnButton).toBeTruthy()\n-    expect(screen.queryByText('即将核销此券')).toBeNull()\n-    expect(screen.queryByText(VERIFY_VOUCHER_FIXTURE.store)).toBeNull()\n-\n-    fireEvent.click(returnButton)\n-    expect(screen.getByText('real-card-route')).toBeTruthy()\n+    expect(screen.getByText('即将核销此券')).toBeTruthy()\n+    expect(screen.getByText(VERIFY_VOUCHER_FIXTURE.store)).toBeTruthy()\n+    expect(screen.queryByRole('button', { name: '返回卡包' })).toBeNull()\n+    expect(screen.queryByText(VERIFY_FEEDBACK.done.title)).toBeNull()\n+    expect(screen.getByText('真实核销服务尚未接入，此页面暂不能执行核销')).toBeTruthy()\n+    expect(\n+      (screen.getByRole('button', { name: '真实核销暂未接入' }) as HTMLButtonElement).disabled,\n+    ).toBe(true)\n   })\n })\ndiff --git a/src/pages/ConfirmVerify.tsx b/src/pages/ConfirmVerify.tsx\nindex b6127c7..dcb2885 100644\n--- a/src/pages/ConfirmVerify.tsx\n+++ b/src/pages/ConfirmVerify.tsx\n@@ -1,11 +1,12 @@\n import { useEffect, useState } from 'react'\n-import { useLocation, useNavigate } from 'react-router-dom'\n+import { useNavigate } from 'react-router-dom'\n import { AlertCircle, Check, CheckCircle2, Store } from 'lucide-react'\n import DebugPanel from '../components/mobile/DebugPanel'\n import PageContainer from '../components/mobile/PageContainer'\n import { useFixtureState } from '../app/fixtures/useFixture'\n import { findRouteByPathname } from '../app/router/routes'\n import { VERIFY_FEEDBACK, VERIFY_VOUCHER_FIXTURE } from '../app/fixtures'\n+import { runtimePolicy } from '../app/config/runtime'\n \n /**\n  * 确认核销（#67 防误触确认 / 已核销 / 重复核销）\n@@ -17,23 +18,9 @@ import { VERIFY_FEEDBACK, VERIFY_VOUCHER_FIXTURE } from '../app/fixtures'\n  */\n export default function ConfirmVerify() {\n   const navigate = useNavigate()\n-  const location = useLocation()\n   const route = findRouteByPathname('/card/verify/confirm')\n   const { state } = useFixtureState(route)\n   const voucher = VERIFY_VOUCHER_FIXTURE\n-  const nativeScanCode =\n-    location.state &&\n-    typeof location.state === 'object' &&\n-    typeof (location.state as { nativeScanCode?: unknown }).nativeScanCode === 'string'\n-      ? (location.state as { nativeScanCode: string }).nativeScanCode\n-      : null\n-  const nativeVerifyResult =\n-    location.state &&\n-    typeof location.state === 'object' &&\n-    (location.state as { nativeVerifyResult?: unknown }).nativeVerifyResult === 'done'\n-      ? 'done'\n-      : null\n-\n   const [toast, setToast] = useState(false)\n \n   useEffect(() => {\n@@ -42,10 +29,9 @@ export default function ConfirmVerify() {\n     return () => window.clearTimeout(id)\n   }, [toast, navigate])\n \n-  const result =\n-    nativeVerifyResult ??\n-    (state?.key === 'done' || state?.key === 'repeat' ? state.key : null)\n-  const isNativeTransactionResult = nativeVerifyResult === 'done'\n+  // URL fixture states are for deterministic visual review only; Native scan callbacks\n+  // never prove that a card voucher has been redeemed.\n+  const result = state?.key === 'done' || state?.key === 'repeat' ? state.key : null\n \n   if (result) {\n     const copy = result === 'done' ? VERIFY_FEEDBACK.done : VERIFY_FEEDBACK.repeat\n@@ -66,22 +52,20 @@ export default function ConfirmVerify() {\n           <p className=\"max-w-[260px] text-sm leading-6 text-text-secondary\">{copy.desc}</p>\n         </div>\n \n-        {!isNativeTransactionResult && (\n-          <section className=\"mx-4 mt-5 rounded-container bg-surface p-4 shadow-sm\">\n-            <div className=\"flex items-center gap-3\">\n-              <Store className=\"h-5 w-5 flex-none text-text-tertiary\" aria-hidden />\n-              <div className=\"min-w-0\">\n-                <p className=\"truncate text-sm text-text-primary\">{voucher.store}</p>\n-                <p className=\"mt-0.5 text-xs text-text-tertiary\">核销时间 {voucher.verifyTime}</p>\n-              </div>\n+        <section className=\"mx-4 mt-5 rounded-container bg-surface p-4 shadow-sm\">\n+          <div className=\"flex items-center gap-3\">\n+            <Store className=\"h-5 w-5 flex-none text-text-tertiary\" aria-hidden />\n+            <div className=\"min-w-0\">\n+              <p className=\"truncate text-sm text-text-primary\">{voucher.store}</p>\n+              <p className=\"mt-0.5 text-xs text-text-tertiary\">核销时间 {voucher.verifyTime}</p>\n             </div>\n-          </section>\n-        )}\n+          </div>\n+        </section>\n \n         <div className=\"mt-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-5\">\n           <button\n             type=\"button\"\n-            onClick={() => navigate(isNativeTransactionResult ? '/card' : '/card?state=used')}\n+            onClick={() => navigate('/card?state=used')}\n             className=\"h-12 w-full rounded-full bg-primary text-base font-medium text-text-inverse active:bg-primary-pressed\"\n           >\n             返回卡包\n@@ -98,13 +82,16 @@ export default function ConfirmVerify() {\n     <PageContainer className=\"pb-[calc(1rem+env(safe-area-inset-bottom))]\">\n       <section\n         className=\"mt-4 flex flex-col items-center text-center\"\n-        data-verify-source={nativeScanCode !== null ? 'native-scan' : 'fixture'}\n       >\n         <span className=\"flex h-16 w-16 items-center justify-center rounded-full bg-success-bg text-success-text\">\n           <Check className=\"h-8 w-8\" />\n         </span>\n         <h2 className=\"mt-4 text-xl font-semibold text-text-primary\">即将核销此券</h2>\n-        <p className=\"mt-1 text-sm text-text-tertiary\">请与门店店员核对，确认后权益将立即扣减</p>\n+        <p className=\"mt-1 text-sm text-text-tertiary\">\n+          {runtimePolicy.fixtureQueriesEnabled\n+            ? '演示流程不会真实扣券，请勿作为实际核销凭证'\n+            : '真实核销服务尚未接入，此页面暂不能执行核销'}\n+        </p>\n       </section>\n \n       <section className=\"mt-6\" aria-label=\"核销设备\">\n@@ -156,10 +143,13 @@ export default function ConfirmVerify() {\n         </button>\n         <button\n           type=\"button\"\n-          onClick={() => setToast(true)}\n-          className=\"h-12 flex-[2] rounded-full bg-primary text-sm font-medium text-text-inverse active:bg-primary-pressed\"\n+          onClick={() => {\n+            if (runtimePolicy.fixtureQueriesEnabled) setToast(true)\n+          }}\n+          disabled={!runtimePolicy.fixtureQueriesEnabled}\n+          className=\"h-12 flex-[2] rounded-full bg-primary text-sm font-medium text-text-inverse active:bg-primary-pressed disabled:cursor-not-allowed disabled:opacity-50\"\n         >\n-          确认核销\n+          {runtimePolicy.fixtureQueriesEnabled ? '模拟核销（仅演示）' : '真实核销暂未接入'}\n         </button>\n       </div>\n \ndiff --git a/src/pages/ScanVerify.test.tsx b/src/pages/ScanVerify.test.tsx\nindex 299e9fa..18ba02c 100644\n--- a/src/pages/ScanVerify.test.tsx\n+++ b/src/pages/ScanVerify.test.tsx\n@@ -2,25 +2,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'\n import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n \n const mocks = vi.hoisted(() => ({\n-  navigate: vi.fn(),\n   scanCode: vi.fn(),\n   getNativeBridgeDiagnostics: vi.fn(),\n-  getNativeHost: vi.fn(),\n   closeWebView: vi.fn(() => Promise.resolve()),\n }))\n \n-vi.mock('react-router-dom', async (importOriginal) => {\n-  const actual = await importOriginal<typeof import('react-router-dom')>()\n-  return {\n-    ...actual,\n-    useNavigate: () => mocks.navigate,\n-  }\n-})\n-\n vi.mock('../services/nativeBridge', () => {\n   class NativeBridgeError extends Error {\n-    constructor(readonly code: string) {\n-      super(code)\n+    readonly name = 'NativeBridgeError'\n+\n+    constructor(readonly code: string, readonly capability: string, message: string) {\n+      super(message)\n     }\n   }\n \n@@ -28,7 +20,6 @@ vi.mock('../services/nativeBridge', () => {\n     NativeBridgeError,\n     scanCode: mocks.scanCode,\n     getNativeBridgeDiagnostics: mocks.getNativeBridgeDiagnostics,\n-    getNativeHost: mocks.getNativeHost,\n     closeWebView: mocks.closeWebView,\n   }\n })\n@@ -36,15 +27,12 @@ vi.mock('../services/nativeBridge', () => {\n import ScanVerify from './ScanVerify'\n \n afterEach(() => {\n-  mocks.navigate.mockReset()\n   mocks.scanCode.mockReset()\n   mocks.getNativeBridgeDiagnostics.mockReset()\n-  mocks.getNativeHost.mockReset()\n-  mocks.getNativeHost.mockReturnValue('browser')\n   mocks.closeWebView.mockClear()\n })\n \n-describe('ScanVerify', () => {\n+describe('ScanVerify card-voucher boundary', () => {\n   it('shows explicit unsupported state instead of simulated scan success', () => {\n     mocks.getNativeBridgeDiagnostics.mockReturnValue({\n       capabilities: {\n@@ -96,99 +84,120 @@ describe('ScanVerify', () => {\n     })\n   })\n \n-  it('waits for the confirmed Android transaction and resumes directly at the completed result state', async () => {\n-    mocks.getNativeHost.mockReturnValue('android')\n+  it('does not send a Native-owned device result into the card-voucher confirmation page', async () => {\n     mocks.getNativeBridgeDiagnostics.mockReturnValue({\n-      capabilities: {\n-        scanCode: true,\n-        closeWebView: false,\n-      },\n+      capabilities: { scanCode: true, closeWebView: false },\n+    })\n+    mocks.scanCode.mockResolvedValue({\n+      code: 'DEVICE-QR-011',\n+      scanType: 'qr',\n+      consumeUse: '11',\n     })\n-    mocks.scanCode.mockResolvedValue({ code: 'REAL-SCAN-CODE' })\n \n     render(<ScanVerify />)\n+    fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n+\n+    await waitFor(() => {\n+      expect(screen.getByText('已识别设备二维码，请按 App 设备页面提示操作')).toBeTruthy()\n+    })\n+    expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n+  })\n \n+  it('does not require consumeUse to avoid misclassifying the Native vending use-17 callback', async () => {\n+    mocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true, closeWebView: false },\n+    })\n+    mocks.scanCode.mockResolvedValue({\n+      code: 'DEVICE-QR-017',\n+      scanType: 'qr',\n+    })\n+\n+    render(<ScanVerify />)\n     fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n \n     await waitFor(() => {\n-      expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n+      expect(screen.getByText('当前扫码结果无法验证为卡券核销码，未发起核销')).toBeTruthy()\n     })\n-    expect(mocks.navigate).toHaveBeenCalledWith('/card/verify/confirm', {\n-      replace: true,\n-      state: {\n-        nativeScanCode: 'REAL-SCAN-CODE',\n-        nativeVerifyResult: 'done',\n-      },\n+  })\n+\n+  it('does not treat every non-device QR as a card voucher', async () => {\n+    mocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true, closeWebView: false },\n+    })\n+    mocks.scanCode.mockResolvedValue({\n+      code: 'UNKNOWN-NON-DEVICE-QR',\n+      scanType: 'qr',\n+      isDevice: false,\n+    })\n+\n+    render(<ScanVerify />)\n+    fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n+\n+    await waitFor(() => {\n+      expect(screen.getByText('暂不支持此卡券核销码，未发起核销')).toBeTruthy()\n     })\n   })\n \n-  it('keeps iOS on the existing confirmation flow until terminal transaction semantics are confirmed', async () => {\n-    mocks.getNativeHost.mockReturnValue('ios')\n+  it('does not infer a card-voucher result from a legacy raw iOS payload', async () => {\n     mocks.getNativeBridgeDiagnostics.mockReturnValue({\n-      capabilities: {\n-        scanCode: true,\n-        closeWebView: false,\n-      },\n+      capabilities: { scanCode: true, closeWebView: false },\n     })\n-    mocks.scanCode.mockResolvedValue({ code: 'IOS-SCAN-CODE' })\n+    mocks.scanCode.mockResolvedValue({ code: 'IOS-RAW-QR' })\n \n     render(<ScanVerify />)\n     fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n \n     await waitFor(() => {\n-      expect(mocks.navigate).toHaveBeenCalledWith('/card/verify/confirm', {\n-        state: { nativeScanCode: 'IOS-SCAN-CODE' },\n-      })\n+      expect(screen.getByText('当前扫码结果无法验证为卡券核销码，未发起核销')).toBeTruthy()\n     })\n-    expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n-    expect(mocks.navigate).not.toHaveBeenCalledWith(\n-      '/card/verify/confirm',\n-      expect.objectContaining({\n-        state: expect.objectContaining({ nativeVerifyResult: 'done' }),\n-      }),\n-    )\n   })\n \n-  it('keeps an unknown Native failure on the scan page', async () => {\n+  it('shows a recoverable failure instead of leaving the scan page busy', async () => {\n     mocks.getNativeBridgeDiagnostics.mockReturnValue({\n-      capabilities: {\n-        scanCode: true,\n-        closeWebView: false,\n-      },\n+      capabilities: { scanCode: true, closeWebView: false },\n     })\n     mocks.scanCode.mockRejectedValue(new Error('native failed'))\n \n     render(<ScanVerify />)\n-\n     fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n \n     await waitFor(() => {\n       expect(screen.getByText('扫码失败，请重试')).toBeTruthy()\n     })\n-    expect(mocks.navigate).not.toHaveBeenCalled()\n+    expect((screen.getByRole('button', { name: '开始扫码核销' }) as HTMLButtonElement).disabled).toBe(false)\n   })\n \n-  it.each([\n-    ['native-cancelled', '已取消扫码'],\n-    ['native-permission-denied', '请允许相机权限后重试'],\n-  ] as const)('shows confirmed %s semantics without navigating', async (code, message) => {\n+  it('returns quietly when the user cancels the card scan', async () => {\n     mocks.getNativeBridgeDiagnostics.mockReturnValue({\n-      capabilities: {\n-        scanCode: true,\n-        closeWebView: false,\n-      },\n+      capabilities: { scanCode: true, closeWebView: false },\n+    })\n+    const { NativeBridgeError } = await import('../services/nativeBridge')\n+    mocks.scanCode.mockRejectedValue(\n+      new NativeBridgeError('native-cancelled', 'scanCode', 'cancelled'),\n+    )\n+\n+    render(<ScanVerify />)\n+    fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n+\n+    await waitFor(() => expect(mocks.scanCode).toHaveBeenCalledTimes(1))\n+    expect(screen.queryByText('已取消扫码')).toBeNull()\n+    expect(screen.getByText('将卡券二维码对准扫描框，点击开始扫码')).toBeTruthy()\n+  })\n+\n+  it('shows a recoverable message when camera permission is denied', async () => {\n+    mocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true, closeWebView: false },\n     })\n     const { NativeBridgeError } = await import('../services/nativeBridge')\n     mocks.scanCode.mockRejectedValue(\n-      new NativeBridgeError(code, 'scanCode', 'confirmed Native failure'),\n+      new NativeBridgeError('native-permission-denied', 'scanCode', 'permission denied'),\n     )\n \n     render(<ScanVerify />)\n     fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n \n     await waitFor(() => {\n-      expect(screen.getByText(message)).toBeTruthy()\n+      expect(screen.getByText('请允许相机权限后重试')).toBeTruthy()\n     })\n-    expect(mocks.navigate).not.toHaveBeenCalled()\n   })\n })\ndiff --git a/src/pages/ScanVerify.tsx b/src/pages/ScanVerify.tsx\nindex 0eac3a8..ced06b6 100644\n--- a/src/pages/ScanVerify.tsx\n+++ b/src/pages/ScanVerify.tsx\n@@ -1,21 +1,13 @@\n import { useEffect, useState } from 'react'\n-import { useNavigate } from 'react-router-dom'\n import { ScanLine } from 'lucide-react'\n import HostCloseButton from '../components/mobile/HostCloseButton'\n import {\n   getNativeBridgeDiagnostics,\n-  getNativeHost,\n   NativeBridgeError,\n   scanCode,\n } from '../services/nativeBridge'\n \n-export interface ScanVerifyNavigationState {\n-  nativeScanCode: string\n-  nativeVerifyResult?: 'done'\n-}\n-\n export default function ScanVerify() {\n-  const navigate = useNavigate()\n   const [supported, setSupported] = useState(\n     () => getNativeBridgeDiagnostics().capabilities.scanCode,\n   )\n@@ -47,30 +39,24 @@ export default function ScanVerify() {\n     setError(null)\n \n     try {\n-      const host = getNativeHost()\n       const result = await scanCode({ scanType: 'qr' })\n \n-      if (host === 'android') {\n-        const state: ScanVerifyNavigationState = {\n-          nativeScanCode: result.code,\n-          nativeVerifyResult: 'done',\n-        }\n-        // H037 has Android evidence that scanCode resolves only after the device transaction\n-        // completes, so H5 resumes directly at the completed result state.\n-        navigate('/card/verify/confirm', { replace: true, state })\n-        return\n+      if (\n+        result.isDevice === true ||\n+        (typeof result.consumeUse === 'string' && result.consumeUse.length > 0)\n+      ) {\n+        setError('已识别设备二维码，请按 App 设备页面提示操作')\n+      } else if (result.isDevice === false) {\n+        // H5 仓库和已读后台合同没有定义可验证的卡券二维码格式；不把任意非设备码当核销码。\n+        setError('暂不支持此卡券核销码，未发起核销')\n+      } else {\n+        // use=17 的 Native 回调只有 text / scanType；缺少业务类型时不伪造核销成功。\n+        setError('当前扫码结果无法验证为卡券核销码，未发起核销')\n       }\n-\n-      // iOS still has only the retained one-callback scanner contract in H5 evidence. Until Native\n-      // terminal transaction semantics are independently confirmed, preserve the existing\n-      // confirmation flow and never label a scan recognition as completed verification.\n-      const state: ScanVerifyNavigationState = { nativeScanCode: result.code }\n-      navigate('/card/verify/confirm', { state })\n     } catch (error) {\n       if (error instanceof NativeBridgeError) {\n-        if (error.code === 'native-cancelled') {\n-          setError('已取消扫码')\n-        } else if (error.code === 'native-permission-denied') {\n+        if (error.code === 'native-cancelled') return\n+        if (error.code === 'native-permission-denied') {\n           setError('请允许相机权限后重试')\n         } else {\n           setError('扫码失败，请重试')\n@@ -78,6 +64,7 @@ export default function ScanVerify() {\n       } else {\n         setError('扫码失败，请重试')\n       }\n+    } finally {\n       setScanning(false)\n     }\n   }\n@@ -86,7 +73,7 @@ export default function ScanVerify() {\n     ? '当前 App 版本暂不支持扫码'\n     : scanning\n       ? '正在调用系统扫码…'\n-      : error ?? '将二维码对准扫描框，点击开始扫码'\n+      : error ?? '将卡券二维码对准扫描框，点击开始扫码'\n \n   return (\n     <div className=\"relative min-h-full bg-black text-white\">\n@@ -124,7 +111,7 @@ export default function ScanVerify() {\n         <div className=\"relative z-10 mt-10 text-center\">\n           <p className=\"text-lg font-semibold\">请将二维码对准扫描框</p>\n           <p className=\"mt-1 text-sm text-white/60\">\n-            {supported ? '扫码后请按页面提示完成设备操作' : '请升级到支持扫码能力的 App 版本'}\n+            {supported ? '扫码后先校验二维码类型；未识别的码不会执行核销' : '请升级到支持扫码能力的 App 版本'}\n           </p>\n         </div>\n       </main>\ndiff --git a/src/services/nativeBridge.scanTransaction.contract.test.ts b/src/services/nativeBridge.scanTransaction.contract.test.ts\nindex 5cf12f0..dca50e7 100644\n--- a/src/services/nativeBridge.scanTransaction.contract.test.ts\n+++ b/src/services/nativeBridge.scanTransaction.contract.test.ts\n@@ -18,6 +18,7 @@ afterEach(() => {\n   delete hostWindow.androidBridge\n   delete hostWindow.androidBridgeCallback\n   delete hostWindow.nativeBridgeCallback\n+  delete hostWindow.iosBridge\n })\n \n function installAndroidScanHost() {\n@@ -34,45 +35,65 @@ function installAndroidScanHost() {\n   }\n }\n \n-describe('scanCode Android H037 transaction contract', () => {\n-  it('keeps the first scan-success callback pending and resolves only after device success', async () => {\n+describe('scanCode Android feat/1109 callback contract', () => {\n+  it('settles the first non-device recognition callback and preserves its routing metadata', async () => {\n     const getRequest = installAndroidScanHost()\n-    const promise = scanCode({ scanType: 'all' })\n+    const promise = scanCode({ scanType: 'qr' })\n     const request = getRequest()\n-\n-    let settled = false\n-    promise.finally(() => {\n-      settled = true\n-    })\n+    const raw = 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000'\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n       code: 0,\n       message: 'ok',\n-      data: { text: 'DEVICE-QR-001', scanType: 'qr' },\n+      data: { text: raw, scanType: 'qr', isDevice: false },\n     })\n \n-    await Promise.resolve()\n-    expect(settled).toBe(false)\n+    await expect(promise).resolves.toEqual({\n+      code: raw,\n+      scanType: 'qr',\n+      isDevice: false,\n+    })\n+  })\n+\n+  it('settles a Native-owned device start callback and preserves consumeUse when present', async () => {\n+    const getRequest = installAndroidScanHost()\n+    const promise = scanCode({ scanType: 'qr' })\n+    const request = getRequest()\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n       code: 0,\n       message: 'ok',\n-      data: { text: 'DEVICE-QR-001', scanType: 'qr' },\n+      data: { text: 'DEVICE-QR-011', scanType: 'qr', consumeUse: '11' },\n     })\n \n-    await expect(promise).resolves.toEqual({ code: 'DEVICE-QR-001' })\n+    await expect(promise).resolves.toEqual({\n+      code: 'DEVICE-QR-011',\n+      scanType: 'qr',\n+      consumeUse: '11',\n+    })\n   })\n \n-  it('rejects when the device stage fails after a successful scan', async () => {\n+  it('settles vending use-17 callbacks without inventing isDevice or consumeUse metadata', async () => {\n     const getRequest = installAndroidScanHost()\n-    const promise = scanCode({ scanType: 'all' })\n+    const promise = scanCode({ scanType: 'qr' })\n     const request = getRequest()\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n       code: 0,\n       message: 'ok',\n-      data: { text: 'DEVICE-QR-002', scanType: 'qr' },\n+      data: { text: 'DEVICE-QR-017', scanType: 'qr' },\n+    })\n+\n+    await expect(promise).resolves.toEqual({\n+      code: 'DEVICE-QR-017',\n+      scanType: 'qr',\n     })\n+  })\n+\n+  it('rejects when the Native-owned device start fails', async () => {\n+    const getRequest = installAndroidScanHost()\n+    const promise = scanCode({ scanType: 'qr' })\n+    const request = getRequest()\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n       code: 5,\n@@ -91,9 +112,9 @@ describe('scanCode Android H037 transaction contract', () => {\n     [1, 'native-cancelled'],\n     [2, 'native-permission-denied'],\n     [3, 'native-permission-denied'],\n-  ] as const)('settles first-stage Native code %s immediately as %s', async (nativeCode, errorCode) => {\n+  ] as const)('settles Native code %s immediately as %s', async (nativeCode, errorCode) => {\n     const getRequest = installAndroidScanHost()\n-    const promise = scanCode({ scanType: 'all' })\n+    const promise = scanCode({ scanType: 'qr' })\n     const request = getRequest()\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n@@ -109,39 +130,45 @@ describe('scanCode Android H037 transaction contract', () => {\n     })\n   })\n \n-  it('cleans up the Android scan pending entry at the existing callback timeout', async () => {\n+  it('cleans the pending callback after the first terminal result', async () => {\n     vi.useFakeTimers()\n \n     const lateCallbackFallback = vi.fn()\n     hostWindow.androidBridgeCallback = lateCallbackFallback\n \n     const getRequest = installAndroidScanHost()\n-    const promise = scanCode({ scanType: 'all' })\n-    promise.catch(() => undefined)\n+    const promise = scanCode({ scanType: 'qr' })\n     const request = getRequest()\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n       code: 0,\n       message: 'ok',\n-      data: { text: 'DEVICE-QR-TIMEOUT', scanType: 'qr' },\n+      data: { text: 'UNKNOWN-QR', scanType: 'qr', isDevice: false },\n     })\n \n+    await expect(promise).resolves.toMatchObject({ code: 'UNKNOWN-QR', isDevice: false })\n+\n+    const latePayload = { code: 0, message: 'duplicate', data: {} }\n+    hostWindow.androidBridgeCallback?.(request.callbackId, latePayload)\n+    expect(lateCallbackFallback).toHaveBeenCalledWith(request.callbackId, latePayload)\n+\n     await vi.advanceTimersByTimeAsync(INJECTED_CALLBACK_TIMEOUT_MS)\n+    expect(lateCallbackFallback).toHaveBeenCalledTimes(1)\n+  })\n \n-    await expect(promise).rejects.toMatchObject({\n-      name: 'NativeBridgeError',\n-      capability: 'scanCode',\n-    })\n+  it('keeps the existing one-shot iOS adapter shape without inferring Android metadata', async () => {\n+    let request: CapturedScanRequest | undefined\n+    hostWindow.iosBridge = {\n+      scanCode(payload: unknown) {\n+        request = JSON.parse(payload as string) as CapturedScanRequest\n+      },\n+    }\n \n-    hostWindow.androidBridgeCallback?.(request.callbackId, {\n-      code: 0,\n-      message: 'late-device-result',\n-      data: { text: 'DEVICE-QR-TIMEOUT', scanType: 'qr' },\n-    })\n+    const promise = scanCode({ scanType: 'qr' })\n+    if (!request) throw new Error('iOS scanCode request was not captured')\n+\n+    hostWindow.nativeBridgeCallback?.(request.callbackId, JSON.stringify({ code: 'IOS-RAW-QR' }))\n \n-    expect(lateCallbackFallback).toHaveBeenCalledWith(\n-      request.callbackId,\n-      expect.objectContaining({ message: 'late-device-result' }),\n-    )\n+    await expect(promise).resolves.toEqual({ code: 'IOS-RAW-QR' })\n   })\n })\ndiff --git a/src/services/nativeBridge.test.ts b/src/services/nativeBridge.test.ts\nindex c858ea7..f5fdb45 100644\n--- a/src/services/nativeBridge.test.ts\n+++ b/src/services/nativeBridge.test.ts\n@@ -422,7 +422,7 @@ describe('JSBridge capability runtime', () => {\n   })\n \n \n-  it('correlates Android scanCode through androidBridgeCallback', async () => {\n+  it('correlates Android scanCode through androidBridgeCallback and preserves one-shot routing metadata', async () => {\n     const { scanCode, getNativeBridgeDiagnostics } = await loadBridge()\n     const received: Array<{ scanType: string; callbackId: string }> = []\n     const androidBridge = {\n@@ -434,22 +434,14 @@ describe('JSBridge capability runtime', () => {\n         }\n         received.push(request)\n         queueMicrotask(() => {\n-          // H037: first success is scan recognition only; the same callbackId remains pending.\n-          bridgeWindow.androidBridgeCallback?.(request.callbackId, {\n-            code: 0,\n-            message: 'ok',\n-            data: {\n-              text: 'QR-ANDROID-001',\n-              scanType: 'qr',\n-            },\n-          })\n-          // Native returns control to H5 only after the device transaction finishes.\n+          // Android feat/1109 marks non-device recognition explicitly and returns once.\n           bridgeWindow.androidBridgeCallback?.(request.callbackId, {\n             code: 0,\n             message: 'ok',\n             data: {\n               text: 'QR-ANDROID-001',\n               scanType: 'qr',\n+              isDevice: false,\n             },\n           })\n         })\n@@ -460,6 +452,8 @@ describe('JSBridge capability runtime', () => {\n     expect(getNativeBridgeDiagnostics().capabilities.scanCode).toBe(true)\n     await expect(scanCode({ scanType: 'all' })).resolves.toEqual({\n       code: 'QR-ANDROID-001',\n+      scanType: 'qr',\n+      isDevice: false,\n     })\n     expect(received).toEqual([\n       {\n@@ -469,7 +463,7 @@ describe('JSBridge capability runtime', () => {\n     ])\n   })\n \n-  it('supports qr/bar/all scanType values on iOS without changing the field name', async () => {\n+    it('supports qr/bar/all scanType values on iOS without changing the field name', async () => {\n     const { scanCode } = await loadBridge()\n     const received: Array<{ scanType: string; callbackId: string }> = []\n     bridgeWindow.iosBridge = {\ndiff --git a/src/services/nativeBridge/capabilities/scan.ts b/src/services/nativeBridge/capabilities/scan.ts\nindex 6468c15..53cd329 100644\n--- a/src/services/nativeBridge/capabilities/scan.ts\n+++ b/src/services/nativeBridge/capabilities/scan.ts\n@@ -27,6 +27,17 @@ function validateInput(input: NativeScanCodeInput): NativeScanCodeInput {\n   return input\n }\n \n+function asRecord(value: unknown): Record<string, unknown> | null {\n+  return value !== null && typeof value === 'object' && !Array.isArray(value)\n+    ? value as Record<string, unknown>\n+    : null\n+}\n+\n+/**\n+ * Normalize Android's current callback envelope while retaining the H5 adapter's\n+ * legacy one-shot { code: string } result shape. This parser does not imply that\n+ * iOS currently exposes a Native scanCode implementation.\n+ */\n function parseResult(payload: unknown): NativeScanCodeResult {\n   let parsed: unknown\n   try {\n@@ -35,51 +46,46 @@ function parseResult(payload: unknown): NativeScanCodeResult {\n     if (error instanceof NativeTransportError && error.code !== 'payload-invalid') throw error\n     parsed = parseJsonPayload(payload)\n   }\n-  const data = parsed !== null && typeof parsed === 'object'\n-    ? (parsed as { data?: unknown }).data\n-    : undefined\n-  const code = typeof (parsed as { code?: unknown })?.code === 'string'\n-    ? (parsed as { code: string }).code\n-    : data !== null && typeof data === 'object'\n-      ? (data as { text?: unknown }).text\n-      : undefined\n-  const nativeCode = (parsed as { code?: unknown })?.code\n-  if (typeof nativeCode === 'number' && nativeCode !== 0) {\n-    const errorCode = nativeCode === 1 ? 'native-cancelled' : nativeCode === 2 || nativeCode === 3 ? 'native-permission-denied' : 'native-failed'\n-    throw new NativeTransportError(errorCode, 'Native scanCode reported ' + (parsed as { message?: string }).message)\n-  }\n-  if (typeof code !== 'string' || !code) {\n+\n+  const envelope = asRecord(parsed)\n+  if (!envelope) {\n     throw new NativeTransportError(\n       'payload-invalid',\n-      'Native scanCode() result must be a JSON string with a string code field.',\n+      'Native scanCode() result must be a JSON object.',\n     )\n   }\n \n-  return { code }\n-}\n-\n-function isAndroidScanTerminalPayload(payload: unknown, callbackCount: number): boolean {\n-  // Current Android H5 voucher verification is a two-stage Native-owned transaction:\n-  // 1) scanner recognized a code, 2) device start/dispense finished. Only the second success\n-  // callback is terminal. A first-stage failure/cancel/permission result still settles immediately.\n-  if (callbackCount > 1) return true\n-\n-  try {\n-    const parsed = parseJsonPayload<unknown>(payload)\n-    if (parsed === null || typeof parsed !== 'object') return true\n-    if ('error' in parsed) return true\n+  const data = asRecord(envelope.data)\n+  const nativeCode = envelope.code\n+  if (typeof nativeCode === 'number' && nativeCode !== 0) {\n+    const errorCode =\n+      nativeCode === 1\n+        ? 'native-cancelled'\n+        : nativeCode === 2 || nativeCode === 3\n+          ? 'native-permission-denied'\n+          : 'native-failed'\n+    const message = typeof envelope.message === 'string' && envelope.message\n+      ? envelope.message\n+      : 'Native scanCode reported failure.'\n+    throw new NativeTransportError(errorCode, message)\n+  }\n \n-    const nativeCode = (parsed as { code?: unknown }).code\n-    const data = (parsed as { data?: unknown }).data\n-    const text =\n-      data !== null && typeof data === 'object'\n-        ? (data as { text?: unknown }).text\n-        : undefined\n+  const code = typeof nativeCode === 'string' && nativeCode.length > 0\n+    ? nativeCode\n+    : data?.text\n+  if (typeof code !== 'string' || code.length === 0) {\n+    throw new NativeTransportError(\n+      'payload-invalid',\n+      'Native scanCode() result must contain a non-empty QR text.',\n+    )\n+  }\n \n-    return !(nativeCode === 0 && typeof text === 'string' && text.length > 0)\n-  } catch {\n-    // Malformed payloads should settle and fail parsing instead of leaving the transaction pending.\n-    return true\n+  const scanType = data?.scanType\n+  return {\n+    code,\n+    ...(scanType === 'qr' || scanType === 'bar' || scanType === 'all' ? { scanType } : {}),\n+    ...(typeof data?.isDevice === 'boolean' ? { isDevice: data.isDevice } : {}),\n+    ...(typeof data?.consumeUse === 'string' ? { consumeUse: data.consumeUse } : {}),\n   }\n }\n \n@@ -92,7 +98,8 @@ const androidTransport = createCallbackInjectedObjectTransport<\n   callbackName: 'nativeBridgeCallback',\n   callbackAliases: ['androidBridgeCallback'],\n   serializeArgs: (input, callbackId) => [serializeJsonValue({ ...validateInput(input), callbackId })],\n-  isTerminalPayload: isAndroidScanTerminalPayload,\n+  // Android feat/1109 returns one callback per H5 scan invocation. Native device\n+  // screens remain Native-owned after their start callback; no second H5 callback is awaited.\n   parseResult,\n })\n \n@@ -104,6 +111,7 @@ const iosTransport = createCallbackInjectedObjectTransport<\n   methodName: 'scanCode',\n   callbackName: 'nativeBridgeCallback',\n   serializeArgs: (input, callbackId) => [serializeJsonValue({ ...validateInput(input), callbackId })],\n+  // Preserve the H5 adapter's existing one-shot parsing; never infer Android metadata on iOS.\n   parseResult,\n })\n \n@@ -125,7 +133,7 @@ export const scanCodeCapability = defineCapability<\n   name: 'scanCode',\n   platforms: ['android', 'ios'],\n   description:\n-    'Scan a QR code, barcode, or either through the confirmed Android/iOS injected-object contract.',\n+    'Scan a QR code, barcode, or either through the injected-object adapter where the host exposes scanCode.',\n   sensitiveResult: true,\n   resolve: resolveTransport,\n })\ndiff --git a/src/services/nativeBridge/types.ts b/src/services/nativeBridge/types.ts\nindex 8c4864d..56c543c 100644\n--- a/src/services/nativeBridge/types.ts\n+++ b/src/services/nativeBridge/types.ts\n@@ -31,7 +31,14 @@ export interface NativeScanCodeInput {\n }\n \n export interface NativeScanCodeResult {\n+  /** Original recognized QR/barcode text. */\n   code: string\n+  /** Native-reported scan mode, when the host includes it. */\n+  scanType?: NativeScanType\n+  /** Explicit false is required before H5 may dispatch a non-device QR on Android. */\n+  isDevice?: boolean\n+  /** Android device category when supplied by the Native-owned device detail flow. */\n+  consumeUse?: string\n }\n \n export interface NativeTakePhotoInput {\ndiff --git a/tests/e2e/bridge-lab.spec.ts b/tests/e2e/bridge-lab.spec.ts\nindex 7bcc433..e44278a 100644\n--- a/tests/e2e/bridge-lab.spec.ts\n+++ b/tests/e2e/bridge-lab.spec.ts\n@@ -59,7 +59,7 @@ test.describe('H027 Bridge Lab', () => {\n     await expect(logs).toContainText('\"success\": true')\n   })\n \n-  test('shows correlated Android scan callback stages without exposing QR text by default', async ({ page }) => {\n+  test('shows the single Android scan callback and redacts QR text by default', async ({ page }) => {\n     await page.addInitScript(() => {\n       const host = window as unknown as {\n         androidBridge?: { scanCode(payload: unknown): void }\n@@ -72,16 +72,13 @@ test.describe('H027 Bridge Lab', () => {\n             host.androidBridgeCallback?.(request.callbackId, {\n               code: 0,\n               message: 'ok',\n-              data: { text: 'e2e-buddy-qr-private', scanType: 'qr' },\n+              data: {\n+                text: 'e2e-buddy-qr-private',\n+                scanType: 'qr',\n+                isDevice: false,\n+              },\n             })\n           }, 0)\n-          setTimeout(() => {\n-            host.androidBridgeCallback?.(request.callbackId, {\n-              code: 0,\n-              message: 'ok',\n-              data: { text: 'e2e-buddy-qr-private', scanType: 'qr' },\n-            })\n-          }, 20)\n         },\n       }\n     })\n@@ -93,9 +90,9 @@ test.describe('H027 Bridge Lab', () => {\n     const logs = page.locator('[data-bridge-lab-logs]')\n     await expect(logs).toContainText('Native invoke · androidBridge.scanCode')\n     await expect(logs).toContainText('androidBridgeCallback')\n-    await expect(logs).toContainText('intermediate')\n     await expect(logs).toContainText('terminal')\n     await expect(logs).toContainText('[REDACTED]')\n+    await expect(logs).toContainText('\"isDevice\": false')\n     await expect(logs).not.toContainText('e2e-buddy-qr-private')\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/14e184bbd9e9637fa511fd68f7f8ec8d84dcc9e7",
            "commitHash": "14e184bbd9e9637fa511fd68f7f8ec8d84dcc9e7",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/299",
            "prTitle": "promote(dev→test): H045 共用扫码分流与 Android 回调适配",
            "prBaseHash": "3126078bbecd21def20f305f812c28f3c1bda7c5",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/42602034645"
          },
          "gitCommit": {
            "shortHash": "14e184b",
            "hash": "14e184bbd9e9637fa511fd68f7f8ec8d84dcc9e7",
            "subject": "Merge d0683ab0a1fe47f376dc16dd49f1ced829146eb1 into 3126078bbecd21def20f305f812c28f3c1bda7c5",
            "body": "Merge d0683ab0a1fe47f376dc16dd49f1ced829146eb1 into 3126078bbecd21def20f305f812c28f3c1bda7c5\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1794220568000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1794220568000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/docs/workbench/evidence/h037-native-bridge-transaction-alignment.md b/docs/workbench/evidence/h037-native-bridge-transaction-alignment.md\nindex d4cf33b..0473d4f 100644\n--- a/docs/workbench/evidence/h037-native-bridge-transaction-alignment.md\n+++ b/docs/workbench/evidence/h037-native-bridge-transaction-alignment.md\n@@ -1,5 +1,8 @@\n # H037｜真实 Native 事务回调对齐设计证据\n \n+> **历史合同说明（2026-11-09）**：本文记录的是 Android `upstream/gitee/master@a8ac84694d2f69e0c1bfa10619fdc3810845a337` 的旧两阶段扫码事务证据。该证据仍保留为历史事实，但不再代表 Android `feat/1109@b595a72b28d38ef033117ebaa91ff5919a4ef763` 的当前 H5 callback 合同。新分支对非设备码一次回调并显式提供 `data.isDevice=false`；设备 11–14 的启动回调带 `consumeUse`，Native 详情页继续负责后续结算；设备 17 的回调只带 `text/scanType`，不带 `isDevice` 或 `consumeUse`。H045（[#295](https://github.com/sanchuang-dev/dr-card-web/issues/295)）据此收口 H5：只对显式 `isDevice=false` 分流，Native 设备结果不进入 H5 业务。**这不构成 iOS 已实现扫码能力的证据。**\n+\n+\n > 本文件是 H037 的设计依据，不是实现结果。  \n > H037 的代码施工、测试与验收必须能回溯到这里记录的已确认事实；未在本文件确认的 Native 内部行为不得由 H5 自行推断。\n \ndiff --git a/docs/workbench/tasks/H037-native-bridge-transaction-alignment.md b/docs/workbench/tasks/H037-native-bridge-transaction-alignment.md\nindex 0766639..abe1db0 100644\n--- a/docs/workbench/tasks/H037-native-bridge-transaction-alignment.md\n+++ b/docs/workbench/tasks/H037-native-bridge-transaction-alignment.md\n@@ -1,5 +1,8 @@\n # H037｜真实 Native 事务回调对齐\n \n+> **历史合同说明（2026-11-09）**：本文记录的是 Android `upstream/gitee/master@a8ac84694d2f69e0c1bfa10619fdc3810845a337` 的旧两阶段扫码事务证据。该证据仍保留为历史事实，但不再代表 Android `feat/1109@b595a72b28d38ef033117ebaa91ff5919a4ef763` 的当前 H5 callback 合同。新分支对非设备码一次回调并显式提供 `data.isDevice=false`；设备 11–14 的启动回调带 `consumeUse`，Native 详情页继续负责后续结算；设备 17 的回调只带 `text/scanType`，不带 `isDevice` 或 `consumeUse`。H045（[#295](https://github.com/sanchuang-dev/dr-card-web/issues/295)）据此收口 H5：只对显式 `isDevice=false` 分流，Native 设备结果不进入 H5 业务。**这不构成 iOS 已实现扫码能力的证据。**\n+\n+\n **Status:** In Progress  \n **Phase:** Host Integration / Contract Alignment  \n **Depends on:** H029, H031, H033, H036\ndiff --git a/src/components/mobile/BottomNav.test.tsx b/src/components/mobile/BottomNav.test.tsx\nindex 1cbed4d..7c97263 100644\n--- a/src/components/mobile/BottomNav.test.tsx\n+++ b/src/components/mobile/BottomNav.test.tsx\n@@ -1,30 +1,73 @@\n import { afterEach, describe, expect, it, vi } from 'vitest'\n import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'\n-import { MemoryRouter } from 'react-router-dom'\n+import { MemoryRouter, useLocation } from 'react-router-dom'\n+\n+type ScanResult = {\n+  code: string\n+  scanType?: 'qr' | 'bar' | 'all'\n+  isDevice?: boolean\n+  consumeUse?: string\n+}\n \n const bridgeMocks = vi.hoisted(() => ({\n-  scanCode: vi.fn(() => Promise.resolve({ code: 'SAMPLE' })),\n+  scanCode: vi.fn<() => Promise<ScanResult>>(() => Promise.resolve({ code: 'SAMPLE' })),\n   getNativeBridgeDiagnostics: vi.fn(),\n+  getNativeHost: vi.fn(() => 'android'),\n }))\n \n-vi.mock('../../services/nativeBridge', () => bridgeMocks)\n+vi.mock('../../services/nativeBridge', () => {\n+  class NativeBridgeError extends Error {\n+    readonly name = 'NativeBridgeError'\n+\n+    constructor(\n+      readonly code: string,\n+      readonly capability: string,\n+      message: string,\n+    ) {\n+      super(message)\n+    }\n+  }\n+\n+  return { ...bridgeMocks, NativeBridgeError }\n+})\n \n import BottomNav from './BottomNav'\n \n+const buddyQr = 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000'\n+\n+function LocationProbe() {\n+  const location = useLocation()\n+  return (\n+    <output\n+      data-testid=\"current-route\"\n+      data-path={location.pathname}\n+      data-state={JSON.stringify(location.state ?? null)}\n+    />\n+  )\n+}\n+\n afterEach(() => {\n   vi.useRealTimers()\n-  bridgeMocks.scanCode.mockClear()\n+  vi.unstubAllEnvs()\n+  bridgeMocks.scanCode.mockReset()\n   bridgeMocks.getNativeBridgeDiagnostics.mockReset()\n+  bridgeMocks.getNativeHost.mockReset()\n+  bridgeMocks.getNativeHost.mockReturnValue('android')\n })\n \n function renderNav() {\n   return render(\n     <MemoryRouter initialEntries={['/']}>\n       <BottomNav />\n+      <LocationProbe />\n     </MemoryRouter>,\n   )\n }\n \n+function currentPath() {\n+  return screen.getByTestId('current-route').getAttribute('data-path')\n+}\n+\n describe('BottomNav middle scan item', () => {\n   it('disables the scan button in a browser host with no scanCode capability', () => {\n     bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n@@ -40,21 +83,145 @@ describe('BottomNav middle scan item', () => {\n     expect(bridgeMocks.scanCode).not.toHaveBeenCalled()\n   })\n \n-  it('calls the shared scanCode capability when the native host supports it', async () => {\n+  it('routes only a trusted Android buddy QR into the existing invitation preview', async () => {\n+    vi.stubEnv('VITE_BUDDY_PUBLIC_ORIGIN', 'https://kbs.3cgroup.cn')\n     bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n       capabilities: { scanCode: true },\n     })\n+    bridgeMocks.scanCode.mockResolvedValueOnce({\n+      code: buddyQr,\n+      scanType: 'qr',\n+      isDevice: false,\n+    })\n \n     renderNav()\n+    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n \n-    const button = screen.getByRole('button', { name: '扫码' }) as HTMLButtonElement\n-    expect(button.disabled).toBe(false)\n+    await waitFor(() => expect(currentPath()).toBe('/buddy/accept'))\n+    expect(JSON.parse(screen.getByTestId('current-route').getAttribute('data-state') ?? 'null'))\n+      .toEqual({\n+        buddyScan: {\n+          source: 'native-buddy-recognition',\n+          raw: buddyQr,\n+        },\n+      })\n+    expect(bridgeMocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n+  })\n+\n+  it('shows a visible unsupported-code message for unknown non-device QR data', async () => {\n+    vi.stubEnv('VITE_BUDDY_PUBLIC_ORIGIN', 'https://kbs.3cgroup.cn')\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n+    })\n+    bridgeMocks.scanCode.mockResolvedValueOnce({\n+      code: 'https://untrusted.example/anything',\n+      scanType: 'qr',\n+      isDevice: false,\n+    })\n \n+    renderNav()\n+    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n+\n+    expect(await screen.findByText('暂不支持此二维码')).toBeTruthy()\n+    expect(currentPath()).toBe('/')\n+  })\n+\n+  it('leaves Native-owned device results untouched, including vending results without consumeUse', async () => {\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n+    })\n+    renderNav()\n+    const button = screen.getByRole('button', { name: '扫码' })\n+\n+    bridgeMocks.scanCode.mockResolvedValueOnce({\n+      code: 'DEVICE-QR-011',\n+      scanType: 'qr',\n+      consumeUse: '11',\n+    })\n+    fireEvent.click(button)\n+    await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false))\n+    expect(bridgeMocks.scanCode).toHaveBeenCalledTimes(1)\n+    expect(currentPath()).toBe('/')\n+    expect(screen.queryByText('暂不支持此二维码')).toBeNull()\n+\n+    bridgeMocks.scanCode.mockResolvedValueOnce({\n+      code: 'DEVICE-QR-017',\n+      scanType: 'qr',\n+    })\n     fireEvent.click(button)\n+    await waitFor(() => expect(bridgeMocks.scanCode).toHaveBeenCalledTimes(2))\n+    expect(currentPath()).toBe('/')\n+    expect(screen.queryByText('暂不支持此二维码')).toBeNull()\n+  })\n+\n+  it('does not infer a new iOS scan-dispatch contract from a legacy raw result', async () => {\n+    bridgeMocks.getNativeHost.mockReturnValue('ios')\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n+    })\n+    bridgeMocks.scanCode.mockResolvedValueOnce({\n+      code: buddyQr,\n+      isDevice: false,\n+    })\n+\n+    renderNav()\n+    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n+\n+    expect(await screen.findByText('当前 App 尚未提供可验证的扫码分流能力')).toBeTruthy()\n+    expect(currentPath()).toBe('/')\n+  })\n+\n+  it('returns quietly on cancellation', async () => {\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n+    })\n+    const { NativeBridgeError } = await import('../../services/nativeBridge')\n+    bridgeMocks.scanCode.mockRejectedValueOnce(\n+      new NativeBridgeError('native-cancelled', 'scanCode', 'cancelled'),\n+    )\n+\n+    renderNav()\n+    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n+\n+    await waitFor(() => expect(bridgeMocks.scanCode).toHaveBeenCalledTimes(1))\n+    expect(currentPath()).toBe('/')\n+    expect(screen.queryByRole('alert')).toBeNull()\n+  })\n+\n+  it('shows recoverable feedback when camera permission is denied', async () => {\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n+    })\n+    const { NativeBridgeError } = await import('../../services/nativeBridge')\n+    bridgeMocks.scanCode.mockRejectedValueOnce(\n+      new NativeBridgeError('native-permission-denied', 'scanCode', 'permission denied'),\n+    )\n+\n+    renderNav()\n+    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n \n-    await waitFor(() => {\n-      expect(bridgeMocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n+    const alert = await screen.findByRole('alert')\n+    expect(alert.textContent).toContain('请允许相机权限后重试')\n+    expect(currentPath()).toBe('/')\n+  })\n+\n+  it('prevents duplicate Native scan invocations while the first scan is pending', async () => {\n+    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true },\n     })\n+    let resolveScan: ((value: { code: string; isDevice: false }) => void) | undefined\n+    bridgeMocks.scanCode.mockReturnValueOnce(new Promise(resolve => {\n+      resolveScan = resolve\n+    }))\n+\n+    renderNav()\n+    const button = screen.getByRole('button', { name: '扫码' })\n+    fireEvent.click(button)\n+    fireEvent.click(button)\n+\n+    expect(bridgeMocks.scanCode).toHaveBeenCalledTimes(1)\n+    resolveScan?.({ code: 'unknown-code', isDevice: false })\n+    expect(await screen.findByText('暂不支持此二维码')).toBeTruthy()\n   })\n \n   it('stops background bridge polling after the late-injection discovery window', () => {\n@@ -77,19 +244,4 @@ describe('BottomNav middle scan item', () => {\n     expect(bridgeMocks.getNativeBridgeDiagnostics.mock.calls.length).toBe(callsAfterDiscoveryWindow)\n     unmount()\n   })\n-\n-  it('silently swallows scan cancellation without leaving the current page', async () => {\n-    bridgeMocks.getNativeBridgeDiagnostics.mockReturnValue({\n-      capabilities: { scanCode: true },\n-    })\n-    bridgeMocks.scanCode.mockRejectedValueOnce(new Error('cancelled'))\n-\n-    renderNav()\n-\n-    fireEvent.click(screen.getByRole('button', { name: '扫码' }))\n-\n-    await waitFor(() => {\n-      expect(bridgeMocks.scanCode).toHaveBeenCalledTimes(1)\n-    })\n-  })\n })\ndiff --git a/src/components/mobile/BottomNav.tsx b/src/components/mobile/BottomNav.tsx\nindex 25750c0..94af356 100644\n--- a/src/components/mobile/BottomNav.tsx\n+++ b/src/components/mobile/BottomNav.tsx\n@@ -1,9 +1,15 @@\n-import { useCallback, useEffect, useState } from 'react'\n+import { useCallback, useEffect, useRef, useState } from 'react'\n import { useLocation, useNavigate } from 'react-router-dom'\n-import { BottomNavigation } from '../ui'\n+import { BottomNavigation, Toast } from '../ui'\n import { LEGACY_TAB_ITEMS } from '../../app/router/routes'\n import { FORMAL_H5_TAB_ROUTES } from '../../app/router/routeScope'\n-import { getNativeBridgeDiagnostics, scanCode } from '../../services/nativeBridge'\n+import {\n+  getNativeBridgeDiagnostics,\n+  getNativeHost,\n+  NativeBridgeError,\n+  scanCode,\n+} from '../../services/nativeBridge'\n+import { parseBuddyInviteCode } from '../../services/buddyRelations'\n \n // 主入口只消费 formal-H5 Tab 路由；Native reference 使用独立 legacy 导航。\n const mainItems = FORMAL_H5_TAB_ROUTES.flatMap((route) =>\n@@ -36,6 +42,11 @@ const SCAN_TAB_PATH = mainItems.find((item) => item.fab)?.value\n const BRIDGE_DISCOVERY_INTERVAL_MS = 500\n const BRIDGE_DISCOVERY_MAX_ATTEMPTS = 20\n \n+type ScanFeedback = {\n+  message: string\n+  status: 'warning' | 'danger'\n+}\n+\n export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'legacy' }) {\n   const location = useLocation()\n   const navigate = useNavigate()\n@@ -46,6 +57,9 @@ export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'le\n   const [scanSupported, setScanSupported] = useState(\n     () => getNativeBridgeDiagnostics().capabilities.scanCode,\n   )\n+  const [scanPending, setScanPending] = useState(false)\n+  const [scanFeedback, setScanFeedback] = useState<ScanFeedback | null>(null)\n+  const scanInFlight = useRef(false)\n \n   useEffect(() => {\n     if (isLegacy || scanSupported) return\n@@ -80,6 +94,12 @@ export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'le\n     }\n   }, [isLegacy, scanSupported])\n \n+  useEffect(() => {\n+    if (!scanFeedback) return\n+    const timer = window.setTimeout(() => setScanFeedback(null), 2200)\n+    return () => window.clearTimeout(timer)\n+  }, [scanFeedback])\n+\n   // 主入口：子路径仍高亮所属一级 Tab\n   // 历史入口：根据当前路径匹配对应 Tab key\n   const active = isLegacy\n@@ -90,14 +110,77 @@ export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'le\n \n   const handleScan = useCallback(async () => {\n     // 浏览器 / 当前 App 版本未注入 scanCode 时保持不可用，不臆造 Web camera fallback。\n-    if (!getNativeBridgeDiagnostics().capabilities.scanCode) return\n+    if (!getNativeBridgeDiagnostics().capabilities.scanCode || scanInFlight.current) return\n+\n+    scanInFlight.current = true\n+    setScanPending(true)\n+    setScanFeedback(null)\n \n     try {\n-      await scanCode({ scanType: 'qr' })\n-    } catch {\n-      // 扫码取消 / 失败 / 超时均静默回落到当前页，不新增反馈链路。\n+      const host = getNativeHost()\n+      const result = await scanCode({ scanType: 'qr' })\n+\n+      // 当前 feat/1109 的扫码分流合同已由 Android 明确提供。iOS 当前分支没有\n+      // scanCode Native 实现证据，不根据旧 H5 adapter 的存在推断其支持该能力。\n+      if (host !== 'android') {\n+        setScanFeedback({\n+          message: '当前 App 尚未提供可验证的扫码分流能力',\n+          status: 'warning',\n+        })\n+        return\n+      }\n+\n+      // 设备页与设备启动/取货事务始终由 Native 管理。部分设备（如 use=17）\n+      // 回调不带 consumeUse；缺少 isDevice=false 时绝不能当作 H5 邀请二维码。\n+      if (\n+        result.isDevice === true ||\n+        (typeof result.consumeUse === 'string' && result.consumeUse.length > 0)\n+      ) {\n+        return\n+      }\n+      if (result.isDevice !== false) return\n+\n+      // 只有官方域名下的固定 UUID 邀请 URL 才进入现有搭子预览；其余未知码拒绝执行。\n+      try {\n+        parseBuddyInviteCode(\n+          result.code,\n+          import.meta.env.VITE_BUDDY_PUBLIC_ORIGIN ?? '',\n+        )\n+      } catch {\n+        setScanFeedback({ message: '暂不支持此二维码', status: 'warning' })\n+        return\n+      }\n+\n+      navigate('/buddy/accept', {\n+        state: {\n+          buddyScan: {\n+            source: 'native-buddy-recognition',\n+            raw: result.code,\n+          },\n+        },\n+      })\n+    } catch (error) {\n+      if (error instanceof NativeBridgeError) {\n+        if (error.code === 'native-cancelled') return\n+        if (error.code === 'native-permission-denied') {\n+          setScanFeedback({ message: '请允许相机权限后重试', status: 'danger' })\n+          return\n+        }\n+        if (\n+          error.code === 'bridge-disabled' ||\n+          error.code === 'bridge-unsupported' ||\n+          error.code === 'capability-unsupported'\n+        ) {\n+          setScanFeedback({ message: '当前 App 版本暂不支持扫码', status: 'warning' })\n+          return\n+        }\n+      }\n+      setScanFeedback({ message: '扫码失败，请重试', status: 'danger' })\n+    } finally {\n+      scanInFlight.current = false\n+      setScanPending(false)\n     }\n-  }, [])\n+  }, [navigate])\n \n   const handleChange = (value: string) => {\n     if (isLegacy) {\n@@ -122,15 +205,24 @@ export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'le\n   const renderedItems = isLegacy\n     ? items\n     : items.map((item) =>\n-        item.value === SCAN_TAB_PATH ? { ...item, disabled: !scanSupported } : item,\n+        item.value === SCAN_TAB_PATH\n+          ? { ...item, disabled: !scanSupported || scanPending }\n+          : item,\n       )\n \n   return (\n-    <BottomNavigation\n-      items={renderedItems}\n-      value={active}\n-      onChange={handleChange}\n-      className={`relative z-40 w-full shrink-0 ${isLegacy ? 'mx-auto max-w-legacy-shell' : ''}`}\n-    />\n+    <>\n+      <BottomNavigation\n+        items={renderedItems}\n+        value={active}\n+        onChange={handleChange}\n+        className={`relative z-40 w-full shrink-0 ${isLegacy ? 'mx-auto max-w-legacy-shell' : ''}`}\n+      />\n+      {scanFeedback && (\n+        <div className=\"fixed bottom-[calc(64px+env(safe-area-inset-bottom)+12px)] left-1/2 z-[60] -translate-x-1/2\">\n+          <Toast message={scanFeedback.message} status={scanFeedback.status} />\n+        </div>\n+      )}\n+    </>\n   )\n }\ndiff --git a/src/pages/BridgeLab.test.tsx b/src/pages/BridgeLab.test.tsx\nindex f7b8213..6535e69 100644\n--- a/src/pages/BridgeLab.test.tsx\n+++ b/src/pages/BridgeLab.test.tsx\n@@ -164,7 +164,7 @@ describe('Bridge Lab page', () => {\n     expect(screen.queryByText(/bridge-lab-scan/)).toBeNull()\n   })\n \n-  it('shows scan callback correlation, actual endpoint, both stages, and redacts the QR text', async () => {\n+  it('shows the single terminal scan callback, actual endpoint, and redacts the QR text', async () => {\n     usePlatform('android')\n     let request: { scanType: string; callbackId: string } | undefined\n     labWindow.androidBridge = {\n@@ -194,27 +194,19 @@ describe('Bridge Lab page', () => {\n     labWindow.androidBridgeCallback?.(request!.callbackId, {\n       code: 0,\n       message: 'ok',\n-      data: { text: 'buddy-qr-private-value', scanType: 'qr' },\n-    })\n-\n-    await waitFor(() => expect(logs.textContent).toContain('intermediate'))\n-    expect(logs.textContent).toContain('\"text\": \"[REDACTED]\"')\n-    expect(logs.textContent).not.toContain('buddy-qr-private-value')\n-\n-    labWindow.androidBridgeCallback?.(request!.callbackId, {\n-      code: 0,\n-      message: 'ok',\n-      data: { text: 'buddy-qr-private-value', scanType: 'qr' },\n+      data: { text: 'buddy-qr-private-value', scanType: 'qr', isDevice: false },\n     })\n \n     await waitFor(() => {\n       expect(logs.textContent).toContain('terminal')\n       expect(logs.textContent).toContain('callbackCount')\n     })\n+    expect(logs.textContent).toContain('\"text\": \"[REDACTED]\"')\n+    expect(logs.textContent).toContain('\"isDevice\": false')\n     expect(logs.textContent).not.toContain('buddy-qr-private-value')\n   })\n \n-  it('can invoke a registered H032 capability with editable JSON input', async () => {\n+    it('can invoke a registered H032 capability with editable JSON input', async () => {\n     usePlatform('android')\n     labWindow.androidBridge = {\n       copyText(payload: unknown) {\ndiff --git a/src/pages/ConfirmVerify.nativeResult.test.tsx b/src/pages/ConfirmVerify.nativeResult.test.tsx\nindex e9fc4dc..cf46476 100644\n--- a/src/pages/ConfirmVerify.nativeResult.test.tsx\n+++ b/src/pages/ConfirmVerify.nativeResult.test.tsx\n@@ -1,12 +1,12 @@\n import { describe, expect, it } from 'vitest'\n-import { fireEvent, render, screen } from '@testing-library/react'\n+import { render, screen } from '@testing-library/react'\n import { MemoryRouter, Route, Routes } from 'react-router-dom'\n \n-import { VERIFY_VOUCHER_FIXTURE } from '../app/fixtures'\n+import { VERIFY_FEEDBACK, VERIFY_VOUCHER_FIXTURE } from '../app/fixtures'\n import ConfirmVerify from './ConfirmVerify'\n \n-describe('ConfirmVerify Native transaction result', () => {\n-  it('shows the completed H5 result without presenting fixture store details as real Native data', () => {\n+describe('ConfirmVerify Native scan result boundary', () => {\n+  it('does not treat a legacy Native scan callback as completed card redemption', () => {\n     render(\n       <MemoryRouter\n         initialEntries={[\n@@ -26,12 +26,13 @@ describe('ConfirmVerify Native transaction result', () => {\n       </MemoryRouter>,\n     )\n \n-    const returnButton = screen.getByRole('button', { name: '返回卡包' })\n-    expect(returnButton).toBeTruthy()\n-    expect(screen.queryByText('即将核销此券')).toBeNull()\n-    expect(screen.queryByText(VERIFY_VOUCHER_FIXTURE.store)).toBeNull()\n-\n-    fireEvent.click(returnButton)\n-    expect(screen.getByText('real-card-route')).toBeTruthy()\n+    expect(screen.getByText('即将核销此券')).toBeTruthy()\n+    expect(screen.getByText(VERIFY_VOUCHER_FIXTURE.store)).toBeTruthy()\n+    expect(screen.queryByRole('button', { name: '返回卡包' })).toBeNull()\n+    expect(screen.queryByText(VERIFY_FEEDBACK.done.title)).toBeNull()\n+    expect(screen.getByText('真实核销服务尚未接入，此页面暂不能执行核销')).toBeTruthy()\n+    expect(\n+      (screen.getByRole('button', { name: '真实核销暂未接入' }) as HTMLButtonElement).disabled,\n+    ).toBe(true)\n   })\n })\ndiff --git a/src/pages/ConfirmVerify.tsx b/src/pages/ConfirmVerify.tsx\nindex b6127c7..dcb2885 100644\n--- a/src/pages/ConfirmVerify.tsx\n+++ b/src/pages/ConfirmVerify.tsx\n@@ -1,11 +1,12 @@\n import { useEffect, useState } from 'react'\n-import { useLocation, useNavigate } from 'react-router-dom'\n+import { useNavigate } from 'react-router-dom'\n import { AlertCircle, Check, CheckCircle2, Store } from 'lucide-react'\n import DebugPanel from '../components/mobile/DebugPanel'\n import PageContainer from '../components/mobile/PageContainer'\n import { useFixtureState } from '../app/fixtures/useFixture'\n import { findRouteByPathname } from '../app/router/routes'\n import { VERIFY_FEEDBACK, VERIFY_VOUCHER_FIXTURE } from '../app/fixtures'\n+import { runtimePolicy } from '../app/config/runtime'\n \n /**\n  * 确认核销（#67 防误触确认 / 已核销 / 重复核销）\n@@ -17,23 +18,9 @@ import { VERIFY_FEEDBACK, VERIFY_VOUCHER_FIXTURE } from '../app/fixtures'\n  */\n export default function ConfirmVerify() {\n   const navigate = useNavigate()\n-  const location = useLocation()\n   const route = findRouteByPathname('/card/verify/confirm')\n   const { state } = useFixtureState(route)\n   const voucher = VERIFY_VOUCHER_FIXTURE\n-  const nativeScanCode =\n-    location.state &&\n-    typeof location.state === 'object' &&\n-    typeof (location.state as { nativeScanCode?: unknown }).nativeScanCode === 'string'\n-      ? (location.state as { nativeScanCode: string }).nativeScanCode\n-      : null\n-  const nativeVerifyResult =\n-    location.state &&\n-    typeof location.state === 'object' &&\n-    (location.state as { nativeVerifyResult?: unknown }).nativeVerifyResult === 'done'\n-      ? 'done'\n-      : null\n-\n   const [toast, setToast] = useState(false)\n \n   useEffect(() => {\n@@ -42,10 +29,9 @@ export default function ConfirmVerify() {\n     return () => window.clearTimeout(id)\n   }, [toast, navigate])\n \n-  const result =\n-    nativeVerifyResult ??\n-    (state?.key === 'done' || state?.key === 'repeat' ? state.key : null)\n-  const isNativeTransactionResult = nativeVerifyResult === 'done'\n+  // URL fixture states are for deterministic visual review only; Native scan callbacks\n+  // never prove that a card voucher has been redeemed.\n+  const result = state?.key === 'done' || state?.key === 'repeat' ? state.key : null\n \n   if (result) {\n     const copy = result === 'done' ? VERIFY_FEEDBACK.done : VERIFY_FEEDBACK.repeat\n@@ -66,22 +52,20 @@ export default function ConfirmVerify() {\n           <p className=\"max-w-[260px] text-sm leading-6 text-text-secondary\">{copy.desc}</p>\n         </div>\n \n-        {!isNativeTransactionResult && (\n-          <section className=\"mx-4 mt-5 rounded-container bg-surface p-4 shadow-sm\">\n-            <div className=\"flex items-center gap-3\">\n-              <Store className=\"h-5 w-5 flex-none text-text-tertiary\" aria-hidden />\n-              <div className=\"min-w-0\">\n-                <p className=\"truncate text-sm text-text-primary\">{voucher.store}</p>\n-                <p className=\"mt-0.5 text-xs text-text-tertiary\">核销时间 {voucher.verifyTime}</p>\n-              </div>\n+        <section className=\"mx-4 mt-5 rounded-container bg-surface p-4 shadow-sm\">\n+          <div className=\"flex items-center gap-3\">\n+            <Store className=\"h-5 w-5 flex-none text-text-tertiary\" aria-hidden />\n+            <div className=\"min-w-0\">\n+              <p className=\"truncate text-sm text-text-primary\">{voucher.store}</p>\n+              <p className=\"mt-0.5 text-xs text-text-tertiary\">核销时间 {voucher.verifyTime}</p>\n             </div>\n-          </section>\n-        )}\n+          </div>\n+        </section>\n \n         <div className=\"mt-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-5\">\n           <button\n             type=\"button\"\n-            onClick={() => navigate(isNativeTransactionResult ? '/card' : '/card?state=used')}\n+            onClick={() => navigate('/card?state=used')}\n             className=\"h-12 w-full rounded-full bg-primary text-base font-medium text-text-inverse active:bg-primary-pressed\"\n           >\n             返回卡包\n@@ -98,13 +82,16 @@ export default function ConfirmVerify() {\n     <PageContainer className=\"pb-[calc(1rem+env(safe-area-inset-bottom))]\">\n       <section\n         className=\"mt-4 flex flex-col items-center text-center\"\n-        data-verify-source={nativeScanCode !== null ? 'native-scan' : 'fixture'}\n       >\n         <span className=\"flex h-16 w-16 items-center justify-center rounded-full bg-success-bg text-success-text\">\n           <Check className=\"h-8 w-8\" />\n         </span>\n         <h2 className=\"mt-4 text-xl font-semibold text-text-primary\">即将核销此券</h2>\n-        <p className=\"mt-1 text-sm text-text-tertiary\">请与门店店员核对，确认后权益将立即扣减</p>\n+        <p className=\"mt-1 text-sm text-text-tertiary\">\n+          {runtimePolicy.fixtureQueriesEnabled\n+            ? '演示流程不会真实扣券，请勿作为实际核销凭证'\n+            : '真实核销服务尚未接入，此页面暂不能执行核销'}\n+        </p>\n       </section>\n \n       <section className=\"mt-6\" aria-label=\"核销设备\">\n@@ -156,10 +143,13 @@ export default function ConfirmVerify() {\n         </button>\n         <button\n           type=\"button\"\n-          onClick={() => setToast(true)}\n-          className=\"h-12 flex-[2] rounded-full bg-primary text-sm font-medium text-text-inverse active:bg-primary-pressed\"\n+          onClick={() => {\n+            if (runtimePolicy.fixtureQueriesEnabled) setToast(true)\n+          }}\n+          disabled={!runtimePolicy.fixtureQueriesEnabled}\n+          className=\"h-12 flex-[2] rounded-full bg-primary text-sm font-medium text-text-inverse active:bg-primary-pressed disabled:cursor-not-allowed disabled:opacity-50\"\n         >\n-          确认核销\n+          {runtimePolicy.fixtureQueriesEnabled ? '模拟核销（仅演示）' : '真实核销暂未接入'}\n         </button>\n       </div>\n \ndiff --git a/src/pages/ScanVerify.test.tsx b/src/pages/ScanVerify.test.tsx\nindex 299e9fa..18ba02c 100644\n--- a/src/pages/ScanVerify.test.tsx\n+++ b/src/pages/ScanVerify.test.tsx\n@@ -2,25 +2,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'\n import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n \n const mocks = vi.hoisted(() => ({\n-  navigate: vi.fn(),\n   scanCode: vi.fn(),\n   getNativeBridgeDiagnostics: vi.fn(),\n-  getNativeHost: vi.fn(),\n   closeWebView: vi.fn(() => Promise.resolve()),\n }))\n \n-vi.mock('react-router-dom', async (importOriginal) => {\n-  const actual = await importOriginal<typeof import('react-router-dom')>()\n-  return {\n-    ...actual,\n-    useNavigate: () => mocks.navigate,\n-  }\n-})\n-\n vi.mock('../services/nativeBridge', () => {\n   class NativeBridgeError extends Error {\n-    constructor(readonly code: string) {\n-      super(code)\n+    readonly name = 'NativeBridgeError'\n+\n+    constructor(readonly code: string, readonly capability: string, message: string) {\n+      super(message)\n     }\n   }\n \n@@ -28,7 +20,6 @@ vi.mock('../services/nativeBridge', () => {\n     NativeBridgeError,\n     scanCode: mocks.scanCode,\n     getNativeBridgeDiagnostics: mocks.getNativeBridgeDiagnostics,\n-    getNativeHost: mocks.getNativeHost,\n     closeWebView: mocks.closeWebView,\n   }\n })\n@@ -36,15 +27,12 @@ vi.mock('../services/nativeBridge', () => {\n import ScanVerify from './ScanVerify'\n \n afterEach(() => {\n-  mocks.navigate.mockReset()\n   mocks.scanCode.mockReset()\n   mocks.getNativeBridgeDiagnostics.mockReset()\n-  mocks.getNativeHost.mockReset()\n-  mocks.getNativeHost.mockReturnValue('browser')\n   mocks.closeWebView.mockClear()\n })\n \n-describe('ScanVerify', () => {\n+describe('ScanVerify card-voucher boundary', () => {\n   it('shows explicit unsupported state instead of simulated scan success', () => {\n     mocks.getNativeBridgeDiagnostics.mockReturnValue({\n       capabilities: {\n@@ -96,99 +84,120 @@ describe('ScanVerify', () => {\n     })\n   })\n \n-  it('waits for the confirmed Android transaction and resumes directly at the completed result state', async () => {\n-    mocks.getNativeHost.mockReturnValue('android')\n+  it('does not send a Native-owned device result into the card-voucher confirmation page', async () => {\n     mocks.getNativeBridgeDiagnostics.mockReturnValue({\n-      capabilities: {\n-        scanCode: true,\n-        closeWebView: false,\n-      },\n+      capabilities: { scanCode: true, closeWebView: false },\n+    })\n+    mocks.scanCode.mockResolvedValue({\n+      code: 'DEVICE-QR-011',\n+      scanType: 'qr',\n+      consumeUse: '11',\n     })\n-    mocks.scanCode.mockResolvedValue({ code: 'REAL-SCAN-CODE' })\n \n     render(<ScanVerify />)\n+    fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n+\n+    await waitFor(() => {\n+      expect(screen.getByText('已识别设备二维码，请按 App 设备页面提示操作')).toBeTruthy()\n+    })\n+    expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n+  })\n \n+  it('does not require consumeUse to avoid misclassifying the Native vending use-17 callback', async () => {\n+    mocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true, closeWebView: false },\n+    })\n+    mocks.scanCode.mockResolvedValue({\n+      code: 'DEVICE-QR-017',\n+      scanType: 'qr',\n+    })\n+\n+    render(<ScanVerify />)\n     fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n \n     await waitFor(() => {\n-      expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n+      expect(screen.getByText('当前扫码结果无法验证为卡券核销码，未发起核销')).toBeTruthy()\n     })\n-    expect(mocks.navigate).toHaveBeenCalledWith('/card/verify/confirm', {\n-      replace: true,\n-      state: {\n-        nativeScanCode: 'REAL-SCAN-CODE',\n-        nativeVerifyResult: 'done',\n-      },\n+  })\n+\n+  it('does not treat every non-device QR as a card voucher', async () => {\n+    mocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true, closeWebView: false },\n+    })\n+    mocks.scanCode.mockResolvedValue({\n+      code: 'UNKNOWN-NON-DEVICE-QR',\n+      scanType: 'qr',\n+      isDevice: false,\n+    })\n+\n+    render(<ScanVerify />)\n+    fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n+\n+    await waitFor(() => {\n+      expect(screen.getByText('暂不支持此卡券核销码，未发起核销')).toBeTruthy()\n     })\n   })\n \n-  it('keeps iOS on the existing confirmation flow until terminal transaction semantics are confirmed', async () => {\n-    mocks.getNativeHost.mockReturnValue('ios')\n+  it('does not infer a card-voucher result from a legacy raw iOS payload', async () => {\n     mocks.getNativeBridgeDiagnostics.mockReturnValue({\n-      capabilities: {\n-        scanCode: true,\n-        closeWebView: false,\n-      },\n+      capabilities: { scanCode: true, closeWebView: false },\n     })\n-    mocks.scanCode.mockResolvedValue({ code: 'IOS-SCAN-CODE' })\n+    mocks.scanCode.mockResolvedValue({ code: 'IOS-RAW-QR' })\n \n     render(<ScanVerify />)\n     fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n \n     await waitFor(() => {\n-      expect(mocks.navigate).toHaveBeenCalledWith('/card/verify/confirm', {\n-        state: { nativeScanCode: 'IOS-SCAN-CODE' },\n-      })\n+      expect(screen.getByText('当前扫码结果无法验证为卡券核销码，未发起核销')).toBeTruthy()\n     })\n-    expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n-    expect(mocks.navigate).not.toHaveBeenCalledWith(\n-      '/card/verify/confirm',\n-      expect.objectContaining({\n-        state: expect.objectContaining({ nativeVerifyResult: 'done' }),\n-      }),\n-    )\n   })\n \n-  it('keeps an unknown Native failure on the scan page', async () => {\n+  it('shows a recoverable failure instead of leaving the scan page busy', async () => {\n     mocks.getNativeBridgeDiagnostics.mockReturnValue({\n-      capabilities: {\n-        scanCode: true,\n-        closeWebView: false,\n-      },\n+      capabilities: { scanCode: true, closeWebView: false },\n     })\n     mocks.scanCode.mockRejectedValue(new Error('native failed'))\n \n     render(<ScanVerify />)\n-\n     fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n \n     await waitFor(() => {\n       expect(screen.getByText('扫码失败，请重试')).toBeTruthy()\n     })\n-    expect(mocks.navigate).not.toHaveBeenCalled()\n+    expect((screen.getByRole('button', { name: '开始扫码核销' }) as HTMLButtonElement).disabled).toBe(false)\n   })\n \n-  it.each([\n-    ['native-cancelled', '已取消扫码'],\n-    ['native-permission-denied', '请允许相机权限后重试'],\n-  ] as const)('shows confirmed %s semantics without navigating', async (code, message) => {\n+  it('returns quietly when the user cancels the card scan', async () => {\n     mocks.getNativeBridgeDiagnostics.mockReturnValue({\n-      capabilities: {\n-        scanCode: true,\n-        closeWebView: false,\n-      },\n+      capabilities: { scanCode: true, closeWebView: false },\n+    })\n+    const { NativeBridgeError } = await import('../services/nativeBridge')\n+    mocks.scanCode.mockRejectedValue(\n+      new NativeBridgeError('native-cancelled', 'scanCode', 'cancelled'),\n+    )\n+\n+    render(<ScanVerify />)\n+    fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n+\n+    await waitFor(() => expect(mocks.scanCode).toHaveBeenCalledTimes(1))\n+    expect(screen.queryByText('已取消扫码')).toBeNull()\n+    expect(screen.getByText('将卡券二维码对准扫描框，点击开始扫码')).toBeTruthy()\n+  })\n+\n+  it('shows a recoverable message when camera permission is denied', async () => {\n+    mocks.getNativeBridgeDiagnostics.mockReturnValue({\n+      capabilities: { scanCode: true, closeWebView: false },\n     })\n     const { NativeBridgeError } = await import('../services/nativeBridge')\n     mocks.scanCode.mockRejectedValue(\n-      new NativeBridgeError(code, 'scanCode', 'confirmed Native failure'),\n+      new NativeBridgeError('native-permission-denied', 'scanCode', 'permission denied'),\n     )\n \n     render(<ScanVerify />)\n     fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n \n     await waitFor(() => {\n-      expect(screen.getByText(message)).toBeTruthy()\n+      expect(screen.getByText('请允许相机权限后重试')).toBeTruthy()\n     })\n-    expect(mocks.navigate).not.toHaveBeenCalled()\n   })\n })\ndiff --git a/src/pages/ScanVerify.tsx b/src/pages/ScanVerify.tsx\nindex 0eac3a8..ced06b6 100644\n--- a/src/pages/ScanVerify.tsx\n+++ b/src/pages/ScanVerify.tsx\n@@ -1,21 +1,13 @@\n import { useEffect, useState } from 'react'\n-import { useNavigate } from 'react-router-dom'\n import { ScanLine } from 'lucide-react'\n import HostCloseButton from '../components/mobile/HostCloseButton'\n import {\n   getNativeBridgeDiagnostics,\n-  getNativeHost,\n   NativeBridgeError,\n   scanCode,\n } from '../services/nativeBridge'\n \n-export interface ScanVerifyNavigationState {\n-  nativeScanCode: string\n-  nativeVerifyResult?: 'done'\n-}\n-\n export default function ScanVerify() {\n-  const navigate = useNavigate()\n   const [supported, setSupported] = useState(\n     () => getNativeBridgeDiagnostics().capabilities.scanCode,\n   )\n@@ -47,30 +39,24 @@ export default function ScanVerify() {\n     setError(null)\n \n     try {\n-      const host = getNativeHost()\n       const result = await scanCode({ scanType: 'qr' })\n \n-      if (host === 'android') {\n-        const state: ScanVerifyNavigationState = {\n-          nativeScanCode: result.code,\n-          nativeVerifyResult: 'done',\n-        }\n-        // H037 has Android evidence that scanCode resolves only after the device transaction\n-        // completes, so H5 resumes directly at the completed result state.\n-        navigate('/card/verify/confirm', { replace: true, state })\n-        return\n+      if (\n+        result.isDevice === true ||\n+        (typeof result.consumeUse === 'string' && result.consumeUse.length > 0)\n+      ) {\n+        setError('已识别设备二维码，请按 App 设备页面提示操作')\n+      } else if (result.isDevice === false) {\n+        // H5 仓库和已读后台合同没有定义可验证的卡券二维码格式；不把任意非设备码当核销码。\n+        setError('暂不支持此卡券核销码，未发起核销')\n+      } else {\n+        // use=17 的 Native 回调只有 text / scanType；缺少业务类型时不伪造核销成功。\n+        setError('当前扫码结果无法验证为卡券核销码，未发起核销')\n       }\n-\n-      // iOS still has only the retained one-callback scanner contract in H5 evidence. Until Native\n-      // terminal transaction semantics are independently confirmed, preserve the existing\n-      // confirmation flow and never label a scan recognition as completed verification.\n-      const state: ScanVerifyNavigationState = { nativeScanCode: result.code }\n-      navigate('/card/verify/confirm', { state })\n     } catch (error) {\n       if (error instanceof NativeBridgeError) {\n-        if (error.code === 'native-cancelled') {\n-          setError('已取消扫码')\n-        } else if (error.code === 'native-permission-denied') {\n+        if (error.code === 'native-cancelled') return\n+        if (error.code === 'native-permission-denied') {\n           setError('请允许相机权限后重试')\n         } else {\n           setError('扫码失败，请重试')\n@@ -78,6 +64,7 @@ export default function ScanVerify() {\n       } else {\n         setError('扫码失败，请重试')\n       }\n+    } finally {\n       setScanning(false)\n     }\n   }\n@@ -86,7 +73,7 @@ export default function ScanVerify() {\n     ? '当前 App 版本暂不支持扫码'\n     : scanning\n       ? '正在调用系统扫码…'\n-      : error ?? '将二维码对准扫描框，点击开始扫码'\n+      : error ?? '将卡券二维码对准扫描框，点击开始扫码'\n \n   return (\n     <div className=\"relative min-h-full bg-black text-white\">\n@@ -124,7 +111,7 @@ export default function ScanVerify() {\n         <div className=\"relative z-10 mt-10 text-center\">\n           <p className=\"text-lg font-semibold\">请将二维码对准扫描框</p>\n           <p className=\"mt-1 text-sm text-white/60\">\n-            {supported ? '扫码后请按页面提示完成设备操作' : '请升级到支持扫码能力的 App 版本'}\n+            {supported ? '扫码后先校验二维码类型；未识别的码不会执行核销' : '请升级到支持扫码能力的 App 版本'}\n           </p>\n         </div>\n       </main>\ndiff --git a/src/services/nativeBridge.scanTransaction.contract.test.ts b/src/services/nativeBridge.scanTransaction.contract.test.ts\nindex 5cf12f0..dca50e7 100644\n--- a/src/services/nativeBridge.scanTransaction.contract.test.ts\n+++ b/src/services/nativeBridge.scanTransaction.contract.test.ts\n@@ -18,6 +18,7 @@ afterEach(() => {\n   delete hostWindow.androidBridge\n   delete hostWindow.androidBridgeCallback\n   delete hostWindow.nativeBridgeCallback\n+  delete hostWindow.iosBridge\n })\n \n function installAndroidScanHost() {\n@@ -34,45 +35,65 @@ function installAndroidScanHost() {\n   }\n }\n \n-describe('scanCode Android H037 transaction contract', () => {\n-  it('keeps the first scan-success callback pending and resolves only after device success', async () => {\n+describe('scanCode Android feat/1109 callback contract', () => {\n+  it('settles the first non-device recognition callback and preserves its routing metadata', async () => {\n     const getRequest = installAndroidScanHost()\n-    const promise = scanCode({ scanType: 'all' })\n+    const promise = scanCode({ scanType: 'qr' })\n     const request = getRequest()\n-\n-    let settled = false\n-    promise.finally(() => {\n-      settled = true\n-    })\n+    const raw = 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000'\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n       code: 0,\n       message: 'ok',\n-      data: { text: 'DEVICE-QR-001', scanType: 'qr' },\n+      data: { text: raw, scanType: 'qr', isDevice: false },\n     })\n \n-    await Promise.resolve()\n-    expect(settled).toBe(false)\n+    await expect(promise).resolves.toEqual({\n+      code: raw,\n+      scanType: 'qr',\n+      isDevice: false,\n+    })\n+  })\n+\n+  it('settles a Native-owned device start callback and preserves consumeUse when present', async () => {\n+    const getRequest = installAndroidScanHost()\n+    const promise = scanCode({ scanType: 'qr' })\n+    const request = getRequest()\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n       code: 0,\n       message: 'ok',\n-      data: { text: 'DEVICE-QR-001', scanType: 'qr' },\n+      data: { text: 'DEVICE-QR-011', scanType: 'qr', consumeUse: '11' },\n     })\n \n-    await expect(promise).resolves.toEqual({ code: 'DEVICE-QR-001' })\n+    await expect(promise).resolves.toEqual({\n+      code: 'DEVICE-QR-011',\n+      scanType: 'qr',\n+      consumeUse: '11',\n+    })\n   })\n \n-  it('rejects when the device stage fails after a successful scan', async () => {\n+  it('settles vending use-17 callbacks without inventing isDevice or consumeUse metadata', async () => {\n     const getRequest = installAndroidScanHost()\n-    const promise = scanCode({ scanType: 'all' })\n+    const promise = scanCode({ scanType: 'qr' })\n     const request = getRequest()\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n       code: 0,\n       message: 'ok',\n-      data: { text: 'DEVICE-QR-002', scanType: 'qr' },\n+      data: { text: 'DEVICE-QR-017', scanType: 'qr' },\n+    })\n+\n+    await expect(promise).resolves.toEqual({\n+      code: 'DEVICE-QR-017',\n+      scanType: 'qr',\n     })\n+  })\n+\n+  it('rejects when the Native-owned device start fails', async () => {\n+    const getRequest = installAndroidScanHost()\n+    const promise = scanCode({ scanType: 'qr' })\n+    const request = getRequest()\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n       code: 5,\n@@ -91,9 +112,9 @@ describe('scanCode Android H037 transaction contract', () => {\n     [1, 'native-cancelled'],\n     [2, 'native-permission-denied'],\n     [3, 'native-permission-denied'],\n-  ] as const)('settles first-stage Native code %s immediately as %s', async (nativeCode, errorCode) => {\n+  ] as const)('settles Native code %s immediately as %s', async (nativeCode, errorCode) => {\n     const getRequest = installAndroidScanHost()\n-    const promise = scanCode({ scanType: 'all' })\n+    const promise = scanCode({ scanType: 'qr' })\n     const request = getRequest()\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n@@ -109,39 +130,45 @@ describe('scanCode Android H037 transaction contract', () => {\n     })\n   })\n \n-  it('cleans up the Android scan pending entry at the existing callback timeout', async () => {\n+  it('cleans the pending callback after the first terminal result', async () => {\n     vi.useFakeTimers()\n \n     const lateCallbackFallback = vi.fn()\n     hostWindow.androidBridgeCallback = lateCallbackFallback\n \n     const getRequest = installAndroidScanHost()\n-    const promise = scanCode({ scanType: 'all' })\n-    promise.catch(() => undefined)\n+    const promise = scanCode({ scanType: 'qr' })\n     const request = getRequest()\n \n     hostWindow.androidBridgeCallback?.(request.callbackId, {\n       code: 0,\n       message: 'ok',\n-      data: { text: 'DEVICE-QR-TIMEOUT', scanType: 'qr' },\n+      data: { text: 'UNKNOWN-QR', scanType: 'qr', isDevice: false },\n     })\n \n+    await expect(promise).resolves.toMatchObject({ code: 'UNKNOWN-QR', isDevice: false })\n+\n+    const latePayload = { code: 0, message: 'duplicate', data: {} }\n+    hostWindow.androidBridgeCallback?.(request.callbackId, latePayload)\n+    expect(lateCallbackFallback).toHaveBeenCalledWith(request.callbackId, latePayload)\n+\n     await vi.advanceTimersByTimeAsync(INJECTED_CALLBACK_TIMEOUT_MS)\n+    expect(lateCallbackFallback).toHaveBeenCalledTimes(1)\n+  })\n \n-    await expect(promise).rejects.toMatchObject({\n-      name: 'NativeBridgeError',\n-      capability: 'scanCode',\n-    })\n+  it('keeps the existing one-shot iOS adapter shape without inferring Android metadata', async () => {\n+    let request: CapturedScanRequest | undefined\n+    hostWindow.iosBridge = {\n+      scanCode(payload: unknown) {\n+        request = JSON.parse(payload as string) as CapturedScanRequest\n+      },\n+    }\n \n-    hostWindow.androidBridgeCallback?.(request.callbackId, {\n-      code: 0,\n-      message: 'late-device-result',\n-      data: { text: 'DEVICE-QR-TIMEOUT', scanType: 'qr' },\n-    })\n+    const promise = scanCode({ scanType: 'qr' })\n+    if (!request) throw new Error('iOS scanCode request was not captured')\n+\n+    hostWindow.nativeBridgeCallback?.(request.callbackId, JSON.stringify({ code: 'IOS-RAW-QR' }))\n \n-    expect(lateCallbackFallback).toHaveBeenCalledWith(\n-      request.callbackId,\n-      expect.objectContaining({ message: 'late-device-result' }),\n-    )\n+    await expect(promise).resolves.toEqual({ code: 'IOS-RAW-QR' })\n   })\n })\ndiff --git a/src/services/nativeBridge.test.ts b/src/services/nativeBridge.test.ts\nindex c858ea7..f5fdb45 100644\n--- a/src/services/nativeBridge.test.ts\n+++ b/src/services/nativeBridge.test.ts\n@@ -422,7 +422,7 @@ describe('JSBridge capability runtime', () => {\n   })\n \n \n-  it('correlates Android scanCode through androidBridgeCallback', async () => {\n+  it('correlates Android scanCode through androidBridgeCallback and preserves one-shot routing metadata', async () => {\n     const { scanCode, getNativeBridgeDiagnostics } = await loadBridge()\n     const received: Array<{ scanType: string; callbackId: string }> = []\n     const androidBridge = {\n@@ -434,22 +434,14 @@ describe('JSBridge capability runtime', () => {\n         }\n         received.push(request)\n         queueMicrotask(() => {\n-          // H037: first success is scan recognition only; the same callbackId remains pending.\n-          bridgeWindow.androidBridgeCallback?.(request.callbackId, {\n-            code: 0,\n-            message: 'ok',\n-            data: {\n-              text: 'QR-ANDROID-001',\n-              scanType: 'qr',\n-            },\n-          })\n-          // Native returns control to H5 only after the device transaction finishes.\n+          // Android feat/1109 marks non-device recognition explicitly and returns once.\n           bridgeWindow.androidBridgeCallback?.(request.callbackId, {\n             code: 0,\n             message: 'ok',\n             data: {\n               text: 'QR-ANDROID-001',\n               scanType: 'qr',\n+              isDevice: false,\n             },\n           })\n         })\n@@ -460,6 +452,8 @@ describe('JSBridge capability runtime', () => {\n     expect(getNativeBridgeDiagnostics().capabilities.scanCode).toBe(true)\n     await expect(scanCode({ scanType: 'all' })).resolves.toEqual({\n       code: 'QR-ANDROID-001',\n+      scanType: 'qr',\n+      isDevice: false,\n     })\n     expect(received).toEqual([\n       {\n@@ -469,7 +463,7 @@ describe('JSBridge capability runtime', () => {\n     ])\n   })\n \n-  it('supports qr/bar/all scanType values on iOS without changing the field name', async () => {\n+    it('supports qr/bar/all scanType values on iOS without changing the field name', async () => {\n     const { scanCode } = await loadBridge()\n     const received: Array<{ scanType: string; callbackId: string }> = []\n     bridgeWindow.iosBridge = {\ndiff --git a/src/services/nativeBridge/capabilities/scan.ts b/src/services/nativeBridge/capabilities/scan.ts\nindex 6468c15..53cd329 100644\n--- a/src/services/nativeBridge/capabilities/scan.ts\n+++ b/src/services/nativeBridge/capabilities/scan.ts\n@@ -27,6 +27,17 @@ function validateInput(input: NativeScanCodeInput): NativeScanCodeInput {\n   return input\n }\n \n+function asRecord(value: unknown): Record<string, unknown> | null {\n+  return value !== null && typeof value === 'object' && !Array.isArray(value)\n+    ? value as Record<string, unknown>\n+    : null\n+}\n+\n+/**\n+ * Normalize Android's current callback envelope while retaining the H5 adapter's\n+ * legacy one-shot { code: string } result shape. This parser does not imply that\n+ * iOS currently exposes a Native scanCode implementation.\n+ */\n function parseResult(payload: unknown): NativeScanCodeResult {\n   let parsed: unknown\n   try {\n@@ -35,51 +46,46 @@ function parseResult(payload: unknown): NativeScanCodeResult {\n     if (error instanceof NativeTransportError && error.code !== 'payload-invalid') throw error\n     parsed = parseJsonPayload(payload)\n   }\n-  const data = parsed !== null && typeof parsed === 'object'\n-    ? (parsed as { data?: unknown }).data\n-    : undefined\n-  const code = typeof (parsed as { code?: unknown })?.code === 'string'\n-    ? (parsed as { code: string }).code\n-    : data !== null && typeof data === 'object'\n-      ? (data as { text?: unknown }).text\n-      : undefined\n-  const nativeCode = (parsed as { code?: unknown })?.code\n-  if (typeof nativeCode === 'number' && nativeCode !== 0) {\n-    const errorCode = nativeCode === 1 ? 'native-cancelled' : nativeCode === 2 || nativeCode === 3 ? 'native-permission-denied' : 'native-failed'\n-    throw new NativeTransportError(errorCode, 'Native scanCode reported ' + (parsed as { message?: string }).message)\n-  }\n-  if (typeof code !== 'string' || !code) {\n+\n+  const envelope = asRecord(parsed)\n+  if (!envelope) {\n     throw new NativeTransportError(\n       'payload-invalid',\n-      'Native scanCode() result must be a JSON string with a string code field.',\n+      'Native scanCode() result must be a JSON object.',\n     )\n   }\n \n-  return { code }\n-}\n-\n-function isAndroidScanTerminalPayload(payload: unknown, callbackCount: number): boolean {\n-  // Current Android H5 voucher verification is a two-stage Native-owned transaction:\n-  // 1) scanner recognized a code, 2) device start/dispense finished. Only the second success\n-  // callback is terminal. A first-stage failure/cancel/permission result still settles immediately.\n-  if (callbackCount > 1) return true\n-\n-  try {\n-    const parsed = parseJsonPayload<unknown>(payload)\n-    if (parsed === null || typeof parsed !== 'object') return true\n-    if ('error' in parsed) return true\n+  const data = asRecord(envelope.data)\n+  const nativeCode = envelope.code\n+  if (typeof nativeCode === 'number' && nativeCode !== 0) {\n+    const errorCode =\n+      nativeCode === 1\n+        ? 'native-cancelled'\n+        : nativeCode === 2 || nativeCode === 3\n+          ? 'native-permission-denied'\n+          : 'native-failed'\n+    const message = typeof envelope.message === 'string' && envelope.message\n+      ? envelope.message\n+      : 'Native scanCode reported failure.'\n+    throw new NativeTransportError(errorCode, message)\n+  }\n \n-    const nativeCode = (parsed as { code?: unknown }).code\n-    const data = (parsed as { data?: unknown }).data\n-    const text =\n-      data !== null && typeof data === 'object'\n-        ? (data as { text?: unknown }).text\n-        : undefined\n+  const code = typeof nativeCode === 'string' && nativeCode.length > 0\n+    ? nativeCode\n+    : data?.text\n+  if (typeof code !== 'string' || code.length === 0) {\n+    throw new NativeTransportError(\n+      'payload-invalid',\n+      'Native scanCode() result must contain a non-empty QR text.',\n+    )\n+  }\n \n-    return !(nativeCode === 0 && typeof text === 'string' && text.length > 0)\n-  } catch {\n-    // Malformed payloads should settle and fail parsing instead of leaving the transaction pending.\n-    return true\n+  const scanType = data?.scanType\n+  return {\n+    code,\n+    ...(scanType === 'qr' || scanType === 'bar' || scanType === 'all' ? { scanType } : {}),\n+    ...(typeof data?.isDevice === 'boolean' ? { isDevice: data.isDevice } : {}),\n+    ...(typeof data?.consumeUse === 'string' ? { consumeUse: data.consumeUse } : {}),\n   }\n }\n \n@@ -92,7 +98,8 @@ const androidTransport = createCallbackInjectedObjectTransport<\n   callbackName: 'nativeBridgeCallback',\n   callbackAliases: ['androidBridgeCallback'],\n   serializeArgs: (input, callbackId) => [serializeJsonValue({ ...validateInput(input), callbackId })],\n-  isTerminalPayload: isAndroidScanTerminalPayload,\n+  // Android feat/1109 returns one callback per H5 scan invocation. Native device\n+  // screens remain Native-owned after their start callback; no second H5 callback is awaited.\n   parseResult,\n })\n \n@@ -104,6 +111,7 @@ const iosTransport = createCallbackInjectedObjectTransport<\n   methodName: 'scanCode',\n   callbackName: 'nativeBridgeCallback',\n   serializeArgs: (input, callbackId) => [serializeJsonValue({ ...validateInput(input), callbackId })],\n+  // Preserve the H5 adapter's existing one-shot parsing; never infer Android metadata on iOS.\n   parseResult,\n })\n \n@@ -125,7 +133,7 @@ export const scanCodeCapability = defineCapability<\n   name: 'scanCode',\n   platforms: ['android', 'ios'],\n   description:\n-    'Scan a QR code, barcode, or either through the confirmed Android/iOS injected-object contract.',\n+    'Scan a QR code, barcode, or either through the injected-object adapter where the host exposes scanCode.',\n   sensitiveResult: true,\n   resolve: resolveTransport,\n })\ndiff --git a/src/services/nativeBridge/types.ts b/src/services/nativeBridge/types.ts\nindex 8c4864d..56c543c 100644\n--- a/src/services/nativeBridge/types.ts\n+++ b/src/services/nativeBridge/types.ts\n@@ -31,7 +31,14 @@ export interface NativeScanCodeInput {\n }\n \n export interface NativeScanCodeResult {\n+  /** Original recognized QR/barcode text. */\n   code: string\n+  /** Native-reported scan mode, when the host includes it. */\n+  scanType?: NativeScanType\n+  /** Explicit false is required before H5 may dispatch a non-device QR on Android. */\n+  isDevice?: boolean\n+  /** Android device category when supplied by the Native-owned device detail flow. */\n+  consumeUse?: string\n }\n \n export interface NativeTakePhotoInput {\ndiff --git a/tests/e2e/bridge-lab.spec.ts b/tests/e2e/bridge-lab.spec.ts\nindex 7bcc433..e44278a 100644\n--- a/tests/e2e/bridge-lab.spec.ts\n+++ b/tests/e2e/bridge-lab.spec.ts\n@@ -59,7 +59,7 @@ test.describe('H027 Bridge Lab', () => {\n     await expect(logs).toContainText('\"success\": true')\n   })\n \n-  test('shows correlated Android scan callback stages without exposing QR text by default', async ({ page }) => {\n+  test('shows the single Android scan callback and redacts QR text by default', async ({ page }) => {\n     await page.addInitScript(() => {\n       const host = window as unknown as {\n         androidBridge?: { scanCode(payload: unknown): void }\n@@ -72,16 +72,13 @@ test.describe('H027 Bridge Lab', () => {\n             host.androidBridgeCallback?.(request.callbackId, {\n               code: 0,\n               message: 'ok',\n-              data: { text: 'e2e-buddy-qr-private', scanType: 'qr' },\n+              data: {\n+                text: 'e2e-buddy-qr-private',\n+                scanType: 'qr',\n+                isDevice: false,\n+              },\n             })\n           }, 0)\n-          setTimeout(() => {\n-            host.androidBridgeCallback?.(request.callbackId, {\n-              code: 0,\n-              message: 'ok',\n-              data: { text: 'e2e-buddy-qr-private', scanType: 'qr' },\n-            })\n-          }, 20)\n         },\n       }\n     })\n@@ -93,9 +90,9 @@ test.describe('H027 Bridge Lab', () => {\n     const logs = page.locator('[data-bridge-lab-logs]')\n     await expect(logs).toContainText('Native invoke · androidBridge.scanCode')\n     await expect(logs).toContainText('androidBridgeCallback')\n-    await expect(logs).toContainText('intermediate')\n     await expect(logs).toContainText('terminal')\n     await expect(logs).toContainText('[REDACTED]')\n+    await expect(logs).toContainText('\"isDevice\": false')\n     await expect(logs).not.toContainText('e2e-buddy-qr-private')\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-11-09T10:37:12.518Z",
                  "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": 1187,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T10:37:12.521Z",
                  "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": 1226,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T10:37:12.677Z",
                  "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": 1067,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T10:37:14.092Z",
                  "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": 1018,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T10:37:14.130Z",
                  "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": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1022,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T10:37:15.175Z",
                  "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": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1073,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T10:37:15.177Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-11-09T10:37:11.486Z",
    "duration": 4828.618,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}