# 豆包智能服务登录认证开发指南

按本文生成登录能力时，必须把“页面登录态”“宿主一次性 code”“开发者一次性 code”“MCP token”分开处理。不要用前端本地状态代替 Manifest 登录配置，也不要把任何 token 或 code 混传。

标准链路：

```text
用户请求需要登录的 MCP tool
-> Manifest 中 tools.<tool>.login_type: normal 触发平台登录卡
-> src/auth/login.ts 或 src/auth/login-page/index.tsx 承接登录
-> 前端调用 login({ timeout }) 获取 login_code
-> 如用户走宿主手机号一键登录，从当前 SDK 定义的登录回调中获取 phone_code
-> 前端调用三方业务登录交换接口 POST /auth/doubao/login
-> 三方服务端用 login_code 换 open_id，并在有 phone_code 时完成手机号绑定
-> 三方服务端持久化短期一次性 developer_login_code
-> 前端立即调用 postLoginResult({ result: true, code: developer_login_code })
-> 豆包平台调用 Manifest 的 mcp_server.user_auth.fetch_token_url
-> FetchTokenURL 用 developer_login_code 换 MCP access_token / refresh_token
-> 后续 MCP 请求由平台写入 X-DB-OPENID / X-DB-ACCESS-TOKEN
-> MCP Server 读取 Header 鉴权后执行 tool
```

## 目标

进入 auth 开发时，先快速判断本轮是否需要登录认证；需要时，至少明确并实现这些内容：

- 哪些 MCP tools 需要登录，哪些不需要登录。
- Manifest 中 `mcp_server.user_auth`、`tools.<tool>.login_type`，以及平台 validator 允许时的 `tools.<tool>.login_params` 配置。
- 前端是否需要 `src/auth/login.ts`、`src/auth/privacy.ts`、`src/auth/login-page/index.tsx`。
- 智能服务后端如何接收前端一次性凭证，用 `login_code` 换 `open_id`，并在宿主手机号入口用 `phone_code` 换手机号。
- 三方账号如何用 `open_id` 与宿主手机号建立绑定关系；没有手机号时，至少维护 `open_id` 对应的业务用户记录。
- 三方服务端如何签发短期一次性 `developer_login_code`，并由前端通过 `postLoginResult` 回传给平台。
- FetchTokenURL / RefreshTokenURL 如何签发、刷新和持久化 MCP token。
- MCP Server 如何从 `X-DB-OPENID`、`X-DB-ACCESS-TOKEN` 识别和授权当前用户。
- Page 内三方业务登录态如何保存、恢复和过期重建。
- DeleteUserInfoURL 如何幂等删除宿主侧授权字段。

## 总原则

- 不把手机号链路写成独立登录体系。手机号只是宿主手机号一键登录入口中的账号关联线索。
- 不把 `login_code`、`phone_code`、`developer_login_code`、MCP `access_token`、Page session 混用。
- 不在前端保存 `app_secret`、应用级 token、私钥、开发者证书、平台证书、MCP token 或长期业务 token。
- 需要跨请求、跨进程重启识别的认证状态必须持久化，包括 `developer_login_code` 的已使用状态、MCP access/refresh token、过期时间、撤销状态、轮换状态、`open_id` 与三方账号绑定关系。
- 开发、联调、上传后真机测试等非正式业务阶段可以优先使用 SQLite；是否已经上传到平台或是否在本机运行不是持久化边界。正式业务使用业务数据库或等价的生产级存储。
- 不让 MCP tool `input_schema` 声明 `open_id`、`access_token` 等身份字段；身份来自平台透传 Header。
- 不把 DeleteUserInfoURL 写成退登、解绑或清除三方登录态接口。
- 代码生成必须覆盖成功、拒绝、失败、过期、刷新、Page 回访、多端和隐私删除路径。

## 四类凭证

| 名称 | 来源 | 用途 | 禁止 |
| --- | --- | --- | --- |
| `login_code` | 前端 `login({ timeout })` | 服务端换 `open_id` | 禁止传给 `postLoginResult` |
| `phone_code` | 宿主手机号一键登录入口返回；具体来源以当前 SDK 类型声明和运行时导出为准 | 服务端换宿主手机号 | 禁止传给 `postLoginResult`，禁止换 `open_id` |
| `developer_login_code` | 三方服务端签发 | 传给 `postLoginResult`，再由 FetchTokenURL 兑换 MCP token | 禁止长期保存，禁止当 Page session |
| MCP `access_token` / `refresh_token` | FetchTokenURL / RefreshTokenURL 返回 | 平台注入 MCP Header，MCP Server 鉴权 | 禁止给前端保存，禁止传给 `postLoginResult` |

变量命名必须显式区分：

- `loginCode`
- `phoneCode`
- `developerLoginCode`
- `pageSessionToken`
- `mcpAccessToken`
- `mcpRefreshToken`

## 硬约束

- 需要登录的 tool 必须在 Manifest 配置 `login_type: normal`。只在前端按钮里做登录不算接入登录流程。
- 不需要登录的 tool 必须显式配置 `login_type: no_login`。
- `postLoginResult({ result: true, code })` 只能使用三方服务端返回的 `developer_login_code`。
- 前端拿到 `developer_login_code` 后，先调用 `postLoginResult(true)`，再写 storage、保存 Page session；如果当前处于开发者自定义实现的 MCP 登录页，最后用 `exitApp()` 关闭页面。
- 本地 storage 写入失败不能阻断 `postLoginResult(true)`。
- 登录交换接口只有在已经持久化可兑换的短期 code，并确认 FetchTokenURL 后续能用它签发 MCP token 后，才允许返回成功。
- 登录交换失败、`login()` 失败、手机号换取失败、自定义登录失败、用户拒绝时，前端必须调用 `postLoginResult({ result: false })`。
- FetchTokenURL 发生在 `postLoginResult(true)` 之后。FetchTokenURL 失败时，服务端返回失败 JSON；前端不能再补一次 `postLoginResult(false)`。
- Page 三方业务 session 不等于 MCP 登录态。MCP Server 只能信任平台透传的 `X-DB-*` Header。
- MCP Server 遇到需要登录但 Header 缺失、token 无效、scope 不足时，返回 HTTP 401。
- 框架 JSB 和平台 OpenAPI 入参必须严格按官方定义生成，不允许自行增加 `login_scene`、`auth_source`、`business_login_ticket`、`page_restore` 等字段。
- 宿主手机号一键登录的交换接口保持最小协议，只传 `app_id`、`login_code` 和 `phone_code`；自定义登录页可以另行定义业务登录接口，携带完成账号认证所必需的业务凭证与 `login_code`。不要把业务凭证传给 `postLoginResult`，也不要把业务凭证伪装成 `phone_code` 或额外发明 `login_scene`、`business_login_ticket` 等场景参数。
- 无论一键登录和自定义登录页登录，出现任何异常和错误，都不能再调用 `postLoginResult({ result: true, code: 'xxxx' })`，可以根据具体业务逻辑调用 `postLoginResult({ result: false })` 或调用 `showToast` 提示用户。
- 开发登录相关前端代码时，必须在关键步骤、状态分支和异常路径记录可用于排查定位问题的结构化日志；字段与脱敏要求见[日志](#日志)。

## 登录入口模型

登录入口由宿主侧用户状态决定：

- 如果用户在宿主侧已绑定手机号，登录页会提供宿主手机号一键登录和三方自定义登录。
- 如果用户在宿主侧没有绑定手机号，登录页只提供三方自定义登录。
- 三方开发者始终需要提供自定义登录页或自定义登录内容，用于承接没有手机号的一般登录链路。自定义登录页里只调用 `login({ timeout })` 获取 `login_code`，不要额外发明 JSB 入参。

宿主手机号一键登录和三方自定义登录不是两套独立认证体系。它们只是在账号关联线索上不同，后续 MCP 授权链路一致。

| 登录入口 | 入口出现条件 | 账号关联线索 | 与公共流程的差异 |
| --- | --- | --- | --- |
| 宿主手机号一键登录 | 用户在宿主侧已绑定手机号，并选择一键登录入口 | 登录回调返回的 `phone_code` | 比自定义登录多一步：用 `phone_code` 调 `get_user_phonenumber` 并解密手机号 |
| 三方自定义登录 | 始终需要提供；宿主侧没有手机号时是唯一入口 | `login({ timeout })` 返回的 `login_code` | 不走 `phone_code`；服务端先校验业务凭证，再用同一个 `get_openid` 流程换 `open_id` |

两种入口共用公共流程：

1. 前端调用 `login({ timeout })` 获取 `login_code`。
2. 前端把 `login_code` 发给三方服务端；如果是宿主手机号一键登录，按当前 SDK 的登录回调协议取得 `phone_code` 并同时带上。
3. 三方服务端用 `login_code` 调用 `get_openid`，换取当前用户在该智能服务应用下的 `open_id`。
4. 三方服务端在有 `phone_code` 时调用 `get_user_phonenumber` 换手机号；没有 `phone_code` 时只完成 `open_id` 对应业务用户的登录/绑定。
5. 三方服务端签发短期一次性 `developer_login_code`。
6. 前端调用 `postLoginResult({ result: true, code: developer_login_code })`。
7. 平台调用三方 FetchTokenURL，换取 MCP `access_token` 和 `refresh_token`。
8. 后续 MCP tool 调用时，平台在 Header 中透传 `X-DB-OPENID` 和 `X-DB-ACCESS-TOKEN`。

## 手机号加解密

本节只使用**开发者证书**，不使用**平台证书**。平台使用开发者证书中的 ECC P-256 公钥加密手机号，业务 Server 使用对应私钥解密。完整说明见[签名认证及加密传输](server/openapi/security/signature-authentication-and-encryption.md)。

只要登录流程需要用 `phone_code` 调用 `get_user_phonenumber`，并解密返回的 `encrypt_phone_number`，就必须接入手机号加密传输；密钥、CSR 和应用环境必须成对管理，不能跨环境混用。

开发者需要生成一对 ECC P-256 密钥：`dev_priv` / `dev_pub`，并通过对应 CSR 申请开发者证书。生成过程涉及两个本地文件：

- `dev.key`：包含 `dev_priv` 的私钥文件，仅业务 Server 可读取。
- `dev.csr`：包含对应 `dev_pub` 的证书签名请求，不包含私钥。沙箱 App 本地调试时，把其完整内容输入 Web 调试器；真实 App 本地调试时，使用该 App 在平台配置的「智能服务应用证书」，不在调试器中另行输入 CSR。

生成命令：

```bash
openssl ecparam -genkey -name prime256v1 -noout -out dev.key
openssl req -new -key dev.key -out dev.csr -subj "/CN=XXXXX/O=XXXXX"
```

不要单独生成另一把公钥或 CSR；`dev.csr` 必须由当前 `dev.key` 生成。私钥绝不进入前端、Manifest、运行态 Skill、日志或版本库。服务端从安全本地路径、密钥管理服务或受控环境读取 `dev.key`，不要通过 HTTP、MCP tool result 或前端传递私钥内容。
解密手机号时必须注意返回类型：`get_user_phonenumber` 返回的是加密手机号，业务 Server 按平台约定完成解密后，最终得到的结果不是 `object` 对象，而就是手机号本身。服务端**必须同时兼容 string 和 number 两种类型**。日志里只能记录 payload 类型或字段名，不能记录完整手机号。


## 交付清单

进入 auth 后，至少产出这些改动或方案：

- Manifest：`mcp_server.user_auth`、相关 tool的 `login_type`，以及平台 validator 允许时的 `login_params`。
- 前端：`src/auth/login.ts`、`src/auth/privacy.ts`、`src/auth/login-page/index.tsx` 中需要的文件。
- 三方服务端：前端登录交换接口，例如 `POST /auth/doubao/login`。
- 三方 OAuth Endpoint：FetchTokenURL、RefreshTokenURL、DeleteUserInfoURL。
- MCP Server：统一鉴权中间件，读取和校验 `X-DB-OPENID`、`X-DB-ACCESS-TOKEN`。
- Page：三方业务 session 的保存、恢复、过期重建。
- 手机号加解密：`dev.key` / `dev.csr` 生成或复用方案、服务端私钥加载方式。
- 调试路径：登录成功、用户拒绝、手机号授权失败、手机号解密失败、CSR 与私钥不匹配、自定义登录失败、token 过期、refresh 失效、MCP 401、Page 回访、重复删除通知。

后端接口不在当前仓库时，不要伪造实现；只写清接口契约、URL 需求、请求体、响应体和调用时机，留给后端工程补齐。

## Manifest

需要登录的工具必须同时配置 `mcp_server.user_auth` 和 `tools.<tool>.login_type`。

```yaml
manifest_version: 2
app_key: db_xxxxxx
name: 业务智能服务

mcp_server:
  end_point: https://developer.example.com/mcp
  description: 业务 MCP Server
  mcp_config:
    protocol: Streamable
  user_auth:
    fetch_token_url: https://developer.example.com/auth/fetch_token
    refresh_token_url: https://developer.example.com/auth/refresh_token
    delete_userinfo_url: https://developer.example.com/auth/delete_userinfo

tools:
  query_city_weather:
    description: 查询指定城市天气
    login_type: normal
    input_schema:
      type: object
      properties:
        city:
          type: string
          description: 城市名，例如北京、上海、杭州
      required:
        - city

  public_city_list:
    description: 查询支持的公开城市列表
    login_type: no_login
    input_schema:
      type: object
      properties: {}
      required: []
```

配置规则：

- `mcp_server.user_auth` 是 MCP Server 级配置，不写到单个 tool 下。
- 单个 tool 是否要求登录由 `tools.<tool>.login_type` 决定。
- `login_type: normal` 表示业务侧自定义登录态，当前平台校验不允许在该类型下填写 `login_params`。不要为了宿主手机号一键登录把 `login_params: [user_mobile]` 写到 `normal` tool 上；需要宿主手机号时由登录入口回调提供 `phone_code`。
- `login_params` 只在平台校验允许的登录类型中使用；修改后必须执行 `dbx app artifacts validate <manifest.yaml路径> --json`，以 validator 结果为准。
- `fetch_token_url`、`refresh_token_url`、`delete_userinfo_url` 必须是稳定可访问的 HTTPS 地址；本地调试时可以使用 localhost。
- FetchTokenURL 和 RefreshTokenURL 需要能被平台服务端按协议直接请求通过；如有额外鉴权要求，提前和平台确认。
- 修改 Manifest 后重启本地调试器或重新构建，确保最新配置被平台读取。

## 前端文件

登录相关文件优先放在 `src/auth`，构建工具会自动发现这些入口，不要在 `src/app.config.ts` 里手动注册。旧项目没有 `src/auth` 时，才使用 `src/mcp-ui` 兼容路径。

```text
src/auth/
  login.ts
  privacy.ts
  login-page/
    index.tsx
    index.scss
```

`mcp-privacy-login-card` 是内置虚拟 Widget，不要在 `src/widgets` 创建同名目录。

## 前端公共 helper

先写公共 helper，确保登录结果只回传一次，并确保成功回调早于本地副作用。

```ts
import {
  login,
  postLoginResult,
  request,
  setStorage
} from '@doubao-apps/framework/api';

const APP_ID = 'db_xxxxxx';
const AUTH_API_BASE = 'https://developer.example.com';
const PAGE_SESSION_KEY = 'business_page_session';

let loginResultPosted = false;

interface AuthExchangeResponse {
  code: string;
  session_token?: string;
  session_expires_in?: number;
}

function requestHeaders() {
  return {
    'content-type': 'application/json'
  };
}

async function postLoginFailureOnce() {
  if (loginResultPosted) return;
  try {
    await postLoginResult({ result: false });
  } finally {
    loginResultPosted = true;
  }
}

async function postLoginSuccessOrFail(developerLoginCode: string) {
  if (loginResultPosted) return;

  try {
    await postLoginResult({
      result: true,
      code: developerLoginCode
    });
    loginResultPosted = true;
  } catch (error) {
    console.error('[auth] postLoginResult(true) failed', error);
    await postLoginFailureOnce();
    throw error;
  }
}

async function safeStorePageSession(auth: AuthExchangeResponse) {
  if (!auth.session_token) return;

  try {
    await setStorage({
      key: PAGE_SESSION_KEY,
      data: JSON.stringify({
        token: auth.session_token,
        expiresIn: auth.session_expires_in,
        updatedAt: Date.now()
      })
    });
  } catch (error) {
    console.warn('[auth] store page session failed', error);
  }
}

async function exchangeLogin(params: {
  loginCode: string;
  phoneCode?: string;
}): Promise<AuthExchangeResponse> {
  const requestData: {
    app_id: string;
    login_code: string;
    phone_code?: string;
  } = {
    app_id: APP_ID,
    login_code: params.loginCode
  };

  if (params.phoneCode) {
    requestData.phone_code = params.phoneCode;
  }

  const response = await request({
    url: `${AUTH_API_BASE}/auth/doubao/login`,
    method: 'POST',
    header: requestHeaders(),
    data: requestData
  });

  if (response.statusCode < 200 || response.statusCode >= 300) {
    throw new Error(`login exchange http ${response.statusCode}`);
  }

  const authData = response.data as Partial<AuthExchangeResponse>;
  if (!authData?.code) {
    throw new Error('login exchange missing developer_login_code');
  }

  return authData as AuthExchangeResponse;
}
```

注意：

- `AuthExchangeResponse.code` 是 `developer_login_code`，只用于 `postLoginResult`。
- 不要把 `developer_login_code` 写入本地 storage。
- 如果响应里包含 Page session，只保存 `session_token`，不要把整个响应铺进 storage。

## 宿主手机号一键登录

`src/auth/login.ts` 承接一键登录入口。手机号授权 `phone_code` 的来源必须以当前 SDK 类型声明和运行时导出为准；不要凭旧模板硬写不存在的 JSB API，也不要自行增加入参。当前 SDK 模板中，宿主手机号一键登录会把 `phone_code` 放在 `defineLoginApi().onLogin(res)` 的回调入参中。

```ts
import { defineLoginApi, type LoginResult } from '@doubao-apps/framework';
import { login } from '@doubao-apps/framework/api';

export default defineLoginApi({
  async onLogin(res: LoginResult) {
    try {
      const phoneCode = res?.code;
      if (!phoneCode) {
        throw new Error('missing phone_code');
      }

      const { code: loginCode } = await login({ timeout: 30000 });

      const auth = await exchangeLogin({
        loginCode,
        phoneCode
      });

      await postLoginSuccessOrFail(auth.code);
      await safeStorePageSession(auth);
    } catch (error) {
      console.error('[auth] host login failed', error);
      await postLoginFailureOnce();
    }
  },

  async onRefuseLogin() {
    await postLoginFailureOnce();
  }
});
```

实现要求：

- 必须另行调用 `login({ timeout })` 获取 `login_code`。
- 必须从宿主手机号一键登录入口取得 `phone_code`。生成代码前先核对当前 SDK 的 `.d.ts` 和真实导出；如果当前版本没有 `getPhoneNumber`，不要生成 `getPhoneNumber()` 调用。
- `phone_code` 只传给三方服务端换手机号。
- 前端不要直接调用 FetchTokenURL、`get_openid`、`get_user_phonenumber`。
- 不要给 `onLogin`、`login`、`postLoginResult` 增加文档没有定义的入参。

## 自定义登录页

自定义登录页位于 `src/auth/login-page/index.tsx`，必须使用 `defineLoginPage()`。它用于承接开发者自己的账号、手机号、验证码、密码或其它业务登录方式。页面不读取 `phone_code`；前端完成输入格式校验后调用 `login({ timeout })` 获取 `login_code`，再把 `login_code` 和业务凭证交给开发者后端完成最终认证与统一登录交换。

开发者自定义实现的登录页中，关闭页面使用 `exitApp()`，不要使用 `navigateBack()`。成功路径必须先完成 `postLoginResult({ result: true, code: developer_login_code })`，再保存本地 Page session，最后 `exitApp()`；取消或失败退出时先调用 `postLoginResult({ result: false })`，再 `exitApp()`。`onDestroy` 中的失败回传必须幂等，不能在成功回传后补发失败态。

`create` 生成的登录页包含验证码输入、倒计时、协议勾选和 mock 登录逻辑。第一阶段 MVP 的目标是把登录业务链路跑通，不是重新设计页面；除登录链路所需的字段、提示和可用性修复外，先保留模板 UI 和状态管理，Vibe Coding 按以下顺序开发：

1. **先完成 mock 登录 MVP。** 在 `src/auth/login-page/index.tsx` 或同一自定义登录模块中直接写一个仅用于本地开发的固定验证码，例如 `const MOCK_VERIFY_CODE = '123456'`，并在页面上显示明确的可见 hint，例如“开发调试验证码：123456”。这是非敏感的开发测试值，不得用于生产。
2. MVP 不通过 `DEV_VERIFY_CODE`、其它环境变量或 `/auth/send-code` 的 `debug_code` 下发验证码，也不要在点击“获取验证码”时依赖发送接口；按钮只需完成手机号校验、设置已发送状态和倒计时，页面 hint 直接提示固定验证码。服务端 mock 分支使用同一个源码中的固定值校验 `verify_code`，不要从环境变量读取。这里的 mock 只允许 mock 业务验证码，**不能 mock、哈希派生或自行生成 `open_id`**。
3. MVP 的主要修改是登录业务逻辑，不是 UI 改版。保留 `defineLoginPage`、表单状态、协议勾选、提交中状态、倒计时、取消处理、失败回传幂等保护和 `exitApp()`；可以补充验证码 hint 或修复影响登录的交互，但品牌、布局、颜色和完整视觉重做放到登录链路验收之后。
4. 登录按钮先完成本地输入格式校验（包括固定验证码校验），再调用 `login({ timeout: 30000 })` 获取 `login_code`。
5. 调用开发者登录交换接口，提交 `app_id`、`login_code` 和完成业务认证所需的最小凭证（MVP 示例为 `phone` 与 `verify_code`）；由后端完成业务凭证校验后，换取 `developer_login_code` 及可选的 Page session。手机号、邮箱、账号、密码或验证码只能发送给开发者自己的业务 Server。
6. 先调用 `postLoginResult({ result: true, code: developer_login_code })`；成功后再写 storage，最后调用 `exitApp()`。
7. 凭证错误、业务接口失败、`login()` 失败、`postLoginResult` 成功回传失败、用户取消和页面销毁时，调用一次 `postLoginResult({ result: false })`。

MVP 通过后，再把固定验证码和本地“获取验证码”状态替换为真实业务 Server 的短信、邮箱或其它验证服务；同时再按业务需要调整 UI。替换时必须移除开发提示，并保留同样的 `login_code`、业务凭证、`developer_login_code` 和回传顺序，不得把业务凭证改传给 `postLoginResult`。

自定义登录页不得直接调用 FetchTokenURL、`get_openid` 或 `get_user_phonenumber`，不得把 `login_code`、业务凭证、MCP token 或 Page session 直接作为 `postLoginResult` 的成功 code。成功 code 只能是开发者后端签发并持久化的短期 `developer_login_code`。

```ts
import { defineLoginPage, useState } from '@doubao-apps/framework';
import { exitApp, login, request, showToast } from '@doubao-apps/framework/api';

interface CustomLoginForm {
  phone: string;
  verifyCode: string;
}

interface AuthExchangeResponse {
  code: string;
  session_token?: string;
  session_expires_in?: number;
}

// 仅用于本地 mock MVP；页面上同时显示“开发调试验证码：123456”。
const MOCK_VERIFY_CODE = '123456';

async function exchangeCustomLogin(form: CustomLoginForm, loginCode: string) {
  const response = await request({
    url: `${AUTH_API_BASE}/auth/custom-login`,
    method: 'POST',
    header: requestHeaders(),
    data: {
      app_id: APP_ID,
      login_code: loginCode,
      phone: form.phone,
      verify_code: form.verifyCode
    }
  });

  if (response.statusCode < 200 || response.statusCode >= 300) {
    throw new Error(`custom login http ${response.statusCode}`);
  }

  const auth = response.data as Partial<AuthExchangeResponse>;
  if (!auth.code) {
    throw new Error('custom login missing developer_login_code');
  }

  return auth as AuthExchangeResponse;
}

async function submitCustomLogin(form: CustomLoginForm) {
  const { code: loginCode } = await login({ timeout: 30000 });
  const auth = await exchangeCustomLogin(form, loginCode);

  await postLoginSuccessOrFail(auth.code);
  await safeStorePageSession(auth);
  await exitApp();
}

function CustomLoginPage() {
  const [submitting, setSubmitting] = useState(false);
  const [form, setForm] = useState<CustomLoginForm>({
    phone: '',
    verifyCode: ''
  });

  const handleSubmit = async () => {
    try {
      setSubmitting(true);
      await submitCustomLogin({
        phone: form.phone,
        verifyCode: form.verifyCode
      });
    } catch (error) {
      console.error('[auth] custom login failed', error);
      await postLoginFailureOnce();
      showToast({ message: '登录失败，请稍后重试', type: 'error' });
    } finally {
      setSubmitting(false);
    }
  };

  const handleCancel = async () => {
    await postLoginFailureOnce();
    await exitApp();
  };

  return (
    <view>
      {/* MVP 仅需保留模板 UI，并显示固定验证码 hint；不要从接口响应或环境变量取验证码。 */}
      <text>开发调试验证码：{MOCK_VERIFY_CODE}</text>
      {/* 页面中的 input、验证码按钮和协议控件通过 setForm 接入真实 UI。 */}
      <button disabled={submitting} onClick={handleSubmit}>登录</button>
      <button disabled={submitting} onClick={handleCancel}>暂不登录</button>
    </view>
  );
}

export default defineLoginPage({
  onDestroy() {
    // postLoginFailureOnce 必须有幂等保护，避免成功后页面销毁又补发失败态。
    void postLoginFailureOnce();
  },
  render() {
    return <CustomLoginPage />;
  }
});
```

示例中的 `phone`、`verifyCode` 和 `/auth/custom-login` 只是业务接口占位；密码、邮箱、企业账号等场景应替换为实际需要的最小字段。后端必须先完成业务账号校验，再**复用一键登录完全相同的 `login_code -> get_openid -> open_id` 平台 OpenAPI 调用**，维护账号绑定关系、持久化可兑换的 `developer_login_code`，最后才允许返回成功。自定义登录只是不带 `phone_code`，不因此改变 `open_id` 的来源。

## dbx dev 联调

登录页开发使用 `dbx dev`，不是 `npm run dev`。`dbx dev` 从 dbx 项目根目录启动，并连接本地业务 Server 暴露的 MCP endpoint；它不会代替开发者启动业务 Server。

开始前确认项目根目录包含 `manifest.yaml`、运行态 `skill/SKILL.md` 和 `.dbx` 状态目录，前端目录由项目布局、`.dbx/config.json` 或 CLI 默认规则解析。标准流程如下：

1. 按 [本地调试总流程](local-debug/overview.md) 选择 App 配置路径并设置业务 Server 启动环境变量。
2. 在独立终端启动业务 Server。业务 Server 负责业务登录接口、MCP `/mcp`、FetchTokenURL、RefreshTokenURL 和需要的本地持久化；AppSecret 从服务端安全配置读取。mock MVP 只通过 `DOUBAO_AUTH_MODE=mock` 选择 mock 链路，固定验证码保留在源码和页面 hint 中，不增加 `DEV_VERIFY_CODE` 等验证码环境变量。
3. 在 dbx 项目根目录启动：

```bash
dbx dev --mcp-endpoint http://127.0.0.1:<port>/mcp
```

4. 使用 Web 调试器或真机触发需要登录的 tool，分别验证一键登录和自定义登录页的 UI、业务接口、`postLoginResult` 和后续 MCP 调用。

业务 Server、登录 callback、前端业务 API、Manifest 和 `--mcp-endpoint` 的地址与端口必须一致；本地 callback 可以通过调试 Bridge 联调，但业务 Server 必须持续运行。修改 `src/auth` 下的登录页代码后等待热更新并刷新 Web 调试器；只有路径、Manifest、运行态 Skill 或 MCP endpoint 变化时才重启 `dbx dev`。需要验证 Skill、Manifest、MCP 和出卡链路时，另行使用 `dbx simulator eval`。

## 隐私协议

拒绝隐私协议时，如果处于登录链路，回传登录失败。

```ts
import { definePrivacyApi } from '@doubao-apps/framework';

export default definePrivacyApi({
  async onAgreePrivacy(res) {
    console.log('[auth] privacy agreed', { result: Boolean(res?.result) });
  },

  async onRefusePrivacy() {
    await postLoginFailureOnce();
  }
});
```

## 三方登录交换接口

前端只能调用业务登录交换接口，不能直接调用 FetchTokenURL。

```http
POST /auth/doubao/login
Content-Type: application/json
```

请求体：

```json
{
  "app_id": "db_xxxxxx",
  "login_code": "doubao_login_code",
  "phone_code": "optional_phone_code"
}
```

服务端必须执行：

1. 校验 `app_id` 属于当前应用。
2. 校验 `login_code` 存在且未处理过。
3. 携带固定 Header 调用平台 `POST /api/login/v1/developer/get_openid`，提交当前应用的 `app_id`、`app_secret`、`grant_type: authorization_code` 和 `login_code`，用平台返回的 `data.open_id` 换取 `open_id`；一键登录和自定义登录都必须走这一步。
4. 请求中存在 `phone_code` 时，携带固定 Header 调用平台 OpenAPI 换手机号，并用开发者私钥解密；解密结果通常是手机号字符串本身，不是 `object`。
5. 维护 `open_id` 与三方账号 ID 的绑定关系；有手机号时可按手机号对撞、绑定或注册，没有手机号时至少维护 `open_id` 对应的业务用户记录。
6. 生成短期一次性 `developer_login_code`。
7. 在持久化事务中保存 `developer_login_code`、`app_id`、`open_id`、三方账号 ID、scope、过期时间、未使用状态。
8. 如 Page 需要业务登录态，生成独立的 Page session。
9. 返回 `developer_login_code` 和可选 Page session。

自定义登录页可以调用独立的业务接口，例如 `POST /auth/custom-login`。该接口除校验业务凭证外，仍必须执行上述**与一键登录相同的** `login_code` 调平台 `get_openid` 换 `open_id`、账号绑定、短期 code 持久化和响应流程；它不接收或生成 `phone_code`，不改变后续 FetchTokenURL 和 MCP 鉴权链路。不要用 `hash(login_code)`、手机号、业务账号或固定字符串冒充 `open_id`。

成功响应：

```json
{
  "code": "developer_issued_one_time_login_code",
  "session_token": "optional_page_session_token",
  "session_expires_in": 7200
}
```

失败响应：

```json
{
  "code": 40004,
  "msg": "login_code is invalid, expired, or already used",
  "data": null
}
```

## FetchTokenURL

豆包平台收到 `postLoginResult({ result: true, code })` 后调用 FetchTokenURL。

```http
POST /auth/fetch_token
Content-Type: application/json
```

请求体：

```json
{
  "app_id": "db_xxxxxx",
  "code": "developer_issued_one_time_login_code"
}
```

成功响应：

```json
{
  "code": 0,
  "msg": "success",
  "data": {
    "access_token": "developer_user_access_token",
    "expires_in": 7200,
    "refresh_token": "developer_user_refresh_token",
    "refresh_token_expires_in": 2592000
  }
}
```

失败时返回非 0 JSON：

```json
{
  "code": 40004,
  "msg": "code is invalid, expired, or already used",
  "data": null
}
```

FetchTokenURL 必须校验 `app_id`、code 存在、未过期、未使用、绑定账号有效、scope 允许。兑换成功时，在同一事务中标记 code 已使用，并持久化 MCP `access_token`、`refresh_token`、过期时间、scope、`app_id`、`open_id`、三方账号 ID。

三方服务端应维护 MCP token 表，至少包含：

- `access_token`
- `refresh_token`
- `app_id`
- `open_id`
- 三方账号 ID
- scope
- access token 过期时间
- refresh token 过期时间
- 撤销时间
- 创建时间和最近刷新时间

这张表及短期兑换 code 的已使用状态必须使用持久化数据库保存，不能用进程内 `Map`、对象或数组代替。开发、联调和上传后真机测试等所有非正式业务阶段都可以优先使用 SQLite；不要因为已经上传到平台就改回内存存储。否则业务 Server 重启后，平台携带既有 `refresh_token` 调用 RefreshTokenURL 时无法识别 token，只能错误地要求用户重新登录。token 可以按安全策略加密或仅保存可查询的安全摘要，但必须能在重启后校验、刷新和撤销。

## RefreshTokenURL

```http
POST /auth/refresh_token
Content-Type: application/json
```

请求体：

```json
{
  "app_id": "db_xxxxxx",
  "refresh_token": "developer_user_refresh_token"
}
```

成功响应：

```json
{
  "code": 0,
  "msg": "success",
  "data": {
    "access_token": "new_developer_user_access_token",
    "expires_in": 7200,
    "refresh_token": "new_developer_user_refresh_token",
    "refresh_token_expires_in": 2592000
  }
}
```

RefreshTokenURL 必须校验 refresh token 存在、属于当前 `app_id`、未过期、未撤销、绑定账号有效。refresh token 无效或过期时返回非 0 JSON，让平台重新触发登录授权。

刷新成功时，必须在同一持久化事务中更新 access token、refresh token、两者过期时间、最近刷新时间和旧 refresh token 的失效/轮换状态。不要只更新内存对象，这样即使 Server 在刷新前后重启，后续 refresh 请求仍能按数据库记录正确处理。

## MCP Server 鉴权

平台调用 MCP Server 时写入身份 Header，tool 的 `input_schema` 不需要声明这些字段。

| Header | 用途 |
| --- | --- |
| `X-DB-OPENID` | 标识当前智能服务应用下的用户 |
| `X-DB-ACCESS-TOKEN` | 校验三方账号登录态 |
| `X-DB-SESSION-TOKEN` | 需要智能服务会话校验时使用 |

处理规则：

- 公开 tool 不读取身份 Header。
- 需要用户身份的 tool 缺少 `X-DB-OPENID` 时返回 HTTP 401。
- 访问三方账号私有数据时必须校验 `X-DB-ACCESS-TOKEN`。
- token 缺失、过期、撤销、scope 不足或与 `X-DB-OPENID` 不匹配时返回 HTTP 401。
- 不要把 `open_id`、`access_token` 放进 tool arguments。
- 不要用 Page storage 或前端 session 判断 MCP 已登录。

最小逻辑：

```ts
function authenticateMcpRequest(req: Request, options: { requireBusinessAccount: boolean }) {
  const openId = req.headers.get('x-db-openid');
  const accessToken = req.headers.get('x-db-access-token');

  if (!openId) {
    throw new HttpError(401, 'missing openid');
  }

  if (!options.requireBusinessAccount) {
    return { openId };
  }

  if (!accessToken) {
    throw new HttpError(401, 'missing access token');
  }

  const token = lookupMcpAccessToken(accessToken);
  if (!token || token.expired || token.revoked || token.openId !== openId) {
    throw new HttpError(401, 'invalid access token');
  }

  return {
    openId,
    businessUserId: token.businessUserId,
    scope: token.scope
  };
}
```

## Page 登录态恢复

Page session 是业务页面登录态，不是 MCP token。

智能服务内回访流程：

1. Page 读取本地业务 session。
2. session 有效时直接请求业务接口。
3. session 缺失或过期时，调用 `login({ timeout })` 获取新的 `login_code`。
4. Page 调用 `POST /auth/doubao/login`，只传 `app_id` 和 `login_code`。
5. 服务端用 `login_code` 换 `open_id`，查找已绑定账号。
6. 服务端返回新的 Page session。
7. Page 保存 session 并刷新数据。

不要把 Page session 传给 `postLoginResult`，不要用 Page session 代替 MCP access token。

有效期建议：

- MCP Token 有效期和 Page session 有效期尽量保持一致。
- MCP Token 有效但 Page session 过期：Page 内用 `login({ timeout })` 静默恢复 session。
- Page session 有效但 MCP Token 过期：重新触发平台登录链路或按平台能力申请新的短期兑换 code，让平台重新兑换 MCP Token。

多端规则：

- 同一用户可以在多个设备使用豆包 APP。
- 三方颁发给平台的 MCP token 应支持多端场景，多个设备在主 bot 对话内链路可以复用或按业务策略独立签发有效 token。
- 智能服务内自定义登录态由三方开发者维护，平台不存储三方 Page session。
- 推荐 Page 使用 `login({ timeout })` 做静默恢复，避免多端场景下重复授权。

## DeleteUserInfoURL

`delete_userinfo_url` 只处理宿主侧授权数据删除通知，不等于退登、解绑或清除三方登录态。

```http
POST /auth/delete_userinfo
Content-Type: application/json
```

请求体：

```json
{
  "request_id": "unique_request_id",
  "open_id": "user_open_id",
  "event_time": "timestamp_ms",
  "fields": [
    {
      "field_name": "PHONE",
      "hard_delete": true
    }
  ],
  "third_party_user_id": "optional_business_user_id",
  "reason": 1,
  "data_source": 1
}
```

成功响应：

```json
{
  "success": true,
  "error_code": 0,
  "error_message": ""
}
```

处理规则：

- 按 `request_id` 幂等处理。
- 只删除或匿名化宿主授权给三方的数据字段，例如手机号、昵称、头像。
- 如果三方账号自身也有手机号、昵称、头像，区分数据来源。
- 不默认撤销 MCP Token。
- 不默认清除 Page session。
- 不默认解绑三方账号。

## 日志

记录结构化日志，禁止泄露敏感值。

必须记录：

- 前端登录交换接口开始、成功、失败。
- `postLoginResult(true)` 开始、成功、失败。
- `postLoginResult(false)` 开始、成功、失败。
- FetchTokenURL、RefreshTokenURL、DeleteUserInfoURL。
- `get_client_token`、`get_openid`、`get_user_phonenumber` 等平台 OpenAPI 调用。
- MCP Server 鉴权失败、token 过期、token 撤销、scope 不足。

每条日志至少包含 `event`、request/trace ID、endpoint 或 tool name、`app_id`、登录场景、阶段、`duration_ms`、HTTP/业务结果码、错误类型、平台 OpenAPI `log_id`。

禁止记录 `app_secret`、`login_code`、`phone_code`、`developer_login_code`、MCP token、Page session、Authorization Header、私钥、完整手机号、完整请求体、完整响应体。

## 异常与失败路径

| 场景 | 开发者处理方式 | 验收标准 |
| --- | --- | --- |
| 用户拒绝登录 | 端侧走 `onRefuseLogin` 或失败回调，调用 `postLoginResult({ result: false })`，不继续请求用户数据 | 页面可恢复，MCP tool 不会拿到错误 token |
| 用户拒绝隐私协议 | 端侧走 `onRefusePrivacy`，停止手机号、头像、昵称等隐私信息获取 | 不产生隐私数据读取调用 |
| 宿主未绑定手机号 | 只展示或只可使用三方自定义登录入口 | 不要求 `phone_code`，不误报手机号授权失败 |
| 用户选择自定义登录 | 不走 `phone_code`；只调用 `login({ timeout })` 获取 `login_code` | 服务端能用 `login_code` 换 `open_id` 并维护业务用户记录 |
| 自定义登录 MVP 输入错误验证码 | 前端拒绝提交或业务 Server 返回凭证错误；不调用成功态回传 | 页面提示重试，不能签发或持久化错误账号的 `developer_login_code` |
| `login_code` 过期或已使用 | 三方服务端返回登录交换失败，端侧重新引导用户登录 | 不复用已失败 code |
| `phone_code` 过期或已使用 | 仅宿主手机号一键登录入口失败；端侧可重新请求手机号授权或引导进入自定义登录 | 不把手机号缺失误判为 OpenID 登录失败 |
| 自定义登录失败 | 不调用成功态 `postLoginResult`；提示用户重试或取消 | 不产生错误账号绑定 |
| `postLoginResult(true)` 调用失败 | 前端立即调用 `postLoginResult(false)` 并抛出或记录错误 | 平台不进入半登录态 |
| FetchTokenURL 收到无效 code | 返回非 0 JSON，说明 code 无效、过期或已使用 | code 不能重复兑换 |
| MCP access token 过期 | 平台调用 RefreshTokenURL；三方返回新的 access token 和 refresh token | 用户无感刷新 |
| 业务 Server 重启后刷新 token | 从持久化 token 表查找 refresh token 并完成轮换 | 重启后平台携带原 refresh token 仍可刷新，除非已过期或撤销 |
| RefreshToken 过期 | 三方返回明确失败，平台重新触发登录授权 | 用户重新登录后恢复 MCP 调用 |
| MCP Server 收到无效 token | 返回 HTTP 401 | 平台按登录失效重新触发登录 |
| MCP Token 有效但 Page session 过期 | Page 内调用 `login({ timeout })` 换 `open_id`，重新下发业务 session | 对话内可用，进入 Page 后也能恢复登录态 |
| Page session 有效但 MCP Token 过期 | 重新触发平台登录链路或按平台能力重新兑换 MCP Token | 不强迫用户在 Page 内重复做无意义登录 |
| 隐私删除重复通知 | 按 `request_id` 幂等处理，删除指定宿主授权字段 | 重复通知不会造成异常，不误退登、不误解绑 |

## 调试验收

按顺序检查信号：

1. 用户请求需要登录的 tool。
2. Trace 出现登录授权卡，而不是模型直接文字回复。
3. 用户点击登录后，业务服务端收到 `/auth/doubao/login` 或自定义登录接口（例如 `/auth/custom-login`）。
4. 对应接口返回 `developer_login_code`，且服务端已持久化该 code。
5. 前端日志出现 `postLoginResult(true)` 开始和完成。
6. 平台调用 FetchTokenURL。
7. FetchTokenURL 返回标准 token JSON。
8. 后续 MCP tools/call 请求带有 `X-DB-OPENID` 和 `X-DB-ACCESS-TOKEN`。
9. MCP Server 鉴权通过并执行 tool。

自定义登录页 MVP 还要单独验收：页面直接展示源码中的固定开发验证码 hint；点击获取验证码不依赖 `DEV_VERIFY_CODE`、`debug_code` 或发送验证码接口；输入错误验证码会失败；输入固定验证码后仍会调用 `login()`，并由自定义业务接口同时收到业务凭证和 `login_code`。服务端必须像一键登录一样调用平台 `get_openid`，不能从 `login_code` 哈希或本地 mock 数据生成 `open_id`。成功时服务端持久化 `developer_login_code`，前端按“先 `postLoginResult(true)`、再保存 Page session、最后 `exitApp()`”完成；失败、取消、`login()` 失败、`postLoginResult` 失败和页面销毁仍各自只回传一次失败态。

常见现象：

| 现象 | 说明 | 排查 |
| --- | --- | --- |
| 模型直接文字回复，没有登录卡 | tool 未配置 `login_type: normal`，或调试器未读取最新 Manifest | 检查 Manifest 并重启调试器 |
| 只有登录卡，没有业务登录请求 | `src/auth/login.ts` 或 `src/auth/login-page/index.tsx` 未触发、登录卡点击失败，或前端调用了当前 SDK 不存在的 API | 检查 `src/auth` 构建产物、业务接口地址、前端日志，并核对 SDK `.d.ts` 与真实导出 |
| `/auth/doubao/login` 成功，但没有 FetchTokenURL | `postLoginResult(true)` 未成功交给宿主，或本地调试器平台代理失败 | 检查 JSB 调用、前端日志、平台代理日志 |
| OpenAPI `get_user_phonenumber` 成功但登录交换失败 | 服务端可能把解密后的手机号字符串误当成对象解析 | 检查解密结果解析逻辑；日志只输出 payload 类型或字段名，不输出手机号 |
| 自定义登录页成功后半屏关闭但不继续请求 | 业务登录接口未返回有效 `developer_login_code`、`postLoginResult(true)` 未完成、页面关闭顺序错误，或 `onDestroy` 又补发失败态 | 先完成业务登录交换和 `postLoginResult(true)`，再保存 session，最后 `exitApp()`；`onDestroy` 失败回传必须幂等 |
| FetchTokenURL 收到无效 code | 登录交换接口返回了未持久化、过期或已使用 code | 检查 code 存储和原子消费 |
| MCP tool 报 `LOGIN_REQUIRED` 或缺少 Header | MCP token 未兑换成功，平台没有注入 `X-DB-*` | 回看 `postLoginResult` 和 FetchTokenURL |
| Page 已登录但 MCP 未登录 | Page session 与 MCP token 混淆 | 检查 FetchTokenURL 和 MCP Header |
| `simulator tool execution failed` 且服务端显示缺少 `X-DB-*` | 登录态没有进入 MCP 调用 | 先修通登录卡、`postLoginResult`、FetchTokenURL |

## 生成代码检查清单

- Manifest 写了 `mcp_server.user_auth`。
- 需要登录的 tool 写了 `login_type: normal`。
- 不需要登录的 tool 写了 `login_type: no_login`。
- `login_type: normal` 的 tool 不写 `login_params`；只有平台 validator 允许的登录类型才配置 `login_params`。
- 前端所有 OpenAPI request 包含固定 Header。
- 服务端所有平台 OpenAPI 调用包含固定 Header。
- 前端按需有 `src/auth/login.ts`、`src/auth/privacy.ts`。
- 自定义登录页位于 `src/auth/login-page/index.tsx`，使用 `defineLoginPage()`。
- 自定义登录页 MVP 直接在源码中使用固定开发验证码，并在页面显示 hint；没有 `DEV_VERIFY_CODE`、`debug_code` 或发送验证码接口依赖。
- 一键登录和自定义登录都通过服务端平台 `get_openid` 获取 `open_id`；mock MVP 只能 mock 验证码，不能 mock 或派生 `open_id`。
- 第一阶段以登录业务链路为验收目标，未为 UI 改版引入与登录无关的复杂改动。
- 一键登录按当前 SDK 定义取得 `phone_code`；如果当前版本通过 `onLogin(res)` 透传，就使用 `res.code`，不要调用不存在的 `getPhoneNumber()`。
- 开发者自定义实现的登录页关闭页面时使用 `exitApp()`，不使用 `navigateBack()`。
- 前端另行调用 `login()` 获取 `login_code`。
- 前端调用三方登录交换接口，而不是直接调用 FetchTokenURL。
- 自定义登录页已将验证码、密码或其它业务凭证接入开发者业务 Server，并与 `login_code` 一起完成业务登录交换。
- 登录交换接口成功前已持久化可兑换的 `developer_login_code`。
- 前端先 `postLoginResult(true)`，再写 storage；开发者自定义实现的 MCP 登录页最后用 `exitApp()` 关闭。
- 前端不持久化 `developer_login_code`。
- 失败、拒绝、取消路径调用 `postLoginResult(false)`。
- FetchTokenURL 校验 code 的 app、用户、账号、过期和已使用状态。
- RefreshTokenURL 校验 refresh token 的 app、用户、账号、过期和撤销状态。
- code、MCP token、账号绑定关系持久化，不只存在进程内存。
- MCP Server 从 Header 鉴权，不从 tool arguments 或 Page storage 鉴权。
- MCP Server 访问三方私有数据时校验 `X-DB-ACCESS-TOKEN`。
- MCP 鉴权失败返回 HTTP 401。
- Page 有独立 session 保存、恢复和过期重建逻辑。
- DeleteUserInfoURL 幂等删除宿主授权字段。
- 日志可关联、已脱敏，并包含平台 `log_id`。

## 与其它 group 的关系

- `mcp`：需要按用户身份执行 tool 时，MCP Server 必须完成 Header 读取和鉴权逻辑。
- `frontend`：需要登录承接、自定义登录页或 Page 内登录态恢复时，实现 `src/auth` 和 Page session 逻辑；旧项目才使用 `src/mcp-ui` 兼容路径。
- `manifest`：把 auth 结论写入 `mcp_server.user_auth`、`tools.<tool>.login_type`，并仅在平台 validator 允许时写入 `tools.<tool>.login_params`。
- `debug`：验证登录成功、拒绝登录、登录失败、token 失效、Page 回访登录态恢复等路径。
- `build`：生产认证 URL 必须是公网 HTTPS URL；localhost/mock URL 只能用于本地调试。

## 完成判据

一条登录链路只有满足以下条件才算完成：

- 已明确本轮是否需要 auth；不需要 auth 时，相关 tool 显式使用 `login_type: no_login`。
- 需要 auth 时，所有需要登录的 tool 都通过 Manifest `login_type: normal` 触发登录。
- 只有平台 validator 允许对应 `login_type` 搭配 `login_params` 时才配置对应参数；`login_type: normal` 的业务自定义登录态不写 `login_params`。
- 前端成功路径调用 `postLoginResult({ result: true, code })`，其中 `code` 是三方服务端签发的一次性短期 `developer_login_code`。
- 前端失败、拒绝、取消路径调用 `postLoginResult({ result: false })`。
- 前端没有把 `login_code`、`phone_code`、MCP token 或 Page session 传给 `postLoginResult`。
- 三方服务端能用 `login_code` 换 `open_id`。
- 宿主手机号一键登录时，三方服务端能用 `phone_code` 换取并解密手机号。
- 自定义登录时，三方服务端不要求 `phone_code`；先校验业务凭证，再用 `login_code` 换 `open_id` 并维护业务用户记录。
- 三方服务端维护 `open_id` 与三方账号的绑定关系。
- FetchTokenURL 能按 `{ "app_id": "...", "code": "..." }` 返回标准 token JSON，并拒绝无效、过期或已使用 code。
- RefreshTokenURL 能按 `{ "app_id": "...", "refresh_token": "..." }` 返回标准 token JSON，并拒绝无效或过期 refresh token。
- `developer_login_code`、MCP access/refresh token、过期/撤销/轮换状态和账号绑定关系已持久化，并验证 Server 重启后的 refresh token 流程。
- MCP Server 不从 tool arguments 读取身份；需要身份时从 `X-DB-OPENID`、`X-DB-ACCESS-TOKEN` 鉴权。
- MCP Server 对三方账号私有数据校验 `X-DB-ACCESS-TOKEN`，无效 token 返回 HTTP 401。
- Page 不假设已有业务登录态；已实现或明确设计业务 session 的保存、恢复和过期重建逻辑。
- 涉及手机号、头像、昵称等宿主授权信息时，实现 DeleteUserInfoURL 并保证幂等删除。
- 所有关键链路有结构化、可关联、已脱敏且不过度冗余的日志；平台 OpenAPI 失败时能从日志获得 `log_id`。
