Merge main into test
# Conflicts: # api/proto/user/v1/user.pb.go # services/cron-service/configs/config.docker.yaml # services/cron-service/configs/config.tencent.example.yaml # services/cron-service/configs/config.yaml
This commit is contained in:
commit
2f9f37f799
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "admin-platform",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--dir", "/Users/hy/Documents/hy/hyapp-admin-platform", "dev", "--port", "7002"],
|
||||
"port": 7002,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,3 +32,4 @@ redis-data/
|
||||
.tmp/
|
||||
/tmp/
|
||||
.claude/settings.local.json
|
||||
backfill-last7
|
||||
|
||||
1
Makefile
1
Makefile
@ -59,6 +59,7 @@ proto:
|
||||
proto/game/v1/game.proto \
|
||||
proto/robot/v1/robot.proto \
|
||||
proto/wallet/v1/wallet.proto \
|
||||
proto/events/luckygift/v1/events.proto \
|
||||
proto/events/room/v1/events.proto
|
||||
|
||||
# `vet` 使用当前 Go 工具链检查 workspace 内所有后端模块,升级 Go 版本时先暴露静态风险。
|
||||
|
||||
@ -180,7 +180,7 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
|
||||
|
||||
规则:
|
||||
|
||||
- `room_outbox` 只由 `room-service` 读取和发布;`wallet_outbox` 只由 `wallet-service` 读取和发布;`user_outbox` 只由 `user-service` 读取和发布;`game_outbox` 只由 `game-service` 读取和发布。
|
||||
- `room_outbox` 只由 `room-service` 读取和发布;`wallet_outbox` 只由 `wallet-service` 读取和发布;`user_outbox` 只由 `user-service` 读取和发布;`game_outbox` 只由 `game-service` 读取和发布;`lucky_gift_outbox` 只由 `lucky-gift-service` 读取和发布。
|
||||
- `statistics-service`、`activity-service`、`notice-service`、`cron-service` 等非 owner service 不能配置 owner 数据库 DSN 或扫描其他服务 outbox 表。
|
||||
- 每个业务消费者使用独立 consumer group。即使消费同一个 topic,不同业务也不能共用 group,否则会互相抢消息。
|
||||
- 每个消费者在自己的库内维护幂等事实,例如按 `app_code + event_id` 或业务唯一键建唯一约束;RocketMQ 的消费位点只解决投递进度,不替代业务幂等。
|
||||
@ -201,10 +201,14 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
|
||||
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-activity-red-packet-wallet-outbox` | activity-service 消费 `WalletRedPacketCreated` 并生成区域飘屏 outbox |
|
||||
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-notice-wallet-outbox` | notice-service 消费余额、账务等私有通知事实 |
|
||||
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-statistics-wallet-outbox` | statistics-service 消费 `WalletRechargeRecorded` 并更新充值聚合 |
|
||||
| `hyapp_lucky_gift_outbox` | `hyapp-lucky-gift-outbox-producer` | `hyapp-room-lucky-gift-outbox` | room-service 消费已到账的 `LuckyGiftDrawn`,以本地 `room_outbox` 幂等投递房间顶部飘屏 |
|
||||
| `hyapp_lucky_gift_outbox` | `hyapp-lucky-gift-outbox-producer` | `hyapp-activity-lucky-gift-outbox` | activity-service 消费已到账的 `LuckyGiftDrawn`,以本地 `im_broadcast_outbox` 幂等投递区域大赢家飘屏 |
|
||||
| `hyapp_game_outbox` | `hyapp-game-outbox-producer` | `hyapp-statistics-game-outbox` | statistics-service 消费 `GameOrderSettled` 并更新游戏聚合 |
|
||||
| `hyapp_room_rocket_launch` | `hyapp-room-rocket-launch-producer` | `hyapp-room-rocket-launch` | 火箭点火后到点唤醒 room-service 发射命令 |
|
||||
|
||||
RocketMQ 不拥有业务状态。`room_outbox`、`wallet_outbox`、`user_outbox`、`game_outbox` 仍是各 owner service MySQL 内的可靠事实源;MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。火箭延迟消息只负责唤醒,到点后 room-service 会重新校验 `rocket_id`、等级、`launch_at_ms`、状态和 UTC 重置边界。
|
||||
RocketMQ 不拥有业务状态。`room_outbox`、`wallet_outbox`、`user_outbox`、`game_outbox`、`lucky_gift_outbox` 仍是各 owner service MySQL 内的可靠事实源;MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。火箭延迟消息只负责唤醒,到点后 room-service 会重新校验 `rocket_id`、等级、`launch_at_ms`、状态和 UTC 重置边界。
|
||||
|
||||
`hyapp_lucky_gift_outbox` 的两个消费者保持实时副作用所需的 latest offset。首次上线必须先创建 topic/group,并先部署、确认 room/activity 两个 group 已在线订阅,再开启 lucky-gift producer;不能用 `ConsumeFromFirst` 扫历史大奖生成过期飘屏。
|
||||
|
||||
## Storage Model
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1450,6 +1450,13 @@ message LevelRewardJob {
|
||||
string failure_reason = 11;
|
||||
int64 created_at_ms = 12;
|
||||
int64 updated_at_ms = 13;
|
||||
string reward_origin = 14;
|
||||
string temporary_level_id = 15;
|
||||
int32 grant_generation = 16;
|
||||
int32 reward_level = 17;
|
||||
bool permanent = 18;
|
||||
int64 temporary_expires_at_ms = 19;
|
||||
string revoke_status = 20;
|
||||
}
|
||||
|
||||
message ListLevelRewardsRequest {
|
||||
@ -1511,6 +1518,55 @@ message SetUserLevelResponse {
|
||||
int64 server_time_ms = 8;
|
||||
}
|
||||
|
||||
// AdminLevelTrackProfile keeps the permanent account and the currently effective display overlay separate.
|
||||
message AdminLevelTrackProfile {
|
||||
string track = 1;
|
||||
int32 real_level = 2;
|
||||
int64 real_total_value = 3;
|
||||
int32 display_level = 4;
|
||||
int64 display_value = 5;
|
||||
string temporary_level_id = 6;
|
||||
int32 temporary_target_level = 7;
|
||||
int64 started_at_ms = 8;
|
||||
int64 expires_at_ms = 9;
|
||||
}
|
||||
|
||||
message AdminUserLevelProfile {
|
||||
int64 user_id = 1;
|
||||
repeated AdminLevelTrackProfile tracks = 2;
|
||||
}
|
||||
|
||||
message BatchGetUserLevelAdminProfilesRequest {
|
||||
RequestMeta meta = 1;
|
||||
repeated int64 user_ids = 2;
|
||||
}
|
||||
|
||||
message BatchGetUserLevelAdminProfilesResponse {
|
||||
repeated AdminUserLevelProfile profiles = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
// TemporaryLevelAdjustment is limited to wealth/charm; duration_days must be positive and level is 1..50.
|
||||
message TemporaryLevelAdjustment {
|
||||
string track = 1;
|
||||
int32 level = 2;
|
||||
int32 duration_days = 3;
|
||||
}
|
||||
|
||||
message AdjustTemporaryUserLevelsRequest {
|
||||
RequestMeta meta = 1;
|
||||
string command_id = 2;
|
||||
int64 user_id = 3;
|
||||
repeated TemporaryLevelAdjustment adjustments = 4;
|
||||
int64 operator_admin_id = 5;
|
||||
string reason = 6;
|
||||
}
|
||||
|
||||
message AdjustTemporaryUserLevelsResponse {
|
||||
AdminUserLevelProfile profile = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message RegistrationLevelBadgeGrant {
|
||||
string track = 1;
|
||||
int32 level = 2;
|
||||
@ -2335,6 +2391,7 @@ service MessageActionConfirmService {
|
||||
service ActivityCronService {
|
||||
rpc ProcessMessageFanoutBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ProcessLevelRewardBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ProcessTemporaryLevelBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ProcessAchievementRewardBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ProcessRoomTurnoverRewardSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ProcessWeeklyStarSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
@ -2529,6 +2586,8 @@ service AdminGrowthLevelService {
|
||||
rpc UpsertLevelTrack(UpsertLevelTrackRequest) returns (UpsertLevelTrackResponse);
|
||||
rpc UpsertLevelRule(UpsertLevelRuleRequest) returns (UpsertLevelRuleResponse);
|
||||
rpc UpsertLevelTier(UpsertLevelTierRequest) returns (UpsertLevelTierResponse);
|
||||
rpc BatchGetUserLevelAdminProfiles(BatchGetUserLevelAdminProfilesRequest) returns (BatchGetUserLevelAdminProfilesResponse);
|
||||
rpc AdjustTemporaryUserLevels(AdjustTemporaryUserLevelsRequest) returns (AdjustTemporaryUserLevelsResponse);
|
||||
}
|
||||
|
||||
// AdminAchievementService is the admin entry for achievement definitions.
|
||||
|
||||
@ -720,6 +720,7 @@ var MessageActionConfirmService_ServiceDesc = grpc.ServiceDesc{
|
||||
const (
|
||||
ActivityCronService_ProcessMessageFanoutBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessMessageFanoutBatch"
|
||||
ActivityCronService_ProcessLevelRewardBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessLevelRewardBatch"
|
||||
ActivityCronService_ProcessTemporaryLevelBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessTemporaryLevelBatch"
|
||||
ActivityCronService_ProcessAchievementRewardBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessAchievementRewardBatch"
|
||||
ActivityCronService_ProcessRoomTurnoverRewardSettlementBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessRoomTurnoverRewardSettlementBatch"
|
||||
ActivityCronService_ProcessWeeklyStarSettlementBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessWeeklyStarSettlementBatch"
|
||||
@ -735,6 +736,7 @@ const (
|
||||
type ActivityCronServiceClient interface {
|
||||
ProcessMessageFanoutBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ProcessLevelRewardBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ProcessTemporaryLevelBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ProcessAchievementRewardBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ProcessRoomTurnoverRewardSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ProcessWeeklyStarSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
@ -770,6 +772,16 @@ func (c *activityCronServiceClient) ProcessLevelRewardBatch(ctx context.Context,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *activityCronServiceClient) ProcessTemporaryLevelBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CronBatchResponse)
|
||||
err := c.cc.Invoke(ctx, ActivityCronService_ProcessTemporaryLevelBatch_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *activityCronServiceClient) ProcessAchievementRewardBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CronBatchResponse)
|
||||
@ -828,6 +840,7 @@ func (c *activityCronServiceClient) ProcessAgencyOpeningSettlementBatch(ctx cont
|
||||
type ActivityCronServiceServer interface {
|
||||
ProcessMessageFanoutBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ProcessLevelRewardBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ProcessTemporaryLevelBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ProcessAchievementRewardBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ProcessRoomTurnoverRewardSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ProcessWeeklyStarSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
@ -849,6 +862,9 @@ func (UnimplementedActivityCronServiceServer) ProcessMessageFanoutBatch(context.
|
||||
func (UnimplementedActivityCronServiceServer) ProcessLevelRewardBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ProcessLevelRewardBatch not implemented")
|
||||
}
|
||||
func (UnimplementedActivityCronServiceServer) ProcessTemporaryLevelBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ProcessTemporaryLevelBatch not implemented")
|
||||
}
|
||||
func (UnimplementedActivityCronServiceServer) ProcessAchievementRewardBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ProcessAchievementRewardBatch not implemented")
|
||||
}
|
||||
@ -921,6 +937,24 @@ func _ActivityCronService_ProcessLevelRewardBatch_Handler(srv interface{}, ctx c
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ActivityCronService_ProcessTemporaryLevelBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CronBatchRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ActivityCronServiceServer).ProcessTemporaryLevelBatch(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ActivityCronService_ProcessTemporaryLevelBatch_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ActivityCronServiceServer).ProcessTemporaryLevelBatch(ctx, req.(*CronBatchRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ActivityCronService_ProcessAchievementRewardBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CronBatchRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1026,6 +1060,10 @@ var ActivityCronService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ProcessLevelRewardBatch",
|
||||
Handler: _ActivityCronService_ProcessLevelRewardBatch_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ProcessTemporaryLevelBatch",
|
||||
Handler: _ActivityCronService_ProcessTemporaryLevelBatch_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ProcessAchievementRewardBatch",
|
||||
Handler: _ActivityCronService_ProcessAchievementRewardBatch_Handler,
|
||||
@ -5846,10 +5884,12 @@ var AdminSevenDayCheckInService_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
const (
|
||||
AdminGrowthLevelService_ListLevelConfig_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/ListLevelConfig"
|
||||
AdminGrowthLevelService_UpsertLevelTrack_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTrack"
|
||||
AdminGrowthLevelService_UpsertLevelRule_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelRule"
|
||||
AdminGrowthLevelService_UpsertLevelTier_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTier"
|
||||
AdminGrowthLevelService_ListLevelConfig_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/ListLevelConfig"
|
||||
AdminGrowthLevelService_UpsertLevelTrack_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTrack"
|
||||
AdminGrowthLevelService_UpsertLevelRule_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelRule"
|
||||
AdminGrowthLevelService_UpsertLevelTier_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTier"
|
||||
AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/BatchGetUserLevelAdminProfiles"
|
||||
AdminGrowthLevelService_AdjustTemporaryUserLevels_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/AdjustTemporaryUserLevels"
|
||||
)
|
||||
|
||||
// AdminGrowthLevelServiceClient is the client API for AdminGrowthLevelService service.
|
||||
@ -5862,6 +5902,8 @@ type AdminGrowthLevelServiceClient interface {
|
||||
UpsertLevelTrack(ctx context.Context, in *UpsertLevelTrackRequest, opts ...grpc.CallOption) (*UpsertLevelTrackResponse, error)
|
||||
UpsertLevelRule(ctx context.Context, in *UpsertLevelRuleRequest, opts ...grpc.CallOption) (*UpsertLevelRuleResponse, error)
|
||||
UpsertLevelTier(ctx context.Context, in *UpsertLevelTierRequest, opts ...grpc.CallOption) (*UpsertLevelTierResponse, error)
|
||||
BatchGetUserLevelAdminProfiles(ctx context.Context, in *BatchGetUserLevelAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelAdminProfilesResponse, error)
|
||||
AdjustTemporaryUserLevels(ctx context.Context, in *AdjustTemporaryUserLevelsRequest, opts ...grpc.CallOption) (*AdjustTemporaryUserLevelsResponse, error)
|
||||
}
|
||||
|
||||
type adminGrowthLevelServiceClient struct {
|
||||
@ -5912,6 +5954,26 @@ func (c *adminGrowthLevelServiceClient) UpsertLevelTier(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminGrowthLevelServiceClient) BatchGetUserLevelAdminProfiles(ctx context.Context, in *BatchGetUserLevelAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelAdminProfilesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(BatchGetUserLevelAdminProfilesResponse)
|
||||
err := c.cc.Invoke(ctx, AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminGrowthLevelServiceClient) AdjustTemporaryUserLevels(ctx context.Context, in *AdjustTemporaryUserLevelsRequest, opts ...grpc.CallOption) (*AdjustTemporaryUserLevelsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdjustTemporaryUserLevelsResponse)
|
||||
err := c.cc.Invoke(ctx, AdminGrowthLevelService_AdjustTemporaryUserLevels_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminGrowthLevelServiceServer is the server API for AdminGrowthLevelService service.
|
||||
// All implementations must embed UnimplementedAdminGrowthLevelServiceServer
|
||||
// for forward compatibility.
|
||||
@ -5922,6 +5984,8 @@ type AdminGrowthLevelServiceServer interface {
|
||||
UpsertLevelTrack(context.Context, *UpsertLevelTrackRequest) (*UpsertLevelTrackResponse, error)
|
||||
UpsertLevelRule(context.Context, *UpsertLevelRuleRequest) (*UpsertLevelRuleResponse, error)
|
||||
UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error)
|
||||
BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error)
|
||||
AdjustTemporaryUserLevels(context.Context, *AdjustTemporaryUserLevelsRequest) (*AdjustTemporaryUserLevelsResponse, error)
|
||||
mustEmbedUnimplementedAdminGrowthLevelServiceServer()
|
||||
}
|
||||
|
||||
@ -5944,6 +6008,12 @@ func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelRule(context.Contex
|
||||
func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpsertLevelTier not implemented")
|
||||
}
|
||||
func (UnimplementedAdminGrowthLevelServiceServer) BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUserLevelAdminProfiles not implemented")
|
||||
}
|
||||
func (UnimplementedAdminGrowthLevelServiceServer) AdjustTemporaryUserLevels(context.Context, *AdjustTemporaryUserLevelsRequest) (*AdjustTemporaryUserLevelsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AdjustTemporaryUserLevels not implemented")
|
||||
}
|
||||
func (UnimplementedAdminGrowthLevelServiceServer) mustEmbedUnimplementedAdminGrowthLevelServiceServer() {
|
||||
}
|
||||
func (UnimplementedAdminGrowthLevelServiceServer) testEmbeddedByValue() {}
|
||||
@ -6038,6 +6108,42 @@ func _AdminGrowthLevelService_UpsertLevelTier_Handler(srv interface{}, ctx conte
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(BatchGetUserLevelAdminProfilesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminGrowthLevelServiceServer).BatchGetUserLevelAdminProfiles(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminGrowthLevelServiceServer).BatchGetUserLevelAdminProfiles(ctx, req.(*BatchGetUserLevelAdminProfilesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminGrowthLevelService_AdjustTemporaryUserLevels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdjustTemporaryUserLevelsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminGrowthLevelServiceServer).AdjustTemporaryUserLevels(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminGrowthLevelService_AdjustTemporaryUserLevels_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminGrowthLevelServiceServer).AdjustTemporaryUserLevels(ctx, req.(*AdjustTemporaryUserLevelsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// AdminGrowthLevelService_ServiceDesc is the grpc.ServiceDesc for AdminGrowthLevelService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -6061,6 +6167,14 @@ var AdminGrowthLevelService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "UpsertLevelTier",
|
||||
Handler: _AdminGrowthLevelService_UpsertLevelTier_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchGetUserLevelAdminProfiles",
|
||||
Handler: _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdjustTemporaryUserLevels",
|
||||
Handler: _AdminGrowthLevelService_AdjustTemporaryUserLevels_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
|
||||
512
api/proto/events/luckygift/v1/events.pb.go
Normal file
512
api/proto/events/luckygift/v1/events.pb.go
Normal file
@ -0,0 +1,512 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.35.1
|
||||
// protoc v7.35.0
|
||||
// source: proto/events/luckygift/v1/events.proto
|
||||
|
||||
package luckygifteventsv1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
// EventEnvelope 是 lucky-gift-service 发布 owner outbox 事实的稳定信封。
|
||||
// occurred_at_ms 固定为抽奖事实创建时的 Unix epoch milliseconds;MQ 实际发送时间不改变业务发生时间。
|
||||
type EventEnvelope struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
|
||||
EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"`
|
||||
AppCode string `protobuf:"bytes,3,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||
// draw_id 是本次送礼命令的聚合中奖标识;批量子抽明细仍只保存在 lucky owner 数据库,不能放大 MQ 负载。
|
||||
DrawId string `protobuf:"bytes,4,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
|
||||
OccurredAtMs int64 `protobuf:"varint,5,opt,name=occurred_at_ms,json=occurredAtMs,proto3" json:"occurred_at_ms,omitempty"`
|
||||
Body []byte `protobuf:"bytes,6,opt,name=body,proto3" json:"body,omitempty"`
|
||||
}
|
||||
|
||||
func (x *EventEnvelope) Reset() {
|
||||
*x = EventEnvelope{}
|
||||
mi := &file_proto_events_luckygift_v1_events_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *EventEnvelope) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*EventEnvelope) ProtoMessage() {}
|
||||
|
||||
func (x *EventEnvelope) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_luckygift_v1_events_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use EventEnvelope.ProtoReflect.Descriptor instead.
|
||||
func (*EventEnvelope) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_luckygift_v1_events_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *EventEnvelope) GetEventId() string {
|
||||
if x != nil {
|
||||
return x.EventId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EventEnvelope) GetEventType() string {
|
||||
if x != nil {
|
||||
return x.EventType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EventEnvelope) GetAppCode() string {
|
||||
if x != nil {
|
||||
return x.AppCode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EventEnvelope) GetDrawId() string {
|
||||
if x != nil {
|
||||
return x.DrawId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EventEnvelope) GetOccurredAtMs() int64 {
|
||||
if x != nil {
|
||||
return x.OccurredAtMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EventEnvelope) GetBody() []byte {
|
||||
if x != nil {
|
||||
return x.Body
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LuckyGiftDrawn 表达钱包返奖已经 granted 的幸运礼物中奖事实。
|
||||
// lucky-gift-service 必须先完成 wallet-service 入账和本地 granted 收敛,再发布本事件;
|
||||
// room-service 与 activity-service 只从该事实派生房内 IM 和区域飘屏,不能反向改变抽奖或账务状态。
|
||||
type LuckyGiftDrawn struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
|
||||
CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
||||
RoomId string `protobuf:"bytes,3,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
|
||||
PoolId string `protobuf:"bytes,4,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
||||
GiftId string `protobuf:"bytes,5,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
|
||||
GiftCount int32 `protobuf:"varint,6,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
|
||||
SenderUserId int64 `protobuf:"varint,7,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"`
|
||||
TargetUserId int64 `protobuf:"varint,8,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
|
||||
// 展示资料是送礼入口固化的快照;消费者不应为了飘屏高频同步反查 user-service。
|
||||
SenderName string `protobuf:"bytes,9,opt,name=sender_name,json=senderName,proto3" json:"sender_name,omitempty"`
|
||||
SenderAvatar string `protobuf:"bytes,10,opt,name=sender_avatar,json=senderAvatar,proto3" json:"sender_avatar,omitempty"`
|
||||
SenderDisplayUserId string `protobuf:"bytes,11,opt,name=sender_display_user_id,json=senderDisplayUserId,proto3" json:"sender_display_user_id,omitempty"`
|
||||
SenderPrettyDisplayUserId string `protobuf:"bytes,12,opt,name=sender_pretty_display_user_id,json=senderPrettyDisplayUserId,proto3" json:"sender_pretty_display_user_id,omitempty"`
|
||||
// visible_region_id 是房间可见区域,区域播报不能用客户端 IP 或服务所在地推断。
|
||||
VisibleRegionId int64 `protobuf:"varint,13,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
|
||||
CountryId int64 `protobuf:"varint,14,opt,name=country_id,json=countryId,proto3" json:"country_id,omitempty"`
|
||||
CoinSpent int64 `protobuf:"varint,15,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"`
|
||||
RuleVersion int64 `protobuf:"varint,16,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"`
|
||||
ExperiencePool string `protobuf:"bytes,17,opt,name=experience_pool,json=experiencePool,proto3" json:"experience_pool,omitempty"`
|
||||
SelectedTierId string `protobuf:"bytes,18,opt,name=selected_tier_id,json=selectedTierId,proto3" json:"selected_tier_id,omitempty"`
|
||||
MultiplierPpm int64 `protobuf:"varint,19,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
|
||||
BaseRewardCoins int64 `protobuf:"varint,20,opt,name=base_reward_coins,json=baseRewardCoins,proto3" json:"base_reward_coins,omitempty"`
|
||||
EffectiveRewardCoins int64 `protobuf:"varint,21,opt,name=effective_reward_coins,json=effectiveRewardCoins,proto3" json:"effective_reward_coins,omitempty"`
|
||||
StageFeedback bool `protobuf:"varint,22,opt,name=stage_feedback,json=stageFeedback,proto3" json:"stage_feedback,omitempty"`
|
||||
HighMultiplier bool `protobuf:"varint,23,opt,name=high_multiplier,json=highMultiplier,proto3" json:"high_multiplier,omitempty"`
|
||||
// reward_status 在本事件中只能是 granted;保留字段是为了下游 JSON 与现有 Flutter 协议同名映射。
|
||||
RewardStatus string `protobuf:"bytes,24,opt,name=reward_status,json=rewardStatus,proto3" json:"reward_status,omitempty"`
|
||||
WalletTransactionId string `protobuf:"bytes,25,opt,name=wallet_transaction_id,json=walletTransactionId,proto3" json:"wallet_transaction_id,omitempty"`
|
||||
// 两个时间均为 UTC Unix epoch milliseconds:抽奖发生时间与钱包返奖确认时间不能混用。
|
||||
DrawCreatedAtMs int64 `protobuf:"varint,26,opt,name=draw_created_at_ms,json=drawCreatedAtMs,proto3" json:"draw_created_at_ms,omitempty"`
|
||||
RewardGrantedAtMs int64 `protobuf:"varint,27,opt,name=reward_granted_at_ms,json=rewardGrantedAtMs,proto3" json:"reward_granted_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) Reset() {
|
||||
*x = LuckyGiftDrawn{}
|
||||
mi := &file_proto_events_luckygift_v1_events_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*LuckyGiftDrawn) ProtoMessage() {}
|
||||
|
||||
func (x *LuckyGiftDrawn) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_luckygift_v1_events_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use LuckyGiftDrawn.ProtoReflect.Descriptor instead.
|
||||
func (*LuckyGiftDrawn) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_luckygift_v1_events_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetDrawId() string {
|
||||
if x != nil {
|
||||
return x.DrawId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetCommandId() string {
|
||||
if x != nil {
|
||||
return x.CommandId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetRoomId() string {
|
||||
if x != nil {
|
||||
return x.RoomId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetPoolId() string {
|
||||
if x != nil {
|
||||
return x.PoolId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetGiftId() string {
|
||||
if x != nil {
|
||||
return x.GiftId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetGiftCount() int32 {
|
||||
if x != nil {
|
||||
return x.GiftCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetSenderUserId() int64 {
|
||||
if x != nil {
|
||||
return x.SenderUserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetTargetUserId() int64 {
|
||||
if x != nil {
|
||||
return x.TargetUserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetSenderName() string {
|
||||
if x != nil {
|
||||
return x.SenderName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetSenderAvatar() string {
|
||||
if x != nil {
|
||||
return x.SenderAvatar
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetSenderDisplayUserId() string {
|
||||
if x != nil {
|
||||
return x.SenderDisplayUserId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetSenderPrettyDisplayUserId() string {
|
||||
if x != nil {
|
||||
return x.SenderPrettyDisplayUserId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetVisibleRegionId() int64 {
|
||||
if x != nil {
|
||||
return x.VisibleRegionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetCountryId() int64 {
|
||||
if x != nil {
|
||||
return x.CountryId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetCoinSpent() int64 {
|
||||
if x != nil {
|
||||
return x.CoinSpent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetRuleVersion() int64 {
|
||||
if x != nil {
|
||||
return x.RuleVersion
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetExperiencePool() string {
|
||||
if x != nil {
|
||||
return x.ExperiencePool
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetSelectedTierId() string {
|
||||
if x != nil {
|
||||
return x.SelectedTierId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetMultiplierPpm() int64 {
|
||||
if x != nil {
|
||||
return x.MultiplierPpm
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetBaseRewardCoins() int64 {
|
||||
if x != nil {
|
||||
return x.BaseRewardCoins
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetEffectiveRewardCoins() int64 {
|
||||
if x != nil {
|
||||
return x.EffectiveRewardCoins
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetStageFeedback() bool {
|
||||
if x != nil {
|
||||
return x.StageFeedback
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetHighMultiplier() bool {
|
||||
if x != nil {
|
||||
return x.HighMultiplier
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetRewardStatus() string {
|
||||
if x != nil {
|
||||
return x.RewardStatus
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetWalletTransactionId() string {
|
||||
if x != nil {
|
||||
return x.WalletTransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetDrawCreatedAtMs() int64 {
|
||||
if x != nil {
|
||||
return x.DrawCreatedAtMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawn) GetRewardGrantedAtMs() int64 {
|
||||
if x != nil {
|
||||
return x.RewardGrantedAtMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var File_proto_events_luckygift_v1_events_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_proto_events_luckygift_v1_events_proto_rawDesc = []byte{
|
||||
0x0a, 0x26, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x6c,
|
||||
0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2f, 0x76, 0x31, 0x2f, 0x65, 0x76, 0x65, 0x6e,
|
||||
0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x19, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
|
||||
0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74,
|
||||
0x2e, 0x76, 0x31, 0x22, 0xb7, 0x01, 0x0a, 0x0d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x45, 0x6e, 0x76,
|
||||
0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64,
|
||||
0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12,
|
||||
0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72,
|
||||
0x61, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x72, 0x61,
|
||||
0x77, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x6f, 0x63, 0x63, 0x75, 0x72, 0x72, 0x65, 0x64, 0x5f,
|
||||
0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x63, 0x63,
|
||||
0x75, 0x72, 0x72, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64,
|
||||
0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xab, 0x08,
|
||||
0x0a, 0x0e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x6e,
|
||||
0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x06, 0x64, 0x72, 0x61, 0x77, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d,
|
||||
0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63,
|
||||
0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f, 0x6f, 0x6d,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49,
|
||||
0x64, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69,
|
||||
0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66,
|
||||
0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e,
|
||||
0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75,
|
||||
0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65,
|
||||
0x72, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64,
|
||||
0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
|
||||
0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03,
|
||||
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1f,
|
||||
0x0a, 0x0b, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x09, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12,
|
||||
0x23, 0x0a, 0x0d, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72,
|
||||
0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x41, 0x76,
|
||||
0x61, 0x74, 0x61, 0x72, 0x12, 0x33, 0x0a, 0x16, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x64,
|
||||
0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0b,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x44, 0x69, 0x73, 0x70,
|
||||
0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x65, 0x6e,
|
||||
0x64, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c,
|
||||
0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x19, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69,
|
||||
0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76,
|
||||
0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64,
|
||||
0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52,
|
||||
0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x75, 0x6e, 0x74,
|
||||
0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x75,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x69, 0x6e, 0x5f, 0x73,
|
||||
0x70, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x69, 0x6e,
|
||||
0x53, 0x70, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x75, 0x6c, 0x65, 0x5f, 0x76, 0x65,
|
||||
0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x10, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x72, 0x75, 0x6c,
|
||||
0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x27, 0x0a, 0x0f, 0x65, 0x78, 0x70, 0x65,
|
||||
0x72, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6f, 0x6c, 0x18, 0x11, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x0e, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x50, 0x6f, 0x6f,
|
||||
0x6c, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x74, 0x69,
|
||||
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x12, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x65, 0x6c,
|
||||
0x65, 0x63, 0x74, 0x65, 0x64, 0x54, 0x69, 0x65, 0x72, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x6d,
|
||||
0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x5f, 0x70, 0x70, 0x6d, 0x18, 0x13, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x50,
|
||||
0x70, 0x6d, 0x12, 0x2a, 0x0a, 0x11, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72,
|
||||
0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x14, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x62,
|
||||
0x61, 0x73, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x34,
|
||||
0x0a, 0x16, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61,
|
||||
0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x15, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14,
|
||||
0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43,
|
||||
0x6f, 0x69, 0x6e, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x74, 0x61, 0x67, 0x65, 0x5f, 0x66, 0x65,
|
||||
0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x16, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x74,
|
||||
0x61, 0x67, 0x65, 0x46, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x12, 0x27, 0x0a, 0x0f, 0x68,
|
||||
0x69, 0x67, 0x68, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x18, 0x17,
|
||||
0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x68, 0x69, 0x67, 0x68, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x70,
|
||||
0x6c, 0x69, 0x65, 0x72, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x73,
|
||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x18, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x77,
|
||||
0x61, 0x72, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x77, 0x61, 0x6c,
|
||||
0x6c, 0x65, 0x74, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
|
||||
0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x2b, 0x0a,
|
||||
0x12, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74,
|
||||
0x5f, 0x6d, 0x73, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x64, 0x72, 0x61, 0x77, 0x43,
|
||||
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x2f, 0x0a, 0x14, 0x72, 0x65,
|
||||
0x77, 0x61, 0x72, 0x64, 0x5f, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f,
|
||||
0x6d, 0x73, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64,
|
||||
0x47, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x42, 0x3d, 0x5a, 0x3b, 0x68,
|
||||
0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x6c, 0x75, 0x63, 0x6b,
|
||||
0x79, 0x67, 0x69, 0x66, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69,
|
||||
0x66, 0x74, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_proto_events_luckygift_v1_events_proto_rawDescOnce sync.Once
|
||||
file_proto_events_luckygift_v1_events_proto_rawDescData = file_proto_events_luckygift_v1_events_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_proto_events_luckygift_v1_events_proto_rawDescGZIP() []byte {
|
||||
file_proto_events_luckygift_v1_events_proto_rawDescOnce.Do(func() {
|
||||
file_proto_events_luckygift_v1_events_proto_rawDescData = protoimpl.X.CompressGZIP(file_proto_events_luckygift_v1_events_proto_rawDescData)
|
||||
})
|
||||
return file_proto_events_luckygift_v1_events_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_events_luckygift_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
|
||||
var file_proto_events_luckygift_v1_events_proto_goTypes = []any{
|
||||
(*EventEnvelope)(nil), // 0: hyapp.events.luckygift.v1.EventEnvelope
|
||||
(*LuckyGiftDrawn)(nil), // 1: hyapp.events.luckygift.v1.LuckyGiftDrawn
|
||||
}
|
||||
var file_proto_events_luckygift_v1_events_proto_depIdxs = []int32{
|
||||
0, // [0:0] is the sub-list for method output_type
|
||||
0, // [0:0] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_proto_events_luckygift_v1_events_proto_init() }
|
||||
func file_proto_events_luckygift_v1_events_proto_init() {
|
||||
if File_proto_events_luckygift_v1_events_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_proto_events_luckygift_v1_events_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 2,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_proto_events_luckygift_v1_events_proto_goTypes,
|
||||
DependencyIndexes: file_proto_events_luckygift_v1_events_proto_depIdxs,
|
||||
MessageInfos: file_proto_events_luckygift_v1_events_proto_msgTypes,
|
||||
}.Build()
|
||||
File_proto_events_luckygift_v1_events_proto = out.File
|
||||
file_proto_events_luckygift_v1_events_proto_rawDesc = nil
|
||||
file_proto_events_luckygift_v1_events_proto_goTypes = nil
|
||||
file_proto_events_luckygift_v1_events_proto_depIdxs = nil
|
||||
}
|
||||
54
api/proto/events/luckygift/v1/events.proto
Normal file
54
api/proto/events/luckygift/v1/events.proto
Normal file
@ -0,0 +1,54 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package hyapp.events.luckygift.v1;
|
||||
|
||||
option go_package = "hyapp.local/api/proto/events/luckygift/v1;luckygifteventsv1";
|
||||
|
||||
// EventEnvelope 是 lucky-gift-service 发布 owner outbox 事实的稳定信封。
|
||||
// occurred_at_ms 固定为抽奖事实创建时的 Unix epoch milliseconds;MQ 实际发送时间不改变业务发生时间。
|
||||
message EventEnvelope {
|
||||
string event_id = 1;
|
||||
string event_type = 2;
|
||||
string app_code = 3;
|
||||
// draw_id 是本次送礼命令的聚合中奖标识;批量子抽明细仍只保存在 lucky owner 数据库,不能放大 MQ 负载。
|
||||
string draw_id = 4;
|
||||
int64 occurred_at_ms = 5;
|
||||
bytes body = 6;
|
||||
}
|
||||
|
||||
// LuckyGiftDrawn 表达钱包返奖已经 granted 的幸运礼物中奖事实。
|
||||
// lucky-gift-service 必须先完成 wallet-service 入账和本地 granted 收敛,再发布本事件;
|
||||
// room-service 与 activity-service 只从该事实派生房内 IM 和区域飘屏,不能反向改变抽奖或账务状态。
|
||||
message LuckyGiftDrawn {
|
||||
string draw_id = 1;
|
||||
string command_id = 2;
|
||||
string room_id = 3;
|
||||
string pool_id = 4;
|
||||
string gift_id = 5;
|
||||
int32 gift_count = 6;
|
||||
int64 sender_user_id = 7;
|
||||
int64 target_user_id = 8;
|
||||
// 展示资料是送礼入口固化的快照;消费者不应为了飘屏高频同步反查 user-service。
|
||||
string sender_name = 9;
|
||||
string sender_avatar = 10;
|
||||
string sender_display_user_id = 11;
|
||||
string sender_pretty_display_user_id = 12;
|
||||
// visible_region_id 是房间可见区域,区域播报不能用客户端 IP 或服务所在地推断。
|
||||
int64 visible_region_id = 13;
|
||||
int64 country_id = 14;
|
||||
int64 coin_spent = 15;
|
||||
int64 rule_version = 16;
|
||||
string experience_pool = 17;
|
||||
string selected_tier_id = 18;
|
||||
int64 multiplier_ppm = 19;
|
||||
int64 base_reward_coins = 20;
|
||||
int64 effective_reward_coins = 21;
|
||||
bool stage_feedback = 22;
|
||||
bool high_multiplier = 23;
|
||||
// reward_status 在本事件中只能是 granted;保留字段是为了下游 JSON 与现有 Flutter 协议同名映射。
|
||||
string reward_status = 24;
|
||||
string wallet_transaction_id = 25;
|
||||
// 两个时间均为 UTC Unix epoch milliseconds:抽奖发生时间与钱包返奖确认时间不能混用。
|
||||
int64 draw_created_at_ms = 26;
|
||||
int64 reward_granted_at_ms = 27;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -72,6 +72,11 @@ message RoomUserJoined {
|
||||
string avatar = 9;
|
||||
string display_user_id = 10;
|
||||
string pretty_display_user_id = 11;
|
||||
// VIP 字段来自同一次 GetMyVip effective state,首次进房 outbox 与重复进房 direct IM 必须保持同一语义。
|
||||
string vip_program_type = 12;
|
||||
int32 effective_vip_level = 13;
|
||||
string effective_vip_name = 14;
|
||||
bool room_entry_notice_enabled = 15;
|
||||
}
|
||||
|
||||
// RoomUserLeft 表达用户离房成功。
|
||||
@ -262,6 +267,38 @@ message RoomGiftBatchSent {
|
||||
int64 visible_region_id = 22;
|
||||
}
|
||||
|
||||
// RoomLuckyGiftDrawn 是 room-service 从 lucky-gift-service owner 事实派生的真人中奖展示事件。
|
||||
// 它只承载腾讯云 IM 房间表现,不是 Room Cell 核心状态,不能推进 room_version、榜单或房间快照。
|
||||
message RoomLuckyGiftDrawn {
|
||||
string draw_id = 1;
|
||||
string command_id = 2;
|
||||
string pool_id = 3;
|
||||
string gift_id = 4;
|
||||
int32 gift_count = 5;
|
||||
int64 sender_user_id = 6;
|
||||
int64 target_user_id = 7;
|
||||
string sender_name = 8;
|
||||
string sender_avatar = 9;
|
||||
string sender_display_user_id = 10;
|
||||
string sender_pretty_display_user_id = 11;
|
||||
int64 visible_region_id = 12;
|
||||
int64 country_id = 13;
|
||||
int64 coin_spent = 14;
|
||||
int64 rule_version = 15;
|
||||
string experience_pool = 16;
|
||||
string selected_tier_id = 17;
|
||||
int64 multiplier_ppm = 18;
|
||||
int64 base_reward_coins = 19;
|
||||
int64 effective_reward_coins = 20;
|
||||
bool stage_feedback = 21;
|
||||
bool high_multiplier = 22;
|
||||
string reward_status = 23;
|
||||
string wallet_transaction_id = 24;
|
||||
// 时间沿用 lucky owner 的 UTC Unix epoch milliseconds,不以 room-service 消费时间覆盖。
|
||||
int64 draw_created_at_ms = 25;
|
||||
int64 reward_granted_at_ms = 26;
|
||||
}
|
||||
|
||||
// RoomRobotLuckyGiftDrawn 是机器人房间专用幸运礼物展示事件。
|
||||
// 它复用客户端 lucky_gift_drawn 表现协议,但不代表真实奖池抽奖和钱包返奖事实。
|
||||
message RoomRobotLuckyGiftDrawn {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -31,6 +31,11 @@ message LuckyGiftMeta {
|
||||
int32 gift_count = 12;
|
||||
int64 visible_region_id = 13;
|
||||
int64 country_id = 14;
|
||||
// sender_* 是 room-service 在送礼入口已经确认的展示快照,只进入中奖事实和 IM,不能参与抽奖或账务判断。
|
||||
string sender_name = 15;
|
||||
string sender_avatar = 16;
|
||||
string sender_display_user_id = 17;
|
||||
string sender_pretty_display_user_id = 18;
|
||||
}
|
||||
|
||||
message LuckyGiftRuleTier {
|
||||
@ -108,6 +113,7 @@ message LuckyGiftDrawResult {
|
||||
int64 user_id = 21;
|
||||
string external_user_id = 22;
|
||||
string app_code = 23;
|
||||
repeated LuckyGiftHit hits = 24;
|
||||
}
|
||||
|
||||
message ExecuteLuckyGiftDrawRequest {
|
||||
@ -267,3 +273,13 @@ service AdminLuckyGiftService {
|
||||
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
|
||||
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);
|
||||
}
|
||||
|
||||
// LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。
|
||||
// 声明追加在文件尾以保持既有 protobuf message index 稳定。
|
||||
message LuckyGiftHit {
|
||||
int32 gift_index = 1;
|
||||
string draw_id = 2;
|
||||
string selected_tier_id = 3;
|
||||
int64 multiplier_ppm = 4;
|
||||
int64 reward_coins = 5;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -110,6 +110,8 @@ message LuckyGiftDrawResult {
|
||||
int64 coin_balance_after = 17;
|
||||
// target_user_id 是本次幸运礼物抽奖对应的收礼用户;多目标送礼用它关联结果。
|
||||
int64 target_user_id = 18;
|
||||
// hits 只包含 gift_count 微批中实际中奖的位置;旧客户端忽略该追加字段。
|
||||
repeated SendGiftLuckyHit hits = 19;
|
||||
}
|
||||
|
||||
// RoomRocketRewardItem 是后台配置给客户端展示的火箭奖励候选项。
|
||||
@ -680,6 +682,18 @@ message JoinRoomRequest {
|
||||
RoomUserDisplayProfile actor_display_profile = 8;
|
||||
// response_mode=lite 只裁剪响应快照,不改变 Room Cell 命令提交、outbox 或 IM 事件语义。
|
||||
string response_mode = 9;
|
||||
// presence_recovery 只用于客户端仍在房间但业务 presence 已超时清理的恢复。
|
||||
// 恢复成功只补回 Room Cell 在线状态,不发布 RoomUserJoined IM/outbox,避免重复入场表现和统计。
|
||||
bool presence_recovery = 10;
|
||||
}
|
||||
|
||||
// RoomEffectiveVipSnapshot 是 room-service 在进房时从 wallet-service 固化的轻量 VIP 展示事实。
|
||||
// 权益执行仍以后端 effective_benefits 为准,客户端不能用该快照自行声明房间权限。
|
||||
message RoomEffectiveVipSnapshot {
|
||||
string program_type = 1;
|
||||
int32 level = 2;
|
||||
string name = 3;
|
||||
bool room_entry_notice_enabled = 4;
|
||||
}
|
||||
|
||||
// JoinRoomResponse 返回加入后的房间快照。
|
||||
@ -687,6 +701,7 @@ message JoinRoomResponse {
|
||||
CommandResult result = 1;
|
||||
RoomUser user = 2;
|
||||
RoomSnapshot room = 3;
|
||||
RoomEffectiveVipSnapshot effective_vip = 4;
|
||||
}
|
||||
|
||||
// RoomHeartbeatRequest 显式刷新已经存在的房间业务 presence。
|
||||
@ -1143,6 +1158,9 @@ message SendGiftRequest {
|
||||
repeated SendGiftDisplayProfile target_display_profiles = 17;
|
||||
// display_mode=batch 只由新 batch-send HTTP 入口设置,用来生成单条多人展示 IM;旧 send 入口保持空值。
|
||||
string display_mode = 18;
|
||||
// combo_session_id + batch_seq 只描述 Flutter 微批会话,command_id 仍是跨服务唯一幂等键。
|
||||
string combo_session_id = 19;
|
||||
int64 batch_seq = 20;
|
||||
}
|
||||
|
||||
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||
@ -1163,6 +1181,8 @@ message SendGiftResponse {
|
||||
int64 gift_income_balance_after = 10;
|
||||
// batch_display 是新 batch-send 接口的多人展示快照;旧 send 入口保持空值。
|
||||
SendGiftBatchDisplay batch_display = 11;
|
||||
// v2 保存并重放首次成功的原始结算结果;追加字段不改变旧 Flutter 对顶层 V1 字段的读取。
|
||||
SendGiftResultV2 v2 = 12;
|
||||
}
|
||||
|
||||
// CheckSpeakPermissionRequest 让腾讯云 IM 发言回调或 gateway 在公屏前同步问房间业务态。
|
||||
@ -1195,8 +1215,20 @@ message VerifyRoomPresenceResponse {
|
||||
int64 room_version = 3;
|
||||
}
|
||||
|
||||
// ListRoomsRequest 查询当前用户所在区域可见的房间列表。
|
||||
// visible_region_id 必须来自 gateway 对 user-service 的 GetUser 结果,客户端不能直接提交。
|
||||
// ResolveRoomAppCodeRequest 仅供已验签的三方回调按全局 room_id 解析租户。
|
||||
// 该入口不接受客户端 app_code,避免固定 URL 参数把 Fami 事件错误路由到 Lalu。
|
||||
message ResolveRoomAppCodeRequest {
|
||||
string request_id = 1;
|
||||
string room_id = 2;
|
||||
}
|
||||
|
||||
message ResolveRoomAppCodeResponse {
|
||||
string app_code = 1;
|
||||
}
|
||||
|
||||
// ListRoomsRequest 查询首页公共房间列表。
|
||||
// visible_region_id 是 gateway 解析的实际查询区域:默认取 viewer 区域,显式筛选时取已校验的 filter_region_id。
|
||||
// gateway 同时写 filter_region_id 绑定游标;客户端不能直接构造任一内部字段。
|
||||
message ListRoomsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 viewer_user_id = 2;
|
||||
@ -1205,12 +1237,14 @@ message ListRoomsRequest {
|
||||
string cursor = 5;
|
||||
int32 limit = 6;
|
||||
string query = 7;
|
||||
// country_code 是可选国家筛选值;gateway 只能传当前用户区域国家列表内的国家码。
|
||||
// country_code 是可选国家筛选值;gateway 必须先确认它属于 active 区域。
|
||||
string country_code = 8;
|
||||
// viewer_country_code 是当前用户国家,用于区域内国家优先排序,不作为客户端可选筛选条件。
|
||||
// viewer_country_code 是当前用户国家,用于 All/区域列表的本国家优先排序。
|
||||
string viewer_country_code = 9;
|
||||
// all_visible_regions 只能由 gateway 根据后台白名单配置注入;为 true 时列表不按 visible_region_id 过滤。
|
||||
// all_visible_regions 只能由 gateway 根据 App 策略或后台白名单注入;为 true 时列表不按 visible_region_id 过滤。
|
||||
bool all_visible_regions = 10;
|
||||
// filter_region_id 是 gateway 校验过的 active 区域筛选;它与 all_visible_regions/country_code 互斥。
|
||||
int64 filter_region_id = 11;
|
||||
}
|
||||
|
||||
// ListRoomsByOwnersRequest 查询一组房主名下 active 房间。
|
||||
@ -1500,6 +1534,7 @@ service RoomCommandService {
|
||||
service RoomGuardService {
|
||||
rpc CheckSpeakPermission(CheckSpeakPermissionRequest) returns (CheckSpeakPermissionResponse);
|
||||
rpc VerifyRoomPresence(VerifyRoomPresenceRequest) returns (VerifyRoomPresenceResponse);
|
||||
rpc ResolveRoomAppCode(ResolveRoomAppCodeRequest) returns (ResolveRoomAppCodeResponse);
|
||||
}
|
||||
|
||||
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
|
||||
@ -1525,3 +1560,41 @@ service RoomQueryService {
|
||||
rpc ListRoomOnlineUsers(ListRoomOnlineUsersRequest) returns (ListRoomOnlineUsersResponse);
|
||||
rpc ListRoomBannedUsers(ListRoomBannedUsersRequest) returns (ListRoomBannedUsersResponse);
|
||||
}
|
||||
|
||||
// SendGiftLuckyHit 是 V2 微批中需要逐次播放的精简中奖命中。
|
||||
// 声明追加在文件尾以保持既有 protobuf message index 稳定;字段可在前面的消息中正常引用。
|
||||
message SendGiftLuckyHit {
|
||||
int32 gift_index = 1;
|
||||
string draw_id = 2;
|
||||
int64 target_user_id = 3;
|
||||
string selected_tier_id = 4;
|
||||
int64 multiplier_ppm = 5;
|
||||
int64 reward_coins = 6;
|
||||
}
|
||||
|
||||
// SendGiftResultV2 是可持久化、可原样重放的送礼结算结果。
|
||||
// 旧客户端继续读取 SendGiftResponse 顶层 V1 字段;V2 客户端只以该对象判断幂等、
|
||||
// 提交顺序和实际结算数量,避免网络重试时拿“当前房间快照”冒充原始结算结果。
|
||||
message SendGiftResultV2 {
|
||||
int32 api_version = 1;
|
||||
string command_id = 2;
|
||||
string combo_session_id = 3;
|
||||
int64 batch_seq = 4;
|
||||
bool idempotent_replay = 5;
|
||||
int64 committed_room_version = 6;
|
||||
string billing_receipt_id = 7;
|
||||
int64 coin_balance_after = 8;
|
||||
int64 gift_income_balance_after = 9;
|
||||
int32 settled_gift_count = 10;
|
||||
repeated int64 settled_target_user_ids = 11;
|
||||
// balance_version 是 sender 被扣费资产在该批次完成后的 wallet 账户版本;幂等重放保持首次值。
|
||||
int64 balance_version = 12;
|
||||
LuckyGiftDrawResult lucky_gift = 13;
|
||||
repeated LuckyGiftDrawResult lucky_gifts = 14;
|
||||
repeated SendGiftLuckyHit lucky_hits = 15;
|
||||
SendGiftBatchDisplay batch_display = 16;
|
||||
int64 room_heat = 17;
|
||||
repeated RankItem gift_rank = 18;
|
||||
// operation_status 当前成功响应固定为 committed;该字段让 V2 契约可以明确区分未来的异步受理态。
|
||||
string operation_status = 19;
|
||||
}
|
||||
|
||||
@ -1457,6 +1457,7 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
|
||||
const (
|
||||
RoomGuardService_CheckSpeakPermission_FullMethodName = "/hyapp.room.v1.RoomGuardService/CheckSpeakPermission"
|
||||
RoomGuardService_VerifyRoomPresence_FullMethodName = "/hyapp.room.v1.RoomGuardService/VerifyRoomPresence"
|
||||
RoomGuardService_ResolveRoomAppCode_FullMethodName = "/hyapp.room.v1.RoomGuardService/ResolveRoomAppCode"
|
||||
)
|
||||
|
||||
// RoomGuardServiceClient is the client API for RoomGuardService service.
|
||||
@ -1467,6 +1468,7 @@ const (
|
||||
type RoomGuardServiceClient interface {
|
||||
CheckSpeakPermission(ctx context.Context, in *CheckSpeakPermissionRequest, opts ...grpc.CallOption) (*CheckSpeakPermissionResponse, error)
|
||||
VerifyRoomPresence(ctx context.Context, in *VerifyRoomPresenceRequest, opts ...grpc.CallOption) (*VerifyRoomPresenceResponse, error)
|
||||
ResolveRoomAppCode(ctx context.Context, in *ResolveRoomAppCodeRequest, opts ...grpc.CallOption) (*ResolveRoomAppCodeResponse, error)
|
||||
}
|
||||
|
||||
type roomGuardServiceClient struct {
|
||||
@ -1497,6 +1499,16 @@ func (c *roomGuardServiceClient) VerifyRoomPresence(ctx context.Context, in *Ver
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomGuardServiceClient) ResolveRoomAppCode(ctx context.Context, in *ResolveRoomAppCodeRequest, opts ...grpc.CallOption) (*ResolveRoomAppCodeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ResolveRoomAppCodeResponse)
|
||||
err := c.cc.Invoke(ctx, RoomGuardService_ResolveRoomAppCode_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RoomGuardServiceServer is the server API for RoomGuardService service.
|
||||
// All implementations must embed UnimplementedRoomGuardServiceServer
|
||||
// for forward compatibility.
|
||||
@ -1505,6 +1517,7 @@ func (c *roomGuardServiceClient) VerifyRoomPresence(ctx context.Context, in *Ver
|
||||
type RoomGuardServiceServer interface {
|
||||
CheckSpeakPermission(context.Context, *CheckSpeakPermissionRequest) (*CheckSpeakPermissionResponse, error)
|
||||
VerifyRoomPresence(context.Context, *VerifyRoomPresenceRequest) (*VerifyRoomPresenceResponse, error)
|
||||
ResolveRoomAppCode(context.Context, *ResolveRoomAppCodeRequest) (*ResolveRoomAppCodeResponse, error)
|
||||
mustEmbedUnimplementedRoomGuardServiceServer()
|
||||
}
|
||||
|
||||
@ -1521,6 +1534,9 @@ func (UnimplementedRoomGuardServiceServer) CheckSpeakPermission(context.Context,
|
||||
func (UnimplementedRoomGuardServiceServer) VerifyRoomPresence(context.Context, *VerifyRoomPresenceRequest) (*VerifyRoomPresenceResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method VerifyRoomPresence not implemented")
|
||||
}
|
||||
func (UnimplementedRoomGuardServiceServer) ResolveRoomAppCode(context.Context, *ResolveRoomAppCodeRequest) (*ResolveRoomAppCodeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ResolveRoomAppCode not implemented")
|
||||
}
|
||||
func (UnimplementedRoomGuardServiceServer) mustEmbedUnimplementedRoomGuardServiceServer() {}
|
||||
func (UnimplementedRoomGuardServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -1578,6 +1594,24 @@ func _RoomGuardService_VerifyRoomPresence_Handler(srv interface{}, ctx context.C
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomGuardService_ResolveRoomAppCode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ResolveRoomAppCodeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomGuardServiceServer).ResolveRoomAppCode(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomGuardService_ResolveRoomAppCode_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomGuardServiceServer).ResolveRoomAppCode(ctx, req.(*ResolveRoomAppCodeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// RoomGuardService_ServiceDesc is the grpc.ServiceDesc for RoomGuardService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -1593,6 +1627,10 @@ var RoomGuardService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "VerifyRoomPresence",
|
||||
Handler: _RoomGuardService_VerifyRoomPresence_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ResolveRoomAppCode",
|
||||
Handler: _RoomGuardService_ResolveRoomAppCode_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/room/v1/room.proto",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -124,6 +124,8 @@ message UserRoleSummary {
|
||||
string coin_seller_status = 11;
|
||||
int64 pending_role_invitations = 12;
|
||||
bool is_manager = 13;
|
||||
// roles 给后台等需要多身份并列展示的调用方提供稳定字符串集合;原布尔字段继续兼容 App 入口显隐。
|
||||
repeated string roles = 14;
|
||||
}
|
||||
|
||||
// AgencyMembership 是 host 与 Agency 的有时效关系事实。
|
||||
@ -480,6 +482,24 @@ message CheckBusinessCapabilityResponse {
|
||||
string reason = 2;
|
||||
}
|
||||
|
||||
// RoleScopePolicy 是 App 级角色拓展边界;调用方只消费 scope,不直接判断具体 app_code。
|
||||
message RoleScopePolicy {
|
||||
string scene = 1;
|
||||
string base_scope = 2;
|
||||
bool region_expansion_configurable = 3;
|
||||
// expanded_scope 是经理通用范围开关开启后的范围;调用方不能自行假设一定是 region。
|
||||
string expanded_scope = 4;
|
||||
}
|
||||
|
||||
message GetRoleScopePolicyRequest {
|
||||
RequestMeta meta = 1;
|
||||
string scene = 2;
|
||||
}
|
||||
|
||||
message GetRoleScopePolicyResponse {
|
||||
RoleScopePolicy policy = 1;
|
||||
}
|
||||
|
||||
message GetAgencyMembersRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 agency_id = 2;
|
||||
@ -678,6 +698,39 @@ message UpdateAgencyProfileResponse {
|
||||
Agency agency = 1;
|
||||
}
|
||||
|
||||
// HostEngagementStats 是 Fami 等公会中心使用的用户侧统计聚合;金额/礼物流水由 wallet-service 单独拥有。
|
||||
message HostEngagementStats {
|
||||
int64 online_duration_ms = 1;
|
||||
int64 valid_mic_duration_ms = 2;
|
||||
int64 valid_mic_days = 3;
|
||||
int64 private_message_senders = 4;
|
||||
int64 new_followers = 5;
|
||||
}
|
||||
|
||||
message GetHostEngagementStatsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 host_user_id = 2;
|
||||
int64 start_at_ms = 3;
|
||||
int64 end_at_ms = 4;
|
||||
}
|
||||
|
||||
message GetHostEngagementStatsResponse {
|
||||
HostEngagementStats stats = 1;
|
||||
}
|
||||
|
||||
// RecordPrivateMessageEvent 由经过腾讯回调鉴权的 gateway 写入私信事实;重复 event_id 必须幂等。
|
||||
message RecordPrivateMessageEventRequest {
|
||||
RequestMeta meta = 1;
|
||||
string event_id = 2;
|
||||
int64 sender_user_id = 3;
|
||||
int64 target_user_id = 4;
|
||||
int64 occurred_at_ms = 5;
|
||||
}
|
||||
|
||||
message RecordPrivateMessageEventResponse {
|
||||
bool created = 1;
|
||||
}
|
||||
|
||||
// UserHostService 是 user-service 内部 host domain 的 gRPC 面,不是独立微服务。
|
||||
service UserHostService {
|
||||
rpc SearchAgencies(SearchAgenciesRequest) returns (SearchAgenciesResponse);
|
||||
@ -707,9 +760,12 @@ service UserHostService {
|
||||
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
|
||||
rpc GetAgency(GetAgencyRequest) returns (GetAgencyResponse);
|
||||
rpc CheckBusinessCapability(CheckBusinessCapabilityRequest) returns (CheckBusinessCapabilityResponse);
|
||||
rpc GetRoleScopePolicy(GetRoleScopePolicyRequest) returns (GetRoleScopePolicyResponse);
|
||||
rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);
|
||||
rpc GetAgencyApplications(GetAgencyApplicationsRequest) returns (GetAgencyApplicationsResponse);
|
||||
rpc UpdateAgencyProfile(UpdateAgencyProfileRequest) returns (UpdateAgencyProfileResponse);
|
||||
rpc GetHostEngagementStats(GetHostEngagementStatsRequest) returns (GetHostEngagementStatsResponse);
|
||||
rpc RecordPrivateMessageEvent(RecordPrivateMessageEventRequest) returns (RecordPrivateMessageEventResponse);
|
||||
}
|
||||
|
||||
// UserHostAdminService 是后台关系管理入口;公网 admin 鉴权由 admin-server 承担。
|
||||
|
||||
@ -45,9 +45,12 @@ const (
|
||||
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
|
||||
UserHostService_GetAgency_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgency"
|
||||
UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability"
|
||||
UserHostService_GetRoleScopePolicy_FullMethodName = "/hyapp.user.v1.UserHostService/GetRoleScopePolicy"
|
||||
UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers"
|
||||
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
|
||||
UserHostService_UpdateAgencyProfile_FullMethodName = "/hyapp.user.v1.UserHostService/UpdateAgencyProfile"
|
||||
UserHostService_GetHostEngagementStats_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostEngagementStats"
|
||||
UserHostService_RecordPrivateMessageEvent_FullMethodName = "/hyapp.user.v1.UserHostService/RecordPrivateMessageEvent"
|
||||
)
|
||||
|
||||
// UserHostServiceClient is the client API for UserHostService service.
|
||||
@ -83,9 +86,12 @@ type UserHostServiceClient interface {
|
||||
GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error)
|
||||
GetAgency(ctx context.Context, in *GetAgencyRequest, opts ...grpc.CallOption) (*GetAgencyResponse, error)
|
||||
CheckBusinessCapability(ctx context.Context, in *CheckBusinessCapabilityRequest, opts ...grpc.CallOption) (*CheckBusinessCapabilityResponse, error)
|
||||
GetRoleScopePolicy(ctx context.Context, in *GetRoleScopePolicyRequest, opts ...grpc.CallOption) (*GetRoleScopePolicyResponse, error)
|
||||
GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error)
|
||||
GetAgencyApplications(ctx context.Context, in *GetAgencyApplicationsRequest, opts ...grpc.CallOption) (*GetAgencyApplicationsResponse, error)
|
||||
UpdateAgencyProfile(ctx context.Context, in *UpdateAgencyProfileRequest, opts ...grpc.CallOption) (*UpdateAgencyProfileResponse, error)
|
||||
GetHostEngagementStats(ctx context.Context, in *GetHostEngagementStatsRequest, opts ...grpc.CallOption) (*GetHostEngagementStatsResponse, error)
|
||||
RecordPrivateMessageEvent(ctx context.Context, in *RecordPrivateMessageEventRequest, opts ...grpc.CallOption) (*RecordPrivateMessageEventResponse, error)
|
||||
}
|
||||
|
||||
type userHostServiceClient struct {
|
||||
@ -356,6 +362,16 @@ func (c *userHostServiceClient) CheckBusinessCapability(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) GetRoleScopePolicy(ctx context.Context, in *GetRoleScopePolicyRequest, opts ...grpc.CallOption) (*GetRoleScopePolicyResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetRoleScopePolicyResponse)
|
||||
err := c.cc.Invoke(ctx, UserHostService_GetRoleScopePolicy_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetAgencyMembersResponse)
|
||||
@ -386,6 +402,26 @@ func (c *userHostServiceClient) UpdateAgencyProfile(ctx context.Context, in *Upd
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) GetHostEngagementStats(ctx context.Context, in *GetHostEngagementStatsRequest, opts ...grpc.CallOption) (*GetHostEngagementStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetHostEngagementStatsResponse)
|
||||
err := c.cc.Invoke(ctx, UserHostService_GetHostEngagementStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) RecordPrivateMessageEvent(ctx context.Context, in *RecordPrivateMessageEventRequest, opts ...grpc.CallOption) (*RecordPrivateMessageEventResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RecordPrivateMessageEventResponse)
|
||||
err := c.cc.Invoke(ctx, UserHostService_RecordPrivateMessageEvent_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UserHostServiceServer is the server API for UserHostService service.
|
||||
// All implementations must embed UnimplementedUserHostServiceServer
|
||||
// for forward compatibility.
|
||||
@ -419,9 +455,12 @@ type UserHostServiceServer interface {
|
||||
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
|
||||
GetAgency(context.Context, *GetAgencyRequest) (*GetAgencyResponse, error)
|
||||
CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error)
|
||||
GetRoleScopePolicy(context.Context, *GetRoleScopePolicyRequest) (*GetRoleScopePolicyResponse, error)
|
||||
GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error)
|
||||
GetAgencyApplications(context.Context, *GetAgencyApplicationsRequest) (*GetAgencyApplicationsResponse, error)
|
||||
UpdateAgencyProfile(context.Context, *UpdateAgencyProfileRequest) (*UpdateAgencyProfileResponse, error)
|
||||
GetHostEngagementStats(context.Context, *GetHostEngagementStatsRequest) (*GetHostEngagementStatsResponse, error)
|
||||
RecordPrivateMessageEvent(context.Context, *RecordPrivateMessageEventRequest) (*RecordPrivateMessageEventResponse, error)
|
||||
mustEmbedUnimplementedUserHostServiceServer()
|
||||
}
|
||||
|
||||
@ -510,6 +549,9 @@ func (UnimplementedUserHostServiceServer) GetAgency(context.Context, *GetAgencyR
|
||||
func (UnimplementedUserHostServiceServer) CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CheckBusinessCapability not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) GetRoleScopePolicy(context.Context, *GetRoleScopePolicyRequest) (*GetRoleScopePolicyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetRoleScopePolicy not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetAgencyMembers not implemented")
|
||||
}
|
||||
@ -519,6 +561,12 @@ func (UnimplementedUserHostServiceServer) GetAgencyApplications(context.Context,
|
||||
func (UnimplementedUserHostServiceServer) UpdateAgencyProfile(context.Context, *UpdateAgencyProfileRequest) (*UpdateAgencyProfileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateAgencyProfile not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) GetHostEngagementStats(context.Context, *GetHostEngagementStatsRequest) (*GetHostEngagementStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetHostEngagementStats not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) RecordPrivateMessageEvent(context.Context, *RecordPrivateMessageEventRequest) (*RecordPrivateMessageEventResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RecordPrivateMessageEvent not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) mustEmbedUnimplementedUserHostServiceServer() {}
|
||||
func (UnimplementedUserHostServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -1008,6 +1056,24 @@ func _UserHostService_CheckBusinessCapability_Handler(srv interface{}, ctx conte
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_GetRoleScopePolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRoleScopePolicyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserHostServiceServer).GetRoleScopePolicy(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserHostService_GetRoleScopePolicy_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserHostServiceServer).GetRoleScopePolicy(ctx, req.(*GetRoleScopePolicyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_GetAgencyMembers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetAgencyMembersRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1062,6 +1128,42 @@ func _UserHostService_UpdateAgencyProfile_Handler(srv interface{}, ctx context.C
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_GetHostEngagementStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetHostEngagementStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserHostServiceServer).GetHostEngagementStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserHostService_GetHostEngagementStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserHostServiceServer).GetHostEngagementStats(ctx, req.(*GetHostEngagementStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_RecordPrivateMessageEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RecordPrivateMessageEventRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserHostServiceServer).RecordPrivateMessageEvent(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserHostService_RecordPrivateMessageEvent_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserHostServiceServer).RecordPrivateMessageEvent(ctx, req.(*RecordPrivateMessageEventRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// UserHostService_ServiceDesc is the grpc.ServiceDesc for UserHostService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -1173,6 +1275,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "CheckBusinessCapability",
|
||||
Handler: _UserHostService_CheckBusinessCapability_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRoleScopePolicy",
|
||||
Handler: _UserHostService_GetRoleScopePolicy_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetAgencyMembers",
|
||||
Handler: _UserHostService_GetAgencyMembers_Handler,
|
||||
@ -1185,6 +1291,14 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "UpdateAgencyProfile",
|
||||
Handler: _UserHostService_UpdateAgencyProfile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetHostEngagementStats",
|
||||
Handler: _UserHostService_GetHostEngagementStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RecordPrivateMessageEvent",
|
||||
Handler: _UserHostService_RecordPrivateMessageEvent_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/user/v1/host.proto",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,16 @@ message RequestMeta {
|
||||
string platform = 11;
|
||||
string language = 12;
|
||||
string timezone = 13;
|
||||
// app_version/build_number 记录发起本次 RPC 的客户端构建;认证链路依赖它们把每次成功登录归档到 login_audit。
|
||||
string app_version = 14;
|
||||
string build_number = 15;
|
||||
// device_model/device_manufacturer/os_version/imei 是客户端设备资料快照,来自 gateway 的
|
||||
// X-Device-Model/X-Device-Manufacturer/X-OS-Version/X-Device-IMEI 头;认证成功后按 device_id
|
||||
// 聚合进 user_devices,支撑后台用户详情的设备 tab。非认证链路可留空,IMEI 在 Android 10+ 通常为空。
|
||||
string device_model = 16;
|
||||
string device_manufacturer = 17;
|
||||
string os_version = 18;
|
||||
string imei = 19;
|
||||
}
|
||||
|
||||
// App 是 user-service 维护的 App 注册表投影,用于把客户端包名映射到内部租户键。
|
||||
@ -160,6 +170,7 @@ message CronBatchRequest {
|
||||
int64 lock_ttl_ms = 5;
|
||||
int64 pending_publish_max_age_ms = 6;
|
||||
int64 publishing_session_max_age_ms = 7;
|
||||
int64 open_session_max_age_ms = 8;
|
||||
}
|
||||
|
||||
// CronBatchResponse 返回一个批处理 run 的执行摘要。
|
||||
@ -691,6 +702,66 @@ message BatchGetUsersResponse {
|
||||
map<int64, User> users = 1;
|
||||
}
|
||||
|
||||
// ActiveUserBanSummary 是后台用户列表读取的有效封禁摘要。
|
||||
// expires_at_ms=0 只在 permanent=true 时表示永久;source 区分 admin、manager 和无新事实表记录的 legacy。
|
||||
message ActiveUserBanSummary {
|
||||
bool active = 1;
|
||||
string source = 2;
|
||||
string ban_id = 3;
|
||||
bool permanent = 4;
|
||||
int64 expires_at_ms = 5;
|
||||
UserStatus user_status = 6;
|
||||
string operator_type = 7;
|
||||
int64 operator_user_id = 8;
|
||||
string reason = 9;
|
||||
int64 created_at_ms = 10;
|
||||
int32 active_ban_count = 11;
|
||||
}
|
||||
|
||||
// UserAdminProfile 聚合后台用户列表和详情需要的 user-service owner 事实。
|
||||
// roles 只返回当前有效身份:host、agency、bd、bd_leader、coin_seller、manager。
|
||||
message UserAdminProfile {
|
||||
User user = 1;
|
||||
string birth = 2;
|
||||
string register_device = 3;
|
||||
int64 last_success_login_at_ms = 4;
|
||||
repeated string roles = 5;
|
||||
ActiveUserBanSummary ban = 6;
|
||||
string last_operator_type = 7;
|
||||
int64 last_operator_user_id = 8;
|
||||
string last_operation_reason = 9;
|
||||
int64 last_operation_at_ms = 10;
|
||||
}
|
||||
|
||||
message BatchGetUserAdminProfilesRequest {
|
||||
RequestMeta meta = 1;
|
||||
repeated int64 user_ids = 2;
|
||||
}
|
||||
|
||||
message BatchGetUserAdminProfilesResponse {
|
||||
map<int64, UserAdminProfile> profiles = 1;
|
||||
}
|
||||
|
||||
// AdminIssueUserAccessTokenRequest 供后台按用户当前最新 active session 重签 access token。
|
||||
// 该链路不写 login_audit,也不修改 session 行——后台取 token 不能污染用户“最近活跃/最近登录”事实;
|
||||
// 审计责任由 admin 侧操作日志承担。
|
||||
message AdminIssueUserAccessTokenRequest {
|
||||
RequestMeta meta = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
}
|
||||
|
||||
// AdminIssueUserAccessTokenResponse 返回重签的 access token 和所属 session 的定位信息。
|
||||
// 不返回 refresh token:后台没有轮换会话的理由,refresh 原文也不允许离开登录主链路。
|
||||
message AdminIssueUserAccessTokenResponse {
|
||||
string access_token = 1;
|
||||
string token_type = 2;
|
||||
int64 expires_in_sec = 3;
|
||||
string session_id = 4;
|
||||
string device_id = 5;
|
||||
int64 session_last_heartbeat_at_ms = 6;
|
||||
}
|
||||
|
||||
// RoomBasicUser 是房间首屏只需要的用户展示基础资料。
|
||||
message RoomBasicUser {
|
||||
int64 user_id = 1;
|
||||
@ -831,6 +902,56 @@ message SetUserStatusResponse {
|
||||
string access_token_revoke_error = 11;
|
||||
}
|
||||
|
||||
// AdminUserBan 是后台管理员封禁的独立事实;expires_at_ms=0 表示永久封禁。
|
||||
message AdminUserBan {
|
||||
string ban_id = 1;
|
||||
string command_id = 2;
|
||||
int64 target_user_id = 3;
|
||||
int64 expires_at_ms = 4;
|
||||
bool permanent = 5;
|
||||
string status = 6;
|
||||
string reason = 7;
|
||||
int64 operator_admin_id = 8;
|
||||
int64 created_at_ms = 9;
|
||||
int64 updated_at_ms = 10;
|
||||
int64 released_by_admin_id = 11;
|
||||
string released_reason = 12;
|
||||
int64 released_at_ms = 13;
|
||||
}
|
||||
|
||||
message AdminBanUserRequest {
|
||||
RequestMeta meta = 1;
|
||||
string command_id = 2;
|
||||
int64 target_user_id = 3;
|
||||
int64 expires_at_ms = 4;
|
||||
int64 operator_admin_id = 5;
|
||||
string reason = 6;
|
||||
}
|
||||
|
||||
message AdminBanUserResponse {
|
||||
AdminUserBan ban = 1;
|
||||
SetUserStatusResponse status = 2;
|
||||
}
|
||||
|
||||
// AdminUnbanUserRequest 由后台管理员强制释放目标用户全部管理员、经理和 legacy 封禁来源。
|
||||
message AdminUnbanUserRequest {
|
||||
RequestMeta meta = 1;
|
||||
// ban_id 已废弃;保留字段号用于滚动发布兼容,强制解封始终按 target_user_id 处理全部有效事实。
|
||||
string ban_id = 2;
|
||||
int64 target_user_id = 3;
|
||||
int64 operator_admin_id = 4;
|
||||
string reason = 5;
|
||||
}
|
||||
|
||||
message AdminUnbanUserResponse {
|
||||
// ban 仅为旧客户端保留;强制解封可能只释放经理或 legacy 来源,因此新调用方不应依赖该字段。
|
||||
AdminUserBan ban = 1;
|
||||
SetUserStatusResponse status = 2;
|
||||
int64 released_admin_ban_count = 3;
|
||||
int64 released_manager_block_count = 4;
|
||||
bool status_restored = 5;
|
||||
}
|
||||
|
||||
message ManagerUserBlock {
|
||||
string block_id = 1;
|
||||
int64 manager_user_id = 2;
|
||||
@ -1343,6 +1464,8 @@ service UserService {
|
||||
rpc BusinessUserLookup(BusinessUserLookupRequest) returns (BusinessUserLookupResponse);
|
||||
rpc GetMyProfileStats(GetMyProfileStatsRequest) returns (GetMyProfileStatsResponse);
|
||||
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
|
||||
rpc BatchGetUserAdminProfiles(BatchGetUserAdminProfilesRequest) returns (BatchGetUserAdminProfilesResponse);
|
||||
rpc AdminIssueUserAccessToken(AdminIssueUserAccessTokenRequest) returns (AdminIssueUserAccessTokenResponse);
|
||||
rpc BatchGetRoomBasicUsers(BatchGetRoomBasicUsersRequest) returns (BatchGetRoomBasicUsersResponse);
|
||||
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
|
||||
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
|
||||
@ -1353,6 +1476,8 @@ service UserService {
|
||||
rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse);
|
||||
rpc AdminChangeUserCountry(AdminChangeUserCountryRequest) returns (ChangeUserCountryResponse);
|
||||
rpc SetUserStatus(SetUserStatusRequest) returns (SetUserStatusResponse);
|
||||
rpc AdminBanUser(AdminBanUserRequest) returns (AdminBanUserResponse);
|
||||
rpc AdminUnbanUser(AdminUnbanUserRequest) returns (AdminUnbanUserResponse);
|
||||
rpc CreateManagerUserBlock(CreateManagerUserBlockRequest) returns (CreateManagerUserBlockResponse);
|
||||
rpc ListManagerUserBlocks(ListManagerUserBlocksRequest) returns (ListManagerUserBlocksResponse);
|
||||
rpc UnblockManagerUser(UnblockManagerUserRequest) returns (UnblockManagerUserResponse);
|
||||
@ -1399,7 +1524,9 @@ service UserCronService {
|
||||
rpc ProcessLoginIPRiskBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ProcessRegionRebuildBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc CompensateMicOpenSessions(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc CompensateRoomOpenSessions(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ExpireManagerUserBlocks(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ExpireAdminUserBans(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc RefreshCPIntimacyLeaderboard(CronBatchRequest) returns (CronBatchResponse);
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,8 @@ const (
|
||||
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
|
||||
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
|
||||
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
|
||||
UserService_BatchGetUserAdminProfiles_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUserAdminProfiles"
|
||||
UserService_AdminIssueUserAccessToken_FullMethodName = "/hyapp.user.v1.UserService/AdminIssueUserAccessToken"
|
||||
UserService_BatchGetRoomBasicUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetRoomBasicUsers"
|
||||
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
|
||||
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
|
||||
@ -34,6 +36,8 @@ const (
|
||||
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
|
||||
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
|
||||
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
|
||||
UserService_AdminBanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminBanUser"
|
||||
UserService_AdminUnbanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminUnbanUser"
|
||||
UserService_CreateManagerUserBlock_FullMethodName = "/hyapp.user.v1.UserService/CreateManagerUserBlock"
|
||||
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
|
||||
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
|
||||
@ -53,6 +57,8 @@ type UserServiceClient interface {
|
||||
BusinessUserLookup(ctx context.Context, in *BusinessUserLookupRequest, opts ...grpc.CallOption) (*BusinessUserLookupResponse, error)
|
||||
GetMyProfileStats(ctx context.Context, in *GetMyProfileStatsRequest, opts ...grpc.CallOption) (*GetMyProfileStatsResponse, error)
|
||||
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
|
||||
BatchGetUserAdminProfiles(ctx context.Context, in *BatchGetUserAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserAdminProfilesResponse, error)
|
||||
AdminIssueUserAccessToken(ctx context.Context, in *AdminIssueUserAccessTokenRequest, opts ...grpc.CallOption) (*AdminIssueUserAccessTokenResponse, error)
|
||||
BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error)
|
||||
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
|
||||
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
|
||||
@ -63,6 +69,8 @@ type UserServiceClient interface {
|
||||
ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
|
||||
AdminChangeUserCountry(ctx context.Context, in *AdminChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
|
||||
SetUserStatus(ctx context.Context, in *SetUserStatusRequest, opts ...grpc.CallOption) (*SetUserStatusResponse, error)
|
||||
AdminBanUser(ctx context.Context, in *AdminBanUserRequest, opts ...grpc.CallOption) (*AdminBanUserResponse, error)
|
||||
AdminUnbanUser(ctx context.Context, in *AdminUnbanUserRequest, opts ...grpc.CallOption) (*AdminUnbanUserResponse, error)
|
||||
CreateManagerUserBlock(ctx context.Context, in *CreateManagerUserBlockRequest, opts ...grpc.CallOption) (*CreateManagerUserBlockResponse, error)
|
||||
ListManagerUserBlocks(ctx context.Context, in *ListManagerUserBlocksRequest, opts ...grpc.CallOption) (*ListManagerUserBlocksResponse, error)
|
||||
UnblockManagerUser(ctx context.Context, in *UnblockManagerUserRequest, opts ...grpc.CallOption) (*UnblockManagerUserResponse, error)
|
||||
@ -129,6 +137,26 @@ func (c *userServiceClient) BatchGetUsers(ctx context.Context, in *BatchGetUsers
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) BatchGetUserAdminProfiles(ctx context.Context, in *BatchGetUserAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserAdminProfilesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(BatchGetUserAdminProfilesResponse)
|
||||
err := c.cc.Invoke(ctx, UserService_BatchGetUserAdminProfiles_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) AdminIssueUserAccessToken(ctx context.Context, in *AdminIssueUserAccessTokenRequest, opts ...grpc.CallOption) (*AdminIssueUserAccessTokenResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminIssueUserAccessTokenResponse)
|
||||
err := c.cc.Invoke(ctx, UserService_AdminIssueUserAccessToken_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(BatchGetRoomBasicUsersResponse)
|
||||
@ -229,6 +257,26 @@ func (c *userServiceClient) SetUserStatus(ctx context.Context, in *SetUserStatus
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) AdminBanUser(ctx context.Context, in *AdminBanUserRequest, opts ...grpc.CallOption) (*AdminBanUserResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminBanUserResponse)
|
||||
err := c.cc.Invoke(ctx, UserService_AdminBanUser_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) AdminUnbanUser(ctx context.Context, in *AdminUnbanUserRequest, opts ...grpc.CallOption) (*AdminUnbanUserResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminUnbanUserResponse)
|
||||
err := c.cc.Invoke(ctx, UserService_AdminUnbanUser_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) CreateManagerUserBlock(ctx context.Context, in *CreateManagerUserBlockRequest, opts ...grpc.CallOption) (*CreateManagerUserBlockResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateManagerUserBlockResponse)
|
||||
@ -300,6 +348,8 @@ type UserServiceServer interface {
|
||||
BusinessUserLookup(context.Context, *BusinessUserLookupRequest) (*BusinessUserLookupResponse, error)
|
||||
GetMyProfileStats(context.Context, *GetMyProfileStatsRequest) (*GetMyProfileStatsResponse, error)
|
||||
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
|
||||
BatchGetUserAdminProfiles(context.Context, *BatchGetUserAdminProfilesRequest) (*BatchGetUserAdminProfilesResponse, error)
|
||||
AdminIssueUserAccessToken(context.Context, *AdminIssueUserAccessTokenRequest) (*AdminIssueUserAccessTokenResponse, error)
|
||||
BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error)
|
||||
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
|
||||
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
|
||||
@ -310,6 +360,8 @@ type UserServiceServer interface {
|
||||
ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
|
||||
AdminChangeUserCountry(context.Context, *AdminChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
|
||||
SetUserStatus(context.Context, *SetUserStatusRequest) (*SetUserStatusResponse, error)
|
||||
AdminBanUser(context.Context, *AdminBanUserRequest) (*AdminBanUserResponse, error)
|
||||
AdminUnbanUser(context.Context, *AdminUnbanUserRequest) (*AdminUnbanUserResponse, error)
|
||||
CreateManagerUserBlock(context.Context, *CreateManagerUserBlockRequest) (*CreateManagerUserBlockResponse, error)
|
||||
ListManagerUserBlocks(context.Context, *ListManagerUserBlocksRequest) (*ListManagerUserBlocksResponse, error)
|
||||
UnblockManagerUser(context.Context, *UnblockManagerUserRequest) (*UnblockManagerUserResponse, error)
|
||||
@ -341,6 +393,12 @@ func (UnimplementedUserServiceServer) GetMyProfileStats(context.Context, *GetMyP
|
||||
func (UnimplementedUserServiceServer) BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUsers not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) BatchGetUserAdminProfiles(context.Context, *BatchGetUserAdminProfilesRequest) (*BatchGetUserAdminProfilesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUserAdminProfiles not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) AdminIssueUserAccessToken(context.Context, *AdminIssueUserAccessTokenRequest) (*AdminIssueUserAccessTokenResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AdminIssueUserAccessToken not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchGetRoomBasicUsers not implemented")
|
||||
}
|
||||
@ -371,6 +429,12 @@ func (UnimplementedUserServiceServer) AdminChangeUserCountry(context.Context, *A
|
||||
func (UnimplementedUserServiceServer) SetUserStatus(context.Context, *SetUserStatusRequest) (*SetUserStatusResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetUserStatus not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) AdminBanUser(context.Context, *AdminBanUserRequest) (*AdminBanUserResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AdminBanUser not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) AdminUnbanUser(context.Context, *AdminUnbanUserRequest) (*AdminUnbanUserResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AdminUnbanUser not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) CreateManagerUserBlock(context.Context, *CreateManagerUserBlockRequest) (*CreateManagerUserBlockResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateManagerUserBlock not implemented")
|
||||
}
|
||||
@ -500,6 +564,42 @@ func _UserService_BatchGetUsers_Handler(srv interface{}, ctx context.Context, de
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_BatchGetUserAdminProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(BatchGetUserAdminProfilesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserServiceServer).BatchGetUserAdminProfiles(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserService_BatchGetUserAdminProfiles_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserServiceServer).BatchGetUserAdminProfiles(ctx, req.(*BatchGetUserAdminProfilesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_AdminIssueUserAccessToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminIssueUserAccessTokenRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserServiceServer).AdminIssueUserAccessToken(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserService_AdminIssueUserAccessToken_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserServiceServer).AdminIssueUserAccessToken(ctx, req.(*AdminIssueUserAccessTokenRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_BatchGetRoomBasicUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(BatchGetRoomBasicUsersRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -680,6 +780,42 @@ func _UserService_SetUserStatus_Handler(srv interface{}, ctx context.Context, de
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_AdminBanUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminBanUserRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserServiceServer).AdminBanUser(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserService_AdminBanUser_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserServiceServer).AdminBanUser(ctx, req.(*AdminBanUserRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_AdminUnbanUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminUnbanUserRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserServiceServer).AdminUnbanUser(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserService_AdminUnbanUser_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserServiceServer).AdminUnbanUser(ctx, req.(*AdminUnbanUserRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_CreateManagerUserBlock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateManagerUserBlockRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -815,6 +951,14 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "BatchGetUsers",
|
||||
Handler: _UserService_BatchGetUsers_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchGetUserAdminProfiles",
|
||||
Handler: _UserService_BatchGetUserAdminProfiles_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminIssueUserAccessToken",
|
||||
Handler: _UserService_AdminIssueUserAccessToken_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchGetRoomBasicUsers",
|
||||
Handler: _UserService_BatchGetRoomBasicUsers_Handler,
|
||||
@ -855,6 +999,14 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "SetUserStatus",
|
||||
Handler: _UserService_SetUserStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminBanUser",
|
||||
Handler: _UserService_AdminBanUser_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminUnbanUser",
|
||||
Handler: _UserService_AdminUnbanUser_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateManagerUserBlock",
|
||||
Handler: _UserService_CreateManagerUserBlock_Handler,
|
||||
@ -1890,7 +2042,9 @@ const (
|
||||
UserCronService_ProcessLoginIPRiskBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessLoginIPRiskBatch"
|
||||
UserCronService_ProcessRegionRebuildBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessRegionRebuildBatch"
|
||||
UserCronService_CompensateMicOpenSessions_FullMethodName = "/hyapp.user.v1.UserCronService/CompensateMicOpenSessions"
|
||||
UserCronService_CompensateRoomOpenSessions_FullMethodName = "/hyapp.user.v1.UserCronService/CompensateRoomOpenSessions"
|
||||
UserCronService_ExpireManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserCronService/ExpireManagerUserBlocks"
|
||||
UserCronService_ExpireAdminUserBans_FullMethodName = "/hyapp.user.v1.UserCronService/ExpireAdminUserBans"
|
||||
UserCronService_RefreshCPIntimacyLeaderboard_FullMethodName = "/hyapp.user.v1.UserCronService/RefreshCPIntimacyLeaderboard"
|
||||
)
|
||||
|
||||
@ -1903,7 +2057,9 @@ type UserCronServiceClient interface {
|
||||
ProcessLoginIPRiskBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ProcessRegionRebuildBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
CompensateMicOpenSessions(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
CompensateRoomOpenSessions(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ExpireManagerUserBlocks(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ExpireAdminUserBans(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
RefreshCPIntimacyLeaderboard(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
}
|
||||
|
||||
@ -1945,6 +2101,16 @@ func (c *userCronServiceClient) CompensateMicOpenSessions(ctx context.Context, i
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userCronServiceClient) CompensateRoomOpenSessions(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CronBatchResponse)
|
||||
err := c.cc.Invoke(ctx, UserCronService_CompensateRoomOpenSessions_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userCronServiceClient) ExpireManagerUserBlocks(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CronBatchResponse)
|
||||
@ -1955,6 +2121,16 @@ func (c *userCronServiceClient) ExpireManagerUserBlocks(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userCronServiceClient) ExpireAdminUserBans(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CronBatchResponse)
|
||||
err := c.cc.Invoke(ctx, UserCronService_ExpireAdminUserBans_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userCronServiceClient) RefreshCPIntimacyLeaderboard(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CronBatchResponse)
|
||||
@ -1974,7 +2150,9 @@ type UserCronServiceServer interface {
|
||||
ProcessLoginIPRiskBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ProcessRegionRebuildBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
CompensateMicOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
CompensateRoomOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ExpireManagerUserBlocks(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ExpireAdminUserBans(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
RefreshCPIntimacyLeaderboard(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
mustEmbedUnimplementedUserCronServiceServer()
|
||||
}
|
||||
@ -1995,9 +2173,15 @@ func (UnimplementedUserCronServiceServer) ProcessRegionRebuildBatch(context.Cont
|
||||
func (UnimplementedUserCronServiceServer) CompensateMicOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CompensateMicOpenSessions not implemented")
|
||||
}
|
||||
func (UnimplementedUserCronServiceServer) CompensateRoomOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CompensateRoomOpenSessions not implemented")
|
||||
}
|
||||
func (UnimplementedUserCronServiceServer) ExpireManagerUserBlocks(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ExpireManagerUserBlocks not implemented")
|
||||
}
|
||||
func (UnimplementedUserCronServiceServer) ExpireAdminUserBans(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ExpireAdminUserBans not implemented")
|
||||
}
|
||||
func (UnimplementedUserCronServiceServer) RefreshCPIntimacyLeaderboard(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RefreshCPIntimacyLeaderboard not implemented")
|
||||
}
|
||||
@ -2076,6 +2260,24 @@ func _UserCronService_CompensateMicOpenSessions_Handler(srv interface{}, ctx con
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserCronService_CompensateRoomOpenSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CronBatchRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserCronServiceServer).CompensateRoomOpenSessions(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserCronService_CompensateRoomOpenSessions_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserCronServiceServer).CompensateRoomOpenSessions(ctx, req.(*CronBatchRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserCronService_ExpireManagerUserBlocks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CronBatchRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -2094,6 +2296,24 @@ func _UserCronService_ExpireManagerUserBlocks_Handler(srv interface{}, ctx conte
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserCronService_ExpireAdminUserBans_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CronBatchRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserCronServiceServer).ExpireAdminUserBans(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserCronService_ExpireAdminUserBans_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserCronServiceServer).ExpireAdminUserBans(ctx, req.(*CronBatchRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserCronService_RefreshCPIntimacyLeaderboard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CronBatchRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -2131,10 +2351,18 @@ var UserCronService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "CompensateMicOpenSessions",
|
||||
Handler: _UserCronService_CompensateMicOpenSessions_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CompensateRoomOpenSessions",
|
||||
Handler: _UserCronService_CompensateRoomOpenSessions_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ExpireManagerUserBlocks",
|
||||
Handler: _UserCronService_ExpireManagerUserBlocks_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ExpireAdminUserBans",
|
||||
Handler: _UserCronService_ExpireAdminUserBans_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RefreshCPIntimacyLeaderboard",
|
||||
Handler: _UserCronService_RefreshCPIntimacyLeaderboard_Handler,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -90,6 +90,8 @@ message DebitGiftResponse {
|
||||
string host_point_policy_instance_code = 25;
|
||||
string host_point_template_code = 26;
|
||||
string host_point_template_version = 27;
|
||||
// balance_version 是 sender 被扣费资产在本次账务完成后的版本;同 command_id 重放返回首次版本。
|
||||
int64 balance_version = 28;
|
||||
}
|
||||
|
||||
// DebitGiftTarget 是一笔批量送礼中的单个接收方账务快照。
|
||||
@ -1267,6 +1269,26 @@ message GetRechargeBillSummaryResponse {
|
||||
RechargeBillSummaryBucket coin_seller = 4;
|
||||
}
|
||||
|
||||
// UserRechargeStats 是 wallet-service 已维护的用户累计充值聚合,不允许后台回扫充值流水临时计算。
|
||||
message UserRechargeStats {
|
||||
int64 user_id = 1;
|
||||
int64 recharge_count = 2;
|
||||
int64 total_coin_amount = 3;
|
||||
int64 total_usd_minor_amount = 4;
|
||||
int64 first_recharged_at_ms = 5;
|
||||
int64 updated_at_ms = 6;
|
||||
}
|
||||
|
||||
message BatchGetUserRechargeStatsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
repeated int64 user_ids = 3;
|
||||
}
|
||||
|
||||
message BatchGetUserRechargeStatsResponse {
|
||||
repeated UserRechargeStats items = 1;
|
||||
}
|
||||
|
||||
message GetRechargeBillOverviewRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
@ -1887,6 +1909,54 @@ message VipRewardItem {
|
||||
string animation_url = 9;
|
||||
}
|
||||
|
||||
// VipProgramConfig 是按 App 隔离的 VIP 规则开关。业务服务只能依据这些显式策略执行,
|
||||
// 禁止通过 app_code 分支推断升级有效期、权益继承或后台发放语义。
|
||||
message VipProgramConfig {
|
||||
string app_code = 1;
|
||||
// program_type: legacy_timed/tiered_privilege_v1。
|
||||
string program_type = 2;
|
||||
int32 level_count = 3;
|
||||
// same_level_expiry_policy: extend_remaining;同级购买从当前有效截止时间续期。
|
||||
string same_level_expiry_policy = 4;
|
||||
// upgrade_expiry_policy: extend_remaining/replace_from_now。
|
||||
string upgrade_expiry_policy = 5;
|
||||
// downgrade_purchase_policy 首版固定 reject,保留字段用于后台完整展示当前规则。
|
||||
string downgrade_purchase_policy = 6;
|
||||
// benefit_inheritance_policy 当前固定 target_only;每级提交最终完整集合,不做自动继承。
|
||||
string benefit_inheritance_policy = 7;
|
||||
// grant_mode: direct_membership/trial_card,决定后台“赠送 VIP”的真实账务对象。
|
||||
string grant_mode = 8;
|
||||
bool trial_card_enabled = 9;
|
||||
string status = 10;
|
||||
int64 config_version = 11;
|
||||
int64 updated_by_admin_id = 12;
|
||||
int64 created_at_ms = 13;
|
||||
int64 updated_at_ms = 14;
|
||||
}
|
||||
|
||||
// VipBenefit 是服务端可执行的权益事实。resource_id 为 0 表示纯功能特权,非 0 时
|
||||
// 指向钱包资源;trial_enabled=false 的权益不会进入体验卡产生的 effective_benefits。
|
||||
message VipBenefit {
|
||||
string benefit_code = 1;
|
||||
string name = 2;
|
||||
// benefit_type: decoration/function。
|
||||
string benefit_type = 3;
|
||||
// unlock_level 只作为 P1 默认矩阵的来源信息;后台仍按每个等级提交完整权益集合,
|
||||
// 运行时不得据此自动补齐高等级权益。
|
||||
int32 unlock_level = 4;
|
||||
string status = 5;
|
||||
bool trial_enabled = 6;
|
||||
int64 resource_id = 7;
|
||||
string resource_type = 8;
|
||||
// execution_scope 标识 wallet/room/user/notice 等实际权限执行方,不代表服务间直连。
|
||||
string execution_scope = 9;
|
||||
bool auto_equip = 10;
|
||||
int32 sort_order = 11;
|
||||
string metadata_json = 12;
|
||||
int64 created_at_ms = 13;
|
||||
int64 updated_at_ms = 14;
|
||||
}
|
||||
|
||||
message VipLevel {
|
||||
int32 level = 1;
|
||||
string name = 2;
|
||||
@ -1903,6 +1973,9 @@ message VipLevel {
|
||||
string purchase_locked_reason = 13;
|
||||
int64 created_at_ms = 14;
|
||||
int64 updated_at_ms = 15;
|
||||
// benefits 是后台为该等级显式保存的最终权益集合,运行时不按 unlock_level 自动继承。
|
||||
repeated VipBenefit benefits = 16;
|
||||
int64 config_version = 17;
|
||||
}
|
||||
|
||||
message UserVip {
|
||||
@ -1913,6 +1986,54 @@ message UserVip {
|
||||
int64 started_at_ms = 5;
|
||||
int64 expires_at_ms = 6;
|
||||
int64 updated_at_ms = 7;
|
||||
string program_type = 8;
|
||||
int64 config_version = 9;
|
||||
}
|
||||
|
||||
// VipTrialCard 是体验卡背包权益的 VIP 投影。entitlement_id 复用通用背包佩戴事实,
|
||||
// 切换卡片只改变装备记录,不修改任何卡片自身的 expires_at_ms。
|
||||
message VipTrialCard {
|
||||
string trial_card_id = 1;
|
||||
string entitlement_id = 2;
|
||||
int64 resource_id = 3;
|
||||
int64 user_id = 4;
|
||||
int32 level = 5;
|
||||
string name = 6;
|
||||
string status = 7;
|
||||
bool equipped = 8;
|
||||
int64 duration_ms = 9;
|
||||
int64 effective_at_ms = 10;
|
||||
int64 expires_at_ms = 11;
|
||||
int64 remaining_duration_ms = 12;
|
||||
string grant_source = 13;
|
||||
string source_grant_id = 14;
|
||||
int64 created_at_ms = 15;
|
||||
int64 updated_at_ms = 16;
|
||||
}
|
||||
|
||||
// VipUserSettings 是用户按 App 隔离的可关闭 VIP 功能偏好。没有持久记录时 wallet
|
||||
// 仍返回两个默认开启值,updated_at_ms=0;设置本身不授予任何 VIP 权益。
|
||||
message VipUserSettings {
|
||||
string app_code = 1;
|
||||
int64 user_id = 2;
|
||||
bool room_entry_notice_enabled = 3;
|
||||
bool online_global_notice_enabled = 4;
|
||||
int64 updated_at_ms = 5;
|
||||
}
|
||||
|
||||
// VipState 同时返回付费会员、当前佩戴体验卡和最终生效会员,避免调用方自行合并两层状态。
|
||||
// effective_benefits 是钱包按 paid/trial 来源、该等级显式矩阵和 trial_enabled 过滤后的资格事实;
|
||||
// 对可关闭通知,执行方还必须结合 user_settings 或调用 CheckVipBenefit。
|
||||
message VipState {
|
||||
UserVip paid_vip = 1;
|
||||
VipTrialCard equipped_trial_card = 2;
|
||||
UserVip effective_vip = 3;
|
||||
// effective_source: paid/trial/none。
|
||||
string effective_source = 4;
|
||||
repeated VipBenefit effective_benefits = 5;
|
||||
int64 evaluated_at_ms = 6;
|
||||
VipProgramConfig program_config = 7;
|
||||
VipUserSettings user_settings = 8;
|
||||
}
|
||||
|
||||
message ListVipPackagesRequest {
|
||||
@ -1924,6 +2045,8 @@ message ListVipPackagesRequest {
|
||||
message ListVipPackagesResponse {
|
||||
UserVip current_vip = 1;
|
||||
repeated VipLevel packages = 2;
|
||||
VipState state = 3;
|
||||
VipProgramConfig program_config = 4;
|
||||
}
|
||||
|
||||
message GetMyVipRequest {
|
||||
@ -1934,6 +2057,7 @@ message GetMyVipRequest {
|
||||
|
||||
message GetMyVipResponse {
|
||||
UserVip vip = 1;
|
||||
VipState state = 2;
|
||||
}
|
||||
|
||||
message PurchaseVipRequest {
|
||||
@ -1950,6 +2074,7 @@ message PurchaseVipResponse {
|
||||
int64 coin_spent = 4;
|
||||
int64 coin_balance_after = 5;
|
||||
repeated VipRewardItem reward_items = 6;
|
||||
VipState state = 7;
|
||||
}
|
||||
|
||||
message DebitCPBreakupFeeRequest {
|
||||
@ -1999,6 +2124,205 @@ message GrantVipResponse {
|
||||
UserVip vip = 2;
|
||||
repeated VipRewardItem reward_items = 3;
|
||||
int64 server_time_ms = 4;
|
||||
VipState state = 5;
|
||||
}
|
||||
|
||||
message GetVipProgramConfigRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
}
|
||||
|
||||
message GetVipProgramConfigResponse {
|
||||
VipProgramConfig config = 1;
|
||||
repeated VipBenefit benefits = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
// UpdateAdminVipProgramConfigRequest 以完整配置替换单 App 当前规则;config_version 由服务端递增,
|
||||
// 客户端提交的审计时间和操作者字段不会被信任。
|
||||
message UpdateAdminVipProgramConfigRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
VipProgramConfig config = 3;
|
||||
int64 operator_user_id = 4;
|
||||
}
|
||||
|
||||
message UpdateAdminVipProgramConfigResponse {
|
||||
VipProgramConfig config = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
// UpdateMyVipSettingsRequest 使用 optional 保留 PATCH 语义;显式 false 不能被当成未提交。
|
||||
message UpdateMyVipSettingsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
optional bool room_entry_notice_enabled = 4;
|
||||
optional bool online_global_notice_enabled = 5;
|
||||
}
|
||||
|
||||
message UpdateMyVipSettingsResponse {
|
||||
VipUserSettings settings = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
// VipDailyCoinRebateRun 固化某个 UTC 自然日的返现金额矩阵。批任务开始后即使后台
|
||||
// 修改等级权益,后续分页也只能复用本 run 的 config_version/level_amounts_json。
|
||||
message VipDailyCoinRebateRun {
|
||||
string run_id = 1;
|
||||
string task_day = 2;
|
||||
int64 day_start_ms = 3;
|
||||
int64 day_end_ms = 4;
|
||||
int64 config_version = 5;
|
||||
string level_amounts_json = 6;
|
||||
string status = 7;
|
||||
int64 last_user_id = 8;
|
||||
int64 scanned_count = 9;
|
||||
int64 created_count = 10;
|
||||
int64 created_at_ms = 11;
|
||||
int64 updated_at_ms = 12;
|
||||
int64 completed_at_ms = 13;
|
||||
}
|
||||
|
||||
// VipDailyCoinRebate 是付费 VIP 在 UTC 日切时生成的可领取金币事实。体验卡不会生成;
|
||||
// status 返回 claimable/claimed/expired,expired 可由查询时钟投影而不改写历史行。
|
||||
message VipDailyCoinRebate {
|
||||
string rebate_id = 1;
|
||||
int64 user_id = 2;
|
||||
string task_day = 3;
|
||||
int32 vip_level = 4;
|
||||
string vip_name = 5;
|
||||
int64 coin_amount = 6;
|
||||
string status = 7;
|
||||
int64 available_at_ms = 8;
|
||||
int64 expires_at_ms = 9;
|
||||
int64 config_version = 10;
|
||||
int64 claimed_at_ms = 11;
|
||||
string wallet_transaction_id = 12;
|
||||
int64 created_at_ms = 13;
|
||||
int64 updated_at_ms = 14;
|
||||
}
|
||||
|
||||
// ProcessVipDailyCoinRebateBatchRequest 由 WalletCronService 接收。task_day 为空时按
|
||||
// wallet-service 当前 UTC 日期处理;同 App+task_day 始终创建或复用同一个 run。
|
||||
message ProcessVipDailyCoinRebateBatchRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
string task_day = 3;
|
||||
int32 batch_size = 4;
|
||||
}
|
||||
|
||||
message ProcessVipDailyCoinRebateBatchResponse {
|
||||
VipDailyCoinRebateRun run = 1;
|
||||
int32 scanned_count = 2;
|
||||
int32 created_count = 3;
|
||||
bool has_more = 4;
|
||||
int64 server_time_ms = 5;
|
||||
}
|
||||
|
||||
message GetMyCurrentVipDailyCoinRebateRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
}
|
||||
|
||||
message GetMyCurrentVipDailyCoinRebateResponse {
|
||||
bool found = 1;
|
||||
VipDailyCoinRebate rebate = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
message ListMyVipDailyCoinRebateStatusesRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
int32 page = 4;
|
||||
int32 page_size = 5;
|
||||
// rebate_ids 非空时精确恢复指定通知的领取状态,最多 100 个;仍强制按 user_id 隔离。
|
||||
repeated string rebate_ids = 6;
|
||||
}
|
||||
|
||||
message ListMyVipDailyCoinRebateStatusesResponse {
|
||||
repeated VipDailyCoinRebate rebates = 1;
|
||||
int64 total = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
message ClaimVipDailyCoinRebateRequest {
|
||||
string command_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
string rebate_id = 4;
|
||||
}
|
||||
|
||||
message ClaimVipDailyCoinRebateResponse {
|
||||
VipDailyCoinRebate rebate = 1;
|
||||
string transaction_id = 2;
|
||||
AssetBalance coin_balance = 3;
|
||||
int64 server_time_ms = 4;
|
||||
}
|
||||
|
||||
// GrantVipTrialCardRequest 允许后台直接按 level+duration_ms 发卡;resource_id 为空时按
|
||||
// vip_trial_card_{level} 解析同 App 预置资源,非空时也必须匹配该等级。
|
||||
message GrantVipTrialCardRequest {
|
||||
string command_id = 1;
|
||||
string app_code = 2;
|
||||
int64 target_user_id = 3;
|
||||
int32 level = 4;
|
||||
int64 duration_ms = 5;
|
||||
optional int64 resource_id = 6;
|
||||
string grant_source = 7;
|
||||
int64 operator_user_id = 8;
|
||||
string reason = 9;
|
||||
}
|
||||
|
||||
message GrantVipTrialCardResponse {
|
||||
VipTrialCard trial_card = 1;
|
||||
VipState state = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
message EquipVipTrialCardRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
// entitlement_id 是背包实例标识,保证同资源的多张卡也能被精确佩戴。
|
||||
string entitlement_id = 4;
|
||||
}
|
||||
|
||||
message EquipVipTrialCardResponse {
|
||||
VipTrialCard trial_card = 1;
|
||||
VipState state = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
message UnequipVipTrialCardRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
}
|
||||
|
||||
message UnequipVipTrialCardResponse {
|
||||
bool unequipped = 1;
|
||||
VipState state = 2;
|
||||
int64 server_time_ms = 3;
|
||||
}
|
||||
|
||||
// CheckVipBenefit 供 room/user 等权益执行方校验单个特权。调用方不能只比较 VIP level,
|
||||
// 因为体验卡排除项和 App 配置会改变最终可执行权益集合。
|
||||
message CheckVipBenefitRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
string benefit_code = 4;
|
||||
}
|
||||
|
||||
message CheckVipBenefitResponse {
|
||||
bool allowed = 1;
|
||||
VipBenefit benefit = 2;
|
||||
VipState state = 3;
|
||||
string denial_reason = 4;
|
||||
int64 evaluated_at_ms = 5;
|
||||
}
|
||||
|
||||
message AdminVipLevelInput {
|
||||
@ -2010,6 +2334,8 @@ message AdminVipLevelInput {
|
||||
int64 reward_resource_group_id = 6;
|
||||
int32 sort_order = 7;
|
||||
int64 required_recharge_coin_amount = 8;
|
||||
// benefits 是该等级完整、显式的权益集合;服务端不根据 unlock_level 自动继承。
|
||||
repeated VipBenefit benefits = 9;
|
||||
}
|
||||
|
||||
message ListAdminVipLevelsRequest {
|
||||
@ -2020,6 +2346,7 @@ message ListAdminVipLevelsRequest {
|
||||
message ListAdminVipLevelsResponse {
|
||||
repeated VipLevel levels = 1;
|
||||
int64 server_time_ms = 2;
|
||||
VipProgramConfig program_config = 3;
|
||||
}
|
||||
|
||||
message UpdateAdminVipLevelsRequest {
|
||||
@ -2032,6 +2359,7 @@ message UpdateAdminVipLevelsRequest {
|
||||
message UpdateAdminVipLevelsResponse {
|
||||
repeated VipLevel levels = 1;
|
||||
int64 server_time_ms = 2;
|
||||
VipProgramConfig program_config = 3;
|
||||
}
|
||||
|
||||
// CreditTaskRewardRequest 是 activity-service 领取任务奖励时调用的钱包入账命令。
|
||||
@ -2435,11 +2763,128 @@ message GetGiftCatalogVersionResponse {
|
||||
int64 version = 1;
|
||||
}
|
||||
|
||||
// HostRevenueStats 聚合主播在自然日区间内的收益 POINT 和送礼用户;gateway 对外仍按产品文案展示为钻石。
|
||||
message HostRevenueStats {
|
||||
int64 diamond_earnings = 1;
|
||||
int64 diamond_exchanged = 2;
|
||||
int64 gift_senders = 3;
|
||||
}
|
||||
|
||||
message GetHostRevenueStatsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 host_user_id = 3;
|
||||
int64 start_at_ms = 4;
|
||||
int64 end_at_ms = 5;
|
||||
}
|
||||
|
||||
message GetHostRevenueStatsResponse {
|
||||
HostRevenueStats stats = 1;
|
||||
}
|
||||
|
||||
// AgencyPointShareStats 只统计独立 Agency 分成流水,不把 owner 自己的主播礼物收益重复算入分成。
|
||||
message AgencyPointShareStats {
|
||||
int64 share_income = 1;
|
||||
int64 gifted_host_count = 2;
|
||||
}
|
||||
|
||||
message GetAgencyPointShareStatsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 agency_owner_user_id = 3;
|
||||
int64 start_at_ms = 4;
|
||||
int64 end_at_ms = 5;
|
||||
}
|
||||
|
||||
message GetAgencyPointShareStatsResponse {
|
||||
AgencyPointShareStats stats = 1;
|
||||
}
|
||||
|
||||
message AgencyHostGiftStats {
|
||||
int64 gift_income = 1;
|
||||
int64 gifted_host_count = 2;
|
||||
}
|
||||
|
||||
message GetAgencyHostGiftStatsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
repeated int64 host_user_ids = 3;
|
||||
int64 start_at_ms = 4;
|
||||
int64 end_at_ms = 5;
|
||||
}
|
||||
|
||||
message GetAgencyHostGiftStatsResponse {
|
||||
AgencyHostGiftStats stats = 1;
|
||||
}
|
||||
|
||||
// PointWithdrawalCoinSellerConfig 是按 App 隔离的 POINT 提现币商白名单与兑换比例。
|
||||
// 比例用整数分子/分母保存,避免浮点金额在 H5、gateway 和账本间产生舍入分歧。
|
||||
message PointWithdrawalCoinSellerConfig {
|
||||
string app_code = 1;
|
||||
int64 seller_user_id = 2;
|
||||
int32 sort_order = 3;
|
||||
int64 point_amount = 4;
|
||||
int64 seller_coin_amount = 5;
|
||||
repeated string service_country_codes = 6;
|
||||
string status = 7;
|
||||
int64 created_at_ms = 8;
|
||||
int64 updated_at_ms = 9;
|
||||
}
|
||||
|
||||
message ListPointWithdrawalCoinSellersRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
// country_code 由 gateway 从登录用户资料注入;为空只用于 Admin/内部查询。
|
||||
string country_code = 3;
|
||||
bool include_disabled = 4;
|
||||
}
|
||||
|
||||
message ListPointWithdrawalCoinSellersResponse {
|
||||
repeated PointWithdrawalCoinSellerConfig sellers = 1;
|
||||
}
|
||||
|
||||
message TransferPointToCoinSellerRequest {
|
||||
string command_id = 1;
|
||||
string app_code = 2;
|
||||
int64 source_user_id = 3;
|
||||
int64 seller_user_id = 4;
|
||||
int64 point_amount = 5;
|
||||
// source_country_code 由 gateway 从用户资料读取,wallet 会再次校验币商服务国家。
|
||||
string source_country_code = 6;
|
||||
string reason = 7;
|
||||
}
|
||||
|
||||
message TransferPointToCoinSellerResponse {
|
||||
string transaction_id = 1;
|
||||
int64 source_point_balance_after = 2;
|
||||
int64 seller_balance_after = 3;
|
||||
int64 point_amount = 4;
|
||||
int64 seller_coin_amount = 5;
|
||||
int64 ratio_point_amount = 6;
|
||||
int64 ratio_seller_coin_amount = 7;
|
||||
}
|
||||
|
||||
message GetPointWithdrawalConfigRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 region_id = 3;
|
||||
int64 now_ms = 4;
|
||||
}
|
||||
|
||||
message GetPointWithdrawalConfigResponse {
|
||||
bool found = 1;
|
||||
int64 points_per_usd = 2;
|
||||
int32 fee_bps = 3;
|
||||
int64 minimum_points = 4;
|
||||
string policy_instance_code = 5;
|
||||
}
|
||||
|
||||
// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。
|
||||
service WalletCronService {
|
||||
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ProcessHostSalaryHalfMonthSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ProcessHostSalaryMonthEndBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ProcessVipDailyCoinRebateBatch(ProcessVipDailyCoinRebateBatchRequest) returns (ProcessVipDailyCoinRebateBatchResponse);
|
||||
}
|
||||
|
||||
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
|
||||
@ -2451,6 +2896,9 @@ service WalletService {
|
||||
rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse);
|
||||
rpc GetActiveHostSalaryPolicy(GetActiveHostSalaryPolicyRequest) returns (GetActiveHostSalaryPolicyResponse);
|
||||
rpc GetHostSalaryProgress(GetHostSalaryProgressRequest) returns (GetHostSalaryProgressResponse);
|
||||
rpc GetHostRevenueStats(GetHostRevenueStatsRequest) returns (GetHostRevenueStatsResponse);
|
||||
rpc GetAgencyPointShareStats(GetAgencyPointShareStatsRequest) returns (GetAgencyPointShareStatsResponse);
|
||||
rpc GetAgencyHostGiftStats(GetAgencyHostGiftStatsRequest) returns (GetAgencyHostGiftStatsResponse);
|
||||
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播预收入工资;经理中心团队工资卡片使用。
|
||||
rpc GetTeamHostSalaryStats(GetTeamHostSalaryStatsRequest) returns (GetTeamHostSalaryStatsResponse);
|
||||
rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse);
|
||||
@ -2460,6 +2908,9 @@ service WalletService {
|
||||
rpc ListCoinSellerSalaryExchangeRateTiers(ListCoinSellerSalaryExchangeRateTiersRequest) returns (ListCoinSellerSalaryExchangeRateTiersResponse);
|
||||
rpc ExchangeSalaryToCoin(ExchangeSalaryToCoinRequest) returns (ExchangeSalaryToCoinResponse);
|
||||
rpc TransferSalaryToCoinSeller(TransferSalaryToCoinSellerRequest) returns (TransferSalaryToCoinSellerResponse);
|
||||
rpc ListPointWithdrawalCoinSellers(ListPointWithdrawalCoinSellersRequest) returns (ListPointWithdrawalCoinSellersResponse);
|
||||
rpc TransferPointToCoinSeller(TransferPointToCoinSellerRequest) returns (TransferPointToCoinSellerResponse);
|
||||
rpc GetPointWithdrawalConfig(GetPointWithdrawalConfigRequest) returns (GetPointWithdrawalConfigResponse);
|
||||
rpc FreezeSalaryWithdrawal(FreezeSalaryWithdrawalRequest) returns (FreezeSalaryWithdrawalResponse);
|
||||
rpc SettleSalaryWithdrawal(SettleSalaryWithdrawalRequest) returns (SettleSalaryWithdrawalResponse);
|
||||
rpc ReleaseSalaryWithdrawal(ReleaseSalaryWithdrawalRequest) returns (ReleaseSalaryWithdrawalResponse);
|
||||
@ -2502,6 +2953,7 @@ service WalletService {
|
||||
rpc PurchaseResourceShopItem(PurchaseResourceShopItemRequest) returns (PurchaseResourceShopItemResponse);
|
||||
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
|
||||
rpc GetRechargeBillSummary(GetRechargeBillSummaryRequest) returns (GetRechargeBillSummaryResponse);
|
||||
rpc BatchGetUserRechargeStats(BatchGetUserRechargeStatsRequest) returns (BatchGetUserRechargeStatsResponse);
|
||||
rpc GetRechargeBillOverview(GetRechargeBillOverviewRequest) returns (GetRechargeBillOverviewResponse);
|
||||
rpc RefreshGooglePaymentPrices(RefreshGooglePaymentPricesRequest) returns (RefreshGooglePaymentPricesResponse);
|
||||
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
|
||||
@ -2530,13 +2982,23 @@ service WalletService {
|
||||
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
|
||||
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
|
||||
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
|
||||
rpc GetVipProgramConfig(GetVipProgramConfigRequest) returns (GetVipProgramConfigResponse);
|
||||
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
|
||||
rpc CheckVipBenefit(CheckVipBenefitRequest) returns (CheckVipBenefitResponse);
|
||||
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
|
||||
rpc DebitCPBreakupFee(DebitCPBreakupFeeRequest) returns (DebitCPBreakupFeeResponse);
|
||||
rpc DebitWheelDraw(DebitWheelDrawRequest) returns (DebitWheelDrawResponse);
|
||||
rpc GrantVip(GrantVipRequest) returns (GrantVipResponse);
|
||||
rpc GrantVipTrialCard(GrantVipTrialCardRequest) returns (GrantVipTrialCardResponse);
|
||||
rpc EquipVipTrialCard(EquipVipTrialCardRequest) returns (EquipVipTrialCardResponse);
|
||||
rpc UnequipVipTrialCard(UnequipVipTrialCardRequest) returns (UnequipVipTrialCardResponse);
|
||||
rpc ListAdminVipLevels(ListAdminVipLevelsRequest) returns (ListAdminVipLevelsResponse);
|
||||
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
|
||||
rpc UpdateAdminVipProgramConfig(UpdateAdminVipProgramConfigRequest) returns (UpdateAdminVipProgramConfigResponse);
|
||||
rpc UpdateMyVipSettings(UpdateMyVipSettingsRequest) returns (UpdateMyVipSettingsResponse);
|
||||
rpc GetMyCurrentVipDailyCoinRebate(GetMyCurrentVipDailyCoinRebateRequest) returns (GetMyCurrentVipDailyCoinRebateResponse);
|
||||
rpc ListMyVipDailyCoinRebateStatuses(ListMyVipDailyCoinRebateStatusesRequest) returns (ListMyVipDailyCoinRebateStatusesResponse);
|
||||
rpc ClaimVipDailyCoinRebate(ClaimVipDailyCoinRebateRequest) returns (ClaimVipDailyCoinRebateResponse);
|
||||
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
|
||||
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
|
||||
rpc CreditWheelReward(CreditWheelRewardRequest) returns (CreditWheelRewardResponse);
|
||||
|
||||
@ -22,6 +22,7 @@ const (
|
||||
WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryDailySettlementBatch"
|
||||
WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryHalfMonthSettlementBatch"
|
||||
WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryMonthEndBatch"
|
||||
WalletCronService_ProcessVipDailyCoinRebateBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessVipDailyCoinRebateBatch"
|
||||
)
|
||||
|
||||
// WalletCronServiceClient is the client API for WalletCronService service.
|
||||
@ -33,6 +34,7 @@ type WalletCronServiceClient interface {
|
||||
ProcessHostSalaryDailySettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ProcessHostSalaryMonthEndBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ProcessVipDailyCoinRebateBatch(ctx context.Context, in *ProcessVipDailyCoinRebateBatchRequest, opts ...grpc.CallOption) (*ProcessVipDailyCoinRebateBatchResponse, error)
|
||||
}
|
||||
|
||||
type walletCronServiceClient struct {
|
||||
@ -73,6 +75,16 @@ func (c *walletCronServiceClient) ProcessHostSalaryMonthEndBatch(ctx context.Con
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletCronServiceClient) ProcessVipDailyCoinRebateBatch(ctx context.Context, in *ProcessVipDailyCoinRebateBatchRequest, opts ...grpc.CallOption) (*ProcessVipDailyCoinRebateBatchResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ProcessVipDailyCoinRebateBatchResponse)
|
||||
err := c.cc.Invoke(ctx, WalletCronService_ProcessVipDailyCoinRebateBatch_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WalletCronServiceServer is the server API for WalletCronService service.
|
||||
// All implementations must embed UnimplementedWalletCronServiceServer
|
||||
// for forward compatibility.
|
||||
@ -82,6 +94,7 @@ type WalletCronServiceServer interface {
|
||||
ProcessHostSalaryDailySettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ProcessHostSalaryHalfMonthSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ProcessHostSalaryMonthEndBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ProcessVipDailyCoinRebateBatch(context.Context, *ProcessVipDailyCoinRebateBatchRequest) (*ProcessVipDailyCoinRebateBatchResponse, error)
|
||||
mustEmbedUnimplementedWalletCronServiceServer()
|
||||
}
|
||||
|
||||
@ -101,6 +114,9 @@ func (UnimplementedWalletCronServiceServer) ProcessHostSalaryHalfMonthSettlement
|
||||
func (UnimplementedWalletCronServiceServer) ProcessHostSalaryMonthEndBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ProcessHostSalaryMonthEndBatch not implemented")
|
||||
}
|
||||
func (UnimplementedWalletCronServiceServer) ProcessVipDailyCoinRebateBatch(context.Context, *ProcessVipDailyCoinRebateBatchRequest) (*ProcessVipDailyCoinRebateBatchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ProcessVipDailyCoinRebateBatch not implemented")
|
||||
}
|
||||
func (UnimplementedWalletCronServiceServer) mustEmbedUnimplementedWalletCronServiceServer() {}
|
||||
func (UnimplementedWalletCronServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -176,6 +192,24 @@ func _WalletCronService_ProcessHostSalaryMonthEndBatch_Handler(srv interface{},
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletCronService_ProcessVipDailyCoinRebateBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ProcessVipDailyCoinRebateBatchRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletCronServiceServer).ProcessVipDailyCoinRebateBatch(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletCronService_ProcessVipDailyCoinRebateBatch_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletCronServiceServer).ProcessVipDailyCoinRebateBatch(ctx, req.(*ProcessVipDailyCoinRebateBatchRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// WalletCronService_ServiceDesc is the grpc.ServiceDesc for WalletCronService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -195,6 +229,10 @@ var WalletCronService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ProcessHostSalaryMonthEndBatch",
|
||||
Handler: _WalletCronService_ProcessHostSalaryMonthEndBatch_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ProcessVipDailyCoinRebateBatch",
|
||||
Handler: _WalletCronService_ProcessVipDailyCoinRebateBatch_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/wallet/v1/wallet.proto",
|
||||
@ -208,6 +246,9 @@ const (
|
||||
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
|
||||
WalletService_GetActiveHostSalaryPolicy_FullMethodName = "/hyapp.wallet.v1.WalletService/GetActiveHostSalaryPolicy"
|
||||
WalletService_GetHostSalaryProgress_FullMethodName = "/hyapp.wallet.v1.WalletService/GetHostSalaryProgress"
|
||||
WalletService_GetHostRevenueStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetHostRevenueStats"
|
||||
WalletService_GetAgencyPointShareStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetAgencyPointShareStats"
|
||||
WalletService_GetAgencyHostGiftStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetAgencyHostGiftStats"
|
||||
WalletService_GetTeamHostSalaryStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetTeamHostSalaryStats"
|
||||
WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
|
||||
WalletService_AdminCreditCoinSellerStock_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditCoinSellerStock"
|
||||
@ -216,6 +257,9 @@ const (
|
||||
WalletService_ListCoinSellerSalaryExchangeRateTiers_FullMethodName = "/hyapp.wallet.v1.WalletService/ListCoinSellerSalaryExchangeRateTiers"
|
||||
WalletService_ExchangeSalaryToCoin_FullMethodName = "/hyapp.wallet.v1.WalletService/ExchangeSalaryToCoin"
|
||||
WalletService_TransferSalaryToCoinSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferSalaryToCoinSeller"
|
||||
WalletService_ListPointWithdrawalCoinSellers_FullMethodName = "/hyapp.wallet.v1.WalletService/ListPointWithdrawalCoinSellers"
|
||||
WalletService_TransferPointToCoinSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferPointToCoinSeller"
|
||||
WalletService_GetPointWithdrawalConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetPointWithdrawalConfig"
|
||||
WalletService_FreezeSalaryWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/FreezeSalaryWithdrawal"
|
||||
WalletService_SettleSalaryWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/SettleSalaryWithdrawal"
|
||||
WalletService_ReleaseSalaryWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/ReleaseSalaryWithdrawal"
|
||||
@ -258,6 +302,7 @@ const (
|
||||
WalletService_PurchaseResourceShopItem_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseResourceShopItem"
|
||||
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
|
||||
WalletService_GetRechargeBillSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRechargeBillSummary"
|
||||
WalletService_BatchGetUserRechargeStats_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchGetUserRechargeStats"
|
||||
WalletService_GetRechargeBillOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRechargeBillOverview"
|
||||
WalletService_RefreshGooglePaymentPrices_FullMethodName = "/hyapp.wallet.v1.WalletService/RefreshGooglePaymentPrices"
|
||||
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
|
||||
@ -286,13 +331,23 @@ const (
|
||||
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
|
||||
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
|
||||
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
|
||||
WalletService_GetVipProgramConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetVipProgramConfig"
|
||||
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
|
||||
WalletService_CheckVipBenefit_FullMethodName = "/hyapp.wallet.v1.WalletService/CheckVipBenefit"
|
||||
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
|
||||
WalletService_DebitCPBreakupFee_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitCPBreakupFee"
|
||||
WalletService_DebitWheelDraw_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitWheelDraw"
|
||||
WalletService_GrantVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVip"
|
||||
WalletService_GrantVipTrialCard_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVipTrialCard"
|
||||
WalletService_EquipVipTrialCard_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipVipTrialCard"
|
||||
WalletService_UnequipVipTrialCard_FullMethodName = "/hyapp.wallet.v1.WalletService/UnequipVipTrialCard"
|
||||
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
|
||||
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
|
||||
WalletService_UpdateAdminVipProgramConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipProgramConfig"
|
||||
WalletService_UpdateMyVipSettings_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateMyVipSettings"
|
||||
WalletService_GetMyCurrentVipDailyCoinRebate_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyCurrentVipDailyCoinRebate"
|
||||
WalletService_ListMyVipDailyCoinRebateStatuses_FullMethodName = "/hyapp.wallet.v1.WalletService/ListMyVipDailyCoinRebateStatuses"
|
||||
WalletService_ClaimVipDailyCoinRebate_FullMethodName = "/hyapp.wallet.v1.WalletService/ClaimVipDailyCoinRebate"
|
||||
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
|
||||
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
|
||||
WalletService_CreditWheelReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditWheelReward"
|
||||
@ -323,6 +378,9 @@ type WalletServiceClient interface {
|
||||
GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error)
|
||||
GetActiveHostSalaryPolicy(ctx context.Context, in *GetActiveHostSalaryPolicyRequest, opts ...grpc.CallOption) (*GetActiveHostSalaryPolicyResponse, error)
|
||||
GetHostSalaryProgress(ctx context.Context, in *GetHostSalaryProgressRequest, opts ...grpc.CallOption) (*GetHostSalaryProgressResponse, error)
|
||||
GetHostRevenueStats(ctx context.Context, in *GetHostRevenueStatsRequest, opts ...grpc.CallOption) (*GetHostRevenueStatsResponse, error)
|
||||
GetAgencyPointShareStats(ctx context.Context, in *GetAgencyPointShareStatsRequest, opts ...grpc.CallOption) (*GetAgencyPointShareStatsResponse, error)
|
||||
GetAgencyHostGiftStats(ctx context.Context, in *GetAgencyHostGiftStatsRequest, opts ...grpc.CallOption) (*GetAgencyHostGiftStatsResponse, error)
|
||||
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播预收入工资;经理中心团队工资卡片使用。
|
||||
GetTeamHostSalaryStats(ctx context.Context, in *GetTeamHostSalaryStatsRequest, opts ...grpc.CallOption) (*GetTeamHostSalaryStatsResponse, error)
|
||||
AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, error)
|
||||
@ -332,6 +390,9 @@ type WalletServiceClient interface {
|
||||
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, in *ListCoinSellerSalaryExchangeRateTiersRequest, opts ...grpc.CallOption) (*ListCoinSellerSalaryExchangeRateTiersResponse, error)
|
||||
ExchangeSalaryToCoin(ctx context.Context, in *ExchangeSalaryToCoinRequest, opts ...grpc.CallOption) (*ExchangeSalaryToCoinResponse, error)
|
||||
TransferSalaryToCoinSeller(ctx context.Context, in *TransferSalaryToCoinSellerRequest, opts ...grpc.CallOption) (*TransferSalaryToCoinSellerResponse, error)
|
||||
ListPointWithdrawalCoinSellers(ctx context.Context, in *ListPointWithdrawalCoinSellersRequest, opts ...grpc.CallOption) (*ListPointWithdrawalCoinSellersResponse, error)
|
||||
TransferPointToCoinSeller(ctx context.Context, in *TransferPointToCoinSellerRequest, opts ...grpc.CallOption) (*TransferPointToCoinSellerResponse, error)
|
||||
GetPointWithdrawalConfig(ctx context.Context, in *GetPointWithdrawalConfigRequest, opts ...grpc.CallOption) (*GetPointWithdrawalConfigResponse, error)
|
||||
FreezeSalaryWithdrawal(ctx context.Context, in *FreezeSalaryWithdrawalRequest, opts ...grpc.CallOption) (*FreezeSalaryWithdrawalResponse, error)
|
||||
SettleSalaryWithdrawal(ctx context.Context, in *SettleSalaryWithdrawalRequest, opts ...grpc.CallOption) (*SettleSalaryWithdrawalResponse, error)
|
||||
ReleaseSalaryWithdrawal(ctx context.Context, in *ReleaseSalaryWithdrawalRequest, opts ...grpc.CallOption) (*ReleaseSalaryWithdrawalResponse, error)
|
||||
@ -374,6 +435,7 @@ type WalletServiceClient interface {
|
||||
PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error)
|
||||
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
|
||||
GetRechargeBillSummary(ctx context.Context, in *GetRechargeBillSummaryRequest, opts ...grpc.CallOption) (*GetRechargeBillSummaryResponse, error)
|
||||
BatchGetUserRechargeStats(ctx context.Context, in *BatchGetUserRechargeStatsRequest, opts ...grpc.CallOption) (*BatchGetUserRechargeStatsResponse, error)
|
||||
GetRechargeBillOverview(ctx context.Context, in *GetRechargeBillOverviewRequest, opts ...grpc.CallOption) (*GetRechargeBillOverviewResponse, error)
|
||||
RefreshGooglePaymentPrices(ctx context.Context, in *RefreshGooglePaymentPricesRequest, opts ...grpc.CallOption) (*RefreshGooglePaymentPricesResponse, error)
|
||||
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
|
||||
@ -402,13 +464,23 @@ type WalletServiceClient interface {
|
||||
GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error)
|
||||
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
|
||||
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
|
||||
GetVipProgramConfig(ctx context.Context, in *GetVipProgramConfigRequest, opts ...grpc.CallOption) (*GetVipProgramConfigResponse, error)
|
||||
GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error)
|
||||
CheckVipBenefit(ctx context.Context, in *CheckVipBenefitRequest, opts ...grpc.CallOption) (*CheckVipBenefitResponse, error)
|
||||
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
|
||||
DebitCPBreakupFee(ctx context.Context, in *DebitCPBreakupFeeRequest, opts ...grpc.CallOption) (*DebitCPBreakupFeeResponse, error)
|
||||
DebitWheelDraw(ctx context.Context, in *DebitWheelDrawRequest, opts ...grpc.CallOption) (*DebitWheelDrawResponse, error)
|
||||
GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error)
|
||||
GrantVipTrialCard(ctx context.Context, in *GrantVipTrialCardRequest, opts ...grpc.CallOption) (*GrantVipTrialCardResponse, error)
|
||||
EquipVipTrialCard(ctx context.Context, in *EquipVipTrialCardRequest, opts ...grpc.CallOption) (*EquipVipTrialCardResponse, error)
|
||||
UnequipVipTrialCard(ctx context.Context, in *UnequipVipTrialCardRequest, opts ...grpc.CallOption) (*UnequipVipTrialCardResponse, error)
|
||||
ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error)
|
||||
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
|
||||
UpdateAdminVipProgramConfig(ctx context.Context, in *UpdateAdminVipProgramConfigRequest, opts ...grpc.CallOption) (*UpdateAdminVipProgramConfigResponse, error)
|
||||
UpdateMyVipSettings(ctx context.Context, in *UpdateMyVipSettingsRequest, opts ...grpc.CallOption) (*UpdateMyVipSettingsResponse, error)
|
||||
GetMyCurrentVipDailyCoinRebate(ctx context.Context, in *GetMyCurrentVipDailyCoinRebateRequest, opts ...grpc.CallOption) (*GetMyCurrentVipDailyCoinRebateResponse, error)
|
||||
ListMyVipDailyCoinRebateStatuses(ctx context.Context, in *ListMyVipDailyCoinRebateStatusesRequest, opts ...grpc.CallOption) (*ListMyVipDailyCoinRebateStatusesResponse, error)
|
||||
ClaimVipDailyCoinRebate(ctx context.Context, in *ClaimVipDailyCoinRebateRequest, opts ...grpc.CallOption) (*ClaimVipDailyCoinRebateResponse, error)
|
||||
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
|
||||
CreditLuckyGiftReward(ctx context.Context, in *CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*CreditLuckyGiftRewardResponse, error)
|
||||
CreditWheelReward(ctx context.Context, in *CreditWheelRewardRequest, opts ...grpc.CallOption) (*CreditWheelRewardResponse, error)
|
||||
@ -504,6 +576,36 @@ func (c *walletServiceClient) GetHostSalaryProgress(ctx context.Context, in *Get
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetHostRevenueStats(ctx context.Context, in *GetHostRevenueStatsRequest, opts ...grpc.CallOption) (*GetHostRevenueStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetHostRevenueStatsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetHostRevenueStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetAgencyPointShareStats(ctx context.Context, in *GetAgencyPointShareStatsRequest, opts ...grpc.CallOption) (*GetAgencyPointShareStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetAgencyPointShareStatsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetAgencyPointShareStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetAgencyHostGiftStats(ctx context.Context, in *GetAgencyHostGiftStatsRequest, opts ...grpc.CallOption) (*GetAgencyHostGiftStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetAgencyHostGiftStatsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetAgencyHostGiftStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetTeamHostSalaryStats(ctx context.Context, in *GetTeamHostSalaryStatsRequest, opts ...grpc.CallOption) (*GetTeamHostSalaryStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetTeamHostSalaryStatsResponse)
|
||||
@ -584,6 +686,36 @@ func (c *walletServiceClient) TransferSalaryToCoinSeller(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListPointWithdrawalCoinSellers(ctx context.Context, in *ListPointWithdrawalCoinSellersRequest, opts ...grpc.CallOption) (*ListPointWithdrawalCoinSellersResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListPointWithdrawalCoinSellersResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ListPointWithdrawalCoinSellers_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) TransferPointToCoinSeller(ctx context.Context, in *TransferPointToCoinSellerRequest, opts ...grpc.CallOption) (*TransferPointToCoinSellerResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(TransferPointToCoinSellerResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_TransferPointToCoinSeller_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetPointWithdrawalConfig(ctx context.Context, in *GetPointWithdrawalConfigRequest, opts ...grpc.CallOption) (*GetPointWithdrawalConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetPointWithdrawalConfigResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetPointWithdrawalConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) FreezeSalaryWithdrawal(ctx context.Context, in *FreezeSalaryWithdrawalRequest, opts ...grpc.CallOption) (*FreezeSalaryWithdrawalResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FreezeSalaryWithdrawalResponse)
|
||||
@ -1004,6 +1136,16 @@ func (c *walletServiceClient) GetRechargeBillSummary(ctx context.Context, in *Ge
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) BatchGetUserRechargeStats(ctx context.Context, in *BatchGetUserRechargeStatsRequest, opts ...grpc.CallOption) (*BatchGetUserRechargeStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(BatchGetUserRechargeStatsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_BatchGetUserRechargeStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetRechargeBillOverview(ctx context.Context, in *GetRechargeBillOverviewRequest, opts ...grpc.CallOption) (*GetRechargeBillOverviewResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetRechargeBillOverviewResponse)
|
||||
@ -1284,6 +1426,16 @@ func (c *walletServiceClient) ListVipPackages(ctx context.Context, in *ListVipPa
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetVipProgramConfig(ctx context.Context, in *GetVipProgramConfigRequest, opts ...grpc.CallOption) (*GetVipProgramConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetVipProgramConfigResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetVipProgramConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetMyVipResponse)
|
||||
@ -1294,6 +1446,16 @@ func (c *walletServiceClient) GetMyVip(ctx context.Context, in *GetMyVipRequest,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) CheckVipBenefit(ctx context.Context, in *CheckVipBenefitRequest, opts ...grpc.CallOption) (*CheckVipBenefitResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CheckVipBenefitResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_CheckVipBenefit_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(PurchaseVipResponse)
|
||||
@ -1334,6 +1496,36 @@ func (c *walletServiceClient) GrantVip(ctx context.Context, in *GrantVipRequest,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GrantVipTrialCard(ctx context.Context, in *GrantVipTrialCardRequest, opts ...grpc.CallOption) (*GrantVipTrialCardResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GrantVipTrialCardResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GrantVipTrialCard_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) EquipVipTrialCard(ctx context.Context, in *EquipVipTrialCardRequest, opts ...grpc.CallOption) (*EquipVipTrialCardResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(EquipVipTrialCardResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_EquipVipTrialCard_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) UnequipVipTrialCard(ctx context.Context, in *UnequipVipTrialCardRequest, opts ...grpc.CallOption) (*UnequipVipTrialCardResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UnequipVipTrialCardResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_UnequipVipTrialCard_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListAdminVipLevelsResponse)
|
||||
@ -1354,6 +1546,56 @@ func (c *walletServiceClient) UpdateAdminVipLevels(ctx context.Context, in *Upda
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) UpdateAdminVipProgramConfig(ctx context.Context, in *UpdateAdminVipProgramConfigRequest, opts ...grpc.CallOption) (*UpdateAdminVipProgramConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateAdminVipProgramConfigResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_UpdateAdminVipProgramConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) UpdateMyVipSettings(ctx context.Context, in *UpdateMyVipSettingsRequest, opts ...grpc.CallOption) (*UpdateMyVipSettingsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateMyVipSettingsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_UpdateMyVipSettings_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetMyCurrentVipDailyCoinRebate(ctx context.Context, in *GetMyCurrentVipDailyCoinRebateRequest, opts ...grpc.CallOption) (*GetMyCurrentVipDailyCoinRebateResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetMyCurrentVipDailyCoinRebateResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetMyCurrentVipDailyCoinRebate_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListMyVipDailyCoinRebateStatuses(ctx context.Context, in *ListMyVipDailyCoinRebateStatusesRequest, opts ...grpc.CallOption) (*ListMyVipDailyCoinRebateStatusesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListMyVipDailyCoinRebateStatusesResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ListMyVipDailyCoinRebateStatuses_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ClaimVipDailyCoinRebate(ctx context.Context, in *ClaimVipDailyCoinRebateRequest, opts ...grpc.CallOption) (*ClaimVipDailyCoinRebateResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ClaimVipDailyCoinRebateResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ClaimVipDailyCoinRebate_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreditTaskRewardResponse)
|
||||
@ -1517,6 +1759,9 @@ type WalletServiceServer interface {
|
||||
GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error)
|
||||
GetActiveHostSalaryPolicy(context.Context, *GetActiveHostSalaryPolicyRequest) (*GetActiveHostSalaryPolicyResponse, error)
|
||||
GetHostSalaryProgress(context.Context, *GetHostSalaryProgressRequest) (*GetHostSalaryProgressResponse, error)
|
||||
GetHostRevenueStats(context.Context, *GetHostRevenueStatsRequest) (*GetHostRevenueStatsResponse, error)
|
||||
GetAgencyPointShareStats(context.Context, *GetAgencyPointShareStatsRequest) (*GetAgencyPointShareStatsResponse, error)
|
||||
GetAgencyHostGiftStats(context.Context, *GetAgencyHostGiftStatsRequest) (*GetAgencyHostGiftStatsResponse, error)
|
||||
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播预收入工资;经理中心团队工资卡片使用。
|
||||
GetTeamHostSalaryStats(context.Context, *GetTeamHostSalaryStatsRequest) (*GetTeamHostSalaryStatsResponse, error)
|
||||
AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error)
|
||||
@ -1526,6 +1771,9 @@ type WalletServiceServer interface {
|
||||
ListCoinSellerSalaryExchangeRateTiers(context.Context, *ListCoinSellerSalaryExchangeRateTiersRequest) (*ListCoinSellerSalaryExchangeRateTiersResponse, error)
|
||||
ExchangeSalaryToCoin(context.Context, *ExchangeSalaryToCoinRequest) (*ExchangeSalaryToCoinResponse, error)
|
||||
TransferSalaryToCoinSeller(context.Context, *TransferSalaryToCoinSellerRequest) (*TransferSalaryToCoinSellerResponse, error)
|
||||
ListPointWithdrawalCoinSellers(context.Context, *ListPointWithdrawalCoinSellersRequest) (*ListPointWithdrawalCoinSellersResponse, error)
|
||||
TransferPointToCoinSeller(context.Context, *TransferPointToCoinSellerRequest) (*TransferPointToCoinSellerResponse, error)
|
||||
GetPointWithdrawalConfig(context.Context, *GetPointWithdrawalConfigRequest) (*GetPointWithdrawalConfigResponse, error)
|
||||
FreezeSalaryWithdrawal(context.Context, *FreezeSalaryWithdrawalRequest) (*FreezeSalaryWithdrawalResponse, error)
|
||||
SettleSalaryWithdrawal(context.Context, *SettleSalaryWithdrawalRequest) (*SettleSalaryWithdrawalResponse, error)
|
||||
ReleaseSalaryWithdrawal(context.Context, *ReleaseSalaryWithdrawalRequest) (*ReleaseSalaryWithdrawalResponse, error)
|
||||
@ -1568,6 +1816,7 @@ type WalletServiceServer interface {
|
||||
PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error)
|
||||
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
|
||||
GetRechargeBillSummary(context.Context, *GetRechargeBillSummaryRequest) (*GetRechargeBillSummaryResponse, error)
|
||||
BatchGetUserRechargeStats(context.Context, *BatchGetUserRechargeStatsRequest) (*BatchGetUserRechargeStatsResponse, error)
|
||||
GetRechargeBillOverview(context.Context, *GetRechargeBillOverviewRequest) (*GetRechargeBillOverviewResponse, error)
|
||||
RefreshGooglePaymentPrices(context.Context, *RefreshGooglePaymentPricesRequest) (*RefreshGooglePaymentPricesResponse, error)
|
||||
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
|
||||
@ -1596,13 +1845,23 @@ type WalletServiceServer interface {
|
||||
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
|
||||
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
|
||||
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
|
||||
GetVipProgramConfig(context.Context, *GetVipProgramConfigRequest) (*GetVipProgramConfigResponse, error)
|
||||
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
|
||||
CheckVipBenefit(context.Context, *CheckVipBenefitRequest) (*CheckVipBenefitResponse, error)
|
||||
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
|
||||
DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error)
|
||||
DebitWheelDraw(context.Context, *DebitWheelDrawRequest) (*DebitWheelDrawResponse, error)
|
||||
GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error)
|
||||
GrantVipTrialCard(context.Context, *GrantVipTrialCardRequest) (*GrantVipTrialCardResponse, error)
|
||||
EquipVipTrialCard(context.Context, *EquipVipTrialCardRequest) (*EquipVipTrialCardResponse, error)
|
||||
UnequipVipTrialCard(context.Context, *UnequipVipTrialCardRequest) (*UnequipVipTrialCardResponse, error)
|
||||
ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error)
|
||||
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
|
||||
UpdateAdminVipProgramConfig(context.Context, *UpdateAdminVipProgramConfigRequest) (*UpdateAdminVipProgramConfigResponse, error)
|
||||
UpdateMyVipSettings(context.Context, *UpdateMyVipSettingsRequest) (*UpdateMyVipSettingsResponse, error)
|
||||
GetMyCurrentVipDailyCoinRebate(context.Context, *GetMyCurrentVipDailyCoinRebateRequest) (*GetMyCurrentVipDailyCoinRebateResponse, error)
|
||||
ListMyVipDailyCoinRebateStatuses(context.Context, *ListMyVipDailyCoinRebateStatusesRequest) (*ListMyVipDailyCoinRebateStatusesResponse, error)
|
||||
ClaimVipDailyCoinRebate(context.Context, *ClaimVipDailyCoinRebateRequest) (*ClaimVipDailyCoinRebateResponse, error)
|
||||
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
|
||||
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
|
||||
CreditWheelReward(context.Context, *CreditWheelRewardRequest) (*CreditWheelRewardResponse, error)
|
||||
@ -1649,6 +1908,15 @@ func (UnimplementedWalletServiceServer) GetActiveHostSalaryPolicy(context.Contex
|
||||
func (UnimplementedWalletServiceServer) GetHostSalaryProgress(context.Context, *GetHostSalaryProgressRequest) (*GetHostSalaryProgressResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetHostSalaryProgress not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetHostRevenueStats(context.Context, *GetHostRevenueStatsRequest) (*GetHostRevenueStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetHostRevenueStats not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetAgencyPointShareStats(context.Context, *GetAgencyPointShareStatsRequest) (*GetAgencyPointShareStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetAgencyPointShareStats not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetAgencyHostGiftStats(context.Context, *GetAgencyHostGiftStatsRequest) (*GetAgencyHostGiftStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetAgencyHostGiftStats not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetTeamHostSalaryStats(context.Context, *GetTeamHostSalaryStatsRequest) (*GetTeamHostSalaryStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetTeamHostSalaryStats not implemented")
|
||||
}
|
||||
@ -1673,6 +1941,15 @@ func (UnimplementedWalletServiceServer) ExchangeSalaryToCoin(context.Context, *E
|
||||
func (UnimplementedWalletServiceServer) TransferSalaryToCoinSeller(context.Context, *TransferSalaryToCoinSellerRequest) (*TransferSalaryToCoinSellerResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method TransferSalaryToCoinSeller not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListPointWithdrawalCoinSellers(context.Context, *ListPointWithdrawalCoinSellersRequest) (*ListPointWithdrawalCoinSellersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListPointWithdrawalCoinSellers not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) TransferPointToCoinSeller(context.Context, *TransferPointToCoinSellerRequest) (*TransferPointToCoinSellerResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method TransferPointToCoinSeller not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetPointWithdrawalConfig(context.Context, *GetPointWithdrawalConfigRequest) (*GetPointWithdrawalConfigResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetPointWithdrawalConfig not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) FreezeSalaryWithdrawal(context.Context, *FreezeSalaryWithdrawalRequest) (*FreezeSalaryWithdrawalResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method FreezeSalaryWithdrawal not implemented")
|
||||
}
|
||||
@ -1799,6 +2076,9 @@ func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *List
|
||||
func (UnimplementedWalletServiceServer) GetRechargeBillSummary(context.Context, *GetRechargeBillSummaryRequest) (*GetRechargeBillSummaryResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetRechargeBillSummary not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) BatchGetUserRechargeStats(context.Context, *BatchGetUserRechargeStatsRequest) (*BatchGetUserRechargeStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUserRechargeStats not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetRechargeBillOverview(context.Context, *GetRechargeBillOverviewRequest) (*GetRechargeBillOverviewResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetRechargeBillOverview not implemented")
|
||||
}
|
||||
@ -1883,9 +2163,15 @@ func (UnimplementedWalletServiceServer) ListWalletTransactions(context.Context,
|
||||
func (UnimplementedWalletServiceServer) ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListVipPackages not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetVipProgramConfig(context.Context, *GetVipProgramConfigRequest) (*GetVipProgramConfigResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetVipProgramConfig not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetMyVip not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) CheckVipBenefit(context.Context, *CheckVipBenefitRequest) (*CheckVipBenefitResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CheckVipBenefit not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method PurchaseVip not implemented")
|
||||
}
|
||||
@ -1898,12 +2184,36 @@ func (UnimplementedWalletServiceServer) DebitWheelDraw(context.Context, *DebitWh
|
||||
func (UnimplementedWalletServiceServer) GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GrantVip not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GrantVipTrialCard(context.Context, *GrantVipTrialCardRequest) (*GrantVipTrialCardResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GrantVipTrialCard not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) EquipVipTrialCard(context.Context, *EquipVipTrialCardRequest) (*EquipVipTrialCardResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method EquipVipTrialCard not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) UnequipVipTrialCard(context.Context, *UnequipVipTrialCardRequest) (*UnequipVipTrialCardResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UnequipVipTrialCard not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListAdminVipLevels not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateAdminVipLevels not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) UpdateAdminVipProgramConfig(context.Context, *UpdateAdminVipProgramConfigRequest) (*UpdateAdminVipProgramConfigResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateAdminVipProgramConfig not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) UpdateMyVipSettings(context.Context, *UpdateMyVipSettingsRequest) (*UpdateMyVipSettingsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateMyVipSettings not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetMyCurrentVipDailyCoinRebate(context.Context, *GetMyCurrentVipDailyCoinRebateRequest) (*GetMyCurrentVipDailyCoinRebateResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetMyCurrentVipDailyCoinRebate not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListMyVipDailyCoinRebateStatuses(context.Context, *ListMyVipDailyCoinRebateStatusesRequest) (*ListMyVipDailyCoinRebateStatusesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListMyVipDailyCoinRebateStatuses not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ClaimVipDailyCoinRebate(context.Context, *ClaimVipDailyCoinRebateRequest) (*ClaimVipDailyCoinRebateResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ClaimVipDailyCoinRebate not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreditTaskReward not implemented")
|
||||
}
|
||||
@ -2096,6 +2406,60 @@ func _WalletService_GetHostSalaryProgress_Handler(srv interface{}, ctx context.C
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetHostRevenueStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetHostRevenueStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetHostRevenueStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetHostRevenueStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetHostRevenueStats(ctx, req.(*GetHostRevenueStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetAgencyPointShareStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetAgencyPointShareStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetAgencyPointShareStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetAgencyPointShareStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetAgencyPointShareStats(ctx, req.(*GetAgencyPointShareStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetAgencyHostGiftStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetAgencyHostGiftStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetAgencyHostGiftStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetAgencyHostGiftStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetAgencyHostGiftStats(ctx, req.(*GetAgencyHostGiftStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetTeamHostSalaryStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetTeamHostSalaryStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -2240,6 +2604,60 @@ func _WalletService_TransferSalaryToCoinSeller_Handler(srv interface{}, ctx cont
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListPointWithdrawalCoinSellers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListPointWithdrawalCoinSellersRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ListPointWithdrawalCoinSellers(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ListPointWithdrawalCoinSellers_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ListPointWithdrawalCoinSellers(ctx, req.(*ListPointWithdrawalCoinSellersRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_TransferPointToCoinSeller_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(TransferPointToCoinSellerRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).TransferPointToCoinSeller(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_TransferPointToCoinSeller_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).TransferPointToCoinSeller(ctx, req.(*TransferPointToCoinSellerRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetPointWithdrawalConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetPointWithdrawalConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetPointWithdrawalConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetPointWithdrawalConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetPointWithdrawalConfig(ctx, req.(*GetPointWithdrawalConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_FreezeSalaryWithdrawal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FreezeSalaryWithdrawalRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -2996,6 +3414,24 @@ func _WalletService_GetRechargeBillSummary_Handler(srv interface{}, ctx context.
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_BatchGetUserRechargeStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(BatchGetUserRechargeStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).BatchGetUserRechargeStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_BatchGetUserRechargeStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).BatchGetUserRechargeStats(ctx, req.(*BatchGetUserRechargeStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetRechargeBillOverview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRechargeBillOverviewRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -3500,6 +3936,24 @@ func _WalletService_ListVipPackages_Handler(srv interface{}, ctx context.Context
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetVipProgramConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetVipProgramConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetVipProgramConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetVipProgramConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetVipProgramConfig(ctx, req.(*GetVipProgramConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetMyVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetMyVipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -3518,6 +3972,24 @@ func _WalletService_GetMyVip_Handler(srv interface{}, ctx context.Context, dec f
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_CheckVipBenefit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CheckVipBenefitRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).CheckVipBenefit(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_CheckVipBenefit_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).CheckVipBenefit(ctx, req.(*CheckVipBenefitRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_PurchaseVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(PurchaseVipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -3590,6 +4062,60 @@ func _WalletService_GrantVip_Handler(srv interface{}, ctx context.Context, dec f
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GrantVipTrialCard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GrantVipTrialCardRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GrantVipTrialCard(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GrantVipTrialCard_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GrantVipTrialCard(ctx, req.(*GrantVipTrialCardRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_EquipVipTrialCard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(EquipVipTrialCardRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).EquipVipTrialCard(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_EquipVipTrialCard_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).EquipVipTrialCard(ctx, req.(*EquipVipTrialCardRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_UnequipVipTrialCard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UnequipVipTrialCardRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).UnequipVipTrialCard(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_UnequipVipTrialCard_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).UnequipVipTrialCard(ctx, req.(*UnequipVipTrialCardRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListAdminVipLevels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListAdminVipLevelsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -3626,6 +4152,96 @@ func _WalletService_UpdateAdminVipLevels_Handler(srv interface{}, ctx context.Co
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_UpdateAdminVipProgramConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateAdminVipProgramConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).UpdateAdminVipProgramConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_UpdateAdminVipProgramConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).UpdateAdminVipProgramConfig(ctx, req.(*UpdateAdminVipProgramConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_UpdateMyVipSettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateMyVipSettingsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).UpdateMyVipSettings(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_UpdateMyVipSettings_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).UpdateMyVipSettings(ctx, req.(*UpdateMyVipSettingsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetMyCurrentVipDailyCoinRebate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetMyCurrentVipDailyCoinRebateRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetMyCurrentVipDailyCoinRebate(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetMyCurrentVipDailyCoinRebate_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetMyCurrentVipDailyCoinRebate(ctx, req.(*GetMyCurrentVipDailyCoinRebateRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListMyVipDailyCoinRebateStatuses_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListMyVipDailyCoinRebateStatusesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ListMyVipDailyCoinRebateStatuses(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ListMyVipDailyCoinRebateStatuses_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ListMyVipDailyCoinRebateStatuses(ctx, req.(*ListMyVipDailyCoinRebateStatusesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ClaimVipDailyCoinRebate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ClaimVipDailyCoinRebateRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ClaimVipDailyCoinRebate(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ClaimVipDailyCoinRebate_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ClaimVipDailyCoinRebate(ctx, req.(*ClaimVipDailyCoinRebateRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_CreditTaskReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreditTaskRewardRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -3931,6 +4547,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetHostSalaryProgress",
|
||||
Handler: _WalletService_GetHostSalaryProgress_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetHostRevenueStats",
|
||||
Handler: _WalletService_GetHostRevenueStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetAgencyPointShareStats",
|
||||
Handler: _WalletService_GetAgencyPointShareStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetAgencyHostGiftStats",
|
||||
Handler: _WalletService_GetAgencyHostGiftStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetTeamHostSalaryStats",
|
||||
Handler: _WalletService_GetTeamHostSalaryStats_Handler,
|
||||
@ -3963,6 +4591,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "TransferSalaryToCoinSeller",
|
||||
Handler: _WalletService_TransferSalaryToCoinSeller_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListPointWithdrawalCoinSellers",
|
||||
Handler: _WalletService_ListPointWithdrawalCoinSellers_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "TransferPointToCoinSeller",
|
||||
Handler: _WalletService_TransferPointToCoinSeller_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetPointWithdrawalConfig",
|
||||
Handler: _WalletService_GetPointWithdrawalConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FreezeSalaryWithdrawal",
|
||||
Handler: _WalletService_FreezeSalaryWithdrawal_Handler,
|
||||
@ -4131,6 +4771,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetRechargeBillSummary",
|
||||
Handler: _WalletService_GetRechargeBillSummary_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchGetUserRechargeStats",
|
||||
Handler: _WalletService_BatchGetUserRechargeStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRechargeBillOverview",
|
||||
Handler: _WalletService_GetRechargeBillOverview_Handler,
|
||||
@ -4243,10 +4887,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListVipPackages",
|
||||
Handler: _WalletService_ListVipPackages_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetVipProgramConfig",
|
||||
Handler: _WalletService_GetVipProgramConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetMyVip",
|
||||
Handler: _WalletService_GetMyVip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CheckVipBenefit",
|
||||
Handler: _WalletService_CheckVipBenefit_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "PurchaseVip",
|
||||
Handler: _WalletService_PurchaseVip_Handler,
|
||||
@ -4263,6 +4915,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GrantVip",
|
||||
Handler: _WalletService_GrantVip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GrantVipTrialCard",
|
||||
Handler: _WalletService_GrantVipTrialCard_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "EquipVipTrialCard",
|
||||
Handler: _WalletService_EquipVipTrialCard_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UnequipVipTrialCard",
|
||||
Handler: _WalletService_UnequipVipTrialCard_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListAdminVipLevels",
|
||||
Handler: _WalletService_ListAdminVipLevels_Handler,
|
||||
@ -4271,6 +4935,26 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "UpdateAdminVipLevels",
|
||||
Handler: _WalletService_UpdateAdminVipLevels_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateAdminVipProgramConfig",
|
||||
Handler: _WalletService_UpdateAdminVipProgramConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateMyVipSettings",
|
||||
Handler: _WalletService_UpdateMyVipSettings_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetMyCurrentVipDailyCoinRebate",
|
||||
Handler: _WalletService_GetMyCurrentVipDailyCoinRebate_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListMyVipDailyCoinRebateStatuses",
|
||||
Handler: _WalletService_ListMyVipDailyCoinRebateStatuses_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ClaimVipDailyCoinRebate",
|
||||
Handler: _WalletService_ClaimVipDailyCoinRebate_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreditTaskReward",
|
||||
Handler: _WalletService_CreditTaskReward_Handler,
|
||||
|
||||
@ -80,10 +80,10 @@ services:
|
||||
condition: service_healthy
|
||||
wallet-service:
|
||||
condition: service_healthy
|
||||
lucky-gift-service:
|
||||
condition: service_healthy
|
||||
rocketmq-broker:
|
||||
condition: service_started
|
||||
lucky-gift-service:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13101/healthz/ready >/dev/null"]
|
||||
interval: 5s
|
||||
@ -186,6 +186,8 @@ services:
|
||||
condition: service_healthy
|
||||
wallet-service:
|
||||
condition: service_healthy
|
||||
rocketmq-broker:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13113/healthz/ready >/dev/null"]
|
||||
interval: 5s
|
||||
|
||||
@ -516,7 +516,7 @@ tasks:
|
||||
timeout: 10s
|
||||
lock_ttl: 30s
|
||||
batch_size: 500
|
||||
app_codes: ["lalu"]
|
||||
app_codes: ["lalu","fami","huwaa"]
|
||||
```
|
||||
|
||||
如果消息 owner 后续拆成独立 `message-service`,这些配置迁移到新服务;gateway 只需要更新 message gRPC 地址。
|
||||
|
||||
1109
docs/Fami_VIP_Flutter对接方案.md
Normal file
1109
docs/Fami_VIP_Flutter对接方案.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
# Outbox 统一治理说明
|
||||
|
||||
更新时间:2026-06-01
|
||||
更新时间:2026-07-11
|
||||
|
||||
## 背景
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
- owner service 继续负责自己的 outbox claim 和 MQ 发布:
|
||||
- `wallet-service` 发布 `hyapp_wallet_outbox`
|
||||
- `room-service` 发布 `hyapp_room_outbox`
|
||||
- `lucky-gift-service` 在钱包返奖和本地 granted 收敛后发布 `hyapp_lucky_gift_outbox`
|
||||
- `activity-service` 发布 activity / broadcast 相关 MQ
|
||||
- `game-service` 发布 game 相关 MQ
|
||||
- `user-service` 发布 `hyapp_user_outbox`
|
||||
@ -54,6 +55,63 @@
|
||||
- `delivering/running expired lock`
|
||||
- claim 查询全部显式带 `app_code`,并匹配对应索引。
|
||||
- delivered/done 历史 outbox 通过归档脚本搬到 archive 表。
|
||||
- wallet/room topic 使用可灰度的 typed Tag:consumer 先部署
|
||||
`legacy || relevant event_type`,producer 再从 `legacy` 切到 `event_type`。
|
||||
- 同步 MQ publisher 每次只 claim 一条 outbox;`batch_size` 表示单轮最多处理
|
||||
条数,不再表示一次提前锁住的行数,避免批尾 lease 在真正发送前过期。
|
||||
|
||||
## Typed Tag 无损切换
|
||||
|
||||
Typed Tag 只降低无关 consumer group 的投递、反序列化和 handler 调用量,不会
|
||||
减少 Broker ingress 或存储。礼物消息总量必须在写入 owner outbox 前通过送礼微批
|
||||
收敛;多人礼物仍按 target 保留各自 `WalletGiftDebited`、`RoomGiftSent` 事实,且
|
||||
每条事实携带聚合后的 `gift_count`。
|
||||
|
||||
切换顺序必须严格执行:
|
||||
|
||||
1. 先滚动发布所有 wallet consumer,确认订阅表达式包含
|
||||
`wallet_outbox_event || <相关 event_type>`,且同一 group 所有副本表达式一致。
|
||||
2. 确认旧 consumer 实例归零、消费错误率稳定,再仅把 wallet-service
|
||||
`rocketmq.wallet_event_tag_mode` 从 `legacy` 改为 `event_type`。
|
||||
3. 保留 consumer 的 legacy 分支,直到切换前积压、retry 和计划内 replay 窗口全部
|
||||
排空;不能在 producer 切换同一批发布中移除 legacy。
|
||||
4. wallet 稳定后,以同样顺序先发布 activity/statistics/notice/user 等 room
|
||||
consumer,再把 room-service `rocketmq.room_event_tag_mode` 从 `legacy` 改为
|
||||
`event_type`。
|
||||
5. 如需回滚 producer,只改回 `legacy`;兼容 consumer 无需回滚。只有确认 Broker
|
||||
已无 legacy 消息且不再重放旧 outbox 后,才可以另行删除 legacy selector。
|
||||
|
||||
推荐验证:
|
||||
|
||||
```text
|
||||
Phase A: producer tag=legacy;每个 consumer group 仍能消费原有事实
|
||||
Phase B: gift typed tag 只进入需要 gift 的 group;首充/累充/邀请等 group lag 不再随礼物增长
|
||||
Rollback: producer 改回 legacy 后所有兼容 consumer 继续消费,无消息不可见窗口
|
||||
```
|
||||
|
||||
Topic 拆分不是降低消息总量的手段。完成微批和 typed Tag 后,只有在礼物流量仍
|
||||
持续挤压非礼物 SLO,或确实需要独立 retention/retry/ACL/顺序策略时才拆高频 Topic;
|
||||
流量占比只能作为评审信号,不能把固定百分比当作行业标准。
|
||||
|
||||
### 历史礼物消费修复
|
||||
|
||||
新代码只阻止新增的 `WalletBalanceChanged(direct_gift_debit)` 与 `RoomGiftSent`
|
||||
双计;已经落入 `stat_app_day_country.consumed_coin` 的历史窗口不能直接拍脑袋减值。
|
||||
上线后使用现有 `replay-stat-tz` 从四个 owner outbox 重建受影响窗口,并分别执行
|
||||
UTC 与 Asia/Shanghai 两套口径。先不带 `--apply` 核对 source_rows,再在停止该
|
||||
窗口实时写入或只选择已完成自然日时执行 apply:
|
||||
|
||||
```bash
|
||||
go run ./services/statistics-service/cmd/replay-stat-tz \
|
||||
--app lalu --timezone UTC --start-day 2026-07-01 --end-day 2026-07-10
|
||||
|
||||
go run ./services/statistics-service/cmd/replay-stat-tz \
|
||||
--app lalu --timezone Asia/Shanghai --start-day 2026-07-01 --end-day 2026-07-10
|
||||
```
|
||||
|
||||
确认四类 owner source_rows 非零且窗口正确后,为同一命令补充 `--apply` 和运行时
|
||||
DSN 环境变量。每个 App 独立执行;完成后校验 `consumed_coin` 中礼物部分等于
|
||||
`RoomGiftSent.coin_spent`,钱包礼物扣款只继续影响 `coin_total`。
|
||||
|
||||
## 修复掉的问题
|
||||
|
||||
|
||||
534
docs/VIP系统架构.md
534
docs/VIP系统架构.md
@ -1,462 +1,182 @@
|
||||
# VIP 系统架构设计
|
||||
|
||||
## 目标
|
||||
|
||||
VIP 是用户可直接用金币购买的限时会员权益。每个 VIP 等级都可以单独购买,单次购买有效期固定 7 天,不同等级价格不同。购买成功后,系统根据后台配置的资源组发放头像框、座驾、气泡、徽章、礼物等权益。
|
||||
|
||||
首版只做当前业务真实需要的能力:
|
||||
|
||||
- 用户可以购买任意有效 VIP 等级。
|
||||
- 用户可以从低等级升级到高等级。
|
||||
- 用户当前 VIP 未过期时,不能购买比当前等级低的 VIP。
|
||||
- 同等级重复购买按续期处理。
|
||||
- VIP 扣金币、会员状态变更、资源组发放必须在 `wallet-service` 内形成一致账务闭环。
|
||||
- 后台只配置 VIP 等级、金币价格、有效期和赠送资源组;客户端不能传价格或资源明细。
|
||||
# 可配置 VIP 系统
|
||||
|
||||
## 服务边界
|
||||
## 目标与边界
|
||||
|
||||
VIP 属于 `wallet-service` 的账务和权益域,不放在 `user-service`。
|
||||
VIP 是 `wallet-service` 按 `app_code` 隔离的会员与权益域。每个 App 通过一份 `vip_program_configs` 选择等级数量、购买有效期策略、后台发放模式和体验卡开关;gateway、room、admin 和客户端只能消费这份显式配置,不得通过 `app_code` 写第二套状态机。
|
||||
|
||||
原因:
|
||||
- `wallet-service`:会员、体验卡、用户功能开关、权益矩阵、购买扣费、每日返现、资源发放、历史和 outbox 的 owner。
|
||||
- `gateway-service`:App HTTP 鉴权、协议转换和钱包 gRPC 编排。
|
||||
- `room-service`:只执行房间权限并通过腾讯云 IM 投递当前房间事件,不能拥有会员状态。
|
||||
- `server/admin`:配置 program、等级和每级完整权益,发放真实 VIP 或体验卡。
|
||||
- Flutter:展示后端状态、操作背包佩戴并处理客户端生命周期效果;不参与购买规则计算。
|
||||
|
||||
- 购买 VIP 必须先扣金币,扣费成功才允许生成 VIP 状态和权益。
|
||||
- 购买 VIP 会发放后台资源组,资源组、资源权益和钱包资产已经归 `wallet-service` 管。
|
||||
- 如果 VIP 状态放在 `user-service`,扣费、会员状态、资源发放会跨服务,需要分布式事务或补偿,复杂度和失败窗口都更大。
|
||||
|
||||
服务职责:
|
||||
全链路业务时间使用 UTC epoch milliseconds。每日结算或刷新按 UTC 0 点切日;用户本地时间只用于展示。
|
||||
|
||||
- `gateway-service`:提供 App HTTP API,做鉴权、参数校验、生成 `request_id`、透传客户端 `command_id`,调用 `wallet-service`。
|
||||
- `wallet-service`:拥有 VIP 等级配置、用户 VIP 状态、VIP 购买订单、金币扣费、资源组发放、VIP 事件 outbox。
|
||||
- `user-service`:只在用户资料聚合时读取 VIP 摘要,不拥有 VIP 状态。
|
||||
- `room-service`:需要展示 VIP 标识时读取用户资料/VIP 投影,不参与 VIP 购买主链路。
|
||||
- `server/admin`:后台配置 VIP 等级和资源组,具体后台接口单独设计。
|
||||
## 当前 App 策略
|
||||
|
||||
## 核心模型
|
||||
| 配置 | Lalu | Fami |
|
||||
| --- | --- | --- |
|
||||
| `program_type` | `legacy_timed` | `tiered_privilege_v1` |
|
||||
| `level_count` | 10 | 9 |
|
||||
| 同级购买 | 从当前截止时间续期 | 从当前截止时间续期 |
|
||||
| 高级升级 | 保留剩余时间后追加新时长 | 立即替换为高级,从购买时刻重新计时,旧等级剩余时间丢弃 |
|
||||
| 低级购买 | 拒绝 | 拒绝 |
|
||||
| 后台发放 | `direct_membership`,直接写真实 VIP | `trial_card`,发到背包后由用户佩戴 |
|
||||
| 体验卡 | 关闭 | 开启 |
|
||||
| 权益集合 | 每级显式保存 | 每级显式保存完整集合,不按 `unlock_level` 自动继承 |
|
||||
|
||||
### VIP 等级
|
||||
Fami 的 VIP1–VIP9 初始全部为 `disabled`。30 天只是不产生零时长脏配置的占位值;价格和资源组保持 0,运营完成配置并启用前不可购买或发卡。
|
||||
|
||||
`vip_levels`
|
||||
## 数据模型
|
||||
|
||||
| 字段 | 说明 |
|
||||
| -------------------------- | -------------------------------------------------- |
|
||||
| `id` | 内部主键 |
|
||||
| `level` | VIP 等级,数字越大等级越高 |
|
||||
| `name` | 展示名称,例如 VIP1、VIP2 |
|
||||
| `coin_price` | 购买该等级需要消耗的金币数 |
|
||||
| `duration_ms` | 有效期,首版固定 `7 * 24 * 60 * 60 * 1000` |
|
||||
| `reward_resource_group_id` | 购买成功后发放的资源组 |
|
||||
| `status` | `draft` / `active` / `disabled` |
|
||||
| `sort_order` | App 展示排序 |
|
||||
| `display_config_json` | 展示配置快照,例如颜色、权益说明、角标,不参与结算 |
|
||||
| `created_at_ms` | 创建时间 |
|
||||
| `updated_at_ms` | 更新时间 |
|
||||
### App 级 program
|
||||
|
||||
约束:
|
||||
`vip_program_configs` 是行为规则的唯一来源:
|
||||
|
||||
- `level` 唯一。
|
||||
- `coin_price > 0`。
|
||||
- `duration_ms` 首版必须等于 7 天,不允许后台临时改成其它值。
|
||||
- `active` 状态必须绑定有效资源组。
|
||||
- `program_type`
|
||||
- `level_count`
|
||||
- `same_level_expiry_policy`
|
||||
- `upgrade_expiry_policy`
|
||||
- `downgrade_purchase_policy`
|
||||
- `benefit_inheritance_policy`,当前固定 `target_only`
|
||||
- `grant_mode`
|
||||
- `trial_card_enabled`
|
||||
- `status`
|
||||
- `config_version`
|
||||
|
||||
### 用户 VIP 当前状态
|
||||
后台修改 program、等级或权益时递增 `config_version`。会员、订单和体验卡保留创建时的 program/version 快照,当前权限判断使用最新有效 program 和等级显式权益集合。
|
||||
|
||||
`user_vip_memberships`
|
||||
### 等级和权益
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --------------- | ---------------------------- |
|
||||
| `user_id` | 用户 ID,唯一 |
|
||||
| `level` | 当前等级 |
|
||||
| `status` | `active` / `expired` |
|
||||
| `started_at_ms` | 当前等级周期开始时间 |
|
||||
| `expires_at_ms` | 当前 VIP 到期时间 |
|
||||
| `last_order_id` | 最近一次购买订单 |
|
||||
| `version` | 乐观版本,配合行锁防并发覆盖 |
|
||||
| `created_at_ms` | 创建时间 |
|
||||
| `updated_at_ms` | 更新时间 |
|
||||
- `vip_levels`:等级名称、状态、金币价格、时长、可选资源组和排序。
|
||||
- `vip_level_benefits`:以 `(app_code, level, benefit_code)` 保存该等级最终完整权益集合。
|
||||
|
||||
读取时以 `expires_at_ms > now` 判断是否有效,`status` 只作为后台检索和事件处理的辅助状态。这样即使过期任务延迟,App 也不会把过期 VIP 当成有效权益。
|
||||
`unlock_level` 只记录 P1 初始化来源,运行时和后台保存都不能据此给高等级自动补权益。纯功能权益的 `resource_id=0`;装扮权益可以绑定同 App 的实际资源。
|
||||
|
||||
### VIP 购买订单
|
||||
### 付费会员
|
||||
|
||||
`vip_purchase_orders`
|
||||
`user_vip_memberships` 保存用户付费会员事实。有效条件是 `level > 0 && expires_at_ms > now_ms`;不依赖异步过期任务才能失效。
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ---------------------------- | --------------------------------- |
|
||||
| `order_id` | VIP 订单号 |
|
||||
| `command_id` | 客户端生成的幂等命令 ID |
|
||||
| `user_id` | 购买用户 |
|
||||
| `target_level` | 目标 VIP 等级 |
|
||||
| `coin_price_snapshot` | 下单时价格快照 |
|
||||
| `duration_ms_snapshot` | 下单时有效期快照 |
|
||||
| `resource_group_id_snapshot` | 下单时资源组快照 |
|
||||
| `previous_level` | 购买前有效等级,无有效 VIP 则为 0 |
|
||||
| `previous_expires_at_ms` | 购买前到期时间 |
|
||||
| `new_level` | 购买后等级 |
|
||||
| `new_expires_at_ms` | 购买后到期时间 |
|
||||
| `wallet_transaction_id` | 金币扣费交易 ID |
|
||||
| `resource_grant_id` | 资源组发放记录 ID |
|
||||
| `status` | `succeeded` / `failed` |
|
||||
| `failure_reason` | 失败原因 |
|
||||
| `request_hash` | 幂等请求摘要 |
|
||||
| `created_at_ms` | 创建时间 |
|
||||
| `updated_at_ms` | 更新时间 |
|
||||
|
||||
约束:
|
||||
|
||||
- `(user_id, command_id)` 唯一。
|
||||
- 相同 `command_id` + 相同 `request_hash` 返回原订单结果。
|
||||
- 相同 `command_id` + 不同 `request_hash` 返回幂等冲突。
|
||||
|
||||
### VIP 变更历史
|
||||
|
||||
`user_vip_history`
|
||||
|
||||
记录每次购买、续期、升级、过期,供客服、后台账单、风控排查使用。历史表不参与 App 主链路强查询。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `history_id`
|
||||
- `user_id`
|
||||
- `event_type`: `purchase` / `renew` / `upgrade` / `expire`
|
||||
- `from_level`
|
||||
- `to_level`
|
||||
- `from_expires_at_ms`
|
||||
- `to_expires_at_ms`
|
||||
- `order_id`
|
||||
- `created_at_ms`
|
||||
|
||||
## 购买和升级规则
|
||||
|
||||
定义:
|
||||
|
||||
- 当前有效 VIP:`membership.expires_at_ms > now && membership.level > 0`
|
||||
- 目标等级:用户本次购买的 `vip_level`
|
||||
|
||||
规则:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| ---------------------- | ------------------------------------------------------------------------ |
|
||||
| 无有效 VIP | 可以购买任意 active 等级,`expires_at_ms = now_ms + 7d_ms` |
|
||||
| 有效 VIP,购买同等级 | 续期,`expires_at_ms = current_expires_at_ms + 7d_ms` |
|
||||
| 有效 VIP,购买更高等级 | 立即升级,`level = target_level`,`expires_at_ms = current_expires_at_ms + 7d_ms` |
|
||||
| 有效 VIP,购买更低等级 | 拒绝,返回 `VIP_DOWNGRADE_NOT_ALLOWED` |
|
||||
| VIP 已过期 | 按无有效 VIP 处理,可以购买任意 active 等级 |
|
||||
|
||||
首版升级采用“保留剩余时间并升级”的策略:如果用户 VIP1 还剩 3 天,又购买 VIP3,则 VIP3 到期时间为当前 VIP1 到期时间再加 7 天。这个规则实现简单、用户感知清晰,也避免升级时损失已付费时长。
|
||||
|
||||
如果后续业务希望“补差价升级”或“升级只生效 7 天”,只需要扩展 VIP 等级的升级策略,不改变主链路表结构。
|
||||
|
||||
## 资源组发放规则
|
||||
|
||||
购买 VIP 成功后,`wallet-service` 根据 `vip_levels.reward_resource_group_id` 发放资源组。
|
||||
|
||||
资源组继续复用现有资源组能力:
|
||||
|
||||
- 头像框:`avatar_frame`
|
||||
- 资料卡:`profile_card`
|
||||
- 座驾:`vehicle`
|
||||
- 聊天气泡:`chat_bubble`
|
||||
- 徽章:`badge`
|
||||
- 飘屏:`floating_screen`
|
||||
- 礼物:`gift`
|
||||
- 金币等钱包资产:仅在明确配置时发放,默认不建议 VIP 购买返金币,避免账务绕圈。
|
||||
|
||||
VIP 资源组需要增加一个明确约束:
|
||||
|
||||
- VIP 专属装扮类资源默认与 VIP 到期时间对齐。
|
||||
- 资源组 item 如果配置了独立 `duration_ms`,后台必须能看到它和 VIP 7 天周期的差异。
|
||||
- 首版推荐新增 `duration_policy = inherit_vip_expiry`,购买 VIP 时将资源权益到期时间直接设置为 `new_expires_at_ms`。
|
||||
|
||||
续期和升级时:
|
||||
|
||||
- 同一资源重复发放时,使用资源权益的 `extend_expiry` 或 `align_to_expiry` 策略,避免重复插入导致用户出现多个同资源有效权益。
|
||||
- 升级后只发放目标等级配置的资源组;低等级已获得的资源不立即删除,到期后自然失效。
|
||||
- App 展示和自动佩戴时按用户当前有效资源、资源等级和佩戴状态决定,不从 VIP 等级反推资源。
|
||||
|
||||
## 主链路流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant App
|
||||
participant Gateway as gateway-service
|
||||
participant Wallet as wallet-service
|
||||
participant DB as Wallet MySQL
|
||||
participant Outbox as Wallet Outbox
|
||||
|
||||
App->>Gateway: POST /api/v1/vip/purchase {level, command_id}
|
||||
Gateway->>Wallet: PurchaseVip(user_id, level, command_id, request_meta)
|
||||
Wallet->>DB: BEGIN
|
||||
Wallet->>DB: 查询并锁定 vip_levels(level)
|
||||
Wallet->>DB: 查询并锁定 user_vip_memberships(user_id)
|
||||
Wallet->>DB: 校验不能降级、计算 new_expires_at_ms
|
||||
Wallet->>DB: 锁定用户 COIN 钱包账户
|
||||
Wallet->>DB: 扣减金币,写 wallet_transaction / wallet_entries
|
||||
Wallet->>DB: 发放 VIP 资源组,写 resource_entitlements
|
||||
Wallet->>DB: upsert user_vip_memberships
|
||||
Wallet->>DB: 写 vip_purchase_orders / user_vip_history
|
||||
Wallet->>Outbox: 写 VipPurchased / VipUpgraded / VipRenewed
|
||||
Wallet->>DB: COMMIT
|
||||
Wallet-->>Gateway: 返回当前 VIP、扣费、权益摘要
|
||||
Gateway-->>App: {code,message,request_id,data}
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
- 金币扣费、VIP 状态、资源组发放必须在同一个 `wallet-service` 数据库事务中完成。
|
||||
- 客户端只传 `level` 和 `command_id`,价格和资源组完全由服务端配置决定。
|
||||
- 扣费成功但资源发放失败不能提交事务;否则用户会付费但拿不到权益。
|
||||
- 资源发放成功但 VIP 状态更新失败也不能提交事务;否则权益和会员状态会不一致。
|
||||
|
||||
## App 一级接口
|
||||
|
||||
### 查询 VIP 等级
|
||||
|
||||
`GET /api/v1/vip/levels`
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"current_vip": {
|
||||
"level": 2,
|
||||
"name": "VIP2",
|
||||
"expires_at_ms": 1760000000000,
|
||||
"remaining_ms": 604800000
|
||||
},
|
||||
"levels": [
|
||||
{
|
||||
"level": 1,
|
||||
"name": "VIP1",
|
||||
"coin_price": 1000,
|
||||
"duration_ms": 604800000,
|
||||
"can_purchase": true,
|
||||
"purchase_block_reason": "",
|
||||
"benefits": [
|
||||
{
|
||||
"resource_type": "avatar_frame",
|
||||
"resource_id": "vip1_frame",
|
||||
"name": "VIP1 Avatar Frame",
|
||||
"duration_policy": "inherit_vip_expiry"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `can_purchase=false` 通常表示当前有效 VIP 等级高于该等级。
|
||||
- App 可以展示所有等级,但购买按钮状态以服务端返回为准。
|
||||
|
||||
### 查询我的 VIP
|
||||
|
||||
`GET /api/v1/vip/me`
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"level": 2,
|
||||
"name": "VIP2",
|
||||
"status": "active",
|
||||
"expires_at_ms": 1760000000000,
|
||||
"remaining_ms": 604800000,
|
||||
"renewable": true,
|
||||
"upgradeable_levels": [3, 4, 5]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 底部“我的”页面只需要拿 VIP 摘要,不要拉完整资源列表。
|
||||
- 完整装扮资源仍从资源/背包接口查询。
|
||||
|
||||
### 购买 VIP
|
||||
|
||||
`POST /api/v1/vip/purchase`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"level": 3,
|
||||
"command_id": "client-generated-id"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "vip_order_123",
|
||||
"event_type": "upgrade",
|
||||
"coin_spent": 3000,
|
||||
"vip": {
|
||||
"level": 3,
|
||||
"name": "VIP3",
|
||||
"expires_at_ms": 1760604800000
|
||||
},
|
||||
"granted_resources": [
|
||||
{
|
||||
"resource_type": "vehicle",
|
||||
"resource_id": "vip3_vehicle",
|
||||
"expires_at_ms": 1760604800000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
购买事务同时完成:
|
||||
|
||||
## gRPC 契约建议
|
||||
|
||||
新增 `wallet.v1.VipService`:
|
||||
|
||||
```protobuf
|
||||
service VipService {
|
||||
rpc ListVipLevels(ListVipLevelsRequest) returns (ListVipLevelsResponse);
|
||||
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
|
||||
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
|
||||
}
|
||||
```
|
||||
|
||||
核心字段:
|
||||
|
||||
- `RequestMeta meta`
|
||||
- `int64 user_id`
|
||||
- `int32 level`
|
||||
- `string command_id`
|
||||
- `int64 now_ms` 不由客户端传,由服务端生成;测试可通过 clock 注入。
|
||||
1. 锁定 program、目标等级和当前会员;
|
||||
2. 根据 program 计算有效期,拒绝降级;
|
||||
3. 锁定 COIN 账户并扣费;
|
||||
4. 发放目标资源组;
|
||||
5. 更新会员,写订单、历史和 wallet outbox;
|
||||
6. 一次事务提交。
|
||||
|
||||
错误码建议纳入 `pkg/xerr` catalog:
|
||||
`command_id` 是购买幂等键,`request_id` 仅用于链路追踪。
|
||||
|
||||
- `VIP_LEVEL_NOT_FOUND`
|
||||
- `VIP_LEVEL_DISABLED`
|
||||
- `VIP_DOWNGRADE_NOT_ALLOWED`
|
||||
- `VIP_PRICE_CHANGED`,仅在未来支持客户端价格确认时使用
|
||||
- `WALLET_INSUFFICIENT_BALANCE`
|
||||
- `IDEMPOTENCY_CONFLICT`
|
||||
`vip_command_locks` 在任何会员、账户或资源业务锁之前串行化 `(app_code, command_id)`。购买、直接会员赠送和体验卡发放共享该守卫空间;同命令并发重试会等待原事务提交后读取稳定回执,三种业务也不能交叉复用同一命令号。
|
||||
|
||||
gRPC status 仍保持少量通用 code:
|
||||
### 体验卡
|
||||
|
||||
- 参数错误:`InvalidArgument`
|
||||
- 余额不足、不能降级:`FailedPrecondition`
|
||||
- 等级不存在或禁用:`NotFound` / `FailedPrecondition`
|
||||
- 幂等冲突:`AlreadyExists`
|
||||
- 内部事务失败:`Internal`
|
||||
体验卡使用三层事实:
|
||||
|
||||
业务错误放在 `google.rpc.ErrorInfo.reason`。
|
||||
- `resources(resource_type=vip_trial_card)`:卡面与等级元数据;
|
||||
- `user_resource_entitlements`:背包实例和绝对有效期;
|
||||
- `user_resource_equipment`:当前单选佩戴指针;
|
||||
- `user_vip_trial_cards`:等级、来源、时长和 program/version 快照。
|
||||
|
||||
## 并发和幂等
|
||||
发卡时即写入 `effective_at_ms` 和 `expires_at_ms`,但不自动佩戴。切换卡片只替换 `user_resource_equipment` 指针,不修改、删除、暂停或延长任一卡片。
|
||||
|
||||
同一用户可能快速点击多次购买或同时升级,必须按用户串行化处理。
|
||||
例如 A=14 天、B=20 天、C=30 天:佩戴 A 后立刻切 B,B 生效,A 仍保留原绝对到期时间;之后只要 A 未过期即可重新佩戴。体验卡等级不受当前付费 VIP 或上一张体验卡等级限制。
|
||||
|
||||
事务内锁顺序固定:
|
||||
### 用户功能开关
|
||||
|
||||
1. 锁 `vip_levels(level)`,读取配置快照。
|
||||
2. 锁 `user_vip_memberships(user_id)`。
|
||||
3. 锁用户 `COIN` 钱包账户。
|
||||
4. 写订单、流水、权益、会员状态。
|
||||
`user_vip_settings` 按 `(app_code,user_id)` 保存两个用户偏好:
|
||||
|
||||
如果 `user_vip_memberships` 不存在,先插入一行占位再锁,或者用用户级 advisory lock。不要只依赖应用内 mutex,因为多实例部署时无效。
|
||||
- `room_entry_notice_enabled`:进房 VIP 高亮通知;
|
||||
- `online_global_notice_enabled`:上线 VIP 全服飘屏。
|
||||
|
||||
幂等规则:
|
||||
无记录时两项默认为 `true`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好;Fami 与 Lalu 互不影响。
|
||||
|
||||
- `command_id` 是购买命令幂等键,不使用 `request_id`。
|
||||
- 已成功订单重复请求直接返回原结果。
|
||||
- 已失败订单是否允许重试取决于失败类型:余额不足可以用新 `command_id` 重试;系统错误如果事务未提交,可以继续用原 `command_id` 重试。
|
||||
- 订单内保存价格、资源组、有效期快照,后台改配置不影响已成功订单解释。
|
||||
### 每日金币返现
|
||||
|
||||
## VIP 过期
|
||||
`daily_coin_rebate` 的 `metadata_json.coin_amount` 是该等级每日可领金币正整数。只有启用的付费 VIP 参与,体验卡即使历史配置误开 `trial_enabled` 也会被 wallet 二次排除。
|
||||
|
||||
VIP 是否有效以 `expires_at_ms` 为准。过期处理分两层:
|
||||
每个 App 每个 UTC 日期只有一个 `vip_daily_coin_rebate_runs`:
|
||||
|
||||
- App 查询时实时判断,过期立即返回 `status=expired`。
|
||||
- 后台定时任务扫描过期会员,写 `user_vip_history(expire)` 和 `VipExpired` outbox,用于消息通知、缓存清理和搜索投影更新。
|
||||
1. 首个成功批次固化当时的 `config_version` 和等级金额矩阵;后续分页不重读新配置。
|
||||
2. wallet 根据会员历史还原 UTC 0 点时的付费 VIP;0 点后才购买或升级的状态从次日起参与。
|
||||
3. 生成 `vip_daily_coin_rebates` 资格,领取窗口固定为 `[UTC 当日0点,UTC 次日0点)`。
|
||||
4. 同事务写 `VipDailyCoinRebateAvailable` outbox;activity-service 幂等投影为“VIP金币返现”系统消息。
|
||||
5. 用户手动领取时,返现行锁、COIN 余额、交易、分录和 `WalletBalanceChanged` outbox 在一个事务内提交。
|
||||
|
||||
资源权益也以自己的 `expires_at_ms` 判断有效性。如果使用 `inherit_vip_expiry`,VIP 到期后头像框、座驾等权益自然失效,不需要额外删除。
|
||||
“日切资格”严格按 UTC 0 点;“金额配置”是当日首个成功 run 的快照,不伪造系统尚未保存的 0 点历史配置。正常情况下 cron 每分钟触发;如零点后 wallet/cron 长时间不可用,恢复前的后台金额修改会进入当日快照。运营若要保证修改次日生效,应在当日 run 已创建后修改。
|
||||
|
||||
## Outbox 事件
|
||||
## 最终生效状态
|
||||
|
||||
`wallet-service` 写出以下事件:
|
||||
|
||||
- `VipPurchased`
|
||||
- `VipRenewed`
|
||||
- `VipUpgraded`
|
||||
- `VipExpired`
|
||||
所有调用方统一消费 `VipState`:
|
||||
|
||||
事件内容:
|
||||
- `paid_vip`:真实购买会员;
|
||||
- `equipped_trial_card`:当前有效且已佩戴的体验卡;
|
||||
- `effective_vip`:最终展示和权限使用的 VIP;
|
||||
- `effective_source`:`paid`、`trial` 或 `none`;
|
||||
- `effective_benefits`:最终身份拥有的权益资格;可关闭的通知类权益还要与 `user_settings` 做 AND;
|
||||
- `program_config`:当前 App 规则;
|
||||
- `user_settings`:当前 App 下的进房/上线通知偏好。
|
||||
|
||||
- `event_id`
|
||||
- `user_id`
|
||||
- `order_id`
|
||||
- `from_level`
|
||||
- `to_level`
|
||||
- `expires_at_ms`
|
||||
- `coin_spent`
|
||||
- `resource_group_id`
|
||||
- `occurred_at_ms`
|
||||
program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡有效并已佩戴时覆盖展示;否则使用有效付费会员;二者都无效则为 `none`。关闭体验卡开关只停止它参与最终权限合并,背包和佩戴原始事实仍保留;关闭整个 program 时付费与卡片原始事实仍可查询,但最终身份为 `none`、不下发权益。
|
||||
|
||||
消费者:
|
||||
钱包只读取 `effective_vip.level` 对应的显式权益行。来源为 `trial` 时,再移除 `trial_enabled=false` 的权益;当前 `daily_coin_rebate` 不对体验卡开放。owner service 必须按 `benefit_code` 判断,不能改回 `level >= N`;进房和上线通知的执行方必须使用 wallet 已合并用户开关的结果。
|
||||
|
||||
- `user-service` 可消费后更新用户资料投影,便于列表页快速展示 VIP 标识。
|
||||
- `room-service` 如需房间内展示 VIP 角标,可消费投影事件或通过用户资料聚合读取,不接入购买主链路。
|
||||
- `activity-service` 可用于 VIP 活动任务统计。
|
||||
## P1 权益编码
|
||||
|
||||
## 缓存策略
|
||||
| 等级 | 本级新增权益 |
|
||||
| --- | --- |
|
||||
| VIP1 | `vip_badge_identity`、`vip_medal`、`avatar_frame`、`vip_title` |
|
||||
| VIP2 | `chat_bubble`、`vip_gift`、`entry_effect`、`visitor_history` |
|
||||
| VIP3 | `voice_wave`、`profile_card`、`custom_room_background`、`room_image_message` |
|
||||
| VIP4 | `animated_avatar`、`animated_room_cover`、`mic_skin`、`room_border`、`vehicle` |
|
||||
| VIP5 | `colored_room_name`、`colored_nickname`、`colored_id`、`gift_tray_skin` |
|
||||
| VIP6 | `hide_profile_data`、`custom_avatar_frame`、`leaderboard_invisible`、`daily_coin_rebate` |
|
||||
| VIP7 | `custom_profile_card`、`anonymous_profile_visit`、`custom_gift` |
|
||||
| VIP8 | `room_entry_notice`、`custom_pretty_id`、`custom_vehicle` |
|
||||
| VIP9 | `anti_kick`、`anti_mute`、`online_global_notice` |
|
||||
|
||||
VIP 配置可以缓存,用户 VIP 状态不要只放 Redis。
|
||||
初始化时高等级行已展开为完整集合,但这是 seed 结果,不是运行时继承规则。后台可以显式移除任一等级的某项权益。
|
||||
|
||||
- `vip_levels`:本地内存缓存 + 短 TTL,后台改配置后通过版本号或事件刷新。
|
||||
- `user_vip_memberships`:MySQL 为准,Redis 只做读优化。
|
||||
- 购买成功后删除或刷新 `vip:me:{user_id}` 缓存。
|
||||
- 过期时间短且强相关权益展示,缓存 TTL 不能超过 `min(60s, expires_at_ms-now)`。
|
||||
## 房间权限和 IM
|
||||
|
||||
## 后台配置约束
|
||||
`room-service` 在需要房间权限时读取 wallet 的 `GetMyVip.state`:
|
||||
|
||||
后台配置 VIP 等级时必须校验:
|
||||
- `custom_room_background`:P1 program 的房主保存或切换自定义背景前强校验;Lalu 保持原逻辑。
|
||||
- `anti_kick`:普通房主/管理员踢人时拒绝操作。
|
||||
- `anti_mute`:普通房主/管理员新增禁言时拒绝操作。
|
||||
- 平台封禁、风控、超级管理员使用独立系统治理入口,不经过上述普通房管守卫。
|
||||
- `room_entry_notice`:权益有效且用户开关开启时,随 `JoinRoomResponse`、`RoomUserJoined` 和腾讯云 IM 当前房间系统消息携带;只在用户进入的当前房间展示,不做全服广播。
|
||||
|
||||
- 等级唯一且大于 0。
|
||||
- 金币价格大于 0。
|
||||
- 有效期首版固定 7 天。
|
||||
- 绑定资源组必须存在且可用。
|
||||
- 资源组里的装扮资源建议使用 `inherit_vip_expiry`。
|
||||
- 禁用等级只影响新购买,不影响已购买用户的剩余有效期。
|
||||
- 修改价格只影响修改后的新订单,不回改历史订单。
|
||||
`online_global_notice` 由 `POST /vip/online-notice` 触发:gateway 先调用 wallet `CheckVipBenefit`,同时校验权益资格和用户开关;然后从 user-service 取服务端用户资料,最后写 activity-service 全局播报 outbox。腾讯云 IM 使用 `TIMCustomElem`,`Ext=im_broadcast`、`Desc=vip_online_notice`、`broadcast_type=vip_online_notice`、`scope=global`。Flutter 在进程内按 UTC 日控制触发:同一进程每天最多触发一次,不持久化该标记;杀进程重启后允许再次触发。服务端不做用户日级去重,但相同 `command_id` 的 HTTP 重试映射到同一 `event_id`。Flutter 不能直接向全服群发消息。
|
||||
|
||||
后台可以修改资源组,但已成功订单使用订单快照解释;用户已经拿到的权益不因为后台换资源组被自动替换。
|
||||
## App HTTP API
|
||||
|
||||
## 数据一致性原则
|
||||
统一前缀 `/api/v1`,响应使用 `{code,message,request_id,data}`:
|
||||
|
||||
- VIP 购买不是普通“资源组发放”,它必须先扣金币。
|
||||
- 不允许 gateway 先扣金币再单独调用资源发放。
|
||||
- 不允许 user-service 写 VIP 状态。
|
||||
- 不允许客户端传资源 ID、资源组 ID、价格。
|
||||
- 不允许用 Redis 作为 VIP 当前状态唯一来源。
|
||||
- 不允许后台直接改用户 VIP 状态来修单,修单必须走后台补单/补偿命令,并写历史和审计。
|
||||
- `GET /vip/packages`:program、可购买等级、每级权益和完整 state。
|
||||
- `GET /vip/me`:付费、体验卡、最终 VIP、权益资格和用户功能开关。
|
||||
- `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取。
|
||||
- `GET|PATCH /vip/settings`:读取或局部修改进房/上线通知偏好;空 PATCH 拒绝。
|
||||
- `POST /vip/online-notice`:请求 `{command_id}`,服务端校验权益、开关和资料后写全服播报 outbox。
|
||||
- `GET /vip/coin-rebates/current`:返回当前 UTC 日返现资格;无资格或 cron 尚未生成时 `found=false`。
|
||||
- `POST /vip/coin-rebates/statuses`:按最多 100 个 `rebate_ids` 恢复系统消息真实状态,或用受限分页查历史。
|
||||
- `POST /vip/coin-rebates/{rebate_id}/claim`:请求 `{command_id}`,金额、归属、过期和入账全由 wallet 裁决。
|
||||
- `GET /users/me/resources?resource_type=vip_trial_card&active_only=true`:查询背包体验卡资源实例。
|
||||
- `POST /vip/trial-cards/{entitlement_id}/equip`:佩戴指定背包实例。
|
||||
- `DELETE /vip/trial-cards/equipped`:卸下当前体验卡。
|
||||
- 历史通用背包装备/卸下接口命中 `vip_trial_card` 时由 wallet service 委托上述专用状态机,禁止仓储层直接改装备槽;Flutter VIP 流程仍使用专用接口以一次取得完整 `VipState`。
|
||||
- `POST /manager-center/vip-grants`:gateway 按当前 program 自动选择直接会员或 30 天体验卡发放;Fami 发卡不自动佩戴。
|
||||
|
||||
## 实现顺序
|
||||
后台接口:
|
||||
|
||||
1. 扩展 protobuf:新增 `VipService`、VIP 等级、我的 VIP、购买 VIP 响应结构。
|
||||
2. 增加 MySQL 表:`vip_levels`、`user_vip_memberships`、`vip_purchase_orders`、`user_vip_history`。
|
||||
3. 在 `wallet-service` 实现 VIP repository,先完成等级查询和用户 VIP 查询。
|
||||
4. 实现 `PurchaseVip` 事务:等级校验、幂等、行锁、扣金币、资源组发放、会员状态更新、历史和 outbox。
|
||||
5. 在 `gateway-service` 增加 App HTTP API:`/api/v1/vip/levels`、`/api/v1/vip/me`、`/api/v1/vip/purchase`。
|
||||
6. 接入用户资料聚合:底部“我的”和房间用户信息只展示 VIP 摘要。
|
||||
7. 增加过期扫描任务和 outbox 消费投影。
|
||||
8. 再做后台 VIP 配置页面和后台补单/审计能力。
|
||||
- `GET|PUT /v1/admin/activity/vip-program`
|
||||
- `GET|PUT /v1/admin/activity/vip-levels`
|
||||
- `POST /v1/admin/activity/vip-trial-card-grants`
|
||||
- `POST /v1/admin/activity/vip-grants`,仅用于 `direct_membership` program
|
||||
|
||||
## 测试重点
|
||||
## 权益落地边界
|
||||
|
||||
必须覆盖:
|
||||
当前服务端已强制执行购买有效期、体验卡状态合并、用户通知开关、每日金币返现资格/消息/手动领取入账、自定义房间背景、防踢、防禁言、房内进场通知和上线全服通知。装扮类权益由已绑定的钱包资源与 Flutter 展示链路执行。
|
||||
|
||||
- 无 VIP 购买 VIP1 成功。
|
||||
- VIP1 未过期时续费 VIP1,到期时间追加 7 天。
|
||||
- VIP1 未过期时购买 VIP3,等级变 VIP3,到期时间追加 7 天。
|
||||
- VIP3 未过期时购买 VIP1 被拒绝。
|
||||
- VIP 过期后可以购买任意等级。
|
||||
- 金币不足时不写 VIP 状态、不发资源。
|
||||
- 资源组发放失败时金币扣费回滚。
|
||||
- 同一 `command_id` 重试返回同一订单。
|
||||
- 同一用户并发购买时只产生符合顺序的一条最终状态。
|
||||
- 后台禁用等级后不能新购买,但不影响已购买用户有效期。
|
||||
以下权益仍只有稳定编码和资格下发,不能仅凭本架构宣称业务已完整闭环:访客匿名/隐藏、榜单隐身、房间发图、动态媒体、彩色样式和礼物托盘等。各 owner 实现时必须调用 `CheckVipBenefit` 或消费 `effective_benefits`,并补自己的存储、审计、风控和边界测试。
|
||||
|
||||
82
docs/flutter对接/Huwaa首页房间筛选Flutter对接.md
Normal file
82
docs/flutter对接/Huwaa首页房间筛选Flutter对接.md
Normal file
@ -0,0 +1,82 @@
|
||||
# Huwaa 首页房间筛选 Flutter 对接
|
||||
|
||||
## 地址
|
||||
|
||||
筛选目录:
|
||||
|
||||
```http
|
||||
GET /api/v1/rooms/filters
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: huwaa
|
||||
```
|
||||
|
||||
房间列表:
|
||||
|
||||
```http
|
||||
GET /api/v1/rooms?tab=hot®ion_id=1001
|
||||
GET /api/v1/rooms?tab=hot&country_code=AE
|
||||
```
|
||||
|
||||
目录接口要求用户已登录并完成资料。租户权威值是 access token claims 中的 `app_code`;`X-App-Code`/包名只参与请求入口解析,不能覆盖 token 或用来跨租户切换。Huwaa 返回本 App 的全部 active 区域和其中 enabled 国家;其他 App 只返回当前用户所在 active 区域及其国家。
|
||||
|
||||
## 参数
|
||||
|
||||
目录接口没有 query 参数。每个 option 都返回 `query_key` 和 `query_value`,Flutter 按下面规则调用房间列表:
|
||||
|
||||
- All:`query_key`、`query_value` 都为空,不传 `region_id` 和 `country_code`。
|
||||
- 区域:把 option 的 `query_key=region_id`、`query_value` 原样放进 query。
|
||||
- 国家:把 option 的 `query_key=country_code`、`query_value` 原样放进 query。
|
||||
- `region_id` 与 `country_code` 不能同时传;切换筛选项时清空旧 cursor,再从第一页请求。
|
||||
- 旧参数 `country` 继续兼容,新代码统一使用 `country_code`。
|
||||
|
||||
`parent_region_id` 只用于把国家展示在所属区域下。筛选国家时只传 `country_code`,不要把 `parent_region_id` 同时作为 `region_id` 传入。
|
||||
|
||||
## 返回值
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req-room-filters",
|
||||
"data": {
|
||||
"all": {
|
||||
"filter_type": "all",
|
||||
"query_key": "",
|
||||
"query_value": ""
|
||||
},
|
||||
"regions": [
|
||||
{
|
||||
"filter_type": "region",
|
||||
"query_key": "region_id",
|
||||
"query_value": "1001",
|
||||
"region_id": 1001,
|
||||
"region_code": "MIDDLE_EAST",
|
||||
"region_name": "Middle East",
|
||||
"sort_order": 10
|
||||
}
|
||||
],
|
||||
"countries": [
|
||||
{
|
||||
"filter_type": "country",
|
||||
"query_key": "country_code",
|
||||
"query_value": "AE",
|
||||
"parent_region_id": 1001,
|
||||
"country_id": 971,
|
||||
"country_code": "AE",
|
||||
"country_name": "United Arab Emirates",
|
||||
"country_display_name": "UAE",
|
||||
"country_flag": "🇦🇪",
|
||||
"sort_order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
目录的数组顺序已经使用后台 `sort_order`。国家只在“国家 enabled 且所属区域 active”时下发,客户端不要自行拼接国家码或区域 ID。
|
||||
|
||||
`GET /api/v1/rooms` 响应中的旧 `data.countries` 字段继续保留,供旧版当前区域国家筛选使用;Huwaa 新版完整目录以本接口为准。
|
||||
|
||||
## 相关 IM/RTC
|
||||
|
||||
筛选目录和列表筛选不创建房间、不入腾讯云 IM 群,也不签发 RTC token。用户实际进入房间后,继续按现有 JoinRoom 流程处理 IM/RTC。
|
||||
@ -123,7 +123,7 @@ responses:
|
||||
schema:
|
||||
$ref: "#/definitions/ErrorEnvelope"
|
||||
Forbidden:
|
||||
description: 已认证但被禁用、封禁或无权限。
|
||||
description: 已认证但资料未完成、被禁用、封禁或无权限;常见 `code` 为 `PROFILE_REQUIRED`、`USER_DISABLED`、`PERMISSION_DENIED`。
|
||||
schema:
|
||||
$ref: "#/definitions/ErrorEnvelope"
|
||||
NotFound:
|
||||
@ -1014,13 +1014,41 @@ paths:
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/filters:
|
||||
get:
|
||||
tags:
|
||||
- rooms
|
||||
summary: 首页房间区域与国家筛选目录
|
||||
operationId: listRoomFilters
|
||||
description: >-
|
||||
返回可直接用于房间发现列表的筛选项。每个 option 的 query_key/query_value 可原样作为
|
||||
GET /api/v1/rooms 的查询参数;All 的两个字段为空,表示不附加 region_id/country_code。
|
||||
Huwaa 返回当前 app scope 的全部 active 区域及其 enabled 国家,其他 App 只返回当前登录用户所在 active 区域,防止下发不可用的跨区选项。
|
||||
该路由要求 access token 有效且用户资料已完成;资料未完成返回 403/PROFILE_REQUIRED。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: 查询成功;国家项只包含 enabled 国家与 active 区域映射的交集。
|
||||
schema:
|
||||
$ref: "#/definitions/RoomFilterCatalogEnvelope"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/responses/Forbidden"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms:
|
||||
get:
|
||||
tags:
|
||||
- rooms
|
||||
summary: 房间发现列表
|
||||
operationId: listRooms
|
||||
description: 按当前登录用户的服务端 region_id 查询房间发现列表;响应卡片显式返回 `im_group_id`,当前值等于 `room_id`,但客户端不能硬编码该映射。
|
||||
description: >-
|
||||
Huwaa 不传 region_id/country_code 时查询 All,传 region_id 时查指定 active 区域,传 country_code 时查指定 enabled 国家,两者互斥。
|
||||
非 Huwaa App 仍按当前登录用户的服务端 region_id 隔离,不接受客户端 region_id。
|
||||
置顶之后的普通房依次是本国家有人、其他国家/区域有人、本国家没人、其他国家/区域没人。
|
||||
响应卡片显式返回 im_group_id,当前值等于 room_id,但客户端不能硬编码该映射。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
@ -1049,6 +1077,23 @@ paths:
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
- name: region_id
|
||||
in: query
|
||||
required: false
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
description: Huwaa 区域筛选;必须原样使用 `/api/v1/rooms/filters` 的 active `region_id`,不得与 `country_code` 同传。
|
||||
- name: country_code
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
description: Huwaa 国家筛选;必须原样使用 `/api/v1/rooms/filters` 的 enabled 大写国家码,不得与 `region_id` 同传。
|
||||
- name: country
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
description: 旧客户端兼容别名,等价于 `country_code`;新客户端统一使用 `country_code`。
|
||||
responses:
|
||||
"200":
|
||||
description: 查询成功,`data.rooms[*].im_group_id` 是 JoinRoom 成功后客户端加入的腾讯 IM 房间群 ID。
|
||||
@ -2155,6 +2200,7 @@ paths:
|
||||
tags:
|
||||
- resources
|
||||
summary: 佩戴我的资源
|
||||
description: vip_trial_card 会在 wallet-service 内部委托 VIP 专用状态机;该通用响应不含完整 VipState,VIP 客户端应使用 /api/v1/vip/trial-cards/{entitlement_id}/equip。
|
||||
operationId: equipMyResource
|
||||
security:
|
||||
- BearerAuth: []
|
||||
@ -2475,6 +2521,7 @@ definitions:
|
||||
- DISPLAY_USER_ID_LEASE_EXPIRED
|
||||
- DISPLAY_USER_ID_PAYMENT_REQUIRED
|
||||
- USER_DISABLED
|
||||
- PROFILE_REQUIRED
|
||||
- PERMISSION_DENIED
|
||||
- NOT_FOUND
|
||||
- CONFLICT
|
||||
@ -2492,6 +2539,7 @@ definitions:
|
||||
- conflict
|
||||
- insufficient balance
|
||||
- permission denied
|
||||
- profile required
|
||||
- not found
|
||||
request_id:
|
||||
type: string
|
||||
@ -4829,6 +4877,12 @@ definitions:
|
||||
type: string
|
||||
room_short_id:
|
||||
type: string
|
||||
country_code:
|
||||
type: string
|
||||
description: 房主国家投影的大写国家码,首页国家筛选和卡片展示均以该字段为准。
|
||||
country_flag:
|
||||
type: string
|
||||
description: 根据 `country_code` 派生的国旗 emoji;无有效国家投影时省略。
|
||||
RoomListData:
|
||||
type: object
|
||||
properties:
|
||||
@ -4838,6 +4892,134 @@ definitions:
|
||||
$ref: "#/definitions/RoomListItemData"
|
||||
next_cursor:
|
||||
type: string
|
||||
countries:
|
||||
type: array
|
||||
description: 兼容旧客户端的当前用户区域国家列表;完整跨区域目录请使用 `/api/v1/rooms/filters`。
|
||||
items:
|
||||
$ref: "#/definitions/RoomListCountryData"
|
||||
RoomListCountryData:
|
||||
type: object
|
||||
required:
|
||||
- country_code
|
||||
properties:
|
||||
country_id:
|
||||
type: integer
|
||||
format: int64
|
||||
country_code:
|
||||
type: string
|
||||
country_name:
|
||||
type: string
|
||||
country_display_name:
|
||||
type: string
|
||||
country_flag:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
format: int32
|
||||
RoomFilterAllData:
|
||||
type: object
|
||||
required:
|
||||
- filter_type
|
||||
- query_key
|
||||
- query_value
|
||||
properties:
|
||||
filter_type:
|
||||
type: string
|
||||
enum: [all]
|
||||
query_key:
|
||||
type: string
|
||||
description: 固定为空字符串;客户端不附加筛选参数。
|
||||
query_value:
|
||||
type: string
|
||||
description: 固定为空字符串。
|
||||
RoomFilterRegionData:
|
||||
type: object
|
||||
required:
|
||||
- filter_type
|
||||
- query_key
|
||||
- query_value
|
||||
- region_id
|
||||
- region_code
|
||||
- region_name
|
||||
- sort_order
|
||||
properties:
|
||||
filter_type:
|
||||
type: string
|
||||
enum: [region]
|
||||
query_key:
|
||||
type: string
|
||||
enum: [region_id]
|
||||
query_value:
|
||||
type: string
|
||||
description: region_id 的十进制字符串,可原样回传。
|
||||
region_id:
|
||||
type: integer
|
||||
format: int64
|
||||
region_code:
|
||||
type: string
|
||||
region_name:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
format: int32
|
||||
RoomFilterCountryData:
|
||||
type: object
|
||||
required:
|
||||
- filter_type
|
||||
- query_key
|
||||
- query_value
|
||||
- parent_region_id
|
||||
- country_id
|
||||
- country_code
|
||||
- country_name
|
||||
- country_display_name
|
||||
- country_flag
|
||||
- sort_order
|
||||
properties:
|
||||
filter_type:
|
||||
type: string
|
||||
enum: [country]
|
||||
query_key:
|
||||
type: string
|
||||
enum: [country_code]
|
||||
query_value:
|
||||
type: string
|
||||
description: 规范化后的大写 country_code,可原样回传。
|
||||
parent_region_id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 仅用于客户端把国家分组到区域;筛选国家时不要同时传 region_id。
|
||||
country_id:
|
||||
type: integer
|
||||
format: int64
|
||||
country_code:
|
||||
type: string
|
||||
country_name:
|
||||
type: string
|
||||
country_display_name:
|
||||
type: string
|
||||
country_flag:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
format: int32
|
||||
RoomFilterCatalogData:
|
||||
type: object
|
||||
required:
|
||||
- all
|
||||
- regions
|
||||
- countries
|
||||
properties:
|
||||
all:
|
||||
$ref: "#/definitions/RoomFilterAllData"
|
||||
regions:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/definitions/RoomFilterRegionData"
|
||||
countries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/definitions/RoomFilterCountryData"
|
||||
MyRoomData:
|
||||
type: object
|
||||
required:
|
||||
@ -5581,6 +5763,13 @@ definitions:
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/RoomListData"
|
||||
RoomFilterCatalogEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/RoomFilterCatalogData"
|
||||
MyRoomEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
|
||||
@ -79,6 +79,8 @@ graph LR
|
||||
|
||||
当前 `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 复用。
|
||||
|
||||
经理中心所有目标用户操作都必须从已验证的 App token 取得 `app_code`,不接受客户端自报租户。App 差异统一由 user-service 的 `manager_operations` 范围策略解析:Huwaa 的基础范围是 `global`;其他 App 的基础范围是 `country`,后台开启“跨区经理”后有效范围扩展为同一有效 `region_id`,仍禁止跨区域。头像框、座驾、徽章、VIP、升级等级、BD Leader/Admin/Superadmin、封禁和转移国家的搜索与写入口共用同一个范围匹配器,不能各自判断具体 App 或让 `scope=all` 绕过数据范围;各动作原有功能权限仍独立校验。
|
||||
|
||||
```sql
|
||||
admin_operation_logs(
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
@ -432,8 +434,9 @@ service UserHostAdminService {
|
||||
}
|
||||
```
|
||||
|
||||
`CreateBDLeaderRequest` 必须携带 `region_id`。user-service 校验该区域 active,锁定目标用户行,必要时更新 `users.region_id`,再创建 `bd_profiles`;同一个 `command_id` 重试只返回已创建的 BD Leader 事实,不重复迁移。
|
||||
这属于允许改变用户区域的后台关系管理路径之一;完整来源见 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources)。
|
||||
`CreateBDLeaderRequest` 不接受 `region_id`。user-service 锁定目标用户行,读取其当前 active `users.region_id`,再使用该区域创建或恢复 BD Leader 事实;不会因创建角色而迁移用户国家/区域。同一个 `command_id` 重试只返回已创建的 BD Leader 事实。
|
||||
|
||||
Agency 邀请、Host 主动申请、BD 邀请的区域例外也由 `user-service` 按 `app_code` 执行:仅 Huwaa 在邀请/申请创建及接受/审核阶段允许跨区,其他 App 保留区域校验。Agency 主动邀请 Host 不在例外内。Coin Seller 列表、子币商申请/审批和转账授权对所有 App 仍为同区域。
|
||||
|
||||
当前项目仍在开发阶段,proto 可以按当前事实调整;修改 `api/proto` 后必须运行 `make proto` 并提交生成文件。
|
||||
|
||||
@ -445,7 +448,7 @@ service UserHostAdminService {
|
||||
- Cycle calculation 使用 deterministic item key。重跑同一个 `draft` cycle 可以替换 draft items 或返回现有结果,不能重复插入。
|
||||
- Cycle approve 必须锁 cycle 当前状态为 `draft`。
|
||||
- Posting 使用 `salary_item_id` 作为钱包幂等键。`post_failed` 重试只处理未 posted item。
|
||||
- Relationship admin action 要锁目标用户的 `users`、`host_profiles`、`agencies`、`bd_profiles`,避免和 App 邀请接受并发造成身份冲突。后台创建 BD Leader 需要在同一事务内更新 `users.region_id` 和 `bd_profiles.region_id`。
|
||||
- Relationship admin action 要锁目标用户的 `users`、`host_profiles`、`agencies`、`bd_profiles`,避免和 App 邀请接受并发造成身份冲突。创建 BD Leader 使用目标用户当前 `users.region_id`,不在角色事务内改写用户区域。
|
||||
|
||||
## Event And Outbox
|
||||
|
||||
@ -511,6 +514,9 @@ Outbox consumers can update App message inbox, BI, risk systems, and export jobs
|
||||
| Posting fails after partial success | Retry posts only unposted items |
|
||||
| Posted item needs correction | Adjustment item created; original item remains immutable |
|
||||
| Admin closes Agency | Agency hidden from App search; existing facts remain auditable |
|
||||
| Huwaa Manager creates a BD Leader in another country/region | Creation succeeds; target keeps its current region |
|
||||
| Other App Manager creates a BD Leader in another country | Request is rejected before `CreateBDLeader` is called |
|
||||
| Huwaa Coin Seller creates or approves a cross-region sub-seller relation | Request is rejected with the existing region boundary |
|
||||
|
||||
## Critical Rules
|
||||
|
||||
@ -520,4 +526,5 @@ Outbox consumers can update App message inbox, BI, risk systems, and export jobs
|
||||
- BD/BD Leader base is downstream host salary only.
|
||||
- Leader direct Agency salary source must be de-duplicated.
|
||||
- Every admin mutation must be audited and idempotent.
|
||||
- Huwaa 跨区例外适用于 Agency 邀请、Host 主动申请、BD 邀请和全部经理中心目标用户操作;Coin Seller 仍为同区域。
|
||||
- Redis cannot be the source of truth for policies, salary, relationships, or audit.
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
## Goals
|
||||
|
||||
- 用户申请加入 Agency 并通过后成为主播;Agency 踢出用户只解除 Agency 归属,不取消主播身份。
|
||||
- 用户只能搜索并申请加入自己所属区域的 active Agency。
|
||||
- Huwaa 用户可搜索并申请加入本 App 内的跨区域 active Agency;其他 App 保留现有国家/区域限制。
|
||||
- Agency 归属于 BD;BD 归属于 BD Leader。BD Leader 也是 BD,只是具备邀请 BD 的能力。
|
||||
- BD 可以邀请用户成为 Agency;BD Leader 可以邀请用户成为 BD,也可以直接邀请用户成为 Agency。
|
||||
- 用户被邀请成为 Agency 时自动成为主播;但已是主播的用户不能再成为 Agency。
|
||||
@ -279,22 +279,20 @@ agency_memberships(
|
||||
|
||||
## Region Rules
|
||||
|
||||
用户所属区域来自 `user-service.users.region_id`,不能由客户端提交。
|
||||
用户所属区域来自 `user-service.users.region_id`,不能由客户端提交。`app_code` 来自 gateway 已验证的 token 并写入 `RequestMeta`;客户端不能通过请求体或请求头伪造 Huwaa 例外。
|
||||
国家/区域后台管理边界在 `hyapp-admin-server`:创建国家、启用/禁用国家、创建区域、启用/禁用区域不属于 App 侧 user-service RPC。App 侧只消费 user-service 已计算好的 `region_id` 和区域投影。
|
||||
后台创建 BD Leader 是关系管理命令,不是客户端区域提交:`hyapp-admin-server` 负责权限和审计,`user-service` 在同一事务里把目标用户的 `users.region_id` 更新为后台选择的区域,并用同一个区域创建 `bd_profiles`。
|
||||
用户区域允许变化的完整来源以 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources) 为准;Host/Agency/BD 关系只消费当前 `users.region_id` 和各关系表里的区域快照。
|
||||
用户区域允许变化的完整来源以 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources) 为准;Host/Agency/BD 关系创建后保留目标用户当前区域,Huwaa 跨区组织关系不会顺带迁移 `users.region_id`。
|
||||
|
||||
| Action | Region Rule |
|
||||
| --- | --- |
|
||||
| 后台创建 BD Leader | 后台必须选择 active 区域;目标用户当前区域可以不同,创建成功后目标用户 `users.region_id` 和 BD Leader `bd_profiles.region_id` 都变为所选区域 |
|
||||
| 搜索 Agency | 只返回 `agency.region_id == user.region_id` 且 active/join_enabled |
|
||||
| 申请加入 Agency | 申请人 `region_id` 必须等于 Agency `region_id` |
|
||||
| BD 邀请 Agency | BD 和被邀请人必须在同一区域,除非后台显式跨区授权 |
|
||||
| Leader 邀请 BD | Leader 和被邀请人必须在同一区域,除非后台显式跨区授权 |
|
||||
| Leader 邀请 Agency | Leader 和被邀请人必须在同一区域,除非后台显式跨区授权 |
|
||||
| 用户改国家导致区域变化 | 不自动迁移 Agency/BD 归属;新申请按新区域限制,历史统计按事件快照归属 |
|
||||
|
||||
如果后续业务需要跨区 Agency 或跨区 BD,必须增加显式 `region_scope` 和后台审批,不能绕过区域规则直接查全局 Agency。
|
||||
| 经理创建 BD Leader | Huwaa 经理可搜索并创建其他国家/区域的 BD Leader;其他 App 保持经理与目标用户同国家。创建后 BD Leader 使用目标用户已有 `users.region_id`,不迁移用户国家/区域 |
|
||||
| 搜索 Agency | Huwaa 返回本 App 内所有 active/join_enabled Agency;其他 App 保留按当前用户国家过滤 |
|
||||
| 申请加入 Agency | Huwaa 申请创建和 Agency 审核通过均允许跨区;其他 App 要求申请人与 Agency 同区域 |
|
||||
| Agency 主动邀请 Host | 所有 App 仍要求目标用户与 Agency 同区域;Huwaa 例外只适用于 Host 主动申请 |
|
||||
| BD/Leader 邀请 Agency | Huwaa 的邀请创建和接受均允许跨区;其他 App 要求邀请人与目标用户同区域 |
|
||||
| Leader 邀请 BD | Huwaa 的邀请创建和接受均允许跨区;其他 App 要求 Leader 与目标用户同区域 |
|
||||
| Coin Seller | 所有 App 继续按区域返回 active 币商;子币商申请创建、审批建立关系和转账前授权均要求父子币商同区域 |
|
||||
| 用户改国家导致区域变化 | 不自动迁移 Agency/BD 归属;新申请或邀请按 `app_code` 对应规则处理,历史统计按事件快照归属 |
|
||||
|
||||
## Application And Invitation Flows
|
||||
|
||||
@ -309,16 +307,16 @@ sequenceDiagram
|
||||
|
||||
C->>G: GET /agencies/search
|
||||
G->>U: SearchAgenciesForUser(user_id)
|
||||
U->>H: load user region and active agencies
|
||||
H-->>U: active agencies in region
|
||||
U-->>G: active agencies in region
|
||||
U->>H: load app_code policy, user country/region and active agencies
|
||||
H-->>U: Huwaa all active agencies; other App same-country agencies
|
||||
U-->>G: app-scoped agency list
|
||||
G-->>C: agency list
|
||||
|
||||
C->>G: POST /host/applications
|
||||
G->>U: ApplyToAgency(user_id, agency_id)
|
||||
U->>H: load user region and agency
|
||||
H->>H: lock user host profile and agency
|
||||
H->>H: create pending application
|
||||
H->>H: enforce app-scoped region policy and create pending application
|
||||
H-->>U: application
|
||||
U-->>G: application
|
||||
```
|
||||
@ -353,7 +351,7 @@ Kick must not delete `host_profiles` and must not delete relationship history. T
|
||||
BDInviteAgency(target_user_id)
|
||||
-> target must not be active host
|
||||
-> target must not own active agency
|
||||
-> target region must match BD region
|
||||
-> Huwaa allows cross-region; other App target region must match BD region
|
||||
-> create role_invitation(type=agency, inviter_bd_user_id)
|
||||
-> target accepts
|
||||
-> create agency owned by target
|
||||
@ -374,7 +372,7 @@ BD can invite self only when:
|
||||
LeaderInviteBD(target_user_id)
|
||||
-> inviter must be active bd_leader
|
||||
-> target must not be active BD
|
||||
-> target region must match leader region
|
||||
-> Huwaa allows cross-region; other App target region must match leader region
|
||||
-> create role_invitation(type=bd)
|
||||
-> target accepts
|
||||
-> create bd_profiles(role=bd, parent_leader_user_id=leader)
|
||||
@ -610,7 +608,7 @@ Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system`
|
||||
| BD joins another Agency | BD profile remains active; host membership is separate |
|
||||
| BD wants own Agency after joining another Agency | Must first leave or be removed from active host membership; then self-invite can create Agency |
|
||||
| Agency owner wants to leave own Agency | Not allowed through normal leave; Agency close or transfer is backend ability |
|
||||
| User region changes | Existing relationships remain; new search/application uses new region; historical stats use event snapshots |
|
||||
| User region changes | Existing relationships remain; new search/application/invitation follows the current App policy; historical stats use event snapshots |
|
||||
| App earning summary and wallet balance differ | Wallet balance is authority for withdrawable amount; earning summary is business explanation |
|
||||
| Client retries application or invitation action | Same `command_id` returns existing result; different `command_id` must hit uniqueness guard |
|
||||
|
||||
@ -622,7 +620,8 @@ Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system`
|
||||
|
||||
| Scenario | Expected |
|
||||
| --- | --- |
|
||||
| User searches Agency | Only same-region active joinable agencies returned |
|
||||
| Huwaa user searches Agency | All active joinable agencies in Huwaa are returned, including other regions |
|
||||
| Other App user searches Agency | Existing country/region restriction remains in force |
|
||||
| User applies and Agency approves | User becomes active host and active Agency member |
|
||||
| Agency kicks host | Membership ends, host profile remains active |
|
||||
| Kicked host reapplies to another Agency | New active membership created |
|
||||
@ -630,6 +629,9 @@ Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system`
|
||||
| BD invites self as Agency while not host | Agency created, BD also becomes host and Agency owner |
|
||||
| BD joins another Agency | BD remains BD and becomes host under that Agency |
|
||||
| BD Leader invites BD | Target gets BD profile, not host profile |
|
||||
| Huwaa cross-region Agency/BD invitation is accepted | Target keeps its own region and the invited relationship is created |
|
||||
| Huwaa manager creates cross-region BD Leader | Target keeps its own region and receives active BD Leader identity |
|
||||
| Huwaa user lists or invites Coin Seller | Only same-region sellers or sub-seller targets are allowed |
|
||||
| Host works across two Agencies in one day | Stats split by relation snapshot |
|
||||
| App reads earning summary before backend settlement | Shows pending/unsettled state, not withdrawable money |
|
||||
| Same application command retried | Returns the original pending application |
|
||||
@ -640,7 +642,7 @@ Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system`
|
||||
- Agency owner is a host, but ordinary active host cannot become Agency.
|
||||
- BD and host identities can coexist; BD role alone does not imply host.
|
||||
- BD Leader is a BD with extra invitation rights.
|
||||
- Region constraints are enforced by server-side `region_id`.
|
||||
- Region policy is selected by authenticated `app_code`: Huwaa only exempts Agency invitation, Host application, BD invitation and manager-created BD Leader; other App paths and all Coin Seller relations retain server-side region restrictions.
|
||||
- App cannot configure policies, approve salary, post wallet balance, or mutate withdrawals.
|
||||
- Wallet is the only owner of salary balance and withdrawal state.
|
||||
- All relationship changes must be auditable and idempotent.
|
||||
|
||||
@ -152,21 +152,21 @@ tasks:
|
||||
timeout: "3s"
|
||||
lock_ttl: "30s"
|
||||
batch_size: 100
|
||||
app_codes: ["lalu"]
|
||||
app_codes: ["lalu","fami","huwaa"]
|
||||
user_region_rebuild:
|
||||
enabled: true
|
||||
interval: "5s"
|
||||
timeout: "5s"
|
||||
lock_ttl: "30s"
|
||||
batch_size: 500
|
||||
app_codes: ["lalu"]
|
||||
app_codes: ["lalu","fami","huwaa"]
|
||||
message_fanout:
|
||||
enabled: true
|
||||
interval: "1s"
|
||||
timeout: "10s"
|
||||
lock_ttl: "30s"
|
||||
batch_size: 500
|
||||
app_codes: ["lalu"]
|
||||
app_codes: ["lalu","fami","huwaa"]
|
||||
mic_open_session_compensation:
|
||||
enabled: true
|
||||
interval: "60s"
|
||||
@ -175,7 +175,7 @@ tasks:
|
||||
batch_size: 100
|
||||
pending_publish_max_age: "2m"
|
||||
publishing_session_max_age: "12h"
|
||||
app_codes: ["lalu"]
|
||||
app_codes: ["lalu","fami","huwaa"]
|
||||
```
|
||||
|
||||
## Cron Database
|
||||
|
||||
@ -96,6 +96,8 @@ rocketmq:
|
||||
namespace: ""
|
||||
send_timeout: "3s"
|
||||
retry: 2
|
||||
# 安全默认值;全部 room consumer 完成 legacy||typed 兼容订阅后才切 event_type。
|
||||
room_event_tag_mode: "legacy"
|
||||
room_outbox:
|
||||
enabled: false
|
||||
topic: "hyapp_room_outbox"
|
||||
@ -127,12 +129,13 @@ outbox_worker:
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| `rocketmq.enabled` | 控制 RocketMQ client 是否启用;本地默认关闭,线上示例开启 |
|
||||
| `rocketmq.room_event_tag_mode` | `legacy` 是无损迁移默认值;`event_type` 只在所有 consumer 已双订阅后显式开启 |
|
||||
| `rocketmq.room_outbox.enabled` | 控制 room outbox 是否可发布到 MQ;`publish_mode=mq/dual` 时必须开启 |
|
||||
| `rocketmq.room_outbox.tencent_im_consumer_enabled` | 控制 room-service 是否作为 IM bridge consumer 消费 `hyapp_room_outbox` |
|
||||
| `rocketmq.rocket_launch.enabled` | 控制火箭点火后是否发布和消费延迟发射唤醒消息 |
|
||||
| `outbox_worker.publish_mode` | 本地默认 `direct`,线上建议 `mq`,迁移期可用 `dual` |
|
||||
| `poll_interval` | worker 空轮询间隔,必须大于 0,避免 busy loop |
|
||||
| `batch_size` | 每轮最大抢占条数,必须大于 0,避免全表扫描 |
|
||||
| `batch_size` | 每轮最多处理条数;同步 publisher 逐条 claim,避免批尾尚未发送 lease 就已过期 |
|
||||
| `publish_timeout` | 单条 outbox 发布 deadline,避免外部系统拖死 worker |
|
||||
| `max_retry_count` | 达到次数后标记 `failed`,防止 poison event 永久刷日志 |
|
||||
|
||||
|
||||
@ -57,7 +57,8 @@
|
||||
| GET | `/api/v1/users/me/friend-requests` | users | `listMyFriendApplications` | 我的好友申请列表 |
|
||||
| GET | `/api/v1/users/me/friends` | users | `listMyFriends` | 我的好友列表 |
|
||||
| DELETE | `/api/v1/users/{user_id}/friend` | users | `deleteFriend` | 双向删除好友关系 |
|
||||
| GET | `/api/v1/rooms` | rooms | `listRooms` | 房间发现列表,支持 `tab=hot/new`,卡片返回 `im_group_id` |
|
||||
| GET | `/api/v1/rooms/filters` | rooms | `listRoomFilters` | Flutter 首页区域/国家筛选目录,下发可原样回传的 `region_id/country_code` |
|
||||
| GET | `/api/v1/rooms` | rooms | `listRooms` | 房间发现列表;Huwaa 默认 All,支持互斥的 `region_id/country_code` 及 `tab=hot/new`,卡片返回 `im_group_id` |
|
||||
| GET | `/api/v1/rooms/me` | rooms | `getMyRoom` | 查询 Mine 顶部我的房间卡片 |
|
||||
| GET | `/api/v1/rooms/feeds` | rooms | `listRoomFeeds` | Mine 页 Visited/Friend/Following/Followed 房间流 |
|
||||
| GET | `/api/v1/rooms/current` | rooms | `getCurrentRoom` | 查询当前可恢复房间 |
|
||||
|
||||
351
docs/新App上线配置检查清单.md
Normal file
351
docs/新App上线配置检查清单.md
Normal file
@ -0,0 +1,351 @@
|
||||
# 新 App 上线配置检查清单
|
||||
|
||||
本文档定义 HyApp 新 App 从租户注册到正式运营的配置边界、业务 owner、开启条件和验收标准。它不是“把旧 App 数据全量复制一遍”的脚本说明;每项数据必须按 owner service 和业务键管理,不能跨租户复用原始自增 ID。
|
||||
|
||||
## 1. 使用方式
|
||||
|
||||
每次新 App 上线前,先建立一份当次上线记录,填写:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `app_code` | 后端租户编码,全链路使用同一个小写值 |
|
||||
| App 名称 | 用户可见品牌名 |
|
||||
| Android package | Android 包名,必须和 Firebase、Google Play 一致 |
|
||||
| iOS bundle ID | iOS bundle identifier,必须和 Firebase、Apple 后台一致 |
|
||||
| 对照 App | 只用于确定初始产品口径,不代表可以直接复制原始 ID |
|
||||
| 计划开服时间 | UTC epoch milliseconds,用于活动、版本和内容生效时间 |
|
||||
| 支持国家/区域 | 国家、区域、内容和价格配置的权威范围 |
|
||||
| 负责人 | 后端、客户端、运营、支付、IM/RTC 各自验收人 |
|
||||
|
||||
状态只使用以下四种:
|
||||
|
||||
- `NOT_STARTED`:尚未配置。
|
||||
- `CONFIGURED`:已写入,但未经过真实请求验证。
|
||||
- `VERIFIED`:已用新 `app_code` 走通生产链路并保留证据。
|
||||
- `BLOCKED`:存在会阻断上线的问题,必须写明 owner 和解决动作。
|
||||
|
||||
## 2. 上线硬性原则
|
||||
|
||||
1. 新 App 数据必须使用新 `app_code`;不能依赖代码中的 `lalu` 默认值维持运行。
|
||||
2. 复制配置必须按 `resource_code`、`group_code`、`region_code`、`product_code` 等业务键重建引用,不能复制旧 App 的自增 ID。
|
||||
3. 礼物、活动、VIP、游戏等只有主配置表不算完成;所有价格、区域、资源、奖励和 worker 必须一起验证。
|
||||
4. 敏感值只能写入线上环境变量或已忽略的运行时 env,不能写入可提交 YAML、SQL、文档或代码。
|
||||
5. 全部时间使用 UTC,范围统一使用 `[start_ms, end_ms)`。
|
||||
6. 共享 RocketMQ topic 不代表多 App 已经就绪;必须确认消息 payload、consumer context、幂等表和 outbox claim 都保留 `app_code`。
|
||||
7. 只有完成“配置写入 → 服务加载 → 真实 API/IM/RTC/MQ 请求 → 日志与数据回查”才能标记 `VERIFIED`。
|
||||
|
||||
## 3. App 注册与租户基础
|
||||
|
||||
Owner:`user-service`、`gateway-service`。
|
||||
|
||||
| 配置项 | 数据/配置入口 | 开启条件 | 验收标准 |
|
||||
| --- | --- | --- | --- |
|
||||
| App 注册 | `hyapp_user.apps` | `status=active`,`app_code` 和包名唯一 | 按 `X-App-Code` 和 package 都能解析到同一 App |
|
||||
| Android/iOS 包信息 | `apps` 及客户端 flavor | 与应用商店、Firebase 完全一致 | 真实安装包登录成功 |
|
||||
| Firebase 项目 | `user-service.third_party.firebase.projects.<app_code>` | 项目 ID、凭据和签名指纹都属于新 App | Google/Firebase token 在新 App 成功,不被其他 App 接受 |
|
||||
| JWT/请求元数据 | gateway → gRPC `RequestMeta.app_code` | 所有需要租户隔离的 RPC 透传 | 下游日志和数据行均为新 `app_code` |
|
||||
| 登录风控 | `auth_risk_configs`、`login_risk_country_blocks` | 明确复制或新建策略 | 允许国家可登录,拦截国家返回预期错误 |
|
||||
|
||||
## 4. 国家、区域与数据可见性
|
||||
|
||||
Owner:`user-service`。
|
||||
|
||||
| 配置项 | 主表 | 开启条件 | 验收标准 |
|
||||
| --- | --- | --- | --- |
|
||||
| 国家 | `countries` | 运营国家 `enabled=1` | `/api/v1/countries` 只返回新 App 允许的国家 |
|
||||
| 区域 | `regions` | `status=active` | 后台和 App 能查到预期区域 |
|
||||
| 国家归属 | `region_countries` | 每个启用国家有且只有一个有效业务区域 | 新注册用户的 `region_id` 正确 |
|
||||
| 存量用户重建 | `user_region_rebuild_tasks` | 只在映射变更或补历史数据时执行 | task 完成且 `UserRegionChanged` outbox 已投递 |
|
||||
|
||||
不能根据服务器所在地、IP 或容器时区推断业务国家/区域。
|
||||
|
||||
## 5. 管理后台与 App 内容入口
|
||||
|
||||
Owner:`server/admin`。
|
||||
|
||||
| 配置项 | 主表 | 开启条件 | 验收标准 |
|
||||
| --- | --- | --- | --- |
|
||||
| App 通用配置 | `admin_app_configs` | 新 App 必须有自己的 `group + key` | H5 入口和功能跳转不读取其他 App 值 |
|
||||
| Banner | `admin_app_banners` | 至少一个符合首页/区域/国家口径的 active 内容 | `/api/v1/app/banners` 在目标国家返回预期内容 |
|
||||
| 弹窗 | `admin_app_popups` | 按产品需求设为 active,并配置生效时间 | `/api/v1/app/popups` 可见且跳转正确 |
|
||||
| 开屏 | `admin_app_splash_screens` | 按 platform/国家/区域生效 | `/api/v1/app/splash-screens` 返回预期素材 |
|
||||
| 版本控制 | `admin_app_versions` | Android/iOS 当前生产版本均有记录 | `/api/v1/app/version` 返回正确版本、build 和强更策略 |
|
||||
| Explore Tab | `admin_app_explore_tabs` | 显式启用并配置预期顺序 | App 探索入口与后台一致 |
|
||||
|
||||
内容 API 返回 `200` 但 `items=[]` 只证明接口可用,不代表运营配置已完成。
|
||||
|
||||
## 6. 腾讯云 IM、RTC 与播报
|
||||
|
||||
Owner:`gateway-service`、`activity-service`、`room-service`;客户端长连属于腾讯云 IM。
|
||||
|
||||
| 配置项 | 配置/链路 | 开启条件 | 验收标准 |
|
||||
| --- | --- | --- | --- |
|
||||
| IM UserSig | `gateway-service.tencent_im` | SDKAppID、secret、admin identifier 与线上 IM 应用一致 | 新 App 真实用户能登录 IM |
|
||||
| IM callback | gateway callback | callback 能从合法 GroupID/请求中确定 `app_code` | Fami/Lalu 回调不会互相落库 |
|
||||
| RTC UserSig | `gateway-service.tencent_rtc` | SDKAppID、secret、room ID 类型与客户端一致 | 真实设备进房、上麦、发流成功 |
|
||||
| RTC callback | `tencent_rtc.callback_url` | 不能把 `app_code` 写死为其他 App | 回调日志与房间租户一致 |
|
||||
| 全局/区域播报群 | `activity-service.broadcast` | 为新 App 和所有 active region 建立群 | 群 ID 含新 `app_code`,客户端能加入 |
|
||||
| 播报 outbox | `im_broadcast_outbox` | worker 必须按记录 `app_code` claim、发送和 mark | 新 App 消息从 pending 到 delivered,无跨 App 更新 |
|
||||
|
||||
至少实测一条全局播报、一条区域播报和一条房间系统消息。
|
||||
|
||||
## 7. 房间配置
|
||||
|
||||
Owner:`room-service`。Room Cell 仍然是房间核心状态 owner;新 App 上线不得把房间状态复制到其他服务。
|
||||
|
||||
| 配置项 | 主表 | 开启条件 | 验收标准 |
|
||||
| --- | --- | --- | --- |
|
||||
| 麦位 | `room_seat_configs` | 有新 App 配置,否则明确接受代码默认值 | 创房后麦位数量与客户端一致 |
|
||||
| 火箭 | `room_rocket_configs` | `enabled=1` 且奖励、油箱、频控已设定 | 完成加油、发射、奖励和 MQ 验证 |
|
||||
| 宝箱 | `room_treasure_configs` | 产品需要时显式开启 | 开关与 App 入口一致 |
|
||||
| 人机房 | `room_human_robot_configs` | 只有配好机器人账号和策略后才能开启 | 机器人加入/离开不污染真人 presence |
|
||||
| 区域置顶 | `room_region_pins` | 按新 App 运营需求单独配置 | 房间变更区域后重新建立置顶 |
|
||||
|
||||
## 8. 礼物、资源与装扮
|
||||
|
||||
Owner:`wallet-service`。
|
||||
|
||||
礼物上线是一组不可拆分的配置:
|
||||
|
||||
| 配置项 | 主表 | 开启条件 |
|
||||
| --- | --- | --- |
|
||||
| 资源定义 | `resources` | 依赖资源必须 `status=active`,素材 URL 可访问 |
|
||||
| 资源组 | `resource_groups`、`resource_group_items` | 所有活动/VIP/成就引用必须映射到新 App 资源 |
|
||||
| 资源商店 | `resource_shop_items` | 价格、有效期和上架状态已审核 |
|
||||
| 礼物类型 | `gift_type_configs` | 礼物分类已按产品口径启用 |
|
||||
| 礼物配置 | `gift_configs` | `status=active`,生效时间正确 |
|
||||
| 礼物价格 | `wallet_gift_prices` | 每个 active 礼物都有有效价格 |
|
||||
| 礼物区域 | `gift_config_regions` | 地区限制和新 App 的 region 映射一致 |
|
||||
| 钻石分成 | `gift_diamond_ratio_configs` | 送礼所在区域有唯一有效分成规则 |
|
||||
|
||||
验收必须使用新 App 真实账号执行:礼物列表 → 余额校验 → 扣款 → Room Cell 表现 → `RoomGiftSent` outbox → 统计/活动/通知下游。
|
||||
|
||||
## 9. 充值、支付与兑换
|
||||
|
||||
Owner:`wallet-service`。
|
||||
|
||||
| 配置项 | 主表/运行配置 | 开启条件 | 验收标准 |
|
||||
| --- | --- | --- | --- |
|
||||
| 充值商品 | `wallet_recharge_products` | 新 App 商品 active,币种、金额、金币数正确 | App 返回商品和后台一致 |
|
||||
| 商品区域 | `wallet_recharge_product_regions` | 用新 App `product_id` 和 `region_id` 重建 | 每个运营区域都有预期商品 |
|
||||
| Google Play | `google_play` 及包名/服务账号 | 新 package 和验单凭据正确 | 测试购买、验单、发币、consume 均成功 |
|
||||
| 第三方支付 | `third_party_payment_channels/methods` | 渠道、国家、币种和回调签名已配置 | 下单和回调的 `app_code` 一致 |
|
||||
| USDT/币安等外部充值 | service YAML/env 的 per-App 映射 | 必须有新 App 账户/地址,不能回落到 Lalu | 创单、归集、确认数和入账正确 |
|
||||
| 钻石兑换 | `wallet_diamond_exchange_rules` | active 规则已按运营口径配置 | 边界值和余额流水一致 |
|
||||
| 红包 | `red_packet_configs` 及档位表 | 开关、金额、份数、过期 worker 均支持新 App | 发、抢、退、播报完整闭环 |
|
||||
|
||||
## 10. VIP
|
||||
|
||||
Owner:`wallet-service`;行为唯一依据是 `vip_program_configs`。
|
||||
|
||||
| 配置项 | 主表 | 开启条件 | 验收标准 |
|
||||
| --- | --- | --- | --- |
|
||||
| VIP 方案 | `vip_program_configs` | `program_type`、等级数、购买/升降级、发放模式明确 | API 返回的方案与后台一致 |
|
||||
| VIP 等级 | `vip_levels` | 只有完成价格、时长和资源组配置的等级才能 active | `/api/v1/vip/packages` 返回预期等级 |
|
||||
| VIP 权益 | `vip_level_benefits` | 每个 active 等级的权益和资源引用有效 | 购买/发卡后权益生效 |
|
||||
| 日奖励 | cron `vip_daily_coin_rebate` | cron `app_codes` 含新 App,金额和 UTC 日切口径已确认 | 当日 run、领取和钱包流水一致 |
|
||||
| 进房/上线播报 | activity IM broadcast | 用户开关、VIP 级别和广播群完整 | 真实设备收到新 App 播报 |
|
||||
|
||||
等级处于 `disabled` 时,套餐接口返回空数组是正确保护行为,但该 App 不能因此被认为“VIP 已上线”。
|
||||
|
||||
## 11. 活动与奖励
|
||||
|
||||
Owner:`activity-service`。
|
||||
|
||||
| 能力 | 主表/配置 | 上线必须同时确认 |
|
||||
| --- | --- | --- |
|
||||
| 注册奖励 | `registration_reward_configs` | 开关、奖励类型、金额/资源组、每日限制 |
|
||||
| 首充奖励 | `first_recharge_reward_configs/tiers` | 主开关和至少一个 active 档位 |
|
||||
| 累充奖励 | `cumulative_recharge_reward_configs/tiers` | 主开关、周期口径和 active 档位 |
|
||||
| 邀请奖励 | `invite_activity_reward_configs/tiers` | 邀请关系、有效邀请和充值事件消费就绪 |
|
||||
| 七日签到 | `seven_day_checkin_configs/versions/rewards` | 开关、一个当前版本和 1–7 天完整奖励 |
|
||||
| 每日任务 | `task_definitions/versions` | active 任务、周期、指标和奖励口径一致 |
|
||||
| 成就 | `achievement_definitions/conditions` | active 成就引用的徽章资源属于新 App |
|
||||
| 成长等级 | `growth_level_tracks/rules/tiers` | 财富/魅力/游戏轨道及展示资源完整 |
|
||||
| CP 周榜 | `cp_weekly_rank_configs/rewards` | 开关、排名数、关系类型和奖励齐全 |
|
||||
| 周星 | `weekly_star_cycles/gifts/rewards` | 当前 UTC 时间命中 active 周期,礼物和 Top 奖励完整 |
|
||||
| 房间流水奖励 | `room_turnover_reward_configs/tiers` | 开关、周期、档位和钱包发放链路完整 |
|
||||
| 转盘 | `wheel_rule_versions/prize_tiers` | 当前规则版本 active,奖品和 RTP 口径已审核 |
|
||||
|
||||
主开关、奖励档位和奖励资源缺任何一项,该活动都不得标记上线。
|
||||
|
||||
## 12. 幸运礼物
|
||||
|
||||
Owner:`lucky-gift-service`。`activity-service` 中的旧副本不能作为正式运行依据。
|
||||
|
||||
| 配置项 | 主表/链路 | 验收标准 |
|
||||
| --- | --- | --- |
|
||||
| 规则版本 | `lucky_gift_rule_versions` | 每个运营奖池有 active 规则 |
|
||||
| 阶段档位 | `lucky_gift_stage_tiers` | 规则对应的 stage/tier 完整且 enabled |
|
||||
| 独立奖池 | `lucky_pools` | 按 `app_code + pool_id` 初始化;只复制规则时不能复制对照 App 的余额、累计入池/出池或用户运行态 |
|
||||
| 礼物映射 | 礼物配置与 pool 映射 | 只有新 App active 礼物才能进入抽奖 |
|
||||
| owner outbox | `lucky_gift_outbox` | 抽奖事实持久化并发布到独立 MQ topic |
|
||||
| 区域飘屏 | activity 独立 consumer group | 不得复用其他 consumer group,且保留 `app_code` |
|
||||
|
||||
真实验收要覆盖:普通奖、大奖、幂等重试、钱包入账、outbox 投递和区域飘屏。
|
||||
|
||||
## 13. 游戏
|
||||
|
||||
Owner:`game-service`。
|
||||
|
||||
| 配置项 | 主表 | 开启条件 |
|
||||
| --- | --- | --- |
|
||||
| 游戏平台 | `game_platforms` | 平台 active,启动和回调参数完整 |
|
||||
| 游戏目录 | `game_catalog` | 新 App 明确选择要开放的游戏,不强制与对照 App 数量相同 |
|
||||
| 展示规则 | `game_display_rules` | 每个 active 游戏有可见规则 |
|
||||
| 自研游戏配置 | `game_self_game_configs`、风控/新手策略 | 游戏开关、RTP、风控参数已审核 |
|
||||
| 机器人 | `game_self_game_robots` | 自研对战需要补位时,必须有新 App 真实机器人用户 |
|
||||
| 档位奖池 | `game_self_game_stake_pools` | 每个可玩档位有 active 奖池和初始水位 |
|
||||
| 回调归属 | gateway JWT → `RequestMeta.app_code` | 厂商回调不能默认记入 Lalu |
|
||||
|
||||
第三方游戏少于对照 App 不一定是错误;必须由产品/运营给出明确游戏清单。但自研游戏已对用户开放时,机器人和奖池为空属于上线阻断。
|
||||
|
||||
## 14. cron、MQ、outbox 和通知
|
||||
|
||||
| 配置项 | Owner | 开启条件 | 验收标准 |
|
||||
| --- | --- | --- | --- |
|
||||
| cron 任务 | `cron-service` | 所有应在新 App 运行的 task `app_codes` 含新 App | `cron_task_runs` 按 `task_name + app_code` 生成成功记录 |
|
||||
| owner outbox publisher | room/wallet/user/game/lucky-gift | 只有 owner 服务扫描自己的 outbox | 新 App pending 可持续转 delivered,无长时间积压 |
|
||||
| RocketMQ consumer group | 每个下游服务 | 不得共享 owner 的 outbox 状态或其他业务消费组 | group 存在、无异常 lag、重投幂等 |
|
||||
| 统计消费 | `statistics-service` | wallet/room/user/game 事实消费器已启用 | 新 App 聚合表产生 UTC 日数据 |
|
||||
| 私有通知 | `notice-service` | RocketMQ 消费器为主链路 | 钱包/CP/房间通知均使用新 App 租户 |
|
||||
| 系统/活动 inbox | `activity-service` message owner | 后台通知通过 owner API/fanout job 创建 | 不直写 inbox/fanout 表,能从 App 查看并已读 |
|
||||
|
||||
`notice-service` 的旧轮询 worker 关闭、RocketMQ consumer 开启是正常迁移状态,不能只看一个 `enabled=false` 就判定新 App 未开通。
|
||||
|
||||
## 15. 统计与数据大屏
|
||||
|
||||
Owner:`statistics-service`;后台查询入口在 `server/admin`。
|
||||
|
||||
1. 确认 `WalletRechargeRecorded`、`GameOrderSettled`、`RoomGiftSent`、`RoomUserJoined`、`UserRegistered` 事件都带新 `app_code`。
|
||||
2. 确认消费幂等表已记录新 App 事件。
|
||||
3. 确认充值、游戏、礼物、活跃、注册和留存聚合表在 UTC 边界正确生成。
|
||||
4. 确认 admin 大屏选择新 App 时只查聚合表,不回查 owner 明细库。
|
||||
5. 使用可控测试账号产生一组注册、进房、充值、游戏、送礼事实,核对次日聚合。
|
||||
|
||||
## 16. 客户端与发布
|
||||
|
||||
| 项目 | 必须验证 |
|
||||
| --- | --- |
|
||||
| flavor/品牌 | App 名称、图标、包名、bundle ID、深链、主题和隐私页均为新 App |
|
||||
| API host | 生产包指向生产 API,不得混入测试 host |
|
||||
| Firebase | flavor 使用自己的 `google-services.json`/iOS 配置 |
|
||||
| IM/RTC | SDKAppID、UserSig 及回调链路完整 |
|
||||
| 支付 | package/product ID/签名与商店后台一致 |
|
||||
| 版本 API | 首个生产 build 已写入 `admin_app_versions` |
|
||||
| 埋点 | 心跳、页面、社交漏斗和游戏事件使用新 `app_code` |
|
||||
|
||||
必须在真实 Android/iOS 设备上执行最终验收,不能只用 curl 代替 IM、RTC、支付和客户端跳转验证。
|
||||
|
||||
## 17. 正式上线验收清单
|
||||
|
||||
### 17.1 配置一致性
|
||||
|
||||
- [ ] 新 App 在所有 owner 库的 `app_code` 一致。
|
||||
- [ ] 所有跨表引用已按业务键重建,不存在指向对照 App ID 的记录。
|
||||
- [ ] 全部敏感配置只存在运行时 env/密钥管理系统。
|
||||
- [ ] 两台或多台副本的 YAML、镜像 tag 和配置 hash 一致。
|
||||
|
||||
### 17.2 业务冒烟
|
||||
|
||||
- [ ] 注册、登录、刷新 token、用户资料、国家/区域归属正常。
|
||||
- [ ] IM 登录、单聊、房间群、全局/区域播报正常。
|
||||
- [ ] RTC 进房、上麦、发流、回调和离开正常。
|
||||
- [ ] 创房、进房、麦位、踢人、锁房、房间恢复正常。
|
||||
- [ ] 礼物列表、送礼扣款、房间表现、收礼统计正常。
|
||||
- [ ] 充值商品、真实测试购买、回调验单、钱包入账正常。
|
||||
- [ ] VIP/活动/签到/任务/成就/周星等已宣布上线的能力全部真实验证。
|
||||
- [ ] 第三方游戏启动、回调归属和余额一致;自研游戏机器人/奖池可用。
|
||||
|
||||
### 17.3 异步与数据
|
||||
|
||||
- [ ] owner outbox 无新 App 长时间 pending/failed 积压。
|
||||
- [ ] RocketMQ consumer group 存在且无异常 lag。
|
||||
- [ ] cron 所需 task 已产生新 App 成功运行记录。
|
||||
- [ ] 通知、inbox、播报和 push 不串 App。
|
||||
- [ ] 统计聚合表有新 App 真实事实,UTC 边界正确。
|
||||
|
||||
### 17.4 发布结论
|
||||
|
||||
只有以下条件全部满足才能标记“可正式运营”:
|
||||
|
||||
1. P0 阻断项为 0。
|
||||
2. 所有宣布上线的功能已完成真实设备和生产链路验证。
|
||||
3. 运营明确接受未上线的可选功能,且 App 客户端不展示无配置入口。
|
||||
4. 发布镜像、数据迁移、MQ、YAML/env、客户端版本和回滚方案均有记录。
|
||||
|
||||
## 18. Fami 生产差异快照(2026-07-13)
|
||||
|
||||
本节是对生产 YAML、生产 MySQL、线上镜像和公网 API 的只读快照,用于保留 Fami 从试运营转正式运营前的基线。后续修复后应追加新日期的验收结果,不覆盖本快照。
|
||||
|
||||
### 18.1 P0 阻断项
|
||||
|
||||
| 项目 | Fami 当前状态 | Lalu 对照 | 必须动作 |
|
||||
| --- | --- | --- | --- |
|
||||
| 礼物 | `gift_configs=0`、`wallet_gift_prices=0` | 95 个 active 礼物、103 个 active 价格 | 按业务键创建 Fami 礼物和价格,验证送礼闭环 |
|
||||
| 资源 | 535 条全部 `deleted`,有效资源/资源组/商城均为 0 | 501 active 资源、56 active 资源组 | 重建 Fami 资源体系和全部引用 |
|
||||
| VIP | 方案 active,但 VIP1–VIP10 全部 disabled | VIP1–VIP8 active | 只启用已完成价格、权益和资源配置的 VIP1–VIP9 |
|
||||
| RTC callback | 两台 gateway 回调 URL 都写死 `app_code=lalu` | Lalu 符合当前值 | 调整回调归属,分别验证 Fami/Lalu RTC 事件 |
|
||||
| IM 播报 worker | 线上 `activity-service:20260712-main-7d011ce` 仍以默认 Lalu context claim/reconcile | Lalu 已投递 71,564 条 | 发布多 App claim/建群逻辑,实测 Fami 全局和区域播报 |
|
||||
| 幸运礼物 owner | `lucky-gift-service` 规则版本和档位均为 0 | 15 个规则版本、255 个档位 | 在 owner 库配置,不使用 activity 旧副本代替 |
|
||||
| 首充奖励 | 开关 0,3 个档位全部 disabled | 开关 1,3 个 active | 确认奖励后启用并走真实充值事实 |
|
||||
| 累充奖励 | 开关 0,8 个档位全部 disabled | 开关 1,8 个 active | 确认周期和奖励后启用 |
|
||||
| 七日签到 | 开关 0,无生效版本,无奖励 | 开关 1,有生效版本和 7 天奖励 | 创建 Fami 版本和 7 天奖励后启用 |
|
||||
| CP 周榜 | 开关 0,无奖励 | 开关 1,3 个奖励 | 确认关系类型和奖励后启用 |
|
||||
| 周星 | 当前 UTC 时间无 active 周期 | Lalu 有 1 个 active 周期 | 创建 Fami 周期、指定礼物和 Top 奖励 |
|
||||
| 自研游戏运行数据 | 机器人 0,档位奖池 0 | dice 机器人 80,dice/rock 奖池 8 | 如 Fami 对用户开放自研游戏,先初始化机器人和奖池 |
|
||||
|
||||
### 18.2 运营内容缺口
|
||||
|
||||
| 项目 | Fami 当前状态 | Lalu 对照 |
|
||||
| --- | --- | --- |
|
||||
| 首页 Banner | 公网 API `items=0` | 相同请求返回 10 条 |
|
||||
| 弹窗 | 公网 API `items=0` | 1 条 |
|
||||
| 开屏 | 公网 API `items=0` | 1 条 |
|
||||
| 版本控制 | `version=""`、`build_number=0` | Android `1.0.9/109` |
|
||||
| H5 入口 | 只有 1 条 app config,缺 23 个 `h5-links` | 24 条 app config |
|
||||
| 第三方游戏目录 | 34 个 active | 37 个 active;Fami 少 `Rocket`、`TeenPattiPro`、`LavaSlot` |
|
||||
| 成长/成就展示资源 | 6 个头像框、25 个徽章档位和 32 个成就引用资源,但 Fami 无 active 资源 | Lalu 有 active 资源 |
|
||||
|
||||
第三方游戏的三个差异需产品确认,不能在没有授权和产品结论时自动复制。
|
||||
|
||||
### 18.3 条件项
|
||||
|
||||
- 币安账户映射当前只有 Lalu。
|
||||
- TRC20 USDT 收款地址当前只有 Lalu。
|
||||
- Fami 已有 16 个 active 充值商品和对齐的第三方支付方式;上述两项只在产品要求 Fami 开放对应渠道时才是阻断。
|
||||
|
||||
### 18.4 已就绪项
|
||||
|
||||
- Fami App 注册状态为 active。
|
||||
- Firebase 项目已配置。
|
||||
- 16 个 cron 任务均已启用且 `app_codes` 包含 `lalu/fami/huwaa`。
|
||||
- Fami 和 Lalu 均有 141 个 enabled 国家,启用国家无缺口。
|
||||
- Firebase、登录风控、CP 关系配置已建立。
|
||||
- 红包、房间火箭、邀请奖励、注册金币奖励、房间流水奖励已开启。
|
||||
- 财富/魅力/游戏成长等级的规则数量已与 Lalu 对齐,但展示资源仍受资源体系缺失阻断。
|
||||
- room、wallet、user、game、statistics 的共享 MQ/outbox 主开关已开启。
|
||||
|
||||
### 18.5 Fami 建议收敛顺序
|
||||
|
||||
1. 重建 Fami 礼物、价格、资源、资源组和商城,修复成长/成就展示引用。
|
||||
2. 修复 RTC callback 的 App 归属,发布 activity 多 App 播报 claim/建群逻辑。
|
||||
3. 在 `lucky-gift-service` owner 库配置 Fami 规则和档位,完成独立 MQ consumer 验证。
|
||||
4. 完成 VIP 价格/权益/资源后启用 VIP1–VIP9。
|
||||
5. 按产品范围启用首充、累充、七日签到、CP 周榜和周星。
|
||||
6. 补齐 Banner、弹窗、开屏、版本和 H5 入口。
|
||||
7. 确认 Fami 游戏清单;如开放自研游戏,初始化机器人和档位奖池。
|
||||
8. 执行第 17 节的真实设备、业务、MQ、数据与统计验收,确认 P0 为 0 后再标记可正式运营。
|
||||
|
||||
### 18.6 首批修复记录(2026-07-13)
|
||||
|
||||
本次只收敛 RTC/IM 业务租户隔离和幸运礼物初始化,不代表第 18.1 节其他 P0 已解除。
|
||||
|
||||
| 项目 | 修复结果 | 验证证据 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| RTC callback | 共享腾讯 RTC 应用不再从 URL 固定读取 `app_code=lalu`;gateway 验签后按 `room_id` 调 room-service 解析真实房间租户,再提交对应 Room Cell | 两台 gateway 的 callback URL 均已改为无查询参数地址;`rooms.room_id` 无重复并已增加唯一索引;gateway/room 双节点运行镜像 `20260713-fami-app-scope-1` 且 healthy | `CONFIGURED`;仍需 Fami/Lalu 各一次真实设备上麦、退流回调后升为 `VERIFIED` |
|
||||
| IM 播报 worker | 同一腾讯 IM SDKAppID 下,群 ID、outbox claim、发送、mark 和 reconcile 均按记录 `app_code` 隔离;`broadcast.app_codes` 显式配置 `lalu/fami/huwaa` | 腾讯 IM 云端已存在 `hy_fami_bc_g_v2` 及区域 24–30 的 7 个 `hy_fami_bc_r_v2_*` 群;activity 双节点运行镜像 `20260713-fami-app-scope-1` 且无 reconcile 错误 | `VERIFIED`(建群与运行态);仍需客户端真实收一条区域/全局播报完成产品验收 |
|
||||
| 幸运礼物规则 | 从 Lalu 复制所有规则版本和阶段档位到 Fami,只变更租户键 | Fami `lucky` 为 6 个规则版本/106 个档位;`super_lucky` 为 9 个规则版本/149 个档位,enabled 数量与 Lalu 一致 | `VERIFIED`(配置数据) |
|
||||
| 幸运礼物奖池 | 未复制 Lalu 奖池状态;Fami 的 `lucky`、`super_lucky` 各自独立初始化 | 两个池均为 `balance=100000`、`reserve_floor=21000`、`total_in=0`、`total_out=0`;Fami RTP 窗口、用户状态、抽奖记录和 outbox 均为 0 | `VERIFIED`(初始化数据);仍需真实普通奖/大奖/钱包/outbox 冒烟 |
|
||||
|
||||
发布前已执行 `make proto`、`make test` 和 `docker compose config`;发布后 6 个实例均 healthy、restart count 为 0,相关 CLB 后端权重均恢复为 100。
|
||||
@ -1,6 +1,6 @@
|
||||
# Voice Room Region Room List Architecture
|
||||
|
||||
本文档定义 App 房间列表的区域化架构。目标是让同一区域内的国家看到同一套房间列表,不同区域看到不同列表,同时不破坏现有 `Room Cell` 单房间状态 owner 模型。
|
||||
本文档定义 App 房间列表的区域化架构。默认 App 仍按用户区域隔离;Huwaa 首页默认读取全部区域,并允许客户端使用服务端目录下发的 active `region_id` 或 enabled `country_code` 做单一筛选。这些列表范围不改变现有 `Room Cell` 单房间状态 owner 模型。
|
||||
|
||||
本文的区域隔离始终发生在单个 `app_code` 内。不同 App 即使使用相同国家、区域和 `room_id`,房间列表也必须互相不可见。
|
||||
|
||||
@ -13,8 +13,8 @@
|
||||
| App 国家/区域解析 | `user-service` | App 注册、改国家和用户投影只读 enabled 国家与 active 区域映射 |
|
||||
| 用户区域归属 | `user-service` | 注册和改国家后写入 `users.region_id` |
|
||||
| 房间可见区域 | `room-service` | 房间创建时绑定 `visible_region_id` |
|
||||
| 房间列表读模型 | `room-service` | 按 `visible_region_id` 查询列表卡片 |
|
||||
| HTTP 列表入口 | `gateway-service` | 鉴权后解析用户区域并调用 room-service |
|
||||
| 房间列表读模型 | `room-service` | 按默认区域、All、指定区域或指定国家查询列表卡片 |
|
||||
| HTTP 列表入口 | `gateway-service` | 鉴权后解析 App 策略,并用 active 区域目录校验客户筛选 |
|
||||
| 实时进房校验 | `room-service` | 列表只负责发现,进入房间仍以 `JoinRoom` 为准 |
|
||||
|
||||
本阶段不做:
|
||||
@ -22,15 +22,15 @@
|
||||
| Excluded | Reason |
|
||||
| --- | --- |
|
||||
| 推荐系统 | 首版先实现区域隔离和稳定排序,不做个性化推荐 |
|
||||
| 跨区域混排 | 产品语义要求不同区域列表隔离 |
|
||||
| 客户端提交 `region_id` | 区域必须由服务端按用户国家计算,避免伪造 |
|
||||
| 非 Huwaa App 客户端提交 `region_id` | 这些 App 仍按用户国家投影的服务端区域隔离 |
|
||||
| Huwaa 客户端提交任意区域/国家值 | 只接受 `/api/v1/rooms/filters` 中 active 区域和 enabled 国家的字段值 |
|
||||
| IP 国家决定房间列表 | `country_by_ip` 只用于审计和风控辅助,不代表用户选择国家 |
|
||||
| 把列表塞进 Room Cell | Room Cell 是单房间高频状态 owner,不承担多房间列表查询 |
|
||||
| 创建者改国家自动迁移房间 | 房间可见区域是创建时确定的业务属性,避免直播中列表突然漂移 |
|
||||
|
||||
## Core Rule
|
||||
|
||||
房间列表按 `region_id` 隔离,而不是按 `country` 隔离。
|
||||
非 Huwaa 房间列表默认按 `region_id` 隔离,而不是按 `country` 隔离。Huwaa All 会合并所有区域;普通房在置顶之后固定排成“本国家有人 → 其他国家/区域有人 → 本国家没人 → 其他国家/区域没人”。
|
||||
|
||||
例如:
|
||||
|
||||
@ -89,10 +89,10 @@ sequenceDiagram
|
||||
participant R as room-service
|
||||
participant S as MySQL/Redis
|
||||
|
||||
C->>G: GET /api/v1/rooms?tab=hot&limit=20
|
||||
C->>G: GET /api/v1/rooms?tab=hot&limit=20[®ion_id|country_code]
|
||||
G->>U: GetUser(user_id)
|
||||
U-->>G: region_id
|
||||
G->>R: ListRooms(region_id, tab, cursor, limit)
|
||||
G->>R: ListRooms(viewer_region_id, all/filter_region_id/country_code, tab, cursor, limit)
|
||||
R->>S: Read room_list_entries + active room_region_pins
|
||||
S-->>R: RoomListItem[]
|
||||
R-->>G: ListRoomsResponse
|
||||
@ -107,11 +107,12 @@ gateway -> room-service.JoinRoom -> Tencent IM join group callback guard
|
||||
|
||||
因此列表允许秒级最终一致;真正能否进入房间由 `JoinRoom` 和 IM 守卫决定。
|
||||
|
||||
区域置顶只影响当前 `visible_region_id` 的公共发现列表排序。`room_region_pins` 保存 `app_code + visible_region_id + room_id` 的运营置顶关系;查询时只 join `status=active` 且 `expires_at_ms > now_ms` 的记录,排序优先级为:
|
||||
默认/指定区域列表只应用全区置顶、该 `visible_region_id` 的区域置顶和已选国家置顶。Huwaa All 会合并所有区域,但每个区域/国家置顶仍只能命中房间自身 `visible_region_id` 和房主国家,不会把某区置顶扩散到其他区域。`room_region_pins` 查询只使用 `status=active` 且 `expires_at_ms > now_ms` 的记录,排序优先级为:
|
||||
|
||||
1. 有效区域置顶在普通房间之前。
|
||||
2. 置顶房间按 `weight DESC, expires_at_ms DESC, room_id ASC`。
|
||||
3. 普通房间继续按 `hot` 的 `sort_score DESC` 或 `new` 的 `created_at_ms DESC`。
|
||||
1. 同一房间同时命中多种置顶时固定选 `global > region > country`,次级类型不能用更高 weight 反超。
|
||||
2. 有效全区/区域置顶在国家置顶和普通房间之前。
|
||||
3. 选定置顶类型后,房间按 `weight DESC, expires_at_ms DESC, room_id ASC`。
|
||||
4. 普通房间继续按本国家/在线桶及 `hot` 的 `sort_score DESC` 或 `new` 的 `created_at_ms DESC`。
|
||||
|
||||
置顶不进入 Room Cell、command log 或 snapshot;它是房间列表读模型的运营控制面。房间改区域后,旧区域置顶不会自动跨区域生效,后台需要在新区域重新建立置顶。
|
||||
|
||||
@ -287,6 +288,8 @@ sort_score = heat * 1000 + online_count * 100 + occupied_seat_count * 10 + fresh
|
||||
```text
|
||||
GET /api/v1/rooms?tab=hot&cursor=&limit=20
|
||||
GET /api/v1/rooms?tab=new&cursor=&limit=20
|
||||
GET /api/v1/rooms?tab=hot®ion_id=2002
|
||||
GET /api/v1/rooms?tab=hot&country_code=BD
|
||||
```
|
||||
|
||||
响应继续使用 gateway envelope:
|
||||
@ -322,6 +325,8 @@ GET /api/v1/rooms?tab=new&cursor=&limit=20
|
||||
- `limit` 默认 `20`,最大 `50`。
|
||||
- `cursor` 使用不透明字符串,客户端不能解析或拼装。
|
||||
- gateway 从 access token 得到 `user_id`,再查 user-service 获取 `region_id`。
|
||||
- Huwaa 不传 `region_id/country_code` 表示 All;两者互斥,且只能回传 `/api/v1/rooms/filters` 的字段值。
|
||||
- 显式 `region_id` 写入不透明 cursor,不允许把 A 区游标复用到 B 区。
|
||||
- 如果 user-service 查询失败,gateway 应返回错误,不应该用 IP 国家临时兜底。
|
||||
- `im_group_id` 当前等于 `room_id`,只用于客户端准备腾讯 IM 房间群;真正入群必须等 `JoinRoom` 成功后由 IM 回调守卫校验 presence。
|
||||
|
||||
@ -341,12 +346,16 @@ message ListRoomsRequest {
|
||||
string tab = 4;
|
||||
string cursor = 5;
|
||||
int32 limit = 6;
|
||||
string query = 7;
|
||||
string country_code = 8;
|
||||
string viewer_country_code = 9;
|
||||
bool all_visible_regions = 10;
|
||||
int64 filter_region_id = 11;
|
||||
}
|
||||
|
||||
message RoomListItem {
|
||||
string room_id = 1;
|
||||
int64 owner_user_id = 2;
|
||||
int64 host_user_id = 3;
|
||||
string title = 4;
|
||||
string cover_url = 5;
|
||||
string mode = 6;
|
||||
@ -359,6 +368,7 @@ message RoomListItem {
|
||||
string app_code = 13;
|
||||
string room_short_id = 14;
|
||||
bool locked = 15;
|
||||
string country_code = 16;
|
||||
}
|
||||
|
||||
message ListRoomsResponse {
|
||||
@ -371,7 +381,7 @@ message ListRoomsResponse {
|
||||
|
||||
- 新增 proto 字段只能追加,不能复用字段号。
|
||||
- `visible_region_id` 使用 `int64`,`0` 表示 `GLOBAL`。
|
||||
- 不要把 `country` 传给 room-service 列表接口;国家到区域的映射不属于 room-service。
|
||||
- 客户端原始 `country/country_code` 不能直传 room-service;gateway 先校验 enabled 国家与 active 区域映射,再传规范化的 `country_code` 给列表读模型。
|
||||
- `app_code` 只从 `RequestMeta` 来;客户端不能在列表 query/body 里自报另一个 App。
|
||||
|
||||
## Write Path Updates
|
||||
@ -409,8 +419,8 @@ message ListRoomsResponse {
|
||||
|
||||
不允许:
|
||||
|
||||
- 用户看到其他区域专属列表。
|
||||
- 客户端通过伪造 `region_id` 越权获取列表。
|
||||
- 非 Huwaa/无 All 权限的用户看到其他区域专属列表。
|
||||
- Huwaa 客户端传入不在 `/api/v1/rooms/filters` active 目录中的 `region_id`,或同时传 `region_id/country_code`。
|
||||
- 列表查询直接访问 Room Cell 全量内存。
|
||||
- Redis 成为唯一数据源。
|
||||
|
||||
|
||||
94
docs/送礼连击V2协议.md
Normal file
94
docs/送礼连击V2协议.md
Normal file
@ -0,0 +1,94 @@
|
||||
# 送礼连击 V2 协议
|
||||
|
||||
## 边界
|
||||
|
||||
V2 只改变客户端封批、可靠重试和结算结果重放,不改变房间状态 owner:
|
||||
|
||||
- Flutter 立即播放连击反馈,第一批立即请求,后续点击按远程窗口聚合。
|
||||
- HTTP 仍是账务入口;`room-service` 的 Room Cell 串行提交房间状态。
|
||||
- `wallet-service` 负责扣费,`lucky-gift-service` 负责逐份独立抽奖,`room-service` 只编排持久化 Saga。
|
||||
- V1 路由和顶层响应字段只为已发布的旧客户端保留;当前 Flutter 固定调用 V2,不做 V1 降级。
|
||||
|
||||
## HTTP 契约
|
||||
|
||||
新客户端调用:
|
||||
|
||||
```text
|
||||
POST /api/v2/rooms/gift/send
|
||||
```
|
||||
|
||||
请求沿用 V1 字段,并要求:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room-id",
|
||||
"command_id": "stable-combo-session_b1",
|
||||
"combo_session_id": "stable-combo-session",
|
||||
"batch_seq": 1,
|
||||
"target_type": "user",
|
||||
"target_user_ids": [1001, 1002],
|
||||
"gift_id": "gift-id",
|
||||
"gift_count": 20,
|
||||
"pool_id": "optional-lucky-pool"
|
||||
}
|
||||
```
|
||||
|
||||
`command_id` 是唯一财务幂等键。超时、429 和临时 5xx 必须复用同一请求体与同一 `command_id`;`combo_session_id + batch_seq` 只用于客户端批次顺序和审计。当前 Flutter 不在 404 或旧响应下改调 V1,发布前必须先保证 V2 路由可用。
|
||||
|
||||
响应继续返回 V1 顶层的 `billing_receipt_id`、`coin_balance_after`、`lucky_gift(s)`、`batch_display` 等字段,同时增加 `v2`:
|
||||
|
||||
```json
|
||||
{
|
||||
"billing_receipt_id": "legacy-compatible-receipt",
|
||||
"coin_balance_after": 9000,
|
||||
"v2": {
|
||||
"api_version": 2,
|
||||
"command_id": "stable-combo-session_b1",
|
||||
"combo_session_id": "stable-combo-session",
|
||||
"batch_seq": 1,
|
||||
"idempotent_replay": false,
|
||||
"committed_room_version": 123,
|
||||
"billing_receipt_id": "legacy-compatible-receipt",
|
||||
"coin_balance_after": 9000,
|
||||
"balance_version": 456,
|
||||
"settled_gift_count": 20,
|
||||
"settled_target_user_ids": [1001, 1002],
|
||||
"lucky_hits": [],
|
||||
"operation_status": "committed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
相同 `command_id` 的重试返回首次提交的同一账务回执、余额版本、实际目标、幸运结果和批次展示,仅把 `idempotent_replay` 置为 `true`。历史 V1 command log 没有 typed 结果时保持原有 snapshot-only 行为,不伪造结算结果。
|
||||
|
||||
## 客户端封批规则
|
||||
|
||||
礼物面板响应的 `combo_config` 控制客户端策略:
|
||||
|
||||
- `enabled`:紧急关闭微批;关闭后仍调用 V2,只是不再聚合连续点击。
|
||||
- `api_version`:服务端契约版本标记,当前 Flutter 只接受 V2,不用它选择 V1 入口。
|
||||
- `window_ms`:后续点击固定封批窗口,默认 300ms。
|
||||
- `max_gift_count_per_batch`:单目标数量上限,最大 999。
|
||||
- `max_gift_units_per_batch`:`gift_count * 去重收礼人数` 上限,最大 5000。
|
||||
- `max_in_flight`:最多 2 个 HTTP 批次在途。
|
||||
- `max_pending_batches`:本地有界队列上限。
|
||||
- `retry_max_attempts`、`retry_base_delay_ms`:可靠重试上限和指数退避基数;429 优先遵守 `Retry-After`。
|
||||
- `rollout_basis_points`:按 `app_code + user_id` 稳定分桶,0 到 10000;只灰度微批策略,不改变 V2 HTTP 入口。
|
||||
|
||||
后台通过以下 app-scoped 接口读取和全量替换配置:
|
||||
|
||||
```text
|
||||
GET /admin/app-config/gift-combo
|
||||
PUT /admin/app-config/gift-combo
|
||||
```
|
||||
|
||||
服务端在 gateway、room、wallet、lucky 四层都强制校验 `gift_count <= 999`、总 gift units `<= 5000` 和幂等键长度。远程配置只能收紧客户端行为,不能提高 owner 上限。
|
||||
|
||||
## 上线顺序
|
||||
|
||||
1. 先执行 `services/room-service/deploy/mysql/migrations/002_gift_operation_v2.sql`,再滚动发布 protobuf、wallet、lucky、room、gateway,确认 V2 可用后发布当前 Flutter;已发布的旧 Flutter 继续走服务端 V1。
|
||||
2. 首次上线把 `rollout_basis_points` 设为 0,验证 V2 单批发送、重复 `command_id` 重放和 Gift Saga 恢复指标;该值不会让当前 Flutter 回退 V1。
|
||||
3. 按 App 从小流量提高微批灰度;异常时把 `enabled=false` 或灰度归零,客户端仍使用 V2 单批发送。
|
||||
4. MQ 先保持 `wallet_event_tag_mode=legacy`、`room_event_tag_mode=legacy`。确认所有兼容 consumer 已上线并排空存量后,再分别切为 `event_type`;不要在同一批发布中同时切 producer 和删除 legacy selector。
|
||||
|
||||
数据库迁移、服务发布、Flutter 微批灰度和 MQ Tag 切换是四个独立开关。任何一步都不能用“客户端已限流”替代 owner 幂等、Saga 恢复或 Broker 侧过滤。
|
||||
68
pkg/apptracking/limits.go
Normal file
68
pkg/apptracking/limits.go
Normal file
@ -0,0 +1,68 @@
|
||||
// Package apptracking defines the transport and persistence limits shared by the
|
||||
// public App tracking endpoint and statistics-service.
|
||||
package apptracking
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxBatchSize keeps one request bounded before gateway fans it into the
|
||||
// statistics write transaction.
|
||||
MaxBatchSize = 50
|
||||
// MaxPropertiesBytes bounds the raw JSON value after surrounding whitespace
|
||||
// is removed. The value is rejected instead of truncated because truncation
|
||||
// can change JSON meaning or produce invalid JSON.
|
||||
MaxPropertiesBytes = 4096
|
||||
|
||||
// Text limits are byte limits, not rune limits. They mirror the durable
|
||||
// app_tracking_events columns and therefore apply identically at both service
|
||||
// boundaries; an oversized client value must never be deferred to MySQL.
|
||||
MaxAppCodeBytes = 32
|
||||
MaxEventIDBytes = 160
|
||||
MaxEventNameBytes = 96
|
||||
MaxEventTypeBytes = 64
|
||||
MaxScreenBytes = 128
|
||||
MaxTargetTypeBytes = 64
|
||||
MaxTargetIDBytes = 128
|
||||
MaxDeviceIDBytes = 128
|
||||
MaxSessionIDBytes = 160
|
||||
MaxPlatformBytes = 32
|
||||
MaxAppVersionBytes = 64
|
||||
MaxLanguageBytes = 32
|
||||
MaxTimezoneBytes = 64
|
||||
MaxErrorCodeBytes = 64
|
||||
)
|
||||
|
||||
// ValidBatchSize rejects both empty reports and reports large enough to hold a
|
||||
// statistics transaction open for an unbounded period.
|
||||
func ValidBatchSize(size int) bool {
|
||||
return size > 0 && size <= MaxBatchSize
|
||||
}
|
||||
|
||||
// NormalizeText removes transport-only surrounding whitespace and checks the
|
||||
// encoded byte length. Oversized values return false and are never shortened:
|
||||
// identifiers and error codes are facts whose suffix may be significant.
|
||||
func NormalizeText(value string, maxBytes int) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if maxBytes > 0 && len(value) > maxBytes {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// NormalizeProperties returns a detached, trimmed JSON value. Empty and JSON
|
||||
// null both mean no properties; non-empty values must satisfy the same byte and
|
||||
// syntax contract in gateway and statistics-service.
|
||||
func NormalizeProperties(raw json.RawMessage) (json.RawMessage, bool) {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return nil, true
|
||||
}
|
||||
if len(trimmed) > MaxPropertiesBytes || !json.Valid(trimmed) {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(bytes.Clone(trimmed)), true
|
||||
}
|
||||
36
pkg/apptracking/limits_test.go
Normal file
36
pkg/apptracking/limits_test.go
Normal file
@ -0,0 +1,36 @@
|
||||
package apptracking
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeTextUsesBytesAndNeverTruncates(t *testing.T) {
|
||||
if got, ok := NormalizeText(" "+strings.Repeat("e", MaxErrorCodeBytes)+" ", MaxErrorCodeBytes); !ok || got != strings.Repeat("e", MaxErrorCodeBytes) {
|
||||
t.Fatalf("text at byte limit must be trimmed but otherwise preserved: ok=%v got=%q", ok, got)
|
||||
}
|
||||
if got, ok := NormalizeText(strings.Repeat("e", 185), MaxErrorCodeBytes); ok || got != "" {
|
||||
t.Fatalf("oversized error_code must be rejected, never truncated: ok=%v got=%q", ok, got)
|
||||
}
|
||||
// 一个汉字编码为三个 UTF-8 bytes;这里防止调用方误把字段限制实现成 rune 数。
|
||||
if got, ok := NormalizeText(strings.Repeat("界", 22), MaxErrorCodeBytes); ok || got != "" {
|
||||
t.Fatalf("text limits must count UTF-8 bytes: ok=%v got=%q", ok, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePropertiesAndBatchContract(t *testing.T) {
|
||||
if !ValidBatchSize(1) || !ValidBatchSize(MaxBatchSize) || ValidBatchSize(0) || ValidBatchSize(MaxBatchSize+1) {
|
||||
t.Fatalf("batch bounds do not match the shared contract")
|
||||
}
|
||||
got, ok := NormalizeProperties(json.RawMessage(` {"screen":"home"} `))
|
||||
if !ok || string(got) != `{"screen":"home"}` {
|
||||
t.Fatalf("valid properties mismatch: ok=%v got=%s", ok, got)
|
||||
}
|
||||
if got, ok := NormalizeProperties(json.RawMessage(`"` + strings.Repeat("x", MaxPropertiesBytes) + `"`)); ok || got != nil {
|
||||
t.Fatalf("properties over the encoded byte limit must be rejected: ok=%v bytes=%d", ok, len(got))
|
||||
}
|
||||
if got, ok := NormalizeProperties(json.RawMessage(`{"broken"`)); ok || got != nil {
|
||||
t.Fatalf("invalid properties JSON must be rejected: ok=%v got=%s", ok, got)
|
||||
}
|
||||
}
|
||||
32
pkg/giftlimits/limits.go
Normal file
32
pkg/giftlimits/limits.go
Normal file
@ -0,0 +1,32 @@
|
||||
// Package giftlimits defines the owner-enforced ceiling for one gift command.
|
||||
// Runtime rate limits may be stricter, but no transport or service is allowed to raise these values
|
||||
// independently because gift_count fan-out reaches wallet, lucky draw, Room Cell and outbox storage.
|
||||
package giftlimits
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
MaxCommandIDBytes = 128
|
||||
MaxGiftCount = int64(999)
|
||||
MaxTotalGiftUnits = int64(5_000)
|
||||
)
|
||||
|
||||
// ValidCommandID bounds the financial idempotency key before it reaches Redis/MySQL key material.
|
||||
func ValidCommandID(commandID string) bool {
|
||||
commandID = strings.TrimSpace(commandID)
|
||||
return commandID != "" && len(commandID) <= MaxCommandIDBytes
|
||||
}
|
||||
|
||||
// WorkUnits calculates gift_count*unique_target_count without overflow and applies the cross-service
|
||||
// hard ceiling. Callers must pass a deduplicated target count; wallet additionally rejects duplicates.
|
||||
func WorkUnits(giftCount int64, uniqueTargetCount int) (int64, bool) {
|
||||
if giftCount <= 0 || giftCount > MaxGiftCount || uniqueTargetCount <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
targetCount := int64(uniqueTargetCount)
|
||||
if targetCount > MaxTotalGiftUnits/giftCount {
|
||||
return 0, false
|
||||
}
|
||||
units := giftCount * targetCount
|
||||
return units, units <= MaxTotalGiftUnits
|
||||
}
|
||||
29
pkg/giftlimits/limits_test.go
Normal file
29
pkg/giftlimits/limits_test.go
Normal file
@ -0,0 +1,29 @@
|
||||
package giftlimits
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkUnitsAppliesCountAndFanoutCeilings(t *testing.T) {
|
||||
if units, ok := WorkUnits(999, 5); !ok || units != 4_995 {
|
||||
t.Fatalf("valid maximum-near workload mismatch: units=%d ok=%t", units, ok)
|
||||
}
|
||||
for _, input := range []struct {
|
||||
count int64
|
||||
targets int
|
||||
}{{0, 1}, {1_000, 1}, {999, 6}, {1, 0}} {
|
||||
if units, ok := WorkUnits(input.count, input.targets); ok || units != 0 {
|
||||
t.Fatalf("invalid workload accepted: %+v units=%d", input, units)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidCommandIDUsesTrimmedNonEmptyByteBound(t *testing.T) {
|
||||
if !ValidCommandID(" combo-1:42 ") {
|
||||
t.Fatal("normal stable combo command id was rejected")
|
||||
}
|
||||
if ValidCommandID(" ") || ValidCommandID(strings.Repeat("x", MaxCommandIDBytes+1)) {
|
||||
t.Fatal("empty or oversized command id was accepted")
|
||||
}
|
||||
}
|
||||
143
pkg/luckygiftmq/messages.go
Normal file
143
pkg/luckygiftmq/messages.go
Normal file
@ -0,0 +1,143 @@
|
||||
// Package luckygiftmq defines the RocketMQ wire contract owned by lucky-gift-service.
|
||||
package luckygiftmq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeLuckyGiftOutboxEvent = "lucky_gift_outbox_event"
|
||||
EventTypeLuckyGiftDrawn = "LuckyGiftDrawn"
|
||||
TagLuckyGiftDrawn = EventTypeLuckyGiftDrawn
|
||||
|
||||
// LuckyGiftDisplayMinMultiplierPPM keeps room and regional top-banner policy on the same exact 10x boundary.
|
||||
// The owner still publishes every granted positive reward; consumers apply this presentation threshold independently.
|
||||
LuckyGiftDisplayMinMultiplierPPM int64 = 10_000_000
|
||||
)
|
||||
|
||||
// LuckyGiftOutboxMessage is the inspectable JSON wrapper around one protobuf owner envelope.
|
||||
// Metadata is duplicated outside envelope only for RocketMQ diagnostics and routing; the protobuf envelope remains authoritative.
|
||||
type LuckyGiftOutboxMessage struct {
|
||||
MessageType string `json:"message_type"`
|
||||
AppCode string `json:"app_code"`
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
DrawID string `json:"draw_id"`
|
||||
OccurredAtMS int64 `json:"occurred_at_ms"`
|
||||
Envelope []byte `json:"envelope"`
|
||||
}
|
||||
|
||||
// EventTypeTag validates an owner-controlled event type before it becomes a broker-side typed Tag.
|
||||
// Only registered fact names are accepted so malformed outbox data cannot publish to an unconsumed routing key.
|
||||
func EventTypeTag(eventType string) (string, error) {
|
||||
switch tag := strings.TrimSpace(eventType); tag {
|
||||
case EventTypeLuckyGiftDrawn:
|
||||
return tag, nil
|
||||
case "":
|
||||
return "", errors.New("lucky gift event_type tag is required")
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported lucky gift event_type tag %q", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
// EncodeLuckyGiftOutboxMessage serializes the protobuf envelope without converting int64 business identifiers through JSON numbers.
|
||||
func EncodeLuckyGiftOutboxMessage(envelope *luckygifteventsv1.EventEnvelope) ([]byte, error) {
|
||||
if err := validateLuckyGiftEnvelope(envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
envelopeBytes, err := proto.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(LuckyGiftOutboxMessage{
|
||||
MessageType: MessageTypeLuckyGiftOutboxEvent,
|
||||
AppCode: envelope.GetAppCode(),
|
||||
EventID: envelope.GetEventId(),
|
||||
EventType: envelope.GetEventType(),
|
||||
DrawID: envelope.GetDrawId(),
|
||||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||
Envelope: envelopeBytes,
|
||||
})
|
||||
}
|
||||
|
||||
// DecodeLuckyGiftOutboxMessage restores and validates the owner envelope.
|
||||
// Wrapper/envelope equality is checked explicitly so operators never diagnose one set of visible metadata while consumers process another.
|
||||
func DecodeLuckyGiftOutboxMessage(body []byte) (*luckygifteventsv1.EventEnvelope, LuckyGiftOutboxMessage, error) {
|
||||
var message LuckyGiftOutboxMessage
|
||||
if err := json.Unmarshal(body, &message); err != nil {
|
||||
return nil, LuckyGiftOutboxMessage{}, err
|
||||
}
|
||||
if message.MessageType != MessageTypeLuckyGiftOutboxEvent {
|
||||
return nil, LuckyGiftOutboxMessage{}, errors.New("unexpected lucky gift outbox message_type")
|
||||
}
|
||||
if len(message.Envelope) == 0 {
|
||||
return nil, LuckyGiftOutboxMessage{}, errors.New("lucky gift outbox envelope is required")
|
||||
}
|
||||
var envelope luckygifteventsv1.EventEnvelope
|
||||
if err := proto.Unmarshal(message.Envelope, &envelope); err != nil {
|
||||
return nil, LuckyGiftOutboxMessage{}, err
|
||||
}
|
||||
if err := validateLuckyGiftEnvelope(&envelope); err != nil {
|
||||
return nil, LuckyGiftOutboxMessage{}, err
|
||||
}
|
||||
if message.AppCode != envelope.GetAppCode() ||
|
||||
message.EventID != envelope.GetEventId() ||
|
||||
message.EventType != envelope.GetEventType() ||
|
||||
message.DrawID != envelope.GetDrawId() ||
|
||||
message.OccurredAtMS != envelope.GetOccurredAtMs() {
|
||||
return nil, LuckyGiftOutboxMessage{}, errors.New("lucky gift outbox wrapper does not match envelope")
|
||||
}
|
||||
return &envelope, message, nil
|
||||
}
|
||||
|
||||
func validateLuckyGiftEnvelope(envelope *luckygifteventsv1.EventEnvelope) error {
|
||||
if envelope == nil {
|
||||
return errors.New("lucky gift outbox envelope is required")
|
||||
}
|
||||
if strings.TrimSpace(envelope.GetAppCode()) == "" ||
|
||||
strings.TrimSpace(envelope.GetEventId()) == "" ||
|
||||
strings.TrimSpace(envelope.GetDrawId()) == "" ||
|
||||
envelope.GetOccurredAtMs() <= 0 ||
|
||||
len(envelope.GetBody()) == 0 {
|
||||
return errors.New("lucky gift outbox envelope is incomplete")
|
||||
}
|
||||
// draw_id 是一次中奖事实的天然幂等键;固定 event_id 规则后,所有消费者都能用同一个键收敛重投,
|
||||
// 也避免某个 producer 临时生成随机 ID 导致 room/activity 各自重复派生外部飘屏。
|
||||
if envelope.GetEventId() != "lucky_gift_drawn:"+envelope.GetDrawId() {
|
||||
return errors.New("lucky gift outbox event_id does not match draw_id")
|
||||
}
|
||||
if _, err := EventTypeTag(envelope.GetEventType()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The current topic carries only granted winning facts. Validate the protobuf body here so poison data fails before broker publish
|
||||
// and is retried from the owner outbox instead of being acknowledged and rejected independently by every consumer.
|
||||
var drawn luckygifteventsv1.LuckyGiftDrawn
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &drawn); err != nil {
|
||||
return fmt.Errorf("decode lucky gift drawn body: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(drawn.GetDrawId()) == "" ||
|
||||
drawn.GetDrawId() != envelope.GetDrawId() ||
|
||||
strings.TrimSpace(drawn.GetCommandId()) == "" ||
|
||||
strings.TrimSpace(drawn.GetRoomId()) == "" ||
|
||||
strings.TrimSpace(drawn.GetGiftId()) == "" ||
|
||||
drawn.GetGiftCount() <= 0 ||
|
||||
drawn.GetSenderUserId() <= 0 ||
|
||||
drawn.GetTargetUserId() <= 0 ||
|
||||
drawn.GetCoinSpent() <= 0 ||
|
||||
drawn.GetMultiplierPpm() <= 0 ||
|
||||
drawn.GetEffectiveRewardCoins() <= 0 ||
|
||||
strings.ToLower(strings.TrimSpace(drawn.GetRewardStatus())) != "granted" ||
|
||||
drawn.GetDrawCreatedAtMs() <= 0 ||
|
||||
drawn.GetDrawCreatedAtMs() != envelope.GetOccurredAtMs() ||
|
||||
drawn.GetRewardGrantedAtMs() <= 0 {
|
||||
return errors.New("lucky gift drawn body is incomplete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
123
pkg/luckygiftmq/messages_test.go
Normal file
123
pkg/luckygiftmq/messages_test.go
Normal file
@ -0,0 +1,123 @@
|
||||
package luckygiftmq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
|
||||
)
|
||||
|
||||
func TestLuckyGiftOutboxMessageRoundTrip(t *testing.T) {
|
||||
envelope := validLuckyGiftEnvelope(t)
|
||||
body, err := EncodeLuckyGiftOutboxMessage(envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeLuckyGiftOutboxMessage failed: %v", err)
|
||||
}
|
||||
|
||||
decoded, message, err := DecodeLuckyGiftOutboxMessage(body)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeLuckyGiftOutboxMessage failed: %v", err)
|
||||
}
|
||||
if !proto.Equal(decoded, envelope) {
|
||||
t.Fatalf("decoded envelope mismatch: got=%v want=%v", decoded, envelope)
|
||||
}
|
||||
if message.MessageType != MessageTypeLuckyGiftOutboxEvent || message.EventType != EventTypeLuckyGiftDrawn || message.DrawID != "draw-1" {
|
||||
t.Fatalf("decoded wrapper metadata mismatch: %+v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuckyGiftOutboxMessageRejectsWrapperEnvelopeDrift(t *testing.T) {
|
||||
envelope := validLuckyGiftEnvelope(t)
|
||||
body, err := EncodeLuckyGiftOutboxMessage(envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeLuckyGiftOutboxMessage failed: %v", err)
|
||||
}
|
||||
var message LuckyGiftOutboxMessage
|
||||
if err := json.Unmarshal(body, &message); err != nil {
|
||||
t.Fatalf("decode wrapper: %v", err)
|
||||
}
|
||||
message.DrawID = "draw-other"
|
||||
tampered, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
t.Fatalf("encode tampered wrapper: %v", err)
|
||||
}
|
||||
if _, _, err := DecodeLuckyGiftOutboxMessage(tampered); err == nil {
|
||||
t.Fatal("wrapper/envelope drift must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuckyGiftOutboxMessageRejectsUnsettledFact(t *testing.T) {
|
||||
envelope := validLuckyGiftEnvelope(t)
|
||||
var drawn luckygifteventsv1.LuckyGiftDrawn
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &drawn); err != nil {
|
||||
t.Fatalf("decode lucky body: %v", err)
|
||||
}
|
||||
drawn.RewardStatus = "pending"
|
||||
body, err := proto.Marshal(&drawn)
|
||||
if err != nil {
|
||||
t.Fatalf("encode pending lucky body: %v", err)
|
||||
}
|
||||
envelope.Body = body
|
||||
if _, err := EncodeLuckyGiftOutboxMessage(envelope); err == nil {
|
||||
t.Fatal("pending reward must not be published as LuckyGiftDrawn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuckyGiftEventTypeTagAndDisplayBoundary(t *testing.T) {
|
||||
tag, err := EventTypeTag(EventTypeLuckyGiftDrawn)
|
||||
if err != nil || tag != TagLuckyGiftDrawn {
|
||||
t.Fatalf("EventTypeTag result: tag=%q err=%v", tag, err)
|
||||
}
|
||||
if _, err := EventTypeTag("LuckyGiftUnknown"); err == nil {
|
||||
t.Fatal("unsupported lucky gift event type must be rejected")
|
||||
}
|
||||
if LuckyGiftDisplayMinMultiplierPPM != 10_000_000 {
|
||||
t.Fatalf("display threshold=%d, want exact 10x ppm", LuckyGiftDisplayMinMultiplierPPM)
|
||||
}
|
||||
}
|
||||
|
||||
func validLuckyGiftEnvelope(t *testing.T) *luckygifteventsv1.EventEnvelope {
|
||||
t.Helper()
|
||||
const drawCreatedAtMS int64 = 1_783_940_398_274
|
||||
drawn := &luckygifteventsv1.LuckyGiftDrawn{
|
||||
DrawId: "draw-1",
|
||||
CommandId: "command-1",
|
||||
RoomId: "room-1",
|
||||
PoolId: "default",
|
||||
GiftId: "43",
|
||||
GiftCount: 1,
|
||||
SenderUserId: 123456,
|
||||
TargetUserId: 654321,
|
||||
SenderName: "Lucky User",
|
||||
SenderAvatar: "https://cdn.example/avatar.png",
|
||||
SenderDisplayUserId: "123456",
|
||||
SenderPrettyDisplayUserId: "888888",
|
||||
VisibleRegionId: 1,
|
||||
CountryId: 86,
|
||||
CoinSpent: 100,
|
||||
RuleVersion: 7,
|
||||
ExperiencePool: "normal",
|
||||
SelectedTierId: "normal_50x",
|
||||
MultiplierPpm: 50_000_000,
|
||||
BaseRewardCoins: 5_000,
|
||||
EffectiveRewardCoins: 5_000,
|
||||
HighMultiplier: true,
|
||||
RewardStatus: "granted",
|
||||
WalletTransactionId: "wallet-tx-1",
|
||||
DrawCreatedAtMs: drawCreatedAtMS,
|
||||
RewardGrantedAtMs: drawCreatedAtMS + 20,
|
||||
}
|
||||
body, err := proto.Marshal(drawn)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal lucky body: %v", err)
|
||||
}
|
||||
return &luckygifteventsv1.EventEnvelope{
|
||||
EventId: "lucky_gift_drawn:draw-1",
|
||||
EventType: EventTypeLuckyGiftDrawn,
|
||||
AppCode: "lalu",
|
||||
DrawId: "draw-1",
|
||||
OccurredAtMs: drawCreatedAtMS,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
163
pkg/outboxpartition/partitions.go
Normal file
163
pkg/outboxpartition/partitions.go
Normal file
@ -0,0 +1,163 @@
|
||||
// Package outboxpartition provides the shared tenant scheduling rules used by
|
||||
// owner-service outbox workers. It does not read or publish business facts;
|
||||
// repositories retain ownership of indexed claims and delivery state.
|
||||
package outboxpartition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
)
|
||||
|
||||
const DefaultDiscoveryRefreshInterval = 5 * time.Minute
|
||||
|
||||
// Partitions resolves the App partitions visible to one outbox lane and rotates
|
||||
// the first partition across claims. A configured allowlist is immutable for the
|
||||
// process lifetime; an empty allowlist falls back to low-frequency DB discovery.
|
||||
type Partitions struct {
|
||||
configured []string
|
||||
refreshInterval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cached []string
|
||||
hasCache bool
|
||||
nextRefresh time.Time
|
||||
cursor atomic.Uint64
|
||||
}
|
||||
|
||||
// New creates a partition scheduler. Operators can stage a backlog safely by
|
||||
// configuring only the Apps currently allowed to publish; an empty list keeps
|
||||
// automatic discovery as the compatibility default for existing deployments.
|
||||
func New(configured []string, refreshInterval time.Duration) *Partitions {
|
||||
if refreshInterval <= 0 {
|
||||
refreshInterval = DefaultDiscoveryRefreshInterval
|
||||
}
|
||||
return &Partitions{
|
||||
configured: Normalize(configured, false),
|
||||
refreshInterval: refreshInterval,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve returns configured Apps without touching MySQL. In discovery mode it
|
||||
// refreshes at most once per interval across all local worker goroutines. A
|
||||
// failed refresh returns the last successful list alongside the error so the
|
||||
// caller can log the stale condition without stopping already-known partitions.
|
||||
func (p *Partitions) Resolve(ctx context.Context, discover func(context.Context) ([]string, error)) ([]string, error) {
|
||||
if p == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if len(p.configured) > 0 {
|
||||
return append([]string(nil), p.configured...), nil
|
||||
}
|
||||
if discover == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
if p.hasCache && now.Before(p.nextRefresh) {
|
||||
return append([]string(nil), p.cached...), nil
|
||||
}
|
||||
|
||||
discovered, err := discover(ctx)
|
||||
if err != nil {
|
||||
// Move the next attempt out by the normal interval: a transient database
|
||||
// error must not turn every hot poll into a DISTINCT query stampede.
|
||||
p.nextRefresh = now.Add(p.refreshInterval)
|
||||
if p.hasCache {
|
||||
return append([]string(nil), p.cached...), err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
p.cached = Normalize(discovered, true)
|
||||
p.hasCache = true
|
||||
p.nextRefresh = now.Add(p.refreshInterval)
|
||||
return append([]string(nil), p.cached...), nil
|
||||
}
|
||||
|
||||
// Order rotates the first App atomically while preserving the remaining cyclic
|
||||
// order. Multiple worker goroutines therefore share one fair cursor instead of
|
||||
// independently preferring the first configured tenant.
|
||||
func (p *Partitions) Order(appCodes []string) []string {
|
||||
if len(appCodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if p == nil {
|
||||
return append([]string(nil), appCodes...)
|
||||
}
|
||||
start := int((p.cursor.Add(1) - 1) % uint64(len(appCodes)))
|
||||
ordered := make([]string, 0, len(appCodes))
|
||||
for offset := 0; offset < len(appCodes); offset++ {
|
||||
ordered = append(ordered, appCodes[(start+offset)%len(appCodes)])
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
// Normalize canonicalizes tenant keys, removes blanks and duplicates, and can
|
||||
// sort DB-discovered keys for deterministic rotation. Explicit configuration
|
||||
// preserves operator order so staged rollouts remain readable and predictable.
|
||||
func Normalize(values []string, sorted bool) []string {
|
||||
normalized := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
value = appcode.Normalize(value)
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
if sorted {
|
||||
sort.Strings(normalized)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// ClaimFair gives every App one bounded first-pass quota, then lets non-empty
|
||||
// partitions use capacity left by empty queues. Each callback remains App-scoped
|
||||
// and is expected to perform its own indexed SKIP LOCKED transaction.
|
||||
func ClaimFair[T any](ctx context.Context, orderedApps []string, limit int, claim func(context.Context, string, int) ([]T, error)) ([]T, error) {
|
||||
if limit <= 0 || len(orderedApps) == 0 || claim == nil {
|
||||
return nil, nil
|
||||
}
|
||||
quota := (limit + len(orderedApps) - 1) / len(orderedApps)
|
||||
claimed := make([]T, 0, limit)
|
||||
for pass := 0; pass < 2 && len(claimed) < limit; pass++ {
|
||||
for _, app := range orderedApps {
|
||||
remaining := limit - len(claimed)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
appLimit := remaining
|
||||
if pass == 0 && appLimit > quota {
|
||||
appLimit = quota
|
||||
}
|
||||
records, err := claim(ctx, app, appLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(records) > appLimit {
|
||||
// Silently truncating would strand already-claimed rows until lease
|
||||
// expiry. Treat repository limit violations as explicit worker errors.
|
||||
return nil, fmt.Errorf("outbox claim for app %q returned %d records above limit %d", app, len(records), appLimit)
|
||||
}
|
||||
claimed = append(claimed, records...)
|
||||
}
|
||||
if pass == 0 && len(claimed) == 0 {
|
||||
// A globally empty first pass must stop here; a second identical pass
|
||||
// only doubles indexed polling queries and cannot discover new work.
|
||||
break
|
||||
}
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
96
pkg/outboxpartition/partitions_test.go
Normal file
96
pkg/outboxpartition/partitions_test.go
Normal file
@ -0,0 +1,96 @@
|
||||
package outboxpartition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfiguredPartitionsNormalizeAndSkipDiscovery(t *testing.T) {
|
||||
partitions := New([]string{" LALU ", "fami", "HUWAA", "fami", ""}, time.Hour)
|
||||
discoverCalls := 0
|
||||
apps, err := partitions.Resolve(context.Background(), func(context.Context) ([]string, error) {
|
||||
discoverCalls++
|
||||
return nil, errors.New("must not run")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve configured partitions: %v", err)
|
||||
}
|
||||
if want := []string{"lalu", "fami", "huwaa"}; !reflect.DeepEqual(apps, want) {
|
||||
t.Fatalf("configured apps = %v, want %v", apps, want)
|
||||
}
|
||||
if discoverCalls != 0 {
|
||||
t.Fatalf("configured allowlist unexpectedly queried discovery %d times", discoverCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveredPartitionsAreCachedAndStaleListSurvivesRefreshFailure(t *testing.T) {
|
||||
partitions := New(nil, time.Hour)
|
||||
discoverCalls := 0
|
||||
discover := func(context.Context) ([]string, error) {
|
||||
discoverCalls++
|
||||
if discoverCalls == 1 {
|
||||
return []string{"huwaa", " LALU ", "fami"}, nil
|
||||
}
|
||||
return nil, errors.New("mysql unavailable")
|
||||
}
|
||||
first, err := partitions.Resolve(context.Background(), discover)
|
||||
if err != nil || !reflect.DeepEqual(first, []string{"fami", "huwaa", "lalu"}) {
|
||||
t.Fatalf("initial discovery apps=%v err=%v", first, err)
|
||||
}
|
||||
second, err := partitions.Resolve(context.Background(), discover)
|
||||
if err != nil || !reflect.DeepEqual(second, first) || discoverCalls != 1 {
|
||||
t.Fatalf("cached discovery apps=%v err=%v calls=%d", second, err, discoverCalls)
|
||||
}
|
||||
|
||||
// Force only the refresh boundary; production reaches this state after the
|
||||
// five-minute interval without exposing a public cache mutation API.
|
||||
partitions.mu.Lock()
|
||||
partitions.nextRefresh = time.Time{}
|
||||
partitions.mu.Unlock()
|
||||
stale, err := partitions.Resolve(context.Background(), discover)
|
||||
if err == nil || !reflect.DeepEqual(stale, first) {
|
||||
t.Fatalf("failed refresh must retain stale apps: apps=%v err=%v", stale, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimFairRotatesLaluFamiHuwaaAndSkipsEmptyQueue(t *testing.T) {
|
||||
partitions := New([]string{"lalu", "fami", "huwaa"}, time.Hour)
|
||||
queues := map[string][]string{
|
||||
"lalu": {"l1", "l2", "l3"},
|
||||
"fami": nil,
|
||||
"huwaa": {"h1", "h2", "h3"},
|
||||
}
|
||||
claim := func(_ context.Context, app string, limit int) ([]string, error) {
|
||||
queue := queues[app]
|
||||
if len(queue) < limit {
|
||||
limit = len(queue)
|
||||
}
|
||||
result := append([]string(nil), queue[:limit]...)
|
||||
queues[app] = queue[limit:]
|
||||
return result, nil
|
||||
}
|
||||
|
||||
apps, err := partitions.Resolve(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve configured apps: %v", err)
|
||||
}
|
||||
first, err := ClaimFair(context.Background(), partitions.Order(apps), 3, claim)
|
||||
if err != nil || !reflect.DeepEqual(first, []string{"l1", "h1", "l2"}) {
|
||||
t.Fatalf("first fair claim records=%v err=%v", first, err)
|
||||
}
|
||||
second, err := ClaimFair(context.Background(), partitions.Order(apps), 3, claim)
|
||||
if err != nil || !reflect.DeepEqual(second, []string{"h2", "l3", "h3"}) {
|
||||
t.Fatalf("rotated fair claim records=%v err=%v", second, err)
|
||||
}
|
||||
emptyCalls := 0
|
||||
empty, err := ClaimFair[string](context.Background(), partitions.Order(apps), 3, func(context.Context, string, int) ([]string, error) {
|
||||
emptyCalls++
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil || len(empty) != 0 || emptyCalls != 3 {
|
||||
t.Fatalf("empty queues records=%v err=%v calls=%d", empty, err, emptyCalls)
|
||||
}
|
||||
}
|
||||
88
pkg/outboxreplay/replay.go
Normal file
88
pkg/outboxreplay/replay.go
Normal file
@ -0,0 +1,88 @@
|
||||
// Package outboxreplay provides the process-level hard bound used by manual
|
||||
// owner-outbox repairs. Normal workers intentionally keep polling; an operator
|
||||
// replay must instead stop after an exact maximum even while the partition
|
||||
// remains non-empty.
|
||||
package outboxreplay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
)
|
||||
|
||||
const ExecuteConfirmation = "REPLAY_EXACT_APP_OUTBOX_BATCH"
|
||||
|
||||
// Scope is the immutable tenant and total-record bound for one process run.
|
||||
// Limit is not a worker batch size: Run enforces it across every claim/publish
|
||||
// iteration and returns once that many rows have been handled.
|
||||
type Scope struct {
|
||||
AppCode string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Result reports whether the selected App partition became empty before the
|
||||
// hard limit. Processed counts rows claimed by processOne, including a row that
|
||||
// was released as retryable after a publish failure.
|
||||
type Result struct {
|
||||
Processed int
|
||||
Drained bool
|
||||
}
|
||||
|
||||
// NewScope rejects an implicit tenant or an unbounded run before any database
|
||||
// or MQ client is opened. maxLimit is command-specific so a service may impose
|
||||
// a smaller operational envelope without weakening this shared contract.
|
||||
func NewScope(rawAppCode string, limit int, maxLimit int) (Scope, error) {
|
||||
if strings.TrimSpace(rawAppCode) == "" {
|
||||
// appcode.Normalize intentionally preserves a legacy empty=>lalu default
|
||||
// for request compatibility. An operator command must reject omission
|
||||
// before normalization or a missing flag would target a real tenant.
|
||||
return Scope{}, errors.New("app-code is required")
|
||||
}
|
||||
appCode := appcode.Normalize(rawAppCode)
|
||||
if maxLimit <= 0 {
|
||||
return Scope{}, errors.New("max limit must be positive")
|
||||
}
|
||||
if limit <= 0 || limit > maxLimit {
|
||||
return Scope{}, fmt.Errorf("limit must be between 1 and %d", maxLimit)
|
||||
}
|
||||
return Scope{AppCode: appCode, Limit: limit}, nil
|
||||
}
|
||||
|
||||
// Run calls processOne at most Scope.Limit times. processOne must use the
|
||||
// owner's ordinary indexed claim path with an effective batch size of one;
|
||||
// accepting a larger count here would let an implementation silently strand
|
||||
// already-claimed tail rows after this process reaches its advertised limit.
|
||||
func Run(ctx context.Context, scope Scope, processOne func(context.Context) (int, error)) (Result, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if strings.TrimSpace(scope.AppCode) == "" || scope.Limit <= 0 {
|
||||
return Result{}, errors.New("valid replay scope is required")
|
||||
}
|
||||
if processOne == nil {
|
||||
return Result{}, errors.New("processOne is required")
|
||||
}
|
||||
|
||||
result := Result{}
|
||||
for result.Processed < scope.Limit {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
processed, err := processOne(ctx)
|
||||
if processed < 0 || processed > 1 {
|
||||
return result, fmt.Errorf("single-record replay processed %d rows", processed)
|
||||
}
|
||||
result.Processed += processed
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if processed == 0 {
|
||||
result.Drained = true
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
81
pkg/outboxreplay/replay_test.go
Normal file
81
pkg/outboxreplay/replay_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package outboxreplay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewScopeNormalizesAndBoundsTenantReplay(t *testing.T) {
|
||||
scope, err := NewScope(" HUWAA ", 3, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope failed: %v", err)
|
||||
}
|
||||
if scope.AppCode != "huwaa" || scope.Limit != 3 {
|
||||
t.Fatalf("scope mismatch: %+v", scope)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
app string
|
||||
limit int
|
||||
max int
|
||||
}{
|
||||
{name: "missing app", limit: 1, max: 10},
|
||||
{name: "zero limit", app: "huwaa", max: 10},
|
||||
{name: "above maximum", app: "huwaa", limit: 11, max: 10},
|
||||
{name: "invalid maximum", app: "huwaa", limit: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := NewScope(test.app, test.limit, test.max); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsAtProcessWideLimit(t *testing.T) {
|
||||
calls := 0
|
||||
result, err := Run(context.Background(), Scope{AppCode: "huwaa", Limit: 3}, func(context.Context) (int, error) {
|
||||
calls++
|
||||
return 1, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run failed: %v", err)
|
||||
}
|
||||
if calls != 3 || result.Processed != 3 || result.Drained {
|
||||
t.Fatalf("hard-limit result mismatch: calls=%d result=%+v", calls, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsWhenPartitionDrains(t *testing.T) {
|
||||
calls := 0
|
||||
result, err := Run(context.Background(), Scope{AppCode: "huwaa", Limit: 5}, func(context.Context) (int, error) {
|
||||
calls++
|
||||
if calls == 3 {
|
||||
return 0, nil
|
||||
}
|
||||
return 1, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run failed: %v", err)
|
||||
}
|
||||
if calls != 3 || result.Processed != 2 || !result.Drained {
|
||||
t.Fatalf("drained result mismatch: calls=%d result=%+v", calls, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCountsClaimedFailureAndRejectsBatchClaims(t *testing.T) {
|
||||
publishErr := errors.New("publish failed")
|
||||
result, err := Run(context.Background(), Scope{AppCode: "huwaa", Limit: 5}, func(context.Context) (int, error) {
|
||||
return 1, publishErr
|
||||
})
|
||||
if !errors.Is(err, publishErr) || result.Processed != 1 {
|
||||
t.Fatalf("failed row result mismatch: result=%+v err=%v", result, err)
|
||||
}
|
||||
|
||||
if _, err := Run(context.Background(), Scope{AppCode: "huwaa", Limit: 5}, func(context.Context) (int, error) {
|
||||
return 2, nil
|
||||
}); err == nil {
|
||||
t.Fatal("expected multi-row claim rejection")
|
||||
}
|
||||
}
|
||||
@ -7,11 +7,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
|
||||
rocketmq "github.com/apache/rocketmq-client-go/v2"
|
||||
"github.com/apache/rocketmq-client-go/v2/consumer"
|
||||
"github.com/apache/rocketmq-client-go/v2/primitive"
|
||||
@ -178,6 +181,7 @@ func (p *Producer) SendSync(ctx context.Context, message Message) error {
|
||||
type Consumer struct {
|
||||
mu sync.Mutex
|
||||
client rocketmq.PushConsumer
|
||||
group string
|
||||
started bool
|
||||
}
|
||||
|
||||
@ -218,7 +222,7 @@ func NewConsumer(cfg ConsumerConfig) (*Consumer, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Consumer{client: client}, nil
|
||||
return &Consumer{client: client, group: group}, nil
|
||||
}
|
||||
|
||||
// Subscribe binds a topic/tag selector to a handler. Empty tag subscribes all.
|
||||
@ -242,7 +246,7 @@ func (c *Consumer) Subscribe(topic string, tag string, handler Handler) error {
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
err := handler(ctx, ConsumedMessage{
|
||||
consumed := ConsumedMessage{
|
||||
Topic: message.Topic,
|
||||
Tag: message.GetTags(),
|
||||
Keys: message.GetKeys(),
|
||||
@ -250,9 +254,23 @@ func (c *Consumer) Subscribe(topic string, tag string, handler Handler) error {
|
||||
Body: message.Body,
|
||||
Properties: message.GetProperties(),
|
||||
ReconsumeTimes: message.ReconsumeTimes,
|
||||
})
|
||||
if err != nil {
|
||||
return consumer.ConsumeRetryLater, err
|
||||
}
|
||||
if err := handler(ctx, consumed); err != nil {
|
||||
// rocketmq-client-go v2.1.2 在 callback error 非空时会把同一 pull batch 的 MessageExt
|
||||
// 整批交给日志格式化;其 Message.String 未持有 properties 锁,而并行消费 goroutine
|
||||
// 同时写 CONSUME_START_TIME 会触发 runtime fatal,进程无法通过 recover 自救。这里仅记录
|
||||
// 已复制出的标量身份,不传 Message、Body 或 Properties,避免日志再次进入不安全 String。
|
||||
logx.Error(ctx, "rocketmq_consume_handler_failed", err,
|
||||
slog.String("consumer_group", c.group),
|
||||
slog.String("topic", consumed.Topic),
|
||||
slog.String("tag", consumed.Tag),
|
||||
slog.String("keys", consumed.Keys),
|
||||
slog.String("msg_id", consumed.MsgID),
|
||||
slog.Int("reconsume_times", int(consumed.ReconsumeTimes)),
|
||||
)
|
||||
// ConsumeRetryLater 才是 SDK 的 send-back/retry 决策信号;callback error 仅驱动其
|
||||
// 危险的整批错误日志。返回 nil error 不会确认消息,仍保留原有重试与死信策略。
|
||||
return consumer.ConsumeRetryLater, nil
|
||||
}
|
||||
}
|
||||
return consumer.ConsumeSuccess, nil
|
||||
|
||||
94
pkg/rocketmqx/rocketmqx_test.go
Normal file
94
pkg/rocketmqx/rocketmqx_test.go
Normal file
@ -0,0 +1,94 @@
|
||||
package rocketmqx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
|
||||
"github.com/apache/rocketmq-client-go/v2/consumer"
|
||||
"github.com/apache/rocketmq-client-go/v2/primitive"
|
||||
)
|
||||
|
||||
type fakePushConsumer struct {
|
||||
callback func(context.Context, ...*primitive.MessageExt) (consumer.ConsumeResult, error)
|
||||
}
|
||||
|
||||
func (f *fakePushConsumer) Start() error { return nil }
|
||||
func (f *fakePushConsumer) Shutdown() error { return nil }
|
||||
func (f *fakePushConsumer) Subscribe(_ string, _ consumer.MessageSelector, callback func(context.Context, ...*primitive.MessageExt) (consumer.ConsumeResult, error)) error {
|
||||
f.callback = callback
|
||||
return nil
|
||||
}
|
||||
func (f *fakePushConsumer) Unsubscribe(string) error { return nil }
|
||||
func (f *fakePushConsumer) Suspend() {}
|
||||
func (f *fakePushConsumer) Resume() {}
|
||||
func (f *fakePushConsumer) GetOffsetDiffMap() map[string]int64 { return nil }
|
||||
|
||||
func TestSubscribeHandlerErrorReturnsRetryLaterWithoutSDKError(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
if err := logx.Init(logx.Config{Service: "rocketmqx-test", Env: "test", Format: "json", Output: &output}); err != nil {
|
||||
t.Fatalf("init test logger: %v", err)
|
||||
}
|
||||
|
||||
client := &fakePushConsumer{}
|
||||
consumerWrapper := &Consumer{client: client, group: "group-safe-log"}
|
||||
handlerErr := errors.New("projection unavailable")
|
||||
if err := consumerWrapper.Subscribe("topic-safe-log", "tag-safe-log", func(context.Context, ConsumedMessage) error {
|
||||
return handlerErr
|
||||
}); err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
if client.callback == nil {
|
||||
t.Fatal("subscribe callback was not captured")
|
||||
}
|
||||
|
||||
extended := &primitive.MessageExt{MsgId: "msg-safe-log", ReconsumeTimes: 3}
|
||||
// 直接初始化嵌入的 Message,避免复制其 RWMutex;Queue 仍故意保持 nil,确保日志路径
|
||||
// 一旦把 MessageExt 交给 String 格式化就会在测试中立即暴露。
|
||||
extended.Topic = "topic-safe-log"
|
||||
extended.Body = []byte("sensitive-body-must-not-be-logged")
|
||||
extended.WithTag("tag-safe-log")
|
||||
extended.WithKeys([]string{"event-safe-log"})
|
||||
extended.WithProperty("private-property", "secret-value-must-not-be-logged")
|
||||
// Queue 故意保持 nil:MessageExt.String 会解引用 Queue 并 panic。callback 能完成并写日志,
|
||||
// 因而同时锁定包装层只记录复制后的安全标量,绝不把 MessageExt 交给格式化器。
|
||||
result, callbackErr := client.callback(context.Background(), extended)
|
||||
if result != consumer.ConsumeRetryLater {
|
||||
t.Fatalf("consume result = %v, want ConsumeRetryLater", result)
|
||||
}
|
||||
if callbackErr != nil {
|
||||
t.Fatalf("callback error = %v, want nil so SDK does not format the shared message batch", callbackErr)
|
||||
}
|
||||
|
||||
rawLog := strings.TrimSpace(output.String())
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal([]byte(rawLog), &record); err != nil {
|
||||
t.Fatalf("decode safe consume log %q: %v", rawLog, err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"msg": "rocketmq_consume_handler_failed",
|
||||
"consumer_group": "group-safe-log",
|
||||
"topic": "topic-safe-log",
|
||||
"tag": "tag-safe-log",
|
||||
"keys": "event-safe-log",
|
||||
"msg_id": "msg-safe-log",
|
||||
"error": handlerErr.Error(),
|
||||
} {
|
||||
if got := record[key]; got != want {
|
||||
t.Errorf("log field %s = %#v, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
if got := record["reconsume_times"]; got != float64(3) {
|
||||
t.Errorf("log field reconsume_times = %#v, want 3", got)
|
||||
}
|
||||
for _, forbidden := range []string{"sensitive-body-must-not-be-logged", "private-property", "secret-value-must-not-be-logged"} {
|
||||
if strings.Contains(rawLog, forbidden) {
|
||||
t.Errorf("safe consume log leaked forbidden message data %q: %s", forbidden, rawLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,9 @@ package roommq
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
@ -13,10 +16,83 @@ const (
|
||||
MessageTypeRoomOutboxEvent = "room_outbox_event"
|
||||
MessageTypeRoomRocketLaunchDue = "room_rocket_launch_due"
|
||||
|
||||
TagRoomOutboxEvent = "room_outbox_event"
|
||||
// TagRoomOutboxEvent 是 typed tag 上线前所有房间事实共用的 legacy Tag。
|
||||
// 迁移窗口内 consumer 必须订阅 legacy || relevant typed tags,等所有旧消息
|
||||
// 和重试消息排空后才允许移除 legacy,不能直接切换造成存量事实不可见。
|
||||
TagRoomOutboxEvent = "room_outbox_event"
|
||||
|
||||
// 以下事件类型是当前 room topic 下游实际使用的稳定事实名。producer 的
|
||||
// typed 模式直接使用 envelope.event_type,consumer 复用这些常量拼 selector,
|
||||
// 避免 producer/consumer 两侧手写字符串漂移后静默漏消费。
|
||||
EventTypeRoomGiftSent = "RoomGiftSent"
|
||||
EventTypeRoomUserJoined = "RoomUserJoined"
|
||||
EventTypeRoomUserLeft = "RoomUserLeft"
|
||||
EventTypeRoomUserKicked = "RoomUserKicked"
|
||||
EventTypeRoomMicChanged = "RoomMicChanged"
|
||||
EventTypeRoomRocketIgnited = "RoomRocketIgnited"
|
||||
EventTypeRoomRocketRewardGranted = "RoomRocketRewardGranted"
|
||||
EventTypeRoomPasswordChanged = "RoomPasswordChanged"
|
||||
|
||||
TagRoomRocketLaunchDue = "room_rocket_launch_due"
|
||||
)
|
||||
|
||||
// EventTagMode 控制 room outbox 的 Broker Tag;它只改变 MQ 路由,不改变消息体协议。
|
||||
type EventTagMode string
|
||||
|
||||
const (
|
||||
// EventTagModeLegacy 是安全默认值,保证缺少新配置的旧环境继续发布统一 Tag。
|
||||
EventTagModeLegacy EventTagMode = "legacy"
|
||||
// EventTagModeEventType 只应在所有 consumer 完成兼容订阅滚动后显式启用。
|
||||
EventTagModeEventType EventTagMode = "event_type"
|
||||
)
|
||||
|
||||
// EventTypeTag 把一个受控的 room event_type 转成 RocketMQ Tag。事件类型来自
|
||||
// owner outbox 而不是用户输入,但仍需拒绝表达式操作符和标点,避免坏数据扩大
|
||||
// 订阅范围或发布成 consumer 无法安全表达的 Tag。
|
||||
func EventTypeTag(eventType string) (string, error) {
|
||||
tag := strings.TrimSpace(eventType)
|
||||
if tag == "" {
|
||||
return "", errors.New("room event_type tag is required")
|
||||
}
|
||||
if tag == TagRoomOutboxEvent {
|
||||
return "", errors.New("room event_type cannot reuse the legacy room tag")
|
||||
}
|
||||
for _, char := range tag {
|
||||
if (char >= 'a' && char <= 'z') ||
|
||||
(char >= 'A' && char <= 'Z') ||
|
||||
(char >= '0' && char <= '9') ||
|
||||
char == '_' || char == '-' {
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("room event_type %q cannot be used as a RocketMQ tag", eventType)
|
||||
}
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// LegacyCompatibleTagExpression 构造无损迁移 selector:legacy 分支消费切换前
|
||||
// 已存在 Broker 的消息,typed 分支只接收当前 consumer 真正处理的事件。排序和
|
||||
// 去重保证同一 consumer group 的所有副本注册完全相同的订阅元数据。
|
||||
func LegacyCompatibleTagExpression(eventTypes ...string) (string, error) {
|
||||
typedTags := make([]string, 0, len(eventTypes))
|
||||
seen := make(map[string]struct{}, len(eventTypes))
|
||||
for _, eventType := range eventTypes {
|
||||
tag, err := EventTypeTag(eventType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, exists := seen[tag]; exists {
|
||||
continue
|
||||
}
|
||||
seen[tag] = struct{}{}
|
||||
typedTags = append(typedTags, tag)
|
||||
}
|
||||
if len(typedTags) == 0 {
|
||||
return "", errors.New("at least one typed room event tag is required")
|
||||
}
|
||||
sort.Strings(typedTags)
|
||||
return TagRoomOutboxEvent + " || " + strings.Join(typedTags, " || "), nil
|
||||
}
|
||||
|
||||
// RoomOutboxMessage is the MQ representation of one durable room_outbox fact.
|
||||
type RoomOutboxMessage struct {
|
||||
MessageType string `json:"message_type"`
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package roommq
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
@ -34,6 +35,59 @@ func TestRoomOutboxMessageRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomEventTypeTagUsesExactTrimmedEventType(t *testing.T) {
|
||||
tag, err := EventTypeTag(" RoomGiftSent ")
|
||||
if err != nil {
|
||||
t.Fatalf("EventTypeTag failed: %v", err)
|
||||
}
|
||||
if tag != EventTypeRoomGiftSent {
|
||||
t.Fatalf("event tag = %q, want %q", tag, EventTypeRoomGiftSent)
|
||||
}
|
||||
|
||||
for _, invalid := range []string{"", TagRoomOutboxEvent, "Room GiftSent", "RoomGiftSent || *", "Room.GiftSent"} {
|
||||
if _, err := EventTypeTag(invalid); err == nil {
|
||||
t.Fatalf("EventTypeTag(%q) unexpectedly succeeded", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomLegacyCompatibleTagExpressionIsDeterministicAndDeduplicated(t *testing.T) {
|
||||
first, err := LegacyCompatibleTagExpression(
|
||||
EventTypeRoomUserJoined,
|
||||
EventTypeRoomGiftSent,
|
||||
EventTypeRoomUserJoined,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
|
||||
}
|
||||
second, err := LegacyCompatibleTagExpression(EventTypeRoomGiftSent, EventTypeRoomUserJoined)
|
||||
if err != nil {
|
||||
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("expressions differ by input order: first=%q second=%q", first, second)
|
||||
}
|
||||
want := TagRoomOutboxEvent + " || " + EventTypeRoomGiftSent + " || " + EventTypeRoomUserJoined
|
||||
if first != want {
|
||||
t.Fatalf("expression = %q, want %q", first, want)
|
||||
}
|
||||
if strings.Count(first, EventTypeRoomUserJoined) != 1 {
|
||||
t.Fatalf("typed tag was not deduplicated: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomLegacyCompatibleTagExpressionRejectsUnsafeOrMissingTypedTags(t *testing.T) {
|
||||
for _, eventTypes := range [][]string{
|
||||
nil,
|
||||
{TagRoomOutboxEvent},
|
||||
{"RoomGiftSent || *"},
|
||||
} {
|
||||
if _, err := LegacyCompatibleTagExpression(eventTypes...); err == nil {
|
||||
t.Fatalf("LegacyCompatibleTagExpression(%v) unexpectedly succeeded", eventTypes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomRocketLaunchDueRoundTrip(t *testing.T) {
|
||||
encoded, err := EncodeRoomRocketLaunchDueMessage(RoomRocketLaunchDueMessage{
|
||||
AppCode: "default",
|
||||
|
||||
@ -464,7 +464,14 @@ func (c *RESTClient) post(ctx context.Context, command string, payload any, out
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
var transportErr *url.Error
|
||||
if errors.As(err, &transportErr) {
|
||||
// net/http 的 url.Error.Error 会拼入完整请求 URL,而腾讯 IM UserSig 位于
|
||||
// query string。只向上保留底层网络原因,既维持 errors.Is 的超时/取消
|
||||
// 语义,又避免日志和 notice last_error 持久化可用签名。
|
||||
return fmt.Errorf("tencent im request failed: %w", transportErr.Err)
|
||||
}
|
||||
return fmt.Errorf("tencent im request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -345,6 +346,26 @@ func TestRESTClientKickUserBuildsTencentRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClientTransportErrorDoesNotExposeUserSig(t *testing.T) {
|
||||
client := newTestRESTClient(t, func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Query().Get("usersig") == "" {
|
||||
t.Fatal("test request must contain the signed Tencent IM query")
|
||||
}
|
||||
return nil, errors.New("synthetic EOF")
|
||||
})
|
||||
|
||||
err := client.KickUser(context.Background(), 10002)
|
||||
if err == nil {
|
||||
t.Fatal("expected transport failure")
|
||||
}
|
||||
if strings.Contains(err.Error(), "usersig=") || strings.Contains(err.Error(), "sdkappid=") {
|
||||
t.Fatalf("transport error exposed signed request URL: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "synthetic EOF") {
|
||||
t.Fatalf("transport cause must remain actionable: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRESTClient(t *testing.T, roundTrip roundTripFunc) *RESTClient {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@ -4,15 +4,92 @@ package walletmq
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeWalletOutboxEvent = "wallet_outbox_event"
|
||||
|
||||
// TagWalletOutboxEvent 是 typed tag 上线前所有钱包事实共用的 legacy Tag。
|
||||
// 兼容窗口内消费者必须同时订阅它和自己需要的 EventType Tag,确保 producer
|
||||
// 切换前已经进入 Broker 的旧消息仍会被同一 consumer group 完整消费。
|
||||
TagWalletOutboxEvent = "wallet_outbox_event"
|
||||
|
||||
// 以下常量只描述当前 wallet topic 消费者实际处理的事件类型。producer 使用
|
||||
// outbox.event_type 作为 Tag,因此消费者和消息体分派必须复用同一拼写,避免
|
||||
// Tag 过滤成功但 handler 因事件名漂移而静默跳过。
|
||||
EventTypeWalletRechargeRecorded = "WalletRechargeRecorded"
|
||||
EventTypeWalletCoinSellerStockPurchased = "WalletCoinSellerStockPurchased"
|
||||
EventTypeWalletLuckyGiftRewardCredited = "WalletLuckyGiftRewardCredited"
|
||||
EventTypeWalletTaskRewardCredited = "WalletTaskRewardCredited"
|
||||
EventTypeWalletWheelRewardCredited = "WalletWheelRewardCredited"
|
||||
EventTypeWalletRoomTurnoverRewardCredited = "WalletRoomTurnoverRewardCredited"
|
||||
EventTypeWalletInviteActivityRewardCredited = "WalletInviteActivityRewardCredited"
|
||||
EventTypeWalletAgencyOpeningRewardCredited = "WalletAgencyOpeningRewardCredited"
|
||||
EventTypeWalletGiftIncomeCredited = "WalletGiftIncomeCredited"
|
||||
EventTypeWalletBalanceChanged = "WalletBalanceChanged"
|
||||
EventTypeWalletGiftDebited = "WalletGiftDebited"
|
||||
EventTypeWalletRedPacketCreated = "WalletRedPacketCreated"
|
||||
EventTypeWalletRedPacketClaimed = "WalletRedPacketClaimed"
|
||||
EventTypeWalletRedPacketRefunded = "WalletRedPacketRefunded"
|
||||
EventTypeVIPActivated = "VipActivated"
|
||||
EventTypeVIPDailyCoinRebateAvailable = "VipDailyCoinRebateAvailable"
|
||||
EventTypeResourceGranted = "ResourceGranted"
|
||||
EventTypeResourceGroupGranted = "ResourceGroupGranted"
|
||||
)
|
||||
|
||||
// EventTypeTag converts one committed wallet event type into its RocketMQ Tag.
|
||||
// Event types are controlled identifiers rather than user input; rejecting expression
|
||||
// operators and punctuation here prevents a malformed outbox row from widening a
|
||||
// subscription or being published under a Tag no consumer can express safely.
|
||||
func EventTypeTag(eventType string) (string, error) {
|
||||
tag := strings.TrimSpace(eventType)
|
||||
if tag == "" {
|
||||
return "", errors.New("wallet event_type tag is required")
|
||||
}
|
||||
if tag == TagWalletOutboxEvent {
|
||||
return "", errors.New("wallet event_type cannot reuse the legacy wallet tag")
|
||||
}
|
||||
for _, char := range tag {
|
||||
if (char >= 'a' && char <= 'z') ||
|
||||
(char >= 'A' && char <= 'Z') ||
|
||||
(char >= '0' && char <= '9') ||
|
||||
char == '_' || char == '-' {
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("wallet event_type %q cannot be used as a RocketMQ tag", eventType)
|
||||
}
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// LegacyCompatibleTagExpression builds the compatibility selector used during
|
||||
// the typed-tag migration: legacy messages remain consumable, while newly published
|
||||
// messages are filtered by the exact event types handled by this consumer.
|
||||
// Sorting and deduplication make the expression deterministic so every process in
|
||||
// the same consumer group registers byte-for-byte identical subscription metadata.
|
||||
func LegacyCompatibleTagExpression(eventTypes ...string) (string, error) {
|
||||
typedTags := make([]string, 0, len(eventTypes))
|
||||
seen := make(map[string]struct{}, len(eventTypes))
|
||||
for _, eventType := range eventTypes {
|
||||
tag, err := EventTypeTag(eventType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, exists := seen[tag]; exists {
|
||||
continue
|
||||
}
|
||||
seen[tag] = struct{}{}
|
||||
typedTags = append(typedTags, tag)
|
||||
}
|
||||
if len(typedTags) == 0 {
|
||||
return "", errors.New("at least one typed wallet event tag is required")
|
||||
}
|
||||
sort.Strings(typedTags)
|
||||
return TagWalletOutboxEvent + " || " + strings.Join(typedTags, " || "), nil
|
||||
}
|
||||
|
||||
// WalletOutboxMessage is the MQ representation of one committed wallet_outbox fact.
|
||||
type WalletOutboxMessage struct {
|
||||
MessageType string `json:"message_type"`
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package walletmq
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWalletOutboxMessageRoundTrip(t *testing.T) {
|
||||
encoded, err := EncodeWalletOutboxMessage(WalletOutboxMessage{
|
||||
@ -27,3 +30,59 @@ func TestWalletOutboxMessageRoundTrip(t *testing.T) {
|
||||
t.Fatalf("decoded message mismatch: %+v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventTypeTagUsesExactTrimmedEventType(t *testing.T) {
|
||||
tag, err := EventTypeTag(" WalletRechargeRecorded ")
|
||||
if err != nil {
|
||||
t.Fatalf("EventTypeTag failed: %v", err)
|
||||
}
|
||||
if tag != EventTypeWalletRechargeRecorded {
|
||||
t.Fatalf("event tag = %q, want %q", tag, EventTypeWalletRechargeRecorded)
|
||||
}
|
||||
|
||||
for _, invalid := range []string{"", TagWalletOutboxEvent, "Wallet RechargeRecorded", "WalletRechargeRecorded || *", "Wallet.RechargeRecorded"} {
|
||||
if _, err := EventTypeTag(invalid); err == nil {
|
||||
t.Fatalf("EventTypeTag(%q) unexpectedly succeeded", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCompatibleTagExpressionIsDeterministicAndDeduplicated(t *testing.T) {
|
||||
first, err := LegacyCompatibleTagExpression(
|
||||
EventTypeWalletGiftDebited,
|
||||
EventTypeWalletRechargeRecorded,
|
||||
EventTypeWalletGiftDebited,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
|
||||
}
|
||||
second, err := LegacyCompatibleTagExpression(
|
||||
EventTypeWalletRechargeRecorded,
|
||||
EventTypeWalletGiftDebited,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("expressions differ by input order: first=%q second=%q", first, second)
|
||||
}
|
||||
want := TagWalletOutboxEvent + " || " + EventTypeWalletGiftDebited + " || " + EventTypeWalletRechargeRecorded
|
||||
if first != want {
|
||||
t.Fatalf("expression = %q, want %q", first, want)
|
||||
}
|
||||
if strings.Count(first, EventTypeWalletGiftDebited) != 1 {
|
||||
t.Fatalf("typed tag was not deduplicated: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCompatibleTagExpressionRejectsUnsafeOrMissingTypedTags(t *testing.T) {
|
||||
for _, eventTypes := range [][]string{
|
||||
nil,
|
||||
{TagWalletOutboxEvent},
|
||||
{"WalletRechargeRecorded || *"},
|
||||
} {
|
||||
if _, err := LegacyCompatibleTagExpression(eventTypes...); err == nil {
|
||||
t.Fatalf("LegacyCompatibleTagExpression(%v) unexpectedly succeeded", eventTypes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,6 +83,8 @@ var catalog = map[Code]Spec{
|
||||
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"),
|
||||
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "vip downgrade is not allowed"),
|
||||
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "vip recharge is required"),
|
||||
VIPCoinRebateNotFound: spec(codes.NotFound, httpStatusNotFound, VIPCoinRebateNotFound, "vip coin rebate not found"),
|
||||
VIPCoinRebateExpired: spec(codes.FailedPrecondition, httpStatusConflict, VIPCoinRebateExpired, "vip coin rebate expired"),
|
||||
|
||||
RedPacketDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketDisabled, "red packet is disabled"),
|
||||
RedPacketInvalidAmountTier: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketInvalidAmountTier, "invalid argument"),
|
||||
|
||||
@ -100,6 +100,10 @@ const (
|
||||
VIPDowngradeNotAllowed Code = "VIP_DOWNGRADE_NOT_ALLOWED"
|
||||
// VIPRechargeRequired 表示目标 VIP 等级需要用户先达到累计充值门槛。
|
||||
VIPRechargeRequired Code = "VIP_RECHARGE_REQUIRED"
|
||||
// VIPCoinRebateNotFound 表示返现不存在,或不属于当前 App/用户。
|
||||
VIPCoinRebateNotFound Code = "VIP_COIN_REBATE_NOT_FOUND"
|
||||
// VIPCoinRebateExpired 表示返现已越过 UTC 次日日切的排他结束边界。
|
||||
VIPCoinRebateExpired Code = "VIP_COIN_REBATE_EXPIRED"
|
||||
|
||||
// RedPacketDisabled 表示红包功能未启用。
|
||||
RedPacketDisabled Code = "RED_PACKET_DISABLED"
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package xerr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
@ -27,6 +28,16 @@ func ToGRPCError(err error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// context 终止描述的是调用链生命周期,而不是业务内部故障。仓储层会直接返回
|
||||
// context.Canceled/DeadlineExceeded;若先走兜底 Internal,主动取消的并发 sibling
|
||||
// 会在服务端被误报成 INTERNAL_ERROR,并把一次正常的快速失败伪装成数据库事故。
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return status.Error(codes.Canceled, context.Canceled.Error())
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return status.Error(codes.DeadlineExceeded, context.DeadlineExceeded.Error())
|
||||
}
|
||||
|
||||
domainErr, ok := As(err)
|
||||
if !ok {
|
||||
domainErr = &Error{Code: Internal, Message: "internal error"}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package xerr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
@ -29,6 +31,29 @@ func TestToGRPCErrorCarriesReason(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestToGRPCErrorPreservesContextTermination 防止并发聚合器主动取消 sibling RPC 时,
|
||||
// database/sql 返回的 context 错误被兜底成 Internal,污染告警并掩盖真正的首个失败。
|
||||
func TestToGRPCErrorPreservesContextTermination(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
code codes.Code
|
||||
}{
|
||||
{name: "canceled", err: context.Canceled, code: codes.Canceled},
|
||||
{name: "wrapped canceled", err: fmt.Errorf("query interrupted: %w", context.Canceled), code: codes.Canceled},
|
||||
{name: "deadline exceeded", err: context.DeadlineExceeded, code: codes.DeadlineExceeded},
|
||||
{name: "wrapped deadline exceeded", err: fmt.Errorf("query interrupted: %w", context.DeadlineExceeded), code: codes.DeadlineExceeded},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := status.Code(ToGRPCError(test.err)); got != test.code {
|
||||
t.Fatalf("grpc code mismatch: got %s want %s", got, test.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogCoversDeclaredCodes(t *testing.T) {
|
||||
declared := declaredCodesFromSource(t)
|
||||
|
||||
|
||||
@ -28,6 +28,15 @@ SQL_FILES=(
|
||||
"${LOCAL_INITDB_DIR}/999_local_grants.sql"
|
||||
)
|
||||
|
||||
# initdb only creates missing tables; a reused local MySQL volume therefore
|
||||
# needs incremental migrations as well. Keep the owned user-service migrations
|
||||
# in this local bootstrap path so authentication schema changes (for example
|
||||
# login_audit client-version fields) cannot leave a locally logged-in client
|
||||
# receiving a misleading 401 during token refresh.
|
||||
USER_MIGRATION_FILES=(
|
||||
"services/user-service/deploy/mysql/migrations/012_login_audit_client_version.sql"
|
||||
)
|
||||
|
||||
# Keep MySQL as the only required dependency for this script. Business services
|
||||
# are started after schema/grants are known to match the current repo.
|
||||
"${SCRIPT_DIR}/prepare-local-mysql-initdb.sh"
|
||||
@ -61,3 +70,13 @@ for sql_file in "${SQL_FILES[@]}"; do
|
||||
printf 'applying mysql init file: %s\n' "${sql_file}"
|
||||
docker compose exec -T mysql mysql --default-character-set=utf8mb4 -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" < "${sql_file}"
|
||||
done
|
||||
|
||||
for sql_file in "${USER_MIGRATION_FILES[@]}"; do
|
||||
if [[ ! -f "${sql_file}" ]]; then
|
||||
printf 'skipping mysql migration because it is absent or not a regular file: %s\n' "${sql_file}"
|
||||
continue
|
||||
fi
|
||||
|
||||
printf 'applying mysql migration: %s\n' "${sql_file}"
|
||||
docker compose exec -T mysql mysql --default-character-set=utf8mb4 -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" < "${sql_file}"
|
||||
done
|
||||
|
||||
@ -44,6 +44,7 @@ TOPICS=(
|
||||
"hyapp_wallet_outbox"
|
||||
"hyapp_wallet_realtime_outbox"
|
||||
"hyapp_room_outbox"
|
||||
"hyapp_lucky_gift_outbox"
|
||||
"hyapp_room_rocket_launch"
|
||||
"hyapp_user_outbox"
|
||||
"hyapp_message_action_outbox"
|
||||
@ -102,8 +103,8 @@ fi
|
||||
|
||||
# Topic creation is idempotent, but each updateTopic call shells into the
|
||||
# broker container and blocks local startup. Query once and only create missing
|
||||
# topics; a warm `make run` then pays one cheap route read instead of seven
|
||||
# management writes.
|
||||
# topics; a warm `make run` then pays one cheap route read instead of repeating
|
||||
# management writes for the full fixed topic set.
|
||||
topic_list="$(docker compose exec -T rocketmq-broker "${MQADMIN}" topicList -n "${NAMESRV}" 2>/dev/null || true)"
|
||||
missing_topics=()
|
||||
for topic in "${TOPICS[@]}"; do
|
||||
|
||||
360
scripts/mysql/060_configurable_vip_program.sql
Normal file
360
scripts/mysql/060_configurable_vip_program.sql
Normal file
@ -0,0 +1,360 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_wallet;
|
||||
|
||||
-- 本迁移只建立按 App 可配置的 VIP 规则和体验卡事实;不会为 Fami 猜测价格、有效期或资源组。
|
||||
CREATE TABLE IF NOT EXISTS vip_program_configs (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||
program_type VARCHAR(32) NOT NULL COMMENT '体系类型:legacy_timed/tiered_privilege_v1',
|
||||
level_count INT NOT NULL COMMENT '该 App 可配置等级数量',
|
||||
same_level_expiry_policy VARCHAR(32) NOT NULL COMMENT '同级续费有效期策略:extend_remaining',
|
||||
upgrade_expiry_policy VARCHAR(32) NOT NULL COMMENT '升级有效期策略:extend_remaining/replace_from_now',
|
||||
downgrade_purchase_policy VARCHAR(32) NOT NULL COMMENT '降级购买策略:reject',
|
||||
benefit_inheritance_policy VARCHAR(32) NOT NULL COMMENT '权益矩阵策略:当前固定 target_only,每级保存完整集合',
|
||||
grant_mode VARCHAR(32) NOT NULL COMMENT '后台赠送语义:direct_membership/trial_card',
|
||||
trial_card_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否允许体验卡背包和佩戴流程',
|
||||
status VARCHAR(32) NOT NULL COMMENT '业务状态:active/disabled',
|
||||
config_version BIGINT NOT NULL DEFAULT 1 COMMENT '配置版本;订单和会员状态持久化该快照版本',
|
||||
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code),
|
||||
KEY idx_vip_program_status (status, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='按 App 配置的 VIP 体系规则';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vip_level_benefits (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||
level INT NOT NULL COMMENT '拥有该权益的具体 VIP 等级',
|
||||
benefit_code VARCHAR(96) NOT NULL COMMENT '稳定权益编码,跨服务权限判断只使用该值',
|
||||
name VARCHAR(128) NOT NULL COMMENT '权益展示名称',
|
||||
benefit_type VARCHAR(32) NOT NULL COMMENT '权益类型:decoration/function',
|
||||
unlock_level INT NOT NULL COMMENT 'P1 初始解锁等级,仅用于展示来源,不参与运行时继承',
|
||||
status VARCHAR(32) NOT NULL COMMENT '业务状态:active/disabled',
|
||||
trial_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '体验卡是否可获得;金币返现必须为 false',
|
||||
resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '绑定资源 ID;0 表示尚未绑定或纯功能权益',
|
||||
resource_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '绑定资源类型;空表示纯功能或待运营配置',
|
||||
execution_scope VARCHAR(32) NOT NULL COMMENT '实际执行方:wallet/room/user/notice',
|
||||
auto_equip BOOLEAN NOT NULL DEFAULT FALSE COMMENT '资源下发后是否自动佩戴最高等级样式',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '权益展示顺序',
|
||||
metadata_json JSON NULL COMMENT '执行方扩展配置;初始 P1 权益不猜测参数',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, level, benefit_code),
|
||||
KEY idx_vip_benefit_code (app_code, benefit_code, status, level),
|
||||
KEY idx_vip_benefit_level_sort (app_code, level, status, sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 每等级完整权益矩阵';
|
||||
|
||||
-- 体验卡元数据和通用背包事实按 entitlement_id 一一对应;佩戴切换不更新卡片绝对过期时间。
|
||||
CREATE TABLE IF NOT EXISTS user_vip_trial_cards (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||
trial_card_id VARCHAR(96) NOT NULL COMMENT '体验卡实例 ID',
|
||||
command_id VARCHAR(128) NOT NULL COMMENT '发卡命令幂等 ID',
|
||||
request_hash VARCHAR(128) NOT NULL COMMENT '发卡请求语义哈希',
|
||||
user_id BIGINT NOT NULL COMMENT '持有用户 ID',
|
||||
entitlement_id VARCHAR(96) NOT NULL COMMENT '通用背包权益实例 ID,也是佩戴入口标识',
|
||||
resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '可选资源 ID;0 表示直接按等级和时长发卡',
|
||||
level INT NOT NULL COMMENT '体验卡 VIP 等级快照',
|
||||
name VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'VIP 等级名称快照',
|
||||
status VARCHAR(32) NOT NULL COMMENT '卡片业务状态:active/revoked',
|
||||
duration_ms BIGINT NOT NULL COMMENT '发卡时长快照;佩戴切换不改变该值',
|
||||
effective_at_ms BIGINT NOT NULL COMMENT '卡片开始计时,UTC epoch ms',
|
||||
expires_at_ms BIGINT NOT NULL COMMENT '卡片绝对过期时间,UTC epoch ms',
|
||||
grant_source VARCHAR(32) NOT NULL COMMENT '发放来源',
|
||||
source_grant_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '通用资源发放 ID',
|
||||
operator_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '发放操作人用户 ID',
|
||||
reason VARCHAR(256) NOT NULL DEFAULT '' COMMENT '发放原因',
|
||||
program_type VARCHAR(32) NOT NULL COMMENT '发卡时 VIP 体系快照',
|
||||
config_version BIGINT NOT NULL COMMENT '发卡时 VIP 配置版本',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, trial_card_id),
|
||||
UNIQUE KEY uk_vip_trial_card_command (app_code, command_id),
|
||||
UNIQUE KEY uk_vip_trial_card_entitlement (app_code, entitlement_id),
|
||||
KEY idx_vip_trial_card_user (app_code, user_id, status, expires_at_ms),
|
||||
KEY idx_vip_trial_card_resource (app_code, resource_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 体验卡背包元数据';
|
||||
|
||||
-- purchase、直接会员赠送与体验卡发放共享同一命令守卫,先串行幂等键再获取业务行锁。
|
||||
CREATE TABLE IF NOT EXISTS vip_command_locks (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||
command_id VARCHAR(128) NOT NULL COMMENT 'VIP 写命令幂等 ID',
|
||||
biz_type VARCHAR(64) NOT NULL COMMENT '命令类型:vip_purchase/vip_grant/vip_trial_card_grant',
|
||||
request_hash VARCHAR(128) NOT NULL COMMENT '请求语义哈希',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '首次命令时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '最后写入时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, command_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 写命令串行化守卫';
|
||||
|
||||
-- 旧库按列逐项补齐;所有 ALTER 都可重复执行,且默认值保持 Lalu 的历史语义。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_memberships' AND COLUMN_NAME = 'program_type') = 0,
|
||||
'ALTER TABLE user_vip_memberships ADD COLUMN program_type VARCHAR(32) NOT NULL DEFAULT ''legacy_timed'' COMMENT ''激活时使用的 VIP 体系快照'' AFTER status',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 历史成功命令必须在上线前进入同一守卫空间,否则升级后一段时间内仍可能走旧唯一键竞争路径。
|
||||
-- wallet_transactions 的业务索引以 app_code 为首列;这里显式限定当前已注册 App,避免生产迁移
|
||||
-- 因只按 biz_type 过滤退化为全表扫描。新 App 上线后的命令会直接经过运行时 command guard。
|
||||
INSERT IGNORE INTO vip_command_locks (
|
||||
app_code, command_id, biz_type, request_hash, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT app_code, command_id, biz_type, request_hash, created_at_ms, updated_at_ms
|
||||
FROM wallet_transactions
|
||||
WHERE app_code IN ('lalu', 'fami', 'huwaa', 'yumi')
|
||||
AND biz_type IN ('vip_purchase', 'vip_grant');
|
||||
|
||||
INSERT IGNORE INTO vip_command_locks (
|
||||
app_code, command_id, biz_type, request_hash, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT app_code, command_id, 'vip_trial_card_grant', request_hash, created_at_ms, updated_at_ms
|
||||
FROM user_vip_trial_cards;
|
||||
|
||||
-- Lalu 显式保持现状;Fami 使用替换升级、体验卡赠送和每级完整权益集合。
|
||||
INSERT IGNORE INTO vip_program_configs (
|
||||
app_code, program_type, level_count, same_level_expiry_policy, upgrade_expiry_policy,
|
||||
downgrade_purchase_policy, benefit_inheritance_policy, grant_mode, trial_card_enabled,
|
||||
status, config_version, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 'legacy_timed', 10, 'extend_remaining', 'extend_remaining',
|
||||
'reject', 'target_only', 'direct_membership', FALSE, 'active', 1, 0, 0, 0),
|
||||
('fami', 'tiered_privilege_v1', 9, 'extend_remaining', 'replace_from_now',
|
||||
'reject', 'target_only', 'trial_card', TRUE, 'active', 1, 0, 0, 0);
|
||||
|
||||
-- Fami 等级初始均不可购买;30 天只满足配置校验,价格与资源组保持 0,须由运营确认后启用。
|
||||
INSERT IGNORE INTO vip_levels (
|
||||
app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id,
|
||||
required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('fami', 1, 'VIP1', 'disabled', 0, 2592000000, 0, 0, 10, 0, 0),
|
||||
('fami', 2, 'VIP2', 'disabled', 0, 2592000000, 0, 0, 20, 0, 0),
|
||||
('fami', 3, 'VIP3', 'disabled', 0, 2592000000, 0, 0, 30, 0, 0),
|
||||
('fami', 4, 'VIP4', 'disabled', 0, 2592000000, 0, 0, 40, 0, 0),
|
||||
('fami', 5, 'VIP5', 'disabled', 0, 2592000000, 0, 0, 50, 0, 0),
|
||||
('fami', 6, 'VIP6', 'disabled', 0, 2592000000, 0, 0, 60, 0, 0),
|
||||
('fami', 7, 'VIP7', 'disabled', 0, 2592000000, 0, 0, 70, 0, 0),
|
||||
('fami', 8, 'VIP8', 'disabled', 0, 2592000000, 0, 0, 80, 0, 0),
|
||||
('fami', 9, 'VIP9', 'disabled', 0, 2592000000, 0, 0, 90, 0, 0);
|
||||
|
||||
-- 每级直接持久化最终权益集合,不在运行时根据 unlock_level 自动继承。
|
||||
INSERT IGNORE INTO vip_level_benefits (
|
||||
app_code, level, benefit_code, name, benefit_type, unlock_level, status, trial_enabled,
|
||||
resource_id, resource_type, execution_scope, auto_equip, sort_order, metadata_json,
|
||||
created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
'fami', levels.level, benefits.benefit_code, benefits.name, benefits.benefit_type,
|
||||
benefits.unlock_level, 'active', benefits.trial_enabled, 0, '', benefits.execution_scope,
|
||||
benefits.auto_equip, benefits.sort_order,
|
||||
CASE
|
||||
WHEN benefits.benefit_code = 'online_global_notice'
|
||||
THEN JSON_OBJECT('message', '欢迎进入Fami,祝你有美好的一天')
|
||||
ELSE NULL
|
||||
END,
|
||||
0, 0
|
||||
FROM (
|
||||
SELECT 1 AS level UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
|
||||
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9
|
||||
) AS levels
|
||||
INNER JOIN (
|
||||
SELECT 'vip_badge_identity' AS benefit_code, 'VIP标识' AS name, 'function' AS benefit_type, 1 AS unlock_level, TRUE AS trial_enabled, 'user' AS execution_scope, FALSE AS auto_equip, 10 AS sort_order
|
||||
UNION ALL SELECT 'vip_medal', 'VIP勋章', 'decoration', 1, TRUE, 'user', TRUE, 20
|
||||
UNION ALL SELECT 'avatar_frame', 'VIP头像框', 'decoration', 1, TRUE, 'user', TRUE, 30
|
||||
UNION ALL SELECT 'vip_title', 'VIP称号', 'function', 1, TRUE, 'user', FALSE, 40
|
||||
UNION ALL SELECT 'chat_bubble', 'VIP聊天气泡', 'decoration', 2, TRUE, 'room', TRUE, 50
|
||||
UNION ALL SELECT 'vip_gift', 'VIP礼物', 'function', 2, TRUE, 'room', FALSE, 60
|
||||
UNION ALL SELECT 'entry_effect', 'VIP进场通知', 'function', 2, TRUE, 'room', FALSE, 70
|
||||
UNION ALL SELECT 'visitor_history', '查看访客', 'function', 2, TRUE, 'user', FALSE, 80
|
||||
UNION ALL SELECT 'voice_wave', 'VIP声波纹', 'decoration', 3, TRUE, 'room', TRUE, 90
|
||||
UNION ALL SELECT 'profile_card', 'VIP资料卡皮肤', 'decoration', 3, TRUE, 'user', TRUE, 100
|
||||
UNION ALL SELECT 'custom_room_background', '自定义房间背景', 'function', 3, TRUE, 'room', FALSE, 110
|
||||
UNION ALL SELECT 'room_image_message', '房间发图', 'function', 3, TRUE, 'room', FALSE, 120
|
||||
UNION ALL SELECT 'animated_avatar', '动态头像', 'function', 4, TRUE, 'user', FALSE, 130
|
||||
UNION ALL SELECT 'animated_room_cover', '动态房间封面', 'function', 4, TRUE, 'room', FALSE, 140
|
||||
UNION ALL SELECT 'mic_skin', '麦克风皮肤', 'decoration', 4, TRUE, 'room', TRUE, 150
|
||||
UNION ALL SELECT 'room_border', '房间边框', 'decoration', 4, TRUE, 'room', TRUE, 160
|
||||
UNION ALL SELECT 'vehicle', 'VIP座驾', 'decoration', 4, TRUE, 'room', TRUE, 170
|
||||
UNION ALL SELECT 'colored_room_name', 'VIP彩色房间名称', 'function', 5, TRUE, 'room', FALSE, 180
|
||||
UNION ALL SELECT 'colored_nickname', 'VIP彩色昵称', 'function', 5, TRUE, 'user', FALSE, 190
|
||||
UNION ALL SELECT 'colored_id', 'VIP彩色ID', 'function', 5, TRUE, 'user', FALSE, 200
|
||||
UNION ALL SELECT 'gift_tray_skin', '送礼托盘皮肤', 'function', 5, TRUE, 'room', FALSE, 210
|
||||
UNION ALL SELECT 'hide_profile_data', '隐藏个人数据', 'function', 6, TRUE, 'user', FALSE, 220
|
||||
UNION ALL SELECT 'custom_avatar_frame', '定制头框', 'function', 6, TRUE, 'user', FALSE, 230
|
||||
UNION ALL SELECT 'leaderboard_invisible', '榜单隐身', 'function', 6, TRUE, 'user', FALSE, 240
|
||||
UNION ALL SELECT 'daily_coin_rebate', '金币返现', 'function', 6, FALSE, 'wallet', FALSE, 250
|
||||
UNION ALL SELECT 'custom_profile_card', '定制资料卡', 'function', 7, TRUE, 'user', FALSE, 260
|
||||
UNION ALL SELECT 'anonymous_profile_visit', '匿名访问主页', 'function', 7, TRUE, 'user', FALSE, 270
|
||||
UNION ALL SELECT 'custom_gift', '定制礼物', 'function', 7, TRUE, 'room', FALSE, 280
|
||||
UNION ALL SELECT 'room_entry_notice', '进房高亮通知', 'function', 8, TRUE, 'room', FALSE, 290
|
||||
UNION ALL SELECT 'custom_pretty_id', '定制靓号', 'function', 8, TRUE, 'user', FALSE, 300
|
||||
UNION ALL SELECT 'custom_vehicle', '定制座驾', 'function', 8, TRUE, 'room', FALSE, 310
|
||||
UNION ALL SELECT 'anti_kick', '防踢', 'function', 9, TRUE, 'room', FALSE, 320
|
||||
UNION ALL SELECT 'anti_mute', '防禁言', 'function', 9, TRUE, 'room', FALSE, 330
|
||||
UNION ALL SELECT 'online_global_notice', '上线全服通知', 'function', 9, TRUE, 'notice', FALSE, 340
|
||||
) AS benefits ON benefits.unlock_level <= levels.level;
|
||||
|
||||
-- 早期执行过本迁移的环境已有权益行;只给空元数据补默认文案,不覆盖运营已配置的 App 文案。
|
||||
UPDATE vip_level_benefits
|
||||
SET metadata_json = JSON_OBJECT('message', '欢迎进入Fami,祝你有美好的一天')
|
||||
WHERE app_code = 'fami'
|
||||
AND benefit_code = 'online_global_notice'
|
||||
AND COALESCE(JSON_LENGTH(metadata_json), 0) = 0;
|
||||
|
||||
-- resource_id 使用 resources 自增主键;空素材和仅含 vip_level 的 metadata 明确等待运营补齐视觉资产。
|
||||
INSERT IGNORE INTO resources (
|
||||
app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled,
|
||||
grant_strategy, wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount,
|
||||
usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order,
|
||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('fami', 'vip_trial_card_1', 'vip_trial_card', 'VIP1体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":1}', 10, 0, 0, 0, 0),
|
||||
('fami', 'vip_trial_card_2', 'vip_trial_card', 'VIP2体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":2}', 20, 0, 0, 0, 0),
|
||||
('fami', 'vip_trial_card_3', 'vip_trial_card', 'VIP3体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":3}', 30, 0, 0, 0, 0),
|
||||
('fami', 'vip_trial_card_4', 'vip_trial_card', 'VIP4体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":4}', 40, 0, 0, 0, 0),
|
||||
('fami', 'vip_trial_card_5', 'vip_trial_card', 'VIP5体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":5}', 50, 0, 0, 0, 0),
|
||||
('fami', 'vip_trial_card_6', 'vip_trial_card', 'VIP6体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":6}', 60, 0, 0, 0, 0),
|
||||
('fami', 'vip_trial_card_7', 'vip_trial_card', 'VIP7体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":7}', 70, 0, 0, 0, 0),
|
||||
('fami', 'vip_trial_card_8', 'vip_trial_card', 'VIP8体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":8}', 80, 0, 0, 0, 0),
|
||||
('fami', 'vip_trial_card_9', 'vip_trial_card', 'VIP9体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":9}', 90, 0, 0, 0, 0);
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_memberships' AND COLUMN_NAME = 'config_version') = 0,
|
||||
'ALTER TABLE user_vip_memberships ADD COLUMN config_version BIGINT NOT NULL DEFAULT 1 COMMENT ''激活时使用的 VIP 配置版本'' AFTER program_type',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'previous_expires_at_ms') = 0,
|
||||
'ALTER TABLE vip_purchase_orders ADD COLUMN previous_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''购买前会员过期时间快照,UTC epoch ms'' AFTER previous_level',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'new_expires_at_ms') = 0,
|
||||
'ALTER TABLE vip_purchase_orders ADD COLUMN new_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''购买后会员过期时间快照,UTC epoch ms'' AFTER previous_expires_at_ms',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'duration_ms') = 0,
|
||||
'ALTER TABLE vip_purchase_orders ADD COLUMN duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''购买等级的有效期配置快照'' AFTER price_coin',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'program_type') = 0,
|
||||
'ALTER TABLE vip_purchase_orders ADD COLUMN program_type VARCHAR(32) NOT NULL DEFAULT ''legacy_timed'' COMMENT ''订单执行的 VIP 体系快照'' AFTER duration_ms',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'config_version') = 0,
|
||||
'ALTER TABLE vip_purchase_orders ADD COLUMN config_version BIGINT NOT NULL DEFAULT 1 COMMENT ''订单执行的 VIP 配置版本'' AFTER program_type',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'expiry_policy') = 0,
|
||||
'ALTER TABLE vip_purchase_orders ADD COLUMN expiry_policy VARCHAR(32) NOT NULL DEFAULT ''extend_remaining'' COMMENT ''本单实际采用的有效期策略'' AFTER config_version',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_history' AND COLUMN_NAME = 'previous_expires_at_ms') = 0,
|
||||
'ALTER TABLE user_vip_history ADD COLUMN previous_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''变更前过期时间快照,UTC epoch ms'' AFTER new_level',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_history' AND COLUMN_NAME = 'new_expires_at_ms') = 0,
|
||||
'ALTER TABLE user_vip_history ADD COLUMN new_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''变更后过期时间快照,UTC epoch ms'' AFTER previous_expires_at_ms',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_history' AND COLUMN_NAME = 'program_type') = 0,
|
||||
'ALTER TABLE user_vip_history ADD COLUMN program_type VARCHAR(32) NOT NULL DEFAULT ''legacy_timed'' COMMENT ''变更使用的 VIP 体系快照'' AFTER new_expires_at_ms',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_history' AND COLUMN_NAME = 'config_version') = 0,
|
||||
'ALTER TABLE user_vip_history ADD COLUMN config_version BIGINT NOT NULL DEFAULT 1 COMMENT ''变更使用的 VIP 配置版本'' AFTER program_type',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 老库 history 在上述有效期列刚增加时只能得到 0,不能据此否定存量会员的日切资格。
|
||||
-- 仅对“完全没有新格式有效期快照”的当前会员写入一条迁移基线;previous_* 保持 0
|
||||
-- 明确表示迁移不猜测基线前状态。稳定 order_id 与 NOT EXISTS 使脚本重复执行不增行,
|
||||
-- 已有完整新格式 history 的用户也不会被覆盖或改写。
|
||||
SET @vip_history_baseline_ms := CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO user_vip_history (
|
||||
app_code, user_id, action, previous_level, new_level, previous_expires_at_ms,
|
||||
new_expires_at_ms, program_type, config_version, order_id, created_at_ms
|
||||
)
|
||||
SELECT
|
||||
memberships.app_code,
|
||||
memberships.user_id,
|
||||
'migration_baseline',
|
||||
0,
|
||||
memberships.level,
|
||||
0,
|
||||
memberships.expires_at_ms,
|
||||
memberships.program_type,
|
||||
memberships.config_version,
|
||||
'vip_configurable_060_baseline',
|
||||
@vip_history_baseline_ms
|
||||
FROM user_vip_memberships AS memberships
|
||||
WHERE memberships.level > 0
|
||||
AND memberships.expires_at_ms > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_vip_history AS complete_history
|
||||
WHERE complete_history.app_code = memberships.app_code
|
||||
AND complete_history.user_id = memberships.user_id
|
||||
AND complete_history.new_level > 0
|
||||
AND complete_history.new_expires_at_ms > 0
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_vip_history AS existing_baseline
|
||||
WHERE existing_baseline.app_code = memberships.app_code
|
||||
AND existing_baseline.user_id = memberships.user_id
|
||||
AND existing_baseline.order_id = 'vip_configurable_060_baseline'
|
||||
);
|
||||
78
scripts/mysql/061_vip_daily_coin_rebate.sql
Normal file
78
scripts/mysql/061_vip_daily_coin_rebate.sql
Normal file
@ -0,0 +1,78 @@
|
||||
-- VIP 每日金币返现由 wallet-service 独占:日 run 固化 UTC 日切金额矩阵,用户返现行保存资格、窗口和领取交易。
|
||||
-- 本迁移可重复执行;不猜测任一等级金额,历史空配置会安全关闭,等待运营填 metadata_json.coin_amount 后再启用。
|
||||
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_wallet;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vip_daily_coin_rebate_runs (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||
task_day CHAR(10) NOT NULL COMMENT 'UTC 日期,YYYY-MM-DD',
|
||||
run_id VARCHAR(96) NOT NULL COMMENT '稳定批任务 ID',
|
||||
day_start_ms BIGINT NOT NULL COMMENT 'UTC 当日开始,epoch ms',
|
||||
day_end_ms BIGINT NOT NULL COMMENT 'UTC 次日开始,排他结束 epoch ms',
|
||||
config_version BIGINT NOT NULL COMMENT '创建 run 时 VIP 配置版本',
|
||||
level_amounts_json JSON NOT NULL COMMENT '等级到返现金额的不可变快照',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'running/completed',
|
||||
last_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '已扫描会员游标',
|
||||
scanned_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计扫描会员数',
|
||||
created_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计创建返现数',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
completed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '完成时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, task_day),
|
||||
UNIQUE KEY uk_vip_rebate_run_id (app_code, run_id),
|
||||
KEY idx_vip_rebate_run_status (app_code, status, task_day)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 每日金币返现运行快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vip_daily_coin_rebates (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||
rebate_id VARCHAR(96) NOT NULL COMMENT '返现事实 ID',
|
||||
run_id VARCHAR(96) NOT NULL COMMENT '来源日 run ID',
|
||||
task_day CHAR(10) NOT NULL COMMENT 'UTC 日期,YYYY-MM-DD',
|
||||
user_id BIGINT NOT NULL COMMENT '领取用户 ID',
|
||||
vip_level INT NOT NULL COMMENT 'UTC 日切时付费 VIP 等级快照',
|
||||
vip_name VARCHAR(64) NOT NULL COMMENT 'VIP 名称快照',
|
||||
coin_amount BIGINT NOT NULL COMMENT '返现金币正整数快照',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'claimable/claimed;expired 由查询层投影',
|
||||
available_at_ms BIGINT NOT NULL COMMENT '领取开始,UTC epoch ms',
|
||||
expires_at_ms BIGINT NOT NULL COMMENT '领取排他结束,UTC epoch ms',
|
||||
config_version BIGINT NOT NULL COMMENT '来源 run 的 VIP 配置版本',
|
||||
claimed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实际领取时间,UTC epoch ms',
|
||||
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '领取入账交易 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, rebate_id),
|
||||
UNIQUE KEY uk_vip_rebate_user_day (app_code, task_day, user_id),
|
||||
KEY idx_vip_rebate_user_day (app_code, user_id, task_day, rebate_id),
|
||||
KEY idx_vip_rebate_expiry (app_code, status, expires_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 每日金币返现领取事实';
|
||||
|
||||
UPDATE vip_level_benefits
|
||||
SET status = 'disabled', trial_enabled = FALSE
|
||||
WHERE benefit_code = 'daily_coin_rebate'
|
||||
AND (
|
||||
JSON_EXTRACT(metadata_json, '$.coin_amount') IS NULL
|
||||
OR JSON_TYPE(JSON_EXTRACT(metadata_json, '$.coin_amount')) <> 'INTEGER'
|
||||
OR CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_amount')) AS DECIMAL(65, 0)) <= 0
|
||||
OR CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_amount')) AS DECIMAL(65, 0)) > 9223372036854775807
|
||||
);
|
||||
|
||||
UPDATE vip_level_benefits
|
||||
SET trial_enabled = FALSE, execution_scope = 'wallet'
|
||||
WHERE benefit_code = 'daily_coin_rebate';
|
||||
|
||||
UPDATE vip_level_benefits
|
||||
SET execution_scope = 'notice'
|
||||
WHERE benefit_code = 'online_global_notice';
|
||||
|
||||
-- 已经先上线过领取交易的环境补齐统一 VIP command guard;同 command 不能再被购买、发卡或另一返现复用。
|
||||
-- 显式限定当前 App 才能使用 wallet_transactions 的 (app_code,biz_type,created_at_ms) 索引;
|
||||
-- 禁止在生产迁移中只按 biz_type 扫描完整钱包交易表。
|
||||
INSERT IGNORE INTO vip_command_locks (
|
||||
app_code, command_id, biz_type, request_hash, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT app_code, command_id, biz_type, request_hash, created_at_ms, updated_at_ms
|
||||
FROM wallet_transactions
|
||||
WHERE app_code IN ('lalu', 'fami', 'huwaa', 'yumi')
|
||||
AND biz_type = 'vip_daily_coin_rebate';
|
||||
15
scripts/mysql/062_vip_user_settings.sql
Normal file
15
scripts/mysql/062_vip_user_settings.sql
Normal file
@ -0,0 +1,15 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_wallet;
|
||||
|
||||
-- 用户级通知开关只保存偏好,不改变 VIP 资格或 effective_benefits;历史用户不批量补行,
|
||||
-- 查询无记录时由 wallet-service 统一返回默认开启。
|
||||
CREATE TABLE IF NOT EXISTS user_vip_settings (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||
room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知',
|
||||
online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启上线全服通知',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户按 App 隔离的 VIP 功能开关';
|
||||
7
scripts/mysql/063_google_paid_sync_retry.sql
Normal file
7
scripts/mysql/063_google_paid_sync_retry.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- Durable Google paid-detail retry state for wallet-service.
|
||||
-- Existing paid_synced_at_ms=0 rows become immediately eligible for the 15-second worker.
|
||||
ALTER TABLE payment_orders
|
||||
ADD COLUMN paid_sync_attempt_count INT NOT NULL DEFAULT 0 COMMENT 'Google 实付同步连续失败次数' AFTER paid_synced_at_ms,
|
||||
ADD COLUMN paid_sync_last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Google 实付同步最近错误' AFTER paid_sync_attempt_count,
|
||||
ADD COLUMN paid_sync_next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 实付同步下次重试时间,UTC epoch ms' AFTER paid_sync_last_error,
|
||||
ADD INDEX idx_payment_orders_paid_sync (provider, paid_synced_at_ms, paid_sync_next_retry_at_ms, created_at_ms);
|
||||
13
scripts/mysql/064_manager_bd_leader_region_scope.sql
Normal file
13
scripts/mysql/064_manager_bd_leader_region_scope.sql
Normal file
@ -0,0 +1,13 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_user;
|
||||
|
||||
-- 区域邀请是经理个人的显式扩权;默认 0 保证现有和新建经理继续使用 App 基础范围。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_add_bd_leader_in_region') = 0,
|
||||
'ALTER TABLE manager_profiles ADD COLUMN can_add_bd_leader_in_region TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否允许把 BD Leader 邀请范围从本国家扩展到经理所属区域'' AFTER can_add_bd_leader',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
23
scripts/mysql/065_manager_cross_region_scope.sql
Normal file
23
scripts/mysql/065_manager_cross_region_scope.sql
Normal file
@ -0,0 +1,23 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_user;
|
||||
|
||||
-- 通用跨区经理替代上一版 BD Leader 专用扩区开关;默认关闭,不扩大任何现有经理的数据范围。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_manage_cross_region') = 0,
|
||||
'ALTER TABLE manager_profiles ADD COLUMN can_manage_cross_region TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否允许经理中心所有目标用户操作跨区域执行'' AFTER can_add_bd_leader',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 若 064 已落库,则继承显式开启值;未执行 064 的环境保持默认关闭。
|
||||
SET @copy_legacy := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_add_bd_leader_in_region') > 0,
|
||||
'UPDATE manager_profiles SET can_manage_cross_region = can_add_bd_leader_in_region WHERE can_add_bd_leader_in_region = 1',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @copy_legacy;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -63,6 +63,7 @@ import (
|
||||
menumodule "hyapp-admin-server/internal/modules/menu"
|
||||
opscentermodule "hyapp-admin-server/internal/modules/opscenter"
|
||||
paymentmodule "hyapp-admin-server/internal/modules/payment"
|
||||
pointwithdrawalconfigmodule "hyapp-admin-server/internal/modules/pointwithdrawalconfig"
|
||||
policyconfigmodule "hyapp-admin-server/internal/modules/policyconfig"
|
||||
prettyidmodule "hyapp-admin-server/internal/modules/prettyid"
|
||||
rbacmodule "hyapp-admin-server/internal/modules/rbac"
|
||||
@ -216,6 +217,13 @@ func main() {
|
||||
}
|
||||
// legacy 账单源的谷歌实付同步依赖 admin 库缓存表,store 建好后再注入;凭证异常只降级该 App,不阻断启动。
|
||||
wireLegacyGooglePaidSync(financeBillSources, cfg.FinanceBillSources, store)
|
||||
if cfg.FinanceGooglePaidSync.Enabled {
|
||||
// Polling is process-owned rather than HTTP-owned: startup immediately drains one batch, then
|
||||
// every 15 seconds prioritizes newest orders while gradually repairing the complete backlog.
|
||||
googlePaidSyncWorker := paymentmodule.NewLegacyGooglePaidSyncWorker(financeBillSources, store, cfg.FinanceGooglePaidSync)
|
||||
googlePaidSyncWorker.Start()
|
||||
defer googlePaidSyncWorker.Close()
|
||||
}
|
||||
if *runBootstrap || cfg.Bootstrap.Enabled {
|
||||
if err := store.Seed(cfg); err != nil {
|
||||
fatalRuntime("seed_database_failed", err)
|
||||
@ -269,11 +277,23 @@ func main() {
|
||||
auth := service.NewAuthService(cfg.JWTSecret, cfg.AccessTokenTTL)
|
||||
auditHandler := auditmodule.New(store)
|
||||
countryRegionHandler := countryregionmodule.New(userclient.NewGRPC(userConn), userDB, store, roomClient, cfg, auditHandler)
|
||||
appUserHandler := appusermodule.New(
|
||||
userclient.NewGRPC(userConn),
|
||||
activityclient.NewGRPC(activityConn),
|
||||
sqlDB,
|
||||
userDB,
|
||||
walletDB,
|
||||
cfg,
|
||||
auditHandler,
|
||||
appusermodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
|
||||
appusermodule.WithExportJobs(store, cfg),
|
||||
)
|
||||
var runner *jobrunner.Runner
|
||||
var jobStatus healthmodule.JobStatusProvider
|
||||
if cfg.Jobs.Enabled {
|
||||
runner = jobrunner.NewRunner(store, redisClient, cfg,
|
||||
jobrunner.WithHandler(countryregionmodule.CountryCodeRenameJobType, countryRegionHandler.Service().HandleCountryCodeRenameJob),
|
||||
jobrunner.WithArtifactHandler(appusermodule.AppUserExportJobType, appUserHandler.HandleExportJob),
|
||||
)
|
||||
runner.Start()
|
||||
defer runner.Close()
|
||||
@ -319,13 +339,32 @@ func main() {
|
||||
financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
|
||||
)
|
||||
// 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。
|
||||
dashboardService := dashboardmodule.NewService(store, cfg, userclient.NewGRPC(userConn), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...))
|
||||
dashboardService := dashboardmodule.NewService(
|
||||
store,
|
||||
cfg,
|
||||
userclient.NewGRPC(userConn),
|
||||
// 用户画像从 userDB 读取已完成真实用户的总量、性别和最近一次成功登录版本;
|
||||
// 当天新增与日活继续走 statistics-service,避免重算事件去重口径。
|
||||
dashboardmodule.WithUserDB(userDB),
|
||||
dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...),
|
||||
)
|
||||
appRegistryService := appregistrymodule.NewService(userDB)
|
||||
// Finance 与 Social BI 必须复用同一 Handler 实例,确保普通充值、已核验币商订单和 H5 币商充值完全同口径。
|
||||
paymentHandler := paymentmodule.New(
|
||||
walletclient.NewGRPC(walletConn),
|
||||
userDB,
|
||||
walletDB,
|
||||
store,
|
||||
auditHandler,
|
||||
paymentmodule.WithMoneyRegionSources(moneyRegionSources...),
|
||||
paymentmodule.WithRechargeBillSources(financeBillSources...),
|
||||
)
|
||||
databiService := databimodule.NewService(
|
||||
store,
|
||||
dashboardService,
|
||||
appRegistryService,
|
||||
userclient.NewGRPC(userConn),
|
||||
databimodule.WithFinanceRechargeOverviewSource(paymentHandler),
|
||||
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources)...),
|
||||
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
|
||||
)
|
||||
@ -338,7 +377,7 @@ func main() {
|
||||
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
AppConfig: appconfigmodule.New(store, auditHandler),
|
||||
AppRegistry: appregistrymodule.New(userDB),
|
||||
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
|
||||
AppUser: appUserHandler,
|
||||
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
|
||||
CountryRegion: countryRegionHandler,
|
||||
CPRelation: cprelationmodule.NewWithActivity(userDB, activityclient.NewGRPC(activityConn), auditHandler),
|
||||
@ -359,40 +398,41 @@ func main() {
|
||||
gamemanagementmodule.WithRobotProfileSource(robotProfileSource),
|
||||
gamemanagementmodule.WithRobotAppearanceServices(walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn)),
|
||||
),
|
||||
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB, walletv1.NewWalletCronServiceClient(walletConn), cfg.WalletService.RequestTimeout, auditHandler),
|
||||
HostWithdrawal: hostwithdrawalmodule.New(sqlDB, walletDB, userDB),
|
||||
InviteActivityReward: inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
OpsCenter: opscentermodule.New(appRegistryService, luckyGiftHandler),
|
||||
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler, paymentmodule.WithMoneyRegionSources(moneyRegionSources...), paymentmodule.WithRechargeBillSources(financeBillSources...)),
|
||||
PolicyConfig: policyconfigmodule.New(sqlDB, walletDB, userDB, activityclient.NewGRPC(activityConn), auditHandler),
|
||||
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
|
||||
RBAC: rbacmodule.New(store, auditHandler),
|
||||
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
Report: reportmodule.New(userDB, roomClient),
|
||||
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
RegionBlock: regionblockmodule.New(userDB, userclient.NewGRPC(userConn), auditHandler),
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler),
|
||||
RiskConfig: riskconfigmodule.New(userclient.NewGRPC(userConn), auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(userDB, store, roomClient, robotClient, auditHandler),
|
||||
RoomRocket: roomrocketmodule.New(roomClient, auditHandler),
|
||||
RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Search: searchmodule.New(store),
|
||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Team: teammodule.New(store, auditHandler),
|
||||
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
|
||||
TeamSalarySettlement: teamSalarySettlementHandler,
|
||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
||||
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
|
||||
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB, walletv1.NewWalletCronServiceClient(walletConn), cfg.WalletService.RequestTimeout, auditHandler),
|
||||
HostWithdrawal: hostwithdrawalmodule.New(sqlDB, walletDB, userDB),
|
||||
InviteActivityReward: inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
OpsCenter: opscentermodule.New(appRegistryService, luckyGiftHandler),
|
||||
Payment: paymentHandler,
|
||||
PolicyConfig: policyconfigmodule.New(sqlDB, walletDB, userDB, activityclient.NewGRPC(activityConn), auditHandler),
|
||||
PointWithdrawalConfig: pointwithdrawalconfigmodule.New(walletDB, userDB, auditHandler),
|
||||
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
|
||||
RBAC: rbacmodule.New(store, auditHandler),
|
||||
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
Report: reportmodule.New(userDB, roomClient),
|
||||
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
RegionBlock: regionblockmodule.New(userDB, userclient.NewGRPC(userConn), auditHandler),
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler),
|
||||
RiskConfig: riskconfigmodule.New(userclient.NewGRPC(userConn), auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(userDB, store, roomClient, robotClient, auditHandler),
|
||||
RoomRocket: roomrocketmodule.New(roomClient, auditHandler),
|
||||
RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Search: searchmodule.New(store),
|
||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Team: teammodule.New(store, auditHandler),
|
||||
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
|
||||
TeamSalarySettlement: teamSalarySettlementHandler,
|
||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
||||
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
|
||||
}
|
||||
engine := router.New(cfg, auth, handlers)
|
||||
|
||||
|
||||
@ -55,6 +55,11 @@ finance_bill_sources:
|
||||
google_package_name: "com.chat.auu"
|
||||
google_service_account_json: |
|
||||
{"type":"service_account","project_id":"aslan-494908","private_key_id":"f3ab2df01b3f92b9f71f4b7d1ebdba3cff5ac93e","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDkCOxtBRVNg5CF\nIfgkT5gv1f2hSRnf6/MSTDYkgXy1sVSbLEXbsZGQdnsYSfAxNXURyJ0QXJOZV551\n+0XOlRIb+HLN++37olcFVm4GPLxQiVCCEbFOBwGPZTMi4RYgriDHEp2umY9Cns8Z\nHYlkYgbLxKLICpzyDJWDsyT3Tc4rBhRGv7+dulxSuPpF5AaLzbV/AjQTwee6qnV4\n8VEPHx/r+3IAJMVLt+VrgFwujIdzJGk8TIZKSSmZ4J6iN5dKgly1PyVu66bppbX9\nHkPDEFg8nxYK5F2/yjo7/VIFurfd5bGKGS3sqf0siRTXLsj0X/wUgt0RlWOpKPeX\nbZq9jzznAgMBAAECggEAASLUPrTMRt8VbLxfFps46GAaC+An21g7FUfA60yj2Onh\nwIYncPFBBuW4NkZEBpK8GxMTST4U1Co+FVtjnSRb+zyxIbqUFHFaGqI0GR7bV1Ff\nz84Two5BYTwBVbamXBJSAnviwjhsoMnWwUrG4POmEgTQRMvcvU33vri5Qewmz0sN\njXTNh/GIUxA+IbOUTaaTDBLSshp7JBOPjuF0ttsd/0zG1AFbaWWtZ1h8YCOhIw+g\np7/CdS3gO/uttgI8PiLj8AImlOcRz5FnFGTcHZqR7pk08W7Olm89ik3NZg+5IozP\nrJ8LOU7uQ7F50zpWRNvpBNaa4m35Ns6bOHTLEV4BXQKBgQD0q8hn1bddwC92RzMw\naBb0Qt0Najg2R/SDWaeSO6Tz3KTflGZXDFtalAOq5KXT47N7QdoIjlXGP50B5iV7\nK/I25f+GONcACPtnnLWyNvjq2OuMz0FIGEoUxY9DFP6aNkyNR5FiE2t2y2XiKmrV\nCRt+P4x/b/KTvdVI+caUSfXezQKBgQDul/F46Uj1evZZToA4XENXRN8oSb4I08cX\nBQK2WUs+KJsT9sWNaT1V5hQ8wXfuN2rhI0kqR0EURY3ycaA41rdLIeYqvAnmR5qC\nSO8HlC2+zQzjSrr7lyt8dcTughW8/9lCf+jANBNYBZrBN2ta3vlQ8eA8rFUThYnC\n8+PFo8MigwKBgFbbGJSL0MFONUsWsXxQpz1k8xYNDBFw78MlM5B87ezH+huIkd/6\n+f8opjinXJrgrVlnIiCBbr+m23TOH6YfDqggc9pRGTng9mZswi+WxjyQbuYYuQL/\n5GSFUXst28gg2IIa0uhvHmoYgH2OM0iXKBRkONsQgZui+zEhwjXoH4lNAoGAKVMJ\n0M5fA52Lg4ZUMO7R/xB/skOrdW3wwqzsflbS8G4qBfgs2URMCk+yW5+KvSi+C0aI\nSplSzUcKwd4qSQ3va0Twz6AH+umV+lDVjbN9hNmRDOEJp7/UGVdwh3ridvy9TYZH\n8tpSK2G1HxgRMQkDl6B9HSUgCySK6shBQB8QEi8CgYBAbUxCJn20RFmwU4bgRASe\ngoDGkN5Dw1SdauuWn+WySTyrsxdxvRcuyy4pQSDBadhnv24/30mza1ToeJeEsyJ/\nJuysBhWBjJKoYVC/qikgS6EuAnqlAKKg8jLIqERmNaFol0Squ03kTvMYx2aGa1dt\nwwMCwljNodGSgZRA3RlwOA==\n-----END PRIVATE KEY-----\n","client_email":"pay-purchase-verify@aslan-494908.iam.gserviceaccount.com","client_id":"100290296879529721706","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/pay-purchase-verify%40aslan-494908.iam.gserviceaccount.com","universe_domain":"googleapis.com"}
|
||||
finance_google_paid_sync:
|
||||
enabled: true
|
||||
poll_interval: "15s"
|
||||
batch_size: 100
|
||||
request_timeout: "2m"
|
||||
mysql_auto_migrate: false
|
||||
migrations:
|
||||
enabled: true
|
||||
@ -102,7 +107,8 @@ game_service:
|
||||
request_timeout: "3s"
|
||||
statistics_service:
|
||||
base_url: "http://10.2.1.16:13010"
|
||||
request_timeout: "3s"
|
||||
# 漏斗冷查询允许短时索引页预热;正常查询应远低于该上限,超时只负责隔离异常请求。
|
||||
request_timeout: "10s"
|
||||
dashboard_external_sources:
|
||||
- enabled: true
|
||||
type: "mysql"
|
||||
|
||||
@ -55,6 +55,11 @@ finance_bill_sources:
|
||||
google_package_name: ""
|
||||
google_service_account_json: ""
|
||||
google_service_account_file: ""
|
||||
finance_google_paid_sync:
|
||||
enabled: true
|
||||
poll_interval: "15s"
|
||||
batch_size: 100
|
||||
request_timeout: "2m"
|
||||
mysql_auto_migrate: true
|
||||
migrations:
|
||||
enabled: true
|
||||
@ -101,7 +106,8 @@ game_service:
|
||||
request_timeout: "3s"
|
||||
statistics_service:
|
||||
base_url: "http://127.0.0.1:13010"
|
||||
request_timeout: "3s"
|
||||
# 漏斗冷查询允许短时索引页预热;正常查询应远低于该上限,超时只负责隔离异常请求。
|
||||
request_timeout: "10s"
|
||||
dashboard_external_sources:
|
||||
- enabled: false
|
||||
type: "mysql"
|
||||
|
||||
@ -217,6 +217,8 @@
|
||||
| 权限码 | kind | 控制范围 |
|
||||
| --- | --- | --- |
|
||||
| `app-user:view` | `menu` | App 用户列表、详情 |
|
||||
| `app-user:level` | `button` | 临时调整 App 用户富豪/魅力等级 |
|
||||
| `app-user:export` | `button` | 按当前筛选条件异步导出 App 用户 |
|
||||
| `app-user:update` | `button` | 修改用户资料 |
|
||||
| `app-user:status` | `button` | 后台封禁、解封 |
|
||||
| `app-user:password` | `button` | 后台设置 App 用户密码 |
|
||||
|
||||
18
server/admin/internal/cache/redis.go
vendored
18
server/admin/internal/cache/redis.go
vendored
@ -70,6 +70,24 @@ return 0`
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshLock 只延长仍由当前 owner 持有的锁;任务 worker 续租 DB lease 时同步续租 Redis,
|
||||
// 防止大导出超过初始 TTL 后被另一实例重复执行并覆盖产物。
|
||||
func (r *Redis) RefreshLock(ctx context.Context, key string, owner string, ttl time.Duration) (bool, error) {
|
||||
if r == nil || r.client == nil {
|
||||
return true, nil
|
||||
}
|
||||
if strings.TrimSpace(owner) == "" || ttl <= 0 {
|
||||
return false, errors.New("lock owner and positive ttl are required")
|
||||
}
|
||||
const script = `
|
||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
||||
end
|
||||
return 0`
|
||||
result, err := r.client.Eval(ctx, script, []string{r.key(key)}, owner, ttl.Milliseconds()).Int64()
|
||||
return result == 1, err
|
||||
}
|
||||
|
||||
func (r *Redis) key(key string) string {
|
||||
if r.prefix == "" {
|
||||
return key
|
||||
|
||||
@ -13,17 +13,19 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServiceName string `yaml:"service_name"`
|
||||
NodeID string `yaml:"node_id"`
|
||||
Environment string `yaml:"environment"`
|
||||
Log logging.Config `yaml:"log"`
|
||||
HTTPAddr string `yaml:"http_addr"`
|
||||
MySQLDSN string `yaml:"mysql_dsn"`
|
||||
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
||||
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
||||
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
|
||||
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
|
||||
FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"`
|
||||
ServiceName string `yaml:"service_name"`
|
||||
NodeID string `yaml:"node_id"`
|
||||
Environment string `yaml:"environment"`
|
||||
Log logging.Config `yaml:"log"`
|
||||
HTTPAddr string `yaml:"http_addr"`
|
||||
MySQLDSN string `yaml:"mysql_dsn"`
|
||||
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
||||
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
||||
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
|
||||
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
|
||||
FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"`
|
||||
// FinanceGooglePaidSync controls the durable polling loop that fills Google Orders API paid details for legacy bill sources.
|
||||
FinanceGooglePaidSync FinanceGooglePaidSyncConfig `yaml:"finance_google_paid_sync"`
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
Migrations MigrationConfig `yaml:"migrations"`
|
||||
JWTSecret string `yaml:"jwt_secret"`
|
||||
@ -173,6 +175,15 @@ type FinanceBillSourceConfig struct {
|
||||
GoogleServiceAccountFile string `yaml:"google_service_account_file"`
|
||||
}
|
||||
|
||||
// FinanceGooglePaidSyncConfig keeps legacy Google paid-detail polling independent from HTTP requests.
|
||||
// New successful Mongo orders are picked every PollInterval; failed orders use persisted retry backoff.
|
||||
type FinanceGooglePaidSyncConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
PollInterval time.Duration `yaml:"poll_interval"`
|
||||
BatchSize int `yaml:"batch_size"`
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
}
|
||||
|
||||
type TencentCOSConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
SecretID string `yaml:"secret-id"`
|
||||
@ -361,6 +372,12 @@ func Default() Config {
|
||||
RequestTimeout: 3 * time.Second,
|
||||
},
|
||||
},
|
||||
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
|
||||
Enabled: true,
|
||||
PollInterval: 15 * time.Second,
|
||||
BatchSize: 100,
|
||||
RequestTimeout: 2 * time.Minute,
|
||||
},
|
||||
TencentCOS: TencentCOSConfig{
|
||||
Enabled: false,
|
||||
ObjectPrefix: "admin",
|
||||
@ -607,6 +624,19 @@ func (cfg *Config) Normalize() {
|
||||
}
|
||||
}
|
||||
cfg.applyFinanceBillSourceEnvOverrides()
|
||||
// The sync feature is enabled by default; normalization only bounds operator-provided cadence and batch size.
|
||||
if cfg.FinanceGooglePaidSync.PollInterval <= 0 {
|
||||
cfg.FinanceGooglePaidSync.PollInterval = Default().FinanceGooglePaidSync.PollInterval
|
||||
}
|
||||
if cfg.FinanceGooglePaidSync.BatchSize <= 0 {
|
||||
cfg.FinanceGooglePaidSync.BatchSize = Default().FinanceGooglePaidSync.BatchSize
|
||||
}
|
||||
if cfg.FinanceGooglePaidSync.BatchSize > 1000 {
|
||||
cfg.FinanceGooglePaidSync.BatchSize = 1000
|
||||
}
|
||||
if cfg.FinanceGooglePaidSync.RequestTimeout <= 0 {
|
||||
cfg.FinanceGooglePaidSync.RequestTimeout = Default().FinanceGooglePaidSync.RequestTimeout
|
||||
}
|
||||
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
|
||||
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
|
||||
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
|
||||
|
||||
@ -324,6 +324,16 @@ func (c *GRPCClient) SetUserLevel(ctx context.Context, req *activityv1.SetUserLe
|
||||
return c.levelClient.SetUserLevel(ctx, req)
|
||||
}
|
||||
|
||||
// BatchGetUserLevelAdminProfiles 读取真实成长账户与有效 overlay,避免 admin-server 复制等级计算规则。
|
||||
func (c *GRPCClient) BatchGetUserLevelAdminProfiles(ctx context.Context, req *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error) {
|
||||
return c.growthClient.BatchGetUserLevelAdminProfiles(ctx, req)
|
||||
}
|
||||
|
||||
// AdjustTemporaryUserLevels 把财富和魅力两条轨道一次交给 activity-service 原子提交。
|
||||
func (c *GRPCClient) AdjustTemporaryUserLevels(ctx context.Context, req *activityv1.AdjustTemporaryUserLevelsRequest) (*activityv1.AdjustTemporaryUserLevelsResponse, error) {
|
||||
return c.growthClient.AdjustTemporaryUserLevels(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) {
|
||||
return c.messageClient.CreateInboxMessage(ctx, req)
|
||||
}
|
||||
|
||||
@ -121,40 +121,71 @@ func (c *Client) GetOrder(ctx context.Context, orderID string) (Order, error) {
|
||||
if orderID == "" {
|
||||
return Order{}, errors.New("google order id is required")
|
||||
}
|
||||
token, err := c.token(ctx)
|
||||
if err != nil {
|
||||
return Order{}, err
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/orders/%s",
|
||||
defaultAPIBaseURL, url.PathEscape(c.packageName), url.PathEscape(orderID))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
body, err := c.getJSON(ctx, endpoint)
|
||||
if err != nil {
|
||||
return Order{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return Order{}, errors.New("google play api request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return Order{}, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusNotFound, http.StatusBadRequest:
|
||||
return Order{}, errors.New("google order is not found")
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return Order{}, errors.New("google play api is not authorized (需要查看财务数据权限)")
|
||||
default:
|
||||
return Order{}, fmt.Errorf("google play api returned %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
var parsed orderResource
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return Order{}, err
|
||||
}
|
||||
return orderFromResource(parsed), nil
|
||||
}
|
||||
|
||||
// GetOrders uses Orders batchGet for backlog repair: one request can fetch up to 1000 distinct orders.
|
||||
// The caller falls back to single-order requests if Google rejects the whole batch because of one stale ID.
|
||||
func (c *Client) GetOrders(ctx context.Context, orderIDs []string) (map[string]Order, error) {
|
||||
if c == nil {
|
||||
return nil, errors.New("google orders client is not configured")
|
||||
}
|
||||
cleaned := make([]string, 0, len(orderIDs))
|
||||
seen := make(map[string]struct{}, len(orderIDs))
|
||||
for _, orderID := range orderIDs {
|
||||
orderID = strings.TrimSpace(orderID)
|
||||
if orderID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[orderID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[orderID] = struct{}{}
|
||||
cleaned = append(cleaned, orderID)
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return map[string]Order{}, nil
|
||||
}
|
||||
if len(cleaned) > 1000 {
|
||||
return nil, errors.New("too many google order ids")
|
||||
}
|
||||
query := url.Values{}
|
||||
for _, orderID := range cleaned {
|
||||
query.Add("orderIds", orderID)
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/orders:batchGet?%s",
|
||||
defaultAPIBaseURL, url.PathEscape(c.packageName), query.Encode())
|
||||
body, err := c.getJSON(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var parsed struct {
|
||||
Orders []orderResource `json:"orders"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orders := make(map[string]Order, len(parsed.Orders))
|
||||
for _, item := range parsed.Orders {
|
||||
order := orderFromResource(item)
|
||||
if order.OrderID != "" {
|
||||
orders[order.OrderID] = order
|
||||
}
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func orderFromResource(parsed orderResource) Order {
|
||||
order := Order{
|
||||
OrderID: parsed.OrderID,
|
||||
State: parsed.State,
|
||||
@ -167,7 +198,39 @@ func (c *Client) GetOrder(ctx context.Context, orderID string) (Order, error) {
|
||||
if order.CurrencyCode == "" {
|
||||
order.CurrencyCode = parsed.Tax.CurrencyCode
|
||||
}
|
||||
return order, nil
|
||||
return order
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
token, err := c.token(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.New("google play api request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusNotFound, http.StatusBadRequest:
|
||||
return nil, errors.New("google order is not found")
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return nil, errors.New("google play api is not authorized (需要查看财务数据权限)")
|
||||
default:
|
||||
return nil, fmt.Errorf("google play api returned %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *Client) token(ctx context.Context) (string, error) {
|
||||
|
||||
@ -2,6 +2,7 @@ package userclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
@ -41,6 +42,7 @@ type Client interface {
|
||||
ReviewCoinSellerSubApplication(ctx context.Context, req ReviewCoinSellerSubApplicationRequest) (*CoinSellerSubApplicationReviewResult, error)
|
||||
GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error)
|
||||
GetUserRoleSummary(ctx context.Context, req GetUserRoleSummaryRequest) (*UserRoleSummary, error)
|
||||
GetRoleScopePolicy(ctx context.Context, req GetRoleScopePolicyRequest) (*RoleScopePolicy, error)
|
||||
CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
|
||||
AdminAddAgencyHost(ctx context.Context, req AdminAddAgencyHostRequest) (*CreateAgencyResult, error)
|
||||
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
|
||||
@ -111,6 +113,19 @@ type ChangeUserCountryResult struct {
|
||||
RegionChanged bool `json:"regionChanged"`
|
||||
}
|
||||
|
||||
type GetRoleScopePolicyRequest struct {
|
||||
RequestID string
|
||||
Caller string
|
||||
Scene string
|
||||
}
|
||||
|
||||
type RoleScopePolicy struct {
|
||||
Scene string `json:"scene"`
|
||||
BaseScope string `json:"baseScope"`
|
||||
ExpandedScope string `json:"expandedScope"`
|
||||
RegionExpansionConfigurable bool `json:"regionExpansionConfigurable"`
|
||||
}
|
||||
|
||||
type SetUserStatusResult struct {
|
||||
User *User `json:"user"`
|
||||
RevokedSessionCount int64 `json:"revokedSessionCount"`
|
||||
@ -379,6 +394,25 @@ func (c *GRPCClient) SetUserStatus(ctx context.Context, req SetUserStatusRequest
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BatchGetUserAdminProfiles 汇总 user-service owner 的生日、注册设备、成功登录、身份和有效封禁事实。
|
||||
func (c *GRPCClient) BatchGetUserAdminProfiles(ctx context.Context, req *userv1.BatchGetUserAdminProfilesRequest) (*userv1.BatchGetUserAdminProfilesResponse, error) {
|
||||
return c.client.BatchGetUserAdminProfiles(ctx, req)
|
||||
}
|
||||
|
||||
// AdminIssueUserAccessToken 透传后台按用户重签 access token 的 RPC;
|
||||
// user-service 侧刻意不写 login_audit,审计由 admin 操作日志完成。
|
||||
func (c *GRPCClient) AdminIssueUserAccessToken(ctx context.Context, req *userv1.AdminIssueUserAccessTokenRequest) (*userv1.AdminIssueUserAccessTokenResponse, error) {
|
||||
return c.client.AdminIssueUserAccessToken(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) AdminBanUser(ctx context.Context, req *userv1.AdminBanUserRequest) (*userv1.AdminBanUserResponse, error) {
|
||||
return c.client.AdminBanUser(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) AdminUnbanUser(ctx context.Context, req *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error) {
|
||||
return c.client.AdminUnbanUser(ctx, req)
|
||||
}
|
||||
|
||||
func userStatusToProto(status string) userv1.UserStatus {
|
||||
switch status {
|
||||
case "active":
|
||||
@ -804,6 +838,26 @@ func (c *GRPCClient) CreateBDLeader(ctx context.Context, req CreateBDLeaderReque
|
||||
return fromProtoBDProfile(resp.GetBdProfile()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetRoleScopePolicy(ctx context.Context, req GetRoleScopePolicyRequest) (*RoleScopePolicy, error) {
|
||||
resp, err := c.hostClient.GetRoleScopePolicy(ctx, &userv1.GetRoleScopePolicyRequest{
|
||||
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
||||
Scene: req.Scene,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policy := resp.GetPolicy()
|
||||
if policy == nil {
|
||||
return nil, fmt.Errorf("role scope policy missing")
|
||||
}
|
||||
return &RoleScopePolicy{
|
||||
Scene: policy.GetScene(),
|
||||
BaseScope: policy.GetBaseScope(),
|
||||
ExpandedScope: policy.GetExpandedScope(),
|
||||
RegionExpansionConfigurable: policy.GetRegionExpansionConfigurable(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) CreateBD(ctx context.Context, req CreateBDRequest) (*BDProfile, error) {
|
||||
resp, err := c.hostAdminClient.CreateBD(ctx, &userv1.CreateBDRequest{
|
||||
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
||||
|
||||
@ -45,6 +45,7 @@ type Client interface {
|
||||
ReleaseSalaryWithdrawal(ctx context.Context, req *walletv1.ReleaseSalaryWithdrawalRequest) (*walletv1.ReleaseSalaryWithdrawalResponse, error)
|
||||
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
|
||||
GetRechargeBillSummary(ctx context.Context, req *walletv1.GetRechargeBillSummaryRequest) (*walletv1.GetRechargeBillSummaryResponse, error)
|
||||
BatchGetUserRechargeStats(ctx context.Context, req *walletv1.BatchGetUserRechargeStatsRequest) (*walletv1.BatchGetUserRechargeStatsResponse, error)
|
||||
GetRechargeBillOverview(ctx context.Context, req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error)
|
||||
RefreshGooglePaymentPrices(ctx context.Context, req *walletv1.RefreshGooglePaymentPricesRequest) (*walletv1.RefreshGooglePaymentPricesResponse, error)
|
||||
ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error)
|
||||
@ -59,9 +60,12 @@ type Client interface {
|
||||
CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
|
||||
UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
|
||||
DeleteRechargeProduct(ctx context.Context, req *walletv1.DeleteRechargeProductRequest) (*walletv1.DeleteRechargeProductResponse, error)
|
||||
GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error)
|
||||
UpdateAdminVipProgramConfig(ctx context.Context, req *walletv1.UpdateAdminVipProgramConfigRequest) (*walletv1.UpdateAdminVipProgramConfigResponse, error)
|
||||
ListAdminVipLevels(ctx context.Context, req *walletv1.ListAdminVipLevelsRequest) (*walletv1.ListAdminVipLevelsResponse, error)
|
||||
UpdateAdminVipLevels(ctx context.Context, req *walletv1.UpdateAdminVipLevelsRequest) (*walletv1.UpdateAdminVipLevelsResponse, error)
|
||||
GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error)
|
||||
GrantVipTrialCard(ctx context.Context, req *walletv1.GrantVipTrialCardRequest) (*walletv1.GrantVipTrialCardResponse, error)
|
||||
GetRedPacketConfig(ctx context.Context, req *walletv1.GetRedPacketConfigRequest) (*walletv1.GetRedPacketConfigResponse, error)
|
||||
UpdateRedPacketConfig(ctx context.Context, req *walletv1.UpdateRedPacketConfigRequest) (*walletv1.UpdateRedPacketConfigResponse, error)
|
||||
ListRedPackets(ctx context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error)
|
||||
@ -225,6 +229,10 @@ func (c *GRPCClient) GetRechargeBillSummary(ctx context.Context, req *walletv1.G
|
||||
return c.client.GetRechargeBillSummary(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) BatchGetUserRechargeStats(ctx context.Context, req *walletv1.BatchGetUserRechargeStatsRequest) (*walletv1.BatchGetUserRechargeStatsResponse, error) {
|
||||
return c.client.BatchGetUserRechargeStats(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetRechargeBillOverview(ctx context.Context, req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error) {
|
||||
return c.client.GetRechargeBillOverview(ctx, req)
|
||||
}
|
||||
@ -281,6 +289,14 @@ func (c *GRPCClient) DeleteRechargeProduct(ctx context.Context, req *walletv1.De
|
||||
return c.client.DeleteRechargeProduct(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) {
|
||||
return c.client.GetVipProgramConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateAdminVipProgramConfig(ctx context.Context, req *walletv1.UpdateAdminVipProgramConfigRequest) (*walletv1.UpdateAdminVipProgramConfigResponse, error) {
|
||||
return c.client.UpdateAdminVipProgramConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListAdminVipLevels(ctx context.Context, req *walletv1.ListAdminVipLevelsRequest) (*walletv1.ListAdminVipLevelsResponse, error) {
|
||||
return c.client.ListAdminVipLevels(ctx, req)
|
||||
}
|
||||
@ -293,6 +309,10 @@ func (c *GRPCClient) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest
|
||||
return c.client.GrantVip(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GrantVipTrialCard(ctx context.Context, req *walletv1.GrantVipTrialCardRequest) (*walletv1.GrantVipTrialCardResponse, error) {
|
||||
return c.client.GrantVipTrialCard(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetRedPacketConfig(ctx context.Context, req *walletv1.GetRedPacketConfigRequest) (*walletv1.GetRedPacketConfigResponse, error) {
|
||||
return c.client.GetRedPacketConfig(ctx, req)
|
||||
}
|
||||
|
||||
@ -26,29 +26,35 @@ const userExportJobType = "user-export"
|
||||
|
||||
type HandlerFunc func(ctx context.Context, job *model.AdminJob) (string, error)
|
||||
|
||||
// ArtifactHandlerFunc 供需要生成可下载文件的业务任务使用;artifactPath 必须位于 runner 配置的导出目录内。
|
||||
type ArtifactHandlerFunc func(ctx context.Context, job *model.AdminJob) (resultJSON string, artifactPath string, err error)
|
||||
|
||||
type Option func(*Runner)
|
||||
|
||||
type Store interface {
|
||||
LeaseNextJob(workerID string, lease time.Duration, maxAttempts int) (*model.AdminJob, error)
|
||||
CompleteJobWithArtifact(id uint, resultJSON string, artifactPath string) error
|
||||
FailJobAttempt(id uint, message string) error
|
||||
ReleaseJobLease(id uint) error
|
||||
RenewJobLease(id uint, workerID string, lease time.Duration) error
|
||||
CompleteJobWithArtifact(id uint, workerID string, resultJSON string, artifactPath string) error
|
||||
FailJobAttempt(id uint, workerID string, message string) error
|
||||
ReleaseJobLease(id uint, workerID string) error
|
||||
ExportUsers(options repository.ListOptions) ([]model.User, error)
|
||||
}
|
||||
|
||||
type Locker interface {
|
||||
TryLock(ctx context.Context, key string, owner string, ttl time.Duration) (bool, cache.UnlockFunc, error)
|
||||
RefreshLock(ctx context.Context, key string, owner string, ttl time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
artifactDir string
|
||||
interval time.Duration
|
||||
leaseTTL time.Duration
|
||||
locker Locker
|
||||
handlers map[string]HandlerFunc
|
||||
maxAttempts int
|
||||
store Store
|
||||
workerID string
|
||||
artifactDir string
|
||||
interval time.Duration
|
||||
leaseTTL time.Duration
|
||||
locker Locker
|
||||
handlers map[string]HandlerFunc
|
||||
artifactHandlers map[string]ArtifactHandlerFunc
|
||||
maxAttempts int
|
||||
store Store
|
||||
workerID string
|
||||
|
||||
closeOnce sync.Once
|
||||
done chan struct{}
|
||||
@ -65,6 +71,17 @@ func WithHandler(jobType string, handler HandlerFunc) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithArtifactHandler 注册带文件产物的任务,避免业务模块绕过统一 job lease、重试和下载校验。
|
||||
func WithArtifactHandler(jobType string, handler ArtifactHandlerFunc) Option {
|
||||
return func(r *Runner) {
|
||||
jobType = strings.TrimSpace(jobType)
|
||||
if jobType == "" || handler == nil {
|
||||
return
|
||||
}
|
||||
r.artifactHandlers[jobType] = handler
|
||||
}
|
||||
}
|
||||
|
||||
func NewRunner(store Store, locker Locker, cfg config.Config, opts ...Option) *Runner {
|
||||
interval := cfg.Jobs.WorkerInterval
|
||||
if interval <= 0 {
|
||||
@ -87,16 +104,17 @@ func NewRunner(store Store, locker Locker, cfg config.Config, opts ...Option) *R
|
||||
workerID = "admin-job-worker"
|
||||
}
|
||||
runner := &Runner{
|
||||
artifactDir: artifactDir,
|
||||
interval: interval,
|
||||
leaseTTL: leaseTTL,
|
||||
locker: locker,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
maxAttempts: maxAttempts,
|
||||
store: store,
|
||||
workerID: workerID,
|
||||
done: make(chan struct{}),
|
||||
stop: make(chan struct{}),
|
||||
artifactDir: artifactDir,
|
||||
interval: interval,
|
||||
leaseTTL: leaseTTL,
|
||||
locker: locker,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
artifactHandlers: make(map[string]ArtifactHandlerFunc),
|
||||
maxAttempts: maxAttempts,
|
||||
store: store,
|
||||
workerID: workerID,
|
||||
done: make(chan struct{}),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
@ -156,19 +174,69 @@ func (r *Runner) runOnce(ctx context.Context) {
|
||||
locked, unlock, err := r.lock(ctx, job)
|
||||
if err != nil {
|
||||
slog.Error("job_lock_failed", "worker_id", r.workerID, "job_id", job.ID, "error", err.Error())
|
||||
_ = r.store.ReleaseJobLease(job.ID)
|
||||
_ = r.store.ReleaseJobLease(job.ID, r.workerID)
|
||||
return
|
||||
}
|
||||
if !locked {
|
||||
_ = r.store.ReleaseJobLease(job.ID)
|
||||
_ = r.store.ReleaseJobLease(job.ID, r.workerID)
|
||||
return
|
||||
}
|
||||
err = r.handle(ctx, job)
|
||||
err = r.handleWithHeartbeat(ctx, job)
|
||||
if unlock != nil {
|
||||
_ = unlock(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
_ = r.store.FailJobAttempt(job.ID, err.Error())
|
||||
_ = r.store.FailJobAttempt(job.ID, r.workerID, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) handleWithHeartbeat(ctx context.Context, job *model.AdminJob) error {
|
||||
jobCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
heartbeatResult := make(chan error, 1)
|
||||
go r.heartbeat(jobCtx, cancel, job, heartbeatResult)
|
||||
|
||||
handleErr := r.handle(jobCtx, job)
|
||||
cancel()
|
||||
heartbeatErr := <-heartbeatResult
|
||||
if handleErr != nil {
|
||||
return handleErr
|
||||
}
|
||||
return heartbeatErr
|
||||
}
|
||||
|
||||
func (r *Runner) heartbeat(ctx context.Context, cancel context.CancelFunc, job *model.AdminJob, result chan<- error) {
|
||||
interval := r.leaseTTL / 3
|
||||
if interval < 100*time.Millisecond {
|
||||
interval = 100 * time.Millisecond
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
result <- nil
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := r.store.RenewJobLease(job.ID, r.workerID, r.leaseTTL); err != nil {
|
||||
cancel()
|
||||
result <- fmt.Errorf("renew job lease: %w", err)
|
||||
return
|
||||
}
|
||||
if r.locker != nil {
|
||||
refreshed, err := r.locker.RefreshLock(ctx, fmt.Sprintf("jobs:%d", job.ID), r.workerID, r.leaseTTL)
|
||||
if err != nil {
|
||||
cancel()
|
||||
result <- fmt.Errorf("refresh job lock: %w", err)
|
||||
return
|
||||
}
|
||||
if !refreshed {
|
||||
cancel()
|
||||
result <- errors.New("job distributed lock is no longer owned by worker")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -178,6 +246,13 @@ func (r *Runner) handle(ctx context.Context, job *model.AdminJob) error {
|
||||
case userExportJobType:
|
||||
return r.exportAdminUsers(ctx, job)
|
||||
default:
|
||||
if handler := r.artifactHandlers[strings.TrimSpace(job.Type)]; handler != nil {
|
||||
resultJSON, artifactPath, err := handler(ctx, job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.store.CompleteJobWithArtifact(job.ID, r.workerID, resultJSON, artifactPath)
|
||||
}
|
||||
handler := r.handlers[strings.TrimSpace(job.Type)]
|
||||
if handler == nil {
|
||||
return fmt.Errorf("unsupported job type: %s", job.Type)
|
||||
@ -186,7 +261,7 @@ func (r *Runner) handle(ctx context.Context, job *model.AdminJob) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.store.CompleteJobWithArtifact(job.ID, resultJSON, "")
|
||||
return r.store.CompleteJobWithArtifact(job.ID, r.workerID, resultJSON, "")
|
||||
}
|
||||
}
|
||||
|
||||
@ -212,7 +287,7 @@ func (r *Runner) exportAdminUsers(_ context.Context, job *model.AdminJob) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.store.CompleteJobWithArtifact(job.ID, string(result), artifactPath)
|
||||
return r.store.CompleteJobWithArtifact(job.ID, r.workerID, string(result), artifactPath)
|
||||
}
|
||||
|
||||
func (r *Runner) lock(ctx context.Context, job *model.AdminJob) (bool, cache.UnlockFunc, error) {
|
||||
|
||||
115
server/admin/internal/job/runner_test.go
Normal file
115
server/admin/internal/job/runner_test.go
Normal file
@ -0,0 +1,115 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/cache"
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
)
|
||||
|
||||
func TestArtifactJobRenewsDatabaseAndDistributedLeases(t *testing.T) {
|
||||
store := &heartbeatStore{}
|
||||
locker := &heartbeatLocker{}
|
||||
runner := NewRunner(store, locker, config.Config{
|
||||
NodeID: "worker-heartbeat",
|
||||
Jobs: config.JobsConfig{LeaseTTL: 150 * time.Millisecond},
|
||||
}, WithArtifactHandler("slow-export", func(ctx context.Context, _ *model.AdminJob) (string, string, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", "", ctx.Err()
|
||||
case <-time.After(350 * time.Millisecond):
|
||||
return `{"rows":1}`, "slow.csv", nil
|
||||
}
|
||||
}))
|
||||
|
||||
if err := runner.handleWithHeartbeat(context.Background(), &model.AdminJob{ID: 91, Type: "slow-export"}); err != nil {
|
||||
t.Fatalf("slow artifact handler failed: %v", err)
|
||||
}
|
||||
store.mu.Lock()
|
||||
renews, completes, completedBy := store.renews, store.completes, store.completedBy
|
||||
store.mu.Unlock()
|
||||
locker.mu.Lock()
|
||||
refreshes := locker.refreshes
|
||||
locker.mu.Unlock()
|
||||
if renews < 2 || refreshes < 2 || completes != 1 || completedBy != "worker-heartbeat" {
|
||||
t.Fatalf("long job lease was not kept alive: renews=%d refreshes=%d completes=%d worker=%q", renews, refreshes, completes, completedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactJobCancelsWhenDistributedLeaseIsLost(t *testing.T) {
|
||||
store := &heartbeatStore{}
|
||||
locker := &heartbeatLocker{loseOnRefresh: true}
|
||||
runner := NewRunner(store, locker, config.Config{
|
||||
NodeID: "worker-lost-lock",
|
||||
Jobs: config.JobsConfig{LeaseTTL: 150 * time.Millisecond},
|
||||
}, WithArtifactHandler("blocked-export", func(ctx context.Context, _ *model.AdminJob) (string, string, error) {
|
||||
<-ctx.Done()
|
||||
return "", "", ctx.Err()
|
||||
}))
|
||||
|
||||
err := runner.handleWithHeartbeat(context.Background(), &model.AdminJob{ID: 92, Type: "blocked-export"})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("lost distributed lock must cancel handler, err=%v", err)
|
||||
}
|
||||
store.mu.Lock()
|
||||
completes := store.completes
|
||||
store.mu.Unlock()
|
||||
if completes != 0 {
|
||||
t.Fatalf("worker that lost its lease must not complete the job: completes=%d", completes)
|
||||
}
|
||||
}
|
||||
|
||||
type heartbeatStore struct {
|
||||
mu sync.Mutex
|
||||
renews int
|
||||
completes int
|
||||
completedBy string
|
||||
}
|
||||
|
||||
func (s *heartbeatStore) LeaseNextJob(string, time.Duration, int) (*model.AdminJob, error) {
|
||||
return nil, errors.New("not used")
|
||||
}
|
||||
|
||||
func (s *heartbeatStore) RenewJobLease(_ uint, _ string, _ time.Duration) error {
|
||||
s.mu.Lock()
|
||||
s.renews++
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *heartbeatStore) CompleteJobWithArtifact(_ uint, workerID string, _, _ string) error {
|
||||
s.mu.Lock()
|
||||
s.completes++
|
||||
s.completedBy = workerID
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *heartbeatStore) FailJobAttempt(uint, string, string) error { return nil }
|
||||
func (s *heartbeatStore) ReleaseJobLease(uint, string) error { return nil }
|
||||
func (s *heartbeatStore) ExportUsers(repository.ListOptions) ([]model.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type heartbeatLocker struct {
|
||||
mu sync.Mutex
|
||||
refreshes int
|
||||
loseOnRefresh bool
|
||||
}
|
||||
|
||||
func (l *heartbeatLocker) TryLock(context.Context, string, string, time.Duration) (bool, cache.UnlockFunc, error) {
|
||||
return true, func(context.Context) error { return nil }, nil
|
||||
}
|
||||
|
||||
func (l *heartbeatLocker) RefreshLock(context.Context, string, string, time.Duration) (bool, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.refreshes++
|
||||
return !l.loseOnRefresh, nil
|
||||
}
|
||||
@ -125,10 +125,12 @@ func (Menu) TableName() string {
|
||||
|
||||
type AppConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Group string `gorm:"size:64;index:idx_admin_app_config_group_key,unique;not null" json:"group"`
|
||||
Key string `gorm:"size:100;index:idx_admin_app_config_group_key,unique;not null" json:"key"`
|
||||
AppCode string `gorm:"size:32;uniqueIndex:uk_admin_app_config_app_group_key;index:idx_admin_app_configs_app_group;not null" json:"appCode"`
|
||||
Group string `gorm:"size:64;uniqueIndex:uk_admin_app_config_app_group_key;index:idx_admin_app_configs_app_group;not null" json:"group"`
|
||||
Key string `gorm:"size:100;uniqueIndex:uk_admin_app_config_app_group_key;not null" json:"key"`
|
||||
Value string `gorm:"type:text" json:"value"`
|
||||
Description string `gorm:"size:255" json:"description"`
|
||||
IsDeleted bool `gorm:"column:is_deleted;not null;default:false" json:"-"`
|
||||
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
|
||||
}
|
||||
@ -549,6 +551,22 @@ func (LegacyGooglePaidDetail) TableName() string {
|
||||
return "admin_legacy_google_paid_details"
|
||||
}
|
||||
|
||||
// LegacyGooglePaidSyncAttempt persists retry state separately from paid details.
|
||||
// A row here never means "synced"; successful synchronization deletes it after the paid-detail upsert commits.
|
||||
type LegacyGooglePaidSyncAttempt struct {
|
||||
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
|
||||
TransactionID string `gorm:"size:96;primaryKey;column:transaction_id" json:"transactionId"`
|
||||
AttemptCount int `gorm:"not null;default:0" json:"attemptCount"`
|
||||
LastError string `gorm:"size:512;not null;default:''" json:"lastError"`
|
||||
NextRetryAtMS int64 `gorm:"column:next_retry_at_ms;not null;default:0;index:idx_legacy_google_paid_retry,priority:2" json:"nextRetryAtMs"`
|
||||
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli;index:idx_legacy_google_paid_retry,priority:3" json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func (LegacyGooglePaidSyncAttempt) TableName() string {
|
||||
return "admin_legacy_google_paid_sync_attempts"
|
||||
}
|
||||
|
||||
type FinanceApplication struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AppCode string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null" json:"appCode"`
|
||||
@ -628,6 +646,14 @@ func (CoinSellerRechargeOrder) TableName() string {
|
||||
return "admin_coin_seller_recharge_orders"
|
||||
}
|
||||
|
||||
// BusinessTimeMS 是财务归档和统计切日使用的订单业务时间;历史订单未保存 order_date_ms 时回退到真实录入时间。
|
||||
func (order CoinSellerRechargeOrder) BusinessTimeMS() int64 {
|
||||
if order.OrderDateMS > 0 {
|
||||
return order.OrderDateMS
|
||||
}
|
||||
return order.CreatedAtMS
|
||||
}
|
||||
|
||||
type UserWithdrawalApplication struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null" json:"appCode"`
|
||||
@ -635,6 +661,11 @@ type UserWithdrawalApplication struct {
|
||||
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
|
||||
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`
|
||||
WithdrawAmountMinor int64 `gorm:"not null;default:0" json:"withdrawAmountMinor"`
|
||||
PointFeeAmount int64 `gorm:"not null;default:0" json:"pointFeeAmount"`
|
||||
PointNetAmount int64 `gorm:"not null;default:0" json:"pointNetAmount"`
|
||||
PointsPerUSD int64 `gorm:"not null;default:100000" json:"pointsPerUsd"`
|
||||
PointFeeBPS int32 `gorm:"not null;default:500" json:"pointFeeBps"`
|
||||
PointPolicyInstance string `gorm:"column:point_policy_instance_code;size:96;not null;default:''" json:"pointPolicyInstanceCode"`
|
||||
WithdrawMethod string `gorm:"size:64;index:idx_admin_withdrawal_method;not null;default:''" json:"withdrawMethod"`
|
||||
WithdrawAddress string `gorm:"size:255;not null;default:''" json:"withdrawAddress"`
|
||||
FreezeCommandID string `gorm:"size:128;not null;default:''" json:"freezeCommandId"`
|
||||
|
||||
224
server/admin/internal/modules/appconfig/gift_combo.go
Normal file
224
server/admin/internal/modules/appconfig/gift_combo.go
Normal file
@ -0,0 +1,224 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
giftComboConfigGroup = "room-gift-combo"
|
||||
giftComboConfigKey = "default"
|
||||
)
|
||||
|
||||
// GiftComboConfigRequest is intentionally a full replacement document. Operators can inspect the
|
||||
// current normalized response before updating it, and enabled=false is therefore an unambiguous
|
||||
// one-click kill switch rather than an omitted boolean accidentally inheriting true.
|
||||
type GiftComboConfigRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIVersion int32 `json:"apiVersion"`
|
||||
WindowMS int32 `json:"windowMs"`
|
||||
MaxGiftCountPerBatch int32 `json:"maxGiftCountPerBatch"`
|
||||
MaxGiftUnitsPerBatch int32 `json:"maxGiftUnitsPerBatch"`
|
||||
MaxInFlight int32 `json:"maxInFlight"`
|
||||
MaxPendingBatches int32 `json:"maxPendingBatches"`
|
||||
RetryMaxAttempts int32 `json:"retryMaxAttempts"`
|
||||
RetryBaseDelayMS int32 `json:"retryBaseDelayMs"`
|
||||
RolloutBasisPoints int32 `json:"rolloutBasisPoints"`
|
||||
}
|
||||
|
||||
type storedGiftComboConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIVersion int32 `json:"api_version"`
|
||||
WindowMS int32 `json:"window_ms"`
|
||||
MaxGiftCountPerBatch int32 `json:"max_gift_count_per_batch"`
|
||||
MaxGiftUnitsPerBatch int32 `json:"max_gift_units_per_batch"`
|
||||
MaxInFlight int32 `json:"max_in_flight"`
|
||||
MaxPendingBatches int32 `json:"max_pending_batches"`
|
||||
RetryMaxAttempts int32 `json:"retry_max_attempts"`
|
||||
RetryBaseDelayMS int32 `json:"retry_base_delay_ms"`
|
||||
RolloutBasisPoints int32 `json:"rollout_basis_points"`
|
||||
}
|
||||
|
||||
type GiftComboConfigDTO struct {
|
||||
GiftComboConfigRequest
|
||||
AppCode string `json:"appCode"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func defaultStoredGiftComboConfig() storedGiftComboConfig {
|
||||
return storedGiftComboConfig{
|
||||
Enabled: true,
|
||||
APIVersion: 2,
|
||||
WindowMS: 300,
|
||||
MaxGiftCountPerBatch: 999,
|
||||
MaxGiftUnitsPerBatch: 5_000,
|
||||
MaxInFlight: 2,
|
||||
MaxPendingBatches: 20,
|
||||
RetryMaxAttempts: 5,
|
||||
RetryBaseDelayMS: 250,
|
||||
// 新 App 没有配置行时只展示安全的 0% 灰度,必须由管理员显式放量。
|
||||
RolloutBasisPoints: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStoredGiftComboConfig(config storedGiftComboConfig) (storedGiftComboConfig, error) {
|
||||
if config.APIVersion == 0 {
|
||||
config.APIVersion = 2
|
||||
}
|
||||
if config.APIVersion != 2 {
|
||||
// 后台只允许调整微批窗口、背压和灰度;接口版本不是运营开关,避免把当前 Flutter
|
||||
// 从具备完整结算结果重放的 V2 静默切回 V1。
|
||||
return storedGiftComboConfig{}, errors.New("apiVersion must be 2")
|
||||
}
|
||||
if config.WindowMS < 100 || config.WindowMS > 1_000 {
|
||||
return storedGiftComboConfig{}, errors.New("windowMs must be between 100 and 1000")
|
||||
}
|
||||
if config.MaxGiftCountPerBatch < 1 || config.MaxGiftCountPerBatch > 999 {
|
||||
return storedGiftComboConfig{}, errors.New("maxGiftCountPerBatch must be between 1 and 999")
|
||||
}
|
||||
if config.MaxGiftUnitsPerBatch < 1 || config.MaxGiftUnitsPerBatch > 5_000 {
|
||||
return storedGiftComboConfig{}, errors.New("maxGiftUnitsPerBatch must be between 1 and 5000")
|
||||
}
|
||||
if config.MaxInFlight < 1 || config.MaxInFlight > 2 {
|
||||
return storedGiftComboConfig{}, errors.New("maxInFlight must be between 1 and 2")
|
||||
}
|
||||
if config.MaxPendingBatches < 1 || config.MaxPendingBatches > 50 {
|
||||
return storedGiftComboConfig{}, errors.New("maxPendingBatches must be between 1 and 50")
|
||||
}
|
||||
if config.MaxPendingBatches < config.MaxInFlight {
|
||||
// The admin contract must reject policies Flutter cannot execute literally; normalizing this
|
||||
// mismatch on-device would make an audited configuration differ from production behavior.
|
||||
return storedGiftComboConfig{}, errors.New("maxPendingBatches must be greater than or equal to maxInFlight")
|
||||
}
|
||||
if config.RetryMaxAttempts < 1 || config.RetryMaxAttempts > 10 {
|
||||
return storedGiftComboConfig{}, errors.New("retryMaxAttempts must be between 1 and 10")
|
||||
}
|
||||
if config.RetryBaseDelayMS < 100 || config.RetryBaseDelayMS > 5_000 {
|
||||
return storedGiftComboConfig{}, errors.New("retryBaseDelayMs must be between 100 and 5000")
|
||||
}
|
||||
if config.RolloutBasisPoints < 0 || config.RolloutBasisPoints > 10_000 {
|
||||
return storedGiftComboConfig{}, errors.New("rolloutBasisPoints must be between 0 and 10000")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func normalizeStoredGiftComboConfigForRead(config storedGiftComboConfig) (storedGiftComboConfig, error) {
|
||||
if config.APIVersion == 1 {
|
||||
// 兼容早期已落库的灰度行,但只迁移存储标记,不恢复客户端 V1 行为。管理员下一次
|
||||
// 保存会写回 2;Update 入口仍通过严格校验拒绝新建 apiVersion=1 配置。
|
||||
config.APIVersion = 2
|
||||
}
|
||||
return normalizeStoredGiftComboConfig(config)
|
||||
}
|
||||
|
||||
func storedGiftComboConfigFromRequest(request GiftComboConfigRequest) (storedGiftComboConfig, error) {
|
||||
return normalizeStoredGiftComboConfig(storedGiftComboConfig{
|
||||
Enabled: request.Enabled,
|
||||
APIVersion: request.APIVersion,
|
||||
WindowMS: request.WindowMS,
|
||||
MaxGiftCountPerBatch: request.MaxGiftCountPerBatch,
|
||||
MaxGiftUnitsPerBatch: request.MaxGiftUnitsPerBatch,
|
||||
MaxInFlight: request.MaxInFlight,
|
||||
MaxPendingBatches: request.MaxPendingBatches,
|
||||
RetryMaxAttempts: request.RetryMaxAttempts,
|
||||
RetryBaseDelayMS: request.RetryBaseDelayMS,
|
||||
RolloutBasisPoints: request.RolloutBasisPoints,
|
||||
})
|
||||
}
|
||||
|
||||
func giftComboConfigDTO(appCode string, config storedGiftComboConfig, updatedAtMS int64) GiftComboConfigDTO {
|
||||
return GiftComboConfigDTO{
|
||||
AppCode: appctx.Normalize(appCode),
|
||||
UpdatedAtMS: updatedAtMS,
|
||||
GiftComboConfigRequest: GiftComboConfigRequest{
|
||||
Enabled: config.Enabled,
|
||||
APIVersion: config.APIVersion,
|
||||
WindowMS: config.WindowMS,
|
||||
MaxGiftCountPerBatch: config.MaxGiftCountPerBatch,
|
||||
MaxGiftUnitsPerBatch: config.MaxGiftUnitsPerBatch,
|
||||
MaxInFlight: config.MaxInFlight,
|
||||
MaxPendingBatches: config.MaxPendingBatches,
|
||||
RetryMaxAttempts: config.RetryMaxAttempts,
|
||||
RetryBaseDelayMS: config.RetryBaseDelayMS,
|
||||
RolloutBasisPoints: config.RolloutBasisPoints,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AppConfigService) GetGiftComboConfig(appCode string) (GiftComboConfigDTO, error) {
|
||||
appCode = appctx.Normalize(appCode)
|
||||
item, err := s.store.GetScopedAppConfig(appCode, giftComboConfigGroup, giftComboConfigKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return giftComboConfigDTO(appCode, defaultStoredGiftComboConfig(), 0), nil
|
||||
}
|
||||
if err != nil {
|
||||
return GiftComboConfigDTO{}, err
|
||||
}
|
||||
config := defaultStoredGiftComboConfig()
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(item.Value)), &config); err != nil {
|
||||
return GiftComboConfigDTO{}, fmt.Errorf("stored gift combo config is invalid: %w", err)
|
||||
}
|
||||
config, err = normalizeStoredGiftComboConfigForRead(config)
|
||||
if err != nil {
|
||||
return GiftComboConfigDTO{}, err
|
||||
}
|
||||
return giftComboConfigDTO(appCode, config, item.UpdatedAtMS), nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateGiftComboConfig(appCode string, request GiftComboConfigRequest) (GiftComboConfigDTO, error) {
|
||||
appCode = appctx.Normalize(appCode)
|
||||
config, err := storedGiftComboConfigFromRequest(request)
|
||||
if err != nil {
|
||||
return GiftComboConfigDTO{}, err
|
||||
}
|
||||
raw, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return GiftComboConfigDTO{}, err
|
||||
}
|
||||
// UpsertScopedAppConfigs writes only the selected app_code, so a rollout or kill switch for one App
|
||||
// cannot unexpectedly alter every tenant sharing this admin database.
|
||||
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{{
|
||||
AppCode: appCode,
|
||||
Group: giftComboConfigGroup,
|
||||
Key: giftComboConfigKey,
|
||||
Value: string(raw),
|
||||
Description: "Flutter room gift combo batching policy",
|
||||
}}); err != nil {
|
||||
return GiftComboConfigDTO{}, err
|
||||
}
|
||||
return s.GetGiftComboConfig(appCode)
|
||||
}
|
||||
|
||||
func (h *Handler) GetGiftComboConfig(c *gin.Context) {
|
||||
item, err := h.service.GetGiftComboConfig(appctx.FromContext(c.Request.Context()))
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取送礼连击配置失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateGiftComboConfig(c *gin.Context) {
|
||||
var request GiftComboConfigRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.BadRequest(c, "参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateGiftComboConfig(appctx.FromContext(c.Request.Context()), request)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLog(c, h.audit, "update-room-gift-combo", "admin_app_configs", "success", fmt.Sprintf("app_code=%s enabled=%t rollout_bps=%d", item.AppCode, item.Enabled, item.RolloutBasisPoints))
|
||||
response.OK(c, item)
|
||||
}
|
||||
60
server/admin/internal/modules/appconfig/gift_combo_test.go
Normal file
60
server/admin/internal/modules/appconfig/gift_combo_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
package appconfig
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStoredGiftComboConfigFromRequestPreservesExplicitKillSwitch(t *testing.T) {
|
||||
config, err := storedGiftComboConfigFromRequest(GiftComboConfigRequest{
|
||||
Enabled: false,
|
||||
APIVersion: 2,
|
||||
WindowMS: 300,
|
||||
MaxGiftCountPerBatch: 999,
|
||||
MaxGiftUnitsPerBatch: 5_000,
|
||||
MaxInFlight: 2,
|
||||
MaxPendingBatches: 20,
|
||||
RetryMaxAttempts: 5,
|
||||
RetryBaseDelayMS: 250,
|
||||
RolloutBasisPoints: 5_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("storedGiftComboConfigFromRequest failed: %v", err)
|
||||
}
|
||||
if config.Enabled || config.RolloutBasisPoints != 5_000 {
|
||||
t.Fatalf("explicit kill switch changed during normalization: %+v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStoredGiftComboConfigRejectsQueueWithoutBackpressure(t *testing.T) {
|
||||
config := defaultStoredGiftComboConfig()
|
||||
config.APIVersion = 1
|
||||
if _, err := normalizeStoredGiftComboConfig(config); err == nil {
|
||||
t.Fatal("apiVersion 1 must not turn the current Flutter back to the legacy HTTP contract")
|
||||
}
|
||||
config = defaultStoredGiftComboConfig()
|
||||
config.MaxInFlight = 3
|
||||
if _, err := normalizeStoredGiftComboConfig(config); err == nil {
|
||||
t.Fatal("maxInFlight above the server contract must be rejected")
|
||||
}
|
||||
config = defaultStoredGiftComboConfig()
|
||||
config.MaxPendingBatches = 0
|
||||
if _, err := normalizeStoredGiftComboConfig(config); err == nil {
|
||||
t.Fatal("unbounded/empty pending queue policy must be rejected")
|
||||
}
|
||||
config = defaultStoredGiftComboConfig()
|
||||
config.MaxInFlight = 2
|
||||
config.MaxPendingBatches = 1
|
||||
if _, err := normalizeStoredGiftComboConfig(config); err == nil {
|
||||
t.Fatal("pending queue smaller than in-flight concurrency must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStoredGiftComboConfigForReadPromotesLegacyVersionMarker(t *testing.T) {
|
||||
config := defaultStoredGiftComboConfig()
|
||||
config.APIVersion = 1
|
||||
resolved, err := normalizeStoredGiftComboConfigForRead(config)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy stored config must remain readable: %v", err)
|
||||
}
|
||||
if resolved.APIVersion != 2 {
|
||||
t.Fatalf("legacy stored marker was not promoted to V2: %+v", resolved)
|
||||
}
|
||||
}
|
||||
@ -23,7 +23,8 @@ func New(store *repository.Store, audit shared.OperationLogger) *Handler {
|
||||
}
|
||||
|
||||
func (h *Handler) ListH5Links(c *gin.Context) {
|
||||
items, err := h.service.ListH5Links()
|
||||
appCode := appctx.FromContext(c.Request.Context())
|
||||
items, err := h.service.ListH5Links(appCode)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取 H5 配置失败")
|
||||
return
|
||||
@ -38,12 +39,13 @@ func (h *Handler) UpdateH5Links(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.service.UpdateH5Links(request)
|
||||
appCode := appctx.FromContext(c.Request.Context())
|
||||
items, err := h.service.UpdateH5Links(appCode, request)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLog(c, h.audit, "update-app-h5-links", "admin_app_configs", "success", fmt.Sprintf("%d h5 links", len(request.Items)))
|
||||
shared.OperationLog(c, h.audit, "update-app-h5-links", "admin_app_configs", "success", fmt.Sprintf("app_code=%s %d h5 links", appCode, len(request.Items)))
|
||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
@ -53,12 +55,13 @@ func (h *Handler) CreateH5Link(c *gin.Context) {
|
||||
response.BadRequest(c, "参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateH5Link(request)
|
||||
appCode := appctx.FromContext(c.Request.Context())
|
||||
item, err := h.service.CreateH5Link(appCode, request)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLog(c, h.audit, "create-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", item.Key))
|
||||
shared.OperationLog(c, h.audit, "create-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("app_code=%s h5_key=%s", appCode, item.Key))
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
@ -73,12 +76,13 @@ func (h *Handler) UpdateH5Link(c *gin.Context) {
|
||||
response.BadRequest(c, "参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateH5Link(key, request)
|
||||
appCode := appctx.FromContext(c.Request.Context())
|
||||
item, err := h.service.UpdateH5Link(appCode, key, request)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLog(c, h.audit, "update-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", item.Key))
|
||||
shared.OperationLog(c, h.audit, "update-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("app_code=%s h5_key=%s", appCode, item.Key))
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
@ -88,11 +92,12 @@ func (h *Handler) DeleteH5Link(c *gin.Context) {
|
||||
response.BadRequest(c, "h5 config key is invalid")
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteH5Link(key); err != nil {
|
||||
appCode := appctx.FromContext(c.Request.Context())
|
||||
if err := h.service.DeleteH5Link(appCode, key); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLog(c, h.audit, "delete-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", key))
|
||||
shared.OperationLog(c, h.audit, "delete-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("app_code=%s h5_key=%s", appCode, key))
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.PUT("/admin/app-config/h5-links", middleware.RequirePermission("app-config:update"), h.UpdateH5Links)
|
||||
protected.PUT("/admin/app-config/h5-links/:key", middleware.RequirePermission("app-config:update"), h.UpdateH5Link)
|
||||
protected.DELETE("/admin/app-config/h5-links/:key", middleware.RequirePermission("app-config:update"), h.DeleteH5Link)
|
||||
protected.GET("/admin/app-config/gift-combo", middleware.RequirePermission("app-config:view"), h.GetGiftComboConfig)
|
||||
protected.PUT("/admin/app-config/gift-combo", middleware.RequirePermission("app-config:update"), h.UpdateGiftComboConfig)
|
||||
|
||||
protected.GET("/admin/app-config/explore-tabs", middleware.RequirePermission("app-config:view"), h.ListExploreTabs)
|
||||
protected.POST("/admin/app-config/explore-tabs", middleware.RequirePermission("app-config:update"), h.CreateExploreTab)
|
||||
|
||||
@ -11,6 +11,8 @@ import (
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const h5LinkGroup = "h5-links"
|
||||
@ -35,6 +37,7 @@ type AppConfigService struct {
|
||||
}
|
||||
|
||||
type H5Link struct {
|
||||
AppCode string `json:"appCode"`
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
@ -126,8 +129,10 @@ func NewService(store *repository.Store) *AppConfigService {
|
||||
return &AppConfigService{store: store}
|
||||
}
|
||||
|
||||
func (s *AppConfigService) ListH5Links() ([]H5Link, error) {
|
||||
configs, err := s.store.ListAppConfigs(h5LinkGroup)
|
||||
func (s *AppConfigService) ListH5Links(appCode string) ([]H5Link, error) {
|
||||
appCode = appctx.Normalize(appCode)
|
||||
// H5 是 App 品牌和运营内容的一部分,未配置时必须返回空列表,不能继承其他 App 的历史链接。
|
||||
configs, err := s.store.ListOwnedAppConfigs(appCode, h5LinkGroup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -139,7 +144,8 @@ func (s *AppConfigService) ListH5Links() ([]H5Link, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link, error) {
|
||||
func (s *AppConfigService) UpdateH5Links(appCode string, request updateH5LinksRequest) ([]H5Link, error) {
|
||||
appCode = appctx.Normalize(appCode)
|
||||
if len(request.Items) == 0 {
|
||||
return nil, errors.New("items are required")
|
||||
}
|
||||
@ -147,7 +153,7 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link
|
||||
items := make([]model.AppConfig, 0, len(request.Items))
|
||||
seen := make(map[string]struct{}, len(request.Items))
|
||||
for _, item := range request.Items {
|
||||
config, err := h5LinkModelFromPayload(item)
|
||||
config, err := h5LinkModelFromPayload(appCode, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -159,43 +165,62 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link
|
||||
items = append(items, config)
|
||||
}
|
||||
|
||||
if err := s.store.UpsertAppConfigs(items); err != nil {
|
||||
if err := s.store.UpsertScopedAppConfigs(appCode, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListH5Links()
|
||||
return s.ListH5Links(appCode)
|
||||
}
|
||||
|
||||
func (s *AppConfigService) CreateH5Link(req h5LinkPayload) (H5Link, error) {
|
||||
item, err := h5LinkModelFromPayload(req)
|
||||
func (s *AppConfigService) CreateH5Link(appCode string, req h5LinkPayload) (H5Link, error) {
|
||||
appCode = appctx.Normalize(appCode)
|
||||
item, err := h5LinkModelFromPayload(appCode, req)
|
||||
if err != nil {
|
||||
return H5Link{}, err
|
||||
}
|
||||
if err := s.store.CreateAppConfig(&item); err != nil {
|
||||
if _, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, item.Key); err == nil {
|
||||
return H5Link{}, fmt.Errorf("h5 link key already exists: %s", item.Key)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return H5Link{}, err
|
||||
}
|
||||
return h5LinkFromModel(item), nil
|
||||
// upsert 只以三元唯一键写当前 App,重试不会触碰其他 App 的同名 H5 key。
|
||||
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{item}); err != nil {
|
||||
return H5Link{}, err
|
||||
}
|
||||
stored, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, item.Key)
|
||||
if err != nil {
|
||||
return H5Link{}, err
|
||||
}
|
||||
return h5LinkFromModel(stored), nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateH5Link(key string, req h5LinkPayload) (H5Link, error) {
|
||||
item, err := s.store.GetAppConfig(h5LinkGroup, key)
|
||||
func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPayload) (H5Link, error) {
|
||||
appCode = appctx.Normalize(appCode)
|
||||
item, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, key)
|
||||
if err != nil {
|
||||
return H5Link{}, err
|
||||
}
|
||||
req.Key = item.Key
|
||||
updated, err := h5LinkModelFromPayload(req)
|
||||
updated, err := h5LinkModelFromPayload(appCode, req)
|
||||
if err != nil {
|
||||
return H5Link{}, err
|
||||
}
|
||||
item.Value = updated.Value
|
||||
item.Description = updated.Description
|
||||
if err := s.store.UpdateAppConfig(&item); err != nil {
|
||||
// 请求作用域决定 app_code;请求体只能更新当前 App 已存在的 key。
|
||||
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{updated}); err != nil {
|
||||
return H5Link{}, err
|
||||
}
|
||||
return h5LinkFromModel(item), nil
|
||||
stored, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, updated.Key)
|
||||
if err != nil {
|
||||
return H5Link{}, err
|
||||
}
|
||||
return h5LinkFromModel(stored), nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) DeleteH5Link(key string) error {
|
||||
return s.store.DeleteAppConfig(h5LinkGroup, key)
|
||||
func (s *AppConfigService) DeleteH5Link(appCode string, key string) error {
|
||||
appCode = appctx.Normalize(appCode)
|
||||
if _, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, key); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.DeleteOwnedAppConfig(appCode, h5LinkGroup, key)
|
||||
}
|
||||
|
||||
func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) {
|
||||
@ -517,7 +542,7 @@ func validateH5URL(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func h5LinkModelFromPayload(req h5LinkPayload) (model.AppConfig, error) {
|
||||
func h5LinkModelFromPayload(appCode string, req h5LinkPayload) (model.AppConfig, error) {
|
||||
key := strings.TrimSpace(req.Key)
|
||||
label := strings.TrimSpace(req.Label)
|
||||
url := strings.TrimSpace(req.URL)
|
||||
@ -534,6 +559,7 @@ func h5LinkModelFromPayload(req h5LinkPayload) (model.AppConfig, error) {
|
||||
return model.AppConfig{}, err
|
||||
}
|
||||
return model.AppConfig{
|
||||
AppCode: appctx.Normalize(appCode),
|
||||
Group: h5LinkGroup,
|
||||
Key: key,
|
||||
Value: url,
|
||||
@ -564,6 +590,7 @@ func h5LinkFromModel(config model.AppConfig) H5Link {
|
||||
label = config.Key
|
||||
}
|
||||
return H5Link{
|
||||
AppCode: appctx.Normalize(config.AppCode),
|
||||
Key: config.Key,
|
||||
Label: label,
|
||||
URL: config.Value,
|
||||
|
||||
@ -1,14 +1,59 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/model"
|
||||
)
|
||||
|
||||
func TestH5AppScopeSchemaMigrationCreatesScopedKeys(t *testing.T) {
|
||||
body, err := os.ReadFile("../../../migrations/086_app_config_app_scope.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read h5 app scope migration failed: %v", err)
|
||||
}
|
||||
sqlText := string(body)
|
||||
for _, snippet := range []string{
|
||||
"app_code VARCHAR(32) NOT NULL DEFAULT ''",
|
||||
"is_deleted BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"DROP INDEX uk_admin_app_configs_group_key",
|
||||
"uk_admin_app_config_app_group_key (app_code, `group`, `key`)",
|
||||
} {
|
||||
if !strings.Contains(sqlText, snippet) {
|
||||
t.Fatalf("h5 app scope migration missing %q", snippet)
|
||||
}
|
||||
}
|
||||
// 086 只负责补齐通用 scoped 存储结构;091 再按 H5 的强隔离边界迁走历史空作用域数据。
|
||||
for _, appCode := range []string{"huwaa", "fami", "yumi", "aslan"} {
|
||||
if strings.Contains(sqlText, "'"+appCode+"'") {
|
||||
t.Fatalf("migration must not hard-code app_code %q", appCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestH5StrictAppScopeMigrationMovesLegacyLinksToDefaultApp(t *testing.T) {
|
||||
body, err := os.ReadFile("../../../migrations/091_h5_config_strict_app_scope.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read strict h5 app scope migration failed: %v", err)
|
||||
}
|
||||
sqlText := string(body)
|
||||
for _, snippet := range []string{
|
||||
"scoped.app_code = 'lalu'",
|
||||
"legacy.app_code = ''",
|
||||
"legacy.`group` = 'h5-links'",
|
||||
"SET app_code = 'lalu'",
|
||||
"`group` = 'h5-links'",
|
||||
} {
|
||||
if !strings.Contains(sqlText, snippet) {
|
||||
t.Fatalf("strict h5 app scope migration missing %q", snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
|
||||
item, err := h5LinkModelFromPayload(h5LinkPayload{
|
||||
item, err := h5LinkModelFromPayload(" YUMI ", h5LinkPayload{
|
||||
Key: "host-center.v2",
|
||||
Label: "Host Center",
|
||||
URL: "https://h5.example.com/host",
|
||||
@ -16,13 +61,13 @@ func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("h5 config should be valid: %v", err)
|
||||
}
|
||||
if item.Group != h5LinkGroup || item.Key != "host-center.v2" || item.Description != "Host Center" || item.Value != "https://h5.example.com/host" {
|
||||
if item.AppCode != "yumi" || item.Group != h5LinkGroup || item.Key != "host-center.v2" || item.Description != "Host Center" || item.Value != "https://h5.example.com/host" {
|
||||
t.Fatalf("h5 config model mismatch: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestH5LinkModelFromPayloadRejectsInvalidKey(t *testing.T) {
|
||||
_, err := h5LinkModelFromPayload(h5LinkPayload{
|
||||
_, err := h5LinkModelFromPayload("lalu", h5LinkPayload{
|
||||
Key: "host center",
|
||||
Label: "Host Center",
|
||||
URL: "https://h5.example.com/host",
|
||||
|
||||
231
server/admin/internal/modules/appuser/detail_extras.go
Normal file
231
server/admin/internal/modules/appuser/detail_extras.go
Normal file
@ -0,0 +1,231 @@
|
||||
package appuser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// AppUserThirdPartyIdentity 暴露三方绑定 openid 事实:openid = third_party_identities.provider_subject。
|
||||
type AppUserThirdPartyIdentity struct {
|
||||
Provider string `json:"provider"`
|
||||
OpenID string `json:"openid"`
|
||||
UnionID string `json:"unionId"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
}
|
||||
|
||||
// AppUserDevice 是设备 tab 的一行设备档案,来自 user_devices 认证成功聚合。
|
||||
type AppUserDevice struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
Platform string `json:"platform"`
|
||||
DeviceModel string `json:"deviceModel"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
OSVersion string `json:"osVersion"`
|
||||
IMEI string `json:"imei"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
BuildNumber string `json:"buildNumber"`
|
||||
FirstSeenAtMs int64 `json:"firstSeenAtMs"`
|
||||
LastSeenAtMs int64 `json:"lastSeenAtMs"`
|
||||
}
|
||||
|
||||
// AppUserAccessToken 是后台按需重签的 access token 结果;不含 refresh token,取用行为已写操作日志。
|
||||
type AppUserAccessToken struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
TokenType string `json:"tokenType"`
|
||||
ExpiresInSec int64 `json:"expiresInSec"`
|
||||
SessionID string `json:"sessionId"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
SessionLastHeartbeatAtMs int64 `json:"sessionLastHeartbeatAtMs"`
|
||||
}
|
||||
|
||||
// accessTokenIssuerClient 只暴露后台重签 token 的 RPC;与 userAdminProjectionClient 相同,
|
||||
// 通过类型断言探测能力,避免为单一后台功能扩大 userclient.Client 主契约。
|
||||
type accessTokenIssuerClient interface {
|
||||
AdminIssueUserAccessToken(context.Context, *userv1.AdminIssueUserAccessTokenRequest) (*userv1.AdminIssueUserAccessTokenResponse, error)
|
||||
}
|
||||
|
||||
// loginAuditSnapshotSQL 按单个 login_type 等值命中 idx_login_audit_latest_success 后取最新一条。
|
||||
// 不能对多个 login_type 用 IN + 全局 ORDER BY:在线用户 access_resign 行每天数千条,索引范围必须逐类型收敛。
|
||||
const loginAuditSnapshotSQL = `
|
||||
(SELECT COALESCE(audit.client_ip, '') AS client_ip, audit.ip_country_code, audit.app_version, audit.build_number, audit.created_at_ms, audit.id
|
||||
FROM login_audit AS audit
|
||||
WHERE audit.app_code = ? AND audit.user_id = ? AND audit.result = 'success' AND audit.blocked = 0 AND audit.login_type = ?
|
||||
ORDER BY audit.created_at_ms DESC, audit.id DESC
|
||||
LIMIT 1)`
|
||||
|
||||
// realLoginTypes 与 dashboard 用户版本统计口径一致:只有真实登录/续期链路携带客户端版本头。
|
||||
// access_resign 是 25s 心跳重签,app_version 恒为空,只用于 IP 新鲜度。
|
||||
var realLoginTypes = []string{"password", "third_party", "refresh"}
|
||||
|
||||
// ipLoginTypes 在真实登录之外追加 access_resign:在线用户的最新 IP 由心跳审计行提供(≤25s 旧)。
|
||||
var ipLoginTypes = []string{"password", "third_party", "refresh", "access_resign"}
|
||||
|
||||
// fillLoginSnapshots 填充详情页的最新登录 IP 和当前 App 版本快照,两者口径不同必须分开取:
|
||||
// IP 含 access_resign(新鲜度优先),版本排除 access_resign(心跳行版本恒为空,会把口径打回 unknown)。
|
||||
func (s *Service) fillLoginSnapshots(ctx context.Context, item *AppUser, userID int64) error {
|
||||
if s.userDB == nil || item == nil {
|
||||
return nil
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
|
||||
ip, ipCountry, _, _, ipAtMs, err := s.queryLatestLoginAudit(ctx, appCode, userID, ipLoginTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.LastLoginIP = ip
|
||||
item.LastLoginIPCountryCode = ipCountry
|
||||
item.LastLoginIPAtMs = ipAtMs
|
||||
|
||||
_, _, appVersion, buildNumber, versionAtMs, err := s.queryLatestLoginAudit(ctx, appCode, userID, realLoginTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.AppVersion = appVersion
|
||||
item.BuildNumber = buildNumber
|
||||
item.AppVersionAtMs = versionAtMs
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryLatestLoginAudit 对给定登录类型集合做“每类型 LIMIT 1 + UNION ALL 再取最新”的单用户查询。
|
||||
// 没有任何成功登录时返回零值而不是错误:老用户/被清审计的用户详情页仍要能打开。
|
||||
func (s *Service) queryLatestLoginAudit(ctx context.Context, appCode string, userID int64, loginTypes []string) (ip string, ipCountry string, appVersion string, buildNumber string, createdAtMs int64, err error) {
|
||||
if len(loginTypes) == 0 {
|
||||
return "", "", "", "", 0, nil
|
||||
}
|
||||
querySQL := ""
|
||||
args := make([]any, 0, len(loginTypes)*3)
|
||||
for i, loginType := range loginTypes {
|
||||
if i > 0 {
|
||||
querySQL += "\n\tUNION ALL\n"
|
||||
}
|
||||
querySQL += loginAuditSnapshotSQL
|
||||
args = append(args, appCode, userID, loginType)
|
||||
}
|
||||
// UNION ALL 的列名取第一个子查询的别名;外层只按 (created_at_ms, id) 在最多 len(loginTypes) 条候选里挑最新。
|
||||
querySQL = `
|
||||
SELECT candidate.client_ip, candidate.ip_country_code, candidate.app_version, candidate.build_number, candidate.created_at_ms
|
||||
FROM (` + querySQL + `
|
||||
) AS candidate
|
||||
ORDER BY candidate.created_at_ms DESC, candidate.id DESC
|
||||
LIMIT 1`
|
||||
|
||||
scanErr := s.userDB.QueryRowContext(ctx, querySQL, args...).Scan(&ip, &ipCountry, &appVersion, &buildNumber, &createdAtMs)
|
||||
if errors.Is(scanErr, sql.ErrNoRows) {
|
||||
return "", "", "", "", 0, nil
|
||||
}
|
||||
if scanErr != nil {
|
||||
return "", "", "", "", 0, scanErr
|
||||
}
|
||||
return ip, ipCountry, appVersion, buildNumber, createdAtMs, nil
|
||||
}
|
||||
|
||||
// fillThirdPartyIdentities 读取用户全部三方绑定;idx_third_party_user_id(app_code, user_id) 覆盖查询。
|
||||
func (s *Service) fillThirdPartyIdentities(ctx context.Context, item *AppUser, userID int64) error {
|
||||
if s.userDB == nil || item == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.userDB.QueryContext(ctx, `
|
||||
SELECT provider, provider_subject, COALESCE(provider_union_id, ''), created_at_ms
|
||||
FROM third_party_identities
|
||||
WHERE app_code = ? AND user_id = ?
|
||||
ORDER BY provider ASC, created_at_ms ASC
|
||||
`, appctx.FromContext(ctx), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
identities := make([]AppUserThirdPartyIdentity, 0, 2)
|
||||
for rows.Next() {
|
||||
var identity AppUserThirdPartyIdentity
|
||||
if err := rows.Scan(&identity.Provider, &identity.OpenID, &identity.UnionID, &identity.CreatedAtMs); err != nil {
|
||||
return err
|
||||
}
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
item.ThirdPartyIdentities = identities
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// fillDevices 读取设备档案。user_devices 由 user-service 迁移 013 引入,线上 DDL 人工执行,
|
||||
// 表尚未创建(Error 1146)时按空列表处理,不能让整个用户详情 500。
|
||||
func (s *Service) fillDevices(ctx context.Context, item *AppUser, userID int64) error {
|
||||
if s.userDB == nil || item == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.userDB.QueryContext(ctx, `
|
||||
SELECT device_id, platform, device_model, manufacturer, os_version, imei, app_version, build_number, first_seen_at_ms, last_seen_at_ms
|
||||
FROM user_devices
|
||||
WHERE app_code = ? AND user_id = ?
|
||||
ORDER BY last_seen_at_ms DESC
|
||||
LIMIT 20
|
||||
`, appctx.FromContext(ctx), userID)
|
||||
if err != nil {
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1146 {
|
||||
item.Devices = []AppUserDevice{}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
devices := make([]AppUserDevice, 0, 4)
|
||||
for rows.Next() {
|
||||
var device AppUserDevice
|
||||
if err := rows.Scan(
|
||||
&device.DeviceID,
|
||||
&device.Platform,
|
||||
&device.DeviceModel,
|
||||
&device.Manufacturer,
|
||||
&device.OSVersion,
|
||||
&device.IMEI,
|
||||
&device.AppVersion,
|
||||
&device.BuildNumber,
|
||||
&device.FirstSeenAtMs,
|
||||
&device.LastSeenAtMs,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
devices = append(devices, device)
|
||||
}
|
||||
item.Devices = devices
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// IssueAccessToken 调 user-service 按用户最新 active session 重签 access token。
|
||||
// NotFound(无活跃会话)原样向上抛,由 handler 映射成前端可直接展示的 404 文案。
|
||||
func (s *Service) IssueAccessToken(ctx context.Context, userID int64) (AppUserAccessToken, error) {
|
||||
if s.tokenIssuerClient == nil {
|
||||
return AppUserAccessToken{}, fmt.Errorf("user client is not configured")
|
||||
}
|
||||
nowMs := nowMillis()
|
||||
appCode := appctx.FromContext(ctx)
|
||||
resp, err := s.tokenIssuerClient.AdminIssueUserAccessToken(ctx, &userv1.AdminIssueUserAccessTokenRequest{
|
||||
Meta: &userv1.RequestMeta{
|
||||
RequestId: fmt.Sprintf("admin-app-user-token-%d", nowMs),
|
||||
Caller: "hyapp-admin-server",
|
||||
SentAtMs: nowMs,
|
||||
AppCode: appCode,
|
||||
},
|
||||
AppCode: appCode,
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return AppUserAccessToken{}, err
|
||||
}
|
||||
return AppUserAccessToken{
|
||||
AccessToken: resp.GetAccessToken(),
|
||||
TokenType: resp.GetTokenType(),
|
||||
ExpiresInSec: resp.GetExpiresInSec(),
|
||||
SessionID: resp.GetSessionId(),
|
||||
DeviceID: resp.GetDeviceId(),
|
||||
SessionLastHeartbeatAtMs: resp.GetSessionLastHeartbeatAtMs(),
|
||||
}, nil
|
||||
}
|
||||
175
server/admin/internal/modules/appuser/detail_extras_test.go
Normal file
175
server/admin/internal/modules/appuser/detail_extras_test.go
Normal file
@ -0,0 +1,175 @@
|
||||
package appuser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestFillLoginSnapshotsSplitsIPAndVersionScopes(t *testing.T) {
|
||||
// IP 口径包含 access_resign(在线用户 25s 心跳行提供最新 IP),版本口径必须排除它(心跳行版本恒为空)。
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := &Service{userDB: db}
|
||||
|
||||
columns := []string{"client_ip", "ip_country_code", "app_version", "build_number", "created_at_ms"}
|
||||
// 第一条查询(IP 口径)带 access_resign 参数;第二条(版本口径)只有三种真实登录类型。
|
||||
mock.ExpectQuery(`FROM \(`).
|
||||
WithArgs("lalu", int64(42), "password", "lalu", int64(42), "third_party", "lalu", int64(42), "refresh", "lalu", int64(42), "access_resign").
|
||||
WillReturnRows(sqlmock.NewRows(columns).AddRow("203.0.113.9", "SA", "", "", int64(9000)))
|
||||
mock.ExpectQuery(`FROM \(`).
|
||||
WithArgs("lalu", int64(42), "password", "lalu", int64(42), "third_party", "lalu", int64(42), "refresh").
|
||||
WillReturnRows(sqlmock.NewRows(columns).AddRow("198.51.100.7", "SA", "1.4.2", "142", int64(7000)))
|
||||
|
||||
item := AppUser{}
|
||||
if err := service.fillLoginSnapshots(context.Background(), &item, 42); err != nil {
|
||||
t.Fatalf("fillLoginSnapshots failed: %v", err)
|
||||
}
|
||||
if item.LastLoginIP != "203.0.113.9" || item.LastLoginIPCountryCode != "SA" || item.LastLoginIPAtMs != 9000 {
|
||||
t.Fatalf("last login ip snapshot mismatch: %+v", item)
|
||||
}
|
||||
if item.AppVersion != "1.4.2" || item.BuildNumber != "142" || item.AppVersionAtMs != 7000 {
|
||||
t.Fatalf("app version snapshot mismatch: %+v", item)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillLoginSnapshotsToleratesUserWithoutAudit(t *testing.T) {
|
||||
// 老用户或审计被清理时详情页仍要能打开,各字段落零值而不是 500。
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := &Service{userDB: db}
|
||||
|
||||
columns := []string{"client_ip", "ip_country_code", "app_version", "build_number", "created_at_ms"}
|
||||
mock.ExpectQuery(`FROM \(`).WillReturnRows(sqlmock.NewRows(columns))
|
||||
mock.ExpectQuery(`FROM \(`).WillReturnRows(sqlmock.NewRows(columns))
|
||||
|
||||
item := AppUser{}
|
||||
if err := service.fillLoginSnapshots(context.Background(), &item, 43); err != nil {
|
||||
t.Fatalf("fillLoginSnapshots failed: %v", err)
|
||||
}
|
||||
if item.LastLoginIP != "" || item.LastLoginIPAtMs != 0 || item.AppVersion != "" || item.AppVersionAtMs != 0 {
|
||||
t.Fatalf("empty audit must produce zero values: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillDevicesToleratesMissingTable(t *testing.T) {
|
||||
// user_devices 由人工执行 DDL 引入;表未创建(Error 1146)时详情页按空列表展示,不能整页失败。
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := &Service{userDB: db}
|
||||
|
||||
mock.ExpectQuery(`FROM user_devices`).WillReturnError(&mysqlDriver.MySQLError{Number: 1146, Message: "Table 'hyapp_user.user_devices' doesn't exist"})
|
||||
|
||||
item := AppUser{}
|
||||
if err := service.fillDevices(context.Background(), &item, 42); err != nil {
|
||||
t.Fatalf("fillDevices must tolerate missing table: %v", err)
|
||||
}
|
||||
if item.Devices == nil || len(item.Devices) != 0 {
|
||||
t.Fatalf("missing table must yield empty device list: %#v", item.Devices)
|
||||
}
|
||||
|
||||
// 其他数据库错误必须继续上抛,不能被 1146 分支吞掉。
|
||||
mock.ExpectQuery(`FROM user_devices`).WillReturnError(&mysqlDriver.MySQLError{Number: 1054, Message: "Unknown column"})
|
||||
if err := service.fillDevices(context.Background(), &item, 42); err == nil {
|
||||
t.Fatalf("non-1146 errors must propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillDevicesScansRowsInLastSeenOrder(t *testing.T) {
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := &Service{userDB: db}
|
||||
|
||||
rows := sqlmock.NewRows([]string{"device_id", "platform", "device_model", "manufacturer", "os_version", "imei", "app_version", "build_number", "first_seen_at_ms", "last_seen_at_ms"}).
|
||||
AddRow("android_abc", "android", "Redmi Note 8 Pro", "Xiaomi", "13", "", "1.4.2", "142", int64(1000), int64(9000)).
|
||||
AddRow("ios_def", "ios", "iPhone17,2", "Apple", "18.1", "", "1.4.0", "140", int64(500), int64(8000))
|
||||
mock.ExpectQuery(`FROM user_devices`).WithArgs("lalu", int64(42)).WillReturnRows(rows)
|
||||
|
||||
item := AppUser{}
|
||||
if err := service.fillDevices(context.Background(), &item, 42); err != nil {
|
||||
t.Fatalf("fillDevices failed: %v", err)
|
||||
}
|
||||
if len(item.Devices) != 2 || item.Devices[0].DeviceID != "android_abc" || item.Devices[1].Manufacturer != "Apple" {
|
||||
t.Fatalf("device rows mismatch: %#v", item.Devices)
|
||||
}
|
||||
if item.Devices[0].LastSeenAtMs != 9000 || item.Devices[0].FirstSeenAtMs != 1000 {
|
||||
t.Fatalf("device timestamps mismatch: %#v", item.Devices[0])
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTokenIssuerClient struct {
|
||||
response *userv1.AdminIssueUserAccessTokenResponse
|
||||
err error
|
||||
lastReq *userv1.AdminIssueUserAccessTokenRequest
|
||||
}
|
||||
|
||||
func (f *fakeTokenIssuerClient) AdminIssueUserAccessToken(_ context.Context, req *userv1.AdminIssueUserAccessTokenRequest) (*userv1.AdminIssueUserAccessTokenResponse, error) {
|
||||
f.lastReq = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.response, nil
|
||||
}
|
||||
|
||||
func TestIssueAccessTokenMapsResponseAndAppCode(t *testing.T) {
|
||||
fake := &fakeTokenIssuerClient{response: &userv1.AdminIssueUserAccessTokenResponse{
|
||||
AccessToken: "jwt-token",
|
||||
TokenType: "Bearer",
|
||||
ExpiresInSec: 3600,
|
||||
SessionId: "sess_1",
|
||||
DeviceId: "android_abc",
|
||||
SessionLastHeartbeatAtMs: 123456,
|
||||
}}
|
||||
service := &Service{tokenIssuerClient: fake}
|
||||
|
||||
result, err := service.IssueAccessToken(context.Background(), 42)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessToken failed: %v", err)
|
||||
}
|
||||
if result.AccessToken != "jwt-token" || result.TokenType != "Bearer" || result.ExpiresInSec != 3600 ||
|
||||
result.SessionID != "sess_1" || result.DeviceID != "android_abc" || result.SessionLastHeartbeatAtMs != 123456 {
|
||||
t.Fatalf("token result mismatch: %+v", result)
|
||||
}
|
||||
// app_code 必须同时写入显式字段和 meta,user-service 端优先消费显式字段。
|
||||
if fake.lastReq.GetUserId() != 42 || fake.lastReq.GetAppCode() != "lalu" || fake.lastReq.GetMeta().GetAppCode() != "lalu" {
|
||||
t.Fatalf("request app scope mismatch: %+v", fake.lastReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAccessTokenPropagatesNotFound(t *testing.T) {
|
||||
// 无活跃会话时 user-service 返回 NotFound;service 层原样透传,由 handler 映射成 404 中文文案。
|
||||
fake := &fakeTokenIssuerClient{err: status.Error(codes.NotFound, "active session not found")}
|
||||
service := &Service{tokenIssuerClient: fake}
|
||||
|
||||
_, err := service.IssueAccessToken(context.Background(), 42)
|
||||
if grpcStatus, ok := status.FromError(err); !ok || grpcStatus.Code() != codes.NotFound {
|
||||
t.Fatalf("expected NotFound passthrough, got %v", err)
|
||||
}
|
||||
|
||||
var missing *Service = &Service{}
|
||||
if _, err := missing.IssueAccessToken(context.Background(), 42); err == nil || errors.Is(err, nil) {
|
||||
t.Fatalf("nil token client must fail closed")
|
||||
}
|
||||
}
|
||||
271
server/admin/internal/modules/appuser/export.go
Normal file
271
server/admin/internal/modules/appuser/export.go
Normal file
@ -0,0 +1,271 @@
|
||||
package appuser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const AppUserExportJobType = "app-user-export"
|
||||
|
||||
type appUserExportJobStore interface {
|
||||
CreateJob(job *model.AdminJob) error
|
||||
}
|
||||
|
||||
type appUserExportPayload struct {
|
||||
AppCode string `json:"appCode"`
|
||||
Query listQuery `json:"query"`
|
||||
}
|
||||
|
||||
type appUserExportJobResponse struct {
|
||||
JobID uint `json:"jobId"`
|
||||
Status string `json:"status"`
|
||||
SnapshotAtMs int64 `json:"snapshotAtMs"`
|
||||
}
|
||||
|
||||
type appUserExportResult struct {
|
||||
JobID uint `json:"jobId"`
|
||||
Rows int64 `json:"rows"`
|
||||
Artifact string `json:"artifact"`
|
||||
DownloadPath string `json:"downloadPath"`
|
||||
SnapshotAtMs int64 `json:"snapshotAtMs"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateExportJob(ctx context.Context, actor shared.Actor, query listQuery) (*appUserExportJobResponse, error) {
|
||||
if s.jobStore == nil || !s.jobsConfig.Enabled {
|
||||
return nil, errors.New("admin jobs are not enabled")
|
||||
}
|
||||
query = normalizeListQuery(query)
|
||||
query = stableAppUserExportQuery(query)
|
||||
query.Page = 1
|
||||
query.PageSize = 100
|
||||
query.SnapshotAtMs = nowMillis()
|
||||
payload := appUserExportPayload{
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
Query: query,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
job := model.AdminJob{
|
||||
Type: AppUserExportJobType,
|
||||
Status: model.JobStatusPending,
|
||||
PayloadJSON: string(body),
|
||||
MaxAttempts: s.jobsConfig.MaxAttempts,
|
||||
CreatedBy: actor.UserID,
|
||||
CreatedByName: strings.TrimSpace(actor.Username),
|
||||
}
|
||||
if job.MaxAttempts <= 0 {
|
||||
job.MaxAttempts = 3
|
||||
}
|
||||
if err := s.jobStore.CreateJob(&job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &appUserExportJobResponse{JobID: job.ID, Status: job.Status, SnapshotAtMs: query.SnapshotAtMs}, nil
|
||||
}
|
||||
|
||||
// HandleExportJob 使用创建任务时固化的 App 与筛选条件逐页读取;任务执行期间不会重新解释前端 query。
|
||||
func (s *Service) HandleExportJob(ctx context.Context, job *model.AdminJob) (string, string, error) {
|
||||
if job == nil || job.ID == 0 {
|
||||
return "", "", errors.New("job is required")
|
||||
}
|
||||
var payload appUserExportPayload
|
||||
if err := json.Unmarshal([]byte(job.PayloadJSON), &payload); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
payload.AppCode = strings.TrimSpace(payload.AppCode)
|
||||
payload.Query = normalizeListQuery(payload.Query)
|
||||
payload.Query = stableAppUserExportQuery(payload.Query)
|
||||
if payload.AppCode == "" || payload.Query.SnapshotAtMs <= 0 {
|
||||
return "", "", ErrInvalidArgument
|
||||
}
|
||||
|
||||
artifactDir := strings.TrimSpace(s.jobsConfig.ArtifactDir)
|
||||
if artifactDir == "" {
|
||||
artifactDir = "storage/exports"
|
||||
}
|
||||
if err := os.MkdirAll(artifactDir, 0o750); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
file, err := os.CreateTemp(artifactDir, fmt.Sprintf("app-user-export-%d-*.csv", job.ID))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
artifactName := filepath.Base(file.Name())
|
||||
succeeded := false
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if !succeeded {
|
||||
_ = os.Remove(file.Name())
|
||||
}
|
||||
}()
|
||||
|
||||
// UTF-8 BOM 让运营直接使用 Excel 打开中文昵称、国家和身份时不出现乱码。
|
||||
if _, err := file.WriteString("\xEF\xBB\xBF"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
writer := csv.NewWriter(file)
|
||||
if err := writer.Write(appUserExportHeaders()); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
exportCtx := appctx.WithContext(ctx, payload.AppCode)
|
||||
query := payload.Query
|
||||
query.Page = 1
|
||||
query.PageSize = 100
|
||||
var exported int64
|
||||
for {
|
||||
items, _, err := s.ListUsers(exportCtx, query)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for i := range items {
|
||||
if err := writer.Write(appUserExportRow(items[i])); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
exported += int64(len(items))
|
||||
if len(items) < query.PageSize {
|
||||
break
|
||||
}
|
||||
last := items[len(items)-1]
|
||||
cursorUserID, err := strconv.ParseInt(last.UserID, 10, 64)
|
||||
if err != nil || cursorUserID <= 0 || last.CreatedAtMs <= 0 {
|
||||
return "", "", errors.New("app user export cursor is invalid")
|
||||
}
|
||||
// 使用 (created_at_ms,user_id) keyset 而不是 OFFSET;任务运行期间新增用户或已读用户变更筛选字段时,
|
||||
// 后续批次仍不会位移、重复或跳过尚未读取的稳定主键区间。
|
||||
query.ExportCursorCreatedAtMs = last.CreatedAtMs
|
||||
query.ExportCursorUserID = cursorUserID
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
succeeded = true
|
||||
|
||||
result := appUserExportResult{
|
||||
JobID: job.ID,
|
||||
Rows: exported,
|
||||
Artifact: artifactName,
|
||||
DownloadPath: fmt.Sprintf("/api/v1/jobs/%d/artifact", job.ID),
|
||||
SnapshotAtMs: payload.Query.SnapshotAtMs,
|
||||
}
|
||||
resultJSON, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return string(resultJSON), artifactName, nil
|
||||
}
|
||||
|
||||
func stableAppUserExportQuery(query listQuery) listQuery {
|
||||
// 导出顺序不属于筛选语义,统一 created_at/user_id 倒序才能和固定 keyset 游标一致。
|
||||
// 这也绕开 coin 投影的全量内存排序,避免每一批重复读取形成 O(批次数×用户数)。
|
||||
query.SortBy = "created_at"
|
||||
query.SortDirection = "desc"
|
||||
return query
|
||||
}
|
||||
|
||||
func (h *Handler) CreateExport(c *gin.Context) {
|
||||
job, err := h.service.CreateExportJob(c.Request.Context(), shared.ActorFromContext(c), parseListQuery(c))
|
||||
if err != nil {
|
||||
response.ServerError(c, "创建 App 用户导出任务失败")
|
||||
return
|
||||
}
|
||||
writeAppUserAuditLog(c, h.audit, "export-app-users", "app_user_exports", int64(job.JobID), "success", fmt.Sprintf("snapshot_at_ms=%d", job.SnapshotAtMs))
|
||||
response.OK(c, job)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleExportJob(ctx context.Context, job *model.AdminJob) (string, string, error) {
|
||||
return h.service.HandleExportJob(ctx, job)
|
||||
}
|
||||
|
||||
func appUserExportHeaders() []string {
|
||||
return []string{
|
||||
"用户ID", "短ID", "靓号", "昵称", "头像", "性别", "生日", "年龄", "国家", "区域",
|
||||
"VIP等级", "VIP到期时间(ms)", "富豪真实等级", "富豪展示等级", "富豪临时目标", "富豪到期时间(ms)",
|
||||
"魅力真实等级", "魅力展示等级", "魅力临时目标", "魅力到期时间(ms)", "游戏等级", "身份",
|
||||
"注册设备", "金币", "钻石", "累计充值(USD分)", "状态", "封禁类型", "封禁到期时间(ms)", "封禁原因",
|
||||
"注册时间(ms)", "最近活跃时间(ms)", "最近成功登录时间(ms)", "最新操作人", "最新操作", "最新操作时间(ms)",
|
||||
}
|
||||
}
|
||||
|
||||
func appUserExportRow(item AppUser) []string {
|
||||
age := ""
|
||||
if item.Age != nil {
|
||||
age = strconv.FormatInt(int64(*item.Age), 10)
|
||||
}
|
||||
banType := ""
|
||||
if item.Ban.Active {
|
||||
if item.Ban.Permanent {
|
||||
banType = "永久"
|
||||
} else {
|
||||
banType = "限时"
|
||||
}
|
||||
}
|
||||
operatorName := ""
|
||||
operatorAction := ""
|
||||
if item.LastOperator != nil {
|
||||
operatorName = item.LastOperator.Name
|
||||
operatorAction = item.LastOperator.Action
|
||||
}
|
||||
row := []string{
|
||||
item.UserID, item.DefaultDisplayUserID, firstNonEmptyString(item.PrettyDisplayUserID, item.PrettyID), item.Username, item.Avatar,
|
||||
item.Gender, item.Birth, age, firstNonEmptyString(item.CountryDisplayName, item.CountryName, item.Country), item.RegionName,
|
||||
strconv.FormatInt(int64(item.VIP.Level), 10), strconv.FormatInt(item.VIP.ExpiresAtMs, 10),
|
||||
strconv.FormatInt(int64(item.Levels.Wealth.RealLevel), 10), strconv.FormatInt(int64(item.Levels.Wealth.DisplayLevel), 10), strconv.FormatInt(int64(item.Levels.Wealth.TemporaryTargetLevel), 10), strconv.FormatInt(item.Levels.Wealth.ExpiresAtMs, 10),
|
||||
strconv.FormatInt(int64(item.Levels.Charm.RealLevel), 10), strconv.FormatInt(int64(item.Levels.Charm.DisplayLevel), 10), strconv.FormatInt(int64(item.Levels.Charm.TemporaryTargetLevel), 10), strconv.FormatInt(item.Levels.Charm.ExpiresAtMs, 10),
|
||||
strconv.FormatInt(int64(item.Levels.Game.DisplayLevel), 10), strings.Join(item.Roles, "|"), item.RegisterDevice,
|
||||
strconv.FormatInt(item.Coin, 10), strconv.FormatInt(item.Diamond, 10), strconv.FormatInt(item.CumulativeRechargeUSDMinor, 10),
|
||||
item.Status, banType, strconv.FormatInt(item.Ban.ExpiresAtMs, 10), item.Ban.Reason,
|
||||
strconv.FormatInt(item.CreatedAtMs, 10), strconv.FormatInt(item.LastActiveAtMs, 10), strconv.FormatInt(item.LastLoginAtMs, 10),
|
||||
operatorName, operatorAction, strconv.FormatInt(item.LastOperatedAtMs, 10),
|
||||
}
|
||||
for index := range row {
|
||||
row[index] = spreadsheetSafeCSVCell(row[index])
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func spreadsheetSafeCSVCell(value string) string {
|
||||
trimmed := strings.TrimLeft(value, " \t\r\n")
|
||||
if trimmed == "" {
|
||||
return value
|
||||
}
|
||||
switch trimmed[0] {
|
||||
case '=', '+', '-', '@':
|
||||
// Excel/Sheets 会把这些前缀解释为公式;前置单引号只影响表格解释,不丢失原始文本内容。
|
||||
return "'" + value
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
106
server/admin/internal/modules/appuser/export_test.go
Normal file
106
server/admin/internal/modules/appuser/export_test.go
Normal file
@ -0,0 +1,106 @@
|
||||
package appuser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
)
|
||||
|
||||
type recordingAppUserExportStore struct {
|
||||
job *model.AdminJob
|
||||
}
|
||||
|
||||
func (s *recordingAppUserExportStore) CreateJob(job *model.AdminJob) error {
|
||||
copy := *job
|
||||
copy.ID = 81
|
||||
job.ID = copy.ID
|
||||
s.job = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCreateExportJobPersistsFilterSnapshot(t *testing.T) {
|
||||
store := &recordingAppUserExportStore{}
|
||||
service := &Service{
|
||||
jobStore: store,
|
||||
jobsConfig: config.JobsConfig{
|
||||
Enabled: true,
|
||||
MaxAttempts: 5,
|
||||
},
|
||||
}
|
||||
ctx := appctx.WithContext(context.Background(), "lalu")
|
||||
response, err := service.CreateExportJob(ctx, shared.Actor{UserID: 7, Username: "operator"}, listQuery{
|
||||
Country: " ph ",
|
||||
Status: "active",
|
||||
Keyword: "1299324",
|
||||
SortBy: "coin",
|
||||
SortDirection: "asc",
|
||||
Page: 9,
|
||||
PageSize: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create export job: %v", err)
|
||||
}
|
||||
if response.JobID != 81 || response.SnapshotAtMs <= 0 || store.job == nil {
|
||||
t.Fatalf("response/job mismatch: response=%+v job=%+v", response, store.job)
|
||||
}
|
||||
if store.job.Type != AppUserExportJobType || store.job.MaxAttempts != 5 || store.job.CreatedBy != 7 {
|
||||
t.Fatalf("job metadata mismatch: %+v", store.job)
|
||||
}
|
||||
var payload appUserExportPayload
|
||||
if err := json.Unmarshal([]byte(store.job.PayloadJSON), &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload.AppCode != "lalu" || payload.Query.Country != "PH" || payload.Query.Status != "active" || payload.Query.Keyword != "1299324" {
|
||||
t.Fatalf("filter snapshot mismatch: %+v", payload)
|
||||
}
|
||||
if payload.Query.Page != 1 || payload.Query.PageSize != 100 || payload.Query.SnapshotAtMs != response.SnapshotAtMs {
|
||||
t.Fatalf("pagination snapshot mismatch: %+v", payload.Query)
|
||||
}
|
||||
if payload.Query.SortBy != "created_at" || payload.Query.SortDirection != "desc" {
|
||||
t.Fatalf("export must use stable database pagination instead of coin projection sort: %+v", payload.Query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableAppUserExportQueryForcesDescendingKeysetOrder(t *testing.T) {
|
||||
for _, query := range []listQuery{
|
||||
{SortBy: "created_at", SortDirection: "asc"},
|
||||
{SortBy: "coin", SortDirection: "asc"},
|
||||
} {
|
||||
stable := stableAppUserExportQuery(normalizeListQuery(query))
|
||||
if stable.SortBy != "created_at" || stable.SortDirection != "desc" {
|
||||
t.Fatalf("export order must match descending keyset: input=%+v stable=%+v", query, stable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppUserExportWhereUsesSnapshotAndKeysetCursor(t *testing.T) {
|
||||
query := normalizeListQuery(listQuery{
|
||||
SnapshotAtMs: 1_000,
|
||||
ExportCursorCreatedAtMs: 900,
|
||||
ExportCursorUserID: 88,
|
||||
})
|
||||
whereSQL, args := appUserListWhereSQLAt("lalu", query, query.SnapshotAtMs)
|
||||
for _, fragment := range []string{
|
||||
"u.created_at_ms <= ?",
|
||||
"u.created_at_ms < ? OR (u.created_at_ms = ? AND u.user_id < ?)",
|
||||
} {
|
||||
if !strings.Contains(whereSQL, fragment) {
|
||||
t.Fatalf("export where SQL missing %q: %s", fragment, whereSQL)
|
||||
}
|
||||
}
|
||||
wantTail := []any{int64(1_000), int64(900), int64(900), int64(88)}
|
||||
if len(args) < len(wantTail) {
|
||||
t.Fatalf("export args too short: %+v", args)
|
||||
}
|
||||
for index, want := range wantTail {
|
||||
if got := args[len(args)-len(wantTail)+index]; got != want {
|
||||
t.Fatalf("export cursor arg[%d]=%v want=%v all=%+v", index, got, want, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@ -15,6 +17,8 @@ import (
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@ -23,9 +27,9 @@ type Handler struct {
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB, cfg config.Config, audit shared.OperationLogger) *Handler {
|
||||
func New(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB, cfg config.Config, audit shared.OperationLogger, opts ...ServiceOption) *Handler {
|
||||
return &Handler{
|
||||
service: NewService(userClient, activityClient, adminDB, userDB, walletDB),
|
||||
service: NewService(userClient, activityClient, adminDB, userDB, walletDB, opts...),
|
||||
cfg: cfg,
|
||||
audit: audit,
|
||||
}
|
||||
@ -113,11 +117,64 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) BanUser(c *gin.Context) {
|
||||
h.setUserStatus(c, "banned", "ban-app-user", "用户已封禁")
|
||||
userID, ok := parseInt64ID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req banUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
response.BadRequest(c, "封禁参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.AdminBanUser(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
|
||||
if err != nil {
|
||||
writeMutationError(c, err, "封禁 App 用户失败")
|
||||
return
|
||||
}
|
||||
detail := "permanent"
|
||||
if req.ExpiresAtMs > 0 {
|
||||
detail = fmt.Sprintf("expires_at_ms=%d", req.ExpiresAtMs)
|
||||
}
|
||||
writeAppUserAuditLog(c, h.audit, "ban-app-user", "app_users", userID, "success", detail)
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) UnbanUser(c *gin.Context) {
|
||||
h.setUserStatus(c, "active", "unban-app-user", "用户已解封")
|
||||
userID, ok := parseInt64ID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req unbanUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
response.BadRequest(c, "解封参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.AdminUnbanUser(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
|
||||
if err != nil {
|
||||
writeMutationError(c, err, "解封 App 用户失败")
|
||||
return
|
||||
}
|
||||
writeAppUserAuditLog(c, h.audit, "unban-app-user", "app_users", userID, "success", strings.TrimSpace(req.Reason))
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) AdjustTemporaryLevels(c *gin.Context) {
|
||||
userID, ok := parseInt64ID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req adjustTemporaryLevelsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "等级调整参数不正确")
|
||||
return
|
||||
}
|
||||
user, err := h.service.AdjustTemporaryLevels(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
|
||||
if err != nil {
|
||||
writeMutationError(c, err, "调整用户等级失败")
|
||||
return
|
||||
}
|
||||
writeAppUserAuditLog(c, h.audit, "adjust-app-user-level", "app_users", userID, "success", fmt.Sprintf("tracks=%d", len(req.Adjustments)))
|
||||
response.OK(c, user)
|
||||
}
|
||||
|
||||
func (h *Handler) SetPassword(c *gin.Context) {
|
||||
@ -138,6 +195,30 @@ func (h *Handler) SetPassword(c *gin.Context) {
|
||||
response.OK(c, gin.H{"passwordSet": true})
|
||||
}
|
||||
|
||||
func (h *Handler) IssueAccessToken(c *gin.Context) {
|
||||
userID, ok := parseInt64ID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.IssueAccessToken(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
if grpcStatus, ok := status.FromError(err); ok && grpcStatus.Code() == codes.NotFound {
|
||||
// 无活跃会话是明确业务事实:token 是无状态 JWT,没有 active session 就没有可重签对象。
|
||||
response.NotFound(c, "该用户当前没有活跃会话")
|
||||
return
|
||||
}
|
||||
writeMutationError(c, err, "获取用户 Token 失败")
|
||||
return
|
||||
}
|
||||
// 重签出的 token 等价于账号接管能力,且 user-service 侧刻意不写 login_audit——
|
||||
// 后台操作日志是唯一审计痕迹,所以这里 fail-closed:审计落库失败就不返回 token。
|
||||
if err := shared.OperationLogWithResourceIDStrict(c, h.audit, "issue-app-user-token", "app_users", fmt.Sprintf("%d", userID), "success", fmt.Sprintf("session_id=%s", result.SessionID)); err != nil {
|
||||
response.ServerError(c, "操作日志写入失败,未返回 Token")
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) setUserStatus(c *gin.Context, status string, action string, successMessage string) {
|
||||
userID, ok := parseInt64ID(c, "id")
|
||||
if !ok {
|
||||
@ -257,6 +338,11 @@ func writeReadError(c *gin.Context, err error, fallback string) {
|
||||
response.NotFound(c, "App 用户不存在")
|
||||
return
|
||||
}
|
||||
// 读路径对外只回兜底文案、不透出内部细节;原始错误必须落日志,否则详情页 500 无从排查。
|
||||
slog.ErrorContext(c.Request.Context(), "admin_app_user_read_failed",
|
||||
slog.String("path", c.Request.URL.Path),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
response.ServerError(c, fallback)
|
||||
}
|
||||
|
||||
@ -269,6 +355,22 @@ func writeMutationError(c *gin.Context, err error, fallback string) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if grpcStatus, ok := status.FromError(err); ok {
|
||||
message := strings.TrimSpace(grpcStatus.Message())
|
||||
if message == "" {
|
||||
message = fallback
|
||||
}
|
||||
switch grpcStatus.Code() {
|
||||
case codes.InvalidArgument, codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
|
||||
// 等级目标不高于真实等级、规则缺失或仍有其他封禁都属于可修正的业务拒绝,不能伪装成 500。
|
||||
response.BadRequest(c, message)
|
||||
case codes.NotFound:
|
||||
response.NotFound(c, message)
|
||||
default:
|
||||
response.ServerError(c, fallback)
|
||||
}
|
||||
return
|
||||
}
|
||||
response.ServerError(c, fallback)
|
||||
}
|
||||
|
||||
|
||||
34
server/admin/internal/modules/appuser/handler_test.go
Normal file
34
server/admin/internal/modules/appuser/handler_test.go
Normal file
@ -0,0 +1,34 @@
|
||||
package appuser
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestWriteMutationErrorMapsGRPCBusinessFailures(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "invalid level", err: status.Error(codes.FailedPrecondition, "target must exceed real level"), wantStatus: http.StatusBadRequest},
|
||||
{name: "missing user", err: status.Error(codes.NotFound, "user not found"), wantStatus: http.StatusNotFound},
|
||||
{name: "owner unavailable", err: status.Error(codes.Unavailable, "activity unavailable"), wantStatus: http.StatusInternalServerError},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
writeMutationError(context, test.err, "mutation failed")
|
||||
if recorder.Code != test.wantStatus {
|
||||
t.Fatalf("HTTP status=%d want=%d body=%s", recorder.Code, test.wantStatus, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
85
server/admin/internal/modules/appuser/projection_test.go
Normal file
85
server/admin/internal/modules/appuser/projection_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package appuser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
)
|
||||
|
||||
type fakeUserAdminProjectionClient struct {
|
||||
profiles map[int64]*userv1.UserAdminProfile
|
||||
}
|
||||
|
||||
func (f *fakeUserAdminProjectionClient) BatchGetUserAdminProfiles(context.Context, *userv1.BatchGetUserAdminProfilesRequest) (*userv1.BatchGetUserAdminProfilesResponse, error) {
|
||||
return &userv1.BatchGetUserAdminProfilesResponse{Profiles: f.profiles}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserAdminProjectionClient) AdminBanUser(context.Context, *userv1.AdminBanUserRequest) (*userv1.AdminBanUserResponse, error) {
|
||||
return &userv1.AdminBanUserResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserAdminProjectionClient) AdminUnbanUser(context.Context, *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error) {
|
||||
return &userv1.AdminUnbanUserResponse{}, nil
|
||||
}
|
||||
|
||||
type fakeLevelAdminProjectionClient struct {
|
||||
profiles []*activityv1.AdminUserLevelProfile
|
||||
}
|
||||
|
||||
func (f *fakeLevelAdminProjectionClient) BatchGetUserLevelAdminProfiles(context.Context, *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error) {
|
||||
return &activityv1.BatchGetUserLevelAdminProfilesResponse{Profiles: f.profiles}, nil
|
||||
}
|
||||
|
||||
func (f *fakeLevelAdminProjectionClient) AdjustTemporaryUserLevels(context.Context, *activityv1.AdjustTemporaryUserLevelsRequest) (*activityv1.AdjustTemporaryUserLevelsResponse, error) {
|
||||
return &activityv1.AdjustTemporaryUserLevelsResponse{}, nil
|
||||
}
|
||||
|
||||
func TestAdminProjectionEnrichmentMapsOwnerFacts(t *testing.T) {
|
||||
service := &Service{
|
||||
userAdminClient: &fakeUserAdminProjectionClient{profiles: map[int64]*userv1.UserAdminProfile{
|
||||
10001: {
|
||||
Birth: "2000-02-03",
|
||||
RegisterDevice: "Pixel 9",
|
||||
LastSuccessLoginAtMs: 7000,
|
||||
Roles: []string{"host", "bd_leader", "manager"},
|
||||
Ban: &userv1.ActiveUserBanSummary{
|
||||
Active: true, Source: "admin", BanId: "ban-1", Permanent: false, ExpiresAtMs: 9000, Reason: "risk",
|
||||
},
|
||||
},
|
||||
}},
|
||||
levelAdminClient: &fakeLevelAdminProjectionClient{profiles: []*activityv1.AdminUserLevelProfile{
|
||||
{UserId: 10001, Tracks: []*activityv1.AdminLevelTrackProfile{
|
||||
{Track: "wealth", RealLevel: 8, RealTotalValue: 800, DisplayLevel: 14, DisplayValue: 1400, TemporaryLevelId: "temp-1", TemporaryTargetLevel: 14, StartedAtMs: 1000, ExpiresAtMs: 9000},
|
||||
{Track: "charm", RealLevel: 9, RealTotalValue: 900, DisplayLevel: 9, DisplayValue: 900},
|
||||
{Track: "game", RealLevel: 6, RealTotalValue: 600, DisplayLevel: 6, DisplayValue: 600},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
items := []AppUser{{UserID: "10001"}}
|
||||
ctx := appctx.WithContext(context.Background(), "lalu")
|
||||
if err := service.fillUserAdminProfiles(ctx, items, []int64{10001}); err != nil {
|
||||
t.Fatalf("fill user profiles: %v", err)
|
||||
}
|
||||
if err := service.fillLevelAdminProfiles(ctx, items, []int64{10001}); err != nil {
|
||||
t.Fatalf("fill level profiles: %v", err)
|
||||
}
|
||||
item := items[0]
|
||||
if item.Birth != "2000-02-03" || item.RegisterDevice != "Pixel 9" || item.LastLoginAtMs != 7000 {
|
||||
t.Fatalf("user owner facts mismatch: %+v", item)
|
||||
}
|
||||
if len(item.Roles) != 3 || item.Roles[0] != "主播" || item.Roles[1] != "BD Leader" || item.Roles[2] != "经理" {
|
||||
t.Fatalf("roles mismatch: %#v", item.Roles)
|
||||
}
|
||||
if !item.Ban.Active || item.Ban.ID != "ban-1" || item.Ban.ExpiresAtMs != 9000 {
|
||||
t.Fatalf("ban mismatch: %+v", item.Ban)
|
||||
}
|
||||
if item.Levels.Wealth.RealLevel != 8 || item.Levels.Wealth.DisplayLevel != 14 || item.Levels.Wealth.TemporaryTargetLevel != 14 {
|
||||
t.Fatalf("wealth overlay mismatch: %+v", item.Levels.Wealth)
|
||||
}
|
||||
if item.Levels.Game.DisplayLevel != 6 || item.Levels.Charm.DisplayLevel != 9 {
|
||||
t.Fatalf("read-only levels mismatch: %+v", item.Levels)
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,11 @@ type listQuery struct {
|
||||
SortDirection string
|
||||
StartMs int64
|
||||
EndMs int64
|
||||
// SnapshotAtMs 只由异步导出设置,使短号租约和筛选时间基线在所有分页中保持一致。
|
||||
SnapshotAtMs int64
|
||||
// ExportCursor* 仅在 job worker 内存中推进 keyset;不进入任务 payload,也不暴露为 HTTP 查询参数。
|
||||
ExportCursorCreatedAtMs int64 `json:"-"`
|
||||
ExportCursorUserID int64 `json:"-"`
|
||||
}
|
||||
|
||||
type loginLogQuery struct {
|
||||
@ -58,3 +63,23 @@ type updateUserRequest struct {
|
||||
type setPasswordRequest struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type temporaryLevelAdjustmentRequest struct {
|
||||
Track string `json:"track" binding:"required"`
|
||||
Level int32 `json:"level" binding:"required"`
|
||||
DurationDays int32 `json:"duration_days" binding:"required"`
|
||||
}
|
||||
|
||||
type adjustTemporaryLevelsRequest struct {
|
||||
Adjustments []temporaryLevelAdjustmentRequest `json:"adjustments" binding:"required,min=1,max=2,dive"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type banUserRequest struct {
|
||||
ExpiresAtMs int64 `json:"expires_at_ms"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type unbanUserRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
@ -12,12 +12,15 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
}
|
||||
|
||||
protected.GET("/app/users", middleware.RequirePermission("app-user:view"), h.ListUsers)
|
||||
protected.POST("/exports/app-users", middleware.RequirePermission("app-user:export"), h.CreateExport)
|
||||
protected.GET("/app/users/bans", middleware.RequirePermission("app-user:view"), h.ListBannedUsers)
|
||||
protected.GET("/app/users/login-logs", middleware.RequirePermission("app-user:view"), h.ListLoginLogs)
|
||||
protected.GET("/app/users/:id/login-logs", middleware.RequirePermission("app-user:view"), h.ListUserLoginLogs)
|
||||
protected.GET("/app/users/:id", middleware.RequirePermission("app-user:view"), h.GetUser)
|
||||
protected.PATCH("/app/users/:id", middleware.RequirePermission("app-user:update"), h.UpdateUser)
|
||||
protected.PUT("/app/users/:id/levels", middleware.RequirePermission("app-user:level"), h.AdjustTemporaryLevels)
|
||||
protected.POST("/app/users/:id/ban", middleware.RequirePermission("app-user:status"), h.BanUser)
|
||||
protected.POST("/app/users/:id/unban", middleware.RequirePermission("app-user:status"), h.UnbanUser)
|
||||
protected.POST("/app/users/:id/password", middleware.RequirePermission("app-user:password"), h.SetPassword)
|
||||
protected.POST("/app/users/:id/access-token", middleware.RequirePermission("app-user:token"), h.IssueAccessToken)
|
||||
}
|
||||
|
||||
@ -12,11 +12,15 @@ import (
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/security"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -25,37 +29,141 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
userClient userclient.Client
|
||||
activityClient activityclient.Client
|
||||
adminDB *sql.DB
|
||||
userDB *sql.DB
|
||||
walletDB *sql.DB
|
||||
userClient userclient.Client
|
||||
userAdminClient userAdminProjectionClient
|
||||
// tokenIssuerClient 只在 gRPC client 具备后台重签 token 能力时非 nil。
|
||||
tokenIssuerClient accessTokenIssuerClient
|
||||
activityClient activityclient.Client
|
||||
levelAdminClient levelAdminProjectionClient
|
||||
walletClient rechargeStatsClient
|
||||
jobStore appUserExportJobStore
|
||||
jobsConfig config.JobsConfig
|
||||
adminDB *sql.DB
|
||||
userDB *sql.DB
|
||||
walletDB *sql.DB
|
||||
}
|
||||
|
||||
// rechargeStatsClient 只暴露用户后台需要的累计充值聚合;列表不能为展示字段回扫钱包流水。
|
||||
type rechargeStatsClient interface {
|
||||
BatchGetUserRechargeStats(context.Context, *walletv1.BatchGetUserRechargeStatsRequest) (*walletv1.BatchGetUserRechargeStatsResponse, error)
|
||||
}
|
||||
|
||||
// userAdminProjectionClient 保证生日、成功登录和封禁语义继续由 user-service 统一解释。
|
||||
type userAdminProjectionClient interface {
|
||||
BatchGetUserAdminProfiles(context.Context, *userv1.BatchGetUserAdminProfilesRequest) (*userv1.BatchGetUserAdminProfilesResponse, error)
|
||||
AdminBanUser(context.Context, *userv1.AdminBanUserRequest) (*userv1.AdminBanUserResponse, error)
|
||||
AdminUnbanUser(context.Context, *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error)
|
||||
}
|
||||
|
||||
// levelAdminProjectionClient 只允许后台读 overlay 和一次原子调整,游戏等级没有写入口。
|
||||
type levelAdminProjectionClient interface {
|
||||
BatchGetUserLevelAdminProfiles(context.Context, *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error)
|
||||
AdjustTemporaryUserLevels(context.Context, *activityv1.AdjustTemporaryUserLevelsRequest) (*activityv1.AdjustTemporaryUserLevelsResponse, error)
|
||||
}
|
||||
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// WithWalletClient 通过 wallet-service 读取它拥有的充值聚合,保留 walletDB 仅用于现有余额与 VIP 展示。
|
||||
func WithWalletClient(client walletclient.Client) ServiceOption {
|
||||
return func(service *Service) {
|
||||
service.walletClient = client
|
||||
}
|
||||
}
|
||||
|
||||
// WithExportJobs 把任务创建留在 admin 数据库,把分批导出交给统一 job runner 执行。
|
||||
func WithExportJobs(store appUserExportJobStore, cfg config.Config) ServiceOption {
|
||||
return func(service *Service) {
|
||||
service.jobStore = store
|
||||
service.jobsConfig = cfg.Jobs
|
||||
}
|
||||
}
|
||||
|
||||
type AppUser struct {
|
||||
Avatar string `json:"avatar"`
|
||||
Balances []AppUserAssetBalance `json:"balances,omitempty"`
|
||||
Coin int64 `json:"coin"`
|
||||
Country string `json:"country"`
|
||||
CountryDisplayName string `json:"countryDisplayName"`
|
||||
CountryName string `json:"countryName"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
|
||||
Diamond int64 `json:"diamond"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
EquippedResources []AppUserResource `json:"equippedResources,omitempty"`
|
||||
Gender string `json:"gender"`
|
||||
LastActiveAtMs int64 `json:"lastActiveAtMs"`
|
||||
PrettyDisplayUserID string `json:"prettyDisplayUserId"`
|
||||
PrettyID string `json:"prettyId"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
RegionName string `json:"regionName"`
|
||||
Resources []AppUserResource `json:"resources,omitempty"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
VIP AppUserVIP `json:"vip"`
|
||||
Age *int32 `json:"age,omitempty"`
|
||||
// AppVersion/BuildNumber/AppVersionAtMs 是最近一次真实登录(password/third_party/refresh,
|
||||
// 不含 access_resign 心跳重签)携带的客户端版本快照,仅在 GetUser 填充,列表不回扫审计表。
|
||||
AppVersion string `json:"appVersion"`
|
||||
AppVersionAtMs int64 `json:"appVersionAtMs"`
|
||||
Avatar string `json:"avatar"`
|
||||
Balances []AppUserAssetBalance `json:"balances,omitempty"`
|
||||
Ban AppUserBan `json:"ban"`
|
||||
Birth string `json:"birth"`
|
||||
BuildNumber string `json:"buildNumber"`
|
||||
Coin int64 `json:"coin"`
|
||||
Country string `json:"country"`
|
||||
CountryDisplayName string `json:"countryDisplayName"`
|
||||
CountryName string `json:"countryName"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
CumulativeRechargeUSDMinor int64 `json:"cumulativeRechargeUsdMinor"`
|
||||
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
|
||||
// Devices 是设备 tab 数据,来自 user_devices 认证成功聚合;仅在 GetUser 填充,按最近活跃倒序。
|
||||
Devices []AppUserDevice `json:"devices,omitempty"`
|
||||
Diamond int64 `json:"diamond"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
EquippedResources []AppUserResource `json:"equippedResources,omitempty"`
|
||||
Gender string `json:"gender"`
|
||||
LastActiveAtMs int64 `json:"lastActiveAtMs"`
|
||||
LastLoginAtMs int64 `json:"lastLoginAtMs"`
|
||||
// LastLoginIp* 来自 login_audit 全部登录类型(含 access_resign 心跳重签)的最新成功行,
|
||||
// 在线用户可精确到 25s 级新鲜度;仅在 GetUser 填充。
|
||||
LastLoginIP string `json:"lastLoginIp"`
|
||||
LastLoginIPAtMs int64 `json:"lastLoginIpAtMs"`
|
||||
LastLoginIPCountryCode string `json:"lastLoginIpCountryCode"`
|
||||
LastOperatedAtMs int64 `json:"lastOperatedAtMs"`
|
||||
LastOperator *AppUserOperator `json:"lastOperator,omitempty"`
|
||||
Levels AppUserLevels `json:"levels"`
|
||||
PrettyDisplayUserID string `json:"prettyDisplayUserId"`
|
||||
PrettyID string `json:"prettyId"`
|
||||
RegisterDevice string `json:"registerDevice"`
|
||||
// RegisterDeviceID/RegisterOsVersion 是 users 表注册时一次性快照,配合设备 tab 的注册设备区块展示。
|
||||
RegisterDeviceID string `json:"registerDeviceId"`
|
||||
RegisterOsVersion string `json:"registerOsVersion"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
RegionName string `json:"regionName"`
|
||||
Resources []AppUserResource `json:"resources,omitempty"`
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
// ThirdPartyIdentities 暴露三方绑定 openid(provider_subject);一个用户可能绑定多个 provider。
|
||||
ThirdPartyIdentities []AppUserThirdPartyIdentity `json:"thirdPartyIdentities,omitempty"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
VIP AppUserVIP `json:"vip"`
|
||||
}
|
||||
|
||||
// AppUserLevels 固定三个成长轨道,前端无需按数组位置猜测富豪、游戏和魅力列。
|
||||
type AppUserLevels struct {
|
||||
Wealth AppUserLevel `json:"wealth"`
|
||||
Game AppUserLevel `json:"game"`
|
||||
Charm AppUserLevel `json:"charm"`
|
||||
}
|
||||
|
||||
// AppUserLevel 同时暴露真实成长事实和当前临时展示事实,详情页可明确标出到期时间。
|
||||
type AppUserLevel struct {
|
||||
Track string `json:"track"`
|
||||
RealLevel int32 `json:"realLevel"`
|
||||
RealTotalValue int64 `json:"realTotalValue"`
|
||||
DisplayLevel int32 `json:"displayLevel"`
|
||||
DisplayValue int64 `json:"displayValue"`
|
||||
TemporaryLevelID string `json:"temporaryLevelId,omitempty"`
|
||||
TemporaryTargetLevel int32 `json:"temporaryTargetLevel,omitempty"`
|
||||
StartedAtMs int64 `json:"startedAtMs,omitempty"`
|
||||
ExpiresAtMs int64 `json:"expiresAtMs,omitempty"`
|
||||
}
|
||||
|
||||
type AppUserBan struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
Permanent bool `json:"permanent"`
|
||||
ExpiresAtMs int64 `json:"expiresAtMs"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type AppUserOperator struct {
|
||||
AdminID string `json:"adminId"`
|
||||
Name string `json:"name"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type AppUserAssetBalance struct {
|
||||
@ -110,8 +218,23 @@ type SetUserStatusResult struct {
|
||||
RoomEvictError string `json:"roomEvictError,omitempty"`
|
||||
}
|
||||
|
||||
func NewService(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB) *Service {
|
||||
return &Service{userClient: userClient, activityClient: activityClient, adminDB: adminDB, userDB: userDB, walletDB: walletDB}
|
||||
func NewService(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB, opts ...ServiceOption) *Service {
|
||||
service := &Service{userClient: userClient, activityClient: activityClient, adminDB: adminDB, userDB: userDB, walletDB: walletDB}
|
||||
if adminClient, ok := any(userClient).(userAdminProjectionClient); ok {
|
||||
service.userAdminClient = adminClient
|
||||
}
|
||||
if tokenClient, ok := any(userClient).(accessTokenIssuerClient); ok {
|
||||
service.tokenIssuerClient = tokenClient
|
||||
}
|
||||
if adminClient, ok := any(activityClient).(levelAdminProjectionClient); ok {
|
||||
service.levelAdminClient = adminClient
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(service)
|
||||
}
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, int64, error) {
|
||||
@ -120,7 +243,10 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
|
||||
}
|
||||
query = normalizeListQuery(query)
|
||||
appCode := appctx.FromContext(ctx)
|
||||
nowMs := nowMillis()
|
||||
nowMs := query.SnapshotAtMs
|
||||
if nowMs <= 0 {
|
||||
nowMs = nowMillis()
|
||||
}
|
||||
whereSQL, args := appUserListWhereSQLAt(appCode, query, nowMs)
|
||||
|
||||
total, err := countRows(ctx, s.userDB, whereSQL, args...)
|
||||
@ -192,6 +318,18 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
|
||||
if err := s.fillVIPLevels(ctx, items, userIDs); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.fillUserAdminProfiles(ctx, items, userIDs); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.fillLevelAdminProfiles(ctx, items, userIDs); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.fillRechargeStats(ctx, items, userIDs); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.fillLatestOperators(ctx, items, userIDs); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
@ -256,6 +394,19 @@ func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, wh
|
||||
if err := s.fillVIPLevels(ctx, pagedItems, appUserNumericIDs(pagedItems)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pagedUserIDs := appUserNumericIDs(pagedItems)
|
||||
if err := s.fillUserAdminProfiles(ctx, pagedItems, pagedUserIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.fillLevelAdminProfiles(ctx, pagedItems, pagedUserIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.fillRechargeStats(ctx, pagedItems, pagedUserIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.fillLatestOperators(ctx, pagedItems, pagedUserIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pagedItems, nil
|
||||
}
|
||||
|
||||
@ -343,7 +494,9 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
||||
GREATEST(
|
||||
COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
|
||||
COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
|
||||
)
|
||||
),
|
||||
COALESCE(u.register_device_id, ''),
|
||||
COALESCE(u.os_version, '')
|
||||
`+appUserPrettySelectColumns()+`
|
||||
FROM users u
|
||||
WHERE u.app_code = ? AND u.user_id = ?
|
||||
@ -363,6 +516,8 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
&item.LastActiveAtMs,
|
||||
&item.RegisterDeviceID,
|
||||
&item.RegisterOsVersion,
|
||||
&item.PrettyDisplayUserID,
|
||||
&item.PrettyID,
|
||||
)
|
||||
@ -380,12 +535,307 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
||||
if err := s.fillVIPLevels(ctx, items, []int64{userID}); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
if err := s.fillUserAdminProfiles(ctx, items, []int64{userID}); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
if err := s.fillLevelAdminProfiles(ctx, items, []int64{userID}); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
if err := s.fillRechargeStats(ctx, items, []int64{userID}); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
if err := s.fillLatestOperators(ctx, items, []int64{userID}); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
if err := s.fillUserResources(ctx, items, userID); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
// 状态记录/设备 tab 的补充事实只在详情页读取,列表接口不承担这三组查询。
|
||||
if err := s.fillLoginSnapshots(ctx, &items[0], userID); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
if err := s.fillThirdPartyIdentities(ctx, &items[0], userID); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
if err := s.fillDevices(ctx, &items[0], userID); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
func (s *Service) fillUserAdminProfiles(ctx context.Context, items []AppUser, userIDs []int64) error {
|
||||
initializeAdminProjectionDefaults(items)
|
||||
if s.userAdminClient == nil || len(items) == 0 || len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
nowMs := nowMillis()
|
||||
response, err := s.userAdminClient.BatchGetUserAdminProfiles(ctx, &userv1.BatchGetUserAdminProfilesRequest{
|
||||
Meta: &userv1.RequestMeta{
|
||||
RequestId: fmt.Sprintf("admin-app-user-profile-%d", nowMs),
|
||||
Caller: "hyapp-admin-server",
|
||||
SentAtMs: nowMs,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
},
|
||||
UserIds: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index := appUserIndex(items)
|
||||
for userID, profile := range response.GetProfiles() {
|
||||
if profile == nil {
|
||||
continue
|
||||
}
|
||||
i, ok := index[userID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items[i].Birth = strings.TrimSpace(profile.GetBirth())
|
||||
items[i].Age = ageAtUTC(items[i].Birth, time.UnixMilli(nowMs).UTC())
|
||||
items[i].RegisterDevice = strings.TrimSpace(profile.GetRegisterDevice())
|
||||
items[i].LastLoginAtMs = profile.GetLastSuccessLoginAtMs()
|
||||
items[i].Roles = displayUserRoles(profile.GetRoles())
|
||||
if ban := profile.GetBan(); ban != nil {
|
||||
items[i].Ban = AppUserBan{
|
||||
ID: strings.TrimSpace(ban.GetBanId()),
|
||||
Active: ban.GetActive(),
|
||||
Permanent: ban.GetPermanent(),
|
||||
ExpiresAtMs: ban.GetExpiresAtMs(),
|
||||
Source: strings.TrimSpace(ban.GetSource()),
|
||||
Reason: strings.TrimSpace(ban.GetReason()),
|
||||
}
|
||||
}
|
||||
if profile.GetLastOperationAtMs() > items[i].LastOperatedAtMs {
|
||||
items[i].LastOperatedAtMs = profile.GetLastOperationAtMs()
|
||||
items[i].LastOperator = &AppUserOperator{
|
||||
AdminID: strconv.FormatInt(profile.GetLastOperatorUserId(), 10),
|
||||
Name: displayOperatorType(profile.GetLastOperatorType()),
|
||||
Action: strings.TrimSpace(profile.GetLastOperationReason()),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) fillLevelAdminProfiles(ctx context.Context, items []AppUser, userIDs []int64) error {
|
||||
initializeAdminProjectionDefaults(items)
|
||||
if s.levelAdminClient == nil || len(items) == 0 || len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
nowMs := nowMillis()
|
||||
response, err := s.levelAdminClient.BatchGetUserLevelAdminProfiles(ctx, &activityv1.BatchGetUserLevelAdminProfilesRequest{
|
||||
Meta: &activityv1.RequestMeta{
|
||||
RequestId: fmt.Sprintf("admin-app-user-level-%d", nowMs),
|
||||
Caller: "hyapp-admin-server",
|
||||
SentAtMs: nowMs,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
},
|
||||
UserIds: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index := appUserIndex(items)
|
||||
for _, profile := range response.GetProfiles() {
|
||||
if profile == nil {
|
||||
continue
|
||||
}
|
||||
i, ok := index[profile.GetUserId()]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, track := range profile.GetTracks() {
|
||||
if track == nil {
|
||||
continue
|
||||
}
|
||||
level := AppUserLevel{
|
||||
Track: strings.ToLower(strings.TrimSpace(track.GetTrack())),
|
||||
RealLevel: track.GetRealLevel(),
|
||||
RealTotalValue: track.GetRealTotalValue(),
|
||||
DisplayLevel: track.GetDisplayLevel(),
|
||||
DisplayValue: track.GetDisplayValue(),
|
||||
TemporaryLevelID: strings.TrimSpace(track.GetTemporaryLevelId()),
|
||||
TemporaryTargetLevel: track.GetTemporaryTargetLevel(),
|
||||
StartedAtMs: track.GetStartedAtMs(),
|
||||
ExpiresAtMs: track.GetExpiresAtMs(),
|
||||
}
|
||||
switch level.Track {
|
||||
case "wealth":
|
||||
items[i].Levels.Wealth = level
|
||||
case "charm":
|
||||
items[i].Levels.Charm = level
|
||||
case "game":
|
||||
items[i].Levels.Game = level
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeAdminProjectionDefaults(items []AppUser) {
|
||||
for i := range items {
|
||||
if len(items[i].Roles) == 0 {
|
||||
items[i].Roles = []string{"普通用户"}
|
||||
}
|
||||
if items[i].Levels.Wealth.Track == "" {
|
||||
items[i].Levels.Wealth.Track = "wealth"
|
||||
}
|
||||
if items[i].Levels.Charm.Track == "" {
|
||||
items[i].Levels.Charm.Track = "charm"
|
||||
}
|
||||
if items[i].Levels.Game.Track == "" {
|
||||
items[i].Levels.Game.Track = "game"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func displayUserRoles(roles []string) []string {
|
||||
displayByRole := map[string]string{
|
||||
"host": "主播",
|
||||
"agency": "公会长",
|
||||
"bd": "BD",
|
||||
"bd_leader": "BD Leader",
|
||||
"coin_seller": "币商",
|
||||
"manager": "经理",
|
||||
}
|
||||
seen := make(map[string]struct{}, len(roles))
|
||||
for _, role := range roles {
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
if _, ok := displayByRole[role]; ok {
|
||||
seen[role] = struct{}{}
|
||||
}
|
||||
}
|
||||
order := []string{"host", "agency", "bd", "bd_leader", "coin_seller", "manager"}
|
||||
result := make([]string, 0, len(seen))
|
||||
for _, role := range order {
|
||||
if _, ok := seen[role]; ok {
|
||||
result = append(result, displayByRole[role])
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return []string{"普通用户"}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func displayOperatorType(operatorType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(operatorType)) {
|
||||
case "admin":
|
||||
return "管理员"
|
||||
case "manager":
|
||||
return "经理"
|
||||
default:
|
||||
return strings.TrimSpace(operatorType)
|
||||
}
|
||||
}
|
||||
|
||||
func ageAtUTC(birth string, now time.Time) *int32 {
|
||||
birthDate, err := time.Parse("2006-01-02", strings.TrimSpace(birth))
|
||||
if err != nil || birthDate.After(now) {
|
||||
return nil
|
||||
}
|
||||
age := now.Year() - birthDate.Year()
|
||||
if now.Month() < birthDate.Month() || (now.Month() == birthDate.Month() && now.Day() < birthDate.Day()) {
|
||||
age--
|
||||
}
|
||||
if age < 0 {
|
||||
return nil
|
||||
}
|
||||
value := int32(age)
|
||||
return &value
|
||||
}
|
||||
|
||||
func (s *Service) fillRechargeStats(ctx context.Context, items []AppUser, userIDs []int64) error {
|
||||
if s.walletClient == nil || len(items) == 0 || len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
response, err := s.walletClient.BatchGetUserRechargeStats(ctx, &walletv1.BatchGetUserRechargeStatsRequest{
|
||||
RequestId: fmt.Sprintf("admin-app-user-recharge-%d", nowMillis()),
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
UserIds: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index := appUserIndex(items)
|
||||
for _, stat := range response.GetItems() {
|
||||
if stat == nil {
|
||||
continue
|
||||
}
|
||||
if i, ok := index[stat.GetUserId()]; ok {
|
||||
items[i].CumulativeRechargeUSDMinor = stat.GetTotalUsdMinorAmount()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) fillLatestOperators(ctx context.Context, items []AppUser, userIDs []int64) error {
|
||||
if s.adminDB == nil || len(items) == 0 || len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
args := make([]any, 0, len(userIDs)+1)
|
||||
args = append(args, "app_users")
|
||||
for _, userID := range userIDs {
|
||||
args = append(args, strconv.FormatInt(userID, 10))
|
||||
}
|
||||
rows, err := s.adminDB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT resource_id, user_id, username, action, created_at_ms
|
||||
FROM admin_operation_logs
|
||||
WHERE resource = ? AND resource_id IN (%s) AND status = 'success'
|
||||
ORDER BY resource_id ASC, created_at_ms DESC, id DESC
|
||||
`, placeholders(len(userIDs))), args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
index := appUserIndex(items)
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
for rows.Next() {
|
||||
var resourceID string
|
||||
var adminID uint64
|
||||
var name string
|
||||
var action string
|
||||
var operatedAtMs int64
|
||||
if err := rows.Scan(&resourceID, &adminID, &name, &action, &operatedAtMs); err != nil {
|
||||
return err
|
||||
}
|
||||
userID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
i, ok := index[userID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
if operatedAtMs <= items[i].LastOperatedAtMs {
|
||||
continue
|
||||
}
|
||||
items[i].LastOperatedAtMs = operatedAtMs
|
||||
items[i].LastOperator = &AppUserOperator{
|
||||
AdminID: strconv.FormatUint(adminID, 10),
|
||||
Name: strings.TrimSpace(name),
|
||||
Action: strings.TrimSpace(action),
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func appUserIndex(items []AppUser) map[int64]int {
|
||||
index := make(map[int64]int, len(items))
|
||||
for i := range items {
|
||||
userID, err := strconv.ParseInt(strings.TrimSpace(items[i].UserID), 10, 64)
|
||||
if err == nil && userID > 0 {
|
||||
index[userID] = i
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUser(ctx context.Context, userID int64, adminUserID int64, requestID string, req updateUserRequest) (AppUser, error) {
|
||||
if s.userDB == nil {
|
||||
return AppUser{}, fmt.Errorf("user mysql is not configured")
|
||||
@ -527,6 +977,152 @@ func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (s *Service) AdjustTemporaryLevels(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req adjustTemporaryLevelsRequest) (AppUser, error) {
|
||||
if s.levelAdminClient == nil {
|
||||
return AppUser{}, fmt.Errorf("activity service level admin client is not configured")
|
||||
}
|
||||
if userID <= 0 || operatorAdminID <= 0 || len(req.Adjustments) == 0 || len(req.Adjustments) > 2 {
|
||||
return AppUser{}, fmt.Errorf("%w: 等级调整参数不正确", ErrInvalidArgument)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(req.Adjustments))
|
||||
adjustments := make([]*activityv1.TemporaryLevelAdjustment, 0, len(req.Adjustments))
|
||||
for _, adjustment := range req.Adjustments {
|
||||
track := strings.ToLower(strings.TrimSpace(adjustment.Track))
|
||||
if track != "wealth" && track != "charm" {
|
||||
return AppUser{}, fmt.Errorf("%w: 只允许调整富豪或魅力等级", ErrInvalidArgument)
|
||||
}
|
||||
if _, duplicated := seen[track]; duplicated {
|
||||
return AppUser{}, fmt.Errorf("%w: 同一等级轨道不能重复提交", ErrInvalidArgument)
|
||||
}
|
||||
if adjustment.Level < 1 || adjustment.Level > 50 || adjustment.DurationDays <= 0 {
|
||||
return AppUser{}, fmt.Errorf("%w: 等级必须为 1-50 且有效期必须大于 0 天", ErrInvalidArgument)
|
||||
}
|
||||
seen[track] = struct{}{}
|
||||
adjustments = append(adjustments, &activityv1.TemporaryLevelAdjustment{
|
||||
Track: track,
|
||||
Level: adjustment.Level,
|
||||
DurationDays: adjustment.DurationDays,
|
||||
})
|
||||
}
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return AppUser{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
|
||||
}
|
||||
nowMs := nowMillis()
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
reason = "admin_temporary_user_level"
|
||||
}
|
||||
_, err := s.levelAdminClient.AdjustTemporaryUserLevels(ctx, &activityv1.AdjustTemporaryUserLevelsRequest{
|
||||
Meta: &activityv1.RequestMeta{
|
||||
RequestId: requestID,
|
||||
Caller: "hyapp-admin-server",
|
||||
SentAtMs: nowMs,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
},
|
||||
CommandId: fmt.Sprintf("app-user-level:%s:%d", requestID, userID),
|
||||
UserId: userID,
|
||||
Adjustments: adjustments,
|
||||
OperatorAdminId: operatorAdminID,
|
||||
Reason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
return s.GetUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) AdminBanUser(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req banUserRequest) (SetUserStatusResult, error) {
|
||||
if s.userAdminClient == nil {
|
||||
return SetUserStatusResult{}, fmt.Errorf("user service admin ban client is not configured")
|
||||
}
|
||||
nowMs := nowMillis()
|
||||
if userID <= 0 || operatorAdminID <= 0 || req.ExpiresAtMs < 0 || (req.ExpiresAtMs > 0 && req.ExpiresAtMs <= nowMs) {
|
||||
return SetUserStatusResult{}, fmt.Errorf("%w: 封禁截止时间必须为 0 或晚于当前 UTC 时间", ErrInvalidArgument)
|
||||
}
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return SetUserStatusResult{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
reason = "admin_app_user_ban"
|
||||
}
|
||||
response, err := s.userAdminClient.AdminBanUser(ctx, &userv1.AdminBanUserRequest{
|
||||
Meta: &userv1.RequestMeta{
|
||||
RequestId: requestID,
|
||||
Caller: "hyapp-admin-server",
|
||||
SentAtMs: nowMs,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
},
|
||||
CommandId: fmt.Sprintf("app-user-ban:%s:%d", requestID, userID),
|
||||
TargetUserId: userID,
|
||||
ExpiresAtMs: req.ExpiresAtMs,
|
||||
OperatorAdminId: operatorAdminID,
|
||||
Reason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
return SetUserStatusResult{}, err
|
||||
}
|
||||
return s.userStatusResultFromAdminResponse(ctx, userID, response.GetStatus())
|
||||
}
|
||||
|
||||
func (s *Service) AdminUnbanUser(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req unbanUserRequest) (SetUserStatusResult, error) {
|
||||
if s.userAdminClient == nil {
|
||||
return SetUserStatusResult{}, fmt.Errorf("user service admin ban client is not configured")
|
||||
}
|
||||
if userID <= 0 || operatorAdminID <= 0 {
|
||||
return SetUserStatusResult{}, fmt.Errorf("%w: 解封参数不正确", ErrInvalidArgument)
|
||||
}
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return SetUserStatusResult{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
reason = "admin_app_user_unban"
|
||||
}
|
||||
nowMs := nowMillis()
|
||||
response, err := s.userAdminClient.AdminUnbanUser(ctx, &userv1.AdminUnbanUserRequest{
|
||||
Meta: &userv1.RequestMeta{
|
||||
RequestId: requestID,
|
||||
Caller: "hyapp-admin-server",
|
||||
SentAtMs: nowMs,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
},
|
||||
// user-service 以 target_user_id 为边界原子释放全部 admin/manager 事实;不再由聚合摘要猜测具体来源。
|
||||
TargetUserId: userID,
|
||||
OperatorAdminId: operatorAdminID,
|
||||
Reason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
return SetUserStatusResult{}, err
|
||||
}
|
||||
return s.userStatusResultFromAdminResponse(ctx, userID, response.GetStatus())
|
||||
}
|
||||
|
||||
func (s *Service) userStatusResultFromAdminResponse(ctx context.Context, userID int64, status *userv1.SetUserStatusResponse) (SetUserStatusResult, error) {
|
||||
user, err := s.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return SetUserStatusResult{}, err
|
||||
}
|
||||
result := SetUserStatusResult{User: user}
|
||||
if status == nil {
|
||||
return result, nil
|
||||
}
|
||||
result.RevokedSessionCount = status.GetRevokedSessionCount()
|
||||
result.AccessTokenRevoked = status.GetAccessTokenRevoked()
|
||||
result.AccessTokenError = status.GetAccessTokenRevokeError()
|
||||
result.IMKicked = status.GetImKicked()
|
||||
result.IMKickError = status.GetImKickError()
|
||||
result.RoomEvicted = status.GetRoomEvicted()
|
||||
result.RoomID = status.GetRoomId()
|
||||
result.RTCKicked = status.GetRtcKicked()
|
||||
result.RTCKickError = status.GetRtcKickError()
|
||||
result.RoomEvictError = status.GetRoomEvictError()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetPassword(ctx context.Context, userID int64, password string) error {
|
||||
if s.userDB == nil {
|
||||
return fmt.Errorf("user mysql is not configured")
|
||||
@ -784,6 +1380,9 @@ func normalizeListQuery(query listQuery) listQuery {
|
||||
if query.EndMs < 0 {
|
||||
query.EndMs = 0
|
||||
}
|
||||
if query.SnapshotAtMs < 0 {
|
||||
query.SnapshotAtMs = 0
|
||||
}
|
||||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||
query.SortBy = normalizeAppUserSortBy(query.SortBy)
|
||||
query.SortDirection = normalizeSortDirection(query.SortDirection)
|
||||
@ -833,6 +1432,16 @@ func appUserListWhereSQLAt(appCode string, query listQuery, nowMs int64) (string
|
||||
whereSQL += " AND u.created_at_ms < ?"
|
||||
args = append(args, query.EndMs)
|
||||
}
|
||||
if query.SnapshotAtMs > 0 {
|
||||
// 导出只读取任务创建时已经存在的用户,避免长任务执行期间的新注册插入到头部并改变分页集合。
|
||||
whereSQL += " AND u.created_at_ms <= ?"
|
||||
args = append(args, query.SnapshotAtMs)
|
||||
}
|
||||
if query.ExportCursorCreatedAtMs > 0 && query.ExportCursorUserID > 0 {
|
||||
// 导出固定 created_at DESC/user_id DESC,组合游标处理同毫秒注册且不依赖 OFFSET。
|
||||
whereSQL += " AND (u.created_at_ms < ? OR (u.created_at_ms = ? AND u.user_id < ?))"
|
||||
args = append(args, query.ExportCursorCreatedAtMs, query.ExportCursorCreatedAtMs, query.ExportCursorUserID)
|
||||
}
|
||||
return whereSQL, args
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package appuser
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
)
|
||||
@ -24,6 +25,68 @@ func TestNormalizeListQuerySort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgeAtUTCBirthdayBoundary(t *testing.T) {
|
||||
beforeBirthday := ageAtUTC("2000-07-11", time.Date(2026, time.July, 10, 23, 59, 59, 0, time.UTC))
|
||||
if beforeBirthday == nil || *beforeBirthday != 25 {
|
||||
t.Fatalf("age before UTC birthday = %v, want 25", beforeBirthday)
|
||||
}
|
||||
onBirthday := ageAtUTC("2000-07-11", time.Date(2026, time.July, 11, 0, 0, 0, 0, time.UTC))
|
||||
if onBirthday == nil || *onBirthday != 26 {
|
||||
t.Fatalf("age on UTC birthday = %v, want 26", onBirthday)
|
||||
}
|
||||
if got := ageAtUTC("", time.Now().UTC()); got != nil {
|
||||
t.Fatalf("empty birth age = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayUserRolesSupportsMultipleIdentities(t *testing.T) {
|
||||
roles := displayUserRoles([]string{"manager", "host", "bd_leader", "host", "unknown"})
|
||||
want := []string{"主播", "BD Leader", "经理"}
|
||||
if strings.Join(roles, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("roles = %#v, want %#v", roles, want)
|
||||
}
|
||||
ordinary := displayUserRoles(nil)
|
||||
if len(ordinary) != 1 || ordinary[0] != "普通用户" {
|
||||
t.Fatalf("ordinary roles = %#v", ordinary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppUserExportRowKeepsCompositeIdentityFieldsAndAddsColumns(t *testing.T) {
|
||||
age := int32(26)
|
||||
item := AppUser{
|
||||
UserID: "10001",
|
||||
DefaultDisplayUserID: "1299324",
|
||||
PrettyDisplayUserID: "11222",
|
||||
Username: "sumon Ahammad",
|
||||
Avatar: "https://example.test/avatar.png",
|
||||
Age: &age,
|
||||
Roles: []string{"主播", "经理"},
|
||||
Levels: AppUserLevels{
|
||||
Wealth: AppUserLevel{RealLevel: 8, DisplayLevel: 14, TemporaryTargetLevel: 14, ExpiresAtMs: 1234},
|
||||
Charm: AppUserLevel{RealLevel: 9, DisplayLevel: 15, TemporaryTargetLevel: 15, ExpiresAtMs: 5678},
|
||||
Game: AppUserLevel{DisplayLevel: 6},
|
||||
},
|
||||
}
|
||||
row := appUserExportRow(item)
|
||||
if len(row) != len(appUserExportHeaders()) {
|
||||
t.Fatalf("export row columns = %d, headers = %d", len(row), len(appUserExportHeaders()))
|
||||
}
|
||||
if row[0] != "10001" || row[1] != "1299324" || row[2] != "11222" || row[3] != "sumon Ahammad" || row[4] != "https://example.test/avatar.png" {
|
||||
t.Fatalf("identity columns changed: %#v", row[:5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetSafeCSVCellEscapesFormulaPrefixes(t *testing.T) {
|
||||
for _, value := range []string{"=HYPERLINK(\"https://example.test\")", "+cmd", " -1+1", "@SUM(A1:A2)"} {
|
||||
if got := spreadsheetSafeCSVCell(value); !strings.HasPrefix(got, "'") {
|
||||
t.Fatalf("dangerous CSV cell %q was not escaped: %q", value, got)
|
||||
}
|
||||
}
|
||||
if got := spreadsheetSafeCSVCell("normal user"); got != "normal user" {
|
||||
t.Fatalf("normal CSV cell changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppUserListWhereSQLFilters(t *testing.T) {
|
||||
whereSQL, args := appUserListWhereSQL("lalu", normalizeListQuery(listQuery{
|
||||
Country: "ph",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user