{
  "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/5b1bc1fcaea8350bbc0afc723df61e71e24cf27c",
        "commitHash": "5b1bc1fcaea8350bbc0afc723df61e71e24cf27c",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/252",
        "prTitle": "chore(release): promote coupon gift integration dev → test",
        "prBaseHash": "31f5618fad8ab5a618f85f45d810026dc31c24d0",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/39234733212"
      },
      "gitCommit": {
        "shortHash": "5b1bc1f",
        "hash": "5b1bc1fcaea8350bbc0afc723df61e71e24cf27c",
        "subject": "Merge 7d5edae654df8ba966fbd7e8ea25f4b42657da2e into 31f5618fad8ab5a618f85f45d810026dc31c24d0",
        "body": "Merge 7d5edae654df8ba966fbd7e8ea25f4b42657da2e into 31f5618fad8ab5a618f85f45d810026dc31c24d0\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1792404242000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1792404242000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/docs/api-index.md b/docs/api-index.md\nindex c099358..da99106 100644\n--- a/docs/api-index.md\n+++ b/docs/api-index.md\n@@ -98,6 +98,7 @@\n | 今天签到 | `POST /api/signrecords/add` | 是 | 否 | `engineering/checkin-api-integration.md` |\n | 本月签到记录（`range=month`） | `GET /api/signrecords/index` | 是 | 否（裸数组） | `engineering/checkin-api-integration.md` |\n | 补签 | `POST /api/signrecords/makeup` | 是 | 否 | `engineering/checkin-api-integration.md` |\n+| 优惠卡转赠 | `POST /api/coupons/give` | 是 | 否 | `engineering/coupon-transfer-api-integration.md` |\n \n > `POST /api/signrecords/makeup` 已纳入最新《签到页面接口文档》；补签前仍必须先通过 Native 激励广告闸门，详见 `engineering/checkin-api-integration.md` §3、§5.1、§6。\n \ndiff --git a/docs/engineering/coupon-transfer-api-integration.md b/docs/engineering/coupon-transfer-api-integration.md\nnew file mode 100644\nindex 0000000..3cbbbcf\n--- /dev/null\n+++ b/docs/engineering/coupon-transfer-api-integration.md\n@@ -0,0 +1,61 @@\n+# 优惠卡转赠接口接入\n+\n+## 1. 契约\n+\n+`POST /api/coupons/give`\n+\n+```http\n+Authorization: Bearer <accessToken>\n+Content-Type: application/json\n+```\n+\n+```json\n+{\n+  \"log_id\": 1001,\n+  \"friend_id\": 2\n+}\n+```\n+\n+- `log_id`：本人未使用优惠卡记录 ID，来自 `POST /api/coupons/MyCoupons`（`type=unused`）的 `data.data[].id`。\n+- `friend_id`：接收方用户 ID，来自好友列表 `GET /api/friends/index` 的 `friend.id`。\n+- 赠送方身份由服务端按登录态取得；H5 不传 `user_id` / `mobile`，接收方手机号由服务端查询。\n+\n+成功：\n+\n+```json\n+{\n+  \"code\": 0,\n+  \"msg\": \"success\",\n+  \"status\": \"succ\",\n+  \"data\": { \"log_id\": 1001, \"friend_id\": 2 }\n+}\n+```\n+\n+## 2. 前端边界\n+\n+- 页面：`src/pages/CardShare.tsx`\n+- Service：`src/services/couponGift.ts`\n+- Mock：`src/mocks/handlers/couponGift.ts`\n+\n+页面只调用 `giveCoupon({ logId, friendId })`，不拼接 URL、不读取用户身份，也不在失败时退回 Mock。\n+\n+## 3. 页面状态\n+\n+- `idle`：加载当前登录用户的真实搭子，单选接收人。\n+- `submitting`：按钮进入「正在转赠…」，禁用搜索与重复提交。\n+- `success`：仅在接口 `code === 0` 且返回结构通过契约校验后展示；成功后「查看我的卡包」重新进入卡包并重新请求 `MyCoupons`。\n+- `error`：优先展示后端原话（如「该优惠卡不属于您」「对方未绑定手机号，无法赠送」）；网络、超时、契约异常使用可重试文案。\n+\n+历史 `/card/share?state=success` 不代表转赠成功，页面不会仅凭 URL 展示成功态。\n+\n+## 4. Mock 规则\n+\n+preview/dev Mock 复用同一 service 与 MSW handler，使用数值演示好友 ID（9001–9004）和 `MY_COUPONS_RECORDS_MOCK`。成功后从 Mock 未使用列表移除对应券，便于验证卡包刷新；成功页明确标注「仅模拟接口成功，未真实转出卡券」。\n+\n+## 5. 验证\n+\n+- Service：`src/services/couponGift.test.ts`\n+- 页面：`src/pages/CardShare.test.tsx`\n+- E2E：`tests/e2e/formal-h5.spec.ts` 中卡券转赠流程\n+\n+`test` / `prod` 的真实 App WebView、登录态与卡博士上游返回仍需在对应环境验收；浏览器 Mock 成功不等于线上已验收。\ndiff --git a/docs/workbench/route-table.md b/docs/workbench/route-table.md\nindex 12e8367..ed04115 100644\n--- a/docs/workbench/route-table.md\n+++ b/docs/workbench/route-table.md\n@@ -70,7 +70,7 @@\n | 路径 | 节点 | 入口 | 返回目标 | 状态参数 |\n | --- | --- | --- | --- | --- |\n | `/card` | #54、#62–#64 | 底部 Tab「卡包」 | 底部 Tab | `?state=available/used/expired`；`?overlay=use&coupon=` |\n-| `/card/share` | #65、#66 | 卡包-转赠 | 卡包 | `?coupon=`；`?state=success` |\n+| `/card/share` | #65、#66 | 卡包-转赠 | 卡包 | `?coupon=`；成功态由 `POST /api/coupons/give` 返回后展示，不提供伪造成功 URL |\n | `/card/verify` | #67 | 卡包-使用 | 卡包 | — |\n | `/card/verify/confirm` | #67 | 扫码页 / 使用弹层 | 卡包 | `?state=done/repeat`；确认后回 `/card?state=used` |\n | `/redeem` | #68、#69 | 我的-卡券兑换 | 我的 / 卡包 | `?state=success/format/invalid/used/network`（2026-10-12 D-083 合同：至少 10 位，原值核兑；原型 12 位为历史） |\ndiff --git a/src/app/fixtures/cards.ts b/src/app/fixtures/cards.ts\nindex 09e55e8..d0632dc 100644\n--- a/src/app/fixtures/cards.ts\n+++ b/src/app/fixtures/cards.ts\n@@ -55,8 +55,8 @@ export function resolveCardCoupon(id: string | null): CardCouponFixture {\n \n /**\n  * 分享接收人夹具（T009 #65；人名、头像首字、单选交互取自 reference/分享.html「搭子列表」）\n- * B-015 已关闭：按原型做「搭子列表」夹具，单选对象 → 下一步 → 分享成功；\n- * 不做对方接受、次数限制、时效限制和持久化，分享后原卡包状态不变。\n+ * id 使用仅存在于 preview Mock 的数值用户 ID，使 Mock 与真实 `/api/coupons/give`\n+ * 共用同一 `friend_id` 契约；它们不是真实账号。\n  */\n export interface ShareTargetFixture {\n   id: string\n@@ -66,10 +66,10 @@ export interface ShareTargetFixture {\n }\n \n export const SHARE_TARGET_FIXTURES: ShareTargetFixture[] = [\n-  { id: 's1', name: '小美', initial: '小' },\n-  { id: 's2', name: '阿轩', initial: '轩' },\n-  { id: 's3', name: '泡泡同学', initial: '泡' },\n-  { id: 's4', name: '室友小婷', initial: '婷' },\n+  { id: '9001', name: '小美', initial: '小' },\n+  { id: '9002', name: '阿轩', initial: '轩' },\n+  { id: '9003', name: '泡泡同学', initial: '泡' },\n+  { id: '9004', name: '室友小婷', initial: '婷' },\n ]\n \n /**\ndiff --git a/src/app/router/index.tsx b/src/app/router/index.tsx\nindex 7dccda2..6a0d21b 100644\n--- a/src/app/router/index.tsx\n+++ b/src/app/router/index.tsx\n@@ -125,7 +125,7 @@ const customPages: Record<string, ReactElement> = {\n   '/luck': <Luck />,\n   '/luck/result': <DrawSuccess />,\n   '/card': <Card />,\n-  /* T009：#65 选择接收人与 #66 转赠成功为同一条链路，用 ?state=success 切换 */\n+  /* T009：#65 选择接收人与 #66 转赠成功共用同一实现；成功态只由真实接口返回驱动 */\n   '/card/share': <CardShare />,\n   '/card/verify': <ScanVerify />,\n   '/card/verify/confirm': <ConfirmVerify />,\ndiff --git a/src/app/router/routes.ts b/src/app/router/routes.ts\nindex a2f7cfd..cf5fe61 100644\n--- a/src/app/router/routes.ts\n+++ b/src/app/router/routes.ts\n@@ -980,12 +980,12 @@ export const ROUTES: RouteMeta[] = [\n   },\n   {\n     path: '/card/share',\n-    title: '选择转赠搭子',\n-    nodes: [65],\n+    title: '优惠卡转赠',\n+    nodes: [65, 66],\n     task: 'T009',\n     entry: '卡包-第三方可用券「转赠」按钮，须携带当前券 id',\n     returnTo: '卡包',\n-    owner: '只读加载当前登录用户的真实搭子并单选；转赠接口尚未接入，不提供成功状态或写操作',\n+    owner: '真实搭子单选 + POST /api/coupons/give；仅在接口成功返回后展示转赠成功，历史 ?state=success 不代表已转赠',\n   },\n   {\n     path: '/card/verify',\ndiff --git a/src/mocks/handlers/couponGift.ts b/src/mocks/handlers/couponGift.ts\nnew file mode 100644\nindex 0000000..7c623bf\n--- /dev/null\n+++ b/src/mocks/handlers/couponGift.ts\n@@ -0,0 +1,50 @@\n+import { http, HttpResponse } from 'msw'\n+\n+import { SHARE_TARGET_FIXTURES } from '../../app/fixtures/cards'\n+import { COUPON_GIFT_PATH } from '../../services/couponGift'\n+import { MY_COUPONS_RECORDS_MOCK } from '../fixtures/myCoupons'\n+\n+const MOCK_FRIEND_IDS = new Set(SHARE_TARGET_FIXTURES.map((friend) => Number(friend.id)))\n+\n+function failure(message: string) {\n+  return HttpResponse.json({ code: 500, message, data: [] })\n+}\n+\n+function readPositiveId(value: unknown, field: '优惠卡记录ID' | '好友ID'): number | string {\n+  if (value == null) return `\"${field}\" is required`\n+  if (typeof value !== 'number') return `\"${field}\" must be a number`\n+  if (!Number.isInteger(value)) return `\"${field}\" must be a number`\n+  if (value < 1) return `\"${field}\" must be greater than or equal to 1`\n+  return value\n+}\n+\n+/**\n+ * Preview/dev network-level Mock for the confirmed `POST /api/coupons/give` contract.\n+ * It simulates the coupon disappearing from the sender's unused list after a successful gift.\n+ */\n+export const couponGiftHandlers = [\n+  http.post(`*${COUPON_GIFT_PATH}`, async ({ request }) => {\n+    const body: unknown = await request.json().catch(() => null)\n+    if (!body || typeof body !== 'object') return failure('参数错误')\n+\n+    const raw = body as { log_id?: unknown; friend_id?: unknown }\n+    const logId = readPositiveId(raw.log_id, '优惠卡记录ID')\n+    if (typeof logId === 'string') return failure(logId)\n+    const friendId = readPositiveId(raw.friend_id, '好友ID')\n+    if (typeof friendId === 'string') return failure(friendId)\n+\n+    if (!MOCK_FRIEND_IDS.has(friendId)) return failure('好友不存在')\n+\n+    const records = MY_COUPONS_RECORDS_MOCK.unused\n+    const index = records.findIndex((record) => String(record.id) === String(logId))\n+    if (index < 0) return failure('该优惠卡不属于您')\n+    records.splice(index, 1)\n+\n+    return HttpResponse.json({\n+      code: 0,\n+      msg: 'success',\n+      status: 'succ',\n+      data: { log_id: logId, friend_id: friendId },\n+    })\n+  }),\n+]\ndiff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts\nindex 194ac02..07c3710 100644\n--- a/src/mocks/handlers/index.ts\n+++ b/src/mocks/handlers/index.ts\n@@ -2,6 +2,7 @@ import { buddyPhoneHandlers } from './buddyPhone'\n import { checkinHandlers } from './checkin'\n import { chatMessageHandlers } from './chatMessages'\n import { couponCategoryHandlers } from './couponCategories'\n+import { couponGiftHandlers } from './couponGift'\n import { exchangeHandlers } from './exchange'\n import { homeHandlers } from './home'\n import { networkProbeHandler } from './networkProbe'\n@@ -20,6 +21,7 @@ export const handlers = [\n   ...noticeHandlers,\n   ...chatMessageHandlers,\n   ...couponCategoryHandlers,\n+  ...couponGiftHandlers,\n   ...checkinHandlers,\n   ...exchangeHandlers,\n   // 两者匹配同一个 settings/detail URL，专用 key 要在通用 fallback 前命中。\n@@ -27,4 +29,4 @@ export const handlers = [\n   ...homeHandlers,\n   ...userProfileHandlers,\n   ...userpointsHandlers,\n-]\n\\ No newline at end of file\n+]\ndiff --git a/src/pages/Card.tsx b/src/pages/Card.tsx\nindex 45ffb4b..f9389e3 100644\n--- a/src/pages/Card.tsx\n+++ b/src/pages/Card.tsx\n@@ -554,7 +554,16 @@ export default function Card() {\n               expired={tab === 'expired'}\n               onUse={() => openUseSheet(coupon)}\n               onShare={() => {\n-                if (coupon.origin !== 'local' && coupon.id) navigate('/card/share?coupon=' + encodeURIComponent(coupon.id))\n+                if (coupon.origin === 'local' || !coupon.id) return\n+                navigate('/card/share?coupon=' + encodeURIComponent(coupon.id), {\n+                  state: {\n+                    couponPreview: {\n+                      name: coupon.name,\n+                      amountLabel: coupon.amountLabel,\n+                      validityLabel: coupon.validityLabel,\n+                    },\n+                  },\n+                })\n               }}\n             />\n           ))}\ndiff --git a/src/pages/CardShare.test.tsx b/src/pages/CardShare.test.tsx\nindex 0f8c61d..ffc610c 100644\n--- a/src/pages/CardShare.test.tsx\n+++ b/src/pages/CardShare.test.tsx\n@@ -3,9 +3,15 @@ import { MemoryRouter } from 'react-router-dom'\n import { afterEach, describe, expect, it, vi } from 'vitest'\n import type { ReactNode } from 'react'\n \n-const mocks = vi.hoisted(() => ({ mode: 'api', relations: vi.fn() }))\n+const mocks = vi.hoisted(() => ({\n+  mode: 'api',\n+  relations: vi.fn(),\n+  give: vi.fn(),\n+}))\n+\n vi.mock('../app/config/runtime', () => ({ runtimePolicy: { get dataMode() { return mocks.mode } } }))\n vi.mock('../services/buddyRelations', () => ({ loadBuddyRelations: mocks.relations }))\n+vi.mock('../services/couponGift', () => ({ giveCoupon: mocks.give }))\n vi.mock('../components/mobile/DebugPanel', () => ({ default: () => null }))\n vi.mock('../components/mobile/PageContainer', () => ({\n   default: ({ children }: { children: ReactNode }) => <main>{children}</main>,\n@@ -18,32 +24,43 @@ const people = [\n   { id: '22', nickname: '天河搭子小芳', avatarUrl: 'https://example.com/avatar.jpg' },\n ]\n \n-const mount = (url = '/card/share?coupon=91001') => render(\n+const mount = (\n+  url: string | { pathname: string; search?: string; state?: unknown } = '/card/share?coupon=91001',\n+) => render(\n   <MemoryRouter initialEntries={[url]}><CardShare /></MemoryRouter>,\n )\n-afterEach(() => { mocks.mode = 'api'; mocks.relations.mockReset() })\n \n-describe('card transfer only prepares a real buddy recipient', () => {\n-  it('reads current authenticated buddies, never the old fixture names, and never claims transfer succeeded', async () => {\n+afterEach(() => {\n+  mocks.mode = 'api'\n+  mocks.relations.mockReset()\n+  mocks.give.mockReset()\n+})\n+\n+describe('card transfer uses the confirmed give API', () => {\n+  it('loads authenticated buddies, posts the coupon log ID and friend user ID, then shows success', async () => {\n     mocks.relations.mockResolvedValue(people)\n+    mocks.give.mockResolvedValue({ log_id: 91001, friend_id: 19 })\n     mount()\n+\n     expect(screen.getByText('正在加载我的搭子…')).toBeTruthy()\n     expect(await screen.findByRole('radio', { name: '南沙搭子阿明' })).toBeTruthy()\n     expect(mocks.relations).toHaveBeenCalledTimes(1)\n     expect(screen.queryByText('小美')).toBeNull()\n-    expect(screen.getByRole('button', { name: '确认选择' }).hasAttribute('disabled')).toBe(true)\n+    expect(screen.getByRole('button', { name: '确认转赠' }).hasAttribute('disabled')).toBe(true)\n \n     fireEvent.click(screen.getByRole('radio', { name: '南沙搭子阿明' }))\n-    fireEvent.change(screen.getByPlaceholderText('搜索我的搭子'), { target: { value: '天河' } })\n-    expect(within(screen.getByLabelText('已选搭子')).getByRole('radio', { name: '南沙搭子阿明' })).toBeTruthy()\n-    expect(screen.getByRole('radio', { name: '天河搭子小芳' })).toBeTruthy()\n-    fireEvent.click(screen.getByRole('button', { name: '确认选择' }))\n-    expect(screen.getByText('已选择「南沙搭子阿明」。转赠接口尚未接入，卡券没有转出。')).toBeTruthy()\n-    expect(screen.queryByText('分享成功')).toBeNull()\n+    fireEvent.click(screen.getByRole('button', { name: '确认转赠' }))\n+\n+    await waitFor(() => {\n+      expect(mocks.give).toHaveBeenCalledWith({ logId: '91001', friendId: '19' })\n+    })\n+    expect(await screen.findByRole('heading', { level: 2, name: '转赠成功' })).toBeTruthy()\n+    expect(screen.getByText('已成功转赠给「南沙搭子阿明」')).toBeTruthy()\n+    expect(screen.getByRole('button', { name: '查看我的卡包' })).toBeTruthy()\n     expect(mocks.relations).toHaveBeenCalledTimes(1)\n   })\n \n-  it('keeps selection when keyword finds nobody, then changes it only on a real click', async () => {\n+  it('keeps the selected recipient while searching and only replaces it on a real click', async () => {\n     mocks.relations.mockResolvedValue(people)\n     mount()\n     fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n@@ -52,7 +69,82 @@ describe('card transfer only prepares a real buddy recipient', () => {\n     fireEvent.change(screen.getByPlaceholderText('搜索我的搭子'), { target: { value: '' } })\n     fireEvent.click(screen.getByRole('radio', { name: '天河搭子小芳' }))\n     expect(within(screen.getByLabelText('已选搭子')).getByRole('radio', { name: '天河搭子小芳' })).toBeTruthy()\n-    expect(screen.queryByText('分享成功')).toBeNull()\n+    expect(mocks.give).not.toHaveBeenCalled()\n+    expect(screen.queryByText('转赠成功')).toBeNull()\n+  })\n+\n+  it('disables the screen while submitting and never posts twice', async () => {\n+    mocks.relations.mockResolvedValue(people)\n+    let resolveGift: (value: { log_id: number; friend_id: number }) => void = () => {}\n+    mocks.give.mockImplementation(() => new Promise((resolve) => { resolveGift = resolve }))\n+    mount()\n+\n+    fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.click(screen.getByRole('button', { name: '确认转赠' }))\n+    const submittingButton = await screen.findByRole('button', { name: '正在转赠…' })\n+    expect(submittingButton.hasAttribute('disabled')).toBe(true)\n+    expect(screen.getByPlaceholderText('搜索我的搭子').hasAttribute('disabled')).toBe(true)\n+\n+    fireEvent.click(submittingButton)\n+    expect(mocks.give).toHaveBeenCalledTimes(1)\n+\n+    resolveGift({ log_id: 91001, friend_id: 19 })\n+    expect(await screen.findByRole('heading', { level: 2, name: '转赠成功' })).toBeTruthy()\n+  })\n+\n+  it('shows the backend business reason and keeps the recipient for a retry', async () => {\n+    const { AppError } = await import('../lib/appError')\n+    mocks.relations.mockResolvedValue(people)\n+    mocks.give.mockRejectedValue(new AppError({\n+      kind: 'business',\n+      message: '对方未绑定手机号，无法赠送',\n+    }))\n+    mount()\n+\n+    fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.click(screen.getByRole('button', { name: '确认转赠' }))\n+\n+    expect(await screen.findByText('对方未绑定手机号，无法赠送')).toBeTruthy()\n+    expect(within(screen.getByLabelText('已选搭子')).getByRole('radio', { name: '南沙搭子阿明' })).toBeTruthy()\n+    expect(screen.queryByRole('heading', { level: 2, name: '转赠成功' })).toBeNull()\n+\n+    fireEvent.click(screen.getByRole('radio', { name: '天河搭子小芳' }))\n+    expect(screen.queryByText('对方未绑定手机号，无法赠送')).toBeNull()\n+  })\n+\n+  it('maps transport failures to actionable copy instead of a false success', async () => {\n+    const { AppError } = await import('../lib/appError')\n+    mocks.relations.mockResolvedValue(people)\n+    mocks.give.mockRejectedValue(new AppError({ kind: 'network', message: 'Network Error' }))\n+    mount()\n+\n+    fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.click(screen.getByRole('button', { name: '确认转赠' }))\n+\n+    expect(await screen.findByText('网络异常，请检查网络后重试')).toBeTruthy()\n+    expect(screen.queryByRole('heading', { level: 2, name: '转赠成功' })).toBeNull()\n+  })\n+\n+  it('shows the coupon preview captured from the card pack on the success screen', async () => {\n+    mocks.relations.mockResolvedValue(people)\n+    mocks.give.mockResolvedValue({ log_id: 91001, friend_id: 19 })\n+    mount({\n+      pathname: '/card/share',\n+      search: '?coupon=91001',\n+      state: {\n+        couponPreview: {\n+          name: '新人体验活动',\n+          amountLabel: '¥30.00',\n+          validityLabel: '2026-10-30 到期',\n+        },\n+      },\n+    })\n+\n+    fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.click(screen.getByRole('button', { name: '确认转赠' }))\n+\n+    expect(await screen.findByText('新人体验活动')).toBeTruthy()\n+    expect(screen.getByText('¥30.00 · 2026-10-30 到期')).toBeTruthy()\n   })\n \n   it('shows empty and failure states without falling back to fixture relations', async () => {\n@@ -63,16 +155,17 @@ describe('card transfer only prepares a real buddy recipient', () => {\n     fireEvent.click(screen.getByRole('button', { name: '重新加载' }))\n     await waitFor(() => expect(screen.getByText('暂无搭子，可先去「洗头搭子」添加好友')).toBeTruthy())\n     expect(mocks.relations).toHaveBeenCalledTimes(2)\n+    expect(mocks.give).not.toHaveBeenCalled()\n   })\n \n-  it('does not request buddies or invent a coupon when link has no coupon ID', () => {\n+  it('does not request buddies or invent a coupon when the link has no coupon ID', () => {\n     mount('/card/share')\n     expect(screen.getByText('未选择待转赠卡券，请从卡包点击「转赠」进入')).toBeTruthy()\n     expect(mocks.relations).not.toHaveBeenCalled()\n-    expect(screen.queryByRole('button', { name: '确认选择' })).toBeNull()\n+    expect(screen.queryByRole('button', { name: '确认转赠' })).toBeNull()\n   })\n \n-  it('clearly labels mock-only preview relations and never calls real list service', async () => {\n+  it('keeps mock-only preview relations clearly labelled and never calls the real list service', async () => {\n     mocks.mode = 'mock'\n     mount()\n     expect(await screen.findByText('演示搭子，仅用于 UI 预览；非真实账号')).toBeTruthy()\ndiff --git a/src/pages/CardShare.tsx b/src/pages/CardShare.tsx\nindex acb0b8b..b17f198 100644\n--- a/src/pages/CardShare.tsx\n+++ b/src/pages/CardShare.tsx\n@@ -1,41 +1,87 @@\n import { useEffect, useMemo, useState } from 'react'\n-import { useSearchParams } from 'react-router-dom'\n-import { Check, RefreshCw, UserRound } from 'lucide-react'\n+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'\n+import { Check, CheckCircle2, RefreshCw, Ticket, UserRound } from 'lucide-react'\n import PageContainer from '../components/mobile/PageContainer'\n import DebugPanel from '../components/mobile/DebugPanel'\n import { Button, SearchField } from '../components/ui'\n import { findRouteByPathname } from '../app/router/routes'\n import { runtimePolicy } from '../app/config/runtime'\n import { SHARE_TARGET_FIXTURES } from '../app/fixtures'\n+import { isAppError } from '../lib/appError'\n import { loadBuddyRelations, type BuddyMember } from '../services/buddyRelations'\n+import { giveCoupon } from '../services/couponGift'\n \n type BuddyRemote =\n   | { state: 'loading' }\n   | { state: 'ready'; items: BuddyMember[] }\n   | { state: 'error'; message: string }\n \n+interface CouponPreview {\n+  name: string\n+  amountLabel?: string\n+  validityLabel?: string\n+}\n+\n+type GiftState =\n+  | { state: 'idle' }\n+  | { state: 'submitting' }\n+  | { state: 'success'; nickname: string }\n+  | { state: 'error'; message: string }\n+\n+function readCouponPreview(state: unknown): CouponPreview | null {\n+  if (!state || typeof state !== 'object' || Array.isArray(state)) return null\n+  const candidate = (state as { couponPreview?: unknown }).couponPreview\n+  if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null\n+\n+  const { name, amountLabel, validityLabel } = candidate as Record<string, unknown>\n+  if (typeof name !== 'string' || !name.trim()) return null\n+\n+  return {\n+    name: name.trim(),\n+    amountLabel:\n+      typeof amountLabel === 'string' && amountLabel.trim() ? amountLabel.trim() : undefined,\n+    validityLabel:\n+      typeof validityLabel === 'string' && validityLabel.trim() ? validityLabel.trim() : undefined,\n+  }\n+}\n+\n+function giftErrorMessage(error: unknown): string {\n+  if (isAppError(error)) {\n+    if (error.status === 401) return '登录已失效，请重新进入 App 后再试'\n+    if (error.kind === 'timeout') return '转赠请求超时，请稍后重试'\n+    if (error.kind === 'network') return '网络异常，请检查网络后重试'\n+    if (error.kind === 'contract') return '转赠服务返回异常，请稍后重试'\n+    return error.message\n+  }\n+  return error instanceof Error && error.message ? error.message : '转赠失败，请稍后重试'\n+}\n+\n /**\n- * Transfer preparation only: pick an actual buddy from the authenticated account.\n- * No transfer API has been signed, so this screen MUST NOT mutate a coupon, navigate\n- * to the legacy fixture success page, or claim that anything was gifted.\n+ * Coupon transfer: pick a real buddy, submit the confirmed `POST /api/coupons/give`\n+ * contract, and only show success after the backend confirms it. A historical\n+ * `?state=success` URL must never imply a transfer on its own.\n  */\n export default function CardShare() {\n   const route = findRouteByPathname('/card/share')\n+  const navigate = useNavigate()\n+  const location = useLocation()\n   const [searchParams] = useSearchParams()\n   const couponId = searchParams.get('coupon')?.trim() || null\n+  const couponPreview = useMemo(() => readCouponPreview(location.state), [location.state])\n   const mockMode = runtimePolicy.dataMode === 'mock'\n   const [keyword, setKeyword] = useState('')\n   const [selectedId, setSelectedId] = useState<string | null>(null)\n-  const [confirmedId, setConfirmedId] = useState<string | null>(null)\n   const [retry, setRetry] = useState(0)\n   const [remote, setRemote] = useState<BuddyRemote>({ state: 'loading' })\n+  const [gift, setGift] = useState<GiftState>({ state: 'idle' })\n+  const submitting = gift.state === 'submitting'\n \n   useEffect(() => {\n     if (!couponId) return\n     let active = true\n     setRemote({ state: 'loading' })\n     setSelectedId(null)\n-    setConfirmedId(null)\n+    setGift({ state: 'idle' })\n     if (mockMode) {\n       setRemote({\n         state: 'ready',\n@@ -66,18 +112,31 @@ export default function CardShare() {\n   }, [items, keyword, selectedId])\n \n   const choose = (id: string) => {\n+    if (submitting) return\n     setSelectedId(id)\n-    setConfirmedId(null)\n+    setGift({ state: 'idle' })\n   }\n \n-  const renderBuddy = (buddy: BuddyMember, checked: boolean) => (\n+  const submitGift = async () => {\n+    if (!couponId || !selected || submitting) return\n+    setGift({ state: 'submitting' })\n+    try {\n+      await giveCoupon({ logId: couponId, friendId: selected.id })\n+      setGift({ state: 'success', nickname: selected.nickname })\n+    } catch (error) {\n+      setGift({ state: 'error', message: giftErrorMessage(error) })\n+    }\n+  }\n+\n+  const renderBuddy = (buddy: BuddyMember, checked: boolean, disabled = false) => (\n     <button\n       key={buddy.id}\n       type=\"button\"\n       role=\"radio\"\n       aria-checked={checked}\n+      disabled={disabled}\n       onClick={() => choose(buddy.id)}\n-      className={`flex min-h-[68px] w-full min-w-0 items-center gap-3 rounded-xl border px-3 py-3 text-left ${\n+      className={`flex min-h-[68px] w-full min-w-0 items-center gap-3 rounded-xl border px-3 py-3 text-left disabled:cursor-not-allowed disabled:opacity-60 ${\n         checked ? 'border-primary bg-surface-selected' : 'border-border-subtle bg-surface'\n       }`}\n     >\n@@ -97,12 +156,84 @@ export default function CardShare() {\n     </button>\n   )\n \n+  if (gift.state === 'success') {\n+    const { nickname } = gift\n+    return (\n+      <PageContainer className=\"flex min-h-full flex-col pb-6\" inset>\n+        <header className=\"px-1 pt-3\">\n+          <h1 className=\"text-[16px] font-semibold text-text-primary\">转赠成功</h1>\n+        </header>\n+\n+        <section className=\"flex flex-col items-center px-2 pt-10 text-center\" aria-live=\"polite\">\n+          <span className=\"flex h-16 w-16 items-center justify-center rounded-full bg-success-bg text-success\" aria-hidden>\n+            <CheckCircle2 className=\"h-8 w-8\" strokeWidth={2.4} />\n+          </span>\n+          <h2 className=\"mt-5 text-xl font-semibold text-text-primary\">转赠成功</h2>\n+          <p className=\"mt-2 text-sm leading-6 text-text-secondary\">\n+            已成功转赠给「{nickname}」\n+          </p>\n+          <p className=\"mt-1 text-xs leading-5 text-text-tertiary\">\n+            优惠卡已从你的卡包转出，接收方可在自己的卡包查看\n+          </p>\n+        </section>\n+\n+        {couponPreview && (\n+          <section\n+            className=\"mt-6 flex items-center gap-3 rounded-xl bg-surface p-4 shadow-sm\"\n+            aria-label=\"已转赠的优惠卡\"\n+          >\n+            <span className=\"flex h-12 w-12 flex-none items-center justify-center rounded-xl bg-reward-subtle text-reward-strong\" aria-hidden>\n+              <Ticket className=\"h-6 w-6\" />\n+            </span>\n+            <span className=\"min-w-0 flex-1\">\n+              <span className=\"block break-words text-sm font-medium text-text-primary\">\n+                {couponPreview.name}\n+              </span>\n+              {(couponPreview.amountLabel || couponPreview.validityLabel) && (\n+                <span className=\"mt-1 block break-words text-xs text-text-tertiary\">\n+                  {[couponPreview.amountLabel, couponPreview.validityLabel]\n+                    .filter(Boolean)\n+                    .join(' · ')}\n+                </span>\n+              )}\n+            </span>\n+          </section>\n+        )}\n+\n+        {mockMode && (\n+          <p role=\"status\" className=\"mt-4 rounded-lg bg-warning-bg px-3 py-2 text-[12px] text-warning-text\">\n+            演示环境：本次转赠仅模拟接口成功，未真实转出卡券\n+          </p>\n+        )}\n+\n+        <div className=\"mt-auto space-y-2 pt-8 pb-[env(safe-area-inset-bottom)]\">\n+          <Button\n+            size=\"large\"\n+            className=\"h-12 w-full rounded-pill\"\n+            onClick={() => navigate('/card?state=available', { replace: true })}\n+          >\n+            查看我的卡包\n+          </Button>\n+          <button\n+            type=\"button\"\n+            onClick={() => navigate(-1)}\n+            className=\"min-h-10 w-full rounded-pill text-sm font-medium text-text-secondary active:bg-surface-pressed\"\n+          >\n+            返回\n+          </button>\n+        </div>\n+\n+        <DebugPanel route={route} />\n+      </PageContainer>\n+    )\n+  }\n+\n   return (\n     <PageContainer className=\"flex min-h-full flex-col pb-6\" inset>\n       <header className=\"px-1 pt-3\">\n         <h1 className=\"text-[16px] font-semibold text-text-primary\">选择转赠搭子</h1>\n         <p className=\"mt-1 text-[12px] text-text-tertiary\">\n-          转赠接口暂未接入，当前仅支持选择接收人，不会实际转出卡券\n+          转赠成功后，这张优惠卡将从你的卡包转出，请确认接收人\n         </p>\n       </header>\n \n@@ -124,6 +255,7 @@ export default function CardShare() {\n               onClear={() => setKeyword('')}\n               placeholder=\"搜索我的搭子\"\n               inputClassName=\"placeholder:text-text-tertiary\"\n+              disabled={submitting}\n             />\n           </div>\n \n@@ -154,11 +286,11 @@ export default function CardShare() {\n                   {selected && (\n                     <div className=\"space-y-2\" aria-label=\"已选搭子\">\n                       <p className=\"text-[12px] font-medium text-reward-text\">已选搭子</p>\n-                      {renderBuddy(selected, true)}\n+                      {renderBuddy(selected, true, submitting)}\n                     </div>\n                   )}\n                   <p className=\"pt-1 text-[12px] text-text-tertiary\">搜索结果</p>\n-                  {filtered.map(item => renderBuddy(item, false))}\n+                  {filtered.map(item => renderBuddy(item, false, submitting))}\n                   {filtered.length === 0 && (\n                     <p className=\"rounded-xl bg-surface px-4 py-5 text-center text-[13px] text-text-tertiary\">\n                       没有匹配的搭子{selected ? '，已选搭子仍保留' : ''}\n@@ -170,18 +302,19 @@ export default function CardShare() {\n           </section>\n \n           <div className=\"mt-auto pt-6 pb-[env(safe-area-inset-bottom)]\">\n-            {confirmedId && selected?.id === confirmedId && (\n-              <p role=\"status\" className=\"mb-3 rounded-xl bg-reward-subtle px-3 py-3 text-center text-[12px] leading-5 text-text-secondary\">\n-                已选择「{selected.nickname}」。转赠接口尚未接入，卡券没有转出。\n+            {gift.state === 'error' && (\n+              <p role=\"alert\" className=\"mb-3 rounded-xl bg-danger-bg px-3 py-3 text-center text-[12px] leading-5 text-danger-text\">\n+                {gift.message}\n               </p>\n             )}\n             <Button\n               size=\"large\"\n               className=\"h-12 w-full rounded-pill\"\n+              loading={submitting}\n               disabled={!selected}\n-              onClick={() => { if (selected) setConfirmedId(selected.id) }}\n+              onClick={() => void submitGift()}\n             >\n-              确认选择\n+              {submitting ? '正在转赠…' : '确认转赠'}\n             </Button>\n           </div>\n         </>\ndiff --git a/src/services/couponGift.test.ts b/src/services/couponGift.test.ts\nnew file mode 100644\nindex 0000000..29be504\n--- /dev/null\n+++ b/src/services/couponGift.test.ts\n@@ -0,0 +1,85 @@\n+import { beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import { COUPON_GIFT_PATH, giveCoupon } from './couponGift'\n+\n+const mocks = vi.hoisted(() => ({\n+  request: vi.fn(),\n+}))\n+\n+vi.mock('./http', async (importOriginal) => {\n+  const actual = await importOriginal<typeof import('./http')>()\n+  return { ...actual, httpClient: { request: mocks.request } }\n+})\n+\n+describe('coupon gift contract', () => {\n+  beforeEach(() => {\n+    mocks.request.mockReset()\n+  })\n+\n+  it('posts only the coupon log ID and friend user ID', async () => {\n+    mocks.request.mockResolvedValue({\n+      code: 0,\n+      msg: 'success',\n+      status: 'succ',\n+      data: { log_id: 1001, friend_id: 2 },\n+    })\n+\n+    await expect(giveCoupon({ logId: '1001', friendId: '2' })).resolves.toEqual({\n+      log_id: 1001,\n+      friend_id: 2,\n+    })\n+    expect(mocks.request).toHaveBeenCalledWith({\n+      method: 'POST',\n+      url: COUPON_GIFT_PATH,\n+      data: { log_id: 1001, friend_id: 2 },\n+    })\n+  })\n+\n+  it('rejects invalid IDs before reaching the network', async () => {\n+    await expect(giveCoupon({ logId: 'abc', friendId: 2 })).rejects.toMatchObject({\n+      kind: 'business',\n+      message: '优惠卡或接收人信息无效，请返回卡包重试',\n+    })\n+    await expect(giveCoupon({ logId: 1001, friendId: 0 })).rejects.toMatchObject({\n+      kind: 'business',\n+      message: '优惠卡或接收人信息无效，请返回卡包重试',\n+    })\n+    expect(mocks.request).not.toHaveBeenCalled()\n+  })\n+\n+  it('preserves the backend business reason for the page', async () => {\n+    mocks.request.mockResolvedValue({\n+      code: 500,\n+      message: '对方未绑定手机号，无法赠送',\n+      data: [],\n+    })\n+\n+    await expect(giveCoupon({ logId: 1001, friendId: 2 })).rejects.toMatchObject({\n+      kind: 'business',\n+      message: '对方未绑定手机号，无法赠送',\n+    })\n+  })\n+\n+  it('uses the documented fallback when the backend provides no reason', async () => {\n+    mocks.request.mockResolvedValue({ code: 500, data: [] })\n+\n+    await expect(giveCoupon({ logId: 1001, friendId: 2 })).rejects.toMatchObject({\n+      kind: 'business',\n+      message: '转赠失败',\n+    })\n+  })\n+\n+  it('rejects malformed success data instead of claiming a transfer', async () => {\n+    mocks.request.mockResolvedValue({\n+      code: 0,\n+      msg: 'success',\n+      status: 'succ',\n+      data: { log_id: '1001', friend_id: 2 },\n+    })\n+\n+    await expect(giveCoupon({ logId: 1001, friendId: 2 })).rejects.toMatchObject({\n+      kind: 'contract',\n+      code: 'CONTRACT_VALIDATION_FAILED',\n+    })\n+  })\n+})\ndiff --git a/src/services/couponGift.ts b/src/services/couponGift.ts\nnew file mode 100644\nindex 0000000..b009021\n--- /dev/null\n+++ b/src/services/couponGift.ts\n@@ -0,0 +1,60 @@\n+import { z } from 'zod'\n+\n+import { AppError } from '../lib/appError'\n+import { parseApiEnvelope } from './contracts/apiEnvelope'\n+import { httpClient } from './http'\n+\n+/**\n+ * 优惠卡转赠（H5 契约）。\n+ *\n+ * 赠送方身份由服务端按登录态取得，H5 只提交：\n+ * - `log_id`：本人未使用优惠卡记录 ID（MyCoupons `data.data[].id`）\n+ * - `friend_id`：好友列表中的用户 ID\n+ *\n+ * 接收方手机号由服务端按 `friend_id` 查询；前端不得传 `user_id` / `mobile`。\n+ */\n+export const COUPON_GIFT_PATH = '/api/coupons/give'\n+\n+const couponGiftResultSchema = z\n+  .object({\n+    log_id: z.number().int().positive(),\n+    friend_id: z.number().int().positive(),\n+  })\n+  .passthrough()\n+\n+export type CouponGiftResult = z.infer<typeof couponGiftResultSchema>\n+\n+export interface CouponGiftParams {\n+  logId: number | string\n+  friendId: number | string\n+}\n+\n+function toPositiveSafeInteger(value: number | string): number | null {\n+  const parsed = typeof value === 'string' ? Number(value.trim()) : value\n+  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null\n+}\n+\n+export async function giveCoupon(params: CouponGiftParams): Promise<CouponGiftResult> {\n+  const logId = toPositiveSafeInteger(params.logId)\n+  const friendId = toPositiveSafeInteger(params.friendId)\n+  if (logId === null || friendId === null) {\n+    throw new AppError({\n+      kind: 'business',\n+      message: '优惠卡或接收人信息无效，请返回卡包重试',\n+    })\n+  }\n+\n+  const payload = await httpClient.request<unknown>({\n+    method: 'POST',\n+    url: COUPON_GIFT_PATH,\n+    data: {\n+      log_id: logId,\n+      friend_id: friendId,\n+    },\n+  })\n+\n+  return parseApiEnvelope(payload, couponGiftResultSchema, {\n+    contract: 'coupons.give',\n+    fallbackMessage: '转赠失败',\n+  })\n+}\ndiff --git a/tests/e2e/formal-h5.spec.ts b/tests/e2e/formal-h5.spec.ts\nindex 9ef231c..384f8b5 100644\n--- a/tests/e2e/formal-h5.spec.ts\n+++ b/tests/e2e/formal-h5.spec.ts\n@@ -348,7 +348,7 @@ test('@formal-h5 H022 prototype states stay on one route implementation', async\n   await page.goto('/card/share?coupon=91001', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n   expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe('/card/share?coupon=91001')\n-  await expect(page.getByRole('button', { name: '确认选择' })).toBeDisabled()\n+  await expect(page.getByRole('button', { name: '确认转赠' })).toBeDisabled()\n \n   // Historic success URL must not imply a transfer without coupon ID or API.\n   await page.goto('/card/share?state=success', { waitUntil: 'domcontentloaded' })\n@@ -367,6 +367,27 @@ test('@formal-h5 H022 prototype states stay on one route implementation', async\n   expect(runtimeErrors, runtimeErrors.join('\\n')).toEqual([])\n })\n \n+test('@formal-h5 card transfer shows success only after the give API confirms it', async ({ page }) => {\n+  const runtimeErrors = collectRuntimeErrors(page)\n+\n+  await page.goto('/card/share?coupon=91001', { waitUntil: 'domcontentloaded' })\n+  await expectHealthyFormalRoute(page)\n+  await page.getByRole('radio', { name: '小美' }).click()\n+  await page.getByRole('button', { name: '确认转赠' }).click()\n+\n+  await expect(page.getByRole('heading', { level: 2, name: '转赠成功' })).toBeVisible()\n+  await expect(page.getByText('已成功转赠给「小美」')).toBeVisible()\n+  await expect(page.getByText('演示环境：本次转赠仅模拟接口成功，未真实转出卡券')).toBeVisible()\n+\n+  await page.getByRole('button', { name: '查看我的卡包' }).click()\n+  await expect(page).toHaveURL(/\\/card\\?state=available$/)\n+  await expectHealthyFormalRoute(page)\n+  await expect(page.getByText('Mock 洗护体验券')).toBeVisible()\n+  await expect(page.getByText('Mock 新人体验活动')).toHaveCount(0)\n+\n+  expect(runtimeErrors, runtimeErrors.join('\\n')).toEqual([])\n+})\n+\n test('@formal-h5 H023 shared SearchField stays consistent across formal H5 consumers', async ({ page }) => {\n   const runtimeErrors = collectRuntimeErrors(page)",
      "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/5b1bc1fcaea8350bbc0afc723df61e71e24cf27c",
            "commitHash": "5b1bc1fcaea8350bbc0afc723df61e71e24cf27c",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/252",
            "prTitle": "chore(release): promote coupon gift integration dev → test",
            "prBaseHash": "31f5618fad8ab5a618f85f45d810026dc31c24d0",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/39234733212"
          },
          "gitCommit": {
            "shortHash": "5b1bc1f",
            "hash": "5b1bc1fcaea8350bbc0afc723df61e71e24cf27c",
            "subject": "Merge 7d5edae654df8ba966fbd7e8ea25f4b42657da2e into 31f5618fad8ab5a618f85f45d810026dc31c24d0",
            "body": "Merge 7d5edae654df8ba966fbd7e8ea25f4b42657da2e into 31f5618fad8ab5a618f85f45d810026dc31c24d0\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1792404242000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1792404242000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/docs/api-index.md b/docs/api-index.md\nindex c099358..da99106 100644\n--- a/docs/api-index.md\n+++ b/docs/api-index.md\n@@ -98,6 +98,7 @@\n | 今天签到 | `POST /api/signrecords/add` | 是 | 否 | `engineering/checkin-api-integration.md` |\n | 本月签到记录（`range=month`） | `GET /api/signrecords/index` | 是 | 否（裸数组） | `engineering/checkin-api-integration.md` |\n | 补签 | `POST /api/signrecords/makeup` | 是 | 否 | `engineering/checkin-api-integration.md` |\n+| 优惠卡转赠 | `POST /api/coupons/give` | 是 | 否 | `engineering/coupon-transfer-api-integration.md` |\n \n > `POST /api/signrecords/makeup` 已纳入最新《签到页面接口文档》；补签前仍必须先通过 Native 激励广告闸门，详见 `engineering/checkin-api-integration.md` §3、§5.1、§6。\n \ndiff --git a/docs/engineering/coupon-transfer-api-integration.md b/docs/engineering/coupon-transfer-api-integration.md\nnew file mode 100644\nindex 0000000..3cbbbcf\n--- /dev/null\n+++ b/docs/engineering/coupon-transfer-api-integration.md\n@@ -0,0 +1,61 @@\n+# 优惠卡转赠接口接入\n+\n+## 1. 契约\n+\n+`POST /api/coupons/give`\n+\n+```http\n+Authorization: Bearer <accessToken>\n+Content-Type: application/json\n+```\n+\n+```json\n+{\n+  \"log_id\": 1001,\n+  \"friend_id\": 2\n+}\n+```\n+\n+- `log_id`：本人未使用优惠卡记录 ID，来自 `POST /api/coupons/MyCoupons`（`type=unused`）的 `data.data[].id`。\n+- `friend_id`：接收方用户 ID，来自好友列表 `GET /api/friends/index` 的 `friend.id`。\n+- 赠送方身份由服务端按登录态取得；H5 不传 `user_id` / `mobile`，接收方手机号由服务端查询。\n+\n+成功：\n+\n+```json\n+{\n+  \"code\": 0,\n+  \"msg\": \"success\",\n+  \"status\": \"succ\",\n+  \"data\": { \"log_id\": 1001, \"friend_id\": 2 }\n+}\n+```\n+\n+## 2. 前端边界\n+\n+- 页面：`src/pages/CardShare.tsx`\n+- Service：`src/services/couponGift.ts`\n+- Mock：`src/mocks/handlers/couponGift.ts`\n+\n+页面只调用 `giveCoupon({ logId, friendId })`，不拼接 URL、不读取用户身份，也不在失败时退回 Mock。\n+\n+## 3. 页面状态\n+\n+- `idle`：加载当前登录用户的真实搭子，单选接收人。\n+- `submitting`：按钮进入「正在转赠…」，禁用搜索与重复提交。\n+- `success`：仅在接口 `code === 0` 且返回结构通过契约校验后展示；成功后「查看我的卡包」重新进入卡包并重新请求 `MyCoupons`。\n+- `error`：优先展示后端原话（如「该优惠卡不属于您」「对方未绑定手机号，无法赠送」）；网络、超时、契约异常使用可重试文案。\n+\n+历史 `/card/share?state=success` 不代表转赠成功，页面不会仅凭 URL 展示成功态。\n+\n+## 4. Mock 规则\n+\n+preview/dev Mock 复用同一 service 与 MSW handler，使用数值演示好友 ID（9001–9004）和 `MY_COUPONS_RECORDS_MOCK`。成功后从 Mock 未使用列表移除对应券，便于验证卡包刷新；成功页明确标注「仅模拟接口成功，未真实转出卡券」。\n+\n+## 5. 验证\n+\n+- Service：`src/services/couponGift.test.ts`\n+- 页面：`src/pages/CardShare.test.tsx`\n+- E2E：`tests/e2e/formal-h5.spec.ts` 中卡券转赠流程\n+\n+`test` / `prod` 的真实 App WebView、登录态与卡博士上游返回仍需在对应环境验收；浏览器 Mock 成功不等于线上已验收。\ndiff --git a/docs/workbench/route-table.md b/docs/workbench/route-table.md\nindex 12e8367..ed04115 100644\n--- a/docs/workbench/route-table.md\n+++ b/docs/workbench/route-table.md\n@@ -70,7 +70,7 @@\n | 路径 | 节点 | 入口 | 返回目标 | 状态参数 |\n | --- | --- | --- | --- | --- |\n | `/card` | #54、#62–#64 | 底部 Tab「卡包」 | 底部 Tab | `?state=available/used/expired`；`?overlay=use&coupon=` |\n-| `/card/share` | #65、#66 | 卡包-转赠 | 卡包 | `?coupon=`；`?state=success` |\n+| `/card/share` | #65、#66 | 卡包-转赠 | 卡包 | `?coupon=`；成功态由 `POST /api/coupons/give` 返回后展示，不提供伪造成功 URL |\n | `/card/verify` | #67 | 卡包-使用 | 卡包 | — |\n | `/card/verify/confirm` | #67 | 扫码页 / 使用弹层 | 卡包 | `?state=done/repeat`；确认后回 `/card?state=used` |\n | `/redeem` | #68、#69 | 我的-卡券兑换 | 我的 / 卡包 | `?state=success/format/invalid/used/network`（2026-10-12 D-083 合同：至少 10 位，原值核兑；原型 12 位为历史） |\ndiff --git a/src/app/fixtures/cards.ts b/src/app/fixtures/cards.ts\nindex 09e55e8..d0632dc 100644\n--- a/src/app/fixtures/cards.ts\n+++ b/src/app/fixtures/cards.ts\n@@ -55,8 +55,8 @@ export function resolveCardCoupon(id: string | null): CardCouponFixture {\n \n /**\n  * 分享接收人夹具（T009 #65；人名、头像首字、单选交互取自 reference/分享.html「搭子列表」）\n- * B-015 已关闭：按原型做「搭子列表」夹具，单选对象 → 下一步 → 分享成功；\n- * 不做对方接受、次数限制、时效限制和持久化，分享后原卡包状态不变。\n+ * id 使用仅存在于 preview Mock 的数值用户 ID，使 Mock 与真实 `/api/coupons/give`\n+ * 共用同一 `friend_id` 契约；它们不是真实账号。\n  */\n export interface ShareTargetFixture {\n   id: string\n@@ -66,10 +66,10 @@ export interface ShareTargetFixture {\n }\n \n export const SHARE_TARGET_FIXTURES: ShareTargetFixture[] = [\n-  { id: 's1', name: '小美', initial: '小' },\n-  { id: 's2', name: '阿轩', initial: '轩' },\n-  { id: 's3', name: '泡泡同学', initial: '泡' },\n-  { id: 's4', name: '室友小婷', initial: '婷' },\n+  { id: '9001', name: '小美', initial: '小' },\n+  { id: '9002', name: '阿轩', initial: '轩' },\n+  { id: '9003', name: '泡泡同学', initial: '泡' },\n+  { id: '9004', name: '室友小婷', initial: '婷' },\n ]\n \n /**\ndiff --git a/src/app/router/index.tsx b/src/app/router/index.tsx\nindex 7dccda2..6a0d21b 100644\n--- a/src/app/router/index.tsx\n+++ b/src/app/router/index.tsx\n@@ -125,7 +125,7 @@ const customPages: Record<string, ReactElement> = {\n   '/luck': <Luck />,\n   '/luck/result': <DrawSuccess />,\n   '/card': <Card />,\n-  /* T009：#65 选择接收人与 #66 转赠成功为同一条链路，用 ?state=success 切换 */\n+  /* T009：#65 选择接收人与 #66 转赠成功共用同一实现；成功态只由真实接口返回驱动 */\n   '/card/share': <CardShare />,\n   '/card/verify': <ScanVerify />,\n   '/card/verify/confirm': <ConfirmVerify />,\ndiff --git a/src/app/router/routes.ts b/src/app/router/routes.ts\nindex a2f7cfd..cf5fe61 100644\n--- a/src/app/router/routes.ts\n+++ b/src/app/router/routes.ts\n@@ -980,12 +980,12 @@ export const ROUTES: RouteMeta[] = [\n   },\n   {\n     path: '/card/share',\n-    title: '选择转赠搭子',\n-    nodes: [65],\n+    title: '优惠卡转赠',\n+    nodes: [65, 66],\n     task: 'T009',\n     entry: '卡包-第三方可用券「转赠」按钮，须携带当前券 id',\n     returnTo: '卡包',\n-    owner: '只读加载当前登录用户的真实搭子并单选；转赠接口尚未接入，不提供成功状态或写操作',\n+    owner: '真实搭子单选 + POST /api/coupons/give；仅在接口成功返回后展示转赠成功，历史 ?state=success 不代表已转赠',\n   },\n   {\n     path: '/card/verify',\ndiff --git a/src/mocks/handlers/couponGift.ts b/src/mocks/handlers/couponGift.ts\nnew file mode 100644\nindex 0000000..7c623bf\n--- /dev/null\n+++ b/src/mocks/handlers/couponGift.ts\n@@ -0,0 +1,50 @@\n+import { http, HttpResponse } from 'msw'\n+\n+import { SHARE_TARGET_FIXTURES } from '../../app/fixtures/cards'\n+import { COUPON_GIFT_PATH } from '../../services/couponGift'\n+import { MY_COUPONS_RECORDS_MOCK } from '../fixtures/myCoupons'\n+\n+const MOCK_FRIEND_IDS = new Set(SHARE_TARGET_FIXTURES.map((friend) => Number(friend.id)))\n+\n+function failure(message: string) {\n+  return HttpResponse.json({ code: 500, message, data: [] })\n+}\n+\n+function readPositiveId(value: unknown, field: '优惠卡记录ID' | '好友ID'): number | string {\n+  if (value == null) return `\"${field}\" is required`\n+  if (typeof value !== 'number') return `\"${field}\" must be a number`\n+  if (!Number.isInteger(value)) return `\"${field}\" must be a number`\n+  if (value < 1) return `\"${field}\" must be greater than or equal to 1`\n+  return value\n+}\n+\n+/**\n+ * Preview/dev network-level Mock for the confirmed `POST /api/coupons/give` contract.\n+ * It simulates the coupon disappearing from the sender's unused list after a successful gift.\n+ */\n+export const couponGiftHandlers = [\n+  http.post(`*${COUPON_GIFT_PATH}`, async ({ request }) => {\n+    const body: unknown = await request.json().catch(() => null)\n+    if (!body || typeof body !== 'object') return failure('参数错误')\n+\n+    const raw = body as { log_id?: unknown; friend_id?: unknown }\n+    const logId = readPositiveId(raw.log_id, '优惠卡记录ID')\n+    if (typeof logId === 'string') return failure(logId)\n+    const friendId = readPositiveId(raw.friend_id, '好友ID')\n+    if (typeof friendId === 'string') return failure(friendId)\n+\n+    if (!MOCK_FRIEND_IDS.has(friendId)) return failure('好友不存在')\n+\n+    const records = MY_COUPONS_RECORDS_MOCK.unused\n+    const index = records.findIndex((record) => String(record.id) === String(logId))\n+    if (index < 0) return failure('该优惠卡不属于您')\n+    records.splice(index, 1)\n+\n+    return HttpResponse.json({\n+      code: 0,\n+      msg: 'success',\n+      status: 'succ',\n+      data: { log_id: logId, friend_id: friendId },\n+    })\n+  }),\n+]\ndiff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts\nindex 194ac02..07c3710 100644\n--- a/src/mocks/handlers/index.ts\n+++ b/src/mocks/handlers/index.ts\n@@ -2,6 +2,7 @@ import { buddyPhoneHandlers } from './buddyPhone'\n import { checkinHandlers } from './checkin'\n import { chatMessageHandlers } from './chatMessages'\n import { couponCategoryHandlers } from './couponCategories'\n+import { couponGiftHandlers } from './couponGift'\n import { exchangeHandlers } from './exchange'\n import { homeHandlers } from './home'\n import { networkProbeHandler } from './networkProbe'\n@@ -20,6 +21,7 @@ export const handlers = [\n   ...noticeHandlers,\n   ...chatMessageHandlers,\n   ...couponCategoryHandlers,\n+  ...couponGiftHandlers,\n   ...checkinHandlers,\n   ...exchangeHandlers,\n   // 两者匹配同一个 settings/detail URL，专用 key 要在通用 fallback 前命中。\n@@ -27,4 +29,4 @@ export const handlers = [\n   ...homeHandlers,\n   ...userProfileHandlers,\n   ...userpointsHandlers,\n-]\n\\ No newline at end of file\n+]\ndiff --git a/src/pages/Card.tsx b/src/pages/Card.tsx\nindex 45ffb4b..f9389e3 100644\n--- a/src/pages/Card.tsx\n+++ b/src/pages/Card.tsx\n@@ -554,7 +554,16 @@ export default function Card() {\n               expired={tab === 'expired'}\n               onUse={() => openUseSheet(coupon)}\n               onShare={() => {\n-                if (coupon.origin !== 'local' && coupon.id) navigate('/card/share?coupon=' + encodeURIComponent(coupon.id))\n+                if (coupon.origin === 'local' || !coupon.id) return\n+                navigate('/card/share?coupon=' + encodeURIComponent(coupon.id), {\n+                  state: {\n+                    couponPreview: {\n+                      name: coupon.name,\n+                      amountLabel: coupon.amountLabel,\n+                      validityLabel: coupon.validityLabel,\n+                    },\n+                  },\n+                })\n               }}\n             />\n           ))}\ndiff --git a/src/pages/CardShare.test.tsx b/src/pages/CardShare.test.tsx\nindex 0f8c61d..ffc610c 100644\n--- a/src/pages/CardShare.test.tsx\n+++ b/src/pages/CardShare.test.tsx\n@@ -3,9 +3,15 @@ import { MemoryRouter } from 'react-router-dom'\n import { afterEach, describe, expect, it, vi } from 'vitest'\n import type { ReactNode } from 'react'\n \n-const mocks = vi.hoisted(() => ({ mode: 'api', relations: vi.fn() }))\n+const mocks = vi.hoisted(() => ({\n+  mode: 'api',\n+  relations: vi.fn(),\n+  give: vi.fn(),\n+}))\n+\n vi.mock('../app/config/runtime', () => ({ runtimePolicy: { get dataMode() { return mocks.mode } } }))\n vi.mock('../services/buddyRelations', () => ({ loadBuddyRelations: mocks.relations }))\n+vi.mock('../services/couponGift', () => ({ giveCoupon: mocks.give }))\n vi.mock('../components/mobile/DebugPanel', () => ({ default: () => null }))\n vi.mock('../components/mobile/PageContainer', () => ({\n   default: ({ children }: { children: ReactNode }) => <main>{children}</main>,\n@@ -18,32 +24,43 @@ const people = [\n   { id: '22', nickname: '天河搭子小芳', avatarUrl: 'https://example.com/avatar.jpg' },\n ]\n \n-const mount = (url = '/card/share?coupon=91001') => render(\n+const mount = (\n+  url: string | { pathname: string; search?: string; state?: unknown } = '/card/share?coupon=91001',\n+) => render(\n   <MemoryRouter initialEntries={[url]}><CardShare /></MemoryRouter>,\n )\n-afterEach(() => { mocks.mode = 'api'; mocks.relations.mockReset() })\n \n-describe('card transfer only prepares a real buddy recipient', () => {\n-  it('reads current authenticated buddies, never the old fixture names, and never claims transfer succeeded', async () => {\n+afterEach(() => {\n+  mocks.mode = 'api'\n+  mocks.relations.mockReset()\n+  mocks.give.mockReset()\n+})\n+\n+describe('card transfer uses the confirmed give API', () => {\n+  it('loads authenticated buddies, posts the coupon log ID and friend user ID, then shows success', async () => {\n     mocks.relations.mockResolvedValue(people)\n+    mocks.give.mockResolvedValue({ log_id: 91001, friend_id: 19 })\n     mount()\n+\n     expect(screen.getByText('正在加载我的搭子…')).toBeTruthy()\n     expect(await screen.findByRole('radio', { name: '南沙搭子阿明' })).toBeTruthy()\n     expect(mocks.relations).toHaveBeenCalledTimes(1)\n     expect(screen.queryByText('小美')).toBeNull()\n-    expect(screen.getByRole('button', { name: '确认选择' }).hasAttribute('disabled')).toBe(true)\n+    expect(screen.getByRole('button', { name: '确认转赠' }).hasAttribute('disabled')).toBe(true)\n \n     fireEvent.click(screen.getByRole('radio', { name: '南沙搭子阿明' }))\n-    fireEvent.change(screen.getByPlaceholderText('搜索我的搭子'), { target: { value: '天河' } })\n-    expect(within(screen.getByLabelText('已选搭子')).getByRole('radio', { name: '南沙搭子阿明' })).toBeTruthy()\n-    expect(screen.getByRole('radio', { name: '天河搭子小芳' })).toBeTruthy()\n-    fireEvent.click(screen.getByRole('button', { name: '确认选择' }))\n-    expect(screen.getByText('已选择「南沙搭子阿明」。转赠接口尚未接入，卡券没有转出。')).toBeTruthy()\n-    expect(screen.queryByText('分享成功')).toBeNull()\n+    fireEvent.click(screen.getByRole('button', { name: '确认转赠' }))\n+\n+    await waitFor(() => {\n+      expect(mocks.give).toHaveBeenCalledWith({ logId: '91001', friendId: '19' })\n+    })\n+    expect(await screen.findByRole('heading', { level: 2, name: '转赠成功' })).toBeTruthy()\n+    expect(screen.getByText('已成功转赠给「南沙搭子阿明」')).toBeTruthy()\n+    expect(screen.getByRole('button', { name: '查看我的卡包' })).toBeTruthy()\n     expect(mocks.relations).toHaveBeenCalledTimes(1)\n   })\n \n-  it('keeps selection when keyword finds nobody, then changes it only on a real click', async () => {\n+  it('keeps the selected recipient while searching and only replaces it on a real click', async () => {\n     mocks.relations.mockResolvedValue(people)\n     mount()\n     fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n@@ -52,7 +69,82 @@ describe('card transfer only prepares a real buddy recipient', () => {\n     fireEvent.change(screen.getByPlaceholderText('搜索我的搭子'), { target: { value: '' } })\n     fireEvent.click(screen.getByRole('radio', { name: '天河搭子小芳' }))\n     expect(within(screen.getByLabelText('已选搭子')).getByRole('radio', { name: '天河搭子小芳' })).toBeTruthy()\n-    expect(screen.queryByText('分享成功')).toBeNull()\n+    expect(mocks.give).not.toHaveBeenCalled()\n+    expect(screen.queryByText('转赠成功')).toBeNull()\n+  })\n+\n+  it('disables the screen while submitting and never posts twice', async () => {\n+    mocks.relations.mockResolvedValue(people)\n+    let resolveGift: (value: { log_id: number; friend_id: number }) => void = () => {}\n+    mocks.give.mockImplementation(() => new Promise((resolve) => { resolveGift = resolve }))\n+    mount()\n+\n+    fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.click(screen.getByRole('button', { name: '确认转赠' }))\n+    const submittingButton = await screen.findByRole('button', { name: '正在转赠…' })\n+    expect(submittingButton.hasAttribute('disabled')).toBe(true)\n+    expect(screen.getByPlaceholderText('搜索我的搭子').hasAttribute('disabled')).toBe(true)\n+\n+    fireEvent.click(submittingButton)\n+    expect(mocks.give).toHaveBeenCalledTimes(1)\n+\n+    resolveGift({ log_id: 91001, friend_id: 19 })\n+    expect(await screen.findByRole('heading', { level: 2, name: '转赠成功' })).toBeTruthy()\n+  })\n+\n+  it('shows the backend business reason and keeps the recipient for a retry', async () => {\n+    const { AppError } = await import('../lib/appError')\n+    mocks.relations.mockResolvedValue(people)\n+    mocks.give.mockRejectedValue(new AppError({\n+      kind: 'business',\n+      message: '对方未绑定手机号，无法赠送',\n+    }))\n+    mount()\n+\n+    fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.click(screen.getByRole('button', { name: '确认转赠' }))\n+\n+    expect(await screen.findByText('对方未绑定手机号，无法赠送')).toBeTruthy()\n+    expect(within(screen.getByLabelText('已选搭子')).getByRole('radio', { name: '南沙搭子阿明' })).toBeTruthy()\n+    expect(screen.queryByRole('heading', { level: 2, name: '转赠成功' })).toBeNull()\n+\n+    fireEvent.click(screen.getByRole('radio', { name: '天河搭子小芳' }))\n+    expect(screen.queryByText('对方未绑定手机号，无法赠送')).toBeNull()\n+  })\n+\n+  it('maps transport failures to actionable copy instead of a false success', async () => {\n+    const { AppError } = await import('../lib/appError')\n+    mocks.relations.mockResolvedValue(people)\n+    mocks.give.mockRejectedValue(new AppError({ kind: 'network', message: 'Network Error' }))\n+    mount()\n+\n+    fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.click(screen.getByRole('button', { name: '确认转赠' }))\n+\n+    expect(await screen.findByText('网络异常，请检查网络后重试')).toBeTruthy()\n+    expect(screen.queryByRole('heading', { level: 2, name: '转赠成功' })).toBeNull()\n+  })\n+\n+  it('shows the coupon preview captured from the card pack on the success screen', async () => {\n+    mocks.relations.mockResolvedValue(people)\n+    mocks.give.mockResolvedValue({ log_id: 91001, friend_id: 19 })\n+    mount({\n+      pathname: '/card/share',\n+      search: '?coupon=91001',\n+      state: {\n+        couponPreview: {\n+          name: '新人体验活动',\n+          amountLabel: '¥30.00',\n+          validityLabel: '2026-10-30 到期',\n+        },\n+      },\n+    })\n+\n+    fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.click(screen.getByRole('button', { name: '确认转赠' }))\n+\n+    expect(await screen.findByText('新人体验活动')).toBeTruthy()\n+    expect(screen.getByText('¥30.00 · 2026-10-30 到期')).toBeTruthy()\n   })\n \n   it('shows empty and failure states without falling back to fixture relations', async () => {\n@@ -63,16 +155,17 @@ describe('card transfer only prepares a real buddy recipient', () => {\n     fireEvent.click(screen.getByRole('button', { name: '重新加载' }))\n     await waitFor(() => expect(screen.getByText('暂无搭子，可先去「洗头搭子」添加好友')).toBeTruthy())\n     expect(mocks.relations).toHaveBeenCalledTimes(2)\n+    expect(mocks.give).not.toHaveBeenCalled()\n   })\n \n-  it('does not request buddies or invent a coupon when link has no coupon ID', () => {\n+  it('does not request buddies or invent a coupon when the link has no coupon ID', () => {\n     mount('/card/share')\n     expect(screen.getByText('未选择待转赠卡券，请从卡包点击「转赠」进入')).toBeTruthy()\n     expect(mocks.relations).not.toHaveBeenCalled()\n-    expect(screen.queryByRole('button', { name: '确认选择' })).toBeNull()\n+    expect(screen.queryByRole('button', { name: '确认转赠' })).toBeNull()\n   })\n \n-  it('clearly labels mock-only preview relations and never calls real list service', async () => {\n+  it('keeps mock-only preview relations clearly labelled and never calls the real list service', async () => {\n     mocks.mode = 'mock'\n     mount()\n     expect(await screen.findByText('演示搭子，仅用于 UI 预览；非真实账号')).toBeTruthy()\ndiff --git a/src/pages/CardShare.tsx b/src/pages/CardShare.tsx\nindex acb0b8b..b17f198 100644\n--- a/src/pages/CardShare.tsx\n+++ b/src/pages/CardShare.tsx\n@@ -1,41 +1,87 @@\n import { useEffect, useMemo, useState } from 'react'\n-import { useSearchParams } from 'react-router-dom'\n-import { Check, RefreshCw, UserRound } from 'lucide-react'\n+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'\n+import { Check, CheckCircle2, RefreshCw, Ticket, UserRound } from 'lucide-react'\n import PageContainer from '../components/mobile/PageContainer'\n import DebugPanel from '../components/mobile/DebugPanel'\n import { Button, SearchField } from '../components/ui'\n import { findRouteByPathname } from '../app/router/routes'\n import { runtimePolicy } from '../app/config/runtime'\n import { SHARE_TARGET_FIXTURES } from '../app/fixtures'\n+import { isAppError } from '../lib/appError'\n import { loadBuddyRelations, type BuddyMember } from '../services/buddyRelations'\n+import { giveCoupon } from '../services/couponGift'\n \n type BuddyRemote =\n   | { state: 'loading' }\n   | { state: 'ready'; items: BuddyMember[] }\n   | { state: 'error'; message: string }\n \n+interface CouponPreview {\n+  name: string\n+  amountLabel?: string\n+  validityLabel?: string\n+}\n+\n+type GiftState =\n+  | { state: 'idle' }\n+  | { state: 'submitting' }\n+  | { state: 'success'; nickname: string }\n+  | { state: 'error'; message: string }\n+\n+function readCouponPreview(state: unknown): CouponPreview | null {\n+  if (!state || typeof state !== 'object' || Array.isArray(state)) return null\n+  const candidate = (state as { couponPreview?: unknown }).couponPreview\n+  if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null\n+\n+  const { name, amountLabel, validityLabel } = candidate as Record<string, unknown>\n+  if (typeof name !== 'string' || !name.trim()) return null\n+\n+  return {\n+    name: name.trim(),\n+    amountLabel:\n+      typeof amountLabel === 'string' && amountLabel.trim() ? amountLabel.trim() : undefined,\n+    validityLabel:\n+      typeof validityLabel === 'string' && validityLabel.trim() ? validityLabel.trim() : undefined,\n+  }\n+}\n+\n+function giftErrorMessage(error: unknown): string {\n+  if (isAppError(error)) {\n+    if (error.status === 401) return '登录已失效，请重新进入 App 后再试'\n+    if (error.kind === 'timeout') return '转赠请求超时，请稍后重试'\n+    if (error.kind === 'network') return '网络异常，请检查网络后重试'\n+    if (error.kind === 'contract') return '转赠服务返回异常，请稍后重试'\n+    return error.message\n+  }\n+  return error instanceof Error && error.message ? error.message : '转赠失败，请稍后重试'\n+}\n+\n /**\n- * Transfer preparation only: pick an actual buddy from the authenticated account.\n- * No transfer API has been signed, so this screen MUST NOT mutate a coupon, navigate\n- * to the legacy fixture success page, or claim that anything was gifted.\n+ * Coupon transfer: pick a real buddy, submit the confirmed `POST /api/coupons/give`\n+ * contract, and only show success after the backend confirms it. A historical\n+ * `?state=success` URL must never imply a transfer on its own.\n  */\n export default function CardShare() {\n   const route = findRouteByPathname('/card/share')\n+  const navigate = useNavigate()\n+  const location = useLocation()\n   const [searchParams] = useSearchParams()\n   const couponId = searchParams.get('coupon')?.trim() || null\n+  const couponPreview = useMemo(() => readCouponPreview(location.state), [location.state])\n   const mockMode = runtimePolicy.dataMode === 'mock'\n   const [keyword, setKeyword] = useState('')\n   const [selectedId, setSelectedId] = useState<string | null>(null)\n-  const [confirmedId, setConfirmedId] = useState<string | null>(null)\n   const [retry, setRetry] = useState(0)\n   const [remote, setRemote] = useState<BuddyRemote>({ state: 'loading' })\n+  const [gift, setGift] = useState<GiftState>({ state: 'idle' })\n+  const submitting = gift.state === 'submitting'\n \n   useEffect(() => {\n     if (!couponId) return\n     let active = true\n     setRemote({ state: 'loading' })\n     setSelectedId(null)\n-    setConfirmedId(null)\n+    setGift({ state: 'idle' })\n     if (mockMode) {\n       setRemote({\n         state: 'ready',\n@@ -66,18 +112,31 @@ export default function CardShare() {\n   }, [items, keyword, selectedId])\n \n   const choose = (id: string) => {\n+    if (submitting) return\n     setSelectedId(id)\n-    setConfirmedId(null)\n+    setGift({ state: 'idle' })\n   }\n \n-  const renderBuddy = (buddy: BuddyMember, checked: boolean) => (\n+  const submitGift = async () => {\n+    if (!couponId || !selected || submitting) return\n+    setGift({ state: 'submitting' })\n+    try {\n+      await giveCoupon({ logId: couponId, friendId: selected.id })\n+      setGift({ state: 'success', nickname: selected.nickname })\n+    } catch (error) {\n+      setGift({ state: 'error', message: giftErrorMessage(error) })\n+    }\n+  }\n+\n+  const renderBuddy = (buddy: BuddyMember, checked: boolean, disabled = false) => (\n     <button\n       key={buddy.id}\n       type=\"button\"\n       role=\"radio\"\n       aria-checked={checked}\n+      disabled={disabled}\n       onClick={() => choose(buddy.id)}\n-      className={`flex min-h-[68px] w-full min-w-0 items-center gap-3 rounded-xl border px-3 py-3 text-left ${\n+      className={`flex min-h-[68px] w-full min-w-0 items-center gap-3 rounded-xl border px-3 py-3 text-left disabled:cursor-not-allowed disabled:opacity-60 ${\n         checked ? 'border-primary bg-surface-selected' : 'border-border-subtle bg-surface'\n       }`}\n     >\n@@ -97,12 +156,84 @@ export default function CardShare() {\n     </button>\n   )\n \n+  if (gift.state === 'success') {\n+    const { nickname } = gift\n+    return (\n+      <PageContainer className=\"flex min-h-full flex-col pb-6\" inset>\n+        <header className=\"px-1 pt-3\">\n+          <h1 className=\"text-[16px] font-semibold text-text-primary\">转赠成功</h1>\n+        </header>\n+\n+        <section className=\"flex flex-col items-center px-2 pt-10 text-center\" aria-live=\"polite\">\n+          <span className=\"flex h-16 w-16 items-center justify-center rounded-full bg-success-bg text-success\" aria-hidden>\n+            <CheckCircle2 className=\"h-8 w-8\" strokeWidth={2.4} />\n+          </span>\n+          <h2 className=\"mt-5 text-xl font-semibold text-text-primary\">转赠成功</h2>\n+          <p className=\"mt-2 text-sm leading-6 text-text-secondary\">\n+            已成功转赠给「{nickname}」\n+          </p>\n+          <p className=\"mt-1 text-xs leading-5 text-text-tertiary\">\n+            优惠卡已从你的卡包转出，接收方可在自己的卡包查看\n+          </p>\n+        </section>\n+\n+        {couponPreview && (\n+          <section\n+            className=\"mt-6 flex items-center gap-3 rounded-xl bg-surface p-4 shadow-sm\"\n+            aria-label=\"已转赠的优惠卡\"\n+          >\n+            <span className=\"flex h-12 w-12 flex-none items-center justify-center rounded-xl bg-reward-subtle text-reward-strong\" aria-hidden>\n+              <Ticket className=\"h-6 w-6\" />\n+            </span>\n+            <span className=\"min-w-0 flex-1\">\n+              <span className=\"block break-words text-sm font-medium text-text-primary\">\n+                {couponPreview.name}\n+              </span>\n+              {(couponPreview.amountLabel || couponPreview.validityLabel) && (\n+                <span className=\"mt-1 block break-words text-xs text-text-tertiary\">\n+                  {[couponPreview.amountLabel, couponPreview.validityLabel]\n+                    .filter(Boolean)\n+                    .join(' · ')}\n+                </span>\n+              )}\n+            </span>\n+          </section>\n+        )}\n+\n+        {mockMode && (\n+          <p role=\"status\" className=\"mt-4 rounded-lg bg-warning-bg px-3 py-2 text-[12px] text-warning-text\">\n+            演示环境：本次转赠仅模拟接口成功，未真实转出卡券\n+          </p>\n+        )}\n+\n+        <div className=\"mt-auto space-y-2 pt-8 pb-[env(safe-area-inset-bottom)]\">\n+          <Button\n+            size=\"large\"\n+            className=\"h-12 w-full rounded-pill\"\n+            onClick={() => navigate('/card?state=available', { replace: true })}\n+          >\n+            查看我的卡包\n+          </Button>\n+          <button\n+            type=\"button\"\n+            onClick={() => navigate(-1)}\n+            className=\"min-h-10 w-full rounded-pill text-sm font-medium text-text-secondary active:bg-surface-pressed\"\n+          >\n+            返回\n+          </button>\n+        </div>\n+\n+        <DebugPanel route={route} />\n+      </PageContainer>\n+    )\n+  }\n+\n   return (\n     <PageContainer className=\"flex min-h-full flex-col pb-6\" inset>\n       <header className=\"px-1 pt-3\">\n         <h1 className=\"text-[16px] font-semibold text-text-primary\">选择转赠搭子</h1>\n         <p className=\"mt-1 text-[12px] text-text-tertiary\">\n-          转赠接口暂未接入，当前仅支持选择接收人，不会实际转出卡券\n+          转赠成功后，这张优惠卡将从你的卡包转出，请确认接收人\n         </p>\n       </header>\n \n@@ -124,6 +255,7 @@ export default function CardShare() {\n               onClear={() => setKeyword('')}\n               placeholder=\"搜索我的搭子\"\n               inputClassName=\"placeholder:text-text-tertiary\"\n+              disabled={submitting}\n             />\n           </div>\n \n@@ -154,11 +286,11 @@ export default function CardShare() {\n                   {selected && (\n                     <div className=\"space-y-2\" aria-label=\"已选搭子\">\n                       <p className=\"text-[12px] font-medium text-reward-text\">已选搭子</p>\n-                      {renderBuddy(selected, true)}\n+                      {renderBuddy(selected, true, submitting)}\n                     </div>\n                   )}\n                   <p className=\"pt-1 text-[12px] text-text-tertiary\">搜索结果</p>\n-                  {filtered.map(item => renderBuddy(item, false))}\n+                  {filtered.map(item => renderBuddy(item, false, submitting))}\n                   {filtered.length === 0 && (\n                     <p className=\"rounded-xl bg-surface px-4 py-5 text-center text-[13px] text-text-tertiary\">\n                       没有匹配的搭子{selected ? '，已选搭子仍保留' : ''}\n@@ -170,18 +302,19 @@ export default function CardShare() {\n           </section>\n \n           <div className=\"mt-auto pt-6 pb-[env(safe-area-inset-bottom)]\">\n-            {confirmedId && selected?.id === confirmedId && (\n-              <p role=\"status\" className=\"mb-3 rounded-xl bg-reward-subtle px-3 py-3 text-center text-[12px] leading-5 text-text-secondary\">\n-                已选择「{selected.nickname}」。转赠接口尚未接入，卡券没有转出。\n+            {gift.state === 'error' && (\n+              <p role=\"alert\" className=\"mb-3 rounded-xl bg-danger-bg px-3 py-3 text-center text-[12px] leading-5 text-danger-text\">\n+                {gift.message}\n               </p>\n             )}\n             <Button\n               size=\"large\"\n               className=\"h-12 w-full rounded-pill\"\n+              loading={submitting}\n               disabled={!selected}\n-              onClick={() => { if (selected) setConfirmedId(selected.id) }}\n+              onClick={() => void submitGift()}\n             >\n-              确认选择\n+              {submitting ? '正在转赠…' : '确认转赠'}\n             </Button>\n           </div>\n         </>\ndiff --git a/src/services/couponGift.test.ts b/src/services/couponGift.test.ts\nnew file mode 100644\nindex 0000000..29be504\n--- /dev/null\n+++ b/src/services/couponGift.test.ts\n@@ -0,0 +1,85 @@\n+import { beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import { COUPON_GIFT_PATH, giveCoupon } from './couponGift'\n+\n+const mocks = vi.hoisted(() => ({\n+  request: vi.fn(),\n+}))\n+\n+vi.mock('./http', async (importOriginal) => {\n+  const actual = await importOriginal<typeof import('./http')>()\n+  return { ...actual, httpClient: { request: mocks.request } }\n+})\n+\n+describe('coupon gift contract', () => {\n+  beforeEach(() => {\n+    mocks.request.mockReset()\n+  })\n+\n+  it('posts only the coupon log ID and friend user ID', async () => {\n+    mocks.request.mockResolvedValue({\n+      code: 0,\n+      msg: 'success',\n+      status: 'succ',\n+      data: { log_id: 1001, friend_id: 2 },\n+    })\n+\n+    await expect(giveCoupon({ logId: '1001', friendId: '2' })).resolves.toEqual({\n+      log_id: 1001,\n+      friend_id: 2,\n+    })\n+    expect(mocks.request).toHaveBeenCalledWith({\n+      method: 'POST',\n+      url: COUPON_GIFT_PATH,\n+      data: { log_id: 1001, friend_id: 2 },\n+    })\n+  })\n+\n+  it('rejects invalid IDs before reaching the network', async () => {\n+    await expect(giveCoupon({ logId: 'abc', friendId: 2 })).rejects.toMatchObject({\n+      kind: 'business',\n+      message: '优惠卡或接收人信息无效，请返回卡包重试',\n+    })\n+    await expect(giveCoupon({ logId: 1001, friendId: 0 })).rejects.toMatchObject({\n+      kind: 'business',\n+      message: '优惠卡或接收人信息无效，请返回卡包重试',\n+    })\n+    expect(mocks.request).not.toHaveBeenCalled()\n+  })\n+\n+  it('preserves the backend business reason for the page', async () => {\n+    mocks.request.mockResolvedValue({\n+      code: 500,\n+      message: '对方未绑定手机号，无法赠送',\n+      data: [],\n+    })\n+\n+    await expect(giveCoupon({ logId: 1001, friendId: 2 })).rejects.toMatchObject({\n+      kind: 'business',\n+      message: '对方未绑定手机号，无法赠送',\n+    })\n+  })\n+\n+  it('uses the documented fallback when the backend provides no reason', async () => {\n+    mocks.request.mockResolvedValue({ code: 500, data: [] })\n+\n+    await expect(giveCoupon({ logId: 1001, friendId: 2 })).rejects.toMatchObject({\n+      kind: 'business',\n+      message: '转赠失败',\n+    })\n+  })\n+\n+  it('rejects malformed success data instead of claiming a transfer', async () => {\n+    mocks.request.mockResolvedValue({\n+      code: 0,\n+      msg: 'success',\n+      status: 'succ',\n+      data: { log_id: '1001', friend_id: 2 },\n+    })\n+\n+    await expect(giveCoupon({ logId: 1001, friendId: 2 })).rejects.toMatchObject({\n+      kind: 'contract',\n+      code: 'CONTRACT_VALIDATION_FAILED',\n+    })\n+  })\n+})\ndiff --git a/src/services/couponGift.ts b/src/services/couponGift.ts\nnew file mode 100644\nindex 0000000..b009021\n--- /dev/null\n+++ b/src/services/couponGift.ts\n@@ -0,0 +1,60 @@\n+import { z } from 'zod'\n+\n+import { AppError } from '../lib/appError'\n+import { parseApiEnvelope } from './contracts/apiEnvelope'\n+import { httpClient } from './http'\n+\n+/**\n+ * 优惠卡转赠（H5 契约）。\n+ *\n+ * 赠送方身份由服务端按登录态取得，H5 只提交：\n+ * - `log_id`：本人未使用优惠卡记录 ID（MyCoupons `data.data[].id`）\n+ * - `friend_id`：好友列表中的用户 ID\n+ *\n+ * 接收方手机号由服务端按 `friend_id` 查询；前端不得传 `user_id` / `mobile`。\n+ */\n+export const COUPON_GIFT_PATH = '/api/coupons/give'\n+\n+const couponGiftResultSchema = z\n+  .object({\n+    log_id: z.number().int().positive(),\n+    friend_id: z.number().int().positive(),\n+  })\n+  .passthrough()\n+\n+export type CouponGiftResult = z.infer<typeof couponGiftResultSchema>\n+\n+export interface CouponGiftParams {\n+  logId: number | string\n+  friendId: number | string\n+}\n+\n+function toPositiveSafeInteger(value: number | string): number | null {\n+  const parsed = typeof value === 'string' ? Number(value.trim()) : value\n+  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null\n+}\n+\n+export async function giveCoupon(params: CouponGiftParams): Promise<CouponGiftResult> {\n+  const logId = toPositiveSafeInteger(params.logId)\n+  const friendId = toPositiveSafeInteger(params.friendId)\n+  if (logId === null || friendId === null) {\n+    throw new AppError({\n+      kind: 'business',\n+      message: '优惠卡或接收人信息无效，请返回卡包重试',\n+    })\n+  }\n+\n+  const payload = await httpClient.request<unknown>({\n+    method: 'POST',\n+    url: COUPON_GIFT_PATH,\n+    data: {\n+      log_id: logId,\n+      friend_id: friendId,\n+    },\n+  })\n+\n+  return parseApiEnvelope(payload, couponGiftResultSchema, {\n+    contract: 'coupons.give',\n+    fallbackMessage: '转赠失败',\n+  })\n+}\ndiff --git a/tests/e2e/formal-h5.spec.ts b/tests/e2e/formal-h5.spec.ts\nindex 9ef231c..384f8b5 100644\n--- a/tests/e2e/formal-h5.spec.ts\n+++ b/tests/e2e/formal-h5.spec.ts\n@@ -348,7 +348,7 @@ test('@formal-h5 H022 prototype states stay on one route implementation', async\n   await page.goto('/card/share?coupon=91001', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n   expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe('/card/share?coupon=91001')\n-  await expect(page.getByRole('button', { name: '确认选择' })).toBeDisabled()\n+  await expect(page.getByRole('button', { name: '确认转赠' })).toBeDisabled()\n \n   // Historic success URL must not imply a transfer without coupon ID or API.\n   await page.goto('/card/share?state=success', { waitUntil: 'domcontentloaded' })\n@@ -367,6 +367,27 @@ test('@formal-h5 H022 prototype states stay on one route implementation', async\n   expect(runtimeErrors, runtimeErrors.join('\\n')).toEqual([])\n })\n \n+test('@formal-h5 card transfer shows success only after the give API confirms it', async ({ page }) => {\n+  const runtimeErrors = collectRuntimeErrors(page)\n+\n+  await page.goto('/card/share?coupon=91001', { waitUntil: 'domcontentloaded' })\n+  await expectHealthyFormalRoute(page)\n+  await page.getByRole('radio', { name: '小美' }).click()\n+  await page.getByRole('button', { name: '确认转赠' }).click()\n+\n+  await expect(page.getByRole('heading', { level: 2, name: '转赠成功' })).toBeVisible()\n+  await expect(page.getByText('已成功转赠给「小美」')).toBeVisible()\n+  await expect(page.getByText('演示环境：本次转赠仅模拟接口成功，未真实转出卡券')).toBeVisible()\n+\n+  await page.getByRole('button', { name: '查看我的卡包' }).click()\n+  await expect(page).toHaveURL(/\\/card\\?state=available$/)\n+  await expectHealthyFormalRoute(page)\n+  await expect(page.getByText('Mock 洗护体验券')).toBeVisible()\n+  await expect(page.getByText('Mock 新人体验活动')).toHaveCount(0)\n+\n+  expect(runtimeErrors, runtimeErrors.join('\\n')).toEqual([])\n+})\n+\n test('@formal-h5 H023 shared SearchField stays consistent across formal H5 consumers', async ({ page }) => {\n   const runtimeErrors = collectRuntimeErrors(page)",
          "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": 64,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T10:05:06.813Z",
                  "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": 1340,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T10:05:06.807Z",
                  "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": 1275,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T10:05:06.933Z",
                  "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": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1047,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T10:05:08.483Z",
                  "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": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1008,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T10:05:08.560Z",
                  "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": 979,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T10:05:09.546Z",
                  "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": 1047,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-19T10:05:09.594Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-19T10:05:05.912Z",
    "duration": 4787.762,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}