红包/任务 需求
This commit is contained in:
parent
15760a55c2
commit
544aee1860
File diff suppressed because it is too large
Load Diff
@ -79,6 +79,45 @@ message User {
|
||||
string phone_country_code = 25;
|
||||
bool country_enabled = 26;
|
||||
string app_code = 27;
|
||||
InviteOverview invite = 28;
|
||||
}
|
||||
|
||||
// InviteOverview 是我的页可直接展示的邀请码和邀请计数 read model。
|
||||
message InviteOverview {
|
||||
string my_invite_code = 1;
|
||||
bool invite_enabled = 2;
|
||||
int64 invite_count = 3;
|
||||
int64 valid_invite_count = 4;
|
||||
int64 valid_invite_threshold_coin = 5;
|
||||
}
|
||||
|
||||
// InviteBinding 表示注册完成请求本次是否新绑定邀请关系。
|
||||
message InviteBinding {
|
||||
bool bound = 1;
|
||||
string invite_code = 2;
|
||||
int64 inviter_user_id = 3;
|
||||
}
|
||||
|
||||
// UserMicLifetimeStats 是用户维度的麦上累计基础指标,不区分主播/公会身份。
|
||||
message UserMicLifetimeStats {
|
||||
int64 user_id = 1;
|
||||
int64 seat_occupied_ms = 2;
|
||||
int64 mic_online_ms = 3;
|
||||
int64 session_count = 4;
|
||||
int64 first_mic_at_ms = 5;
|
||||
int64 last_mic_at_ms = 6;
|
||||
int64 updated_at_ms = 7;
|
||||
string app_code = 8;
|
||||
}
|
||||
|
||||
// GetUserMicLifetimeStatsRequest 查询单个用户全部历史麦位时长。
|
||||
message GetUserMicLifetimeStatsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
}
|
||||
|
||||
message GetUserMicLifetimeStatsResponse {
|
||||
UserMicLifetimeStats stats = 1;
|
||||
}
|
||||
|
||||
// AuthToken 是登录、刷新和注册完成后返回给 gateway 的访问令牌投影。
|
||||
@ -173,6 +212,7 @@ message CompleteOnboardingRequest {
|
||||
string avatar = 4;
|
||||
string country = 5;
|
||||
string gender = 6;
|
||||
string invite_code = 7;
|
||||
}
|
||||
|
||||
// CompleteOnboardingResponse 返回固化后的最小注册状态。
|
||||
@ -182,6 +222,7 @@ message CompleteOnboardingResponse {
|
||||
int64 profile_completed_at_ms = 3;
|
||||
string onboarding_status = 4;
|
||||
AuthToken token = 5;
|
||||
InviteBinding invite = 6;
|
||||
}
|
||||
|
||||
// BindPushTokenRequest 绑定当前登录用户的系统推送 token。
|
||||
@ -396,6 +437,7 @@ service UserService {
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
|
||||
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
|
||||
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
|
||||
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
|
||||
rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse);
|
||||
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
|
||||
|
||||
@ -19,12 +19,13 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
|
||||
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
|
||||
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
|
||||
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
|
||||
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
|
||||
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
|
||||
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
|
||||
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
|
||||
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
|
||||
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
|
||||
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
|
||||
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
|
||||
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
|
||||
)
|
||||
|
||||
// UserServiceClient is the client API for UserService service.
|
||||
@ -36,6 +37,7 @@ type UserServiceClient interface {
|
||||
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*GetUserResponse, error)
|
||||
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
|
||||
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
|
||||
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
|
||||
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
|
||||
ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
|
||||
CompleteOnboarding(ctx context.Context, in *CompleteOnboardingRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error)
|
||||
@ -79,6 +81,16 @@ func (c *userServiceClient) ListUserIDs(ctx context.Context, in *ListUserIDsRequ
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetUserMicLifetimeStatsResponse)
|
||||
err := c.cc.Invoke(ctx, UserService_GetUserMicLifetimeStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateUserProfileResponse)
|
||||
@ -118,6 +130,7 @@ type UserServiceServer interface {
|
||||
GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error)
|
||||
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
|
||||
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
|
||||
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
|
||||
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
|
||||
ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
|
||||
CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error)
|
||||
@ -140,6 +153,9 @@ func (UnimplementedUserServiceServer) BatchGetUsers(context.Context, *BatchGetUs
|
||||
func (UnimplementedUserServiceServer) ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListUserIDs not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetUserMicLifetimeStats not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserProfile not implemented")
|
||||
}
|
||||
@ -224,6 +240,24 @@ func _UserService_ListUserIDs_Handler(srv interface{}, ctx context.Context, dec
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_GetUserMicLifetimeStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetUserMicLifetimeStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserServiceServer).GetUserMicLifetimeStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserService_GetUserMicLifetimeStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserServiceServer).GetUserMicLifetimeStats(ctx, req.(*GetUserMicLifetimeStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_UpdateUserProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateUserProfileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -297,6 +331,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListUserIDs",
|
||||
Handler: _UserService_ListUserIDs_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetUserMicLifetimeStats",
|
||||
Handler: _UserService_GetUserMicLifetimeStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateUserProfile",
|
||||
Handler: _UserService_UpdateUserProfile_Handler,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# App Entry State Machine
|
||||
|
||||
本文定义用户从打开 App 到进入主界面的完整状态机。它只描述 App 入口流程、接口调用顺序、跳转条件和异常恢复边界;登录注册细节见 [Login And Registration Technical Design](./auth-login-register-technical-design.md),房间重连细节见 [Room Entry Reconnect Development Requirements](./room-entry-reconnect-development-requirements.md)。
|
||||
本文定义用户从打开 App 到进入主界面的完整状态机。它只描述 App 入口流程、接口调用顺序、跳转条件和异常恢复边界;登录注册细节见 [Login And Registration Technical Design](./auth-login-register-technical-design.md),邀请码细节见 [Registration Invite Code Architecture](./registration-invite-code-architecture.md),房间重连细节见 [Room Entry Reconnect Development Requirements](./room-entry-reconnect-development-requirements.md)。
|
||||
|
||||
## Goals
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| 三方登录即注册 | `POST /api/v1/auth/third-party/login` | DONE | 返回 `is_new_user/profile_completed/onboarding_status` |
|
||||
| refresh token | `POST /api/v1/auth/token/refresh` | DONE | 成功后轮换 refresh token |
|
||||
| 资料补全 | `POST /api/v1/users/me/onboarding/complete` | DONE | 成功后返回新的 access token,不轮换 refresh token |
|
||||
| 资料补全 | `POST /api/v1/users/me/onboarding/complete` | DONE | 成功后返回新的 access token,不轮换 refresh token;可选提交 `invite_code` |
|
||||
| 注册国家 | `GET /api/v1/countries` | DONE | 登录前可用 |
|
||||
| 我的页首屏 | `GET /api/v1/users/me/overview` | DONE | 主界面轻聚合 |
|
||||
| 房间列表 | `GET /api/v1/rooms?tab=hot/new` | DONE | 需要 completed profile |
|
||||
@ -201,6 +201,8 @@ GET /api/v1/countries
|
||||
POST /api/v1/users/me/onboarding/complete
|
||||
```
|
||||
|
||||
请求体包含 `username/avatar/gender/country`,可以选填 `invite_code`。如果用户填写邀请码但服务端无法解析到有效邀请人,返回 `INVALID_INVITE_CODE`,客户端停留在资料补全页让用户修改或清空邀请码。
|
||||
|
||||
成功后:
|
||||
|
||||
- 替换响应里的新 access token。
|
||||
@ -321,6 +323,7 @@ DELETE /api/v1/devices/push-token
|
||||
| `SESSION_EXPIRED` | refresh session 过期 | 清 token,登录 |
|
||||
| `SESSION_REVOKED` | refresh session 已吊销 | 清 token,登录 |
|
||||
| `PROFILE_REQUIRED` | 已登录但资料未完成 | 资料补全 |
|
||||
| `INVALID_INVITE_CODE` | 注册页邀请码无效 | 停留资料补全页,提示修改或清空邀请码 |
|
||||
| `USER_DISABLED` | 用户被禁用 | 账号异常页 |
|
||||
| `USER_BANNED` | 用户被封禁 | 账号异常页 |
|
||||
| `UPSTREAM_ERROR` | 上游不可用 | 重试,不清 token |
|
||||
@ -360,6 +363,8 @@ DELETE /api/v1/devices/push-token
|
||||
| --- | --- |
|
||||
| 首次安装无 token | bootstrap 后进入登录页 |
|
||||
| 新用户三方登录 | 创建用户,进入资料补全页 |
|
||||
| 新用户资料补全填写有效邀请码 | 注册完成,邀请关系绑定到邀请码 owner |
|
||||
| 新用户资料补全填写无效邀请码 | 返回 `INVALID_INVITE_CODE`,仍在资料补全页 |
|
||||
| 资料补全成功 | 替换 access token,进入主界面 |
|
||||
| access token 过期 | refresh 成功后重试 overview |
|
||||
| refresh token 过期 | 清 token,进入登录页 |
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
# Login And Registration Technical Design
|
||||
|
||||
本文档定义 v1 登录、三方注册登录、密码设置和 token 生命周期。它只描述当前架构事实和接口边界,不记录实现流水账。
|
||||
本文档定义 v1 登录、三方注册登录、密码设置、邀请码归因和 token 生命周期。它只描述当前架构事实和接口边界,不记录实现流水账。邀请码详细架构见 [Registration Invite Code Architecture](./registration-invite-code-architecture.md)。
|
||||
|
||||
## Scope
|
||||
|
||||
v1 只有一个用户创建入口:
|
||||
|
||||
- 三方身份登录即注册。`provider + provider_subject` 不存在时创建用户、生成 `default_display_user_id`、设置当前 `display_user_id`、绑定三方身份并签发 token。
|
||||
- 注册邀请码是可选归因能力。非空邀请码如果有效,则在注册事务或资料补全事务内绑定邀请人;邀请码关系一旦绑定不可由客户端修改。
|
||||
|
||||
三方登录不拆成“先登录、发现未注册、再注册”两步。客户端调用三方 SDK 拿到 credential 后,只调用 `LoginThirdParty`。服务端根据 `provider + provider_subject` 决定登录还是创建用户,并通过 `is_new_user` 和 `profile_completed` 告诉客户端下一步是否进入资料补全页。
|
||||
|
||||
@ -36,6 +37,7 @@ v1 不支持:
|
||||
| HTTP JSON API | `gateway-service` | 只暴露 `/api/v1/auth/*`,统一返回 envelope |
|
||||
| 用户创建 | `user-service` | 只能由三方身份首次登录触发 |
|
||||
| 注册完成状态 | `user-service` | 创建用户后默认未完成资料,资料补全后显式标记完成 |
|
||||
| 邀请码归因 | `user-service` | 生成用户自己的邀请码,解析注册邀请码并写邀请关系事实 |
|
||||
| 展示短号 | `user-service` | 新用户创建时生成默认短号,后续登录用当前有效短号;临时靓号有效期内会覆盖默认短号 |
|
||||
| 密码身份 | `user-service` | 已登录用户手动设置后才存在 |
|
||||
| 三方身份绑定 | `user-service` | `provider + subject` 唯一绑定用户 |
|
||||
@ -118,9 +120,9 @@ Response `data`:
|
||||
}
|
||||
```
|
||||
|
||||
三方接口不拆注册和登录。新用户不会默认生成密码;注册上下文和来源快照写入 `users`,同时从 App 序列分配 `default_display_user_id`(首号 `163000`),并在同一事务中创建 `user_display_user_ids`、`third_party_identities` 和首个 `auth_sessions`。`birth` 必须是 `yyyy-mm-dd`;`platform` 当前只接受 `android` 或 `ios`;gateway 只接收 `app_version`,不会读取误拼字段。客户端不能提交注册 IP、UA 或 IP 国家,`register_ip` 和 `register_user_agent` 来自 gateway 请求上下文,`country_by_ip` 来自服务端/可信边缘层根据入口 IP 形成的国家快照。`provider_subject` 只允许由 user-service 校验 provider credential 后得到,不能信任客户端直传,也不能把客户端传入的 `credential` 原样当成 subject。三方绑定唯一键是 `app_code + provider + provider_subject`,同一个 Firebase UID 可以在不同 App 下创建不同业务用户。
|
||||
三方接口不拆注册和登录。新用户不会默认生成密码;注册上下文和来源快照写入 `users`,同时从 App 序列分配 `default_display_user_id`(首号 `163000`)、生成用户自己的邀请码,并在同一事务中创建 `user_display_user_ids`、`third_party_identities` 和首个 `auth_sessions`。`birth` 必须是 `yyyy-mm-dd`;`platform` 当前只接受 `android` 或 `ios`;gateway 只接收 `app_version`,不会读取误拼字段。客户端不能提交注册 IP、UA 或 IP 国家,`register_ip` 和 `register_user_agent` 来自 gateway 请求上下文,`country_by_ip` 来自服务端/可信边缘层根据入口 IP 形成的国家快照。`provider_subject` 只允许由 user-service 校验 provider credential 后得到,不能信任客户端直传,也不能把客户端传入的 `credential` 原样当成 subject。三方绑定唯一键是 `app_code + provider + provider_subject`,同一个 Firebase UID 可以在不同 App 下创建不同业务用户。
|
||||
|
||||
`LoginThirdParty` 中的 `username/avatar/gender/country` 只作为 provider 或客户端当次传入的注册资料快照,不代表注册完成。App 注册页的权威提交点是 `CompleteOnboarding(username, avatar, gender, country)`;只有该接口成功后才设置 `profile_completed=true`。
|
||||
`LoginThirdParty` 中的 `username/avatar/gender/country` 只作为 provider 或客户端当次传入的注册资料快照,不代表注册完成。App 注册页的权威提交点是 `CompleteOnboarding(username, avatar, gender, country, optional invite_code)`;只有该接口成功后才设置 `profile_completed=true`。
|
||||
|
||||
注册链路分成两类数据,不能混用:
|
||||
|
||||
@ -128,9 +130,12 @@ Response `data`:
|
||||
| --- | --- | --- | --- |
|
||||
| 注册上下文 | `POST /api/v1/auth/third-party/login` | 客户端自动上报或 gateway 观察 | `device_id`、`platform`、`device`、`os_version`、`app_version`、`build_number`、`language`、`timezone`、`source`、`install_channel`、`campaign`、`register_ip`、`register_user_agent`、`country_by_ip` |
|
||||
| 注册页用户资料 | `POST /api/v1/users/me/onboarding/complete` | 用户在注册页主动填写或选择 | `username`、`avatar`、`gender`、`country` |
|
||||
| 注册邀请归因 | `POST /api/v1/auth/third-party/login` 或 `POST /api/v1/users/me/onboarding/complete` | 用户选填的邀请码 | `invite_code` |
|
||||
|
||||
`device_id` 和 `platform` 是注册创建用户时必须登记的客户端上下文,不是注册页让用户填写的内容,也不进入 `CompleteOnboarding` request body。注册页只负责让用户完成可展示、可分区的最小资料。
|
||||
|
||||
`invite_code` 推荐在资料补全页提交。为了兼容客户端在三方登录前已经拿到邀请码的场景,`LoginThirdParty` 也保留可选 `invite_code`。已有用户再次登录时提交的邀请码不修改历史邀请关系。
|
||||
|
||||
客户端跳转规则:
|
||||
|
||||
- `is_new_user=true` 表示本次调用首次创建了用户和三方绑定,客户端应进入资料补全页。
|
||||
@ -145,7 +150,7 @@ Response `data`:
|
||||
| `username` | 64 | trimmed display name |
|
||||
| `gender` | 32 | client enum/string snapshot |
|
||||
| `country` | 3 | ISO 3166-1 alpha-2/alpha-3 canonical code, normalized uppercase |
|
||||
| `invite_code` | 64 | optional invite code |
|
||||
| `invite_code` | 64 | optional invite code, trim and uppercase before resolving |
|
||||
| `register_ip` | 64 | gateway observed IP only |
|
||||
| `register_user_agent` | 512 | gateway observed User-Agent only |
|
||||
| `country_by_ip` | 2 | trusted edge/server country code only |
|
||||
@ -281,7 +286,9 @@ Request:
|
||||
{
|
||||
"username": "hy",
|
||||
"avatar": "https://cdn.example/avatar.png",
|
||||
"country": "SG"
|
||||
"gender": "male",
|
||||
"country": "SG",
|
||||
"invite_code": "A1B2C3"
|
||||
}
|
||||
```
|
||||
|
||||
@ -297,6 +304,11 @@ Response `data`:
|
||||
"profile_completed": true,
|
||||
"profile_completed_at_ms": 1777000000000,
|
||||
"onboarding_status": "completed",
|
||||
"invite": {
|
||||
"bound": true,
|
||||
"invite_code": "A1B2C3",
|
||||
"inviter_user_id": "10001"
|
||||
},
|
||||
"token": {
|
||||
"user_id": "918274650129384448",
|
||||
"session_id": "sess_xxx",
|
||||
@ -310,7 +322,7 @@ Response `data`:
|
||||
}
|
||||
```
|
||||
|
||||
该接口是注册页唯一提交入口。客户端在注册页一次性提交国家、用户名和头像;user-service 在同一事务内校验资料、解析国家和区域、更新用户资料,并把用户标记为注册完成。
|
||||
该接口是注册页唯一提交入口。客户端在注册页一次性提交国家、用户名、头像和可选邀请码;user-service 在同一事务内校验资料、解析国家和区域、按需绑定邀请关系、更新用户资料,并把用户标记为注册完成。
|
||||
|
||||
`token.access_token` 是注册完成后立即替换旧 access token 的新 JWT,包含 `profile_completed=true`。该接口不轮换 refresh token,`token.refresh_token` 为空或不返回;客户端必须保留登录时已有的 refresh token。
|
||||
|
||||
@ -322,6 +334,7 @@ Response `data`:
|
||||
| `avatar` | yes | App 注册页要求用户头像,房间和 IM 展示不使用空头像 |
|
||||
| `country` | yes | 房间列表按区域分发,必须有用户选择国家 |
|
||||
| `region_id` | no | 国家可以暂时没有区域映射,此时进入 `GLOBAL` 房间列表 |
|
||||
| `invite_code` | no | 注册邀请归因;非空时必须能解析到有效邀请人 |
|
||||
|
||||
注册上下文必需字段:
|
||||
|
||||
@ -336,11 +349,13 @@ Response `data`:
|
||||
- `username` 必须 trim 后非空,并满足 `users.username` 长度限制。
|
||||
- `avatar` 必须是合法 `http` 或 `https` URL,并满足 `users.avatar` 长度限制。
|
||||
- `country` 必须命中 active `countries.country_code`,并同步解析 `region_id`。
|
||||
- `invite_code` 非空时必须能解析到同一 `app_code` 下的 active 用户,且不能是当前用户自己的邀请码。
|
||||
- 邀请关系只能在未完成资料且尚未绑定邀请关系时创建;已完成注册后不能再补填邀请码。
|
||||
- 初次完成注册时设置国家不消耗国家修改冷却期;注册完成后的国家修改才走 `ChangeUserCountry` 和 30 天冷却。
|
||||
- 如果用户已经 `profile_completed=true`,该接口不再修改资料;客户端需要改资料时使用资料修改接口,改国家时使用国家修改接口。
|
||||
- `CompleteOnboarding` 成功后必须返回新的 access token,客户端立即替换旧 token;refresh token 不轮换。
|
||||
|
||||
如果必填字段缺失或格式非法,返回 `INVALID_ARGUMENT`。如果资料未完成用户访问被门禁保护的业务能力,返回 `PROFILE_REQUIRED`。`PROFILE_REQUIRED` 只用于已登录但资料未完成的业务门禁,不用于三方登录接口。
|
||||
如果必填字段缺失或格式非法,返回 `INVALID_ARGUMENT`。如果非空邀请码无法解析为有效邀请人,返回 `INVALID_INVITE_CODE`。如果资料未完成用户访问被门禁保护的业务能力,返回 `PROFILE_REQUIRED`。`PROFILE_REQUIRED` 只用于已登录但资料未完成的业务门禁,不用于三方登录接口。
|
||||
|
||||
## App Login State Machine
|
||||
|
||||
@ -359,7 +374,7 @@ unauthenticated
|
||||
|
||||
```text
|
||||
onboarding_required
|
||||
-> POST /api/v1/users/me/onboarding/complete(username, avatar, country)
|
||||
-> POST /api/v1/users/me/onboarding/complete(username, avatar, gender, country, optional invite_code)
|
||||
-> token_received(profile_completed=true)
|
||||
-> authenticated_home
|
||||
```
|
||||
@ -589,6 +604,7 @@ message CompleteOnboardingRequest {
|
||||
string avatar = 4;
|
||||
string country = 5;
|
||||
string gender = 6;
|
||||
string invite_code = 7;
|
||||
}
|
||||
|
||||
message CompleteOnboardingResponse {
|
||||
@ -611,6 +627,7 @@ service UserService {
|
||||
- `profile_completed_at_ms=0` 表示从未完成 onboarding。
|
||||
- `onboarding_status` 首版只允许 `profile_required` 和 `completed`,后续可以追加 `country_required`、`age_required` 等更细状态。
|
||||
- `CompleteOnboardingRequest.username/avatar/gender/country` 是注册页必填字段,不能从客户端拆成多次部分提交。
|
||||
- `CompleteOnboardingRequest.invite_code` 是注册页选填字段;非空时必须解析到有效邀请人,否则返回 `INVALID_INVITE_CODE`。
|
||||
- `CompleteOnboardingResponse.token.access_token` 是注册完成后用于替换旧 JWT 的新 access token;它复用当前 `session_id`,不轮换 refresh token。
|
||||
|
||||
## Data Model
|
||||
@ -629,6 +646,8 @@ service UserService {
|
||||
| `auth_sessions` | refresh token hash 唯一键带 `app_code` |
|
||||
| `login_audit` | 登录审计查询索引带 `app_code` |
|
||||
| `display_user_id_sequences` | 每个 App 独立短号序列,首个 `next_value` 为 `163000` |
|
||||
| `user_invite_codes` | 用户自己的邀请码按 `app_code + code` 唯一 |
|
||||
| `user_invite_relations` | 被邀请用户按 `app_code + invited_user_id` 最多一条关系 |
|
||||
|
||||
| Column | Type | Rule |
|
||||
| --- | --- | --- |
|
||||
@ -640,7 +659,7 @@ service UserService {
|
||||
| `username` | varchar | registration display name snapshot |
|
||||
| `gender` | varchar | client submitted gender value |
|
||||
| `country` | varchar | ISO 3166-1 alpha-2/alpha-3 canonical country code |
|
||||
| `invite_code` | varchar | optional invite code |
|
||||
| `invite_code` | varchar | optional submitted invite code snapshot; not the authoritative inviter relation |
|
||||
| `register_ip` | varchar | gateway observed request ip snapshot |
|
||||
| `register_user_agent` | varchar | gateway observed User-Agent snapshot |
|
||||
| `country_by_ip` | varchar | server/edge inferred country from entry IP |
|
||||
@ -673,6 +692,7 @@ service UserService {
|
||||
`CompleteOnboarding` 成功时:
|
||||
|
||||
- 校验 `username/avatar/gender/country`。
|
||||
- 如果提交了 `invite_code` 且尚未绑定邀请关系,解析并写 `user_invite_relations`。
|
||||
- 写入 `users.username`、`users.avatar`、`users.gender`、`users.country` 和 `users.region_id`。
|
||||
- 设置 `profile_completed=true`。
|
||||
- 设置 `profile_completed_at_ms=now`。
|
||||
@ -680,6 +700,44 @@ service UserService {
|
||||
|
||||
资料完成状态只能由 user-service 修改,gateway 和客户端不能直接提交该字段。
|
||||
|
||||
### `user_invite_codes`
|
||||
|
||||
用户自己的邀请码表。新用户创建时必须生成一条 active primary code,供用户在我的页复制分享。
|
||||
|
||||
| Column | Type | Rule |
|
||||
| --- | --- | --- |
|
||||
| `invite_code_id` | bigint | primary id |
|
||||
| `code` | varchar | trim + uppercase 后保存,`app_code + code` 唯一 |
|
||||
| `owner_user_id` | bigint | code owner user |
|
||||
| `status` | varchar | active or disabled |
|
||||
| `is_primary` | tinyint/bool | 首版一个用户只有一个 primary code |
|
||||
| `created_at_ms` | bigint | creation time |
|
||||
| `updated_at_ms` | bigint | update time |
|
||||
|
||||
邀请码不能复用 `current_display_user_id`。展示短号可能因为靓号、运营调整或过期发生变化;邀请码需要长期稳定,才能解释历史邀请关系。
|
||||
|
||||
### `user_invite_relations`
|
||||
|
||||
用户邀请关系事实表。它是“谁邀请了谁”的权威来源。
|
||||
|
||||
| Column | Type | Rule |
|
||||
| --- | --- | --- |
|
||||
| `invited_user_id` | bigint | 被邀请用户,`app_code + invited_user_id` 主键 |
|
||||
| `inviter_user_id` | bigint | 邀请人用户 |
|
||||
| `invite_code_id` | bigint | 使用的邀请码 ID |
|
||||
| `invite_code` | varchar | 绑定时的邀请码快照 |
|
||||
| `source` | varchar | login or onboarding |
|
||||
| `bound_at_ms` | bigint | relation binding time |
|
||||
| `request_id` | varchar | gateway trace id |
|
||||
| `created_at_ms` | bigint | creation time |
|
||||
|
||||
关系绑定规则:
|
||||
|
||||
- 一个用户最多一条邀请关系。
|
||||
- 非空邀请码不存在、停用、跨 App、owner 非 active 或 owner 是当前用户时,返回 `INVALID_INVITE_CODE`。
|
||||
- 已有三方身份再次登录时不能通过 `invite_code` 修改邀请人。
|
||||
- 历史关系不因为邀请码后续停用或邀请人资料变化而删除。
|
||||
|
||||
### `password_accounts`
|
||||
|
||||
密码身份表。它表达“该用户已经设置过密码”,不表达独立账号体系。
|
||||
@ -789,7 +847,11 @@ sequenceDiagram
|
||||
U->>U: check user status
|
||||
U->>U: create session
|
||||
else identity missing
|
||||
U->>U: create user + registration context + default_display_user_id + third_party identity + session
|
||||
U->>U: create user + registration context + default_display_user_id + own invite code
|
||||
opt invite_code present
|
||||
U->>U: resolve invite code + bind inviter relation
|
||||
end
|
||||
U->>U: create third_party identity + session
|
||||
end
|
||||
U-->>G: AuthToken + is_new_user + profile_completed
|
||||
G-->>C: envelope(data=AuthToken)
|
||||
@ -809,11 +871,14 @@ sequenceDiagram
|
||||
participant G as gateway-service
|
||||
participant U as user-service
|
||||
|
||||
C->>G: POST /api/v1/users/me/onboarding/complete(username, avatar, gender, country)
|
||||
C->>G: POST /api/v1/users/me/onboarding/complete(username, avatar, gender, country, optional invite_code)
|
||||
G->>G: verify access token
|
||||
G->>U: CompleteOnboarding(user_id from token, username, avatar, gender, country)
|
||||
G->>U: CompleteOnboarding(user_id from token, username, avatar, gender, country, invite_code)
|
||||
U->>U: validate username/avatar/gender/country
|
||||
U->>U: resolve active country and region_id
|
||||
opt invite_code present and no relation exists
|
||||
U->>U: resolve invite code and bind inviter relation
|
||||
end
|
||||
U->>U: update profile + mark profile_completed
|
||||
U->>U: re-sign access token with profile_completed=true
|
||||
U-->>G: User + profile_completed=true + AuthToken(access_token only)
|
||||
@ -933,6 +998,7 @@ gateway HTTP envelope 的 `code` 不使用数字。
|
||||
| `OK` | 200 | success |
|
||||
| `INVALID_JSON` | 400 | request body is invalid JSON |
|
||||
| `INVALID_ARGUMENT` | 400 | field format or required field invalid |
|
||||
| `INVALID_INVITE_CODE` | 400 | submitted invite code does not resolve to a valid inviter |
|
||||
| `RATE_LIMITED` | 429 | public auth rate limit exceeded |
|
||||
| `AUTH_FAILED` | 401 | account missing, password wrong, no password set, or provider rejected |
|
||||
| `UNAUTHORIZED` | 401 | missing or invalid access token |
|
||||
@ -970,6 +1036,8 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所
|
||||
- 三方 provider secret、Firebase service account 和 Firebase project id 只属于 `user-service` 配置或服务端密钥系统。
|
||||
- 生产三方 verifier 必须校验 Firebase ID token 或 provider credential 的真实性和用途,不能接受客户端自报 subject,不能把静态 verifier 配到真实 provider。
|
||||
- 注册完成必须由 user-service 在同一事务内写入 `username/avatar/gender/country/region_id/profile_completed`,不能由 gateway 分多步拼状态。
|
||||
- 邀请码解析和邀请关系绑定必须由 user-service 在注册或资料补全事务内完成,不能由 gateway 先查后写。
|
||||
- 邀请关系不能只保存邀请码字符串;必须保存 `inviter_user_id` 和绑定时的邀请码快照。
|
||||
- 登录失败日志不能包含明文密码、三方 credential、refresh token 原文。
|
||||
- gateway 统一过滤内部错误,客户端只拿稳定错误码和 `request_id`。
|
||||
- access token 校验失败不访问 `user-service`,避免每个业务请求都打用户服务。
|
||||
@ -986,8 +1054,10 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所
|
||||
- 首版 Firebase 登录必须配置 `provider=firebase`、Firebase project id、Firebase verifier 和 `firebase.sign_in_provider` allowlist;不能只把 `firebase` 加入静态 allowlist。
|
||||
- 新增直接 provider 必须同时新增专用 verifier、配置项和测试;不能只把 provider 名加入静态 allowlist。
|
||||
- 三方登录创建用户必须使用事务,保证 `users` 注册上下文、`user_display_user_ids` 和 `third_party_identities` 不出现半绑定。
|
||||
- 注册页只调用 `CompleteOnboarding(username, avatar, gender, country)`;不要让客户端分别调用资料、国家和完成状态三个接口拼注册流程,也不要把 `device_id/platform` 放进注册页表单。
|
||||
- 三方登录创建新用户时必须同时生成用户自己的邀请码。
|
||||
- 注册页只调用 `CompleteOnboarding(username, avatar, gender, country, optional invite_code)`;不要让客户端分别调用资料、国家和完成状态三个接口拼注册流程,也不要把 `device_id/platform` 放进注册页表单。
|
||||
- `CompleteOnboarding` 成功后必须返回新的 access token;否则旧 access token 的 `profile_completed=false` 仍会被 gateway profile gate 拦截。
|
||||
- 已有邀请关系或已完成资料的用户不能通过再次登录或再次 onboarding 修改邀请人。
|
||||
- 设置密码必须使用 `user_id` 主键幂等判断,不能引入独立账号唯一键。
|
||||
- 登录响应中的 `display_user_id` 表示当前有效短号,服务端权限、账务、房间和 IM 身份一律使用长 `user_id`。
|
||||
|
||||
@ -999,6 +1069,10 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所
|
||||
- 三方新用户首次登录返回 `profile_completed=false` 和 `onboarding_status=profile_required`。
|
||||
- 三方新用户创建时写入 `device_id/platform/app_version/build_number/os_version/language/timezone` 等注册上下文,但不把这些字段作为注册页用户输入。
|
||||
- 注册页提交 `username/avatar/gender/country` 后,user-service 在同一事务内写用户资料、解析 `region_id` 并返回 `profile_completed=true`。
|
||||
- 新用户创建时生成自己的 active 邀请码,`GET /api/v1/users/me/overview` 可以展示该邀请码。
|
||||
- 注册页提交有效 `invite_code` 后,user-service 写 `user_invite_relations(invited_user_id, inviter_user_id)`。
|
||||
- 注册页提交不存在、停用、跨 App、owner 非 active 或自己的邀请码时返回 `INVALID_INVITE_CODE`。
|
||||
- 已有三方身份再次登录时传入新 `invite_code` 不改变原邀请关系。
|
||||
- 注册页完成后旧 access token 仍被 profile gate 拒绝;`CompleteOnboardingResponse.token.access_token` 能访问房间列表、IM UserSig、RTC Token 和付费链路。
|
||||
- 注册完成缺少 `username`、缺少 `avatar`、缺少 `gender`、缺少 `country`、头像 URL 非法或国家不存在时返回 `INVALID_ARGUMENT`。
|
||||
- 未完成注册用户访问房间列表、创建房间、进房、IM UserSig、RTC Token 或付费行为时返回 `PROFILE_REQUIRED`。
|
||||
|
||||
@ -40,7 +40,8 @@ graph LR
|
||||
MQ --> User
|
||||
|
||||
User --> HostOutbox[("host_outbox")]
|
||||
HostOutbox --> BI["BI / notification / risk"]
|
||||
HostOutbox --> Message["message inbox owner"]
|
||||
HostOutbox --> BI["BI / risk / export"]
|
||||
```
|
||||
|
||||
| Service | Admin Responsibility |
|
||||
@ -76,6 +77,8 @@ graph LR
|
||||
|
||||
后台管理某个 App 的 host、Agency、BD、政策或工资时,必须先在后台上下文中选择 `app_code`。`hyapp_admin` 库不按 App 拆库;审计行可以在 `detail` 中记录 `app_code`。业务事实仍写入 user/wallet 等业务库,并由对应表的 `app_code` 隔离。
|
||||
|
||||
当前 `user_id` 是内部稳定主键,后台仍必须在所有 host/Agency/BD 查询、幂等和审计 detail 中带上 `app_code`。如果某些现有表还使用全局 `PRIMARY KEY(user_id)`、`PRIMARY KEY(command_id)` 或 `PRIMARY KEY(event_id)`,这只能说明当前实现暂时要求这些 ID 全局唯一;不能据此省略 `app_code`,也不能在多 App 写入前声称同一个 `command_id` 可以跨 App 复用。
|
||||
|
||||
```sql
|
||||
admin_operation_logs(
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
@ -281,9 +284,9 @@ stateDiagram-v2
|
||||
|
||||
### Host Salary
|
||||
|
||||
输入来自 App 文档定义的 `host_daily_stats`:
|
||||
输入来自 App 文档定义的 `host_daily_stats`。基础麦上在线事实先按所有用户沉淀,详见 [User Mic Online Time Architecture](./user-mic-online-time-architecture.md);`host_daily_stats` 是工资需要的关系快照派生表。
|
||||
|
||||
- Sum `mic_publishing_ms` within cycle。
|
||||
- Sum `mic_online_ms` within cycle。
|
||||
- Sum `gift_point_received` within cycle。
|
||||
- Sum `effective_day`。
|
||||
- 使用 stats 中的 `agency_id/bd_user_id/bd_leader_user_id` 关系快照做归属。
|
||||
@ -459,7 +462,23 @@ service UserHostAdminService {
|
||||
| `SalaryAdjustmentPosted` | adjustment credited or reversed in wallet |
|
||||
| `AdminHostRelationChanged` | admin creates/stops BD/Agency/Leader |
|
||||
|
||||
Outbox consumers can update admin notifications, BI, risk systems, and export jobs. They must not be required for salary facts to commit.
|
||||
Outbox consumers can update App message inbox, BI, risk systems, and export jobs. They must not be required for salary facts to commit.
|
||||
|
||||
## App Message Notifications
|
||||
|
||||
后台关系和工资动作需要通知 App 用户时,统一进入 App `消息` tab 的 `system` 分区,具体 message inbox 设计见 [App Message Tab Architecture](./app-message-tab-architecture.md)。`hyapp-admin-server` 只负责后台权限和审计;App 消息事实由 message inbox owner 落库。
|
||||
|
||||
| Admin/Salary Fact | Target User | Message Type | Action |
|
||||
| --- | --- | --- | --- |
|
||||
| Admin creates BD Leader | target user | `system` | `bd_profile_detail` |
|
||||
| Admin creates BD | target user | `system` | `bd_profile_detail` |
|
||||
| Admin creates Agency | owner user | `system` | `agency_detail` |
|
||||
| Agency closed by admin | owner and active members | `system` | `agency_detail` |
|
||||
| Salary cycle item posted | salary receiver | `system` | `salary_item_detail` |
|
||||
| Salary adjustment posted | affected receiver | `system` | `salary_adjustment_detail` |
|
||||
| Salary posting failed after approval | no App user notification by default | internal alert |
|
||||
|
||||
工资、关系和钱包入账事实不依赖消息投递。message inbox 消费失败时只能重试或告警,不能回滚 salary cycle、salary item 或 wallet transaction。
|
||||
|
||||
## Edge Cases
|
||||
|
||||
|
||||
@ -75,7 +75,9 @@ graph LR
|
||||
|
||||
host、Agency、BD、申请、邀请、统计和收益读模型都按 `app_code` 隔离。gateway 必须把客户端解析出的 `app_code` 写入 `RequestMeta`;`user-service` host domain 的所有查询、锁、唯一约束、幂等结果和 outbox 都必须带 `app_code`。
|
||||
|
||||
不同 App 下可以出现相同的 `user_id`、`agency_id`、`command_id` 或 `event_id`,但不能跨 `app_code` 查询或复用关系事实。后台操作某个 App 的 host 关系时,也必须由 `hyapp-admin-server` 显式选择并透传 `app_code`。
|
||||
当前用户体系的 `user_id` 是内部稳定主键,业务查询仍必须带 `app_code` 做租户作用域。`display_user_id`、`command_id`、`event_id`、申请、邀请和 outbox 幂等键都必须在 `app_code` 下判定唯一,不能因为某个本地表暂时使用全局主键就让业务逻辑绕过 `app_code`。
|
||||
|
||||
后台操作某个 App 的 host 关系时,也必须由 `hyapp-admin-server` 显式选择并透传 `app_code`。如果后续要允许同一数值业务 ID 在不同 App 下复用,必须先把对应表的主键或唯一键迁移为 `(app_code, id)` 或 `(app_code, command_id)`,再开放多 App 数据写入。
|
||||
|
||||
## Identity Rules
|
||||
|
||||
@ -388,17 +390,20 @@ Leader inviting Agency uses the same Agency creation flow as BD, but `parent_bd_
|
||||
|
||||
App 统计和后台结算都不能直接从当前 membership 反查,因为关系会变化。每条工作指标必须在发生时固化归属快照。
|
||||
|
||||
麦上在线时长的基础事实不是主播专属指标,先按所有用户统一沉淀。通用用户麦上时长见 [User Mic Online Time Architecture](./user-mic-online-time-architecture.md)。本节的 `host_daily_stats` 是主播、Agency、BD 工资和团队统计的派生读模型,不是唯一时长来源。
|
||||
|
||||
### Input Events
|
||||
|
||||
| Event | Source | Use |
|
||||
| --- | --- | --- |
|
||||
| `RoomMicChanged` | `room-service` outbox | 上麦、确认发流、下麦,计算有效上麦时长 |
|
||||
| `UserMicSessionClosed` / user mic stats | user mic time consumer | 用户级麦上在线时长基础事实,host stats 从这里派生 |
|
||||
| `RoomMicChanged` | `room-service` outbox | 上麦、确认发流、下麦;由用户麦时长 consumer 先统一处理 |
|
||||
| `RoomGiftSent` | `room-service` outbox | 房间展示和用户贡献统计 |
|
||||
| `WalletGiftDebited` | `wallet-service` outbox | 权威 `gift_point_added` 和价格版本 |
|
||||
| `WalletRechargeRecorded` | `wallet-service` outbox | 用户充值口径,币商转账按区域政策折算后的 `usd_minor_amount` |
|
||||
| `HostAgencyMembershipChanged` | `user-service` host outbox | 关系变化审计和缓存失效 |
|
||||
|
||||
有效上麦时长只计算 `publish_state=publishing` 的时间段。`pending_publish` 不计有效工作时长,避免用户占麦但 RTC 没有成功发流时产生收益口径。
|
||||
有效麦上在线默认只计算 `publish_state=publishing` 的时间段。`pending_publish` 不计默认在线时长,避免用户占麦但 RTC 没有成功发流时产生任务或收益口径。若某个运营玩法明确要求“占麦时长”,应读取通用用户麦时长里的 `seat_occupied_ms`,不要改 host stats 口径。
|
||||
|
||||
### Daily Stats
|
||||
|
||||
@ -412,7 +417,8 @@ host_daily_stats(
|
||||
agency_id BIGINT NULL,
|
||||
bd_user_id BIGINT NULL,
|
||||
bd_leader_user_id BIGINT NULL,
|
||||
mic_publishing_ms BIGINT NOT NULL,
|
||||
mic_online_ms BIGINT NOT NULL,
|
||||
seat_occupied_ms BIGINT NOT NULL DEFAULT 0,
|
||||
gift_point_received BIGINT NOT NULL,
|
||||
coin_received_value BIGINT NOT NULL,
|
||||
effective_day TINYINT NOT NULL,
|
||||
@ -437,6 +443,7 @@ host_event_dedup(
|
||||
- `agency_id/bd_user_id/bd_leader_user_id` 是事件发生时的关系快照。
|
||||
- `relation_scope_key` 必须是非空稳定值,例如 `agency:{agency_id}` 或 `none:{region_id}`,避免 MySQL 在 nullable key 上无法保证无 Agency host 的幂等聚合。
|
||||
- host 一天内换 Agency 时,同一天可以有多行 stats,按不同 Agency 拆分。
|
||||
- 用户成为 host 之前的麦上在线历史仍保留在用户级 stats;是否计入某个 host/工资政策,由派生规则决定。
|
||||
- `effective_day` 由政策解释,例如当天 `publishing >= 60 minutes` 记 1 天;不要硬编码在统计表结构里。
|
||||
- 所有事件消费必须按 `app_code + event_id` 幂等。
|
||||
- 迟到事件可以修正未锁定 stats;已生成收益结果的修正由 Admin 调整单处理,App 只读取最终状态。
|
||||
@ -576,6 +583,23 @@ service WalletService {
|
||||
|
||||
Outbox consumers can update search indexes, notifications, BI, admin views, and risk systems. They must not be required for core relationship facts to commit.
|
||||
|
||||
## App Message Tab Integration
|
||||
|
||||
Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system` 分区,具体 message inbox 设计见 [App Message Tab Architecture](./app-message-tab-architecture.md)。`user-service` host domain 只产出 host outbox 事件或调用内部 `CreateInboxMessage`;不能直接写 message inbox 表,也不能复用后台 `admin_notifications`。
|
||||
|
||||
| Source Event | Target User | Message Type | Action |
|
||||
| --- | --- | --- | --- |
|
||||
| `AgencyApplicationSubmitted` | Agency owner or authorized reviewer | `system` | `agency_application_detail` |
|
||||
| `AgencyApplicationApproved` | applicant host | `system` | `host_application_detail` |
|
||||
| `AgencyApplicationRejected` | applicant user | `system` | `host_application_detail` |
|
||||
| `AgencyMembershipChanged(kicked)` | kicked host | `system` | `agency_membership_detail` |
|
||||
| `RoleInvitationCreated(type=agency)` | invited user | `system` | `role_invitation_detail` |
|
||||
| `RoleInvitationCreated(type=bd)` | invited user | `system` | `role_invitation_detail` |
|
||||
| `RoleInvitationAccepted` | inviter | `system` | `bd_team_detail` |
|
||||
| `BDCreated` | new BD | `system` | `bd_profile_detail` |
|
||||
|
||||
消息只做通知和跳转,不是关系事实。App 收到消息后仍必须调用 host/agency/bd 查询接口刷新当前状态。
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Case | Rule |
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
- 新增 protobuf:host profile、Agency、BD profile、coin_seller profile、Agency membership、application、role invitation 的内部 gRPC 契约,由 `user-service` 实现。
|
||||
- 在 user MySQL schema 新增表:`host_profiles`、`agencies`、`bd_profiles`、`coin_seller_profiles`、`agency_memberships`、`agency_applications`、`role_invitations`、`host_outbox`。
|
||||
- 所有 host domain 表、命令幂等键、查询条件和 outbox 都必须带 `app_code`;首个种子 App 是 `com.org.laluparty -> lalu`。
|
||||
- 审计 host domain 表的主键和唯一键:如果继续使用全局 `PRIMARY KEY(user_id)`、`PRIMARY KEY(command_id)` 或 `PRIMARY KEY(event_id)`,则 ID 生成和命令 ID 必须全局唯一;如果要支持同 ID 跨 App 复用,必须先迁移为 `(app_code, id)` 或 `(app_code, command_id)`。
|
||||
- 实现 command id 幂等:申请、邀请、审核、踢出、接受邀请都不能因重试重复创建事实。
|
||||
- 实现关系锁:涉及用户身份变化时锁 `host_profiles`、`agencies`、`bd_profiles` 和 active membership。
|
||||
|
||||
@ -25,6 +26,7 @@
|
||||
- coin_seller 可以和 Agency、BD、BD Leader 身份并存。
|
||||
- `go test ./services/user-service/...` 通过。
|
||||
- 修改 protobuf 后必须 `make proto && go test ./...`。
|
||||
- 多 App 写入前必须完成 keying 审计,不能出现文档按 `app_code` 幂等、数据库却用全局唯一键导致跨 App 冲突的灰区。
|
||||
|
||||
## Phase 2: Minimal Admin For Organization Seed
|
||||
|
||||
@ -86,21 +88,43 @@
|
||||
- 币商余额不足时转账失败,玩家 `COIN` 不增加。
|
||||
- 币商和玩家不在同一区域时拒绝转账;缺少 active 充值政策时不产生账务分录。
|
||||
|
||||
## Phase 4: Stats And Attribution
|
||||
## Phase 3.5: App Message Notifications
|
||||
|
||||
关系主链路稳定后再消费房间和钱包事件。统计必须使用事件发生时的关系快照,不能从当前 membership 反推历史归属。
|
||||
关系主链路跑通后接入 App `消息` tab。通知是用户体验能力,不是关系事实提交前置条件;任何通知失败都不能回滚 host/Agency/BD 事务。
|
||||
|
||||
交付:
|
||||
|
||||
- 消费 `RoomMicChanged`,只累计 `publish_state=publishing` 的有效上麦时间。
|
||||
- host outbox 事件接入 message inbox owner,具体接口和表见 [App Message Tab Architecture](./app-message-tab-architecture.md)。
|
||||
- Agency 申请提交后通知 Agency owner 或授权审核者。
|
||||
- 申请通过、拒绝、被踢出 Agency 时通知目标用户。
|
||||
- BD/BD Leader 邀请 Agency 或 BD 时通知被邀请人。
|
||||
- 邀请被接受后通知邀请人刷新团队列表。
|
||||
- 后台创建 BD Leader、BD、Agency 后通知目标用户或 owner。
|
||||
- 所有消息使用 `producer=user-service-host`、稳定 `producer_event_id`,重复消费不重复入箱。
|
||||
|
||||
验收:
|
||||
|
||||
- 同一 host outbox 事件重复消费,只生成一条 App system 消息。
|
||||
- 消息只出现在当前 `app_code`、目标 `user_id` 的 `system` 分区。
|
||||
- App 点击消息后仍通过 host/agency/bd 查询接口读取最新事实。
|
||||
- message inbox 失败时 host 关系事实已提交,outbox 可重试。
|
||||
|
||||
## Phase 4: Stats And Attribution
|
||||
|
||||
关系主链路稳定后再消费房间和钱包事件。麦上在线时长先按所有用户统一沉淀,详见 [User Mic Online Time Architecture](./user-mic-online-time-architecture.md)。host stats 是派生读模型;工资和团队统计必须使用派生时的关系快照,不能从当前 membership 反推历史归属。
|
||||
|
||||
交付:
|
||||
|
||||
- 消费或读取用户麦上时长基础事实,默认只累计 `publish_state=publishing` 的 `mic_online_ms`。
|
||||
- 消费礼物/钱包事件,累计 host 收到的礼物积分或等价统计。
|
||||
- 写入 `host_daily_stats`,包含 `agency_id`、`bd_user_id`、`bd_leader_user_id` 和非空 `relation_scope_key`。
|
||||
- 写入通用用户麦时长表,并从中派生 `host_daily_stats`;`host_daily_stats` 包含 `agency_id`、`bd_user_id`、`bd_leader_user_id` 和非空 `relation_scope_key`。
|
||||
- 写入 `host_event_dedup`,按 `event_id` 幂等消费。
|
||||
- 构建 App 读模型:`host_me_view`、`agency_member_view`、`bd_team_view`、`earning_summary_view` 的基础查询。
|
||||
|
||||
验收:
|
||||
|
||||
- `pending_publish` 不计有效工作时长。
|
||||
- 用户成为 host 前累计的用户级麦上在线时长不会丢;是否用于 host 任务由任务政策决定。
|
||||
- host 一天内换 Agency 时,stats 按 relation snapshot 拆行。
|
||||
- 同一事件重复消费不会重复累计。
|
||||
- 迟到事件可以修正未锁定 stats;已结算结果交给 Admin 调整单处理。
|
||||
@ -120,6 +144,7 @@
|
||||
- 后台审核工资周期。
|
||||
- 调用 `wallet-service` 给 `USD_BALANCE` 入账,幂等键为 `salary:{salary_item_id}`。
|
||||
- 支持调整单:迟到事件、人工修正、争议处理都走补差或冲正。
|
||||
- Salary posted 和 adjustment posted 事件接入 App message inbox,通知接收人查看收益明细;通知失败不影响钱包入账事实。
|
||||
|
||||
验收:
|
||||
|
||||
@ -155,6 +180,7 @@
|
||||
| M1 | Phase 1 | 用 gRPC 创建基础关系事实,证明幂等和锁正确 |
|
||||
| M2 | Phase 2 | 后台创建 BD/Agency,App 可以搜到 Agency |
|
||||
| M3 | Phase 3 | 用户申请、审核、踢出、重新申请、邀请链路完整跑通 |
|
||||
| M3.5 | Phase 3.5 | 申请、邀请、后台创建关系结果能进入 App 系统消息 |
|
||||
| M4 | Phase 4 | 有效上麦和礼物统计按关系快照落库 |
|
||||
| M5 | Phase 5 | 后台生成工资单,审批后入账 `USD_BALANCE` |
|
||||
| M6 | Phase 6 | 失败补偿、导出、重建和监控可用 |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# My Page Overview API
|
||||
|
||||
本文定义 App 底部 `我的` 页首屏聚合接口。该接口只返回首屏渲染需要的轻量摘要:用户资料、核心资产余额、身份开关、消息红点和入口显隐。流水、成员列表、背包完整列表、工资明细和团队统计必须放到二级页接口。
|
||||
本文定义 App 底部 `我的` 页首屏聚合接口。该接口只返回首屏渲染需要的轻量摘要:用户资料、我的邀请码、核心资产余额、身份开关、消息红点和入口显隐。流水、成员列表、背包完整列表、工资明细、邀请列表和团队统计必须放到二级页接口。
|
||||
|
||||
## Goals
|
||||
|
||||
@ -27,6 +27,7 @@ Authorization: Bearer <access_token>
|
||||
"data": {
|
||||
"profile": {},
|
||||
"wallet": {},
|
||||
"invite": {},
|
||||
"roles": {},
|
||||
"badges": {},
|
||||
"entries": [],
|
||||
@ -85,6 +86,13 @@ Authorization: Bearer <access_token>
|
||||
"diamond_exchange_enabled": true,
|
||||
"withdraw_enabled": true
|
||||
},
|
||||
"invite": {
|
||||
"my_invite_code": "A1B2C3",
|
||||
"invite_enabled": true,
|
||||
"invite_count": 12,
|
||||
"valid_invite_count": 3,
|
||||
"valid_invite_threshold_coin": 80000
|
||||
},
|
||||
"roles": {
|
||||
"is_host": true,
|
||||
"is_agent": false,
|
||||
@ -139,6 +147,10 @@ Authorization: Bearer <access_token>
|
||||
| --- | --- | --- |
|
||||
| `profile` | `user-service.GetUser` | single row by `(app_code, user_id)` |
|
||||
| `wallet.balances` | `wallet-service.GetBalances` | point read for `COIN`、`DIAMOND`、`USD_BALANCE` |
|
||||
| `invite.my_invite_code` | `user-service` invite code module | point read active primary code by `(app_code, user_id)` |
|
||||
| `invite.invite_count` | invite read model | users that successfully filled this invite code; without read model return `0` or omit |
|
||||
| `invite.valid_invite_count` | invite read model | invited users whose cumulative eligible recharge reaches the configured threshold |
|
||||
| `invite.valid_invite_threshold_coin` | invite validity policy | current configured threshold, default `80000` `COIN` |
|
||||
| `roles` | `user-service` host identity module | point read role facts or future role summary read model |
|
||||
| `badges.unread_*` | message inbox owner | counter/read model, not message list scan |
|
||||
| `entries` | gateway composition rules | computed from `roles` and feature flags |
|
||||
@ -179,6 +191,14 @@ Authorization: Bearer <access_token>
|
||||
|
||||
## Expensive Fields To Exclude
|
||||
|
||||
不要在 `overview` 中实时查询这些字段:
|
||||
|
||||
| Field | Reason | Replacement |
|
||||
| --- | --- | --- |
|
||||
| 邀请用户列表 | 需要分页,可能扫 `user_invite_relations` | 二级页 `GET /api/v1/invites/users` |
|
||||
| 实时邀请人数 `COUNT(*)` | 高活跃邀请人会让首页变慢 | read model counter 或返回 `0` |
|
||||
| 邀请奖励流水 | 属于钱包/活动明细 | 二级页分页 |
|
||||
|
||||
这些字段不要放进首屏 overview:
|
||||
|
||||
| Field | Reason | Replacement |
|
||||
|
||||
@ -2433,6 +2433,30 @@ definitions:
|
||||
unavailable:
|
||||
type: boolean
|
||||
description: 钱包上游不可用时为 true;此时 balances 为空。
|
||||
MyOverviewInviteData:
|
||||
type: object
|
||||
properties:
|
||||
my_invite_code:
|
||||
type: string
|
||||
description: 当前用户自己的 active primary 邀请码。
|
||||
invite_enabled:
|
||||
type: boolean
|
||||
description: 当前用户的邀请码是否可用于新用户注册归因。
|
||||
invite_count:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 邀请人数,表示成功填写当前用户邀请码并绑定关系的人数;没有 read model 时返回 0 或不返回,禁止首页实时 COUNT。
|
||||
valid_invite_count:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 有效邀请人数,表示被邀请用户累计符合条件充值达到后台配置门槛的人数。
|
||||
valid_invite_threshold_coin:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 当前有效邀请门槛,默认 80000 COIN,可由后台配置。
|
||||
unavailable:
|
||||
type: boolean
|
||||
description: 邀请码上游不可用时为 true;客户端可隐藏复制入口。
|
||||
MyOverviewBadgesData:
|
||||
type: object
|
||||
required:
|
||||
@ -2486,6 +2510,7 @@ definitions:
|
||||
required:
|
||||
- profile
|
||||
- wallet
|
||||
- invite
|
||||
- roles
|
||||
- badges
|
||||
- entries
|
||||
@ -2495,6 +2520,8 @@ definitions:
|
||||
$ref: "#/definitions/UserProfileData"
|
||||
wallet:
|
||||
$ref: "#/definitions/MyOverviewWalletData"
|
||||
invite:
|
||||
$ref: "#/definitions/MyOverviewInviteData"
|
||||
roles:
|
||||
$ref: "#/definitions/MyOverviewRolesData"
|
||||
badges:
|
||||
@ -2870,11 +2897,25 @@ definitions:
|
||||
maxLength: 3
|
||||
pattern: "^[A-Za-z]{2,3}$"
|
||||
description: 注册页从 `/api/v1/countries` 取得的 country_code。
|
||||
invite_code:
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: 可选邀请码;非空时必须解析到有效邀请人。
|
||||
CompleteOnboardingData:
|
||||
allOf:
|
||||
- $ref: "#/definitions/UserProfileData"
|
||||
- type: object
|
||||
properties:
|
||||
invite:
|
||||
type: object
|
||||
properties:
|
||||
bound:
|
||||
type: boolean
|
||||
invite_code:
|
||||
type: string
|
||||
inviter_user_id:
|
||||
type: string
|
||||
description: 64 位用户 ID 使用字符串返回,未绑定时为空。
|
||||
token:
|
||||
$ref: "#/definitions/AuthTokenData"
|
||||
description: 注册完成后替换旧 access token;refresh_token 为空或不返回。
|
||||
|
||||
@ -967,6 +967,34 @@ definitions:
|
||||
enum:
|
||||
- profile_required
|
||||
- completed
|
||||
invite:
|
||||
$ref: "#/definitions/InviteOverview"
|
||||
InviteOverview:
|
||||
type: object
|
||||
properties:
|
||||
my_invite_code:
|
||||
type: string
|
||||
invite_enabled:
|
||||
type: boolean
|
||||
invite_count:
|
||||
type: integer
|
||||
format: int64
|
||||
valid_invite_count:
|
||||
type: integer
|
||||
format: int64
|
||||
valid_invite_threshold_coin:
|
||||
type: integer
|
||||
format: int64
|
||||
InviteBinding:
|
||||
type: object
|
||||
properties:
|
||||
bound:
|
||||
type: boolean
|
||||
invite_code:
|
||||
type: string
|
||||
inviter_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
GetUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -1077,6 +1105,10 @@ definitions:
|
||||
maxLength: 3
|
||||
pattern: "^[A-Z]{2,3}$"
|
||||
description: 注册页开放国家列表返回的 country_code。
|
||||
invite_code:
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: 可选邀请码;非空时必须解析到有效邀请人。
|
||||
CompleteOnboardingResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -1089,6 +1121,10 @@ definitions:
|
||||
format: int64
|
||||
onboarding_status:
|
||||
type: string
|
||||
token:
|
||||
$ref: "#/definitions/AuthToken"
|
||||
invite:
|
||||
$ref: "#/definitions/InviteBinding"
|
||||
BindPushTokenRequest:
|
||||
type: object
|
||||
required:
|
||||
|
||||
538
docs/registration-invite-code-architecture.md
Normal file
538
docs/registration-invite-code-architecture.md
Normal file
@ -0,0 +1,538 @@
|
||||
# Registration Invite Code Architecture
|
||||
|
||||
本文定义 App 注册邀请码能力。用户注册时可以选填邀请码;如果该邀请码属于某个有效用户,则该用户成为新用户的邀请人。邀请码归因是用户关系事实,不能只把字符串放在 `users.invite_code` 里。
|
||||
|
||||
## Goals
|
||||
|
||||
- 每个用户有一个可展示、可复制的永久邀请码。
|
||||
- 新用户注册或完成资料时可以选填邀请码。
|
||||
- 非空邀请码必须能解析到同一 `app_code` 下的有效邀请人。
|
||||
- 邀请关系一旦绑定不可被客户端修改。
|
||||
- 邀请关系必须落 MySQL;Redis 只能做短 TTL 查询缓存。
|
||||
- 邀请统计必须拆成 `invite_count` 和 `valid_invite_count`:前者表示成功填码绑定关系的人数,后者表示被邀请人累计充值达到后台配置门槛的人数。
|
||||
- 后续奖励、活动、风控和运营统计都从邀请关系事实消费,不反推历史字符串。
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 首版不在注册链路内同步发奖励。
|
||||
- 首版不支持用户自己修改邀请码。
|
||||
- 首版不支持跨 App 邀请。
|
||||
- 首版不把邀请码等同于 Agency、BD、币商或主播组织关系。
|
||||
- 首版不做邀请排行榜;只保存事实和提供轻量查询。
|
||||
- 首版不在注册链路内同步判断有效邀请;有效邀请由钱包充值事实异步推进。
|
||||
|
||||
## Ownership
|
||||
|
||||
| Capability | Owner | Rule |
|
||||
| --- | --- | --- |
|
||||
| 邀请码生成 | `user-service` | 新用户创建时生成自己的邀请码 |
|
||||
| 邀请码解析 | `user-service` | 注册事务内按 `app_code + code` 查询有效 code |
|
||||
| 邀请关系绑定 | `user-service` | 一个被邀请用户最多一条关系 |
|
||||
| 外部 HTTP | `gateway-service` | 转发 `invite_code`,不自行解析归属 |
|
||||
| 邀请人数统计 | `user-service` invite module | `invite_count` 来自 `user_invite_relations` 绑定事实 |
|
||||
| 有效邀请判定 | `user-service` invite module + `wallet-service` recharge event | 钱包只产出充值事实,邀请模块按后台策略累计并标记有效 |
|
||||
| 我的页展示 | `gateway-service` + `user-service` | `overview` 返回当前用户自己的邀请码、邀请人数和有效邀请人数 |
|
||||
| 奖励/通知 | 后续活动或钱包模块 | 通过 outbox/event 异步消费,不阻塞注册 |
|
||||
|
||||
## Product Rules
|
||||
|
||||
### Code Ownership
|
||||
|
||||
- 邀请码属于用户,不属于设备、session、三方账号或 Agency。
|
||||
- 邀请码按 `app_code` 隔离;`lalu` 的邀请码不能邀请 `popoo` 用户。
|
||||
- 用户被禁用或封禁后,默认不允许继续作为新邀请人的有效 code owner;历史邀请关系不删除。
|
||||
- 用户自己的邀请码创建后长期稳定。后续如果允许换码,必须保留旧码和历史关系快照。
|
||||
|
||||
### Binding Rules
|
||||
|
||||
| Case | Result |
|
||||
| --- | --- |
|
||||
| 注册不填邀请码 | 创建用户,不创建邀请关系 |
|
||||
| 注册填写有效邀请码 | 创建用户,并绑定 `inviter_user_id` |
|
||||
| 注册填写不存在、停用、跨 App 或 owner 非 active 的邀请码 | 返回 `INVALID_INVITE_CODE`,不静默忽略 |
|
||||
| 用户填写自己的邀请码 | 返回 `INVALID_INVITE_CODE` |
|
||||
| 已有邀请关系的用户再次提交邀请码 | 不修改原关系 |
|
||||
| 已完成注册用户提交邀请码 | 不修改原关系;如果通过 onboarding 入口提交,按已完成资料逻辑不处理邀请码 |
|
||||
| 已存在三方身份再次登录并带新邀请码 | 不创建、不修改邀请关系 |
|
||||
|
||||
非空邀请码失败时选择“失败注册”而不是“忽略邀请码”,原因是用户和渠道都需要明确知道归因没有成功,避免后续奖励和申诉没有事实依据。
|
||||
|
||||
### Count Rules
|
||||
|
||||
邀请统计必须拆成两个指标:
|
||||
|
||||
| Metric | Meaning | Source | Update Timing |
|
||||
| --- | --- | --- | --- |
|
||||
| `invite_count` | 邀请人数,表示有多少用户成功填写邀请码并绑定到当前邀请人 | `user_invite_relations` | 绑定关系成功后增加 |
|
||||
| `valid_invite_count` | 有效邀请人数,表示有多少被邀请用户累计成功充值金币达到策略门槛 | `user_invite_recharge_progress.status=valid` | 钱包充值事件累计达到门槛后增加 |
|
||||
|
||||
规则:
|
||||
|
||||
- `invite_count` 只看填码绑定事实,不要求被邀请人充值、消费、上麦或进入房间。
|
||||
- `valid_invite_count` 默认门槛是被邀请人累计成功充值 `80000` `COIN`,门槛必须由后台策略配置,不能写死在代码里。
|
||||
- 有效邀请只统计钱包标记为“充值”的 `COIN` 账务事实;送礼收入、奖励、后台普通调账、资源赠送和当前余额变化都不能直接计入。
|
||||
- 币商给玩家转金币如果已经由 `wallet-service.wallet_recharge_records` 记录为用户充值,可以计入;是否计入 Apple/Google、线下人工充值、币商转账等类型由策略的 `eligible_recharge_types` 控制。
|
||||
- 同一个被邀请用户只能让邀请人成为一次有效邀请;充值超过门槛后不重复增加。
|
||||
- 有效状态一旦达成,首版不因后续消费减少而回退;如果后续接入退款、拒付或作弊处理,需要后台风控撤销或新增负向事件重算。
|
||||
- 邀请人后续被封禁不删除历史关系和历史有效事实,但可以让其邀请码失效,避免继续产生新关系。
|
||||
|
||||
## App Flow
|
||||
|
||||
首版兼容两种客户端采集时机:
|
||||
|
||||
1. 客户端在三方登录前已经拿到邀请码:提交到 `POST /api/v1/auth/third-party/login`。
|
||||
2. 客户端在资料补全页让用户输入邀请码:提交到 `POST /api/v1/users/me/onboarding/complete`。
|
||||
|
||||
推荐 App 把邀请码放在资料补全页,因为当前架构是“三方登录即创建未完成用户”,注册页用户资料都在 `CompleteOnboarding` 原子提交。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as gateway-service
|
||||
participant U as user-service
|
||||
participant DB as hyapp_user
|
||||
|
||||
C->>G: POST /auth/third-party/login(provider, credential, optional invite_code)
|
||||
G->>U: LoginThirdParty
|
||||
U->>U: verify provider credential
|
||||
alt new third-party identity
|
||||
U->>DB: create users + own user_invite_codes
|
||||
opt invite_code present
|
||||
U->>DB: resolve invite code and insert user_invite_relations
|
||||
end
|
||||
U->>DB: create third_party_identity + auth_session
|
||||
else existing identity
|
||||
U->>DB: create auth_session only
|
||||
end
|
||||
U-->>G: token(profile_completed=false/true)
|
||||
|
||||
C->>G: POST /users/me/onboarding/complete(username, avatar, country, optional invite_code)
|
||||
G->>U: CompleteOnboarding
|
||||
U->>DB: lock users row
|
||||
opt no existing invite relation and invite_code present
|
||||
U->>DB: resolve invite code and insert user_invite_relations
|
||||
end
|
||||
U->>DB: update profile_completed=true
|
||||
U-->>G: new access_token(profile_completed=true)
|
||||
```
|
||||
|
||||
## HTTP Contract
|
||||
|
||||
### Login Or Register
|
||||
|
||||
```http
|
||||
POST /api/v1/auth/third-party/login
|
||||
```
|
||||
|
||||
`invite_code` 仍然是可选字段。只有本次请求首次创建用户时,该字段才可能绑定邀请关系;已有用户登录时忽略该字段,不允许通过重新登录改归因。
|
||||
|
||||
### Complete Onboarding
|
||||
|
||||
```http
|
||||
POST /api/v1/users/me/onboarding/complete
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "Alice",
|
||||
"avatar": "https://cdn.example/avatar.png",
|
||||
"gender": "female",
|
||||
"country": "SG",
|
||||
"invite_code": "A1B2C3"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "918274650129384448",
|
||||
"profile_completed": true,
|
||||
"invite": {
|
||||
"bound": true,
|
||||
"invite_code": "A1B2C3",
|
||||
"inviter_user_id": "10001"
|
||||
},
|
||||
"token": {
|
||||
"access_token": "jwt_xxx",
|
||||
"profile_completed": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`invite` 可以首版只返回 `bound`,但服务端必须在库里保存 `inviter_user_id`。客户端不能根据返回内容自行计算邀请关系。
|
||||
|
||||
### My Invite Code
|
||||
|
||||
首屏可以直接跟随 `GET /api/v1/users/me/overview` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"invite": {
|
||||
"my_invite_code": "A1B2C3",
|
||||
"invite_enabled": true,
|
||||
"invite_count": 12,
|
||||
"valid_invite_count": 3,
|
||||
"valid_invite_threshold_coin": 80000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`invite_count` 和 `valid_invite_count` 必须来自预计算 read model。首版没有 read model 时返回 `0` 或不返回,不能为了首页展示实时 `COUNT(*)` 扫邀请关系或充值流水。`invited_count` 这个旧命名语义不清,后续对外接口应收敛为 `invite_count`。
|
||||
|
||||
## Data Model
|
||||
|
||||
### `user_invite_codes`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_invite_codes (
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
invite_code_id BIGINT NOT NULL,
|
||||
code VARCHAR(64) NOT NULL,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
is_primary TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, invite_code_id),
|
||||
UNIQUE KEY uk_user_invite_codes_code (app_code, code),
|
||||
KEY idx_user_invite_codes_owner (app_code, owner_user_id, status)
|
||||
);
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `code` 入库前 trim、转大写,只允许 `A-Z0-9`,长度建议 6 到 12。
|
||||
- `status` 首版只需要 `active`、`disabled`。
|
||||
- 一个 owner 首版只创建一个 primary code。MySQL 无法跨状态表达复杂唯一时,先通过事务锁用户行和查询保证;后续可加 generated column 做 active primary 唯一。
|
||||
- code 不使用 `users.current_display_user_id`,避免靓号、短号过期或短号运营变更影响邀请关系。
|
||||
|
||||
### `user_invite_relations`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_invite_relations (
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
invited_user_id BIGINT NOT NULL,
|
||||
inviter_user_id BIGINT NOT NULL,
|
||||
invite_code_id BIGINT NOT NULL,
|
||||
invite_code VARCHAR(64) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
bound_at_ms BIGINT NOT NULL,
|
||||
request_id VARCHAR(96) NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, invited_user_id),
|
||||
KEY idx_user_invite_relations_inviter (app_code, inviter_user_id, bound_at_ms),
|
||||
KEY idx_user_invite_relations_code (app_code, invite_code, bound_at_ms)
|
||||
);
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `invited_user_id` 主键保证一个用户只能被归因一次。
|
||||
- `invite_code` 是绑定时的快照;即使后续 code 被禁用或换码,历史关系仍可解释。
|
||||
- `source` 首版可取 `login` 或 `onboarding`。
|
||||
- 历史关系不因为邀请人被封禁、改资料或换码而删除。
|
||||
|
||||
### `user_invite_validity_policies`
|
||||
|
||||
有效邀请门槛必须由后台配置。默认策略是累计成功充值 `80000` `COIN` 后成为有效邀请。
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_invite_validity_policies (
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
policy_id BIGINT NOT NULL,
|
||||
policy_version VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
region_id BIGINT NOT NULL DEFAULT 0,
|
||||
required_recharge_coin_amount BIGINT NOT NULL,
|
||||
eligible_recharge_types JSON NOT NULL,
|
||||
effective_from_ms BIGINT NOT NULL,
|
||||
effective_to_ms BIGINT NULL,
|
||||
created_by_user_id BIGINT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, policy_id),
|
||||
UNIQUE KEY uk_user_invite_validity_policy_version (app_code, policy_version),
|
||||
KEY idx_user_invite_validity_policy_active (app_code, region_id, status, effective_from_ms)
|
||||
);
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `required_recharge_coin_amount` 默认配置为 `80000`,单位是钱包 `COIN` 最小整数单位。
|
||||
- `region_id=0` 表示全局默认策略;有区域策略时按被邀请用户注册/当前业务区域选择,具体选择规则必须写入策略说明。
|
||||
- `eligible_recharge_types` 控制哪些充值类型可计入有效邀请,例如 `provider_iap`、`coin_seller_transfer`、`manual_recharge`。奖励、礼物收入、活动赠送和普通调账默认不计入。
|
||||
- 策略修改只能影响新达成有效邀请的判断;已达成的 `valid` 事实不自动重算。
|
||||
|
||||
### `user_invite_recharge_progress`
|
||||
|
||||
该表保存每个被邀请用户距离有效邀请的累计进度。它不是钱包余额来源,只消费钱包充值事实。
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_invite_recharge_progress (
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
invited_user_id BIGINT NOT NULL,
|
||||
inviter_user_id BIGINT NOT NULL,
|
||||
policy_id BIGINT NOT NULL,
|
||||
policy_version VARCHAR(64) NOT NULL,
|
||||
required_recharge_coin_amount BIGINT NOT NULL,
|
||||
accumulated_recharge_coin_amount BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
valid_at_ms BIGINT NULL,
|
||||
valid_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, invited_user_id),
|
||||
KEY idx_user_invite_progress_inviter_status (app_code, inviter_user_id, status, valid_at_ms),
|
||||
KEY idx_user_invite_progress_updated (app_code, status, updated_at_ms)
|
||||
);
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `status` 首版使用 `pending` 和 `valid`。
|
||||
- `accumulated_recharge_coin_amount` 只累计符合策略的成功充值金币数量。
|
||||
- `valid_transaction_id` 保存让累计金额首次达到门槛的钱包交易 ID,便于审计。
|
||||
- 同一个 `invited_user_id` 只能有一条进度;达到 `valid` 后不再重复增加 `valid_invite_count`。
|
||||
|
||||
### `user_invite_event_consumption`
|
||||
|
||||
钱包充值事件可能重投,邀请模块必须记录消费幂等。
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_invite_event_consumption (
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
source VARCHAR(64) NOT NULL,
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
transaction_id VARCHAR(96) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
error_message VARCHAR(512) NOT NULL DEFAULT '',
|
||||
consumed_at_ms BIGINT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, source, event_id),
|
||||
KEY idx_user_invite_event_tx (app_code, transaction_id),
|
||||
KEY idx_user_invite_event_status (app_code, status, updated_at_ms)
|
||||
);
|
||||
```
|
||||
|
||||
同一 `transaction_id` 如果通过不同事件重复到达,也不能重复累计;实现时应在同一事务中检查 `event_id` 和 `transaction_id` 的已消费状态。
|
||||
|
||||
### `user_invite_counters`
|
||||
|
||||
我的页和后台列表不能实时扫关系表或钱包流水,应读取计数 read model。
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_invite_counters (
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
inviter_user_id BIGINT NOT NULL,
|
||||
invite_count BIGINT NOT NULL DEFAULT 0,
|
||||
valid_invite_count BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, inviter_user_id)
|
||||
);
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `invite_count` 由 `UserInvited` 事实推进。
|
||||
- `valid_invite_count` 由 `UserInviteBecameValid` 事实推进。
|
||||
- 计数写入必须幂等;重复消费同一事件不能重复增加。
|
||||
- 如果计数异常,允许后台重算:`invite_count` 从 `user_invite_relations` 汇总,`valid_invite_count` 从 `user_invite_recharge_progress.status='valid'` 汇总。
|
||||
|
||||
### `users.invite_code`
|
||||
|
||||
现有 `users.invite_code` 只保留“注册时提交的邀请码字符串快照”。它不能作为权威邀请关系来源,因为它没有 `inviter_user_id`,也无法处理 code 换绑、停用、跨 App 和历史审计。
|
||||
|
||||
新代码应优先读取 `user_invite_relations`;`users.invite_code` 可以继续保留用于兼容和排障。
|
||||
|
||||
## Transaction Rules
|
||||
|
||||
新用户创建事务内建议顺序:
|
||||
|
||||
1. 校验 provider credential。
|
||||
2. 锁定或分配 `user_id` 和默认短号。
|
||||
3. 创建 `users`。
|
||||
4. 创建用户自己的 `user_invite_codes`。
|
||||
5. 如果请求带 `invite_code`,解析并锁定 code owner 的 `users` 行。
|
||||
6. 校验 owner active、非自己、同 `app_code`。
|
||||
7. 插入 `user_invite_relations`。
|
||||
8. 创建 `third_party_identities` 和 `auth_sessions`。
|
||||
9. 写 `UserRegistered` / `UserInvited` outbox 事件。
|
||||
|
||||
`CompleteOnboarding` 绑定邀请码时:
|
||||
|
||||
1. 锁当前 `users` 行。
|
||||
2. 如果 `profile_completed=true`,不允许再绑定邀请码。
|
||||
3. 如果已有 `user_invite_relations`,不修改原关系。
|
||||
4. 如果请求带非空 `invite_code`,解析 code,锁 inviter 用户行,插入关系。
|
||||
5. 更新资料、国家、区域和 `profile_completed`。
|
||||
6. 返回新的 access token。
|
||||
|
||||
关系插入和资料完成必须在同一事务内提交,避免用户看到注册完成但邀请关系丢失。
|
||||
|
||||
邀请关系绑定成功后,同事务或同一 outbox 消费链路需要初始化 `user_invite_recharge_progress(status=pending)` 并幂等增加 `user_invite_counters.invite_count`。如果计数更新失败,关系事实不能回滚;必须通过 `UserInvited` outbox 重试补偿计数。
|
||||
|
||||
## Effective Invite Flow
|
||||
|
||||
有效邀请不在注册链路内计算。它由钱包充值事实异步推进:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant W as wallet-service
|
||||
participant WO as wallet_outbox
|
||||
participant C as invite consumer
|
||||
participant U as user-service invite module
|
||||
participant DB as hyapp_user
|
||||
|
||||
W->>W: commit successful COIN recharge
|
||||
W->>WO: WalletRechargeRecorded(user_id, coin_amount, recharge_type)
|
||||
C->>WO: consume recharge event
|
||||
C->>U: ApplyInviteRecharge(user_id, transaction_id, coin_amount)
|
||||
U->>DB: find user_invite_relations by invited_user_id
|
||||
alt relation exists and recharge_type eligible
|
||||
U->>DB: lock user_invite_recharge_progress
|
||||
U->>DB: accumulate recharge coin amount
|
||||
opt first time accumulated >= required threshold
|
||||
U->>DB: mark status=valid and valid_at_ms
|
||||
U->>DB: increment user_invite_counters.valid_invite_count
|
||||
U->>DB: write UserInviteBecameValid outbox
|
||||
end
|
||||
else no relation or ineligible recharge
|
||||
U-->>C: idempotent skipped
|
||||
end
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
- 钱包只负责产出充值事实,不理解邀请关系,也不直接增加邀请统计。
|
||||
- 邀请模块按 `app_code + transaction_id` 对充值事件做幂等消费,同一充值事件不能重复累计。
|
||||
- 被邀请用户未绑定邀请关系时,充值事件跳过,不创建关系。
|
||||
- 充值事件早于邀请关系绑定时不应补算,除非后台明确执行重算任务;正常链路要求先填邀请码、后充值才算有效邀请。
|
||||
- 达到门槛的判断使用累计充值金币数量,不使用当前 `COIN` 余额;用户充值后消费完仍然可以成为有效邀请。
|
||||
- 如果后续接入退款/拒付事件,需要新增负向充值处理策略;首版只处理正向成功充值。
|
||||
|
||||
## Code Generation
|
||||
|
||||
邀请码生成建议:
|
||||
|
||||
- 使用服务端随机生成,不让客户端提交自己的 code。
|
||||
- 字符集使用 `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`,去掉容易混淆的 `0/O/1/I`。
|
||||
- 首版 8 位即可;如果发生唯一冲突,最多重试 5 次,仍失败返回内部错误并告警。
|
||||
- 不把手机号、邮箱、设备号、短号直接作为邀请码。
|
||||
|
||||
## Caching
|
||||
|
||||
注册量没有明显压力前,邀请码解析直接查 MySQL。
|
||||
|
||||
如果后续加入 Redis:
|
||||
|
||||
| Key | Value | TTL | Rule |
|
||||
| --- | --- | --- | --- |
|
||||
| `invite_code:{app_code}:{code}` | `owner_user_id,status,version` | 5-30 min | 只做加速 |
|
||||
|
||||
Redis miss 或异常时必须回 MySQL 或 fail-closed,不能把不存在当有效。禁用邀请码、封禁用户或换码时必须主动删除缓存;不能只依赖 TTL。
|
||||
|
||||
## Outbox And Rewards
|
||||
|
||||
注册链路只写事实,不同步发奖励。后续奖励通过事件消费:
|
||||
|
||||
- `UserRegistered`
|
||||
- `UserInvited`
|
||||
- `UserInviteBecameValid`
|
||||
|
||||
`UserInvited` payload 至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"app_code": "lalu",
|
||||
"invited_user_id": "20001",
|
||||
"inviter_user_id": "10001",
|
||||
"invite_code": "A1B2C3",
|
||||
"source": "onboarding",
|
||||
"bound_at_ms": 1777996800000
|
||||
}
|
||||
```
|
||||
|
||||
`UserInviteBecameValid` payload 至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"app_code": "lalu",
|
||||
"invited_user_id": "20001",
|
||||
"inviter_user_id": "10001",
|
||||
"policy_id": "9001",
|
||||
"policy_version": "invite-valid-v1",
|
||||
"required_recharge_coin_amount": 80000,
|
||||
"accumulated_recharge_coin_amount": 80000,
|
||||
"valid_transaction_id": "wtx_01",
|
||||
"valid_at_ms": 1777996800000
|
||||
}
|
||||
```
|
||||
|
||||
奖励消费者必须幂等,幂等键使用 `app_code + invited_user_id + reward_type`,不能因为 outbox 重投重复发放。需要按有效邀请发奖励时,只消费 `UserInviteBecameValid`,不要消费原始 `UserInvited`。
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- gateway 对带邀请码的注册请求继续使用 public auth 频控。
|
||||
- user-service 记录 `device_id`、IP、UA、country_by_ip,用于后续识别同设备互邀、批量注册和异常渠道。
|
||||
- 自己邀请自己必须拒绝。
|
||||
- 同设备大量互邀、同 IP 大量注册、邀请人短时间异常增长不阻断注册主链路,但要进入风控统计或后台审核。
|
||||
- 有效邀请达成前必须保留充值事件来源、充值类型和触发交易 ID,便于识别币商刷量、同设备互充、异常退款和人工充值滥用。
|
||||
- 后台可以冻结某个邀请人的新邀请资格或奖励资格,但不能删除历史 `user_invite_relations`。
|
||||
- 邀请码解析失败不要返回邀请人是否存在的细节;统一 `INVALID_INVITE_CODE`。
|
||||
|
||||
## Admin Requirements
|
||||
|
||||
后台需要提供最小管理能力:
|
||||
|
||||
- 查询用户自己的邀请码、邀请人数、有效邀请人数、被邀请用户列表和有效达成明细。
|
||||
- 启用/禁用某个用户的邀请码;禁用只影响新绑定,不删除历史关系。
|
||||
- 配置有效邀请策略:门槛金币数、适用区域、可计入充值类型、生效时间和停用时间。
|
||||
- 查看某个被邀请用户的充值累计进度和触发有效邀请的钱包交易 ID。
|
||||
- 标记异常邀请或撤销奖励资格;撤销奖励资格不能物理删除邀请关系和有效达成记录。
|
||||
- 触发计数重算任务,用于修复 `user_invite_counters` 与事实表不一致。
|
||||
|
||||
所有后台写操作必须经过 `hyapp-admin-server` 鉴权和审计,不能让 App 用户直接调用。
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
1. 增加 `user_invite_codes` 和 `user_invite_relations` 表。
|
||||
2. 增加 `user_invite_validity_policies`、`user_invite_recharge_progress`、`user_invite_event_consumption`、`user_invite_counters` 和 `user_outbox`。
|
||||
3. 后台增加有效邀请策略配置,默认 `required_recharge_coin_amount=80000`。
|
||||
4. user-service 新用户创建时生成自己的邀请码。
|
||||
5. `LoginThirdParty` 首次创建用户时解析可选 `invite_code` 并绑定关系。
|
||||
6. `CompleteOnboarding` 增加可选 `invite_code`,仅在未完成资料且未绑定关系时处理。
|
||||
7. 绑定成功后写 `UserInvited`,并通过 outbox 幂等维护 `invite_count`。
|
||||
8. 消费钱包 `WalletRechargeRecorded` 事件,累计被邀请用户充值金币,达到策略门槛后写 `UserInviteBecameValid` 并维护 `valid_invite_count`。
|
||||
9. `GetUser` 或 `GetUserOverview` 增加当前用户的邀请码、邀请人数、有效邀请人数和当前有效门槛。
|
||||
10. gateway HTTP request/response 增加字段映射。
|
||||
11. 加测试和基础监控。
|
||||
|
||||
## Test Matrix
|
||||
|
||||
| Scenario | Expected |
|
||||
| --- | --- |
|
||||
| 新用户不填邀请码 | 注册成功,无 `user_invite_relations` |
|
||||
| 新用户填有效邀请码 | 注册成功,关系表写入 inviter |
|
||||
| 新用户填不存在邀请码 | 返回 `INVALID_INVITE_CODE`,不创建完成关系 |
|
||||
| 邀请码跨 App | 返回 `INVALID_INVITE_CODE` |
|
||||
| 邀请人 disabled/banned | 返回 `INVALID_INVITE_CODE` |
|
||||
| 用户尝试填自己的邀请码 | 返回 `INVALID_INVITE_CODE` |
|
||||
| 已绑定关系后再次提交邀请码 | 原关系不变 |
|
||||
| 已完成注册后提交邀请码 | 不修改原关系,不补填邀请人 |
|
||||
| 三方身份再次登录带邀请码 | 登录成功但不修改邀请关系 |
|
||||
| 并发提交两个邀请码 | 只有一条关系成功,另一条命中幂等或冲突 |
|
||||
| 邀请码大小写/空格 | trim + uppercase 后解析 |
|
||||
| 被邀请用户累计充值 79999 COIN | `invite_count` 已增加,`valid_invite_count` 不增加 |
|
||||
| 被邀请用户累计充值达到 80000 COIN | 首次标记 `user_invite_recharge_progress.status=valid`,`valid_invite_count` 增加 1 |
|
||||
| 被邀请用户一次充值超过门槛 | 只产生 1 个有效邀请 |
|
||||
| 被邀请用户多次充值累计达到门槛 | 达标那笔交易触发有效邀请 |
|
||||
| 同一充值事件重复消费 | 累计金额和有效邀请计数不重复增加 |
|
||||
| 充值发生在绑定邀请码之前 | 默认不补算有效邀请,除非后台重算任务明确处理 |
|
||||
| 后台把有效邀请门槛改为 100000 | 已经 valid 的历史事实不重算,新达成用户按新策略判断 |
|
||||
| 我的页 overview | 返回当前用户自己的邀请码、`invite_count`、`valid_invite_count`,不实时扫关系或充值流水 |
|
||||
@ -155,6 +155,14 @@ Auth 专用 reason:
|
||||
| `SESSION_EXPIRED` | `Unauthenticated` | `401` |
|
||||
| `SESSION_REVOKED` | `Unauthenticated` | `401` |
|
||||
|
||||
User 专用 reason:
|
||||
|
||||
| Reason | gRPC Code | HTTP Mapping |
|
||||
| --- | --- | --- |
|
||||
| `INVALID_INVITE_CODE` | `InvalidArgument` | `400` |
|
||||
| `COUNTRY_CHANGE_COOLDOWN` | `FailedPrecondition` | `409` |
|
||||
| `USER_PROFILE_REQUIRED` | `FailedPrecondition` | `403` |
|
||||
|
||||
Wallet 专用 reason:
|
||||
|
||||
| Reason | gRPC Code | HTTP Mapping |
|
||||
@ -267,22 +275,43 @@ internal/transport/grpc
|
||||
```text
|
||||
apps
|
||||
users
|
||||
user_invite_codes
|
||||
user_invite_relations
|
||||
user_invite_validity_policies
|
||||
user_invite_recharge_progress
|
||||
user_invite_event_consumption
|
||||
user_invite_counters
|
||||
password_accounts
|
||||
third_party_identities
|
||||
auth_sessions
|
||||
login_audit
|
||||
user_country_change_logs
|
||||
user_outbox
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- `apps` 是 App 注册表,保存 `package_name/platform -> app_code` 映射,由 gateway 解析入口使用。
|
||||
- `users` 是用户主记录,包含注册资料和注册来源快照;不保存 credential、密码明文或 refresh token 原文。
|
||||
- `user_invite_codes` 保存用户自己的可分享邀请码,按 `app_code + code` 唯一。
|
||||
- `user_invite_relations` 保存注册邀请关系事实,按 `app_code + invited_user_id` 保证一个用户最多一个邀请人。
|
||||
- `user_invite_validity_policies` 保存有效邀请策略,默认累计充值 `80000` `COIN`,但必须允许后台按 App/区域配置。
|
||||
- `user_invite_recharge_progress` 保存被邀请用户累计充值进度和是否已成为有效邀请。
|
||||
- `user_invite_event_consumption` 保存钱包充值事件消费幂等,避免 wallet outbox 重投导致重复累计。
|
||||
- `user_invite_counters` 保存我的页和后台列表使用的邀请计数 read model,包含 `invite_count` 和 `valid_invite_count`。
|
||||
- `password_accounts` 保存已登录用户手动设置后的密码 hash,不承载独立账号体系。
|
||||
- `third_party_identities` 保存 provider 绑定。
|
||||
- `auth_sessions` 保存 refresh token hash 和会话状态。
|
||||
- `login_audit` 不影响主链路成功与否。
|
||||
- `user_country_change_logs` 保存国家修改审计,并作为滚动 30 天冷却判断来源。
|
||||
- `user_outbox` 保存 `UserRegistered`、`UserInvited`、`UserInviteBecameValid` 等用户领域事件,供奖励、活动、消息和风控异步消费。
|
||||
|
||||
邀请统计规则:
|
||||
|
||||
- `invite_count` 表示成功填写邀请码并绑定关系的人数,来源是 `user_invite_relations`。
|
||||
- `valid_invite_count` 表示被邀请用户累计成功充值金币达到有效邀请策略门槛的人数,默认门槛是 `80000` `COIN`。
|
||||
- 有效邀请只消费 `wallet-service` 的充值事实事件;钱包不理解邀请关系,user-service invite module 不读取钱包余额作为判断依据。
|
||||
- 后台修改有效邀请门槛只影响后续达成判断;已达成的有效邀请事实不自动重算。
|
||||
|
||||
### Readiness
|
||||
|
||||
|
||||
424
docs/user-mic-online-time-architecture.md
Normal file
424
docs/user-mic-online-time-architecture.md
Normal file
@ -0,0 +1,424 @@
|
||||
# User Mic Online Time Architecture
|
||||
|
||||
本文定义“用户麦上在线时长”的统一统计架构。这个指标属于所有用户,不属于主播专属体系。用户无论当时是不是主播,只要在麦上产生了有效在线时长,都应该累计到用户维度;后续用户成为主播、参与任务、参与活动或参与成长体系时,可以直接读取历史累计结果。
|
||||
|
||||
## Core Decision
|
||||
|
||||
麦位时间先按用户维度沉淀,再被主播、任务、活动和工资等业务读取。
|
||||
|
||||
不能把麦位时间只写进 `host_daily_stats`,也不能在统计事件时判断“这个用户当时是不是主播”。主播身份是后置业务条件,不是麦位在线事实本身。
|
||||
|
||||
示例:
|
||||
|
||||
- 用户 A 不是主播时累计麦上在线 10 小时。
|
||||
- 后来用户 A 申请并成为主播。
|
||||
- 如果任务规则是“主播累计麦上在线达到 10 小时”,该任务可以直接使用用户 A 已有的 10 小时完成。
|
||||
|
||||
## Goals
|
||||
|
||||
- 所有用户共享同一套麦上在线时长事实。
|
||||
- 支持日统计、周期统计、 lifetime 累计和任务进度读取。
|
||||
- 支持用户成为主播前的历史时长被后续主播任务使用。
|
||||
- 统计来源必须可恢复、可重放、可幂等。
|
||||
- Redis 只能做缓存,不能作为时长事实来源。
|
||||
- 后续主播工资如需“只算成为主播后的有效工作时长”,必须在派生层另行按政策过滤,不能污染基础用户时长。
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 不在 room-service 命令链路里同步计算任务进度。
|
||||
- 不在上麦接口里同步判断主播、Agency、BD 或任务状态。
|
||||
- 不把当前麦位状态当成历史统计。
|
||||
- 不用客户端上报时长作为权威事实。
|
||||
- 不把单个任务进度直接写进麦位会话表。
|
||||
|
||||
## Terminology
|
||||
|
||||
| Term | Meaning |
|
||||
| --- | --- |
|
||||
| 麦位占用时长 | 从 `MicUp(action=up)` 到 `down/leave/kick/close` 的时间,包含 `pending_publish` |
|
||||
| 麦上在线时长 | 用户真实完成发流确认后的麦上时间,默认从 `publish_state=publishing` 开始算 |
|
||||
| `mic_session_id` | 单次上麦会话 ID,同一用户多次上下麦会产生多条 session |
|
||||
| 基础用户时长 | 不区分用户身份、房间、Agency、BD 的用户级时长事实 |
|
||||
| 派生业务时长 | 主播工资、活动任务等业务按自己的政策从基础用户时长或 session 明细派生 |
|
||||
|
||||
## Which Time Counts
|
||||
|
||||
首版建议同时保存两个值,但默认任务使用 `mic_online_ms`:
|
||||
|
||||
| Metric | Start | End | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| `seat_occupied_ms` | `RoomMicChanged.action=up` | `down/leave/kick/close` | 产品想看“占麦多久”时使用 |
|
||||
| `mic_online_ms` | `RoomMicChanged.action=publish_confirmed` | `down/leave/kick/close` | 任务、成长、有效麦上在线默认使用 |
|
||||
|
||||
原因:
|
||||
|
||||
- `MicUp` 只表示业务占麦成功,用户可能没有切 RTC anchor、没有打开麦克风、网络失败或马上超时。
|
||||
- `publish_confirmed` 表示服务端已经接受客户端或 RTC webhook 的发流确认,更接近“用户真的在麦上在线”。
|
||||
- 如果后续有运营活动明确要求“只要占麦就算”,可以读 `seat_occupied_ms`,不需要改历史表。
|
||||
|
||||
## Source Events
|
||||
|
||||
权威来源是 `room-service` 的 `room_outbox`:
|
||||
|
||||
| Event | Action | Effect |
|
||||
| --- | --- | --- |
|
||||
| `RoomMicChanged` | `up` | 创建或开启一条用户麦位 session,记录 `seat_started_at_ms` |
|
||||
| `RoomMicChanged` | `publish_confirmed` | 设置 `publishing_started_at_ms`,开始累计 `mic_online_ms` |
|
||||
| `RoomMicChanged` | `change` | 不结束 session,只更新当前 seat;时长连续 |
|
||||
| `RoomMicChanged` | `down` | 结束 session,结算 `seat_occupied_ms` 和 `mic_online_ms` |
|
||||
|
||||
`LeaveRoom`、`KickUser`、`CloseRoom`、RTC `audio_stopped/room_exited` 和 `publish_timeout` 都必须在释放麦位时产生 `RoomMicChanged(action=down)`。user-service mic time consumer 只消费这个统一麦位事实,不从 `RoomUserLeft`、`RoomUserKicked` 或 `RoomClosed` 反推关闭。
|
||||
|
||||
当前释放原因约定:
|
||||
|
||||
| Release Path | `RoomMicChanged.reason` |
|
||||
| --- | --- |
|
||||
| 主动离房 | `leave` |
|
||||
| presence stale 清理 | 调用方传入的 stale reason,例如 `presence_stale` |
|
||||
| 踢人 | `kick` |
|
||||
| 关房 | `room_closed` |
|
||||
| pending publish 超时 | `publish_timeout` |
|
||||
| RTC 停止/退房 | gateway/RTC 事件传入的 reason |
|
||||
|
||||
当前 `RoomMicChanged` 已经包含 `mic_session_id`、`publish_state`、`reason`、`publish_deadline_ms` 和 `publish_event_time_ms`。用户麦时长消费者必须优先使用这些字段,不能用 `room_id + user_id` 自行拼会话 ID。
|
||||
|
||||
事件兼容规则:
|
||||
|
||||
- `mic_session_id` 为空的 `up/publish_confirmed/down` 事件不能进入正常累计;当前实现写入 `user_mic_event_consumption` 并标记 `skipped/incomplete_mic_event`。
|
||||
- `publish_confirmed` 必须只推进当前 open session;如果 session 已结束或事件时间早于已接受时间,按幂等或过期事件处理。
|
||||
- `change` 事件没有时长增量,只更新 seat 快照和 `last_event_id`。
|
||||
- `down` 的结束时间优先取 envelope `occurred_at_ms`;如果后续事件体提供更精确 RTC stop time,再使用事件体时间。
|
||||
- 消费者只消费 `room_outbox` 事实,不订阅腾讯 RTC webhook 原始事件,避免绕过 Room Cell 状态机。
|
||||
|
||||
## Service Boundary
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Room["room-service\nRoom Cell"] --> Outbox[("room_outbox")]
|
||||
Outbox --> Consumer["user-service\nmic time consumer"]
|
||||
Consumer --> UserMicDB[("hyapp_user\nuser_mic_* tables")]
|
||||
UserMicDB --> Task["activity/task service"]
|
||||
UserMicDB --> Host["host/agency/bd module"]
|
||||
UserMicDB --> Admin["admin salary/risk"]
|
||||
```
|
||||
|
||||
首版建议把用户麦上时长放在 `user-service`,原因:
|
||||
|
||||
- 统计主维度是 `user_id`,不是房间。
|
||||
- 任务、主播身份、Agency 和用户资料都需要读取用户侧统计。
|
||||
- `room-service` 应继续只拥有房间实时状态、快照、command log 和 outbox,不承载跨天统计。
|
||||
|
||||
如果后续活动系统独立承接所有任务,也可以由 `activity-service` 消费 room outbox,但基础事实表仍应按用户维度建模,并通过内部 RPC 提供给 host/admin 使用。
|
||||
|
||||
## Data Model
|
||||
|
||||
### `user_mic_sessions`
|
||||
|
||||
保存每一次上麦会话。它是可审计明细,不直接服务首页大查询。
|
||||
|
||||
```sql
|
||||
user_mic_sessions(
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
mic_session_id VARCHAR(128) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
first_seat_no INT NOT NULL DEFAULT 0,
|
||||
current_seat_no INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
seat_started_at_ms BIGINT NOT NULL,
|
||||
publishing_started_at_ms BIGINT NULL,
|
||||
ended_at_ms BIGINT NULL,
|
||||
end_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
seat_occupied_ms BIGINT NOT NULL DEFAULT 0,
|
||||
mic_online_ms BIGINT NOT NULL DEFAULT 0,
|
||||
opened_event_id VARCHAR(128) NOT NULL,
|
||||
publish_event_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
closed_event_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
last_event_id VARCHAR(128) NOT NULL,
|
||||
last_room_version BIGINT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, mic_session_id),
|
||||
KEY idx_user_mic_sessions_user_status (app_code, user_id, status, updated_at_ms),
|
||||
KEY idx_user_mic_sessions_room_status (app_code, room_id, status),
|
||||
KEY idx_user_mic_sessions_open_timeout (app_code, status, seat_started_at_ms)
|
||||
);
|
||||
```
|
||||
|
||||
`status`:
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `pending_publish` | 已占麦,尚未确认发流 |
|
||||
| `publishing` | 已确认发流,正在累计在线时长 |
|
||||
| `ended` | 会话已结束 |
|
||||
| `abnormal_closed` | 预留给后续补偿任务;当前基础消费者只写 `ended` |
|
||||
|
||||
### `user_mic_daily_stats`
|
||||
|
||||
按当前实现的 UTC 日期聚合,服务任务、活动和个人中心二级页。
|
||||
|
||||
```sql
|
||||
user_mic_daily_stats(
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
stat_date CHAR(10) NOT NULL,
|
||||
seat_occupied_ms BIGINT NOT NULL DEFAULT 0,
|
||||
mic_online_ms BIGINT NOT NULL DEFAULT 0,
|
||||
session_count BIGINT NOT NULL DEFAULT 0,
|
||||
room_count BIGINT NOT NULL DEFAULT 0,
|
||||
first_mic_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_mic_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, user_id, stat_date),
|
||||
KEY idx_user_mic_daily_date (app_code, stat_date, mic_online_ms)
|
||||
);
|
||||
```
|
||||
|
||||
当前实现以 UTC 日期字符串切分 `stat_date`。跨天 session 必须按 UTC 日切分。例如 23:30 到 01:10,要拆成两天分别累计。后续如果产品需要按 App 运营时区切分,需要先新增明确时区配置和重算策略,不能在代码里隐式依赖机器本地时区。
|
||||
|
||||
### `user_mic_lifetime_stats`
|
||||
|
||||
保存用户累计值,避免任务系统每次扫 daily 表。
|
||||
|
||||
```sql
|
||||
user_mic_lifetime_stats(
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
seat_occupied_ms BIGINT NOT NULL DEFAULT 0,
|
||||
mic_online_ms BIGINT NOT NULL DEFAULT 0,
|
||||
session_count BIGINT NOT NULL DEFAULT 0,
|
||||
first_mic_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_mic_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
任务判断“累计麦上在线 10 小时”时优先读 `user_mic_lifetime_stats.mic_online_ms`。
|
||||
|
||||
### `user_mic_event_consumption`
|
||||
|
||||
消费者幂等和跳过原因表。所有 room mic 事件先写入这张表,再推进 session/aggregate;重复 `event_id` 直接跳过,避免 `down` 重放重复累计。
|
||||
|
||||
```sql
|
||||
user_mic_event_consumption(
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
mic_session_id VARCHAR(128) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
skip_reason VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
consumed_at_ms BIGINT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
KEY idx_user_mic_event_session (app_code, mic_session_id, created_at_ms),
|
||||
KEY idx_user_mic_event_user (app_code, user_id, created_at_ms)
|
||||
);
|
||||
```
|
||||
|
||||
## Event Processing Rules
|
||||
|
||||
### `up`
|
||||
|
||||
处理 `RoomMicChanged(action=up)`:
|
||||
|
||||
1. 用 `app_code + event_id` 做幂等。
|
||||
2. 用 `app_code + mic_session_id` upsert `user_mic_sessions`。
|
||||
3. 设置 `status=pending_publish`。
|
||||
4. 设置 `seat_started_at_ms = envelope.occurred_at_ms`。
|
||||
5. 不增加 `mic_online_ms`。
|
||||
|
||||
### `publish_confirmed`
|
||||
|
||||
处理 `RoomMicChanged(action=publish_confirmed)`:
|
||||
|
||||
1. 找到 `mic_session_id` 对应 session。
|
||||
2. 如果 session 已经是 `publishing/ended`,按幂等处理。
|
||||
3. 设置 `publishing_started_at_ms`。
|
||||
4. 设置 `status=publishing`。
|
||||
5. 不立即增加 daily/lifetime,避免 open session 周期性重复累计。
|
||||
|
||||
`publishing_started_at_ms` 建议使用事件里的 `publish_event_time_ms`;如果为空,才使用 envelope 的 `occurred_at_ms`。
|
||||
|
||||
### `change`
|
||||
|
||||
处理 `RoomMicChanged(action=change)`:
|
||||
|
||||
- 不结束 session。
|
||||
- 更新 `current_seat_no`。
|
||||
- 不切断 `mic_online_ms`。
|
||||
|
||||
换麦是同一次上麦会话里的位置变化,不应把时长拆断。
|
||||
|
||||
### `down`
|
||||
|
||||
处理 `RoomMicChanged(action=down)`:
|
||||
|
||||
1. 找到 `mic_session_id` 对应 session。
|
||||
2. 设置 `ended_at_ms` 和 `end_reason`。
|
||||
3. 计算 `seat_occupied_ms = ended_at_ms - seat_started_at_ms`。
|
||||
4. 如果已 `publishing`,计算 `mic_online_ms = ended_at_ms - publishing_started_at_ms`。
|
||||
5. 按天切分写入 `user_mic_daily_stats`。
|
||||
6. 增量写入 `user_mic_lifetime_stats`。
|
||||
7. 设置 session `status=ended`。
|
||||
|
||||
如果没有 `publish_confirmed` 就下麦,`mic_online_ms=0`,但 `seat_occupied_ms` 仍可记录。
|
||||
|
||||
## Open Session Compensation
|
||||
|
||||
需要一个补偿 worker 处理异常 open session:
|
||||
|
||||
| Case | Action |
|
||||
| --- | --- |
|
||||
| `pending_publish` 超过 room-service publish timeout | 等 room-service `publish_timeout` down 事件;若长期没有事件,按异常关闭 |
|
||||
| `publishing` 长时间没有 down,但用户 presence 已离开 | 使用 presence/room close 事实补偿关闭 |
|
||||
| 房间关闭但仍有 open session | 以 `RoomClosed.occurred_at_ms` 关闭 |
|
||||
| 消费者停机后恢复 | 从 `room_outbox` 或 MQ 位点继续消费;必要时用未结束 session 扫描补偿 |
|
||||
|
||||
补偿关闭必须写 `end_reason=compensated_*`,不能伪装成用户主动下麦。
|
||||
|
||||
补偿必须设置最大可累计窗口,避免房间事件缺失时把异常 open session 算成超长在线。首版建议按房间关闭时间、presence 离开时间或 `publish_deadline_ms` 截断;仍无法确认时只关闭 session 并告警,不增加 `mic_online_ms`。
|
||||
|
||||
## Derived Events And Consumers
|
||||
|
||||
用户麦时长消费者写入 MySQL 后,可以通过 user-service outbox 产出派生事件,供 host stats、任务、活动和 BI 消费。派生事件不能替代基础事实表,消费者失败不影响 `user_mic_sessions`、daily 和 lifetime 事实。
|
||||
|
||||
| Event | Trigger | Consumer |
|
||||
| --- | --- | --- |
|
||||
| `UserMicSessionClosed` | 一条 session 正常或补偿结束 | host stats、activity task、BI |
|
||||
| `UserMicDailyStatsUpdated` | daily stats 有增量 | task progress、growth system |
|
||||
| `UserMicLifetimeStatsUpdated` | lifetime stats 有增量 | lifetime task cache |
|
||||
|
||||
host/Agency/BD 统计应该优先消费 `UserMicSessionClosed` 或读取 user mic facts 派生关系快照,不应该再直接消费 `RoomMicChanged` 重算同一套用户时长。
|
||||
|
||||
## Task Usage
|
||||
|
||||
任务系统不要自己消费房间事件来算麦位时间,应该读取用户麦上时长事实。
|
||||
|
||||
示例任务:
|
||||
|
||||
```text
|
||||
task: become_host_online_10h
|
||||
condition:
|
||||
user must currently be host
|
||||
user_mic_lifetime_stats.mic_online_ms >= 10 * 60 * 60 * 1000
|
||||
```
|
||||
|
||||
注意:任务判断时校验“当前是否主播”,但时长使用用户历史累计,不要求这些时长发生在成为主播之后。
|
||||
|
||||
如果某个任务明确要求“成为主播之后再累计 10 小时”,应该新增任务条件:
|
||||
|
||||
```text
|
||||
sum user_mic_daily_stats where stat_date/time >= host_profile.first_became_host_at_ms
|
||||
```
|
||||
|
||||
这属于任务政策,不属于基础统计口径。
|
||||
|
||||
## Relationship To Host Salary
|
||||
|
||||
用户麦上在线时长是基础事实;主播工资是派生事实。
|
||||
|
||||
| Use Case | Recommended Source |
|
||||
| --- | --- |
|
||||
| 用户成长任务 | `user_mic_lifetime_stats` |
|
||||
| 主播入门任务 | `user_mic_lifetime_stats` + 当前 host 身份 |
|
||||
| 主播周期任务 | `user_mic_daily_stats` 按周期汇总 |
|
||||
| 主播工资 | 从 `user_mic_sessions` 或 `user_mic_daily_stats` 派生,并按工资政策决定是否只算 host 生效后的时间 |
|
||||
| Agency/BD 归属工资 | 需要额外关系快照,不直接用基础用户累计 |
|
||||
|
||||
因此后续 `host_daily_stats` 不应该是唯一时长来源。它可以从 `user_mic_sessions` 派生,并附带 Agency/BD 关系快照,用于工资和团队统计。
|
||||
|
||||
## API Suggestions
|
||||
|
||||
### Internal Query
|
||||
|
||||
```protobuf
|
||||
message GetUserMicLifetimeStatsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
}
|
||||
|
||||
message UserMicLifetimeStats {
|
||||
int64 user_id = 1;
|
||||
int64 seat_occupied_ms = 2;
|
||||
int64 mic_online_ms = 3;
|
||||
int64 session_count = 4;
|
||||
int64 first_mic_at_ms = 5;
|
||||
int64 last_mic_at_ms = 6;
|
||||
int64 updated_at_ms = 7;
|
||||
string app_code = 8;
|
||||
}
|
||||
|
||||
message GetUserMicLifetimeStatsResponse {
|
||||
UserMicLifetimeStats stats = 1;
|
||||
}
|
||||
```
|
||||
|
||||
当前 proto 已在 `UserService` 暴露 `GetUserMicLifetimeStats(user_id)`。区间查询后续再加,不把未稳定的 range 语义提前暴露给客户端或任务系统。
|
||||
|
||||
### App Query
|
||||
|
||||
个人页首屏不要返回麦位明细。二级页可以提供:
|
||||
|
||||
```http
|
||||
GET /api/v1/users/me/mic-stats?range=lifetime
|
||||
GET /api/v1/users/me/mic-stats?from_ms=...&to_ms=...
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"seat_occupied_ms": 39600000,
|
||||
"mic_online_ms": 36000000,
|
||||
"session_count": 18,
|
||||
"first_mic_at_ms": 1777000000000,
|
||||
"last_mic_at_ms": 1777996800000
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
1. 已增加 `user_mic_sessions`、`user_mic_daily_stats`、`user_mic_lifetime_stats`、`user_mic_event_consumption`。
|
||||
2. 已调整 room-service:释放麦位路径统一产出 `RoomMicChanged(action=down)`,包括 leave、kick、close、RTC stopped、publish timeout。
|
||||
3. 已增加 user-service mic time consumer,消费 `RoomMicChanged`。
|
||||
4. 已实现 session 状态机:`up -> publish_confirmed -> down`,支持 `change` 不切断。
|
||||
5. 已实现 daily/lifetime 聚合,跨 UTC 日切分。
|
||||
6. 已增加内部查询 RPC `GetUserMicLifetimeStats`,供任务、主播、后台读取 lifetime 聚合。
|
||||
7. 下一步增加补偿 worker,关闭异常 open session。
|
||||
8. 下一步增加 user mic outbox,产出 `UserMicSessionClosed`、`UserMicDailyStatsUpdated`、`UserMicLifetimeStatsUpdated`。
|
||||
9. 任务系统接入 `user_mic_lifetime_stats`,不要重复消费 room 事件。
|
||||
10. host stats 从 user mic facts 或 user mic outbox 派生,不直接重复处理 Room Cell 麦位事件。
|
||||
|
||||
## Verification Matrix
|
||||
|
||||
| Scenario | Expected |
|
||||
| --- | --- |
|
||||
| 普通用户上麦并确认发流 10 分钟后下麦 | `mic_online_ms` 增加 10 分钟 |
|
||||
| 普通用户只占麦但未发流,超时下麦 | `seat_occupied_ms` 有值,`mic_online_ms=0` |
|
||||
| 用户换麦位 | session 不结束,时长连续 |
|
||||
| 用户离房释放麦位 | session 结束并累计到 daily/lifetime |
|
||||
| 用户被踢释放麦位 | session 结束,`end_reason=kick` |
|
||||
| 房间关闭 | 所有 open session 结束 |
|
||||
| 同一事件重复消费 | 不重复累计 |
|
||||
| 用户跨天上麦 | daily stats 拆成两天 |
|
||||
| 用户不是主播时累计 10 小时,后续成为主播 | 读取 lifetime 后主播任务可直接完成 |
|
||||
| 工资只要求 host 期间时长 | 工资派生层按 host 生效时间过滤,不改基础累计 |
|
||||
| `mic_session_id` 为空的麦位事件 | 不累计,进入异常队列或告警 |
|
||||
| host stats 消费用户麦时长派生事件 | 不重复累计 room outbox 同一事件 |
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- 基础麦位时长按用户累计,不按主播身份累计。
|
||||
- 任务判断可以检查当前身份,但不要把历史时长清零。
|
||||
- `pending_publish` 默认不算 `mic_online_ms`。
|
||||
- `change` 不结束 session。
|
||||
- Redis 不能作为时长事实来源。
|
||||
- 客户端上报时长不能作为权威,只能作为辅助诊断。
|
||||
- 所有统计写入必须带 `app_code`。
|
||||
- `mic_session_id` 是会话幂等和新旧事件隔离的核心字段,不能用用户当前是否在麦位替代。
|
||||
- host stats、任务和活动读取用户麦时长事实,不应各自重复消费 room 麦位事件。
|
||||
@ -67,6 +67,8 @@ const (
|
||||
RegionCountryConflict Code = "REGION_COUNTRY_CONFLICT"
|
||||
// RegionDisabled 表示区域已经停用,不能继续配置国家。
|
||||
RegionDisabled Code = "REGION_DISABLED"
|
||||
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
|
||||
InvalidInviteCode Code = "INVALID_INVITE_CODE"
|
||||
|
||||
// InsufficientBalance 表示钱包余额不足。
|
||||
InsufficientBalance Code = "INSUFFICIENT_BALANCE"
|
||||
|
||||
@ -14,7 +14,7 @@ const errorDomain = "hyapp"
|
||||
// gateway 后续只需要读取 gRPC code 和 ErrorInfo.reason 即可生成 HTTP envelope。
|
||||
func GRPCCode(code Code) codes.Code {
|
||||
switch code {
|
||||
case InvalidArgument, DisplayUserIDInvalid, InvalidSection, PageTokenInvalid:
|
||||
case InvalidArgument, DisplayUserIDInvalid, InvalidInviteCode, InvalidSection, PageTokenInvalid:
|
||||
return codes.InvalidArgument
|
||||
case NotFound, DisplayUserIDNotFound, CountryNotFound, RegionNotFound, MessageNotFound, MessageRecalled:
|
||||
return codes.NotFound
|
||||
|
||||
@ -21,6 +21,7 @@ var overviewAssetTypes = []string{"COIN", "DIAMOND", "USD_BALANCE"}
|
||||
type myOverviewData struct {
|
||||
Profile userProfileData `json:"profile"`
|
||||
Wallet overviewWalletData `json:"wallet"`
|
||||
Invite overviewInviteData `json:"invite"`
|
||||
Roles overviewRolesData `json:"roles"`
|
||||
Badges overviewBadgesData `json:"badges"`
|
||||
Entries []overviewEntryData `json:"entries"`
|
||||
@ -44,6 +45,14 @@ type overviewRolesData struct {
|
||||
Unavailable bool `json:"unavailable,omitempty"`
|
||||
}
|
||||
|
||||
type overviewInviteData struct {
|
||||
MyInviteCode string `json:"my_invite_code"`
|
||||
InviteEnabled bool `json:"invite_enabled"`
|
||||
InviteCount int64 `json:"invite_count"`
|
||||
ValidInviteCount int64 `json:"valid_invite_count"`
|
||||
ValidInviteThresholdCoin int64 `json:"valid_invite_threshold_coin"`
|
||||
}
|
||||
|
||||
type overviewBadgesData struct {
|
||||
UnreadSystemMessages int64 `json:"unread_system_messages"`
|
||||
UnreadActivityMessages int64 `json:"unread_activity_messages"`
|
||||
@ -141,6 +150,7 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques
|
||||
writeOK(writer, request, myOverviewData{
|
||||
Profile: profileData(profileResp.GetUser(), 0),
|
||||
Wallet: wallet,
|
||||
Invite: overviewInvite(profileResp.GetUser().GetInvite()),
|
||||
Roles: roles,
|
||||
Badges: badges,
|
||||
Entries: overviewEntries(roles),
|
||||
@ -148,6 +158,19 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques
|
||||
})
|
||||
}
|
||||
|
||||
func overviewInvite(invite *userv1.InviteOverview) overviewInviteData {
|
||||
if invite == nil {
|
||||
return overviewInviteData{}
|
||||
}
|
||||
return overviewInviteData{
|
||||
MyInviteCode: invite.GetMyInviteCode(),
|
||||
InviteEnabled: invite.GetInviteEnabled(),
|
||||
InviteCount: invite.GetInviteCount(),
|
||||
ValidInviteCount: invite.GetValidInviteCount(),
|
||||
ValidInviteThresholdCoin: invite.GetValidInviteThresholdCoin(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) getOverviewRoles(ctx context.Context, request *http.Request, userID int64) (overviewRolesData, error) {
|
||||
if h.userHostClient == nil {
|
||||
return overviewRolesData{}, xerr.New(xerr.Unavailable, "user host service is not configured")
|
||||
|
||||
@ -71,6 +71,10 @@ func TestGetMyOverviewComposesProfileWalletRolesBadgesAndEntries(t *testing.T) {
|
||||
if wallet["recharge_enabled"] != true || wallet["diamond_exchange_enabled"] != true || wallet["withdraw_enabled"] != true {
|
||||
t.Fatalf("wallet feature flags mismatch: %+v", wallet)
|
||||
}
|
||||
invite := data["invite"].(map[string]any)
|
||||
if invite["my_invite_code"] != "A1B2C3" || invite["invite_enabled"] != true || invite["invite_count"] != float64(12) || invite["valid_invite_count"] != float64(3) || invite["valid_invite_threshold_coin"] != float64(80000) {
|
||||
t.Fatalf("invite overview mismatch: %+v", invite)
|
||||
}
|
||||
balances := wallet["balances"].([]any)
|
||||
if len(balances) != 3 {
|
||||
t.Fatalf("overview must return three fixed balances, got %+v", balances)
|
||||
|
||||
@ -105,7 +105,7 @@ func writeRPCError(writer http.ResponseWriter, request *http.Request, err error)
|
||||
|
||||
func mapReasonToHTTP(reason xerr.Code) (int, string, string) {
|
||||
switch reason {
|
||||
case xerr.InvalidArgument, xerr.DisplayUserIDInvalid, xerr.InvalidSection, xerr.PageTokenInvalid:
|
||||
case xerr.InvalidArgument, xerr.DisplayUserIDInvalid, xerr.InvalidInviteCode, xerr.InvalidSection, xerr.PageTokenInvalid:
|
||||
return http.StatusBadRequest, string(reason), "invalid argument"
|
||||
case xerr.AuthFailed:
|
||||
return http.StatusUnauthorized, string(reason), "authentication failed"
|
||||
|
||||
@ -313,6 +313,13 @@ func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRe
|
||||
RegionId: regionID,
|
||||
ProfileCompleted: true,
|
||||
OnboardingStatus: "completed",
|
||||
Invite: &userv1.InviteOverview{
|
||||
MyInviteCode: "A1B2C3",
|
||||
InviteEnabled: true,
|
||||
InviteCount: 12,
|
||||
ValidInviteCount: 3,
|
||||
ValidInviteThresholdCoin: 80000,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
@ -359,6 +366,10 @@ func (f *fakeUserProfileClient) CompleteOnboarding(_ context.Context, req *userv
|
||||
DisplayUserId: "100001",
|
||||
ProfileCompleted: true,
|
||||
OnboardingStatus: "completed",
|
||||
}, Invite: &userv1.InviteBinding{
|
||||
Bound: req.GetInviteCode() != "",
|
||||
InviteCode: req.GetInviteCode(),
|
||||
InviterUserId: 10001,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
@ -1960,7 +1971,7 @@ func TestChangeCountryUsesAuthenticatedUserIDAndMapsCooldown(t *testing.T) {
|
||||
func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *testing.T) {
|
||||
profileClient := &fakeUserProfileClient{}
|
||||
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"male","country":"SG"}`)
|
||||
body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"male","country":"SG","invite_code":"A1B2C3"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/onboarding/complete", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false))
|
||||
request.Header.Set("X-Request-ID", "req-onboarding")
|
||||
@ -1974,7 +1985,7 @@ func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *
|
||||
if profileClient.lastComplete == nil || profileClient.lastComplete.GetUserId() != 42 {
|
||||
t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastComplete)
|
||||
}
|
||||
if profileClient.lastComplete.GetMeta().GetRequestId() != "req-onboarding" || profileClient.lastComplete.GetUsername() != "hy" || profileClient.lastComplete.GetGender() != "male" || profileClient.lastComplete.GetCountry() != "SG" {
|
||||
if profileClient.lastComplete.GetMeta().GetRequestId() != "req-onboarding" || profileClient.lastComplete.GetUsername() != "hy" || profileClient.lastComplete.GetGender() != "male" || profileClient.lastComplete.GetCountry() != "SG" || profileClient.lastComplete.GetInviteCode() != "A1B2C3" {
|
||||
t.Fatalf("complete onboarding request mismatch: %+v", profileClient.lastComplete)
|
||||
}
|
||||
if profileClient.lastComplete.GetMeta().GetSessionId() != "sess-test" {
|
||||
@ -1993,6 +2004,10 @@ func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *
|
||||
if !ok || token["access_token"] != "access-completed" || token["profile_completed"] != true {
|
||||
t.Fatalf("complete onboarding must return replacement access token: %+v", data["token"])
|
||||
}
|
||||
invite, ok := data["invite"].(map[string]any)
|
||||
if !ok || invite["bound"] != true || invite["invite_code"] != "A1B2C3" || invite["inviter_user_id"] != "10001" {
|
||||
t.Fatalf("complete onboarding invite response mismatch: %+v", data["invite"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileMutationEndpointsRequireCompletedProfileToken(t *testing.T) {
|
||||
|
||||
@ -55,7 +55,14 @@ type userProfileData struct {
|
||||
// token.refresh_token 首版为空,客户端继续持有登录时的 refresh token,仅替换 access_token。
|
||||
type completeOnboardingData struct {
|
||||
userProfileData
|
||||
Token authTokenData `json:"token"`
|
||||
Invite *inviteBindingData `json:"invite,omitempty"`
|
||||
Token authTokenData `json:"token"`
|
||||
}
|
||||
|
||||
type inviteBindingData struct {
|
||||
Bound bool `json:"bound"`
|
||||
InviteCode string `json:"invite_code,omitempty"`
|
||||
InviterUserID string `json:"inviter_user_id,omitempty"`
|
||||
}
|
||||
|
||||
// countryData 是注册页国家列表的公开响应投影。
|
||||
@ -139,10 +146,11 @@ func (h *Handler) resolveDisplayUserID(writer http.ResponseWriter, request *http
|
||||
// gateway 只负责从 access token 取 user_id,资料校验和国家/区域解析必须由 user-service 同事务完成。
|
||||
func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
Gender string `json:"gender"`
|
||||
Country string `json:"country"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
Gender string `json:"gender"`
|
||||
Country string `json:"country"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
}
|
||||
if !decode(writer, request, &body) {
|
||||
return
|
||||
@ -153,12 +161,13 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http
|
||||
}
|
||||
|
||||
resp, err := h.userProfileClient.CompleteOnboarding(request.Context(), &userv1.CompleteOnboardingRequest{
|
||||
Meta: authRequestMeta(request, ""),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
Username: body.Username,
|
||||
Avatar: body.Avatar,
|
||||
Gender: body.Gender,
|
||||
Country: body.Country,
|
||||
Meta: authRequestMeta(request, ""),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
Username: body.Username,
|
||||
Avatar: body.Avatar,
|
||||
Gender: body.Gender,
|
||||
Country: body.Country,
|
||||
InviteCode: body.InviteCode,
|
||||
})
|
||||
if err != nil {
|
||||
writeRPCError(writer, request, err)
|
||||
@ -167,6 +176,7 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http
|
||||
|
||||
writeOK(writer, request, completeOnboardingData{
|
||||
userProfileData: profileData(resp.GetUser(), 0),
|
||||
Invite: inviteBindingDataFromProto(resp.GetInvite()),
|
||||
Token: authData(resp.GetToken(), nil),
|
||||
})
|
||||
}
|
||||
@ -357,6 +367,17 @@ func profileData(user *userv1.User, nextCountryChangeAtMs int64) userProfileData
|
||||
}
|
||||
}
|
||||
|
||||
func inviteBindingDataFromProto(binding *userv1.InviteBinding) *inviteBindingData {
|
||||
if binding == nil {
|
||||
return nil
|
||||
}
|
||||
return &inviteBindingData{
|
||||
Bound: binding.GetBound(),
|
||||
InviteCode: binding.GetInviteCode(),
|
||||
InviterUserID: userIDString(binding.GetInviterUserId()),
|
||||
}
|
||||
}
|
||||
|
||||
func userIDString(userID int64) string {
|
||||
if userID > 0 {
|
||||
return strconv.FormatInt(userID, 10)
|
||||
|
||||
@ -61,6 +61,22 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
type occupiedMicSeat struct {
|
||||
userID int64
|
||||
seatNo int32
|
||||
micSessionID string
|
||||
}
|
||||
occupiedSeats := make([]occupiedMicSeat, 0, len(current.MicSeats))
|
||||
for _, seat := range current.MicSeats {
|
||||
if seat.UserID != 0 && seat.MicSessionID != "" {
|
||||
occupiedSeats = append(occupiedSeats, occupiedMicSeat{
|
||||
userID: seat.UserID,
|
||||
seatNo: seat.SeatNo,
|
||||
micSessionID: seat.MicSessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
current.Status = state.RoomStatusClosed
|
||||
current.OnlineUsers = map[int64]*state.RoomUserState{}
|
||||
for index := range current.MicSeats {
|
||||
@ -75,6 +91,16 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records := make([]outbox.Record, 0, len(occupiedSeats)+1)
|
||||
for _, seat := range occupiedSeats {
|
||||
// 关房是 Room Cell 统一清场,给每个占麦 session 单独写 down 事件,保证用户时长能闭合。
|
||||
micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), seat.userID, seat.seatNo, seat.micSessionID, "room_closed")
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records = append(records, micEvent)
|
||||
}
|
||||
records = append(records, closedEvent)
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
@ -89,7 +115,7 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
|
||||
"reason": cmd.Reason,
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{closedEvent}, nil
|
||||
}, records, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
25
services/room-service/internal/room/service/mic_events.go
Normal file
25
services/room-service/internal/room/service/mic_events.go
Normal file
@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
|
||||
// buildMicDownEventForSeat records every non-MicDown release path as the same
|
||||
// RoomMicChanged/down fact, so downstream duration consumers never need to infer
|
||||
// mic closure from leave/kick/close side effects.
|
||||
func buildMicDownEventForSeat(roomID string, roomVersion int64, now time.Time, actorUserID int64, targetUserID int64, seatNo int32, micSessionID string, reason string) (outbox.Record, error) {
|
||||
return outbox.Build(roomID, "RoomMicChanged", roomVersion, now, &roomeventsv1.RoomMicChanged{
|
||||
ActorUserId: actorUserID,
|
||||
TargetUserId: targetUserID,
|
||||
FromSeat: seatNo,
|
||||
ToSeat: 0,
|
||||
Action: "down",
|
||||
MicSessionId: micSessionID,
|
||||
PublishState: state.MicPublishIdle,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
@ -123,9 +123,13 @@ func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*r
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
|
||||
var releasedSeatNo int32
|
||||
var releasedMicSessionID string
|
||||
if seat, exists := current.SeatByUser(cmd.TargetUserID); exists {
|
||||
// 被踢用户如果在麦上,必须先释放麦位,避免恢复后出现幽灵占位。
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
releasedSeatNo = seat.SeatNo
|
||||
releasedMicSessionID = current.MicSeats[index].MicSessionID
|
||||
current.ClearMicSession(index)
|
||||
}
|
||||
|
||||
@ -142,6 +146,16 @@ func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*r
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records := make([]outbox.Record, 0, 2)
|
||||
if releasedMicSessionID != "" {
|
||||
// 踢人释放麦位必须也生成 down 事实,时长聚合不能从 RoomUserKicked 反推麦位结束。
|
||||
micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), cmd.TargetUserID, releasedSeatNo, releasedMicSessionID, "kick")
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records = append(records, micEvent)
|
||||
}
|
||||
records = append(records, kickEvent)
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
@ -153,7 +167,7 @@ func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*r
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
RoomVersion: current.Version,
|
||||
},
|
||||
}, []outbox.Record{kickEvent}, nil
|
||||
}, records, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -165,15 +165,30 @@ func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason s
|
||||
}
|
||||
|
||||
delete(current.OnlineUsers, cmd.ActorUserID())
|
||||
var micSessionID string
|
||||
if onSeat {
|
||||
// 离房必须同步释放麦位,但对外只发 RoomUserLeft 主事件,避免客户端处理双事件竞态。
|
||||
// 离房必须同步释放麦位;同时写 RoomMicChanged/down,给时长聚合一个稳定闭合事件。
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
if index >= 0 {
|
||||
micSessionID = current.MicSeats[index].MicSessionID
|
||||
current.ClearMicSession(index)
|
||||
}
|
||||
}
|
||||
current.Version++
|
||||
|
||||
records := make([]outbox.Record, 0, 2)
|
||||
if onSeat && micSessionID != "" {
|
||||
micReason := reason
|
||||
if micReason == "" {
|
||||
micReason = "leave"
|
||||
}
|
||||
micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), cmd.ActorUserID(), seat.SeatNo, micSessionID, micReason)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records = append(records, micEvent)
|
||||
}
|
||||
|
||||
// 离房事件进入 outbox,并尝试同步通知腾讯云 IM;stale 清理会通过 attributes 标记原因。
|
||||
leftEvent, err := outbox.Build(current.RoomID, "RoomUserLeft", current.Version, now, &roomeventsv1.RoomUserLeft{
|
||||
UserId: cmd.ActorUserID(),
|
||||
@ -181,6 +196,7 @@ func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason s
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records = append(records, leftEvent)
|
||||
|
||||
attributes := map[string]string{}
|
||||
if reason != "" {
|
||||
@ -198,7 +214,7 @@ func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason s
|
||||
RoomVersion: current.Version,
|
||||
Attributes: attributes,
|
||||
},
|
||||
}, []outbox.Record{leftEvent}, nil
|
||||
}, records, nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
@ -622,6 +623,9 @@ func TestRoomLifecycleGiftAndGuards(t *testing.T) {
|
||||
if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
|
||||
t.Fatalf("unexpected presence guard response: %+v", presenceResp)
|
||||
}
|
||||
if got := countPendingMicDownReason(t, repository, "kick"); got != 1 {
|
||||
t.Fatalf("KickUser should write one RoomMicChanged/down reason=kick, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomManagementPermissionsAndHostFlow(t *testing.T) {
|
||||
@ -1180,6 +1184,9 @@ func TestCloseRoomClearsPresenceSeatsAndBlocksEntry(t *testing.T) {
|
||||
if seat := findSeat(closeResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
|
||||
t.Fatalf("closed room should release seats: %+v", seat)
|
||||
}
|
||||
if got := countPendingMicDownReason(t, repository, "room_closed"); got != 1 {
|
||||
t.Fatalf("CloseRoom should write one RoomMicChanged/down reason=room_closed, got %d", got)
|
||||
}
|
||||
meta, exists, err := repository.GetRoomMeta(ctx, roomID)
|
||||
if err != nil || !exists || meta.Status != "closed" {
|
||||
t.Fatalf("room meta should be closed: exists=%v meta=%+v err=%v", exists, meta, err)
|
||||
@ -1418,6 +1425,9 @@ func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) {
|
||||
if seat := findSeat(leaveResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 {
|
||||
t.Fatalf("leave should release seat: %+v", seat)
|
||||
}
|
||||
if got := countPendingMicDownReason(t, repository, "leave"); got != 1 {
|
||||
t.Fatalf("LeaveRoom should write one RoomMicChanged/down reason=leave, got %d", got)
|
||||
}
|
||||
|
||||
speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2})
|
||||
if err != nil {
|
||||
@ -1936,6 +1946,31 @@ func countPendingOutboxType(t *testing.T, repository roomservice.Repository, eve
|
||||
return count
|
||||
}
|
||||
|
||||
func countPendingMicDownReason(t *testing.T, repository roomservice.Repository, reason string) int {
|
||||
t.Helper()
|
||||
|
||||
records, err := repository.ListPendingOutbox(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPendingOutbox failed: %v", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, record := range records {
|
||||
if record.EventType != "RoomMicChanged" || record.Envelope == nil {
|
||||
continue
|
||||
}
|
||||
var body roomeventsv1.RoomMicChanged
|
||||
if err := proto.Unmarshal(record.Envelope.GetBody(), &body); err != nil {
|
||||
t.Fatalf("decode RoomMicChanged failed: %v", err)
|
||||
}
|
||||
if body.GetAction() == "down" && body.GetReason() == reason {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
|
||||
for _, user := range snapshot.GetOnlineUsers() {
|
||||
if user.GetUserId() == userID {
|
||||
|
||||
@ -20,6 +20,16 @@ region_rebuild_worker:
|
||||
poll_interval_sec: 60
|
||||
lock_ttl_sec: 30
|
||||
batch_size: 500
|
||||
invite_recharge_worker:
|
||||
enabled: true
|
||||
wallet_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
mic_time_worker:
|
||||
enabled: true
|
||||
room_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
jwt:
|
||||
issuer: "hyapp"
|
||||
access_token_ttl_sec: 1800
|
||||
|
||||
@ -20,6 +20,16 @@ region_rebuild_worker:
|
||||
poll_interval_sec: 60
|
||||
lock_ttl_sec: 30
|
||||
batch_size: 500
|
||||
invite_recharge_worker:
|
||||
enabled: true
|
||||
wallet_mysql_dsn: "${TENCENT_MYSQL_WALLET_DSN}"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
mic_time_worker:
|
||||
enabled: true
|
||||
room_mysql_dsn: "${TENCENT_MYSQL_ROOM_DSN}"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
jwt:
|
||||
issuer: "hyapp"
|
||||
access_token_ttl_sec: 1800
|
||||
|
||||
@ -20,6 +20,16 @@ region_rebuild_worker:
|
||||
poll_interval_sec: 60
|
||||
lock_ttl_sec: 30
|
||||
batch_size: 500
|
||||
invite_recharge_worker:
|
||||
enabled: true
|
||||
wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
mic_time_worker:
|
||||
enabled: true
|
||||
room_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
jwt:
|
||||
issuer: "hyapp"
|
||||
access_token_ttl_sec: 1800
|
||||
|
||||
@ -190,6 +190,197 @@ CREATE TABLE IF NOT EXISTS user_push_tokens (
|
||||
KEY idx_user_push_tokens_updated (app_code, status, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_invite_codes (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
invite_code_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
code VARCHAR(64) NOT NULL,
|
||||
code_type VARCHAR(32) NOT NULL DEFAULT 'primary',
|
||||
status VARCHAR(32) NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_user_invite_codes_code (app_code, code),
|
||||
UNIQUE KEY uk_user_invite_codes_owner_primary (app_code, owner_user_id, code_type),
|
||||
KEY idx_user_invite_codes_owner (app_code, owner_user_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_invite_relations (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
relation_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
invited_user_id BIGINT NOT NULL,
|
||||
inviter_user_id BIGINT NOT NULL,
|
||||
invite_code_id BIGINT NOT NULL,
|
||||
invite_code VARCHAR(64) NOT NULL,
|
||||
source VARCHAR(64) NOT NULL,
|
||||
request_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||
bound_at_ms BIGINT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_user_invite_relations_invited (app_code, invited_user_id),
|
||||
KEY idx_user_invite_relations_inviter (app_code, inviter_user_id, bound_at_ms),
|
||||
KEY idx_user_invite_relations_code (app_code, invite_code, bound_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_invite_validity_policies (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
policy_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
region_id BIGINT NOT NULL DEFAULT 0,
|
||||
policy_version VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
required_recharge_coin_amount BIGINT NOT NULL,
|
||||
eligible_recharge_types JSON NOT NULL,
|
||||
effective_from_ms BIGINT NOT NULL,
|
||||
effective_to_ms BIGINT NULL,
|
||||
created_by_user_id BIGINT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_user_invite_policy_version (app_code, region_id, policy_version),
|
||||
KEY idx_user_invite_policies_active (app_code, region_id, status, effective_from_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO user_invite_validity_policies (
|
||||
app_code, region_id, policy_version, status, required_recharge_coin_amount,
|
||||
eligible_recharge_types, effective_from_ms, effective_to_ms, created_by_user_id, created_at_ms, updated_at_ms
|
||||
) VALUES (
|
||||
'lalu', 0, 'invite-valid-default-v1', 'active', 80000,
|
||||
JSON_ARRAY('coin_seller_transfer'), 0, NULL, NULL, 0, 0
|
||||
) ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
required_recharge_coin_amount = VALUES(required_recharge_coin_amount),
|
||||
eligible_recharge_types = VALUES(eligible_recharge_types),
|
||||
effective_from_ms = VALUES(effective_from_ms),
|
||||
effective_to_ms = VALUES(effective_to_ms),
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_invite_recharge_progress (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
invited_user_id BIGINT NOT NULL,
|
||||
inviter_user_id BIGINT NOT NULL,
|
||||
policy_id BIGINT NOT NULL,
|
||||
policy_version VARCHAR(64) NOT NULL,
|
||||
required_recharge_coin_amount BIGINT NOT NULL,
|
||||
accumulated_recharge_coin_amount BIGINT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
valid_at_ms BIGINT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, invited_user_id),
|
||||
KEY idx_user_invite_progress_inviter_status (app_code, inviter_user_id, status, updated_at_ms),
|
||||
KEY idx_user_invite_progress_policy (app_code, policy_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_invite_event_consumption (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
transaction_id VARCHAR(96) NOT NULL,
|
||||
invited_user_id BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
skip_reason VARCHAR(128) NOT NULL DEFAULT '',
|
||||
consumed_at_ms BIGINT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
UNIQUE KEY uk_user_invite_event_tx (app_code, transaction_id, event_type),
|
||||
KEY idx_user_invite_event_user (app_code, invited_user_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_invite_counters (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
user_id BIGINT NOT NULL,
|
||||
invite_count BIGINT NOT NULL DEFAULT 0,
|
||||
valid_invite_count BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_outbox (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
aggregate_type VARCHAR(64) NOT NULL,
|
||||
aggregate_id BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
payload_json JSON NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
KEY idx_user_outbox_status_created (app_code, status, created_at_ms),
|
||||
KEY idx_user_outbox_aggregate (app_code, aggregate_type, aggregate_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_mic_sessions (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
mic_session_id VARCHAR(128) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
first_seat_no INT NOT NULL,
|
||||
current_seat_no INT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
seat_started_at_ms BIGINT NOT NULL,
|
||||
publishing_started_at_ms BIGINT NULL,
|
||||
ended_at_ms BIGINT NULL,
|
||||
end_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
seat_occupied_ms BIGINT NOT NULL DEFAULT 0,
|
||||
mic_online_ms BIGINT NOT NULL DEFAULT 0,
|
||||
opened_event_id VARCHAR(128) NOT NULL,
|
||||
publish_event_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
closed_event_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
last_event_id VARCHAR(128) NOT NULL,
|
||||
last_room_version BIGINT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, mic_session_id),
|
||||
KEY idx_user_mic_sessions_user_status (app_code, user_id, status, updated_at_ms),
|
||||
KEY idx_user_mic_sessions_room_status (app_code, room_id, status),
|
||||
KEY idx_user_mic_sessions_open_timeout (app_code, status, seat_started_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_mic_daily_stats (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
user_id BIGINT NOT NULL,
|
||||
stat_date CHAR(10) NOT NULL,
|
||||
seat_occupied_ms BIGINT NOT NULL DEFAULT 0,
|
||||
mic_online_ms BIGINT NOT NULL DEFAULT 0,
|
||||
session_count BIGINT NOT NULL DEFAULT 0,
|
||||
room_count BIGINT NOT NULL DEFAULT 0,
|
||||
first_mic_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_mic_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, user_id, stat_date),
|
||||
KEY idx_user_mic_daily_date (app_code, stat_date, mic_online_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_mic_lifetime_stats (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
user_id BIGINT NOT NULL,
|
||||
seat_occupied_ms BIGINT NOT NULL DEFAULT 0,
|
||||
mic_online_ms BIGINT NOT NULL DEFAULT 0,
|
||||
session_count BIGINT NOT NULL DEFAULT 0,
|
||||
first_mic_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_mic_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, user_id),
|
||||
KEY idx_user_mic_lifetime_mic_online (app_code, mic_online_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_mic_event_consumption (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
mic_session_id VARCHAR(128) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
skip_reason VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
consumed_at_ms BIGINT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
KEY idx_user_mic_event_session (app_code, mic_session_id, created_at_ms),
|
||||
KEY idx_user_mic_event_user (app_code, user_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_profiles (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
user_id BIGINT NOT NULL PRIMARY KEY,
|
||||
|
||||
@ -3,6 +3,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
@ -15,8 +16,12 @@ import (
|
||||
"hyapp/services/user-service/internal/config"
|
||||
authservice "hyapp/services/user-service/internal/service/auth"
|
||||
hostservice "hyapp/services/user-service/internal/service/host"
|
||||
inviteservice "hyapp/services/user-service/internal/service/invite"
|
||||
mictimeservice "hyapp/services/user-service/internal/service/mictime"
|
||||
userservice "hyapp/services/user-service/internal/service/user"
|
||||
mysqlstorage "hyapp/services/user-service/internal/storage/mysql"
|
||||
invitestorage "hyapp/services/user-service/internal/storage/mysql/invite"
|
||||
mictimestorage "hyapp/services/user-service/internal/storage/mysql/mictime"
|
||||
grpcserver "hyapp/services/user-service/internal/transport/grpc"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
@ -33,13 +38,21 @@ type App struct {
|
||||
health *grpchealth.ServingChecker
|
||||
// mysqlRepo 持有用户主数据、认证身份、session 和短号事务的唯一运行时存储。
|
||||
mysqlRepo *mysqlstorage.Repository
|
||||
// walletDB 只用于读取 wallet_outbox 充值事实,实际账务状态仍由 wallet-service owner。
|
||||
walletDB *sql.DB
|
||||
// roomDB 只用于读取 room_outbox 麦位事实,Room Cell 状态仍由 room-service owner。
|
||||
roomDB *sql.DB
|
||||
// userSvc 持有用户主数据用例,后台 region rebuild worker 复用它消费任务。
|
||||
userSvc *userservice.Service
|
||||
// inviteSvc 消费 wallet recharge outbox,维护有效邀请 read model。
|
||||
inviteSvc *inviteservice.Service
|
||||
// micTimeSvc 消费 room mic outbox,维护用户维度麦上时长 read model。
|
||||
micTimeSvc *mictimeservice.Service
|
||||
// cfg 保存 worker 运行参数,避免 Run 阶段重新读配置。
|
||||
cfg config.Config
|
||||
// workerCtx 统一控制后台 worker 生命周期。
|
||||
workerCtx context.Context
|
||||
// workerCancel 停止 region rebuild worker。
|
||||
// workerCancel 停止 region rebuild、invite recharge 和 mic time worker。
|
||||
workerCancel context.CancelFunc
|
||||
// workerWG 等待后台 worker 当前批次安全退出。
|
||||
workerWG sync.WaitGroup
|
||||
@ -83,6 +96,8 @@ func New(cfg config.Config) (*App, error) {
|
||||
regionRepo := mysqlRepo.RegionRepository()
|
||||
deviceRepo := mysqlRepo.DeviceRepository()
|
||||
hostRepo := mysqlRepo.HostRepository()
|
||||
inviteRepo := mysqlRepo.InviteRepository()
|
||||
micTimeRepo := mysqlRepo.MicTimeRepository()
|
||||
|
||||
// auth service 负责登录、session 和 token;用户主数据和短号事务仍通过各自领域存储完成。
|
||||
authSvc := authservice.New(authservice.Config{
|
||||
@ -115,7 +130,48 @@ func New(cfg config.Config) (*App, error) {
|
||||
hostSvc := hostservice.New(hostRepo,
|
||||
hostservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)),
|
||||
)
|
||||
var walletDB *sql.DB
|
||||
var inviteSvc *inviteservice.Service
|
||||
if cfg.InviteRechargeWorker.Enabled {
|
||||
walletDB, err = sql.Open("mysql", cfg.InviteRechargeWorker.WalletMySQLDSN)
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := walletDB.PingContext(startupCtx); err != nil {
|
||||
_ = walletDB.Close()
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, err
|
||||
}
|
||||
inviteSvc = inviteservice.New(inviteRepo, invitestorage.NewWalletOutboxReader(walletDB))
|
||||
}
|
||||
var roomDB *sql.DB
|
||||
micTimeSvc := mictimeservice.New(micTimeRepo, nil)
|
||||
if cfg.MicTimeWorker.Enabled {
|
||||
roomDB, err = sql.Open("mysql", cfg.MicTimeWorker.RoomMySQLDSN)
|
||||
if err != nil {
|
||||
if walletDB != nil {
|
||||
_ = walletDB.Close()
|
||||
}
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := roomDB.PingContext(startupCtx); err != nil {
|
||||
_ = roomDB.Close()
|
||||
if walletDB != nil {
|
||||
_ = walletDB.Close()
|
||||
}
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, err
|
||||
}
|
||||
micTimeSvc = mictimeservice.New(micTimeRepo, mictimestorage.NewRoomOutboxReader(roomDB))
|
||||
}
|
||||
userServer := grpcserver.NewServer(authSvc, userSvc, hostSvc)
|
||||
userServer.SetMicTimeService(micTimeSvc)
|
||||
// 多个 protobuf service 共用一个 Server 适配器,领域逻辑仍拆在 auth/user/host service。
|
||||
userv1.RegisterAuthServiceServer(server, userServer)
|
||||
userv1.RegisterUserServiceServer(server, userServer)
|
||||
@ -138,7 +194,11 @@ func New(cfg config.Config) (*App, error) {
|
||||
listener: listener,
|
||||
health: health,
|
||||
mysqlRepo: mysqlRepo,
|
||||
walletDB: walletDB,
|
||||
roomDB: roomDB,
|
||||
userSvc: userSvc,
|
||||
inviteSvc: inviteSvc,
|
||||
micTimeSvc: micTimeSvc,
|
||||
cfg: cfg,
|
||||
workerCtx: workerCtx,
|
||||
workerCancel: workerCancel,
|
||||
@ -167,6 +227,28 @@ func (a *App) Run() error {
|
||||
})
|
||||
}()
|
||||
}
|
||||
if a.cfg.InviteRechargeWorker.Enabled && a.inviteSvc != nil {
|
||||
a.workerWG.Add(1)
|
||||
go func() {
|
||||
defer a.workerWG.Done()
|
||||
a.inviteSvc.RunWalletRechargeWorker(a.workerCtx, inviteservice.WorkerOptions{
|
||||
WorkerID: a.cfg.NodeID,
|
||||
PollInterval: time.Duration(a.cfg.InviteRechargeWorker.PollIntervalSec) * time.Second,
|
||||
BatchSize: a.cfg.InviteRechargeWorker.BatchSize,
|
||||
})
|
||||
}()
|
||||
}
|
||||
if a.cfg.MicTimeWorker.Enabled && a.micTimeSvc != nil {
|
||||
a.workerWG.Add(1)
|
||||
go func() {
|
||||
defer a.workerWG.Done()
|
||||
a.micTimeSvc.RunRoomMicWorker(a.workerCtx, mictimeservice.WorkerOptions{
|
||||
WorkerID: a.cfg.NodeID,
|
||||
PollInterval: time.Duration(a.cfg.MicTimeWorker.PollIntervalSec) * time.Second,
|
||||
BatchSize: a.cfg.MicTimeWorker.BatchSize,
|
||||
})
|
||||
}()
|
||||
}
|
||||
err := a.server.Serve(a.listener)
|
||||
a.health.MarkStopped()
|
||||
if errors.Is(err, grpc.ErrServerStopped) {
|
||||
@ -192,5 +274,11 @@ func (a *App) Close() {
|
||||
// MySQL 连接池最后关闭,保证 GracefulStop 期间 repository 仍可用。
|
||||
_ = a.mysqlRepo.Close()
|
||||
}
|
||||
if a.walletDB != nil {
|
||||
_ = a.walletDB.Close()
|
||||
}
|
||||
if a.roomDB != nil {
|
||||
_ = a.roomDB.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -23,6 +23,10 @@ type Config struct {
|
||||
ThirdParty ThirdPartyConfig `yaml:"third_party"`
|
||||
// RegionRebuildWorker 控制历史用户 region_id 快照后台重算。
|
||||
RegionRebuildWorker RegionRebuildWorkerConfig `yaml:"region_rebuild_worker"`
|
||||
// InviteRechargeWorker 控制邀请有效人数的 wallet recharge outbox 消费。
|
||||
InviteRechargeWorker InviteRechargeWorkerConfig `yaml:"invite_recharge_worker"`
|
||||
// MicTimeWorker 控制 room-service 麦位事件消费和用户麦上时长聚合。
|
||||
MicTimeWorker MicTimeWorkerConfig `yaml:"mic_time_worker"`
|
||||
// JWT 控制 access token 和 refresh session 的签发参数。
|
||||
JWT JWTConfig `yaml:"jwt"`
|
||||
}
|
||||
@ -71,6 +75,30 @@ type RegionRebuildWorkerConfig struct {
|
||||
BatchSize int `yaml:"batch_size"`
|
||||
}
|
||||
|
||||
// InviteRechargeWorkerConfig 保存钱包充值事实消费策略。
|
||||
type InviteRechargeWorkerConfig struct {
|
||||
// Enabled 控制是否启动钱包充值事件轮询。
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// WalletMySQLDSN 是 wallet-service outbox 只读连接串;本地可复用同一个 MySQL 实例。
|
||||
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
||||
// PollIntervalSec 是没有积压时两轮扫描间隔秒数。
|
||||
PollIntervalSec int64 `yaml:"poll_interval_sec"`
|
||||
// BatchSize 是单轮最多消费的 wallet_outbox 事件数。
|
||||
BatchSize int `yaml:"batch_size"`
|
||||
}
|
||||
|
||||
// MicTimeWorkerConfig 保存房间麦位事件消费策略。
|
||||
type MicTimeWorkerConfig struct {
|
||||
// Enabled 控制是否启动 room_outbox 轮询;关闭后查询仍可读取已聚合数据。
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// RoomMySQLDSN 是 room-service outbox 只读连接串。
|
||||
RoomMySQLDSN string `yaml:"room_mysql_dsn"`
|
||||
// PollIntervalSec 是没有积压时两轮扫描间隔秒数。
|
||||
PollIntervalSec int64 `yaml:"poll_interval_sec"`
|
||||
// BatchSize 是单轮最多消费的 RoomMicChanged 事件数。
|
||||
BatchSize int `yaml:"batch_size"`
|
||||
}
|
||||
|
||||
// JWTConfig 保存 access token 和 refresh token 的签发策略。
|
||||
type JWTConfig struct {
|
||||
// Issuer 写入 access token iss claim。
|
||||
@ -113,6 +141,18 @@ func Default() Config {
|
||||
LockTTLSec: 30,
|
||||
BatchSize: 500,
|
||||
},
|
||||
InviteRechargeWorker: InviteRechargeWorkerConfig{
|
||||
Enabled: true,
|
||||
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
PollIntervalSec: 5,
|
||||
BatchSize: 100,
|
||||
},
|
||||
MicTimeWorker: MicTimeWorkerConfig{
|
||||
Enabled: true,
|
||||
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
PollIntervalSec: 5,
|
||||
BatchSize: 100,
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Issuer: "hyapp",
|
||||
AccessTokenTTLSec: 1800,
|
||||
|
||||
102
services/user-service/internal/domain/invite/invite.go
Normal file
102
services/user-service/internal/domain/invite/invite.go
Normal file
@ -0,0 +1,102 @@
|
||||
// Package invite defines user-service invitation facts and read models.
|
||||
package invite
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// CodeStatusActive means a code can be used by new registrations.
|
||||
CodeStatusActive = "active"
|
||||
// CodeTypePrimary is the shareable per-user permanent invite code.
|
||||
CodeTypePrimary = "primary"
|
||||
// ProgressStatusPending means the invited user has not reached the configured recharge threshold.
|
||||
ProgressStatusPending = "pending"
|
||||
// ProgressStatusValid means the invited user has reached the threshold and counted once.
|
||||
ProgressStatusValid = "valid"
|
||||
// EventTypeUserInvited is emitted when an invitation relation is first bound.
|
||||
EventTypeUserInvited = "UserInvited"
|
||||
// EventTypeUserInviteBecameValid is emitted when recharge progress first crosses the policy threshold.
|
||||
EventTypeUserInviteBecameValid = "UserInviteBecameValid"
|
||||
// RechargeEventWalletRecorded is the wallet outbox event that invitation validity consumes.
|
||||
RechargeEventWalletRecorded = "WalletRechargeRecorded"
|
||||
// RechargeTypeCoinSellerTransfer is the current recharge fact produced by wallet-service coin seller transfer.
|
||||
RechargeTypeCoinSellerTransfer = "coin_seller_transfer"
|
||||
)
|
||||
|
||||
const (
|
||||
// CodeMinLength keeps user-entered invite codes short enough for mobile manual entry.
|
||||
CodeMinLength = 6
|
||||
// CodeMaxLength follows the current SQL VARCHAR boundary for invite code snapshots.
|
||||
CodeMaxLength = 64
|
||||
)
|
||||
|
||||
var codePattern = regexp.MustCompile(`^[A-Z0-9]{6,64}$`)
|
||||
|
||||
// Overview is the read model returned with user profile for my-page aggregation.
|
||||
type Overview struct {
|
||||
MyInviteCode string
|
||||
InviteEnabled bool
|
||||
InviteCount int64
|
||||
ValidInviteCount int64
|
||||
ValidInviteThresholdCoin int64
|
||||
}
|
||||
|
||||
// Binding is the result of one registration/onboarding invitation bind attempt.
|
||||
type Binding struct {
|
||||
Bound bool
|
||||
InviteCode string
|
||||
InviterUserID int64
|
||||
}
|
||||
|
||||
// BindCommand describes one optional invitation attribution write.
|
||||
type BindCommand struct {
|
||||
AppCode string
|
||||
InvitedUserID int64
|
||||
InvitedRegionID int64
|
||||
InviteCode string
|
||||
Source string
|
||||
RequestID string
|
||||
BoundAtMs int64
|
||||
}
|
||||
|
||||
// RechargeEvent is the wallet recharge fact consumed by invitation validity.
|
||||
type RechargeEvent struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
EventType string
|
||||
TransactionID string
|
||||
CommandID string
|
||||
InvitedUserID int64
|
||||
AssetType string
|
||||
RechargeCoinAmount int64
|
||||
RechargeType string
|
||||
OccurredAtMs int64
|
||||
PayloadJSON string
|
||||
}
|
||||
|
||||
// RechargeApplyResult summarizes idempotent invitation recharge processing.
|
||||
type RechargeApplyResult struct {
|
||||
Consumed bool
|
||||
Skipped bool
|
||||
Valid bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// WalletRechargeCursor lets a worker scan wallet_outbox in stable creation order.
|
||||
type WalletRechargeCursor struct {
|
||||
CreatedAtMs int64
|
||||
EventID string
|
||||
}
|
||||
|
||||
// NormalizeCode trims and uppercases user-provided invite code text.
|
||||
func NormalizeCode(code string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
// ValidCode checks only syntax; ownership and active status are repository facts.
|
||||
func ValidCode(code string) bool {
|
||||
code = NormalizeCode(code)
|
||||
return len(code) >= CodeMinLength && len(code) <= CodeMaxLength && codePattern.MatchString(code)
|
||||
}
|
||||
77
services/user-service/internal/domain/mictime/mictime.go
Normal file
77
services/user-service/internal/domain/mictime/mictime.go
Normal file
@ -0,0 +1,77 @@
|
||||
// Package mictime defines user-scoped microphone duration facts.
|
||||
package mictime
|
||||
|
||||
const (
|
||||
// RoomMicChangedEventType is the room-service outbox event consumed by this read model.
|
||||
RoomMicChangedEventType = "RoomMicChanged"
|
||||
|
||||
// ActionUp opens a mic session and starts seat occupation time.
|
||||
ActionUp = "up"
|
||||
// ActionPublishConfirmed starts effective microphone online time for an open session.
|
||||
ActionPublishConfirmed = "publish_confirmed"
|
||||
// ActionChange moves the occupied seat without splitting duration.
|
||||
ActionChange = "change"
|
||||
// ActionDown closes a session and rolls duration into daily/lifetime aggregates.
|
||||
ActionDown = "down"
|
||||
|
||||
// PublishStatePending means the user occupies a business mic seat but has not confirmed RTC audio.
|
||||
PublishStatePending = "pending_publish"
|
||||
// PublishStatePublishing means RTC audio has been confirmed for the session.
|
||||
PublishStatePublishing = "publishing"
|
||||
// PublishStateIdle means the seat has been released.
|
||||
PublishStateIdle = "idle"
|
||||
|
||||
// SessionStatusPendingPublish is persisted before RTC audio confirmation.
|
||||
SessionStatusPendingPublish = "pending_publish"
|
||||
// SessionStatusPublishing is persisted after publish_confirmed.
|
||||
SessionStatusPublishing = "publishing"
|
||||
// SessionStatusEnded is terminal and must never be reopened by replayed events.
|
||||
SessionStatusEnded = "ended"
|
||||
)
|
||||
|
||||
// RoomMicEvent is the decoded RoomMicChanged fact used by user-service duration aggregation.
|
||||
type RoomMicEvent struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
EventType string
|
||||
RoomID string
|
||||
RoomVersion int64
|
||||
OutboxCreatedAtMs int64
|
||||
OccurredAtMs int64
|
||||
ActorUserID int64
|
||||
TargetUserID int64
|
||||
FromSeat int32
|
||||
ToSeat int32
|
||||
Action string
|
||||
MicSessionID string
|
||||
PublishState string
|
||||
Reason string
|
||||
PublishDeadlineMs int64
|
||||
PublishEventTimeMs int64
|
||||
}
|
||||
|
||||
// RoomOutboxCursor lets the worker scan room_outbox in stable creation order.
|
||||
type RoomOutboxCursor struct {
|
||||
CreatedAtMs int64
|
||||
EventID string
|
||||
}
|
||||
|
||||
// ApplyResult describes one idempotent event application.
|
||||
type ApplyResult struct {
|
||||
Consumed bool
|
||||
Skipped bool
|
||||
Closed bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// LifetimeStats is the user-wide aggregate consumed by host tasks and profile views.
|
||||
type LifetimeStats struct {
|
||||
AppCode string
|
||||
UserID int64
|
||||
SeatOccupiedMs int64
|
||||
MicOnlineMs int64
|
||||
SessionCount int64
|
||||
FirstMicAtMs int64
|
||||
LastMicAtMs int64
|
||||
UpdatedAtMs int64
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
// Package user 定义 user-service 的用户主数据、展示短号和临时靓号领域模型。
|
||||
package user
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
)
|
||||
|
||||
// Status 是用户主状态,业务服务只应该依赖这里的稳定语义。
|
||||
type Status string
|
||||
@ -111,6 +115,10 @@ type User struct {
|
||||
RegionName string
|
||||
// InviteCode 是注册时使用的邀请码,空值表示未使用邀请关系。
|
||||
InviteCode string
|
||||
// InviteOverview 是我的页读取的邀请码和计数 read model,不参与用户主状态决策。
|
||||
InviteOverview invitedomain.Overview
|
||||
// InviteBinding 是注册完成请求本次邀请归因结果,只服务入口响应,不作为关系事实来源。
|
||||
InviteBinding invitedomain.Binding
|
||||
// RegisterIP 是注册请求携带或 gateway 观察到的注册 IP 快照。
|
||||
RegisterIP string
|
||||
// RegisterUserAgent 是 gateway 从 HTTP header 观察到的注册 UA 快照。
|
||||
@ -229,6 +237,10 @@ type CompleteOnboardingCommand struct {
|
||||
Country string
|
||||
// RegionID 是按 Country 当前 active 映射解析出的区域快照,0 表示 GLOBAL 兜底桶。
|
||||
RegionID int64
|
||||
// InviteCode 是注册页可选的邀请码;非空时 repository 必须解析并绑定关系。
|
||||
InviteCode string
|
||||
// RequestID 是入口请求 ID,写入邀请关系和 outbox 便于排查。
|
||||
RequestID string
|
||||
// CompletedAtMs 是资料完成时间,同时作为 updated_at_ms。
|
||||
CompletedAtMs int64
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"hyapp/pkg/idgen"
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
userservice "hyapp/services/user-service/internal/service/user"
|
||||
)
|
||||
@ -54,7 +55,7 @@ type AuthRepository interface {
|
||||
// FindThirdPartyIdentity 查找 provider + subject 的绑定。
|
||||
FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error)
|
||||
// CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。
|
||||
CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session) error
|
||||
CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand) error
|
||||
// RecordLoginAudit 记录登录审计,主链路不会因为审计失败而失败。
|
||||
RecordLoginAudit(ctx context.Context, audit authdomain.LoginAudit) error
|
||||
}
|
||||
|
||||
@ -208,7 +208,7 @@ func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
now = time.UnixMilli(2000)
|
||||
if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG"); err != nil {
|
||||
if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil {
|
||||
t.Fatalf("CompleteOnboarding failed: %v", err)
|
||||
}
|
||||
|
||||
@ -236,7 +236,7 @@ func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotati
|
||||
t.Fatalf("LoginThirdParty failed: %v", err)
|
||||
}
|
||||
now = time.UnixMilli(2000)
|
||||
if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG"); err != nil {
|
||||
if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil {
|
||||
t.Fatalf("CompleteOnboarding failed: %v", err)
|
||||
}
|
||||
|
||||
@ -318,11 +318,35 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
|
||||
seedCountry(t, repository, "CN")
|
||||
now := time.UnixMilli(1000)
|
||||
svc := newAuthService(repository, &now, []int64{900003}, []string{"100003"})
|
||||
if err := repository.CreateUserWithIdentity(ctx, userdomain.User{
|
||||
AppCode: "lalu",
|
||||
UserID: 800001,
|
||||
DefaultDisplayUserID: "188001",
|
||||
CurrentDisplayUserID: "188001",
|
||||
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
Status: userdomain.StatusActive,
|
||||
OnboardingStatus: userdomain.OnboardingStatusProfileRequired,
|
||||
CreatedAtMs: now.UnixMilli(),
|
||||
UpdatedAtMs: now.UnixMilli(),
|
||||
}, userdomain.Identity{
|
||||
AppCode: "lalu",
|
||||
UserID: 800001,
|
||||
DisplayUserID: "188001",
|
||||
DefaultDisplayUserID: "188001",
|
||||
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
Status: userdomain.DisplayUserIDStatusActive,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed inviter failed: %v", err)
|
||||
}
|
||||
inviter, err := repository.GetUser(ctx, 800001)
|
||||
if err != nil {
|
||||
t.Fatalf("get inviter failed: %v", err)
|
||||
}
|
||||
registration := authdomain.ThirdPartyRegistration{
|
||||
Username: " hy ",
|
||||
Gender: "male",
|
||||
Country: "CN",
|
||||
InviteCode: "INV-1",
|
||||
InviteCode: inviter.InviteOverview.MyInviteCode,
|
||||
IP: "198.51.100.9",
|
||||
DeviceID: " dev-1 ",
|
||||
Device: "iPhone 15",
|
||||
@ -351,7 +375,7 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser failed: %v", err)
|
||||
}
|
||||
if user.Username != "hy" || user.Gender != "male" || user.Country != "CN" || user.InviteCode != "INV-1" {
|
||||
if user.Username != "hy" || user.Gender != "male" || user.Country != "CN" || user.InviteCode != inviter.InviteOverview.MyInviteCode {
|
||||
t.Fatalf("profile fields mismatch: %+v", user)
|
||||
}
|
||||
if user.RegisterIP != "203.0.113.10" || user.RegisterUserAgent != "hyapp-test/1.0" || user.CountryByIP != "CN" {
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
)
|
||||
|
||||
@ -147,7 +148,16 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
|
||||
UpdatedAtMs: user.UpdatedAtMs,
|
||||
}
|
||||
|
||||
err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session)
|
||||
inviteBind := invitedomain.BindCommand{
|
||||
AppCode: registration.AppCode,
|
||||
InvitedUserID: user.UserID,
|
||||
InvitedRegionID: registration.RegionID,
|
||||
InviteCode: registration.InviteCode,
|
||||
Source: "third_party_registration",
|
||||
RequestID: meta.RequestID,
|
||||
BoundAtMs: user.CreatedAtMs,
|
||||
}
|
||||
err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session, inviteBind)
|
||||
if err == nil {
|
||||
// repository 事务成功后,用户、默认短号、三方绑定和首个 session 同时存在。
|
||||
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
|
||||
@ -184,7 +194,7 @@ func normalizeThirdPartyRegistration(registration authdomain.ThirdPartyRegistrat
|
||||
registration.Username = strings.TrimSpace(registration.Username)
|
||||
registration.Gender = strings.TrimSpace(registration.Gender)
|
||||
registration.Country = strings.ToUpper(strings.TrimSpace(registration.Country))
|
||||
registration.InviteCode = strings.TrimSpace(registration.InviteCode)
|
||||
registration.InviteCode = invitedomain.NormalizeCode(registration.InviteCode)
|
||||
// 注册 IP 和 UA 只能来自 gateway 观察到的请求上下文,不能信任公网 JSON body。
|
||||
registration.IP = strings.TrimSpace(meta.ClientIP)
|
||||
registration.UserAgent = strings.TrimSpace(meta.UserAgent)
|
||||
@ -214,6 +224,10 @@ func validateThirdPartyRegistration(registration authdomain.ThirdPartyRegistrati
|
||||
// 用户选择国家只接受 2 到 3 位大写英文国家码,是否可选由 countries 表决定。
|
||||
return xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters")
|
||||
}
|
||||
if registration.InviteCode != "" && !invitedomain.ValidCode(registration.InviteCode) {
|
||||
// 邀请码错误统一返回 INVALID_INVITE_CODE,避免客户端通过错误细节枚举邀请码。
|
||||
return xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
||||
}
|
||||
if registration.BirthDate != "" {
|
||||
if !userdomain.ValidProfileBirthDate(registration.BirthDate) {
|
||||
// 生日只保存日期,不接受带时区或非零填充格式,避免跨端解析不一致。
|
||||
|
||||
87
services/user-service/internal/service/invite/service.go
Normal file
87
services/user-service/internal/service/invite/service.go
Normal file
@ -0,0 +1,87 @@
|
||||
// Package invite runs asynchronous invite validity consumers.
|
||||
package invite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
)
|
||||
|
||||
// Repository applies wallet recharge events to user-service invite progress.
|
||||
type Repository interface {
|
||||
ApplyRechargeEvent(ctx context.Context, event invitedomain.RechargeEvent) (invitedomain.RechargeApplyResult, error)
|
||||
}
|
||||
|
||||
// WalletOutboxReader scans wallet recharge facts in creation order.
|
||||
type WalletOutboxReader interface {
|
||||
ListRechargeEventsAfter(ctx context.Context, cursor invitedomain.WalletRechargeCursor, batchSize int) ([]invitedomain.RechargeEvent, error)
|
||||
}
|
||||
|
||||
// Service owns the invite validity event consumer.
|
||||
type Service struct {
|
||||
repository Repository
|
||||
reader WalletOutboxReader
|
||||
}
|
||||
|
||||
// WorkerOptions controls local wallet outbox polling.
|
||||
type WorkerOptions struct {
|
||||
WorkerID string
|
||||
PollInterval time.Duration
|
||||
BatchSize int
|
||||
}
|
||||
|
||||
// New creates an invite service with persistence and wallet outbox reader dependencies.
|
||||
func New(repository Repository, reader WalletOutboxReader) *Service {
|
||||
return &Service{repository: repository, reader: reader}
|
||||
}
|
||||
|
||||
// RunWalletRechargeWorker consumes WalletRechargeRecorded facts and advances valid_invite_count.
|
||||
func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOptions) {
|
||||
if s == nil || s.repository == nil || s.reader == nil {
|
||||
log.Printf("component=user_invite op=wallet_recharge_worker status=disabled reason=missing_dependency")
|
||||
return
|
||||
}
|
||||
if options.PollInterval <= 0 {
|
||||
options.PollInterval = 5 * time.Second
|
||||
}
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = 100
|
||||
}
|
||||
|
||||
var cursor invitedomain.WalletRechargeCursor
|
||||
for {
|
||||
processed, err := s.processBatch(ctx, &cursor, options.BatchSize)
|
||||
if err != nil {
|
||||
log.Printf("component=user_invite op=wallet_recharge_worker worker_id=%s status=failed error=%q", options.WorkerID, err.Error())
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if processed >= options.BatchSize {
|
||||
// 有积压时不 sleep,直到追上 wallet_outbox 当前末尾。
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(options.PollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) processBatch(ctx context.Context, cursor *invitedomain.WalletRechargeCursor, batchSize int) (int, error) {
|
||||
events, err := s.reader.ListRechargeEventsAfter(ctx, *cursor, batchSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, event := range events {
|
||||
if _, err := s.repository.ApplyRechargeEvent(ctx, event); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cursor.CreatedAtMs = event.OccurredAtMs
|
||||
cursor.EventID = event.EventID
|
||||
}
|
||||
return len(events), nil
|
||||
}
|
||||
97
services/user-service/internal/service/mictime/service.go
Normal file
97
services/user-service/internal/service/mictime/service.go
Normal file
@ -0,0 +1,97 @@
|
||||
// Package mictime runs room mic outbox consumers and exposes mic duration queries.
|
||||
package mictime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/xerr"
|
||||
mictimedomain "hyapp/services/user-service/internal/domain/mictime"
|
||||
)
|
||||
|
||||
// Repository applies room mic events and returns user duration aggregates.
|
||||
type Repository interface {
|
||||
ProcessRoomMicEvent(ctx context.Context, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error)
|
||||
GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error)
|
||||
}
|
||||
|
||||
// RoomOutboxReader scans room-service RoomMicChanged facts in creation order.
|
||||
type RoomOutboxReader interface {
|
||||
ListRoomMicEventsAfter(ctx context.Context, cursor mictimedomain.RoomOutboxCursor, batchSize int) ([]mictimedomain.RoomMicEvent, error)
|
||||
}
|
||||
|
||||
// Service owns the user mic duration read model.
|
||||
type Service struct {
|
||||
repository Repository
|
||||
reader RoomOutboxReader
|
||||
}
|
||||
|
||||
// WorkerOptions controls local room outbox polling.
|
||||
type WorkerOptions struct {
|
||||
WorkerID string
|
||||
PollInterval time.Duration
|
||||
BatchSize int
|
||||
}
|
||||
|
||||
// New creates a mic time service with persistence and optional room outbox reader.
|
||||
func New(repository Repository, reader RoomOutboxReader) *Service {
|
||||
return &Service{repository: repository, reader: reader}
|
||||
}
|
||||
|
||||
// GetLifetimeStats returns user-wide accumulated mic duration.
|
||||
func (s *Service) GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error) {
|
||||
if s == nil || s.repository == nil {
|
||||
return mictimedomain.LifetimeStats{}, xerr.New(xerr.Unavailable, "mic time repository is not configured")
|
||||
}
|
||||
return s.repository.GetLifetimeStats(ctx, appCode, userID)
|
||||
}
|
||||
|
||||
// RunRoomMicWorker consumes RoomMicChanged facts from room-service outbox.
|
||||
func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) {
|
||||
if s == nil || s.repository == nil || s.reader == nil {
|
||||
log.Printf("component=user_mic_time op=room_mic_worker status=disabled reason=missing_dependency")
|
||||
return
|
||||
}
|
||||
if options.PollInterval <= 0 {
|
||||
options.PollInterval = 5 * time.Second
|
||||
}
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = 100
|
||||
}
|
||||
|
||||
var cursor mictimedomain.RoomOutboxCursor
|
||||
for {
|
||||
processed, err := s.processBatch(ctx, &cursor, options.BatchSize)
|
||||
if err != nil {
|
||||
log.Printf("component=user_mic_time op=room_mic_worker worker_id=%s status=failed error=%q", options.WorkerID, err.Error())
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if processed >= options.BatchSize {
|
||||
// Backlog is still present; continue without sleeping until the cursor catches up.
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(options.PollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) processBatch(ctx context.Context, cursor *mictimedomain.RoomOutboxCursor, batchSize int) (int, error) {
|
||||
events, err := s.reader.ListRoomMicEventsAfter(ctx, *cursor, batchSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, event := range events {
|
||||
if _, err := s.repository.ProcessRoomMicEvent(ctx, event); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cursor.CreatedAtMs = event.OutboxCreatedAtMs
|
||||
cursor.EventID = event.EventID
|
||||
}
|
||||
return len(events), nil
|
||||
}
|
||||
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
)
|
||||
|
||||
@ -76,11 +77,12 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username
|
||||
|
||||
// CompleteOnboarding 是注册页唯一提交入口。
|
||||
// 它必须一次性校验并写入 username/avatar/gender/country/region_id/profile_completed,避免客户端拆接口拼出半完成状态。
|
||||
func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username string, avatar string, gender string, country string) (userdomain.User, error) {
|
||||
func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username string, avatar string, gender string, country string, inviteCode string, requestID string) (userdomain.User, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
avatar = strings.TrimSpace(avatar)
|
||||
gender = strings.TrimSpace(gender)
|
||||
country = userdomain.NormalizeCountryCode(country)
|
||||
inviteCode = invitedomain.NormalizeCode(inviteCode)
|
||||
if userID <= 0 {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||
}
|
||||
@ -111,6 +113,10 @@ func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username
|
||||
if !userdomain.ValidCountryCode(country) {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters")
|
||||
}
|
||||
if inviteCode != "" && !invitedomain.ValidCode(inviteCode) {
|
||||
// 邀请码不存在、格式错误或不可用都统一落到 INVALID_INVITE_CODE,避免邀请码枚举。
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
||||
}
|
||||
if s.userRepository == nil {
|
||||
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||
}
|
||||
@ -141,6 +147,8 @@ func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username
|
||||
Gender: gender,
|
||||
Country: resolvedCountry.CountryCode,
|
||||
RegionID: regionID,
|
||||
InviteCode: inviteCode,
|
||||
RequestID: strings.TrimSpace(requestID),
|
||||
CompletedAtMs: s.now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -322,7 +322,7 @@ func TestCompleteOnboardingWritesProfileRegionAndSkipsCountryCooldown(t *testing
|
||||
userservice.WithCountryChangeCooldown(time.Hour),
|
||||
)
|
||||
|
||||
user, err := svc.CompleteOnboarding(ctx, 10001, " hy ", " https://cdn.example/a.png ", " male ", " sg ")
|
||||
user, err := svc.CompleteOnboarding(ctx, 10001, " hy ", " https://cdn.example/a.png ", " male ", " sg ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteOnboarding failed: %v", err)
|
||||
}
|
||||
@ -362,7 +362,7 @@ func TestCountryWithoutExplicitRegionFallsBackToGlobal(t *testing.T) {
|
||||
t.Fatalf("country without mapping should resolve to GLOBAL: ok=%t region=%+v", ok, region)
|
||||
}
|
||||
|
||||
user, err := svc.CompleteOnboarding(ctx, 10001, "hy", "https://cdn.example/a.png", "male", "ZZ")
|
||||
user, err := svc.CompleteOnboarding(ctx, 10001, "hy", "https://cdn.example/a.png", "male", "ZZ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteOnboarding failed: %v", err)
|
||||
}
|
||||
@ -396,7 +396,7 @@ func TestCompleteOnboardingValidation(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := svc.CompleteOnboarding(ctx, 10001, tc.username, tc.avatar, tc.gender, tc.country)
|
||||
_, err := svc.CompleteOnboarding(ctx, 10001, tc.username, tc.avatar, tc.gender, tc.country, "", "")
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("expected INVALID_ARGUMENT, got %v", err)
|
||||
}
|
||||
|
||||
@ -7,7 +7,9 @@ import (
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
invitestorage "hyapp/services/user-service/internal/storage/mysql/invite"
|
||||
"hyapp/services/user-service/internal/storage/mysql/shared"
|
||||
userstorage "hyapp/services/user-service/internal/storage/mysql/user"
|
||||
)
|
||||
@ -32,7 +34,7 @@ func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string
|
||||
|
||||
// CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。
|
||||
|
||||
func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session) error {
|
||||
func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, inviteBind invitedomain.BindCommand) error {
|
||||
// 三方首次登录注册必须同时写用户、默认短号、三方绑定和首个 session。
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@ -44,6 +46,12 @@ func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.U
|
||||
// 默认短号冲突由 service 重试。
|
||||
return mapAuthDuplicateError(err)
|
||||
}
|
||||
if _, err := invitestorage.EnsurePrimaryCodeForUser(ctx, tx, user.AppCode, user.UserID, user.CreatedAtMs); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := invitestorage.BindRelation(ctx, tx, inviteBind); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO third_party_identities (app_code, user_id, provider, provider_subject, provider_union_id, created_at_ms, updated_at_ms)
|
||||
|
||||
@ -0,0 +1,536 @@
|
||||
// Package invite stores invitation codes, relations, counters and recharge progress in MySQL.
|
||||
package invite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/xerr"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
"hyapp/services/user-service/internal/storage/mysql/shared"
|
||||
)
|
||||
|
||||
const generatedCodeLength = 8
|
||||
|
||||
const inviteCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// Repository owns invitation persistence inside the user-service database.
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New creates an invitation repository backed by the shared user-service MySQL pool.
|
||||
func New(db *sql.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// EnsurePrimaryCodeForUser creates the current user's own shareable invite code inside the caller transaction.
|
||||
func EnsurePrimaryCodeForUser(ctx context.Context, tx *sql.Tx, appCode string, ownerUserID int64, nowMs int64) (string, error) {
|
||||
code, ok, err := primaryCodeForUpdate(ctx, tx, appCode, ownerUserID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ok {
|
||||
return code, nil
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
candidate := generateInviteCode(generatedCodeLength)
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_invite_codes (app_code, owner_user_id, code, code_type, status, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, appcode.Normalize(appCode), ownerUserID, candidate, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive, nowMs, nowMs)
|
||||
if err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
if !isDuplicate(err) {
|
||||
return "", err
|
||||
}
|
||||
// Owner-level duplicate means another code was created earlier in this transaction path; reuse it.
|
||||
if code, ok, lookupErr := primaryCodeForUpdate(ctx, tx, appCode, ownerUserID); lookupErr != nil {
|
||||
return "", lookupErr
|
||||
} else if ok {
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", xerr.New(xerr.Internal, "invite code allocation failed")
|
||||
}
|
||||
|
||||
// BindRelation validates an optional invite code and binds the invited user once inside the caller transaction.
|
||||
func BindRelation(ctx context.Context, tx *sql.Tx, command invitedomain.BindCommand) (invitedomain.Binding, error) {
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
command.InviteCode = invitedomain.NormalizeCode(command.InviteCode)
|
||||
command.Source = strings.TrimSpace(command.Source)
|
||||
command.RequestID = strings.TrimSpace(command.RequestID)
|
||||
if command.InviteCode == "" {
|
||||
return invitedomain.Binding{}, nil
|
||||
}
|
||||
if command.InvitedUserID <= 0 || !invitedomain.ValidCode(command.InviteCode) {
|
||||
return invitedomain.Binding{}, invalidInviteCode()
|
||||
}
|
||||
if command.BoundAtMs <= 0 {
|
||||
command.BoundAtMs = time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
code, err := resolveActiveCodeForUpdate(ctx, tx, command.AppCode, command.InviteCode)
|
||||
if err != nil {
|
||||
return invitedomain.Binding{}, err
|
||||
}
|
||||
if code.OwnerUserID == command.InvitedUserID || code.OwnerStatus != "active" {
|
||||
return invitedomain.Binding{}, invalidInviteCode()
|
||||
}
|
||||
|
||||
existing, exists, err := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID)
|
||||
if err != nil {
|
||||
return invitedomain.Binding{}, err
|
||||
}
|
||||
if exists {
|
||||
// 邀请归因只能首次绑定;重复提交不改写原邀请人,也不重复增加计数。
|
||||
return invitedomain.Binding{InviteCode: existing.InviteCode, InviterUserID: existing.InviterUserID}, nil
|
||||
}
|
||||
|
||||
policy, err := currentPolicyForUpdate(ctx, tx, command.AppCode, command.InvitedRegionID, command.BoundAtMs)
|
||||
if err != nil {
|
||||
return invitedomain.Binding{}, err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO user_invite_relations (
|
||||
app_code, invited_user_id, inviter_user_id, invite_code_id, invite_code, source, request_id, bound_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, command.AppCode, command.InvitedUserID, code.OwnerUserID, code.InviteCodeID, code.Code, command.Source, command.RequestID, command.BoundAtMs, command.BoundAtMs, command.BoundAtMs)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
existing, exists, lookupErr := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID)
|
||||
if lookupErr != nil {
|
||||
return invitedomain.Binding{}, lookupErr
|
||||
}
|
||||
if exists {
|
||||
return invitedomain.Binding{InviteCode: existing.InviteCode, InviterUserID: existing.InviterUserID}, nil
|
||||
}
|
||||
return invitedomain.Binding{}, nil
|
||||
}
|
||||
return invitedomain.Binding{}, err
|
||||
}
|
||||
if err := insertProgress(ctx, tx, command, code.OwnerUserID, policy); err != nil {
|
||||
return invitedomain.Binding{}, err
|
||||
}
|
||||
if err := incrementInviteCount(ctx, tx, command.AppCode, code.OwnerUserID, command.BoundAtMs); err != nil {
|
||||
return invitedomain.Binding{}, err
|
||||
}
|
||||
if err := insertUserOutbox(ctx, tx, command.AppCode, invitedomain.EventTypeUserInvited, "user_invite_relation", command.InvitedUserID, command.BoundAtMs, map[string]any{
|
||||
"invited_user_id": command.InvitedUserID,
|
||||
"inviter_user_id": code.OwnerUserID,
|
||||
"invite_code": code.Code,
|
||||
"request_id": command.RequestID,
|
||||
"source": command.Source,
|
||||
"bound_at_ms": command.BoundAtMs,
|
||||
}); err != nil {
|
||||
return invitedomain.Binding{}, err
|
||||
}
|
||||
|
||||
return invitedomain.Binding{Bound: true, InviteCode: code.Code, InviterUserID: code.OwnerUserID}, nil
|
||||
}
|
||||
|
||||
// GetOverview returns the precomputed invite read model for my-page aggregation.
|
||||
func (r *Repository) GetOverview(ctx context.Context, appCode string, userID int64, regionID int64, nowMs int64) (invitedomain.Overview, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return invitedomain.Overview{}, xerr.New(xerr.Unavailable, "invite repository is not configured")
|
||||
}
|
||||
if nowMs <= 0 {
|
||||
nowMs = time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
var overview invitedomain.Overview
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT code
|
||||
FROM user_invite_codes
|
||||
WHERE app_code = ? AND owner_user_id = ? AND code_type = ? AND status = ?
|
||||
ORDER BY invite_code_id DESC
|
||||
LIMIT 1
|
||||
`, appcode.Normalize(appCode), userID, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive).Scan(&overview.MyInviteCode)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return invitedomain.Overview{}, err
|
||||
}
|
||||
overview.InviteEnabled = overview.MyInviteCode != ""
|
||||
|
||||
err = r.db.QueryRowContext(ctx, `
|
||||
SELECT invite_count, valid_invite_count
|
||||
FROM user_invite_counters
|
||||
WHERE app_code = ? AND user_id = ?
|
||||
`, appcode.Normalize(appCode), userID).Scan(&overview.InviteCount, &overview.ValidInviteCount)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return invitedomain.Overview{}, err
|
||||
}
|
||||
|
||||
policy, err := currentPolicy(ctx, r.db, appCode, regionID, nowMs)
|
||||
if err == nil {
|
||||
overview.ValidInviteThresholdCoin = policy.RequiredRechargeCoinAmount
|
||||
} else if err != sql.ErrNoRows {
|
||||
return invitedomain.Overview{}, err
|
||||
}
|
||||
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
// ApplyRechargeEvent consumes one wallet recharge fact and advances invite validity idempotently.
|
||||
func (r *Repository) ApplyRechargeEvent(ctx context.Context, event invitedomain.RechargeEvent) (invitedomain.RechargeApplyResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return invitedomain.RechargeApplyResult{}, xerr.New(xerr.Unavailable, "invite repository is not configured")
|
||||
}
|
||||
event.AppCode = appcode.Normalize(event.AppCode)
|
||||
event.EventID = strings.TrimSpace(event.EventID)
|
||||
event.EventType = strings.TrimSpace(event.EventType)
|
||||
event.TransactionID = strings.TrimSpace(event.TransactionID)
|
||||
event.AssetType = strings.TrimSpace(event.AssetType)
|
||||
event.RechargeType = strings.TrimSpace(event.RechargeType)
|
||||
if event.RechargeType == "" {
|
||||
event.RechargeType = invitedomain.RechargeTypeCoinSellerTransfer
|
||||
}
|
||||
if event.EventID == "" || event.TransactionID == "" || event.InvitedUserID <= 0 {
|
||||
return invitedomain.RechargeApplyResult{}, xerr.New(xerr.InvalidArgument, "wallet recharge event identity is required")
|
||||
}
|
||||
if event.OccurredAtMs <= 0 {
|
||||
event.OccurredAtMs = time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := insertEventConsumption(ctx, tx, event); err != nil {
|
||||
if isDuplicate(err) {
|
||||
return invitedomain.RechargeApplyResult{Skipped: true, Reason: "already_consumed"}, nil
|
||||
}
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
if event.EventType != invitedomain.RechargeEventWalletRecorded || event.AssetType != "COIN" || event.RechargeCoinAmount <= 0 {
|
||||
if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "not_eligible_wallet_event", event.OccurredAtMs); err != nil {
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
return invitedomain.RechargeApplyResult{Skipped: true, Reason: "not_eligible_wallet_event"}, tx.Commit()
|
||||
}
|
||||
|
||||
progress, exists, err := progressForInvitedForUpdate(ctx, tx, event.AppCode, event.InvitedUserID)
|
||||
if err != nil {
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
if !exists {
|
||||
if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "no_invite_relation", event.OccurredAtMs); err != nil {
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
return invitedomain.RechargeApplyResult{Skipped: true, Reason: "no_invite_relation"}, tx.Commit()
|
||||
}
|
||||
if progress.Status == invitedomain.ProgressStatusValid {
|
||||
if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "already_valid", event.OccurredAtMs); err != nil {
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
return invitedomain.RechargeApplyResult{Skipped: true, Reason: "already_valid"}, tx.Commit()
|
||||
}
|
||||
if !eligibleRechargeType(progress.EligibleRechargeTypes, event.RechargeType) {
|
||||
if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "ineligible_recharge_type", event.OccurredAtMs); err != nil {
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
return invitedomain.RechargeApplyResult{Skipped: true, Reason: "ineligible_recharge_type"}, tx.Commit()
|
||||
}
|
||||
|
||||
newAccumulated := progress.AccumulatedRechargeCoinAmount + event.RechargeCoinAmount
|
||||
becameValid := newAccumulated >= progress.RequiredRechargeCoinAmount
|
||||
status := invitedomain.ProgressStatusPending
|
||||
var validAt any
|
||||
if becameValid {
|
||||
status = invitedomain.ProgressStatusValid
|
||||
validAt = event.OccurredAtMs
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE user_invite_recharge_progress
|
||||
SET accumulated_recharge_coin_amount = ?, status = ?, valid_at_ms = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND invited_user_id = ?
|
||||
`, newAccumulated, status, validAt, event.OccurredAtMs, event.AppCode, event.InvitedUserID)
|
||||
if err != nil {
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
if becameValid {
|
||||
if err := incrementValidInviteCount(ctx, tx, event.AppCode, progress.InviterUserID, event.OccurredAtMs); err != nil {
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
if err := insertUserOutbox(ctx, tx, event.AppCode, invitedomain.EventTypeUserInviteBecameValid, "user_invite_relation", event.InvitedUserID, event.OccurredAtMs, map[string]any{
|
||||
"invited_user_id": event.InvitedUserID,
|
||||
"inviter_user_id": progress.InviterUserID,
|
||||
"transaction_id": event.TransactionID,
|
||||
"accumulated_recharge_coin_amount": newAccumulated,
|
||||
"required_recharge_coin_amount": progress.RequiredRechargeCoinAmount,
|
||||
"valid_at_ms": event.OccurredAtMs,
|
||||
}); err != nil {
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
}
|
||||
if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "consumed", "", event.OccurredAtMs); err != nil {
|
||||
return invitedomain.RechargeApplyResult{}, err
|
||||
}
|
||||
|
||||
return invitedomain.RechargeApplyResult{Consumed: true, Valid: becameValid}, tx.Commit()
|
||||
}
|
||||
|
||||
type inviteCodeRow struct {
|
||||
InviteCodeID int64
|
||||
Code string
|
||||
OwnerUserID int64
|
||||
OwnerStatus string
|
||||
}
|
||||
|
||||
type relationRow struct {
|
||||
InviterUserID int64
|
||||
InviteCode string
|
||||
}
|
||||
|
||||
type policyRow struct {
|
||||
PolicyID int64
|
||||
PolicyVersion string
|
||||
RequiredRechargeCoinAmount int64
|
||||
EligibleRechargeTypes []string
|
||||
}
|
||||
|
||||
type progressRow struct {
|
||||
InviterUserID int64
|
||||
PolicyID int64
|
||||
PolicyVersion string
|
||||
RequiredRechargeCoinAmount int64
|
||||
AccumulatedRechargeCoinAmount int64
|
||||
Status string
|
||||
EligibleRechargeTypes []string
|
||||
}
|
||||
|
||||
func primaryCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, ownerUserID int64) (string, bool, error) {
|
||||
var code string
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT code
|
||||
FROM user_invite_codes
|
||||
WHERE app_code = ? AND owner_user_id = ? AND code_type = ? AND status = ?
|
||||
ORDER BY invite_code_id DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`, appcode.Normalize(appCode), ownerUserID, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive).Scan(&code)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return code, true, nil
|
||||
}
|
||||
|
||||
func resolveActiveCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, code string) (inviteCodeRow, error) {
|
||||
var row inviteCodeRow
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT c.invite_code_id, c.code, c.owner_user_id, u.status
|
||||
FROM user_invite_codes c
|
||||
INNER JOIN users u ON u.app_code = c.app_code AND u.user_id = c.owner_user_id
|
||||
WHERE c.app_code = ? AND c.code = ? AND c.status = ?
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`, appCode, code, invitedomain.CodeStatusActive).Scan(&row.InviteCodeID, &row.Code, &row.OwnerUserID, &row.OwnerStatus)
|
||||
if err == sql.ErrNoRows {
|
||||
return inviteCodeRow{}, invalidInviteCode()
|
||||
}
|
||||
if err != nil {
|
||||
return inviteCodeRow{}, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func relationForInvitedForUpdate(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64) (relationRow, bool, error) {
|
||||
var row relationRow
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT inviter_user_id, invite_code
|
||||
FROM user_invite_relations
|
||||
WHERE app_code = ? AND invited_user_id = ?
|
||||
FOR UPDATE
|
||||
`, appCode, invitedUserID).Scan(&row.InviterUserID, &row.InviteCode)
|
||||
if err == sql.ErrNoRows {
|
||||
return relationRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return relationRow{}, false, err
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
func currentPolicyForUpdate(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, nowMs int64) (policyRow, error) {
|
||||
policy, err := currentPolicy(ctx, tx, appCode, regionID, nowMs)
|
||||
if err == sql.ErrNoRows {
|
||||
return policyRow{}, xerr.New(xerr.Internal, "invite validity policy is not configured")
|
||||
}
|
||||
return policy, err
|
||||
}
|
||||
|
||||
func currentPolicy(ctx context.Context, q shared.Queryer, appCode string, regionID int64, nowMs int64) (policyRow, error) {
|
||||
var policy policyRow
|
||||
var eligibleJSON string
|
||||
err := q.QueryRowContext(ctx, `
|
||||
SELECT policy_id, policy_version, required_recharge_coin_amount, CAST(eligible_recharge_types AS CHAR)
|
||||
FROM user_invite_validity_policies
|
||||
WHERE app_code = ?
|
||||
AND status = 'active'
|
||||
AND (region_id = ? OR region_id = 0)
|
||||
AND effective_from_ms <= ?
|
||||
AND (effective_to_ms IS NULL OR effective_to_ms > ?)
|
||||
ORDER BY CASE WHEN region_id = ? THEN 0 ELSE 1 END, effective_from_ms DESC, policy_id DESC
|
||||
LIMIT 1
|
||||
`, appcode.Normalize(appCode), nullableRegion(regionID), nowMs, nowMs, nullableRegion(regionID)).Scan(&policy.PolicyID, &policy.PolicyVersion, &policy.RequiredRechargeCoinAmount, &eligibleJSON)
|
||||
if err != nil {
|
||||
return policyRow{}, err
|
||||
}
|
||||
policy.EligibleRechargeTypes = parseEligibleTypes(eligibleJSON)
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func insertProgress(ctx context.Context, tx *sql.Tx, command invitedomain.BindCommand, inviterUserID int64, policy policyRow) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_invite_recharge_progress (
|
||||
app_code, invited_user_id, inviter_user_id, policy_id, policy_version, required_recharge_coin_amount,
|
||||
accumulated_recharge_coin_amount, status, valid_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms
|
||||
`, command.AppCode, command.InvitedUserID, inviterUserID, policy.PolicyID, policy.PolicyVersion, policy.RequiredRechargeCoinAmount, invitedomain.ProgressStatusPending, command.BoundAtMs, command.BoundAtMs)
|
||||
return err
|
||||
}
|
||||
|
||||
func incrementInviteCount(ctx context.Context, tx *sql.Tx, appCode string, inviterUserID int64, nowMs int64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_invite_counters (app_code, user_id, invite_count, valid_invite_count, updated_at_ms)
|
||||
VALUES (?, ?, 1, 0, ?)
|
||||
ON DUPLICATE KEY UPDATE invite_count = invite_count + 1, updated_at_ms = VALUES(updated_at_ms)
|
||||
`, appCode, inviterUserID, nowMs)
|
||||
return err
|
||||
}
|
||||
|
||||
func incrementValidInviteCount(ctx context.Context, tx *sql.Tx, appCode string, inviterUserID int64, nowMs int64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_invite_counters (app_code, user_id, invite_count, valid_invite_count, updated_at_ms)
|
||||
VALUES (?, ?, 0, 1, ?)
|
||||
ON DUPLICATE KEY UPDATE valid_invite_count = valid_invite_count + 1, updated_at_ms = VALUES(updated_at_ms)
|
||||
`, appCode, inviterUserID, nowMs)
|
||||
return err
|
||||
}
|
||||
|
||||
func insertUserOutbox(ctx context.Context, tx *sql.Tx, appCode string, eventType string, aggregateType string, aggregateID int64, nowMs int64, payload any) error {
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)
|
||||
`, appcode.Normalize(appCode), inviteEventID(eventType, aggregateID), eventType, aggregateType, aggregateID, string(payloadJSON), nowMs, nowMs)
|
||||
return err
|
||||
}
|
||||
|
||||
func insertEventConsumption(ctx context.Context, tx *sql.Tx, event invitedomain.RechargeEvent) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_invite_event_consumption (
|
||||
app_code, event_id, event_type, transaction_id, invited_user_id, status, skip_reason, consumed_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, 'processing', '', NULL, ?, ?)
|
||||
`, event.AppCode, event.EventID, event.EventType, event.TransactionID, event.InvitedUserID, event.OccurredAtMs, event.OccurredAtMs)
|
||||
return err
|
||||
}
|
||||
|
||||
func finishConsumption(ctx context.Context, tx *sql.Tx, appCode string, eventID string, status string, reason string, nowMs int64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_invite_event_consumption
|
||||
SET status = ?, skip_reason = ?, consumed_at_ms = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND event_id = ?
|
||||
`, status, reason, nowMs, nowMs, appCode, eventID)
|
||||
return err
|
||||
}
|
||||
|
||||
func progressForInvitedForUpdate(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64) (progressRow, bool, error) {
|
||||
var row progressRow
|
||||
var eligibleJSON string
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT p.inviter_user_id, p.policy_id, p.policy_version, p.required_recharge_coin_amount,
|
||||
p.accumulated_recharge_coin_amount, p.status, CAST(pol.eligible_recharge_types AS CHAR)
|
||||
FROM user_invite_recharge_progress p
|
||||
INNER JOIN user_invite_validity_policies pol ON pol.app_code = p.app_code AND pol.policy_id = p.policy_id
|
||||
WHERE p.app_code = ? AND p.invited_user_id = ?
|
||||
FOR UPDATE
|
||||
`, appCode, invitedUserID).Scan(&row.InviterUserID, &row.PolicyID, &row.PolicyVersion, &row.RequiredRechargeCoinAmount, &row.AccumulatedRechargeCoinAmount, &row.Status, &eligibleJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return progressRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return progressRow{}, false, err
|
||||
}
|
||||
row.EligibleRechargeTypes = parseEligibleTypes(eligibleJSON)
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
func parseEligibleTypes(raw string) []string {
|
||||
var types []string
|
||||
if err := json.Unmarshal([]byte(raw), &types); err != nil || len(types) == 0 {
|
||||
return []string{invitedomain.RechargeTypeCoinSellerTransfer}
|
||||
}
|
||||
for index := range types {
|
||||
types[index] = strings.TrimSpace(types[index])
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func eligibleRechargeType(types []string, eventType string) bool {
|
||||
for _, item := range types {
|
||||
if item == eventType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func inviteEventID(eventType string, aggregateID int64) string {
|
||||
return fmt.Sprintf("%s:%d", eventType, aggregateID)
|
||||
}
|
||||
|
||||
func invalidInviteCode() error {
|
||||
return xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
||||
}
|
||||
|
||||
func nullableRegion(regionID int64) int64 {
|
||||
if regionID <= 0 {
|
||||
return 0
|
||||
}
|
||||
return regionID
|
||||
}
|
||||
|
||||
func generateInviteCode(length int) string {
|
||||
if length <= 0 {
|
||||
length = generatedCodeLength
|
||||
}
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
fallback := strings.ToUpper(strings.ReplaceAll(idgen.New("INV"), "_", ""))
|
||||
if len(fallback) >= length {
|
||||
return fallback[len(fallback)-length:]
|
||||
}
|
||||
return fmt.Sprintf("%0*s", length, fallback)
|
||||
}
|
||||
for index, value := range buf {
|
||||
buf[index] = inviteCodeAlphabet[int(value)%len(inviteCodeAlphabet)]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func isDuplicate(err error) bool {
|
||||
var mysqlErr *mysqldriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||
}
|
||||
@ -0,0 +1,136 @@
|
||||
package invite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
"hyapp/services/user-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
func TestInviteRelationCountersAndRechargeValidity(t *testing.T) {
|
||||
repo := mysqltest.NewRepository(t)
|
||||
ctx := context.Background()
|
||||
|
||||
createUser(t, repo, 10001, "163001", 1000)
|
||||
createUser(t, repo, 10002, "163002", 1001)
|
||||
|
||||
inviter, err := repo.GetUser(ctx, 10001)
|
||||
if err != nil {
|
||||
t.Fatalf("get inviter failed: %v", err)
|
||||
}
|
||||
if inviter.InviteOverview.MyInviteCode == "" || !inviter.InviteOverview.InviteEnabled || inviter.InviteOverview.ValidInviteThresholdCoin != 80000 {
|
||||
t.Fatalf("inviter should have active code and default policy threshold: %+v", inviter.InviteOverview)
|
||||
}
|
||||
|
||||
invited, err := repo.CompleteOnboarding(ctx, userdomain.CompleteOnboardingCommand{
|
||||
AppCode: "lalu",
|
||||
UserID: 10002,
|
||||
Username: "alice",
|
||||
Avatar: "https://cdn.example/a.png",
|
||||
Gender: "female",
|
||||
Country: "SG",
|
||||
InviteCode: inviter.InviteOverview.MyInviteCode,
|
||||
RequestID: "req-invite-bind",
|
||||
CompletedAtMs: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("complete onboarding with invite failed: %v", err)
|
||||
}
|
||||
if !invited.InviteBinding.Bound || invited.InviteBinding.InviterUserID != 10001 {
|
||||
t.Fatalf("invite binding mismatch: %+v", invited.InviteBinding)
|
||||
}
|
||||
|
||||
inviter, err = repo.GetUser(ctx, 10001)
|
||||
if err != nil {
|
||||
t.Fatalf("get inviter after bind failed: %v", err)
|
||||
}
|
||||
if inviter.InviteOverview.InviteCount != 1 || inviter.InviteOverview.ValidInviteCount != 0 {
|
||||
t.Fatalf("invite counter after bind mismatch: %+v", inviter.InviteOverview)
|
||||
}
|
||||
|
||||
inviteRepo := repo.Repository.InviteRepository()
|
||||
first, err := inviteRepo.ApplyRechargeEvent(ctx, invitedomain.RechargeEvent{
|
||||
AppCode: "lalu",
|
||||
EventID: "evt-recharge-1",
|
||||
EventType: invitedomain.RechargeEventWalletRecorded,
|
||||
TransactionID: "tx-recharge-1",
|
||||
CommandID: "cmd-recharge-1",
|
||||
InvitedUserID: 10002,
|
||||
AssetType: "COIN",
|
||||
RechargeCoinAmount: 79999,
|
||||
RechargeType: invitedomain.RechargeTypeCoinSellerTransfer,
|
||||
OccurredAtMs: 3000,
|
||||
})
|
||||
if err != nil || !first.Consumed || first.Valid {
|
||||
t.Fatalf("first recharge result mismatch: result=%+v err=%v", first, err)
|
||||
}
|
||||
inviter, _ = repo.GetUser(ctx, 10001)
|
||||
if inviter.InviteOverview.ValidInviteCount != 0 {
|
||||
t.Fatalf("79999 coin must not count as valid invite: %+v", inviter.InviteOverview)
|
||||
}
|
||||
|
||||
second, err := inviteRepo.ApplyRechargeEvent(ctx, invitedomain.RechargeEvent{
|
||||
AppCode: "lalu",
|
||||
EventID: "evt-recharge-2",
|
||||
EventType: invitedomain.RechargeEventWalletRecorded,
|
||||
TransactionID: "tx-recharge-2",
|
||||
CommandID: "cmd-recharge-2",
|
||||
InvitedUserID: 10002,
|
||||
AssetType: "COIN",
|
||||
RechargeCoinAmount: 1,
|
||||
RechargeType: invitedomain.RechargeTypeCoinSellerTransfer,
|
||||
OccurredAtMs: 4000,
|
||||
})
|
||||
if err != nil || !second.Consumed || !second.Valid {
|
||||
t.Fatalf("second recharge should make invite valid: result=%+v err=%v", second, err)
|
||||
}
|
||||
duplicate, err := inviteRepo.ApplyRechargeEvent(ctx, invitedomain.RechargeEvent{
|
||||
AppCode: "lalu",
|
||||
EventID: "evt-recharge-2",
|
||||
EventType: invitedomain.RechargeEventWalletRecorded,
|
||||
TransactionID: "tx-recharge-2",
|
||||
CommandID: "cmd-recharge-2",
|
||||
InvitedUserID: 10002,
|
||||
AssetType: "COIN",
|
||||
RechargeCoinAmount: 1,
|
||||
RechargeType: invitedomain.RechargeTypeCoinSellerTransfer,
|
||||
OccurredAtMs: 4000,
|
||||
})
|
||||
if err != nil || !duplicate.Skipped || duplicate.Reason != "already_consumed" {
|
||||
t.Fatalf("duplicate recharge should be idempotently skipped: result=%+v err=%v", duplicate, err)
|
||||
}
|
||||
|
||||
inviter, err = repo.GetUser(ctx, 10001)
|
||||
if err != nil {
|
||||
t.Fatalf("get inviter after valid failed: %v", err)
|
||||
}
|
||||
if inviter.InviteOverview.InviteCount != 1 || inviter.InviteOverview.ValidInviteCount != 1 {
|
||||
t.Fatalf("valid invite counter mismatch: %+v", inviter.InviteOverview)
|
||||
}
|
||||
}
|
||||
|
||||
func createUser(t *testing.T, repo *mysqltest.Repository, userID int64, displayUserID string, nowMs int64) {
|
||||
t.Helper()
|
||||
if err := repo.CreateUserWithIdentity(context.Background(), userdomain.User{
|
||||
AppCode: "lalu",
|
||||
UserID: userID,
|
||||
DefaultDisplayUserID: displayUserID,
|
||||
CurrentDisplayUserID: displayUserID,
|
||||
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
Status: userdomain.StatusActive,
|
||||
OnboardingStatus: userdomain.OnboardingStatusProfileRequired,
|
||||
CreatedAtMs: nowMs,
|
||||
UpdatedAtMs: nowMs,
|
||||
}, userdomain.Identity{
|
||||
AppCode: "lalu",
|
||||
UserID: userID,
|
||||
DisplayUserID: displayUserID,
|
||||
DefaultDisplayUserID: displayUserID,
|
||||
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
Status: userdomain.DisplayUserIDStatusActive,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user %d failed: %v", userID, err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package invite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
)
|
||||
|
||||
// WalletOutboxReader reads recharge facts from wallet-service outbox without mutating wallet state.
|
||||
type WalletOutboxReader struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewWalletOutboxReader creates a read-only wallet outbox scanner.
|
||||
func NewWalletOutboxReader(db *sql.DB) *WalletOutboxReader {
|
||||
return &WalletOutboxReader{db: db}
|
||||
}
|
||||
|
||||
// ListRechargeEventsAfter returns wallet recharge events after the caller's in-memory cursor.
|
||||
func (r *WalletOutboxReader) ListRechargeEventsAfter(ctx context.Context, cursor invitedomain.WalletRechargeCursor, batchSize int) ([]invitedomain.RechargeEvent, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "wallet outbox reader is not configured")
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, available_delta,
|
||||
COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms
|
||||
FROM wallet_outbox
|
||||
WHERE event_type = ?
|
||||
AND (created_at_ms > ? OR (created_at_ms = ? AND event_id > ?))
|
||||
ORDER BY created_at_ms ASC, event_id ASC
|
||||
LIMIT ?
|
||||
`, invitedomain.RechargeEventWalletRecorded, cursor.CreatedAtMs, cursor.CreatedAtMs, cursor.EventID, batchSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]invitedomain.RechargeEvent, 0, batchSize)
|
||||
for rows.Next() {
|
||||
var event invitedomain.RechargeEvent
|
||||
if err := rows.Scan(&event.AppCode, &event.EventID, &event.EventType, &event.TransactionID, &event.CommandID, &event.InvitedUserID, &event.AssetType, &event.RechargeCoinAmount, &event.PayloadJSON, &event.OccurredAtMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
event.AppCode = appcode.Normalize(event.AppCode)
|
||||
event.RechargeType = rechargeTypeFromPayload(event.PayloadJSON)
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func rechargeTypeFromPayload(payload string) string {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &decoded); err == nil {
|
||||
if value, ok := decoded["recharge_type"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
// 当前 wallet-service 的 WalletRechargeRecorded 来源是币商转账充值事实。
|
||||
return invitedomain.RechargeTypeCoinSellerTransfer
|
||||
}
|
||||
@ -0,0 +1,471 @@
|
||||
// Package mictime stores user-scoped microphone online duration read models in MySQL.
|
||||
package mictime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
mictimedomain "hyapp/services/user-service/internal/domain/mictime"
|
||||
)
|
||||
|
||||
// Repository owns mic session state and user duration aggregates inside user-service MySQL.
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New creates a mic duration repository backed by the shared user-service connection pool.
|
||||
func New(db *sql.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// ProcessRoomMicEvent idempotently applies one RoomMicChanged fact.
|
||||
func (r *Repository) ProcessRoomMicEvent(ctx context.Context, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return mictimedomain.ApplyResult{}, xerr.New(xerr.Unavailable, "mic time repository is not configured")
|
||||
}
|
||||
event = normalizeRoomMicEvent(event)
|
||||
if event.EventID == "" || event.EventType == "" {
|
||||
return mictimedomain.ApplyResult{}, xerr.New(xerr.InvalidArgument, "room mic event identity is required")
|
||||
}
|
||||
if event.OccurredAtMs <= 0 {
|
||||
event.OccurredAtMs = time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := insertEventConsumption(ctx, tx, event); err != nil {
|
||||
if isDuplicate(err) {
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "already_consumed"}, nil
|
||||
}
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
|
||||
if event.EventType != mictimedomain.RoomMicChangedEventType {
|
||||
if err := finishEventConsumption(ctx, tx, event, "skipped", "not_room_mic_changed"); err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "not_room_mic_changed"}, tx.Commit()
|
||||
}
|
||||
if event.MicSessionID == "" || event.TargetUserID <= 0 {
|
||||
if err := finishEventConsumption(ctx, tx, event, "skipped", "incomplete_mic_event"); err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "incomplete_mic_event"}, tx.Commit()
|
||||
}
|
||||
|
||||
var result mictimedomain.ApplyResult
|
||||
switch event.Action {
|
||||
case mictimedomain.ActionUp:
|
||||
result, err = applyUp(ctx, tx, event)
|
||||
case mictimedomain.ActionPublishConfirmed:
|
||||
result, err = applyPublishConfirmed(ctx, tx, event)
|
||||
case mictimedomain.ActionChange:
|
||||
result, err = applyChange(ctx, tx, event)
|
||||
case mictimedomain.ActionDown:
|
||||
result, err = applyDown(ctx, tx, event)
|
||||
default:
|
||||
result = mictimedomain.ApplyResult{Skipped: true, Reason: "unsupported_mic_action"}
|
||||
}
|
||||
if err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
if result.Reason == "" && result.Skipped {
|
||||
result.Reason = "skipped"
|
||||
}
|
||||
status := "consumed"
|
||||
if result.Skipped {
|
||||
status = "skipped"
|
||||
}
|
||||
if err := finishEventConsumption(ctx, tx, event, status, result.Reason); err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
if !result.Skipped {
|
||||
result.Consumed = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLifetimeStats returns the current user-wide mic duration aggregate.
|
||||
func (r *Repository) GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return mictimedomain.LifetimeStats{}, xerr.New(xerr.Unavailable, "mic time repository is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return mictimedomain.LifetimeStats{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||
}
|
||||
stats := mictimedomain.LifetimeStats{AppCode: appcode.Normalize(appCode), UserID: userID}
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT seat_occupied_ms, mic_online_ms, session_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms
|
||||
FROM user_mic_lifetime_stats
|
||||
WHERE app_code = ? AND user_id = ?
|
||||
`, stats.AppCode, userID).Scan(&stats.SeatOccupiedMs, &stats.MicOnlineMs, &stats.SessionCount, &stats.FirstMicAtMs, &stats.LastMicAtMs, &stats.UpdatedAtMs)
|
||||
if err == sql.ErrNoRows {
|
||||
return stats, nil
|
||||
}
|
||||
if err != nil {
|
||||
return mictimedomain.LifetimeStats{}, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func applyUp(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
|
||||
seatNo := event.ToSeat
|
||||
if seatNo <= 0 {
|
||||
seatNo = event.FromSeat
|
||||
}
|
||||
if seatNo <= 0 {
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "missing_seat_no"}, nil
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_mic_sessions (
|
||||
app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status,
|
||||
seat_started_at_ms, publishing_started_at_ms, ended_at_ms, end_reason,
|
||||
seat_occupied_ms, mic_online_ms, opened_event_id, publish_event_id, closed_event_id,
|
||||
last_event_id, last_room_version, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, '', 0, 0, ?, '', '', ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
current_seat_no = CASE WHEN status = ? THEN current_seat_no ELSE VALUES(current_seat_no) END,
|
||||
last_event_id = VALUES(last_event_id),
|
||||
last_room_version = GREATEST(last_room_version, VALUES(last_room_version)),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, event.AppCode, event.MicSessionID, event.TargetUserID, event.RoomID, seatNo, seatNo, mictimedomain.SessionStatusPendingPublish,
|
||||
event.OccurredAtMs, event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.OccurredAtMs, mictimedomain.SessionStatusEnded)
|
||||
if err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Consumed: true}, nil
|
||||
}
|
||||
|
||||
func applyPublishConfirmed(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
|
||||
session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID)
|
||||
if err != nil || !exists {
|
||||
if err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil
|
||||
}
|
||||
if session.UserID != event.TargetUserID {
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil
|
||||
}
|
||||
if session.Status == mictimedomain.SessionStatusEnded {
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil
|
||||
}
|
||||
publishAt := event.PublishEventTimeMs
|
||||
if publishAt <= 0 {
|
||||
publishAt = event.OccurredAtMs
|
||||
}
|
||||
if publishAt < session.SeatStartedAtMs {
|
||||
// RTC/client clocks can arrive slightly earlier than server MicUp time; clamp to avoid negative online windows.
|
||||
publishAt = session.SeatStartedAtMs
|
||||
}
|
||||
if session.PublishingStartedAtMs.Valid && publishAt <= session.PublishingStartedAtMs.Int64 {
|
||||
if err := updateSessionLastEvent(ctx, tx, event); err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "publish_already_confirmed"}, nil
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE user_mic_sessions
|
||||
SET status = ?, publishing_started_at_ms = ?, publish_event_id = ?, last_event_id = ?,
|
||||
last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
|
||||
WHERE app_code = ? AND mic_session_id = ? AND status <> ?
|
||||
`, mictimedomain.SessionStatusPublishing, publishAt, event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs,
|
||||
event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded)
|
||||
if err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Consumed: true}, nil
|
||||
}
|
||||
|
||||
func applyChange(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
|
||||
session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID)
|
||||
if err != nil || !exists {
|
||||
if err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil
|
||||
}
|
||||
if session.UserID != event.TargetUserID {
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil
|
||||
}
|
||||
if session.Status == mictimedomain.SessionStatusEnded {
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil
|
||||
}
|
||||
if event.ToSeat <= 0 {
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "missing_target_seat"}, nil
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE user_mic_sessions
|
||||
SET current_seat_no = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
|
||||
WHERE app_code = ? AND mic_session_id = ? AND status <> ?
|
||||
`, event.ToSeat, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded)
|
||||
if err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Consumed: true}, nil
|
||||
}
|
||||
|
||||
func applyDown(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
|
||||
session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID)
|
||||
if err != nil || !exists {
|
||||
if err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil
|
||||
}
|
||||
if session.UserID != event.TargetUserID {
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil
|
||||
}
|
||||
if session.Status == mictimedomain.SessionStatusEnded {
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil
|
||||
}
|
||||
|
||||
endAt := event.OccurredAtMs
|
||||
if endAt < session.SeatStartedAtMs {
|
||||
endAt = session.SeatStartedAtMs
|
||||
}
|
||||
seatOccupiedMs := maxInt64(0, endAt-session.SeatStartedAtMs)
|
||||
micOnlineMs := int64(0)
|
||||
if session.PublishingStartedAtMs.Valid {
|
||||
publishStart := session.PublishingStartedAtMs.Int64
|
||||
if publishStart < session.SeatStartedAtMs {
|
||||
publishStart = session.SeatStartedAtMs
|
||||
}
|
||||
micOnlineMs = maxInt64(0, endAt-publishStart)
|
||||
}
|
||||
endReason := strings.TrimSpace(event.Reason)
|
||||
if endReason == "" {
|
||||
endReason = "down"
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE user_mic_sessions
|
||||
SET status = ?, ended_at_ms = ?, end_reason = ?, seat_occupied_ms = ?, mic_online_ms = ?,
|
||||
closed_event_id = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
|
||||
WHERE app_code = ? AND mic_session_id = ? AND status <> ?
|
||||
`, mictimedomain.SessionStatusEnded, endAt, endReason, seatOccupiedMs, micOnlineMs,
|
||||
event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded)
|
||||
if err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
if err := incrementLifetime(ctx, tx, session, event, endAt, seatOccupiedMs, micOnlineMs); err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
if err := incrementDaily(ctx, tx, session, event, endAt); err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Consumed: true, Closed: true}, nil
|
||||
}
|
||||
|
||||
func incrementLifetime(ctx context.Context, tx *sql.Tx, session micSessionRow, event mictimedomain.RoomMicEvent, endAt int64, seatMs int64, micMs int64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_mic_lifetime_stats (
|
||||
app_code, user_id, seat_occupied_ms, mic_online_ms, session_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, 1, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
seat_occupied_ms = seat_occupied_ms + VALUES(seat_occupied_ms),
|
||||
mic_online_ms = mic_online_ms + VALUES(mic_online_ms),
|
||||
session_count = session_count + 1,
|
||||
first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END,
|
||||
last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, event.AppCode, session.UserID, seatMs, micMs, session.SeatStartedAtMs, endAt, event.OccurredAtMs)
|
||||
return err
|
||||
}
|
||||
|
||||
func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, event mictimedomain.RoomMicEvent, endAt int64) error {
|
||||
seatParts := splitByUTCDay(session.SeatStartedAtMs, endAt)
|
||||
micParts := map[string]dayDuration{}
|
||||
if session.PublishingStartedAtMs.Valid {
|
||||
publishStart := session.PublishingStartedAtMs.Int64
|
||||
if publishStart < session.SeatStartedAtMs {
|
||||
publishStart = session.SeatStartedAtMs
|
||||
}
|
||||
micParts = splitByUTCDay(publishStart, endAt)
|
||||
}
|
||||
dates := map[string]struct{}{}
|
||||
for date := range seatParts {
|
||||
dates[date] = struct{}{}
|
||||
}
|
||||
for date := range micParts {
|
||||
dates[date] = struct{}{}
|
||||
}
|
||||
for date := range dates {
|
||||
seatPart := seatParts[date]
|
||||
micPart := micParts[date]
|
||||
firstAt := minPositive(seatPart.FirstAtMs, micPart.FirstAtMs)
|
||||
lastAt := maxInt64(seatPart.LastAtMs, micPart.LastAtMs)
|
||||
if firstAt == 0 || lastAt == 0 {
|
||||
continue
|
||||
}
|
||||
sessionCount := int64(0)
|
||||
roomCount := int64(0)
|
||||
if seatPart.DurationMs > 0 {
|
||||
// A cross-day session is visible on every day it occupied a seat.
|
||||
sessionCount = 1
|
||||
roomCount = 1
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_mic_daily_stats (
|
||||
app_code, user_id, stat_date, seat_occupied_ms, mic_online_ms, session_count, room_count,
|
||||
first_mic_at_ms, last_mic_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
seat_occupied_ms = seat_occupied_ms + VALUES(seat_occupied_ms),
|
||||
mic_online_ms = mic_online_ms + VALUES(mic_online_ms),
|
||||
session_count = session_count + VALUES(session_count),
|
||||
room_count = room_count + VALUES(room_count),
|
||||
first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END,
|
||||
last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, event.AppCode, session.UserID, date, seatPart.DurationMs, micPart.DurationMs, sessionCount, roomCount, firstAt, lastAt, event.OccurredAtMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type micSessionRow struct {
|
||||
UserID int64
|
||||
RoomID string
|
||||
FirstSeatNo int32
|
||||
CurrentSeatNo int32
|
||||
Status string
|
||||
SeatStartedAtMs int64
|
||||
PublishingStartedAtMs sql.NullInt64
|
||||
EndedAtMs sql.NullInt64
|
||||
LastRoomVersion int64
|
||||
}
|
||||
|
||||
func sessionForUpdate(ctx context.Context, tx *sql.Tx, appCode string, micSessionID string) (micSessionRow, bool, error) {
|
||||
var session micSessionRow
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms,
|
||||
publishing_started_at_ms, ended_at_ms, last_room_version
|
||||
FROM user_mic_sessions
|
||||
WHERE app_code = ? AND mic_session_id = ?
|
||||
FOR UPDATE
|
||||
`, appCode, micSessionID).Scan(&session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status,
|
||||
&session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion)
|
||||
if err == sql.ErrNoRows {
|
||||
return micSessionRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return micSessionRow{}, false, err
|
||||
}
|
||||
return session, true, nil
|
||||
}
|
||||
|
||||
func updateSessionLastEvent(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_mic_sessions
|
||||
SET last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
|
||||
WHERE app_code = ? AND mic_session_id = ?
|
||||
`, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func insertEventConsumption(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_mic_event_consumption (
|
||||
app_code, event_id, event_type, mic_session_id, user_id, action, status, skip_reason,
|
||||
created_at_ms, consumed_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'processing', '', ?, NULL, ?)
|
||||
`, event.AppCode, event.EventID, event.EventType, event.MicSessionID, event.TargetUserID, event.Action, event.OccurredAtMs, event.OccurredAtMs)
|
||||
return err
|
||||
}
|
||||
|
||||
func finishEventConsumption(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent, status string, reason string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_mic_event_consumption
|
||||
SET status = ?, skip_reason = ?, consumed_at_ms = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND event_id = ?
|
||||
`, status, strings.TrimSpace(reason), event.OccurredAtMs, event.OccurredAtMs, event.AppCode, event.EventID)
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeRoomMicEvent(event mictimedomain.RoomMicEvent) mictimedomain.RoomMicEvent {
|
||||
event.AppCode = appcode.Normalize(event.AppCode)
|
||||
event.EventID = strings.TrimSpace(event.EventID)
|
||||
event.EventType = strings.TrimSpace(event.EventType)
|
||||
event.RoomID = strings.TrimSpace(event.RoomID)
|
||||
event.Action = strings.TrimSpace(event.Action)
|
||||
event.MicSessionID = strings.TrimSpace(event.MicSessionID)
|
||||
event.PublishState = strings.TrimSpace(event.PublishState)
|
||||
event.Reason = strings.TrimSpace(event.Reason)
|
||||
return event
|
||||
}
|
||||
|
||||
type dayDuration struct {
|
||||
DurationMs int64
|
||||
FirstAtMs int64
|
||||
LastAtMs int64
|
||||
}
|
||||
|
||||
func splitByUTCDay(startMs int64, endMs int64) map[string]dayDuration {
|
||||
parts := map[string]dayDuration{}
|
||||
if endMs <= startMs {
|
||||
return parts
|
||||
}
|
||||
for cursor := startMs; cursor < endMs; {
|
||||
cursorTime := time.UnixMilli(cursor).UTC()
|
||||
dayStart := time.Date(cursorTime.Year(), cursorTime.Month(), cursorTime.Day(), 0, 0, 0, 0, time.UTC)
|
||||
nextDayMs := dayStart.AddDate(0, 0, 1).UnixMilli()
|
||||
segmentEnd := minInt64(endMs, nextDayMs)
|
||||
date := dayStart.Format("2006-01-02")
|
||||
part := parts[date]
|
||||
if part.FirstAtMs == 0 || cursor < part.FirstAtMs {
|
||||
part.FirstAtMs = cursor
|
||||
}
|
||||
if segmentEnd > part.LastAtMs {
|
||||
part.LastAtMs = segmentEnd
|
||||
}
|
||||
part.DurationMs += segmentEnd - cursor
|
||||
parts[date] = part
|
||||
cursor = segmentEnd
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func isDuplicate(err error) bool {
|
||||
var mysqlErr *mysqldriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||
}
|
||||
|
||||
func minPositive(a int64, b int64) int64 {
|
||||
if a <= 0 {
|
||||
return b
|
||||
}
|
||||
if b <= 0 || a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func minInt64(a int64, b int64) int64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt64(a int64, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
package mictime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mictimedomain "hyapp/services/user-service/internal/domain/mictime"
|
||||
"hyapp/services/user-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
func TestProcessRoomMicEventsAggregatesLifetimeAndDeduplicates(t *testing.T) {
|
||||
repo := mysqltest.NewRepository(t).Repository.MicTimeRepository()
|
||||
ctx := context.Background()
|
||||
base := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC).UnixMilli()
|
||||
micSessionID := "mic_session_normal"
|
||||
|
||||
mustApply(t, repo, roomMicEvent("evt-up", micSessionID, mictimedomain.ActionUp, 10001, 0, 1, base))
|
||||
mustApply(t, repo, roomMicEvent("evt-confirm", micSessionID, mictimedomain.ActionPublishConfirmed, 10001, 1, 1, base+60_000))
|
||||
mustApply(t, repo, roomMicEvent("evt-change", micSessionID, mictimedomain.ActionChange, 10001, 1, 2, base+5*60_000))
|
||||
mustApply(t, repo, roomMicEvent("evt-down", micSessionID, mictimedomain.ActionDown, 10001, 2, 0, base+11*60_000))
|
||||
|
||||
stats, err := repo.GetLifetimeStats(ctx, "lalu", 10001)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLifetimeStats failed: %v", err)
|
||||
}
|
||||
if stats.SeatOccupiedMs != 11*60_000 || stats.MicOnlineMs != 10*60_000 || stats.SessionCount != 1 {
|
||||
t.Fatalf("normal mic duration mismatch: %+v", stats)
|
||||
}
|
||||
|
||||
duplicate, err := repo.ProcessRoomMicEvent(ctx, roomMicEvent("evt-down", micSessionID, mictimedomain.ActionDown, 10001, 2, 0, base+11*60_000))
|
||||
if err != nil || !duplicate.Skipped || duplicate.Reason != "already_consumed" {
|
||||
t.Fatalf("duplicate down should skip: result=%+v err=%v", duplicate, err)
|
||||
}
|
||||
stats, _ = repo.GetLifetimeStats(ctx, "lalu", 10001)
|
||||
if stats.SeatOccupiedMs != 11*60_000 || stats.MicOnlineMs != 10*60_000 || stats.SessionCount != 1 {
|
||||
t.Fatalf("duplicate down must not double count: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPendingPublishTimeoutCountsSeatButNotMicOnline(t *testing.T) {
|
||||
repo := mysqltest.NewRepository(t).Repository.MicTimeRepository()
|
||||
ctx := context.Background()
|
||||
base := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC).UnixMilli()
|
||||
micSessionID := "mic_session_pending_timeout"
|
||||
|
||||
mustApply(t, repo, roomMicEvent("evt-pending-up", micSessionID, mictimedomain.ActionUp, 10002, 0, 1, base))
|
||||
down := roomMicEvent("evt-pending-down", micSessionID, mictimedomain.ActionDown, 10002, 1, 0, base+2*60_000)
|
||||
down.Reason = "publish_timeout"
|
||||
mustApply(t, repo, down)
|
||||
|
||||
stats, err := repo.GetLifetimeStats(ctx, "lalu", 10002)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLifetimeStats failed: %v", err)
|
||||
}
|
||||
if stats.SeatOccupiedMs != 2*60_000 || stats.MicOnlineMs != 0 || stats.SessionCount != 1 {
|
||||
t.Fatalf("pending timeout duration mismatch: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCrossDayMicSessionKeepsLifetimeTotals(t *testing.T) {
|
||||
repo := mysqltest.NewRepository(t).Repository.MicTimeRepository()
|
||||
ctx := context.Background()
|
||||
base := time.Date(2026, 5, 8, 23, 59, 0, 0, time.UTC).UnixMilli()
|
||||
micSessionID := "mic_session_cross_day"
|
||||
|
||||
mustApply(t, repo, roomMicEvent("evt-cross-up", micSessionID, mictimedomain.ActionUp, 10003, 0, 1, base))
|
||||
mustApply(t, repo, roomMicEvent("evt-cross-confirm", micSessionID, mictimedomain.ActionPublishConfirmed, 10003, 1, 1, base+30_000))
|
||||
mustApply(t, repo, roomMicEvent("evt-cross-down", micSessionID, mictimedomain.ActionDown, 10003, 1, 0, base+2*60_000))
|
||||
|
||||
stats, err := repo.GetLifetimeStats(ctx, "lalu", 10003)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLifetimeStats failed: %v", err)
|
||||
}
|
||||
if stats.SeatOccupiedMs != 2*60_000 || stats.MicOnlineMs != 90_000 || stats.SessionCount != 1 {
|
||||
t.Fatalf("cross-day lifetime mismatch: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func mustApply(t *testing.T, repo interface {
|
||||
ProcessRoomMicEvent(context.Context, mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error)
|
||||
}, event mictimedomain.RoomMicEvent) {
|
||||
t.Helper()
|
||||
result, err := repo.ProcessRoomMicEvent(context.Background(), event)
|
||||
if err != nil || result.Skipped {
|
||||
t.Fatalf("ProcessRoomMicEvent failed: event=%+v result=%+v err=%v", event, result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func roomMicEvent(eventID string, micSessionID string, action string, userID int64, fromSeat int32, toSeat int32, occurredAtMs int64) mictimedomain.RoomMicEvent {
|
||||
event := mictimedomain.RoomMicEvent{
|
||||
AppCode: "lalu",
|
||||
EventID: eventID,
|
||||
EventType: mictimedomain.RoomMicChangedEventType,
|
||||
RoomID: "room-mic-time-test",
|
||||
RoomVersion: occurredAtMs,
|
||||
OccurredAtMs: occurredAtMs,
|
||||
ActorUserID: userID,
|
||||
TargetUserID: userID,
|
||||
FromSeat: fromSeat,
|
||||
ToSeat: toSeat,
|
||||
Action: action,
|
||||
MicSessionID: micSessionID,
|
||||
PublishState: mictimedomain.PublishStatePending,
|
||||
PublishDeadlineMs: occurredAtMs + 60_000,
|
||||
}
|
||||
if action == mictimedomain.ActionPublishConfirmed {
|
||||
event.PublishState = mictimedomain.PublishStatePublishing
|
||||
event.PublishEventTimeMs = occurredAtMs
|
||||
}
|
||||
if action == mictimedomain.ActionDown {
|
||||
event.PublishState = mictimedomain.PublishStateIdle
|
||||
}
|
||||
return event
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
package mictime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
mictimedomain "hyapp/services/user-service/internal/domain/mictime"
|
||||
)
|
||||
|
||||
// RoomOutboxReader scans room-service outbox without mutating room-service delivery state.
|
||||
type RoomOutboxReader struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewRoomOutboxReader creates a read-only scanner for RoomMicChanged events.
|
||||
func NewRoomOutboxReader(db *sql.DB) *RoomOutboxReader {
|
||||
return &RoomOutboxReader{db: db}
|
||||
}
|
||||
|
||||
// ListRoomMicEventsAfter returns RoomMicChanged events after the caller's in-memory cursor.
|
||||
func (r *RoomOutboxReader) ListRoomMicEventsAfter(ctx context.Context, cursor mictimedomain.RoomOutboxCursor, batchSize int) ([]mictimedomain.RoomMicEvent, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "room outbox reader is not configured")
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
createdAfter := time.UnixMilli(cursor.CreatedAtMs)
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT event_id, envelope, created_at
|
||||
FROM room_outbox
|
||||
WHERE event_type = ?
|
||||
AND (created_at > ? OR (created_at = ? AND event_id > ?))
|
||||
ORDER BY created_at ASC, event_id ASC
|
||||
LIMIT ?
|
||||
`, mictimedomain.RoomMicChangedEventType, createdAfter, createdAfter, cursor.EventID, batchSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]mictimedomain.RoomMicEvent, 0, batchSize)
|
||||
for rows.Next() {
|
||||
var eventID string
|
||||
var envelopeBytes []byte
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&eventID, &envelopeBytes, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var envelope roomeventsv1.EventEnvelope
|
||||
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body roomeventsv1.RoomMicChanged
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decodedEventID := envelope.GetEventId()
|
||||
if decodedEventID == "" {
|
||||
decodedEventID = eventID
|
||||
}
|
||||
events = append(events, mictimedomain.RoomMicEvent{
|
||||
AppCode: appcode.Normalize(envelope.GetAppCode()),
|
||||
EventID: decodedEventID,
|
||||
EventType: envelope.GetEventType(),
|
||||
RoomID: envelope.GetRoomId(),
|
||||
RoomVersion: envelope.GetRoomVersion(),
|
||||
OutboxCreatedAtMs: createdAt.UnixMilli(),
|
||||
OccurredAtMs: envelope.GetOccurredAtMs(),
|
||||
ActorUserID: body.GetActorUserId(),
|
||||
TargetUserID: body.GetTargetUserId(),
|
||||
FromSeat: body.GetFromSeat(),
|
||||
ToSeat: body.GetToSeat(),
|
||||
Action: body.GetAction(),
|
||||
MicSessionID: body.GetMicSessionId(),
|
||||
PublishState: body.GetPublishState(),
|
||||
Reason: body.GetReason(),
|
||||
PublishDeadlineMs: body.GetPublishDeadlineMs(),
|
||||
PublishEventTimeMs: body.GetPublishEventTimeMs(),
|
||||
})
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
@ -6,6 +6,8 @@ import (
|
||||
devicestorage "hyapp/services/user-service/internal/storage/mysql/device"
|
||||
hoststorage "hyapp/services/user-service/internal/storage/mysql/host"
|
||||
identitystorage "hyapp/services/user-service/internal/storage/mysql/identity"
|
||||
invitestorage "hyapp/services/user-service/internal/storage/mysql/invite"
|
||||
mictimestorage "hyapp/services/user-service/internal/storage/mysql/mictime"
|
||||
regionstorage "hyapp/services/user-service/internal/storage/mysql/region"
|
||||
userstorage "hyapp/services/user-service/internal/storage/mysql/user"
|
||||
)
|
||||
@ -65,3 +67,19 @@ func (r *Repository) HostRepository() *hoststorage.Repository {
|
||||
}
|
||||
return hoststorage.New(r.db)
|
||||
}
|
||||
|
||||
// 基于 user-service 共享 MySQL 连接池返回邀请关系和计数存储对象。
|
||||
func (r *Repository) InviteRepository() *invitestorage.Repository {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return invitestorage.New(r.db)
|
||||
}
|
||||
|
||||
// 基于 user-service 共享 MySQL 连接池返回用户麦上时长存储对象。
|
||||
func (r *Repository) MicTimeRepository() *mictimestorage.Repository {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return mictimestorage.New(r.db)
|
||||
}
|
||||
|
||||
@ -236,7 +236,7 @@ func InsertUserIdentity(ctx context.Context, tx *sql.Tx, user userdomain.User, i
|
||||
VALUES (
|
||||
?, ?, ?, ?, ?, NULL,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`, appcode.Normalize(user.AppCode), user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), shared.NullableString(user.Username), shared.NullableString(user.Gender), shared.NullableString(user.Country), shared.NullableRegionID(user.RegionID), shared.NullableString(user.InviteCode), shared.NullableString(user.RegisterIP), shared.NullableString(user.RegisterUserAgent), shared.NullableString(user.CountryByIP), shared.NullableString(user.RegisterDeviceID), shared.NullableString(user.RegisterDevice), shared.NullableString(user.RegisterOSVersion), shared.NullableString(user.Avatar), shared.NullableString(user.BirthDate), shared.NullableString(user.RegisterAppVersion), shared.NullableString(user.RegisterBuildNumber), shared.NullableString(user.RegisterSource), shared.NullableString(user.RegisterInstallChannel), shared.NullableString(user.RegisterCampaign), shared.NullableString(user.RegisterPlatform), shared.NullableString(user.Language), shared.NullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, onboardingStatusForInsert(user), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
|
||||
if err != nil {
|
||||
|
||||
@ -5,10 +5,13 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
invitestorage "hyapp/services/user-service/internal/storage/mysql/invite"
|
||||
"hyapp/services/user-service/internal/storage/mysql/shared"
|
||||
)
|
||||
|
||||
@ -22,6 +25,9 @@ func (r *Repository) GetUser(ctx context.Context, userID int64) (userdomain.User
|
||||
if err != nil {
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
if err := r.hydrateInviteOverview(ctx, &user); err != nil {
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@ -112,6 +118,9 @@ func (r *Repository) CreateUserWithIdentity(ctx context.Context, user userdomain
|
||||
// 唯一键冲突转换成 display_user_id exists,供 service 有限重试。
|
||||
return shared.MapDisplayDuplicateError(err)
|
||||
}
|
||||
if _, err := invitestorage.EnsurePrimaryCodeForUser(ctx, tx, user.AppCode, user.UserID, user.CreatedAtMs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
@ -186,6 +195,17 @@ func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain.
|
||||
}
|
||||
return r.GetUser(ctx, command.UserID)
|
||||
}
|
||||
var inviteBindingErr error
|
||||
inviteSnapshot := command.InviteCode
|
||||
if command.InviteCode != "" {
|
||||
user.InviteBinding, inviteBindingErr = invitestorage.BindRelation(ctx, tx, invitestorageBindCommand(command))
|
||||
if inviteBindingErr != nil {
|
||||
return userdomain.User{}, inviteBindingErr
|
||||
}
|
||||
if user.InviteBinding.InviteCode != "" {
|
||||
inviteSnapshot = user.InviteBinding.InviteCode
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
@ -194,13 +214,14 @@ func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain.
|
||||
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), true, command.CompletedAtMs, string(userdomain.OnboardingStatusCompleted), command.CompletedAtMs, command.UserID, appcode.Normalize(command.AppCode))
|
||||
`, 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
|
||||
}
|
||||
@ -208,7 +229,9 @@ func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain.
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
|
||||
return r.GetUser(ctx, command.UserID)
|
||||
updated, err := r.GetUser(ctx, command.UserID)
|
||||
updated.InviteBinding = user.InviteBinding
|
||||
return updated, err
|
||||
}
|
||||
|
||||
// ChangeUserCountry 修改国家并写日志;冷却期检查和日志写入必须在同一事务内完成。
|
||||
@ -285,4 +308,32 @@ func (r *Repository) ChangeUserCountry(ctx context.Context, command userdomain.C
|
||||
return updated, command.ChangedAtMs + command.CooldownMs, err
|
||||
}
|
||||
|
||||
func (r *Repository) hydrateInviteOverview(ctx context.Context, user *userdomain.User) error {
|
||||
if user == nil || user.UserID <= 0 {
|
||||
return nil
|
||||
}
|
||||
overview, err := invitestorage.New(r.db).GetOverview(ctx, user.AppCode, user.UserID, user.RegionID, timeNowMs())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.InviteOverview = overview
|
||||
return nil
|
||||
}
|
||||
|
||||
func invitestorageBindCommand(command userdomain.CompleteOnboardingCommand) invitedomain.BindCommand {
|
||||
return invitedomain.BindCommand{
|
||||
AppCode: command.AppCode,
|
||||
InvitedUserID: command.UserID,
|
||||
InvitedRegionID: command.RegionID,
|
||||
InviteCode: command.InviteCode,
|
||||
Source: "complete_onboarding",
|
||||
RequestID: command.RequestID,
|
||||
BoundAtMs: command.CompletedAtMs,
|
||||
}
|
||||
}
|
||||
|
||||
func timeNowMs() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
// QueryUser 读取 users 快照字段;既服务普通查询,也服务短号事务中的 FOR UPDATE 读。
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"hyapp/pkg/appcode"
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
mysqlstorage "hyapp/services/user-service/internal/storage/mysql"
|
||||
"hyapp/services/user-service/internal/testutil/mysqlschema"
|
||||
@ -217,8 +218,8 @@ func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string
|
||||
}
|
||||
|
||||
// CreateThirdPartyUser 让测试 wrapper 继续满足认证接口。
|
||||
func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session) error {
|
||||
return r.Repository.AuthRepository().CreateThirdPartyUser(ctx, user, identity, thirdParty, session)
|
||||
func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand) error {
|
||||
return r.Repository.AuthRepository().CreateThirdPartyUser(ctx, user, identity, thirdParty, session, invite)
|
||||
}
|
||||
|
||||
// RecordLoginAudit 让测试 wrapper 继续满足认证接口。
|
||||
|
||||
@ -5,6 +5,8 @@ import (
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
mictimedomain "hyapp/services/user-service/internal/domain/mictime"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
authservice "hyapp/services/user-service/internal/service/auth"
|
||||
)
|
||||
@ -74,6 +76,44 @@ func toProtoUser(user userdomain.User) *userv1.User {
|
||||
IsoNumeric: user.ISONumeric,
|
||||
PhoneCountryCode: user.PhoneCountryCode,
|
||||
CountryEnabled: user.CountryEnabled,
|
||||
Invite: toProtoInviteOverview(user.InviteOverview),
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoInviteOverview(overview invitedomain.Overview) *userv1.InviteOverview {
|
||||
if overview.MyInviteCode == "" && !overview.InviteEnabled && overview.InviteCount == 0 && overview.ValidInviteCount == 0 && overview.ValidInviteThresholdCoin == 0 {
|
||||
return nil
|
||||
}
|
||||
return &userv1.InviteOverview{
|
||||
MyInviteCode: overview.MyInviteCode,
|
||||
InviteEnabled: overview.InviteEnabled,
|
||||
InviteCount: overview.InviteCount,
|
||||
ValidInviteCount: overview.ValidInviteCount,
|
||||
ValidInviteThresholdCoin: overview.ValidInviteThresholdCoin,
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoInviteBinding(binding invitedomain.Binding) *userv1.InviteBinding {
|
||||
if !binding.Bound && binding.InviteCode == "" && binding.InviterUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
return &userv1.InviteBinding{
|
||||
Bound: binding.Bound,
|
||||
InviteCode: binding.InviteCode,
|
||||
InviterUserId: binding.InviterUserID,
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoMicLifetimeStats(stats mictimedomain.LifetimeStats) *userv1.UserMicLifetimeStats {
|
||||
return &userv1.UserMicLifetimeStats{
|
||||
AppCode: stats.AppCode,
|
||||
UserId: stats.UserID,
|
||||
SeatOccupiedMs: stats.SeatOccupiedMs,
|
||||
MicOnlineMs: stats.MicOnlineMs,
|
||||
SessionCount: stats.SessionCount,
|
||||
FirstMicAtMs: stats.FirstMicAtMs,
|
||||
LastMicAtMs: stats.LastMicAtMs,
|
||||
UpdatedAtMs: stats.UpdatedAtMs,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
authservice "hyapp/services/user-service/internal/service/auth"
|
||||
hostservice "hyapp/services/user-service/internal/service/host"
|
||||
mictimeservice "hyapp/services/user-service/internal/service/mictime"
|
||||
userservice "hyapp/services/user-service/internal/service/user"
|
||||
)
|
||||
|
||||
@ -43,6 +44,8 @@ type Server struct {
|
||||
userSvc *userservice.Service
|
||||
// hostSvc 承载 user-service 内 host/Agency/BD 事实用例。
|
||||
hostSvc *hostservice.Service
|
||||
// micTimeSvc 承载用户维度麦位在线时长聚合查询。
|
||||
micTimeSvc *mictimeservice.Service
|
||||
}
|
||||
|
||||
// NewServer 创建 user-service gRPC server。
|
||||
@ -59,6 +62,11 @@ func NewServer(authSvc *authservice.Service, userSvc *userservice.Service, hostS
|
||||
return server
|
||||
}
|
||||
|
||||
// SetMicTimeService 挂载可选的麦位时长 read model,避免破坏现有测试构造函数签名。
|
||||
func (s *Server) SetMicTimeService(micTimeSvc *mictimeservice.Service) {
|
||||
s.micTimeSvc = micTimeSvc
|
||||
}
|
||||
|
||||
// ResolveApp 把客户端包名映射成内部 app_code,gateway 后续会把 app_code 写入 JWT 和 RequestMeta。
|
||||
func (s *Server) ResolveApp(ctx context.Context, req *userv1.ResolveAppRequest) (*userv1.ResolveAppResponse, error) {
|
||||
app, err := s.userSvc.ResolveApp(ctx, req.GetAppCode(), req.GetPackageName(), req.GetPlatform())
|
||||
@ -193,6 +201,23 @@ func (s *Server) ListUserIDs(ctx context.Context, req *userv1.ListUserIDsRequest
|
||||
return &userv1.ListUserIDsResponse{UserIds: userIDs, NextCursorUserId: nextCursor, Done: done}, nil
|
||||
}
|
||||
|
||||
// GetUserMicLifetimeStats 返回用户维度全部历史麦位时长,主播任务后续也读这份基础指标。
|
||||
func (s *Server) GetUserMicLifetimeStats(ctx context.Context, req *userv1.GetUserMicLifetimeStatsRequest) (*userv1.GetUserMicLifetimeStatsResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
if s.micTimeSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "mic time service is not configured"))
|
||||
}
|
||||
appCode := ""
|
||||
if req.GetMeta() != nil {
|
||||
appCode = req.GetMeta().GetAppCode()
|
||||
}
|
||||
stats, err := s.micTimeSvc.GetLifetimeStats(ctx, appCode, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &userv1.GetUserMicLifetimeStatsResponse{Stats: toProtoMicLifetimeStats(stats)}, nil
|
||||
}
|
||||
|
||||
// UpdateUserProfile 修改当前用户基础资料。
|
||||
func (s *Server) UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
@ -229,7 +254,7 @@ func (s *Server) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnb
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "token service is not configured"))
|
||||
}
|
||||
// gateway 只透传已鉴权 user_id;资料校验和国家解析必须停留在 user-service。
|
||||
user, err := s.userSvc.CompleteOnboarding(ctx, req.GetUserId(), req.GetUsername(), req.GetAvatar(), req.GetGender(), req.GetCountry())
|
||||
user, err := s.userSvc.CompleteOnboarding(ctx, req.GetUserId(), req.GetUsername(), req.GetAvatar(), req.GetGender(), req.GetCountry(), req.GetInviteCode(), req.GetMeta().GetRequestId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
@ -245,6 +270,7 @@ func (s *Server) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnb
|
||||
ProfileCompletedAtMs: user.ProfileCompletedAtMs,
|
||||
OnboardingStatus: string(user.OnboardingStatus),
|
||||
Token: toProtoToken(token),
|
||||
Invite: toProtoInviteBinding(user.InviteBinding),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -72,6 +72,30 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) {
|
||||
authservice.WithIdentityRepository(repository),
|
||||
authservice.WithCountryRegionRepository(repository), authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"})))
|
||||
server := grpcserver.NewServer(authSvc, newUserService(repository))
|
||||
if err := repository.CreateUserWithIdentity(ctx, userdomain.User{
|
||||
AppCode: "lalu",
|
||||
UserID: 800001,
|
||||
DefaultDisplayUserID: "188001",
|
||||
CurrentDisplayUserID: "188001",
|
||||
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
Status: userdomain.StatusActive,
|
||||
OnboardingStatus: userdomain.OnboardingStatusProfileRequired,
|
||||
CreatedAtMs: 1000,
|
||||
UpdatedAtMs: 1000,
|
||||
}, userdomain.Identity{
|
||||
AppCode: "lalu",
|
||||
UserID: 800001,
|
||||
DisplayUserID: "188001",
|
||||
DefaultDisplayUserID: "188001",
|
||||
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
Status: userdomain.DisplayUserIDStatusActive,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed inviter failed: %v", err)
|
||||
}
|
||||
inviter, err := repository.GetUser(ctx, 800001)
|
||||
if err != nil {
|
||||
t.Fatalf("get inviter failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := server.LoginThirdParty(ctx, &userv1.LoginThirdPartyRequest{
|
||||
Meta: &userv1.RequestMeta{RequestId: "req-third", DeviceId: "meta-device", ClientIp: "203.0.113.10", UserAgent: "hyapp-test/1.0", CountryByIp: "US"},
|
||||
@ -80,7 +104,7 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) {
|
||||
Username: "hy",
|
||||
Gender: "male",
|
||||
Country: "CN",
|
||||
InviteCode: "INV-1",
|
||||
InviteCode: inviter.InviteOverview.MyInviteCode,
|
||||
DeviceId: "req-device",
|
||||
Device: "iPhone 15",
|
||||
OsVersion: "iOS 18.1",
|
||||
@ -103,7 +127,7 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser failed: %v", err)
|
||||
}
|
||||
if user.Username != "hy" || user.InviteCode != "INV-1" || user.RegisterIP != "203.0.113.10" || user.RegisterUserAgent != "hyapp-test/1.0" || user.CountryByIP != "US" || user.RegisterDeviceID != "req-device" {
|
||||
if user.Username != "hy" || user.InviteCode != inviter.InviteOverview.MyInviteCode || user.RegisterIP != "203.0.113.10" || user.RegisterUserAgent != "hyapp-test/1.0" || user.CountryByIP != "US" || user.RegisterDeviceID != "req-device" {
|
||||
t.Fatalf("registration fields were not mapped: %+v", user)
|
||||
}
|
||||
if user.RegisterDevice != "iPhone 15" || user.RegisterOSVersion != "iOS 18.1" || user.BirthDate != "2000-01-02" || user.RegisterAppVersion != "1.2.3" || user.RegisterBuildNumber != "123" || user.RegisterInstallChannel != "app_store" || user.RegisterCampaign != "spring_launch" || user.RegisterPlatform != "ios" || user.Timezone != "America/Los_Angeles" {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user