304 lines
9.5 KiB
Markdown
304 lines
9.5 KiB
Markdown
# Google 三方注册 Flutter 对接
|
||
|
||
## 边界
|
||
|
||
Google 登录后,Flutter 只暂存 Firebase ID token 和 Google 基本信息,不写入 `AppAuthSession`。
|
||
|
||
资料页允许 pending third-party auth 进入,不要求 `_authService.isSignedIn`。用户点击完成后,Flutter 调注册接口;后端校验 Firebase ID token、创建或补齐用户资料、返回 access/refresh token。Flutter 收到 token 后再写入现有 `AppAuthSession`。
|
||
|
||
旧接口 `POST /api/v1/auth/third-party/login` 仍保留兼容。新 Google 流程只能在检查接口返回 `registered=true` 后调用它拿 session;不要在 `registered=false` 时调用它,避免旧接口创建 pending 用户。
|
||
|
||
## 调用流程
|
||
|
||
1. Flutter 调 Google Sign-In / Firebase Auth,拿到 Firebase ID token。
|
||
2. Flutter 暂存:
|
||
- `provider=firebase`
|
||
- `credential=<firebase_id_token>`
|
||
- Google 返回的昵称、头像等基础信息
|
||
- `device_id`、`platform`、`language`、`timezone`
|
||
3. 调注册检查接口。
|
||
4. 如果 `registered=true`,调用旧三方登录接口拿 session,写入 `AppAuthSession` 后进首页。
|
||
5. 如果 `registered=false`,进入资料页,用户补齐 `username`、`avatar`、`gender`、`country`、`invite_code`。
|
||
6. 如果用户选择本地头像,先调用注册头像上传接口拿 URL。
|
||
7. 调 `POST /api/v1/auth/third-party/register`。
|
||
8. 成功后把返回的 token 写入 `AppAuthSession`。
|
||
|
||
## 注册检查
|
||
|
||
地址:
|
||
|
||
```http
|
||
POST /api/v1/auth/third-party/registered/check
|
||
```
|
||
|
||
Headers:
|
||
|
||
| Header | 必填 | 说明 |
|
||
| --- | --- | --- |
|
||
| `Content-Type` | 是 | `application/json` |
|
||
| `X-App-Code` | 否 | App 编码,默认 `lalu` |
|
||
| `X-App-Package` | 否 | 包名;后端可按包名解析 app_code。 |
|
||
|
||
参数:
|
||
|
||
| 字段 | 类型 | 必填 | 说明 |
|
||
| --- | --- | --- | --- |
|
||
| `provider` | string | 是 | 固定传 `firebase`。 |
|
||
| `credential` | string | 是 | Firebase ID token。 |
|
||
|
||
请求示例:
|
||
|
||
```json
|
||
{
|
||
"provider": "firebase",
|
||
"credential": "firebase_id_token"
|
||
}
|
||
```
|
||
|
||
返回值:
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "ok",
|
||
"request_id": "req_xxx",
|
||
"data": {
|
||
"registered": true
|
||
}
|
||
}
|
||
```
|
||
|
||
Flutter 处理:
|
||
|
||
| `registered` | 处理 |
|
||
| --- | --- |
|
||
| `true` | 用户已完成注册;再调用 `POST /api/v1/auth/third-party/login` 获取 session。 |
|
||
| `false` | 用户未完成注册,或者只有旧流程留下的 pending 用户;进入资料页,最后调用注册接口。 |
|
||
|
||
## 注册头像上传
|
||
|
||
地址:
|
||
|
||
```http
|
||
POST /api/v1/files/registration/avatar/upload
|
||
```
|
||
|
||
Headers:
|
||
|
||
| Header | 必填 | 说明 |
|
||
| --- | --- | --- |
|
||
| `Content-Type` | 是 | `multipart/form-data` |
|
||
| `X-App-Code` | 否 | App 编码,默认 `lalu` |
|
||
|
||
参数:
|
||
|
||
| 字段 | 类型 | 必填 | 说明 |
|
||
| --- | --- | --- | --- |
|
||
| `file` | file | 是 | 头像文件;也支持字段名 `avatar`。 |
|
||
|
||
限制:
|
||
|
||
| 项 | 说明 |
|
||
| --- | --- |
|
||
| 登录态 | 不需要 `Authorization`。 |
|
||
| 大小 | 最大 5MB。 |
|
||
| 格式 | `jpg`、`jpeg`、`png`、`webp`。 |
|
||
| 校验 | 后端按文件头识别图片类型,不信任客户端传入的 Content-Type。 |
|
||
|
||
返回值:
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "ok",
|
||
"request_id": "req_xxx",
|
||
"data": {
|
||
"url": "https://media.example.com/app/avatars/registration/20260626/avatar_xxx.png",
|
||
"object_key": "app/avatars/registration/20260626/avatar_xxx.png",
|
||
"content_type": "image/png",
|
||
"size_bytes": 204800
|
||
}
|
||
}
|
||
```
|
||
|
||
Flutter 使用 `data.url` 作为注册接口的 `avatar`。
|
||
|
||
## 三方注册
|
||
|
||
地址:
|
||
|
||
```http
|
||
POST /api/v1/auth/third-party/register
|
||
```
|
||
|
||
Headers:
|
||
|
||
| Header | 必填 | 说明 |
|
||
| --- | --- | --- |
|
||
| `Content-Type` | 是 | `application/json` |
|
||
| `X-App-Code` | 否 | App 编码,默认 `lalu` |
|
||
| `X-App-Package` | 否 | 包名;后端可按包名解析 app_code。 |
|
||
|
||
参数:
|
||
|
||
| 字段 | 类型 | 必填 | 说明 |
|
||
| --- | --- | --- | --- |
|
||
| `provider` | string | 是 | 固定传 `firebase`。 |
|
||
| `credential` | string | 是 | Firebase ID token。 |
|
||
| `device_id` | string | 是 | 设备 ID,用于 refresh session 绑定和注册风控。 |
|
||
| `username` | string | 是 | 用户昵称。 |
|
||
| `avatar` | string | 是 | HTTP(S) 头像 URL,可以是上传接口返回的 `url`。 |
|
||
| `gender` | string | 是 | 性别值,后端只保存原值。 |
|
||
| `country` | string | 是 | 国家码,例如 `SG`、`CN`。 |
|
||
| `invite_code` | string | 否 | 邀请码。 |
|
||
| `device` | string | 否 | 设备型号。 |
|
||
| `os_version` | string | 否 | 系统版本。 |
|
||
| `birth` | string | 否 | 生日,格式 `yyyy-mm-dd`。 |
|
||
| `app_version` | string | 否 | App 版本。 |
|
||
| `build_number` | string | 否 | 构建号。 |
|
||
| `source` | string | 否 | 注册来源。 |
|
||
| `install_channel` | string | 否 | 安装渠道。 |
|
||
| `campaign` | string | 否 | 投放活动。 |
|
||
| `platform` | string | 是 | `android` 或 `ios`。 |
|
||
| `language` | string | 否 | 客户端语言,例如 `en-US`。 |
|
||
| `timezone` | string | 否 | IANA 时区,例如 `Asia/Shanghai`。 |
|
||
|
||
请求示例:
|
||
|
||
```json
|
||
{
|
||
"provider": "firebase",
|
||
"credential": "firebase_id_token",
|
||
"device_id": "ios_device_001",
|
||
"username": "Alice",
|
||
"avatar": "https://media.example.com/app/avatars/registration/20260626/avatar_xxx.png",
|
||
"gender": "female",
|
||
"country": "SG",
|
||
"invite_code": "",
|
||
"device": "iPhone 15",
|
||
"os_version": "iOS 18.1",
|
||
"birth": "2000-01-02",
|
||
"app_version": "1.2.3",
|
||
"build_number": "123",
|
||
"source": "google",
|
||
"install_channel": "app_store",
|
||
"campaign": "",
|
||
"platform": "ios",
|
||
"language": "en-US",
|
||
"timezone": "Asia/Shanghai"
|
||
}
|
||
```
|
||
|
||
返回值:
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "ok",
|
||
"request_id": "req_xxx",
|
||
"data": {
|
||
"app_code": "lalu",
|
||
"user_id": "10001",
|
||
"display_user_id": "163001",
|
||
"default_display_user_id": "163001",
|
||
"display_user_id_kind": "default",
|
||
"display_user_id_expires_at_ms": 0,
|
||
"session_id": "sess_xxx",
|
||
"access_token": "access_token",
|
||
"refresh_token": "refresh_token",
|
||
"expires_in_sec": 1800,
|
||
"token_type": "Bearer",
|
||
"is_new_user": true,
|
||
"profile_completed": true,
|
||
"onboarding_status": "completed"
|
||
}
|
||
}
|
||
```
|
||
|
||
Flutter 成功处理:
|
||
|
||
| 字段 | 处理 |
|
||
| --- | --- |
|
||
| `access_token` | 写入现有 `AppAuthSession.accessToken`。 |
|
||
| `refresh_token` | 写入现有 `AppAuthSession.refreshToken`。 |
|
||
| `session_id` | 写入现有 session 标识。 |
|
||
| `user_id` | 字符串保存,不要转 JS number。 |
|
||
| `display_user_id` | 展示号。 |
|
||
| `profile_completed` | 必须为 `true`;否则不要进入首页。 |
|
||
| `onboarding_status` | 成功应为 `completed`。 |
|
||
|
||
## 重试和兼容
|
||
|
||
同一个 Firebase subject 如果之前已经被旧流程创建成 pending 用户,新注册接口会补齐这个用户并返回 token,不会再创建第二个用户。
|
||
|
||
同一个 Firebase subject 如果已经完成注册,再调该接口会按已有用户登录返回 token,不会覆盖用户后续改过的资料。
|
||
|
||
## 错误处理
|
||
|
||
| HTTP | code | 场景 | Flutter 处理 |
|
||
| --- | --- | --- | --- |
|
||
| `400` | `INVALID_ARGUMENT` | 缺必填字段、国家格式错误、头像 URL 非 HTTP(S)、生日格式错误。 | 停留资料页,提示用户修正。 |
|
||
| `400` | `INVALID_INVITE_CODE` | 邀请码格式错误或不可用。 | 提示邀请码无效,可允许清空后重试。 |
|
||
| `401` | `AUTH_FAILED` | Firebase ID token 无效、过期或 provider 不允许。 | 回到 Google 登录重新拿 token。 |
|
||
| `429` | `RATE_LIMITED` | 登录注册入口限流。 | 稍后重试。 |
|
||
| `409` | `DEVICE_ALREADY_REGISTERED` | 当前设备已达到注册账号上限。 | 提示当前设备无法继续注册。 |
|
||
| `502` | `UPSTREAM_ERROR` | user-service 或对象存储不可用。 | 稍后重试。 |
|
||
|
||
## Dart 调用示例
|
||
|
||
```dart
|
||
Future<AppAuthSession> registerFirebaseUser({
|
||
required AppNetworkService network,
|
||
required String firebaseIdToken,
|
||
required String deviceId,
|
||
required String username,
|
||
required String avatar,
|
||
required String gender,
|
||
required String country,
|
||
String inviteCode = '',
|
||
}) async {
|
||
final envelope = await network.postEnvelope(
|
||
'/api/v1/auth/third-party/register',
|
||
data: {
|
||
'provider': 'firebase',
|
||
'credential': firebaseIdToken,
|
||
'device_id': deviceId,
|
||
'username': username,
|
||
'avatar': avatar,
|
||
'gender': gender,
|
||
'country': country,
|
||
'invite_code': inviteCode,
|
||
'platform': Platform.isIOS ? 'ios' : 'android',
|
||
'language': PlatformDispatcher.instance.locale.toLanguageTag(),
|
||
'timezone': await currentIanaTimezone(),
|
||
},
|
||
requiresAuthorization: false,
|
||
);
|
||
|
||
if (envelope.code != 0) {
|
||
throw ApiException(envelope.code, envelope.message);
|
||
}
|
||
|
||
final data = envelope.data as Map<String, dynamic>;
|
||
if (data['profile_completed'] != true ||
|
||
data['onboarding_status'] != 'completed') {
|
||
throw StateError('third-party register did not complete profile');
|
||
}
|
||
|
||
return AppAuthSession(
|
||
appCode: data['app_code'] as String? ?? '',
|
||
userId: data['user_id'] as String? ?? '',
|
||
displayUserId: data['display_user_id'] as String? ?? '',
|
||
sessionId: data['session_id'] as String? ?? '',
|
||
accessToken: data['access_token'] as String? ?? '',
|
||
refreshToken: data['refresh_token'] as String? ?? '',
|
||
expiresInSec: (data['expires_in_sec'] as num? ?? 0).toInt(),
|
||
tokenType: data['token_type'] as String? ?? 'Bearer',
|
||
profileCompleted: data['profile_completed'] == true,
|
||
onboardingStatus: data['onboarding_status'] as String? ?? '',
|
||
);
|
||
}
|
||
```
|
||
|
||
相关 IM:注册接口成功后,后端会导入腾讯 IM 用户资料;Flutter 不需要额外调用 IM 导入接口。
|