新注册流程
This commit is contained in:
parent
210bef6955
commit
5890e3321a
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
249
docs/flutter对接/Google三方注册Flutter对接.md
Normal file
249
docs/flutter对接/Google三方注册Flutter对接.md
Normal 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 导入接口。
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 的注册风控配置。
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 写登录审计;调用方不会让审计失败影响主链路。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 token;service 层会在写库前验证资料完整度。
|
||||
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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user