duoqi-api/docs/fusion-auth-integration.md
Wang Zhuoxuan a2282975ca feat: 集成阿里云融合认证实现手机号一键登录与登录方式管理
- 新增 POST /auth/fusion/token 获取 SDK 鉴权 Token
- 新增 POST /auth/fusion/verify 用 verifyToken 换取手机号并登录/注册
- 新增 GET /auth/providers 按平台返回可用登录方式列表
- 新增 PUT /admin/auth-providers 管理端热切换第三方登录开关
- 新增 appSettings 表存储运行时配置,支持不重启生效
- 修复 schema 中超长外键名称导致的 db:push 失败
2026-05-27 22:50:11 +08:00

423 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 阿里云融合认证集成 — 设计与实施计划
> Phase 1e: Phone Number Authentication
> Created: 2026-05-27
> Status: Implemented
## 概述
集成阿里云号码认证服务的**融合认证**方案,为多奇平台提供手机号一键登录能力。融合认证是一种校验用户手机号的云原生服务,集成了号码认证(运营商网关取号)、短信验证码、语音验证码等通信功能。
### 目标
- 新增 `POST /auth/fusion/token` 端点,为客户端 SDK 提供鉴权 Token对齐 Flutter 客户端 `FUSION_AUTH_INTEGRATION.md` 第 5.1 节)
- 新增 `POST /auth/fusion/verify` 端点,用运营商 verifyToken 换取手机号并完成登录(对齐 Flutter 客户端第 5.2 节)
- 支持游客账号通过手机号关联升级为正式账号
### 不在范围内
- 短信验证码/语音验证码登录(号码认证降级到短信时由阿里云模板自动处理,服务端感知不变)
- Flutter 客户端 SDK 集成(客户端侧独立进行,见 `duoqi-flutter/docs/FUSION_AUTH_INTEGRATION.md`
- 修改现有 `POST /auth/phone`(保留 501 占位,未来可用于非融合认证的手机登录)
---
## 架构分析
### 现有认证架构
项目已实现 `guest`、`huawei`、`apple` 三种登录方式,采用统一的 `findOrCreate*` 模式:
```
客户端 ──▶ 路由层(Zod校验) ──▶ 服务层(业务逻辑) ──▶ 数据库(users表)
signTokens() + buildLoginResponse()
```
关键设计:
- `users` 表以 `(authType, authId)` 唯一索引标识用户
- `authType` 枚举已包含 `'phone'``authId` 用于存储手机号
- JWT payload 携带 `{ userId, authType, tier }`
- 登录响应统一为 `{ user, tokens }` 结构
### 融合认证集成点
融合认证引入一个**两步交互**流程,比现有的华为/Apple 认证多一个"预取 Token"步骤:
```
┌──────────┐ ┌──────────────┐ ┌──────────────────┐
│ 客户端 │ │ duoqi-api │ │ 阿里云 Dypnsapi │
└────┬─────┘ └──────┬───────┘ └────────┬─────────┘
│ │ │
│ ① POST /auth/fusion/token │
│ { platform, packageName?, bundleId? } │
│───────────────────▶│ │
│ │ ② GetFusionAuthToken │
│ │────────────────────────▶│
│ │◀── { authToken } ────────│
│◀── { fusionAuthToken } ──│ │
│ │ │
│ [客户端 SDK 使用 authToken 完成认证] │
│ [获得 verifyToken] │ │
│ │ │
│ ③ POST /auth/fusion/verify │
│ { verifyToken } │ │
│───────────────────▶│ │
│ │ ④ VerifyWithFusionAuthToken
│ │────────────────────────▶│
│ │◀── { phoneNumber, verifyResult, phoneScore }
│ │ │
│ │ ⑤ 查找或创建用户,签发JWT │
│◀── { user, tokens } │ │
│ │ │
```
> 路由路径对齐 Flutter 客户端 `FUSION_AUTH_INTEGRATION.md` 第 5 节的定义。
---
## 关键设计决策
### 决策 1手机号存储格式
**选择:存储阿里云返回的原始手机号字符串(如 `18012341234`**
理由:
- `VerifyWithFusionAuthToken` 在号码认证通过时返回完整手机号(非掩码)
- 完整手机号作为 `authId` 可确保用户唯一性
- 掩码号(如 `180****1234`)仅出现在日志或审计场景,不用于身份标识
替代方案(未采纳):
- 存储掩码号:无法唯一标识用户,不可行
- 对手机号做哈希:丧失可读性,且哈希碰撞风险虽极低但非零
### 决策 2authToken 有效期
**选择900 秒15 分钟API 允许的最小值)**
理由:
- 融合认证的典型交互在几秒到几十秒内完成
- 更短的 Token 有效期意味着更小的被盗用风险窗口
- 与项目中 access_token 1 小时、refresh_token 30 天的分级策略一致——authToken 作为临时凭证应有最短有效期
### 决策 3阿里云 SDK 客户端生命周期
**选择:懒初始化单例模式**
理由:
- 阿里云 SDK 客户端是线程安全的,可复用
- 避免每次请求创建新客户端的开销HTTP 连接池复用)
- 与项目中 `db` 客户端的单例模式保持一致
- 仅在配置了阿里云环境变量时才初始化,不影响其他认证方式
### 决策 4路由设计
**选择:新增 `/auth/fusion/token` 和 `/auth/fusion/verify`,独立于 `/auth/phone`**
理由:
- Flutter 客户端已在 `FUSION_AUTH_INTEGRATION.md` 第 5 节明确定义了这两个路径,服务端需对齐
- `/auth/fusion/` 作为独立命名空间,语义上更准确——融合认证不只是一键取号,而是由阿里云模板编排的多种认证方式(号码认证、短信、图形验证等)
- `/auth/phone` 保留 501 占位,未来可用于非融合认证的原生短信验证码登录等场景
替代方案(未采纳):
- `POST /auth/phone/fusion-token` + 修改 `POST /auth/phone`:路径层级不合理,融合认证不等同于"手机号登录"
-`/auth/phone` 请求体中加 `action: 'getToken' | 'verify'`:语义不够明确,增加请求体复杂度
### 决策 5账号关联扩展
**选择:在 `/auth/link` 中支持 `provider: 'phone'`**
理由:
- 已有游客关联华为/Apple 的完整流程(`account-link-service.ts`
- 手机号关联复用相同的 `accountMigrations` 表和幂等逻辑
- 仅需扩展 `linkSchema``provider` 枚举和 `credential` 结构
---
## 阿里云 API 参考
### GetFusionAuthToken
| 项目 | 值 |
|------|------|
| Action | `GetFusionAuthToken` |
| 授权 | `dypns:GetFusionAuthToken` |
| Endpoint | `dypnsapi.aliyuncs.com` |
**请求参数:**
| 名称 | 类型 | 必填 | 描述 | 示例值 |
|------|------|------|------|--------|
| SchemeCode | string | 是 | 认证方案 Code | `FA1000*************201` |
| Platform | string | 是 | 平台:`Android` 或 `iOS` | `Android` |
| PackageName | string | Android 必填 | App 包名 | `com.example.test` |
| PackageSign | string | Android 必填 | App 包签名 | `47fcc************************278` |
| BundleId | string | iOS 必填 | App bundleId | `com.example.test` |
| DurationSeconds | long | 是 | Token 有效时长(秒),范围 90043200 | `900` |
**响应示例:**
```json
{
"Message": "成功",
"RequestId": "CC3BB6D2-2FDF-4321-9DCE-B38165CE4C47",
"Model": "FKcksloqk***********jalEc+",
"Code": "OK",
"Success": true
}
```
### VerifyWithFusionAuthToken
| 项目 | 值 |
|------|------|
| Action | `VerifyWithFusionAuthToken` |
| 授权 | `dypns:VerifyWithFusionAuthToken` |
| Endpoint | `dypnsapi.aliyuncs.com` |
**请求参数:**
| 名称 | 类型 | 必填 | 描述 | 示例值 |
|------|------|------|------|--------|
| VerifyToken | string | 是 | 客户端 SDK 完成认证后返回的 Token | `LD108enNdlsl*******sFLKCks1==` |
**响应示例:**
```json
{
"Message": "示例值",
"RequestId": "CC3BB6D2-2FDF-4321-9DCE-B38165CE4C47",
"Model": {
"PhoneNumber": "18012341234",
"VerifyResult": "PASS",
"PhoneScore": 20
},
"Code": "OK",
"Success": true
}
```
**关键错误码:**
| HTTP 状态码 | 错误码 | 描述 |
|-------------|--------|------|
| 400 | `SmsCodeVerifyFail` | 短信验证码失败(号码认证降级到短信时) |
| 400 | `Throttling.System` | 接口被限流 |
| 400 | `VerifySchemeNotExist` | 认证方案不存在 |
| 400 | `SchemeNotPassed` | 认证方案未通过审核 |
| 400 | `Unsupported.Account` | 账号未开通号码认证服务 |
| 403 | `UnauthorizedOperation` | 权限校验失败 |
| 500 | `SystemError` | 系统异常 |
---
## 分步实施计划
### Phase 1基础设施前置条件
| # | 任务 | 产出文件 | 验证标准 |
|---|------|----------|----------|
| 1.1 | 安装阿里云 SDK 依赖 | `package.json` | `@alicloud/dypnsapi20170525`、`@alicloud/openapi-client`、`@alicloud/tea-util` 安装成功 |
| 1.2 | 添加环境变量 | `src/utils/config.ts`、`.env.example` | `ALIYUN_ACCESS_KEY_ID`、`ALIYUN_ACCESS_KEY_SECRET`、`ALIYUN_FUSION_SCHEME_CODE` 加入 Zod schema可选字段 |
| 1.3 | 类型检查通过 | — | `bun run typecheck` 无错误 |
### Phase 2服务层
| # | 任务 | 产出文件 | 验证标准 |
|---|------|----------|----------|
| 2.1 | 创建阿里云客户端封装 | `src/services/auth/fusion-auth-client.ts`(新建) | 懒初始化单例,配置从 `config` 读取 |
| 2.2 | 实现 `getFusionAuthToken` | `src/services/auth/fusion-auth-client.ts` | 接收平台参数,调用阿里云 API返回 Token 字符串 |
| 2.3 | 实现 `verifyWithFusionAuthToken` | `src/services/auth/fusion-auth-client.ts` | 接收 verifyToken调用阿里云 API返回 `{ phoneNumber, verifyResult }` |
| 2.4 | 实现 `findOrCreatePhone` | `src/services/auth/jwt.ts` | 遵循 `findOrCreateGuest` / `findOrCreateHuawei` 相同模式 |
| 2.5 | 类型检查通过 | — | `bun run typecheck` 无错误 |
### Phase 3路由层
| # | 任务 | 产出文件 | 验证标准 |
|---|------|----------|----------|
| 3.1 | 新增 `POST /auth/fusion/token` 路由 | `src/routes/auth.ts` | 接收 `{ platform, packageName?, packageSign?, bundleId? }`,调用 `getFusionAuthToken`,返回 `{ fusionAuthToken }` |
| 3.2 | 新增 `POST /auth/fusion/verify` 路由 | `src/routes/auth.ts` | 接收 `{ verifyToken }`,调用 `verifyWithFusionAuthToken` + `findOrCreatePhone`,返回标准 `LoginResponse` |
| 3.3 | 扩展 `/auth/link` 支持 phone provider | `src/routes/auth.ts`、`src/services/auth/account-link-service.ts` | `linkSchema.provider` 增加 `'phone'`credential 结构适配 |
| 3.4 | 类型检查通过 | — | `bun run typecheck` 无错误 |
### Phase 4测试
| # | 任务 | 产出文件 | 验证标准 |
|---|------|----------|----------|
| 4.1 | fusion-auth-client 单元测试 | `src/__tests__/services/fusion-auth.test.ts`(新建) | mock 阿里云 SDK测试正常流程和错误处理 |
| 4.2 | findOrCreatePhone 单元测试 | `src/__tests__/services/auth.test.ts` | 测试新用户创建和已有用户查找 |
| 4.3 | 全量测试通过 | — | `bun run test` 全部通过 |
### Phase 5文档
| # | 任务 | 产出文件 | 验证标准 |
|---|------|----------|----------|
| 5.1 | 更新 API 文档 | `docs/api-reference.md` | 新增 `POST /auth/fusion/token``POST /auth/fusion/verify` 端点描述 |
| 5.2 | 更新 CLAUDE.md 项目结构 | `CLAUDE.md` | services/auth 目录列表新增 `fusion-auth-client` |
---
## 新增环境变量
```bash
# .env 新增项(阿里云融合认证)
ALIYUN_ACCESS_KEY_ID= # 阿里云 RAM 用户的 AccessKey ID
ALIYUN_ACCESS_KEY_SECRET= # 阿里云 RAM 用户的 AccessKey Secret
ALIYUN_FUSION_SCHEME_CODE= # 融合认证方案 Code在阿里云控制台创建
```
配置特点:
- 三个变量均为可选——未配置时不影响游客、华为、Apple 等其他登录方式
- 调用融合认证相关接口时检查配置,未配置则返回 `NOT_IMPLEMENTED``SERVICE_UNAVAILABLE`
- AccessKey 通过 RAM 子用户授权,仅需 `AliyunDypnsFullAccess` 权限
---
## 新增 API 端点定义
> 路由路径与 Flutter 客户端 `FUSION_AUTH_INTEGRATION.md` 第 5 节保持一致。
### POST /auth/fusion/token
认证:无
限流10 次/分钟
用途:为客户端融合认证 SDK 提供鉴权 Token。客户端在调用 `FusionAuth.init(schemeCode, authToken)` 时需要此 Token。对应阿里云 `GetFusionAuthToken` API。
请求:
```json
{
"platform": "Android",
"packageName": "com.duoqi.app",
"packageSign": "47fcc************************278"
}
```
```json
{
"platform": "iOS",
"bundleId": "com.duoqi.app"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `platform` | `'Android' \| 'iOS'` | 是 | 客户端平台 |
| `packageName` | string | Android 必填 | App 包名 |
| `packageSign` | string | Android 必填 | App 包签名 |
| `bundleId` | string | iOS 必填 | App bundleId |
响应:
```json
{
"success": true,
"data": {
"fusionAuthToken": "FKcksloqk***********jalEc+"
},
"error": null
}
```
错误响应(服务未配置):
```json
{
"success": false,
"data": null,
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "Fusion auth is not configured"
}
}
```
### POST /auth/fusion/verify
认证:无
限流10 次/分钟
用途:客户端融合认证 SDK 完成认证后(`onVerifySuccess` 事件),将运营商返回的 verifyToken 发送到此端点。服务端用 verifyToken 向阿里云换取手机号,完成用户查找或创建,签发 JWT。对应阿里云 `VerifyWithFusionAuthToken` API。
请求:
```json
{
"verifyToken": "LD108enNdlsl*******sFLKCks1=="
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `verifyToken` | string | 是 | 客户端 SDK `onVerifySuccess` 事件返回的 Token |
成功响应(同 `/auth/guest`
```json
{
"success": true,
"data": {
"user": {
"id": "uuid",
"nickname": null,
"avatarUrl": null,
"tier": "free"
},
"tokens": {
"accessToken": "jwt",
"refreshToken": "jwt"
}
},
"error": null
}
```
错误响应(认证失败):
```json
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Phone verification failed"
}
}
```
错误响应(服务未配置):
```json
{
"success": false,
"data": null,
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "Fusion auth is not configured"
}
}
```
---
## 前置条件检查清单
在开始编码之前,需要以下准备工作:
- [ ] **阿里云 RAM 子用户**:创建 RAM 子用户,授予 `AliyunDypnsFullAccess` 权限,获取 AccessKey ID 和 Secret
- [ ] **融合认证方案**:在号码认证服务控制台创建融合认证方案,获取 SchemeCode
- [ ] **Android 签名信息**:确认 App 包名 (`PackageName`) 和签名 (`PackageSign`)
- [ ] **iOS Bundle 信息**:确认 App bundleId
- [ ] **Flutter 客户端状态**:确认 duoqi-flutter 是否已集成融合认证 SDK
---
## 风险与注意事项
1. **手机号隐私合规**:存储用户手机号需符合《个人信息保护法》,建议在隐私政策中明确告知
2. **运营商支持范围**号码认证一键取号依赖运营商网关WiFi 环境可能降级到短信验证码
3. **限流**:阿里云 API 有自身限流(错误码 `Throttling.System`),服务端应做好重试和降级
4. **错误码映射**:阿里云的错误码需映射到项目统一的 `error.code` 体系
5. **账号冲突**:同一手机号可能已通过其他方式注册(如先游客后手机号),需考虑合并策略