红包/任务 需求

This commit is contained in:
zhx 2026-05-08 18:48:29 +08:00
parent 15760a55c2
commit 544aee1860
56 changed files with 5110 additions and 814 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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);

View File

@ -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,

View File

@ -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进入登录页 |

View File

@ -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客户端立即替换旧 tokenrefresh 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`

View File

@ -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

View File

@ -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 |

View File

@ -14,6 +14,7 @@
- 新增 protobufhost 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/AgencyApp 可以搜到 Agency |
| M3 | Phase 3 | 用户申请、审核、踢出、重新申请、邀请链路完整跑通 |
| M3.5 | Phase 3.5 | 申请、邀请、后台创建关系结果能进入 App 系统消息 |
| M4 | Phase 4 | 有效上麦和礼物统计按关系快照落库 |
| M5 | Phase 5 | 后台生成工资单,审批后入账 `USD_BALANCE` |
| M6 | Phase 6 | 失败补偿、导出、重建和监控可用 |

View File

@ -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 |

View File

@ -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 tokenrefresh_token 为空或不返回。

View File

@ -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:

View File

@ -0,0 +1,538 @@
# Registration Invite Code Architecture
本文定义 App 注册邀请码能力。用户注册时可以选填邀请码;如果该邀请码属于某个有效用户,则该用户成为新用户的邀请人。邀请码归因是用户关系事实,不能只把字符串放在 `users.invite_code` 里。
## Goals
- 每个用户有一个可展示、可复制的永久邀请码。
- 新用户注册或完成资料时可以选填邀请码。
- 非空邀请码必须能解析到同一 `app_code` 下的有效邀请人。
- 邀请关系一旦绑定不可被客户端修改。
- 邀请关系必须落 MySQLRedis 只能做短 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`,不实时扫关系或充值流水 |

View File

@ -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

View 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 麦位事件。

View File

@ -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"

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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"

View File

@ -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) {

View File

@ -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)

View File

@ -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

View 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,
})
}

View File

@ -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

View File

@ -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并尝试同步通知腾讯云 IMstale 清理会通过 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
})
}

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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()
}
})
}

View File

@ -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,

View 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)
}

View 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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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" {

View File

@ -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) {
// 生日只保存日期,不接受带时区或非零填充格式,避免跨端解析不一致。

View 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
}

View 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
}

View File

@ -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(),
})
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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 读。

View File

@ -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 继续满足认证接口。

View File

@ -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,
}
}

View File

@ -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_codegateway 后续会把 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
}

View File

@ -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" {