新注册流程

This commit is contained in:
zhx 2026-06-26 10:15:48 +08:00
parent 210bef6955
commit 5890e3321a
17 changed files with 1380 additions and 195 deletions

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,31 @@ message LoginThirdPartyRequest {
string campaign = 20;
}
// RegisterThirdPartyRequest 使
// profile_required credential session
message RegisterThirdPartyRequest {
RequestMeta meta = 1;
string provider = 2;
string credential = 3;
string device_id = 4;
string username = 5;
string avatar = 6;
string gender = 7;
string country = 8;
string invite_code = 9;
string device = 10;
string os_version = 11;
string birth = 12;
string app_version = 13;
string build_number = 14;
string source = 15;
string install_channel = 16;
string campaign = 17;
string platform = 18;
string language = 19;
string timezone = 20;
}
// AuthResponse
message AuthResponse {
AuthToken token = 1;
@ -182,6 +207,7 @@ message AppHeartbeatResponse {
service AuthService {
rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse);
rpc LoginThirdParty(LoginThirdPartyRequest) returns (AuthResponse);
rpc RegisterThirdParty(RegisterThirdPartyRequest) returns (AuthResponse);
rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse);
rpc QuickCreateAccount(QuickCreateAccountRequest) returns (QuickCreateAccountResponse);
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);

View File

@ -21,6 +21,7 @@ const _ = grpc.SupportPackageIsVersion9
const (
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
AuthService_RegisterThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/RegisterThirdParty"
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
@ -40,6 +41,7 @@ const (
type AuthServiceClient interface {
LoginPassword(ctx context.Context, in *LoginPasswordRequest, opts ...grpc.CallOption) (*AuthResponse, error)
LoginThirdParty(ctx context.Context, in *LoginThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error)
RegisterThirdParty(ctx context.Context, in *RegisterThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error)
SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error)
QuickCreateAccount(ctx context.Context, in *QuickCreateAccountRequest, opts ...grpc.CallOption) (*QuickCreateAccountResponse, error)
RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, error)
@ -79,6 +81,16 @@ func (c *authServiceClient) LoginThirdParty(ctx context.Context, in *LoginThirdP
return out, nil
}
func (c *authServiceClient) RegisterThirdParty(ctx context.Context, in *RegisterThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AuthResponse)
err := c.cc.Invoke(ctx, AuthService_RegisterThirdParty_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetPasswordResponse)
@ -177,6 +189,7 @@ func (c *authServiceClient) AppHeartbeat(ctx context.Context, in *AppHeartbeatRe
type AuthServiceServer interface {
LoginPassword(context.Context, *LoginPasswordRequest) (*AuthResponse, error)
LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error)
RegisterThirdParty(context.Context, *RegisterThirdPartyRequest) (*AuthResponse, error)
SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error)
QuickCreateAccount(context.Context, *QuickCreateAccountRequest) (*QuickCreateAccountResponse, error)
RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, error)
@ -202,6 +215,9 @@ func (UnimplementedAuthServiceServer) LoginPassword(context.Context, *LoginPassw
func (UnimplementedAuthServiceServer) LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method LoginThirdParty not implemented")
}
func (UnimplementedAuthServiceServer) RegisterThirdParty(context.Context, *RegisterThirdPartyRequest) (*AuthResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RegisterThirdParty not implemented")
}
func (UnimplementedAuthServiceServer) SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetPassword not implemented")
}
@ -286,6 +302,24 @@ func _AuthService_LoginThirdParty_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _AuthService_RegisterThirdParty_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterThirdPartyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).RegisterThirdParty(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_RegisterThirdParty_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).RegisterThirdParty(ctx, req.(*RegisterThirdPartyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_SetPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetPasswordRequest)
if err := dec(in); err != nil {
@ -463,6 +497,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "LoginThirdParty",
Handler: _AuthService_LoginThirdParty_Handler,
},
{
MethodName: "RegisterThirdParty",
Handler: _AuthService_RegisterThirdParty_Handler,
},
{
MethodName: "SetPassword",
Handler: _AuthService_SetPassword_Handler,

View File

@ -0,0 +1,249 @@
# 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 完整注册流程。
## 调用流程
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. 进入资料页,用户补齐 `username``avatar``gender``country``invite_code`
4. 如果用户选择本地头像,先调用注册头像上传接口拿 URL。
5. 调 `POST /api/v1/auth/third-party/register`
6. 成功后把返回的 token 写入 `AppAuthSession`
## 注册头像上传
地址:
```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 导入接口。

View File

@ -12,6 +12,7 @@ import (
type UserAuthClient interface {
LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error)
LoginThirdParty(ctx context.Context, req *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error)
RegisterThirdParty(ctx context.Context, req *userv1.RegisterThirdPartyRequest) (*userv1.AuthResponse, error)
SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error)
QuickCreateAccount(ctx context.Context, req *userv1.QuickCreateAccountRequest) (*userv1.QuickCreateAccountResponse, error)
RefreshToken(ctx context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error)
@ -255,6 +256,10 @@ func (c *grpcUserAuthClient) LoginThirdParty(ctx context.Context, req *userv1.Lo
return c.client.LoginThirdParty(ctx, req)
}
func (c *grpcUserAuthClient) RegisterThirdParty(ctx context.Context, req *userv1.RegisterThirdPartyRequest) (*userv1.AuthResponse, error) {
return c.client.RegisterThirdParty(ctx, req)
}
func (c *grpcUserAuthClient) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
return c.client.SetPassword(ctx, req)
}

View File

@ -89,6 +89,10 @@ func (h *Handler) UploadAvatar(writer http.ResponseWriter, request *http.Request
h.uploadAvatar(writer, request)
}
func (h *Handler) UploadRegistrationAvatar(writer http.ResponseWriter, request *http.Request) {
h.uploadRegistrationAvatar(writer, request)
}
func (h *Handler) ProxyEffectMedia(writer http.ResponseWriter, request *http.Request) {
h.proxyEffectMedia(writer, request)
}

View File

@ -58,15 +58,20 @@ type uploadResponse struct {
// uploadAvatar 上传用户头像文件,只接受常见静态图片格式。
func (h *Handler) uploadAvatar(writer http.ResponseWriter, request *http.Request) {
h.uploadObject(writer, request, avatarUploadPolicy)
h.uploadObject(writer, request, avatarUploadPolicy, fmt.Sprintf("%d", auth.UserIDFromContext(request.Context())))
}
// uploadRegistrationAvatar 上传注册页临时头像,供尚未拿到后端 session 的三方注册流程使用。
func (h *Handler) uploadRegistrationAvatar(writer http.ResponseWriter, request *http.Request) {
h.uploadObject(writer, request, avatarUploadPolicy, "registration")
}
// uploadFile 上传 App 通用文件,不绑定用户资料更新语义。
func (h *Handler) uploadFile(writer http.ResponseWriter, request *http.Request) {
h.uploadObject(writer, request, fileUploadPolicy)
h.uploadObject(writer, request, fileUploadPolicy, fmt.Sprintf("%d", auth.UserIDFromContext(request.Context())))
}
func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request, policy uploadPolicy) {
func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request, policy uploadPolicy, ownerSegment string) {
if request.Method != http.MethodPost {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
@ -106,7 +111,7 @@ func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request
return
}
objectKey := buildUploadObjectKey(policy, auth.UserIDFromContext(request.Context()), header.Filename, contentType, time.Now().UTC())
objectKey := buildUploadObjectKey(policy, ownerSegment, header.Filename, contentType, time.Now().UTC())
objectURL, err := h.objectUploader.PutObject(request.Context(), objectKey, io.MultiReader(bytes.NewReader(head), file), header.Size, contentType)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -180,7 +185,7 @@ func isAvatarContentType(contentType string) bool {
}
}
func buildUploadObjectKey(policy uploadPolicy, userID int64, filename string, contentType string, now time.Time) string {
func buildUploadObjectKey(policy uploadPolicy, ownerSegment string, filename string, contentType string, now time.Time) string {
date := now.UTC().Format("20060102")
extension := normalizedUploadExtension(filename, contentType)
if extension == "" && policy.RequireImage {
@ -188,7 +193,12 @@ func buildUploadObjectKey(policy uploadPolicy, userID int64, filename string, co
}
idPrefix := strings.TrimSuffix(policy.Kind, "s")
return fmt.Sprintf("app/%s/%d/%s/%s%s", policy.Kind, userID, date, idgen.New(idPrefix), extension)
ownerSegment = strings.Trim(ownerSegment, "/")
if ownerSegment == "" {
// 调用方必须明确文件归属分区;空分区会让不同入口对象混在一起,直接回落到 unknown 便于排查。
ownerSegment = "unknown"
}
return fmt.Sprintf("app/%s/%s/%s/%s%s", policy.Kind, ownerSegment, date, idgen.New(idPrefix), extension)
}
func normalizedUploadExtension(filename string, contentType string) string {

View File

@ -287,6 +287,83 @@ func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Requ
httpkit.WriteOK(writer, request, authData(resp.GetToken(), &isNewUser))
}
// registerThirdParty 处理资料页完成时的三方注册,不返回 profile_required 中间态。
func (h *Handler) registerThirdParty(writer http.ResponseWriter, request *http.Request) {
var body struct {
Provider string `json:"provider"`
Credential string `json:"credential"`
DeviceID string `json:"device_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Gender string `json:"gender"`
Country string `json:"country"`
InviteCode string `json:"invite_code"`
Device string `json:"device"`
OSVersion string `json:"os_version"`
Birth string `json:"birth"`
AppVersion string `json:"app_version"`
BuildNumber string `json:"build_number"`
Source string `json:"source"`
InstallChannel string `json:"install_channel"`
Campaign string `json:"campaign"`
Platform string `json:"platform"`
Language string `json:"language"`
Timezone string `json:"timezone"`
}
if !decode(writer, request, &body) {
return
}
if !h.allowPublicAuthRequest(writer, request, authRateInput{
operation: authOperationThirdParty,
deviceID: body.DeviceID,
provider: body.Provider,
}) {
return
}
if !h.allowLoginRisk(writer, request, loginRiskInput{
Channel: body.Provider,
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
}) {
return
}
if h.userClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userClient.RegisterThirdParty(request.Context(), &userv1.RegisterThirdPartyRequest{
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
Provider: body.Provider,
Credential: body.Credential,
DeviceId: body.DeviceID,
Username: body.Username,
Avatar: body.Avatar,
Gender: body.Gender,
Country: body.Country,
InviteCode: body.InviteCode,
Device: body.Device,
OsVersion: body.OSVersion,
Birth: body.Birth,
AppVersion: body.AppVersion,
BuildNumber: body.BuildNumber,
Source: body.Source,
InstallChannel: body.InstallChannel,
Campaign: body.Campaign,
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
isNewUser := resp.GetIsNewUser()
httpkit.WriteOK(writer, request, authData(resp.GetToken(), &isNewUser))
}
// setPassword 处理已登录用户首次设置密码。
func (h *Handler) setPassword(writer http.ResponseWriter, request *http.Request) {
var body struct {

View File

@ -33,6 +33,7 @@ type AuthHandlers struct {
SetPassword http.HandlerFunc
QuickCreateAccount http.HandlerFunc
LoginThirdParty http.HandlerFunc
RegisterThirdParty http.HandlerFunc
RefreshToken http.HandlerFunc
Logout http.HandlerFunc
}
@ -59,6 +60,7 @@ type AppHandlers struct {
HandleTencentRTCCallback http.HandlerFunc
UploadFile http.HandlerFunc
UploadAvatar http.HandlerFunc
UploadRegistrationAvatar http.HandlerFunc
ProxyEffectMedia http.HandlerFunc
HandlePushToken http.HandlerFunc
AppHeartbeat http.HandlerFunc
@ -369,6 +371,7 @@ func (r routes) registerAuthRoutes() {
r.auth("/auth/password/set", "", h.SetPassword)
r.public("/auth/account/quick-create", http.MethodPost, h.QuickCreateAccount)
r.public("/auth/third-party/login", "", h.LoginThirdParty)
r.public("/auth/third-party/register", http.MethodPost, h.RegisterThirdParty)
r.public("/auth/token/refresh", "", h.RefreshToken)
r.public("/auth/logout", "", h.Logout)
}
@ -396,6 +399,7 @@ func (r routes) registerAppRoutes() {
r.public("/tencent-rtc/callback", "", h.HandleTencentRTCCallback)
r.auth("/files/upload", "", h.UploadFile)
r.auth("/files/avatar/upload", "", h.UploadAvatar)
r.public("/files/registration/avatar/upload", http.MethodPost, h.UploadRegistrationAvatar)
r.public("/media/effects/proxy", http.MethodGet, h.ProxyEffectMedia)
r.auth("/app/heartbeat", http.MethodPost, h.AppHeartbeat)
r.profile("/devices/push-token", "", h.HandlePushToken)

View File

@ -288,6 +288,7 @@ func (f *fakeRoomClient) UnfollowRoom(_ context.Context, req *roomv1.UnfollowRoo
type fakeUserAuthClient struct {
lastLoginThirdParty *userv1.LoginThirdPartyRequest
lastRegisterThird *userv1.RegisterThirdPartyRequest
lastLoginPassword *userv1.LoginPasswordRequest
lastSetPassword *userv1.SetPasswordRequest
lastQuickCreate *userv1.QuickCreateAccountRequest
@ -686,6 +687,31 @@ func (f *fakeUserAuthClient) LoginThirdParty(_ context.Context, req *userv1.Logi
}, nil
}
func (f *fakeUserAuthClient) RegisterThirdParty(_ context.Context, req *userv1.RegisterThirdPartyRequest) (*userv1.AuthResponse, error) {
f.lastRegisterThird = req
f.loginThirdCount++
if f.loginErr != nil {
return nil, f.loginErr
}
return &userv1.AuthResponse{
Token: &userv1.AuthToken{
UserId: 10001,
DisplayUserId: "100001",
SessionId: "sess-register",
AccessToken: "access-register",
RefreshToken: "refresh-register",
ExpiresInSec: 1800,
TokenType: "Bearer",
ProfileCompleted: true,
OnboardingStatus: "completed",
},
IsNewUser: true,
ProfileCompleted: true,
OnboardingStatus: "completed",
}, nil
}
func (f *fakeUserAuthClient) SetPassword(_ context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
f.lastSetPassword = req
return &userv1.SetPasswordResponse{PasswordSet: true}, nil
@ -5460,6 +5486,55 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
}
}
func TestThirdPartyRegisterUsesPublicEnvelopeAndReturnsCompletedToken(t *testing.T) {
userClient := &fakeUserAuthClient{}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"dev-1","username":"hy","avatar":"https://cdn.example/avatar.png","gender":"male","country":"CN","invite_code":"INV-1","device":"iPhone 15","os_version":"iOS 18.1","birth":"2000-01-02","app_version":"1.2.3","build_number":"123","source":"campaign-a","install_channel":"app_store","campaign":"spring_launch","platform":"ios","language":"zh-CN","timezone":"Asia/Shanghai"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/register", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-auth-register")
request.Header.Set("X-Forwarded-For", "203.0.113.10")
request.Header.Set("User-Agent", "hyapp-test/1.0")
request.Header.Set("CF-IPCountry", "sg")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
generatedRequestID := assertGeneratedRequestID(t, recorder, "req-auth-register")
if response.Code != httpkit.CodeOK || response.RequestID != generatedRequestID {
t.Fatalf("unexpected envelope: %+v", response)
}
data, ok := response.Data.(map[string]any)
if !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" || data["access_token"] != "access-register" {
t.Fatalf("register must return completed token data: %+v", response.Data)
}
req := userClient.lastRegisterThird
if req == nil {
t.Fatal("RegisterThirdParty request was not sent")
}
if userClient.lastLoginThirdParty != nil {
t.Fatalf("register endpoint must not call LoginThirdParty: %+v", userClient.lastLoginThirdParty)
}
if req.GetMeta().GetRequestId() != generatedRequestID || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetCountryByIp() != "SG" {
t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta())
}
if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" || req.GetDeviceId() != "dev-1" {
t.Fatalf("provider credential device fields mismatch: %+v", req)
}
if req.GetUsername() != "hy" || req.GetAvatar() == "" || req.GetGender() != "male" || req.GetCountry() != "CN" || req.GetInviteCode() != "INV-1" {
t.Fatalf("profile fields mismatch: %+v", req)
}
if req.GetPlatform() != "ios" || req.GetLanguage() != "zh-CN" || req.GetTimezone() != "Asia/Shanghai" || req.GetInstallChannel() != "app_store" || req.GetCampaign() != "spring_launch" {
t.Fatalf("source fields mismatch: %+v", req)
}
}
func TestAuthClientIPPrefersForwardedPublicIPOverProxyRealIP(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", nil)
request.RemoteAddr = "10.0.0.9:54123"
@ -8566,6 +8641,39 @@ func TestUploadAvatarAllowsIncompleteProfileAndWritesObject(t *testing.T) {
}
}
func TestUploadRegistrationAvatarAllowsPublicPendingAuthUpload(t *testing.T) {
uploader := &fakeObjectUploader{}
handler := NewHandler(&fakeRoomClient{})
handler.SetObjectUploader(uploader)
router := handler.Routes(auth.NewVerifier("secret"))
payload := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, bytes.Repeat([]byte{1}, 32)...)
body, contentType := multipartUploadBody(t, "avatar", "avatar.png", "image/png", payload)
request := httptest.NewRequest(http.MethodPost, "/api/v1/files/registration/avatar/upload", body)
request.Header.Set("Content-Type", contentType)
request.Header.Set("X-Request-ID", "req-upload-register-avatar")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode upload response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected upload envelope: %+v", response)
}
if !strings.HasPrefix(uploader.lastKey, "app/avatars/registration/") || !strings.HasSuffix(uploader.lastKey, ".png") {
t.Fatalf("unexpected registration avatar object key: %q", uploader.lastKey)
}
if data["url"] != "https://media.example.com/"+uploader.lastKey || data["object_key"] != uploader.lastKey || data["content_type"] != "image/png" {
t.Fatalf("unexpected upload data: %+v", data)
}
}
func TestUploadAvatarRejectsSpoofedImageContentType(t *testing.T) {
uploader := &fakeObjectUploader{}
handler := NewHandler(&fakeRoomClient{})

View File

@ -169,6 +169,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
SetPassword: h.setPassword,
QuickCreateAccount: h.quickCreateAccount,
LoginThirdParty: h.loginThirdParty,
RegisterThirdParty: h.registerThirdParty,
RefreshToken: h.refreshToken,
Logout: h.logout,
},
@ -194,6 +195,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
HandleTencentRTCCallback: callbackAPI.HandleTencentRTCCallback,
UploadFile: appAPI.UploadFile,
UploadAvatar: appAPI.UploadAvatar,
UploadRegistrationAvatar: appAPI.UploadRegistrationAvatar,
ProxyEffectMedia: appAPI.ProxyEffectMedia,
HandlePushToken: appAPI.HandlePushToken,
AppHeartbeat: appAPI.AppHeartbeat,

View File

@ -68,6 +68,8 @@ type AuthRepository interface {
ListDisplayUserIDsByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) ([]string, error)
// CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。
CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand, maxAccountsPerDevice int32) error
// CompleteThirdPartyRegistration 原子补齐旧三方 pending 用户资料并创建首个注册完成 session。
CompleteThirdPartyRegistration(ctx context.Context, command userdomain.CompleteOnboardingCommand, session authdomain.Session) (userdomain.User, error)
// GetRegisterRiskConfig 读取当前 App 的注册风控配置。
GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, error)
// UpsertRegisterRiskConfig 保存当前 App 的注册风控配置。

View File

@ -1011,6 +1011,80 @@ func TestThirdPartyLoginOrRegister(t *testing.T) {
}
}
func TestRegisterThirdPartyCreatesCompletedUserAndSession(t *testing.T) {
// 新注册接口必须一次性完成资料、三方绑定、session 和注册事实,客户端不再拿 profile_required token。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
country := seedCountry(t, repository, "SG")
region := mustResolveRegionByCountry(t, repository, "SG")
now := time.UnixMilli(1000)
imImporter := &fakeIMAccountImporter{}
svc := newAuthService(repository, &now, []int64{900030}, []string{"100030"}, authservice.WithIMAccountImporter(imImporter))
token, isNewUser, err := svc.RegisterThirdParty(ctx, "wechat", "openid-register-complete", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third-register", ClientIP: "203.0.113.10", UserAgent: "hyapp-test/1.0"})
if err != nil {
t.Fatalf("RegisterThirdParty failed: %v", err)
}
if !isNewUser || token.UserID != 900030 || token.ProfileCompleted != true || token.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) || token.AccessToken == "" || token.RefreshToken == "" {
t.Fatalf("register must return completed token for new user: token=%+v isNew=%v", token, isNewUser)
}
user, err := repository.GetUser(ctx, token.UserID)
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if !user.ProfileCompleted || user.ProfileCompletedAtMs != now.UnixMilli() || user.OnboardingStatus != userdomain.OnboardingStatusCompleted {
t.Fatalf("user must be completed immediately: %+v", user)
}
if user.Country != country.CountryCode || user.RegionID != region.RegionID || user.Username != "hy" || user.Avatar != "https://cdn.example/avatar.png" {
t.Fatalf("registration profile mismatch: %+v", user)
}
if imImporter.userID != token.UserID || imImporter.nickname != "hy" || imImporter.faceURL != "https://cdn.example/avatar.png" {
t.Fatalf("completed registration must import final IM profile: %+v", imImporter)
}
assertUserRegisteredOutboxCount(t, repository, token.UserID, 1)
assertUserRegisteredOutboxFact(t, repository, token.UserID, country.CountryID, region.RegionID, now.UnixMilli())
}
func TestRegisterThirdPartyCompletesExistingPendingUser(t *testing.T) {
// 兼容旧流程:同一 provider subject 已有 pending 用户时,新接口补齐资料并签新 session不创建第二个账号。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
country := seedCountry(t, repository, "SG")
region := mustResolveRegionByCountry(t, repository, "SG")
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900031, 900032}, []string{"100031", "100032"})
pendingToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-pending-register", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-third-pending"})
if err != nil {
t.Fatalf("LoginThirdParty pending seed failed: %v", err)
}
if !isNewUser || pendingToken.ProfileCompleted {
t.Fatalf("seed login must create pending user: token=%+v isNew=%v", pendingToken, isNewUser)
}
assertUserRegisteredOutboxCount(t, repository, pendingToken.UserID, 0)
now = time.UnixMilli(2000)
register := thirdPartyRegistration("ios")
register.Username = "finished"
register.DeviceID = "dev-ios-finished"
completedToken, isNewUser, err := svc.RegisterThirdParty(ctx, "wechat", "openid-pending-register", register, authservice.Meta{RequestID: "req-third-complete"})
if err != nil {
t.Fatalf("RegisterThirdParty completion failed: %v", err)
}
if isNewUser || completedToken.UserID != pendingToken.UserID || !completedToken.ProfileCompleted || completedToken.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
t.Fatalf("pending subject must complete existing user: token=%+v isNew=%v pending=%+v", completedToken, isNewUser, pendingToken)
}
user, err := repository.GetUser(ctx, pendingToken.UserID)
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if user.Username != "finished" || user.Country != country.CountryCode || user.RegionID != region.RegionID || user.ProfileCompletedAtMs != now.UnixMilli() {
t.Fatalf("pending user completion mismatch: %+v", user)
}
assertUserRegisteredOutboxCount(t, repository, pendingToken.UserID, 1)
assertUserRegisteredOutboxFact(t, repository, pendingToken.UserID, country.CountryID, region.RegionID, now.UnixMilli())
}
func TestThirdPartyRegisterRejectsReusedDeviceID(t *testing.T) {
// 设备号只限制新账号注册;同一个 provider subject 的再次登录仍然可以创建新 session。
ctx := context.Background()

View File

@ -95,6 +95,69 @@ func (s *Service) LoginThirdParty(ctx context.Context, provider string, credenti
return s.createThirdPartyUser(ctx, provider, profile, registration, meta, maxAccountsPerDevice)
}
// RegisterThirdParty 校验三方凭证并一次性完成 App 自然注册。
// 该入口服务 Flutter 资料页完成动作:资料未齐不建用户,不向客户端暴露 profile_required session。
func (s *Service) RegisterThirdParty(ctx context.Context, provider string, credential string, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
meta.AppCode = appcode.Normalize(meta.AppCode)
registration.AppCode = meta.AppCode
provider = strings.ToLower(strings.TrimSpace(provider))
credential = strings.TrimSpace(credential)
registration = normalizeThirdPartyRegistration(registration, meta)
meta.Platform = registration.Platform
meta.Language = registration.Language
meta.Timezone = registration.Timezone
if provider == "" || credential == "" {
// provider/credential 缺失时不能进入 verifier避免把客户端参数错误伪装成三方认证失败。
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "provider and credential are required")
}
if registration.DeviceID == "" || registration.Platform == "" {
// 注册完成后会立即创建 refresh session缺设备或平台会让 session 和注册来源失去可追踪性。
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "device_id and platform are required")
}
if err := validateThirdPartyRegistrationRequiredForCreate(registration); err != nil {
// 新注册入口承诺不落 pending 用户,因此 username/avatar/gender/country 必须在事务前全部具备。
return authdomain.Token{}, false, err
}
if err := validateThirdPartyRegistration(registration); err != nil {
return authdomain.Token{}, false, err
}
if s.authRepository == nil {
return authdomain.Token{}, false, xerr.New(xerr.Unavailable, "auth repository is not configured")
}
if !s.TokenConfigValid() {
// 资料和三方绑定一旦提交就必须返回可用 token签名配置不完整时提前失败避免写出不可登录用户。
return authdomain.Token{}, false, xerr.New(xerr.Unavailable, "token service is not configured")
}
profile, err := s.thirdPartyVerifier.Verify(ctx, provider, credential)
if err != nil {
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.AuthFailed))
return authdomain.Token{}, false, authFailed()
}
identity, err := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
if err == nil {
// 兼容旧 Flutter 流程已创建的 profile_required 占位用户;同一 provider subject 不能新建第二个账号。
return s.registerExistingThirdParty(ctx, identity, registration, meta)
}
if !xerr.IsCode(err, xerr.NotFound) {
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_register_lookup_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
maxAccountsPerDevice, err := s.ensureDeviceCanRegisterThirdParty(ctx, registration, meta, provider)
if err != nil {
return authdomain.Token{}, false, err
}
registration, err = s.resolveRegistrationCountry(ctx, registration)
if err != nil {
return authdomain.Token{}, false, err
}
return s.createRegisteredThirdPartyUser(ctx, provider, profile, registration, meta, maxAccountsPerDevice)
}
func (s *Service) ensureDeviceCanRegisterThirdParty(ctx context.Context, registration authdomain.ThirdPartyRegistration, meta Meta, provider string) (int32, error) {
// 设备号限制只约束“创建新账号”路径;已绑定三方身份登录不经过这里,避免把正常跨设备登录误拦。
config, err := s.GetRegisterRiskConfig(ctx)
@ -149,6 +212,59 @@ func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdoma
return token, false, nil
}
func (s *Service) registerExistingThirdParty(ctx context.Context, identity authdomain.ThirdPartyIdentity, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
// 已完成资料的同一三方身份按登录处理,保持客户端重试和多端登录的幂等语义。
user, err := s.freshUser(ctx, identity.UserID, s.now().UnixMilli(), meta.RequestID)
if err != nil {
s.audit(ctx, meta, identity.UserID, loginThird, identity.Provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
if !user.CanLogin() {
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultFailed, string(xerr.UserDisabled))
return authdomain.Token{}, false, xerr.New(xerr.UserDisabled, "user is disabled")
}
if user.ProfileCompleted {
return s.loginExistingThirdParty(ctx, identity, registration, meta)
}
registration, err = s.resolveRegistrationCountry(ctx, registration)
if err != nil {
s.audit(ctx, meta, identity.UserID, loginThird, identity.Provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
session, refreshToken, err := s.newSession(meta.AppCode, identity.UserID, registration.DeviceID)
if err != nil {
return authdomain.Token{}, false, err
}
updated, err := s.authRepository.CompleteThirdPartyRegistration(ctx, userdomain.CompleteOnboardingCommand{
AppCode: meta.AppCode,
UserID: identity.UserID,
Username: registration.Username,
Avatar: registration.Avatar,
Gender: registration.Gender,
Country: registration.Country,
RegionID: registration.RegionID,
InviteCode: registration.InviteCode,
RequestID: meta.RequestID,
CompletedAtMs: s.now().UnixMilli(),
}, session)
if err != nil {
s.audit(ctx, meta, identity.UserID, loginThird, identity.Provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "")
token, err := s.issueToken(updated, session.SessionID, refreshToken)
if err != nil {
return authdomain.Token{}, false, err
}
s.importIMAccountAfterCommit(ctx, updated, meta)
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(identity.Provider), registration.Platform, registration.Language, registration.Timezone)
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, updated.UserID, meta)
s.issueRegistrationLevelBadgesAfterCommit(ctx, registration.AppCode, updated.UserID, meta)
return token, false, nil
}
func (s *Service) createThirdPartyUser(ctx context.Context, provider string, profile authdomain.ThirdPartyProfile, registration authdomain.ThirdPartyRegistration, meta Meta, maxAccountsPerDevice int32) (authdomain.Token, bool, error) {
var lastErr error
for attempt := 0; attempt < s.allocateMaxAttempts; attempt++ {
@ -231,6 +347,86 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
return authdomain.Token{}, false, xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed")
}
func (s *Service) createRegisteredThirdPartyUser(ctx context.Context, provider string, profile authdomain.ThirdPartyProfile, registration authdomain.ThirdPartyRegistration, meta Meta, maxAccountsPerDevice int32) (authdomain.Token, bool, error) {
var lastErr error
for attempt := 0; attempt < s.allocateMaxAttempts; attempt++ {
// 注册专用入口直接创建 completed 用户所有公开资料、绑定、session 和注册事实必须同事务提交。
user, displayIdentity := s.newUserWithIdentity(attempt)
user = applyThirdPartyRegistration(user, registration)
user.ProfileCompleted = true
user.ProfileCompletedAtMs = user.CreatedAtMs
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
displayUserID, err := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode)
if err != nil {
return authdomain.Token{}, false, err
}
user.DefaultDisplayUserID = displayUserID
user.CurrentDisplayUserID = displayUserID
displayIdentity.DisplayUserID = displayUserID
displayIdentity.DefaultDisplayUserID = displayUserID
displayIdentity.AppCode = registration.AppCode
if !userdomain.ValidDisplayUserID(displayIdentity.DisplayUserID) {
// 非法候选不写库,继续拿下一组 user_id/display_user_id保持默认短号约束一致。
continue
}
session, refreshToken, err := s.newSession(registration.AppCode, user.UserID, registration.DeviceID)
if err != nil {
return authdomain.Token{}, false, err
}
thirdParty := authdomain.ThirdPartyIdentity{
AppCode: registration.AppCode,
UserID: user.UserID,
Provider: provider,
ProviderSubject: profile.ProviderSubject,
ProviderUnionID: profile.ProviderUnionID,
CreatedAtMs: user.CreatedAtMs,
UpdatedAtMs: user.UpdatedAtMs,
}
inviteBind := invitedomain.BindCommand{
AppCode: registration.AppCode,
InvitedUserID: user.UserID,
InvitedRegionID: registration.RegionID,
InviteCode: registration.InviteCode,
Source: "third_party_register",
RequestID: meta.RequestID,
BoundAtMs: user.CreatedAtMs,
}
err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session, inviteBind, maxAccountsPerDevice)
if err == nil {
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken)
if tokenErr != nil {
return authdomain.Token{}, false, tokenErr
}
s.importIMAccountAfterCommit(ctx, user, meta)
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(provider), registration.Platform, registration.Language, registration.Timezone)
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta)
s.issueRegistrationLevelBadgesAfterCommit(ctx, registration.AppCode, user.UserID, meta)
return token, true, nil
}
if xerr.IsCode(err, xerr.DisplayUserIDExists) {
// 默认短号冲突可重试;同一次 Firebase subject 不会因为短号冲突暴露成注册失败。
lastErr = err
continue
}
if xerr.IsCode(err, xerr.Conflict) {
identity, findErr := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
if findErr == nil {
return s.registerExistingThirdParty(ctx, identity, registration, meta)
}
}
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_complete_register_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
return authdomain.Token{}, false, err
}
if lastErr != nil {
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.DisplayUserIDAllocateFailed))
}
return authdomain.Token{}, false, xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed")
}
func (s *Service) issueRegistrationRewardAfterCommit(ctx context.Context, appCode string, userID int64, meta Meta) {
if s.registrationRewardIssuer == nil {
return

View File

@ -127,7 +127,112 @@ func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.U
// session 插入失败会回滚用户和三方绑定。
return err
}
if err := userstorage.InsertUserRegisteredOutbox(ctx, tx, user); err != nil {
// 完整注册入口会传入 profile_completed=true旧三方登录仍是 pending 用户,这里会按用户状态自动跳过。
return err
}
return tx.Commit()
}
// CompleteThirdPartyRegistration 原子补齐旧三方 pending 用户资料并写入可续期 session。
// 它只服务 provider subject 已绑定但尚未 completed 的兼容路径,避免 Flutter 完成资料时再创建第二个用户。
func (r *Repository) CompleteThirdPartyRegistration(ctx context.Context, command userdomain.CompleteOnboardingCommand, session authdomain.Session) (userdomain.User, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userdomain.User{}, err
}
defer tx.Rollback()
user, err := userstorage.QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
if err == sql.ErrNoRows {
return userdomain.User{}, xerr.New(xerr.NotFound, "user not found")
}
if err != nil {
return userdomain.User{}, err
}
if !user.CanLogin() {
// pending 用户可能已被后台禁用;注册完成入口不能绕过用户状态限制创建新 session。
return userdomain.User{}, xerr.New(xerr.UserDisabled, "user is disabled")
}
if user.ProfileCompleted {
// 幂等兼容:已完成用户只补 session不重写用户资料避免重复提交覆盖用户后续修改。
if err := insertSession(ctx, tx, session); err != nil {
return userdomain.User{}, err
}
if err := tx.Commit(); err != nil {
return userdomain.User{}, err
}
return userstorage.New(r.db).GetUser(ctx, command.UserID)
}
inviteSnapshot := command.InviteCode
if command.InviteCode != "" {
binding, err := invitestorage.BindRelation(ctx, tx, invitedomain.BindCommand{
AppCode: command.AppCode,
InvitedUserID: command.UserID,
InvitedRegionID: command.RegionID,
InviteCode: command.InviteCode,
Source: "third_party_register",
RequestID: command.RequestID,
BoundAtMs: command.CompletedAtMs,
})
if err != nil {
return userdomain.User{}, err
}
if binding.InviteCode != "" {
inviteSnapshot = binding.InviteCode
user.InviteBinding = binding
}
}
_, err = tx.ExecContext(ctx, `
UPDATE users
SET username = ?,
avatar = ?,
gender = ?,
country = ?,
region_id = ?,
invite_code = CASE WHEN ? = '' THEN invite_code ELSE ? END,
profile_completed = ?,
profile_completed_at_ms = ?,
onboarding_status = ?,
updated_at_ms = ?
WHERE user_id = ?
AND app_code = ?
`, command.Username, command.Avatar, command.Gender, command.Country, shared.NullableRegionID(command.RegionID), inviteSnapshot, inviteSnapshot, true, command.CompletedAtMs, string(userdomain.OnboardingStatusCompleted), command.CompletedAtMs, command.UserID, appcode.Normalize(command.AppCode))
if err != nil {
return userdomain.User{}, err
}
user.Username = command.Username
user.Avatar = command.Avatar
user.Gender = command.Gender
user.Country = command.Country
user.RegionID = command.RegionID
if inviteSnapshot != "" {
user.InviteCode = inviteSnapshot
}
user.ProfileCompleted = true
user.ProfileCompletedAtMs = command.CompletedAtMs
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
user.UpdatedAtMs = command.CompletedAtMs
if err := insertSession(ctx, tx, session); err != nil {
// session 和资料完成必须同事务提交,否则 Flutter 会拿不到可落 AppAuthSession 的 refresh token。
return userdomain.User{}, err
}
if err := userstorage.InsertUserRegisteredOutbox(ctx, tx, user); err != nil {
// UserRegistered 是统计和注册完成事实,必须和首次资料完成一起提交。
return userdomain.User{}, err
}
if err := tx.Commit(); err != nil {
return userdomain.User{}, err
}
updated, err := userstorage.New(r.db).GetUser(ctx, command.UserID)
if err != nil {
return userdomain.User{}, err
}
updated.InviteBinding = user.InviteBinding
return updated, nil
}
// RecordLoginAudit 写登录审计;调用方不会让审计失败影响主链路。

View File

@ -369,6 +369,11 @@ func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.U
return r.Repository.AuthRepository().CreateThirdPartyUser(ctx, user, identity, thirdParty, session, invite, maxAccountsPerDevice)
}
// CompleteThirdPartyRegistration 让测试 wrapper 继续满足注册完成兼容接口。
func (r *Repository) CompleteThirdPartyRegistration(ctx context.Context, command userdomain.CompleteOnboardingCommand, session authdomain.Session) (userdomain.User, error) {
return r.Repository.AuthRepository().CompleteThirdPartyRegistration(ctx, command, session)
}
// GetRegisterRiskConfig 让测试 wrapper 继续满足认证接口。
func (r *Repository) GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, error) {
return r.Repository.AuthRepository().GetRegisterRiskConfig(ctx, appCode)

View File

@ -139,6 +139,38 @@ func (s *Server) LoginThirdParty(ctx context.Context, req *userv1.LoginThirdPart
return &userv1.AuthResponse{Token: toProtoToken(token), IsNewUser: isNewUser, ProfileCompleted: token.ProfileCompleted, OnboardingStatus: token.OnboardingStatus}, nil
}
// RegisterThirdParty 在资料页完成时一次性校验 provider credential、创建或补齐用户并签发完整 session。
func (s *Server) RegisterThirdParty(ctx context.Context, req *userv1.RegisterThirdPartyRequest) (*userv1.AuthResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
registration := authdomain.ThirdPartyRegistration{
Username: req.GetUsername(),
Gender: req.GetGender(),
Country: req.GetCountry(),
InviteCode: req.GetInviteCode(),
IP: req.GetMeta().GetClientIp(),
DeviceID: req.GetDeviceId(),
Device: req.GetDevice(),
OSVersion: req.GetOsVersion(),
Avatar: req.GetAvatar(),
BirthDate: req.GetBirth(),
AppVersion: req.GetAppVersion(),
BuildNumber: req.GetBuildNumber(),
Source: req.GetSource(),
InstallChannel: req.GetInstallChannel(),
Campaign: req.GetCampaign(),
Platform: req.GetPlatform(),
Language: req.GetLanguage(),
Timezone: req.GetTimezone(),
}
// 注册专用 RPC 不返回 profile_required tokenservice 层会在写库前验证资料完整度。
token, isNewUser, err := s.authSvc.RegisterThirdParty(ctx, req.GetProvider(), req.GetCredential(), registration, authMeta(req.GetMeta()))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.AuthResponse{Token: toProtoToken(token), IsNewUser: isNewUser, ProfileCompleted: token.ProfileCompleted, OnboardingStatus: token.OnboardingStatus}, nil
}
// SetPassword 为已登录用户首次写入密码身份。
func (s *Server) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())