新注册流程

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; 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 // AuthResponse
message AuthResponse { message AuthResponse {
AuthToken token = 1; AuthToken token = 1;
@ -182,6 +207,7 @@ message AppHeartbeatResponse {
service AuthService { service AuthService {
rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse); rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse);
rpc LoginThirdParty(LoginThirdPartyRequest) returns (AuthResponse); rpc LoginThirdParty(LoginThirdPartyRequest) returns (AuthResponse);
rpc RegisterThirdParty(RegisterThirdPartyRequest) returns (AuthResponse);
rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse); rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse);
rpc QuickCreateAccount(QuickCreateAccountRequest) returns (QuickCreateAccountResponse); rpc QuickCreateAccount(QuickCreateAccountRequest) returns (QuickCreateAccountResponse);
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse); rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);

View File

@ -21,6 +21,7 @@ const _ = grpc.SupportPackageIsVersion9
const ( const (
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword" AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty" 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_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount" AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken" AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
@ -40,6 +41,7 @@ const (
type AuthServiceClient interface { type AuthServiceClient interface {
LoginPassword(ctx context.Context, in *LoginPasswordRequest, opts ...grpc.CallOption) (*AuthResponse, error) LoginPassword(ctx context.Context, in *LoginPasswordRequest, opts ...grpc.CallOption) (*AuthResponse, error)
LoginThirdParty(ctx context.Context, in *LoginThirdPartyRequest, 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) SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error)
QuickCreateAccount(ctx context.Context, in *QuickCreateAccountRequest, opts ...grpc.CallOption) (*QuickCreateAccountResponse, error) QuickCreateAccount(ctx context.Context, in *QuickCreateAccountRequest, opts ...grpc.CallOption) (*QuickCreateAccountResponse, error)
RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, 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 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) { func (c *authServiceClient) SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetPasswordResponse) out := new(SetPasswordResponse)
@ -177,6 +189,7 @@ func (c *authServiceClient) AppHeartbeat(ctx context.Context, in *AppHeartbeatRe
type AuthServiceServer interface { type AuthServiceServer interface {
LoginPassword(context.Context, *LoginPasswordRequest) (*AuthResponse, error) LoginPassword(context.Context, *LoginPasswordRequest) (*AuthResponse, error)
LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error) LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error)
RegisterThirdParty(context.Context, *RegisterThirdPartyRequest) (*AuthResponse, error)
SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error) SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error)
QuickCreateAccount(context.Context, *QuickCreateAccountRequest) (*QuickCreateAccountResponse, error) QuickCreateAccount(context.Context, *QuickCreateAccountRequest) (*QuickCreateAccountResponse, error)
RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, 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) { func (UnimplementedAuthServiceServer) LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method LoginThirdParty not implemented") 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) { func (UnimplementedAuthServiceServer) SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetPassword not implemented") 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) 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) { func _AuthService_SetPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetPasswordRequest) in := new(SetPasswordRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -463,6 +497,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "LoginThirdParty", MethodName: "LoginThirdParty",
Handler: _AuthService_LoginThirdParty_Handler, Handler: _AuthService_LoginThirdParty_Handler,
}, },
{
MethodName: "RegisterThirdParty",
Handler: _AuthService_RegisterThirdParty_Handler,
},
{ {
MethodName: "SetPassword", MethodName: "SetPassword",
Handler: _AuthService_SetPassword_Handler, 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 { type UserAuthClient interface {
LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error)
LoginThirdParty(ctx context.Context, req *userv1.LoginThirdPartyRequest) (*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) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error)
QuickCreateAccount(ctx context.Context, req *userv1.QuickCreateAccountRequest) (*userv1.QuickCreateAccountResponse, error) QuickCreateAccount(ctx context.Context, req *userv1.QuickCreateAccountRequest) (*userv1.QuickCreateAccountResponse, error)
RefreshToken(ctx context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, 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) 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) { func (c *grpcUserAuthClient) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
return c.client.SetPassword(ctx, req) 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) 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) { func (h *Handler) ProxyEffectMedia(writer http.ResponseWriter, request *http.Request) {
h.proxyEffectMedia(writer, request) h.proxyEffectMedia(writer, request)
} }

View File

@ -58,15 +58,20 @@ type uploadResponse struct {
// uploadAvatar 上传用户头像文件,只接受常见静态图片格式。 // uploadAvatar 上传用户头像文件,只接受常见静态图片格式。
func (h *Handler) uploadAvatar(writer http.ResponseWriter, request *http.Request) { 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 通用文件,不绑定用户资料更新语义。 // uploadFile 上传 App 通用文件,不绑定用户资料更新语义。
func (h *Handler) uploadFile(writer http.ResponseWriter, request *http.Request) { 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 { if request.Method != http.MethodPost {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
@ -106,7 +111,7 @@ func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request
return 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) objectURL, err := h.objectUploader.PutObject(request.Context(), objectKey, io.MultiReader(bytes.NewReader(head), file), header.Size, contentType)
if err != nil { if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") 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") date := now.UTC().Format("20060102")
extension := normalizedUploadExtension(filename, contentType) extension := normalizedUploadExtension(filename, contentType)
if extension == "" && policy.RequireImage { if extension == "" && policy.RequireImage {
@ -188,7 +193,12 @@ func buildUploadObjectKey(policy uploadPolicy, userID int64, filename string, co
} }
idPrefix := strings.TrimSuffix(policy.Kind, "s") 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 { 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)) 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 处理已登录用户首次设置密码。 // setPassword 处理已登录用户首次设置密码。
func (h *Handler) setPassword(writer http.ResponseWriter, request *http.Request) { func (h *Handler) setPassword(writer http.ResponseWriter, request *http.Request) {
var body struct { var body struct {

View File

@ -33,6 +33,7 @@ type AuthHandlers struct {
SetPassword http.HandlerFunc SetPassword http.HandlerFunc
QuickCreateAccount http.HandlerFunc QuickCreateAccount http.HandlerFunc
LoginThirdParty http.HandlerFunc LoginThirdParty http.HandlerFunc
RegisterThirdParty http.HandlerFunc
RefreshToken http.HandlerFunc RefreshToken http.HandlerFunc
Logout http.HandlerFunc Logout http.HandlerFunc
} }
@ -59,6 +60,7 @@ type AppHandlers struct {
HandleTencentRTCCallback http.HandlerFunc HandleTencentRTCCallback http.HandlerFunc
UploadFile http.HandlerFunc UploadFile http.HandlerFunc
UploadAvatar http.HandlerFunc UploadAvatar http.HandlerFunc
UploadRegistrationAvatar http.HandlerFunc
ProxyEffectMedia http.HandlerFunc ProxyEffectMedia http.HandlerFunc
HandlePushToken http.HandlerFunc HandlePushToken http.HandlerFunc
AppHeartbeat http.HandlerFunc AppHeartbeat http.HandlerFunc
@ -369,6 +371,7 @@ func (r routes) registerAuthRoutes() {
r.auth("/auth/password/set", "", h.SetPassword) r.auth("/auth/password/set", "", h.SetPassword)
r.public("/auth/account/quick-create", http.MethodPost, h.QuickCreateAccount) r.public("/auth/account/quick-create", http.MethodPost, h.QuickCreateAccount)
r.public("/auth/third-party/login", "", h.LoginThirdParty) 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/token/refresh", "", h.RefreshToken)
r.public("/auth/logout", "", h.Logout) r.public("/auth/logout", "", h.Logout)
} }
@ -396,6 +399,7 @@ func (r routes) registerAppRoutes() {
r.public("/tencent-rtc/callback", "", h.HandleTencentRTCCallback) r.public("/tencent-rtc/callback", "", h.HandleTencentRTCCallback)
r.auth("/files/upload", "", h.UploadFile) r.auth("/files/upload", "", h.UploadFile)
r.auth("/files/avatar/upload", "", h.UploadAvatar) 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.public("/media/effects/proxy", http.MethodGet, h.ProxyEffectMedia)
r.auth("/app/heartbeat", http.MethodPost, h.AppHeartbeat) r.auth("/app/heartbeat", http.MethodPost, h.AppHeartbeat)
r.profile("/devices/push-token", "", h.HandlePushToken) 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 { type fakeUserAuthClient struct {
lastLoginThirdParty *userv1.LoginThirdPartyRequest lastLoginThirdParty *userv1.LoginThirdPartyRequest
lastRegisterThird *userv1.RegisterThirdPartyRequest
lastLoginPassword *userv1.LoginPasswordRequest lastLoginPassword *userv1.LoginPasswordRequest
lastSetPassword *userv1.SetPasswordRequest lastSetPassword *userv1.SetPasswordRequest
lastQuickCreate *userv1.QuickCreateAccountRequest lastQuickCreate *userv1.QuickCreateAccountRequest
@ -686,6 +687,31 @@ func (f *fakeUserAuthClient) LoginThirdParty(_ context.Context, req *userv1.Logi
}, nil }, 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) { func (f *fakeUserAuthClient) SetPassword(_ context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
f.lastSetPassword = req f.lastSetPassword = req
return &userv1.SetPasswordResponse{PasswordSet: true}, nil 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) { func TestAuthClientIPPrefersForwardedPublicIPOverProxyRealIP(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", nil) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", nil)
request.RemoteAddr = "10.0.0.9:54123" 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) { func TestUploadAvatarRejectsSpoofedImageContentType(t *testing.T) {
uploader := &fakeObjectUploader{} uploader := &fakeObjectUploader{}
handler := NewHandler(&fakeRoomClient{}) handler := NewHandler(&fakeRoomClient{})

View File

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

View File

@ -68,6 +68,8 @@ type AuthRepository interface {
ListDisplayUserIDsByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) ([]string, error) ListDisplayUserIDsByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) ([]string, error)
// CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。 // CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。
CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand, maxAccountsPerDevice int32) error 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 读取当前 App 的注册风控配置。
GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, error) GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, error)
// UpsertRegisterRiskConfig 保存当前 App 的注册风控配置。 // 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) { func TestThirdPartyRegisterRejectsReusedDeviceID(t *testing.T) {
// 设备号只限制新账号注册;同一个 provider subject 的再次登录仍然可以创建新 session。 // 设备号只限制新账号注册;同一个 provider subject 的再次登录仍然可以创建新 session。
ctx := context.Background() 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) 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) { func (s *Service) ensureDeviceCanRegisterThirdParty(ctx context.Context, registration authdomain.ThirdPartyRegistration, meta Meta, provider string) (int32, error) {
// 设备号限制只约束“创建新账号”路径;已绑定三方身份登录不经过这里,避免把正常跨设备登录误拦。 // 设备号限制只约束“创建新账号”路径;已绑定三方身份登录不经过这里,避免把正常跨设备登录误拦。
config, err := s.GetRegisterRiskConfig(ctx) config, err := s.GetRegisterRiskConfig(ctx)
@ -149,6 +212,59 @@ func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdoma
return token, false, nil 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) { 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 var lastErr error
for attempt := 0; attempt < s.allocateMaxAttempts; attempt++ { 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") 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) { func (s *Service) issueRegistrationRewardAfterCommit(ctx context.Context, appCode string, userID int64, meta Meta) {
if s.registrationRewardIssuer == nil { if s.registrationRewardIssuer == nil {
return return

View File

@ -127,7 +127,112 @@ func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.U
// session 插入失败会回滚用户和三方绑定。 // session 插入失败会回滚用户和三方绑定。
return err return err
} }
if err := userstorage.InsertUserRegisteredOutbox(ctx, tx, user); err != nil {
// 完整注册入口会传入 profile_completed=true旧三方登录仍是 pending 用户,这里会按用户状态自动跳过。
return err
}
return tx.Commit() 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 写登录审计;调用方不会让审计失败影响主链路。 // 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) 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 继续满足认证接口。 // GetRegisterRiskConfig 让测试 wrapper 继续满足认证接口。
func (r *Repository) GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, error) { func (r *Repository) GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, error) {
return r.Repository.AuthRepository().GetRegisterRiskConfig(ctx, appCode) 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 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 为已登录用户首次写入密码身份。 // SetPassword 为已登录用户首次写入密码身份。
func (s *Server) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) { func (s *Server) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta()) ctx = contextWithApp(ctx, req.GetMeta())