{
  "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/e4b12769b586950a27e65a84155c24e72448c3fc",
        "commitHash": "e4b12769b586950a27e65a84155c24e72448c3fc",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/311",
        "prTitle": "release(test): enable QR friend requests via existing add API",
        "prBaseHash": "56d64469fc586a22038d085ec84a9987d3668961",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/42800333058"
      },
      "gitCommit": {
        "shortHash": "e4b1276",
        "hash": "e4b12769b586950a27e65a84155c24e72448c3fc",
        "subject": "Merge 2b0b892cd7dec2eeccfa0750f14abf17c858be08 into 56d64469fc586a22038d085ec84a9987d3668961",
        "body": "Merge 2b0b892cd7dec2eeccfa0750f14abf17c858be08 into 56d64469fc586a22038d085ec84a9987d3668961\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1794306881000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1794306881000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/src/pages/BuddyAccept.test.tsx b/src/pages/BuddyAccept.test.tsx\nindex 558fd29..6603660 100644\n--- a/src/pages/BuddyAccept.test.tsx\n+++ b/src/pages/BuddyAccept.test.tsx\n@@ -82,19 +82,39 @@ describe('H5 scan invite confirmation', () => {\n     expect(mocks.accept).not.toHaveBeenCalled()\n   })\n \n-  it('shows the real inviter but disables unsupported QR relationship creation', async () => {\n+  it('sends one pending QR friend application on explicit click without claiming a buddy relation', async () => {\n     mocks.mode = 'api'\n     mocks.preview.mockResolvedValue({\n       inviter: { id: '9', nickname: '真实邀请人', avatarUrl: null },\n       relationship: 'available',\n       demo: false,\n-      confirmationAvailable: false,\n+      confirmationAvailable: true,\n     })\n+    mocks.accept.mockResolvedValue({ result: 'pending', demo: false })\n     mount({ source: 'native-buddy-recognition', raw: 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000' })\n     await waitFor(() => expect(screen.getByText(/真实邀请人邀请你成为洗头搭子/)).toBeTruthy())\n-    expect(screen.getByText(/后台暂未开放二维码添加好友接口/)).toBeTruthy()\n-    expect((screen.getByRole('button', { name: '添加好友' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect(screen.getByText(/发送好友申请/)).toBeTruthy()\n+    expect((screen.getByRole('button', { name: '添加好友' }) as HTMLButtonElement).disabled).toBe(false)\n     expect(mocks.accept).not.toHaveBeenCalled()\n+    fireEvent.click(screen.getByRole('button', { name: '添加好友' }))\n+    await waitFor(() => expect(screen.getByText('好友申请已发送')).toBeTruthy())\n+    expect(screen.getByText(/等待对方在通知中确认/)).toBeTruthy()\n+    expect(mocks.accept).toHaveBeenCalledOnce()\n+    expect(mocks.navigate).not.toHaveBeenCalled()\n+    expect(screen.queryByRole('button', { name: '添加好友' })).toBeNull()\n+    fireEvent.click(screen.getByRole('button', { name: '完成' }))\n+    expect(mocks.navigate).toHaveBeenCalledWith('/', { debug: null }, { replace: true })\n+  })\n+\n+  it('shows a backend duplicate error without sending a second application automatically', async () => {\n+    mocks.mode = 'api'\n+    mocks.preview.mockResolvedValue({ inviter, relationship: 'available', demo: false, confirmationAvailable: true })\n+    mocks.accept.mockRejectedValue(new Error('你们已经是好友了'))\n+    mount({ source: 'native-buddy-recognition', raw: 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000' })\n+    await waitFor(() => expect(screen.getByRole('button', { name: '添加好友' })).toBeTruthy())\n+    fireEvent.click(screen.getByRole('button', { name: '添加好友' }))\n+    await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('你们已经是好友了'))\n+    expect(mocks.accept).toHaveBeenCalledOnce()\n   })\n \n   it('does not expose a fake acceptance in API mode without a native recognition result', async () => {\ndiff --git a/src/pages/BuddyAccept.tsx b/src/pages/BuddyAccept.tsx\nindex 11c17c4..59d4489 100644\n--- a/src/pages/BuddyAccept.tsx\n+++ b/src/pages/BuddyAccept.tsx\n@@ -20,11 +20,13 @@ type ViewState =\n   | { kind: 'ready'; preview: BuddyPreview }\n   | { kind: 'failed'; message: string }\n   | { kind: 'demo-complete' }\n+  | { kind: 'request-sent' }\n \n /**\n  * #107: consumes ONLY an in-App, route-state QR recognition result; never the public\n  * /buddy/invite/scan URL. #110 Native must preserve existing device/card redemption.\n- * API/test/prod may preview the actual UUID owner, but QR relation writes remain blocked until #105.\n+ * API/test/prod previews the UUID owner; explicit click sends a pending Friends.add request.\n+ * The recipient must separately agree before an actual buddy relation exists.\n  */\n export default function BuddyAccept() {\n   const location = useLocation()\n@@ -74,8 +76,10 @@ export default function BuddyAccept() {\n       if (!alive.current) return\n       if (result.demo) {\n         setView({ kind: 'demo-complete' })\n+      } else if (result.result === 'pending') {\n+        // This is a pending request, not a confirmed buddy relation.\n+        setView({ kind: 'request-sent' })\n       } else {\n-        // No local fixture mutation. Buddy list independently reloads from the backend.\n         fixtureNavigate('/buddy', { debug: debug ? '1' : null }, { replace: true })\n       }\n     } catch (error) {\n@@ -93,6 +97,7 @@ export default function BuddyAccept() {\n     : view.kind === 'loading' ? '正在查询邀请…'\n       : view.kind === 'failed' ? '邀请查询失败'\n         : view.kind === 'demo-complete' ? '演示确认已完成'\n+          : view.kind === 'request-sent' ? '好友申请已发送'\n           : relationship === 'self' ? '不能邀请自己'\n               : relationship === 'unavailable' ? '这张邀请暂不可用'\n                 : `${preview?.inviter.nickname ?? '对方'}邀请你成为洗头搭子`\n@@ -100,12 +105,13 @@ export default function BuddyAccept() {\n     ? '扫码识别协议尚未接通，请在卡博士 App 内使用诗得丽扫一扫'\n     : view.kind === 'failed' ? view.message\n       : view.kind === 'demo-complete' ? '这只是 H5 演示，不会创建或保存真实搭子关系'\n+        : view.kind === 'request-sent' ? '等待对方在通知中确认；确认后你们才能成为搭子'\n         : relationship === 'self' ? '自己的二维码无法用于成为搭子'\n             : relationship === 'unavailable' ? '请确认二维码仍可使用'\n               : preview?.demo ? '演示用户，仅验证确认交互，不会绑定真实账号'\n                 : preview?.confirmationAvailable === false\n                   ? '已识别邀请人；后台暂未开放二维码添加好友接口'\n-                  : '确认后你们将成为洗头搭子'\n+                  : '添加后将发送好友申请；对方确认后才能成为搭子'\n \n   return (\n     <>\n@@ -146,7 +152,7 @@ export default function BuddyAccept() {\n             onClick={() => setRetry(v => v + 1)}>重新查询</Button>\n         )}\n         <Button variant=\"ghost\" disabled={sending} className=\"mt-2 w-full rounded-full\" onClick={dismiss}>\n-          {view.kind === 'demo-complete' ? '关闭演示' : '取消'}\n+          {view.kind === 'demo-complete' ? '关闭演示' : view.kind === 'request-sent' ? '完成' : '取消'}\n         </Button>\n       </PromptOverlay>\n     </>\ndiff --git a/src/services/buddyBackend.test.ts b/src/services/buddyBackend.test.ts\nindex eeca215..6cdb9ab 100644\n--- a/src/services/buddyBackend.test.ts\n+++ b/src/services/buddyBackend.test.ts\n@@ -7,7 +7,7 @@ vi.mock('./http', async importOriginal => {\n })\n vi.mock('./notices', () => ({ fetchNoticePage: mock.noticePage, noticeText: (value: string | null) => value || '' }))\n import {\n-  fetchBackendBuddyList, sendBackendBuddyApplication, agreeBackendBuddyApplication,\n+  fetchBackendBuddyList, sendBackendBuddyApplication, sendBackendBuddyQrApplication, agreeBackendBuddyApplication,\n   fetchBackendBuddyApplications, fetchUserByIdentifyCode,\n } from './buddyBackend'\n \n@@ -53,6 +53,27 @@ describe('existing backend buddy contract (kbs/API master@b6d2821)', () => {\n     expect(mock.request).toHaveBeenCalledTimes(1)\n   })\n \n+  it('submits QR source=20 with the looked-up numeric friend_id, never a mobile number or an accepted relation', async () => {\n+    mock.request.mockResolvedValueOnce(envelope({ id: 12, friend_id: 9, source: 20, status: 10 }))\n+    await sendBackendBuddyQrApplication('9')\n+    expect(mock.request).toHaveBeenCalledWith({\n+      method: 'POST', url: '/api/friends/add', data: { friend_id: 9, source: 20 },\n+    })\n+    for (const bad of ['9abc', '0', '-1', '01', '4294967296', '9007199254740992']) {\n+      await expect(sendBackendBuddyQrApplication(bad)).rejects.toThrow('编号无效')\n+    }\n+    expect(mock.request).toHaveBeenCalledTimes(1)\n+  })\n+\n+  it('rejects wrong target, accepted status and the existing backend duplicate/self errors', async () => {\n+    mock.request.mockResolvedValueOnce(envelope({ id: 12, friend_id: 8, source: 20, status: 10 }))\n+    await expect(sendBackendBuddyQrApplication('9')).rejects.toThrow()\n+    mock.request.mockResolvedValueOnce(envelope({ id: 12, friend_id: 9, source: 20, status: 20 }))\n+    await expect(sendBackendBuddyQrApplication('9')).rejects.toThrow()\n+    mock.request.mockResolvedValueOnce({ code: 500, message: '好友申请已发送，等待对方确认', data: [] })\n+    await expect(sendBackendBuddyQrApplication('9')).rejects.toThrow('好友申请已发送，等待对方确认')\n+  })\n+\n   it('does not mark already-agreed users as newly invited', async () => {\n     mock.request.mockResolvedValueOnce(envelope({ id: 12, status: 20 }))\n     await expect(sendBackendBuddyApplication('13800000000')).rejects.toThrow()\ndiff --git a/src/services/buddyBackend.ts b/src/services/buddyBackend.ts\nindex c76cae5..eaaeddd 100644\n--- a/src/services/buddyBackend.ts\n+++ b/src/services/buddyBackend.ts\n@@ -93,6 +93,30 @@ export async function sendBackendBuddyApplication(phone: string): Promise<void>\n   })\n }\n \n+/** The same Friends.add endpoint accepts a validated target user ID for QR invitations.\n+ * Source 20 identifies a QR application; status 10 is pending until the recipient accepts.\n+ * Verified against the kbs/API Friends controller, validator, service and api-buddy.md.\n+ */\n+export async function sendBackendBuddyQrApplication(friendId: string): Promise<void> {\n+  if (!/^[1-9]\\d*$/.test(friendId) ||\n+      !Number.isSafeInteger(Number(friendId)) || Number(friendId) > 4294967295) {\n+    throw new Error('邀请人用户编号无效')\n+  }\n+  const targetId = Number(friendId)\n+  const response = await httpClient.request<unknown>({\n+    method: 'POST',\n+    url: BUDDY_FRIENDS_ADD_PATH,\n+    data: { friend_id: targetId, source: 20 },\n+  })\n+  parseApiEnvelope(response, friendRequestSchema.extend({\n+    friend_id: z.literal(targetId),\n+    source: z.literal(20),\n+  }), {\n+    contract: 'buddy.friends.add.qr',\n+    fallbackMessage: '二维码好友申请发送失败',\n+  })\n+}\n+\n const friendAgreeSchema = z.object({\n   id: z.number().int().positive(),\n   status: z.literal(20),\ndiff --git a/src/services/buddyRelations.test.ts b/src/services/buddyRelations.test.ts\nindex e8f50ae..2b841b4 100644\n--- a/src/services/buddyRelations.test.ts\n+++ b/src/services/buddyRelations.test.ts\n@@ -5,11 +5,13 @@ const live = vi.hoisted(() => ({\n   lookup: vi.fn(),\n   profile: vi.fn(),\n   relations: vi.fn(),\n+  qrAdd: vi.fn(),\n }))\n vi.mock('../app/config/runtime', () => ({ runtimePolicy: live.mode }))\n vi.mock('./buddyBackend', () => ({\n   fetchUserByIdentifyCode: live.lookup,\n   fetchBackendBuddyList: live.relations,\n+  sendBackendBuddyQrApplication: live.qrAdd,\n }))\n vi.mock('./userProfile', () => ({ fetchUserProfile: live.profile }))\n afterEach(() => {\n@@ -17,6 +19,7 @@ afterEach(() => {\n   live.lookup.mockReset()\n   live.profile.mockReset()\n   live.relations.mockReset()\n+  live.qrAdd.mockReset()\n   vi.unstubAllEnvs()\n })\n \n@@ -101,7 +104,7 @@ describe('QR recognition and relationship backend boundary', () => {\n   })\n })\n \n-describe('real H5 UUID invitation preview without the unsigned binding API', () => {\n+describe('real H5 UUID invitation through the existing friends/add application API', () => {\n   const origin = 'https://kbs.3cgroup.cn'\n   const uuid = '550e8400-e29b-41d4-a716-446655440000'\n   const qr = `${origin}/buddy/invite/scan?code=${uuid}`\n@@ -122,12 +125,24 @@ describe('real H5 UUID invitation preview without the unsigned binding API', ()\n       demo: false,\n       inviter: { id: '9', nickname: '真实邀请人', avatarUrl: null },\n       relationship: 'available',\n-      confirmationAvailable: false,\n+      confirmationAvailable: true,\n     })\n     expect(live.lookup).toHaveBeenCalledWith(uuid)\n     expect(live.profile).not.toHaveBeenCalled()\n     expect(live.relations).not.toHaveBeenCalled()\n-    await expect(acceptBuddyQr(qr)).rejects.toMatchObject({ reason: 'not-configured' })\n+    expect(live.qrAdd).not.toHaveBeenCalled()\n+    expect(await acceptBuddyQr(qr)).toEqual({ result: 'pending', demo: false })\n+    expect(live.qrAdd).toHaveBeenCalledExactlyOnceWith('9')\n+    expect(live.lookup).toHaveBeenCalledTimes(2)\n+  })\n+\n+  it('rejects malformed QR before any write and preserves backend business errors', async () => {\n+    prepare()\n+    await expect(acceptBuddyQr(qr.replace('https://kbs.3cgroup.cn', 'https://attacker.example'))).rejects.toMatchObject({ reason: 'invalid-code' })\n+    expect(live.lookup).not.toHaveBeenCalled()\n+    expect(live.qrAdd).not.toHaveBeenCalled()\n+    live.qrAdd.mockRejectedValueOnce(new Error('你们已经是好友了'))\n+    await expect(acceptBuddyQr(qr)).rejects.toThrow('你们已经是好友了')\n   })\n \n   it('does not preflight the friendship or self state before showing an inviter', async () => {\ndiff --git a/src/services/buddyRelations.ts b/src/services/buddyRelations.ts\nindex 8f912f3..755c60e 100644\n--- a/src/services/buddyRelations.ts\n+++ b/src/services/buddyRelations.ts\n@@ -1,7 +1,7 @@\n import { z } from 'zod'\n import { runtimePolicy } from '../app/config/runtime'\n import { validateBuddyQrUrl } from './buddyQr'\n-import { fetchBackendBuddyList, fetchUserByIdentifyCode } from './buddyBackend'\n+import { fetchBackendBuddyList, fetchUserByIdentifyCode, sendBackendBuddyQrApplication } from './buddyBackend'\n \n /**\n  * #107 H5-only contract. #105 Backend and #110 Native have not signed their APIs:\n@@ -34,7 +34,9 @@ export interface BuddyRelationsBackend {\n   list: () => Promise<unknown>\n }\n \n-/** Backend Owner must explicitly supply a signed adapter; never silently use /api/friends CRUD. */\n+/** The optional adapter covers future direct-accept contracts; current API mode reuses\n+ * the verified Friends.add application endpoint instead of creating a relationship.\n+ */\n const signedBackend: BuddyRelationsBackend | null = null\n \n export class BuddyRelationsError extends Error {\n@@ -108,7 +110,7 @@ export async function previewBuddyQr(\n   adapter: BuddyRelationsBackend | null = signedBackend,\n ): Promise<BuddyPreview> {\n   // #107 independent H5 slice: existing authenticated read-only backend endpoints.\n-  // Relation creation stays blocked until #105 provides its dedicated authorized QR acceptance.\n+  // QR preview is read-only. A click can send an existing Friends.add request.\n   if (!adapter && runtimePolicy.dataMode === 'api') {\n     const origin = import.meta.env.VITE_BUDDY_PUBLIC_ORIGIN\n     if (!origin?.trim()) throw new BuddyRelationsError('not-configured')\n@@ -117,7 +119,7 @@ export async function previewBuddyQr(\n       // The QR preview identifies the inviter only. Friendship/self validation\n       // must be enforced by the authenticated, idempotent backend confirmation.\n       const inviter = await fetchUserByIdentifyCode(code)\n-      return { inviter, relationship: 'available', demo: false, confirmationAvailable: false }\n+      return { inviter, relationship: 'available', demo: false, confirmationAvailable: true }\n     } catch (error) {\n       if (error instanceof BuddyRelationsError) throw error\n       throw new BuddyRelationsError('request-failed')\n@@ -142,7 +144,17 @@ export async function previewBuddyQr(\n export async function acceptBuddyQr(\n   raw: string,\n   adapter: BuddyRelationsBackend | null = signedBackend,\n-): Promise<{ result: 'accepted' | 'already-buddies' | 'demo-only'; demo: boolean }> {\n+): Promise<{ result: 'accepted' | 'already-buddies' | 'pending' | 'demo-only'; demo: boolean }> {\n+  if (!adapter && runtimePolicy.dataMode === 'api') {\n+    const origin = import.meta.env.VITE_BUDDY_PUBLIC_ORIGIN\n+    if (!origin?.trim()) throw new BuddyRelationsError('not-configured')\n+    const code = parseBuddyInviteCode(raw, origin)\n+    // Resolve the UUID through the authenticated read endpoint on explicit click.\n+    // The backend checks self, duplicates, reverse pending, and login identity.\n+    const inviter = await fetchUserByIdentifyCode(code)\n+    await sendBackendBuddyQrApplication(inviter.id)\n+    return { result: 'pending', demo: false }\n+  }\n   const demo = checkQr(raw, adapter ?? undefined)\n   // A preview never mutates the old in-memory buddy fixture or claims durable success.\n   if (demo) return { result: 'demo-only', demo: true }",
      "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/e4b12769b586950a27e65a84155c24e72448c3fc",
            "commitHash": "e4b12769b586950a27e65a84155c24e72448c3fc",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/311",
            "prTitle": "release(test): enable QR friend requests via existing add API",
            "prBaseHash": "56d64469fc586a22038d085ec84a9987d3668961",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/42800333058"
          },
          "gitCommit": {
            "shortHash": "e4b1276",
            "hash": "e4b12769b586950a27e65a84155c24e72448c3fc",
            "subject": "Merge 2b0b892cd7dec2eeccfa0750f14abf17c858be08 into 56d64469fc586a22038d085ec84a9987d3668961",
            "body": "Merge 2b0b892cd7dec2eeccfa0750f14abf17c858be08 into 56d64469fc586a22038d085ec84a9987d3668961\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1794306881000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1794306881000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/src/pages/BuddyAccept.test.tsx b/src/pages/BuddyAccept.test.tsx\nindex 558fd29..6603660 100644\n--- a/src/pages/BuddyAccept.test.tsx\n+++ b/src/pages/BuddyAccept.test.tsx\n@@ -82,19 +82,39 @@ describe('H5 scan invite confirmation', () => {\n     expect(mocks.accept).not.toHaveBeenCalled()\n   })\n \n-  it('shows the real inviter but disables unsupported QR relationship creation', async () => {\n+  it('sends one pending QR friend application on explicit click without claiming a buddy relation', async () => {\n     mocks.mode = 'api'\n     mocks.preview.mockResolvedValue({\n       inviter: { id: '9', nickname: '真实邀请人', avatarUrl: null },\n       relationship: 'available',\n       demo: false,\n-      confirmationAvailable: false,\n+      confirmationAvailable: true,\n     })\n+    mocks.accept.mockResolvedValue({ result: 'pending', demo: false })\n     mount({ source: 'native-buddy-recognition', raw: 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000' })\n     await waitFor(() => expect(screen.getByText(/真实邀请人邀请你成为洗头搭子/)).toBeTruthy())\n-    expect(screen.getByText(/后台暂未开放二维码添加好友接口/)).toBeTruthy()\n-    expect((screen.getByRole('button', { name: '添加好友' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect(screen.getByText(/发送好友申请/)).toBeTruthy()\n+    expect((screen.getByRole('button', { name: '添加好友' }) as HTMLButtonElement).disabled).toBe(false)\n     expect(mocks.accept).not.toHaveBeenCalled()\n+    fireEvent.click(screen.getByRole('button', { name: '添加好友' }))\n+    await waitFor(() => expect(screen.getByText('好友申请已发送')).toBeTruthy())\n+    expect(screen.getByText(/等待对方在通知中确认/)).toBeTruthy()\n+    expect(mocks.accept).toHaveBeenCalledOnce()\n+    expect(mocks.navigate).not.toHaveBeenCalled()\n+    expect(screen.queryByRole('button', { name: '添加好友' })).toBeNull()\n+    fireEvent.click(screen.getByRole('button', { name: '完成' }))\n+    expect(mocks.navigate).toHaveBeenCalledWith('/', { debug: null }, { replace: true })\n+  })\n+\n+  it('shows a backend duplicate error without sending a second application automatically', async () => {\n+    mocks.mode = 'api'\n+    mocks.preview.mockResolvedValue({ inviter, relationship: 'available', demo: false, confirmationAvailable: true })\n+    mocks.accept.mockRejectedValue(new Error('你们已经是好友了'))\n+    mount({ source: 'native-buddy-recognition', raw: 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000' })\n+    await waitFor(() => expect(screen.getByRole('button', { name: '添加好友' })).toBeTruthy())\n+    fireEvent.click(screen.getByRole('button', { name: '添加好友' }))\n+    await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('你们已经是好友了'))\n+    expect(mocks.accept).toHaveBeenCalledOnce()\n   })\n \n   it('does not expose a fake acceptance in API mode without a native recognition result', async () => {\ndiff --git a/src/pages/BuddyAccept.tsx b/src/pages/BuddyAccept.tsx\nindex 11c17c4..59d4489 100644\n--- a/src/pages/BuddyAccept.tsx\n+++ b/src/pages/BuddyAccept.tsx\n@@ -20,11 +20,13 @@ type ViewState =\n   | { kind: 'ready'; preview: BuddyPreview }\n   | { kind: 'failed'; message: string }\n   | { kind: 'demo-complete' }\n+  | { kind: 'request-sent' }\n \n /**\n  * #107: consumes ONLY an in-App, route-state QR recognition result; never the public\n  * /buddy/invite/scan URL. #110 Native must preserve existing device/card redemption.\n- * API/test/prod may preview the actual UUID owner, but QR relation writes remain blocked until #105.\n+ * API/test/prod previews the UUID owner; explicit click sends a pending Friends.add request.\n+ * The recipient must separately agree before an actual buddy relation exists.\n  */\n export default function BuddyAccept() {\n   const location = useLocation()\n@@ -74,8 +76,10 @@ export default function BuddyAccept() {\n       if (!alive.current) return\n       if (result.demo) {\n         setView({ kind: 'demo-complete' })\n+      } else if (result.result === 'pending') {\n+        // This is a pending request, not a confirmed buddy relation.\n+        setView({ kind: 'request-sent' })\n       } else {\n-        // No local fixture mutation. Buddy list independently reloads from the backend.\n         fixtureNavigate('/buddy', { debug: debug ? '1' : null }, { replace: true })\n       }\n     } catch (error) {\n@@ -93,6 +97,7 @@ export default function BuddyAccept() {\n     : view.kind === 'loading' ? '正在查询邀请…'\n       : view.kind === 'failed' ? '邀请查询失败'\n         : view.kind === 'demo-complete' ? '演示确认已完成'\n+          : view.kind === 'request-sent' ? '好友申请已发送'\n           : relationship === 'self' ? '不能邀请自己'\n               : relationship === 'unavailable' ? '这张邀请暂不可用'\n                 : `${preview?.inviter.nickname ?? '对方'}邀请你成为洗头搭子`\n@@ -100,12 +105,13 @@ export default function BuddyAccept() {\n     ? '扫码识别协议尚未接通，请在卡博士 App 内使用诗得丽扫一扫'\n     : view.kind === 'failed' ? view.message\n       : view.kind === 'demo-complete' ? '这只是 H5 演示，不会创建或保存真实搭子关系'\n+        : view.kind === 'request-sent' ? '等待对方在通知中确认；确认后你们才能成为搭子'\n         : relationship === 'self' ? '自己的二维码无法用于成为搭子'\n             : relationship === 'unavailable' ? '请确认二维码仍可使用'\n               : preview?.demo ? '演示用户，仅验证确认交互，不会绑定真实账号'\n                 : preview?.confirmationAvailable === false\n                   ? '已识别邀请人；后台暂未开放二维码添加好友接口'\n-                  : '确认后你们将成为洗头搭子'\n+                  : '添加后将发送好友申请；对方确认后才能成为搭子'\n \n   return (\n     <>\n@@ -146,7 +152,7 @@ export default function BuddyAccept() {\n             onClick={() => setRetry(v => v + 1)}>重新查询</Button>\n         )}\n         <Button variant=\"ghost\" disabled={sending} className=\"mt-2 w-full rounded-full\" onClick={dismiss}>\n-          {view.kind === 'demo-complete' ? '关闭演示' : '取消'}\n+          {view.kind === 'demo-complete' ? '关闭演示' : view.kind === 'request-sent' ? '完成' : '取消'}\n         </Button>\n       </PromptOverlay>\n     </>\ndiff --git a/src/services/buddyBackend.test.ts b/src/services/buddyBackend.test.ts\nindex eeca215..6cdb9ab 100644\n--- a/src/services/buddyBackend.test.ts\n+++ b/src/services/buddyBackend.test.ts\n@@ -7,7 +7,7 @@ vi.mock('./http', async importOriginal => {\n })\n vi.mock('./notices', () => ({ fetchNoticePage: mock.noticePage, noticeText: (value: string | null) => value || '' }))\n import {\n-  fetchBackendBuddyList, sendBackendBuddyApplication, agreeBackendBuddyApplication,\n+  fetchBackendBuddyList, sendBackendBuddyApplication, sendBackendBuddyQrApplication, agreeBackendBuddyApplication,\n   fetchBackendBuddyApplications, fetchUserByIdentifyCode,\n } from './buddyBackend'\n \n@@ -53,6 +53,27 @@ describe('existing backend buddy contract (kbs/API master@b6d2821)', () => {\n     expect(mock.request).toHaveBeenCalledTimes(1)\n   })\n \n+  it('submits QR source=20 with the looked-up numeric friend_id, never a mobile number or an accepted relation', async () => {\n+    mock.request.mockResolvedValueOnce(envelope({ id: 12, friend_id: 9, source: 20, status: 10 }))\n+    await sendBackendBuddyQrApplication('9')\n+    expect(mock.request).toHaveBeenCalledWith({\n+      method: 'POST', url: '/api/friends/add', data: { friend_id: 9, source: 20 },\n+    })\n+    for (const bad of ['9abc', '0', '-1', '01', '4294967296', '9007199254740992']) {\n+      await expect(sendBackendBuddyQrApplication(bad)).rejects.toThrow('编号无效')\n+    }\n+    expect(mock.request).toHaveBeenCalledTimes(1)\n+  })\n+\n+  it('rejects wrong target, accepted status and the existing backend duplicate/self errors', async () => {\n+    mock.request.mockResolvedValueOnce(envelope({ id: 12, friend_id: 8, source: 20, status: 10 }))\n+    await expect(sendBackendBuddyQrApplication('9')).rejects.toThrow()\n+    mock.request.mockResolvedValueOnce(envelope({ id: 12, friend_id: 9, source: 20, status: 20 }))\n+    await expect(sendBackendBuddyQrApplication('9')).rejects.toThrow()\n+    mock.request.mockResolvedValueOnce({ code: 500, message: '好友申请已发送，等待对方确认', data: [] })\n+    await expect(sendBackendBuddyQrApplication('9')).rejects.toThrow('好友申请已发送，等待对方确认')\n+  })\n+\n   it('does not mark already-agreed users as newly invited', async () => {\n     mock.request.mockResolvedValueOnce(envelope({ id: 12, status: 20 }))\n     await expect(sendBackendBuddyApplication('13800000000')).rejects.toThrow()\ndiff --git a/src/services/buddyBackend.ts b/src/services/buddyBackend.ts\nindex c76cae5..eaaeddd 100644\n--- a/src/services/buddyBackend.ts\n+++ b/src/services/buddyBackend.ts\n@@ -93,6 +93,30 @@ export async function sendBackendBuddyApplication(phone: string): Promise<void>\n   })\n }\n \n+/** The same Friends.add endpoint accepts a validated target user ID for QR invitations.\n+ * Source 20 identifies a QR application; status 10 is pending until the recipient accepts.\n+ * Verified against the kbs/API Friends controller, validator, service and api-buddy.md.\n+ */\n+export async function sendBackendBuddyQrApplication(friendId: string): Promise<void> {\n+  if (!/^[1-9]\\d*$/.test(friendId) ||\n+      !Number.isSafeInteger(Number(friendId)) || Number(friendId) > 4294967295) {\n+    throw new Error('邀请人用户编号无效')\n+  }\n+  const targetId = Number(friendId)\n+  const response = await httpClient.request<unknown>({\n+    method: 'POST',\n+    url: BUDDY_FRIENDS_ADD_PATH,\n+    data: { friend_id: targetId, source: 20 },\n+  })\n+  parseApiEnvelope(response, friendRequestSchema.extend({\n+    friend_id: z.literal(targetId),\n+    source: z.literal(20),\n+  }), {\n+    contract: 'buddy.friends.add.qr',\n+    fallbackMessage: '二维码好友申请发送失败',\n+  })\n+}\n+\n const friendAgreeSchema = z.object({\n   id: z.number().int().positive(),\n   status: z.literal(20),\ndiff --git a/src/services/buddyRelations.test.ts b/src/services/buddyRelations.test.ts\nindex e8f50ae..2b841b4 100644\n--- a/src/services/buddyRelations.test.ts\n+++ b/src/services/buddyRelations.test.ts\n@@ -5,11 +5,13 @@ const live = vi.hoisted(() => ({\n   lookup: vi.fn(),\n   profile: vi.fn(),\n   relations: vi.fn(),\n+  qrAdd: vi.fn(),\n }))\n vi.mock('../app/config/runtime', () => ({ runtimePolicy: live.mode }))\n vi.mock('./buddyBackend', () => ({\n   fetchUserByIdentifyCode: live.lookup,\n   fetchBackendBuddyList: live.relations,\n+  sendBackendBuddyQrApplication: live.qrAdd,\n }))\n vi.mock('./userProfile', () => ({ fetchUserProfile: live.profile }))\n afterEach(() => {\n@@ -17,6 +19,7 @@ afterEach(() => {\n   live.lookup.mockReset()\n   live.profile.mockReset()\n   live.relations.mockReset()\n+  live.qrAdd.mockReset()\n   vi.unstubAllEnvs()\n })\n \n@@ -101,7 +104,7 @@ describe('QR recognition and relationship backend boundary', () => {\n   })\n })\n \n-describe('real H5 UUID invitation preview without the unsigned binding API', () => {\n+describe('real H5 UUID invitation through the existing friends/add application API', () => {\n   const origin = 'https://kbs.3cgroup.cn'\n   const uuid = '550e8400-e29b-41d4-a716-446655440000'\n   const qr = `${origin}/buddy/invite/scan?code=${uuid}`\n@@ -122,12 +125,24 @@ describe('real H5 UUID invitation preview without the unsigned binding API', ()\n       demo: false,\n       inviter: { id: '9', nickname: '真实邀请人', avatarUrl: null },\n       relationship: 'available',\n-      confirmationAvailable: false,\n+      confirmationAvailable: true,\n     })\n     expect(live.lookup).toHaveBeenCalledWith(uuid)\n     expect(live.profile).not.toHaveBeenCalled()\n     expect(live.relations).not.toHaveBeenCalled()\n-    await expect(acceptBuddyQr(qr)).rejects.toMatchObject({ reason: 'not-configured' })\n+    expect(live.qrAdd).not.toHaveBeenCalled()\n+    expect(await acceptBuddyQr(qr)).toEqual({ result: 'pending', demo: false })\n+    expect(live.qrAdd).toHaveBeenCalledExactlyOnceWith('9')\n+    expect(live.lookup).toHaveBeenCalledTimes(2)\n+  })\n+\n+  it('rejects malformed QR before any write and preserves backend business errors', async () => {\n+    prepare()\n+    await expect(acceptBuddyQr(qr.replace('https://kbs.3cgroup.cn', 'https://attacker.example'))).rejects.toMatchObject({ reason: 'invalid-code' })\n+    expect(live.lookup).not.toHaveBeenCalled()\n+    expect(live.qrAdd).not.toHaveBeenCalled()\n+    live.qrAdd.mockRejectedValueOnce(new Error('你们已经是好友了'))\n+    await expect(acceptBuddyQr(qr)).rejects.toThrow('你们已经是好友了')\n   })\n \n   it('does not preflight the friendship or self state before showing an inviter', async () => {\ndiff --git a/src/services/buddyRelations.ts b/src/services/buddyRelations.ts\nindex 8f912f3..755c60e 100644\n--- a/src/services/buddyRelations.ts\n+++ b/src/services/buddyRelations.ts\n@@ -1,7 +1,7 @@\n import { z } from 'zod'\n import { runtimePolicy } from '../app/config/runtime'\n import { validateBuddyQrUrl } from './buddyQr'\n-import { fetchBackendBuddyList, fetchUserByIdentifyCode } from './buddyBackend'\n+import { fetchBackendBuddyList, fetchUserByIdentifyCode, sendBackendBuddyQrApplication } from './buddyBackend'\n \n /**\n  * #107 H5-only contract. #105 Backend and #110 Native have not signed their APIs:\n@@ -34,7 +34,9 @@ export interface BuddyRelationsBackend {\n   list: () => Promise<unknown>\n }\n \n-/** Backend Owner must explicitly supply a signed adapter; never silently use /api/friends CRUD. */\n+/** The optional adapter covers future direct-accept contracts; current API mode reuses\n+ * the verified Friends.add application endpoint instead of creating a relationship.\n+ */\n const signedBackend: BuddyRelationsBackend | null = null\n \n export class BuddyRelationsError extends Error {\n@@ -108,7 +110,7 @@ export async function previewBuddyQr(\n   adapter: BuddyRelationsBackend | null = signedBackend,\n ): Promise<BuddyPreview> {\n   // #107 independent H5 slice: existing authenticated read-only backend endpoints.\n-  // Relation creation stays blocked until #105 provides its dedicated authorized QR acceptance.\n+  // QR preview is read-only. A click can send an existing Friends.add request.\n   if (!adapter && runtimePolicy.dataMode === 'api') {\n     const origin = import.meta.env.VITE_BUDDY_PUBLIC_ORIGIN\n     if (!origin?.trim()) throw new BuddyRelationsError('not-configured')\n@@ -117,7 +119,7 @@ export async function previewBuddyQr(\n       // The QR preview identifies the inviter only. Friendship/self validation\n       // must be enforced by the authenticated, idempotent backend confirmation.\n       const inviter = await fetchUserByIdentifyCode(code)\n-      return { inviter, relationship: 'available', demo: false, confirmationAvailable: false }\n+      return { inviter, relationship: 'available', demo: false, confirmationAvailable: true }\n     } catch (error) {\n       if (error instanceof BuddyRelationsError) throw error\n       throw new BuddyRelationsError('request-failed')\n@@ -142,7 +144,17 @@ export async function previewBuddyQr(\n export async function acceptBuddyQr(\n   raw: string,\n   adapter: BuddyRelationsBackend | null = signedBackend,\n-): Promise<{ result: 'accepted' | 'already-buddies' | 'demo-only'; demo: boolean }> {\n+): Promise<{ result: 'accepted' | 'already-buddies' | 'pending' | 'demo-only'; demo: boolean }> {\n+  if (!adapter && runtimePolicy.dataMode === 'api') {\n+    const origin = import.meta.env.VITE_BUDDY_PUBLIC_ORIGIN\n+    if (!origin?.trim()) throw new BuddyRelationsError('not-configured')\n+    const code = parseBuddyInviteCode(raw, origin)\n+    // Resolve the UUID through the authenticated read endpoint on explicit click.\n+    // The backend checks self, duplicates, reverse pending, and login identity.\n+    const inviter = await fetchUserByIdentifyCode(code)\n+    await sendBackendBuddyQrApplication(inviter.id)\n+    return { result: 'pending', demo: false }\n+  }\n   const demo = checkQr(raw, adapter ?? undefined)\n   // A preview never mutates the old in-memory buddy fixture or claims durable success.\n   if (demo) return { result: 'demo-only', demo: true }",
          "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": 77,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-10T10:35:57.279Z",
                  "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": 1309,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-10T10:35:57.277Z",
                  "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": 1265,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-10T10:35:57.419Z",
                  "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": 1117,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-10T10:35:58.890Z",
                  "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": 1124,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-10T10:35:58.964Z",
                  "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": 1049,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-10T10:36:00.024Z",
                  "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": 1098,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-10T10:36:00.105Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-11-10T10:35:56.351Z",
    "duration": 4906.577,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}