# 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": 604800,
    "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 导入接口。
