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:
zhx 2026-07-14 10:41:29 +08:00
commit 2f9f37f799
610 changed files with 74475 additions and 17759 deletions

12
.claude/launch.json Normal file
View 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
View File

@ -32,3 +32,4 @@ redis-data/
.tmp/
/tmp/
.claude/settings.local.json
backfill-last7

View File

@ -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 版本时先暴露静态风险。

View File

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

View File

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

View File

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

View 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 millisecondsMQ 实际发送时间不改变业务发生时间。
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
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 tabIMEI 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 adminmanager 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 hostagencybdbd_leadercoin_sellermanager
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 tokenrefresh
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);
}

View File

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

View File

@ -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/expiredexpired
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
// / H5gateway
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);

View File

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

View File

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

View File

@ -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 地址。

File diff suppressed because it is too large Load Diff

View File

@ -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 Tagconsumer 先部署
`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`
## 修复掉的问题

View File

@ -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 的 VIP1VIP9 初始全部为 `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 后立刻切 BB 生效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 点时的付费 VIP0 点后才购买或升级的状态从次日起参与。
3. 生成 `vip_daily_coin_rebates` 资格,领取窗口固定为 `[UTC 当日0点,UTC 次日0点)`
4. 同事务写 `VipDailyCoinRebateAvailable` outboxactivity-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`,并补自己的存储、审计、风控和边界测试。

View 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&region_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。

View File

@ -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 专用状态机;该通用响应不含完整 VipStateVIP 客户端应使用 /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"

View File

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

View File

@ -9,7 +9,7 @@
## Goals
- 用户申请加入 Agency 并通过后成为主播Agency 踢出用户只解除 Agency 归属,不取消主播身份。
- 用户只能搜索并申请加入自己所属区域的 active Agency
- Huwaa 用户可搜索并申请加入本 App 内的跨区域 active Agency其他 App 保留现有国家/区域限制
- Agency 归属于 BDBD 归属于 BD Leader。BD Leader 也是 BD只是具备邀请 BD 的能力。
- BD 可以邀请用户成为 AgencyBD 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.

View File

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

View File

@ -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 永久刷日志 |

View File

@ -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` | 查询当前可恢复房间 |

View 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` | 开关、一个当前版本和 17 天完整奖励 |
| 每日任务 | `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但 VIP1VIP10 全部 disabled | VIP1VIP8 active | 只启用已完成价格、权益和资源配置的 VIP1VIP9 |
| 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 旧副本代替 |
| 首充奖励 | 开关 03 个档位全部 disabled | 开关 13 个 active | 确认奖励后启用并走真实充值事实 |
| 累充奖励 | 开关 08 个档位全部 disabled | 开关 18 个 active | 确认周期和奖励后启用 |
| 七日签到 | 开关 0无生效版本无奖励 | 开关 1有生效版本和 7 天奖励 | 创建 Fami 版本和 7 天奖励后启用 |
| CP 周榜 | 开关 0无奖励 | 开关 13 个奖励 | 确认关系类型和奖励后启用 |
| 周星 | 当前 UTC 时间无 active 周期 | Lalu 有 1 个 active 周期 | 创建 Fami 周期、指定礼物和 Top 奖励 |
| 自研游戏运行数据 | 机器人 0档位奖池 0 | dice 机器人 80dice/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 个 activeFami 少 `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 价格/权益/资源后启用 VIP1VIP9。
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` 及区域 2430 的 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。

View File

@ -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[&region_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&region_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-servicegateway 先校验 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 成为唯一数据源。

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

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

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

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

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

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

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

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

View File

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

View 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避免复制其 RWMutexQueue 仍故意保持 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 故意保持 nilMessageExt.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)
}
}
}

View File

@ -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_typeconsumer 复用这些常量拼 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 构造无损迁移 selectorlegacy 分支消费切换前
// 已存在 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"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 '绑定资源 ID0 表示尚未绑定或纯功能权益',
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 '可选资源 ID0 表示直接按等级和时长发卡',
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'
);

View 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/claimedexpired 由查询层投影',
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';

View 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 功能开关';

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

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

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

View File

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

View File

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

View File

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

View File

@ -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 用户密码 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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 行为。管理员下一次
// 保存会写回 2Update 入口仍通过严格校验拒绝新建 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)
}

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

View File

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

View File

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

View File

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

View File

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

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

View 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 必须同时写入显式字段和 metauser-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 返回 NotFoundservice 层原样透传,由 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")
}
}

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

View 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 = &copy
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)
}
}
}

View File

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

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

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

View File

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

View File

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

View File

@ -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 暴露三方绑定 openidprovider_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
}

View File

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