相关装扮接口

This commit is contained in:
zhx 2026-06-04 10:06:49 +08:00
parent 3c7648f98e
commit 86ae6db83f
51 changed files with 3645 additions and 822 deletions

File diff suppressed because it is too large Load Diff

View File

@ -825,6 +825,7 @@ message LevelTrackOverview {
int64 display_badge_resource_id = 10; int64 display_badge_resource_id = 10;
int64 reward_pending_count = 11; int64 reward_pending_count = 11;
int32 sort_order = 12; int32 sort_order = 12;
int32 display_badge_source_level = 13;
} }
message GetMyLevelOverviewRequest { message GetMyLevelOverviewRequest {
@ -850,6 +851,35 @@ message GetLevelTrackResponse {
int64 server_time_ms = 4; int64 server_time_ms = 4;
} }
// LevelDisplayTrackProfile 使
message LevelDisplayTrackProfile {
string track = 1;
int32 level = 2;
int64 tier_id = 3;
int64 avatar_frame_resource_id = 4;
int64 badge_resource_id = 5;
int32 badge_source_level = 6;
int64 updated_at_ms = 7;
}
// UserLevelDisplayProfile
message UserLevelDisplayProfile {
int64 user_id = 1;
LevelDisplayTrackProfile wealth = 2;
LevelDisplayTrackProfile game = 3;
LevelDisplayTrackProfile charm = 4;
}
message BatchGetUserLevelDisplayProfilesRequest {
RequestMeta meta = 1;
repeated int64 user_ids = 2;
}
message BatchGetUserLevelDisplayProfilesResponse {
repeated UserLevelDisplayProfile profiles = 1;
int64 server_time_ms = 2;
}
// LevelRewardJob // LevelRewardJob
message LevelRewardJob { message LevelRewardJob {
string reward_job_id = 1; string reward_job_id = 1;
@ -905,6 +935,24 @@ message ConsumeLevelEventResponse {
int64 reward_job_count = 6; int64 reward_job_count = 6;
} }
message RegistrationLevelBadgeGrant {
string track = 1;
int32 level = 2;
int64 resource_id = 3;
string grant_id = 4;
}
message IssueRegistrationLevelBadgesRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string command_id = 3;
}
message IssueRegistrationLevelBadgesResponse {
repeated RegistrationLevelBadgeGrant grants = 1;
int64 server_time_ms = 2;
}
message UpsertLevelTrackRequest { message UpsertLevelTrackRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string track = 2; string track = 2;
@ -1383,8 +1431,10 @@ service TaskService {
service GrowthLevelService { service GrowthLevelService {
rpc GetMyLevelOverview(GetMyLevelOverviewRequest) returns (GetMyLevelOverviewResponse); rpc GetMyLevelOverview(GetMyLevelOverviewRequest) returns (GetMyLevelOverviewResponse);
rpc GetLevelTrack(GetLevelTrackRequest) returns (GetLevelTrackResponse); rpc GetLevelTrack(GetLevelTrackRequest) returns (GetLevelTrackResponse);
rpc BatchGetUserLevelDisplayProfiles(BatchGetUserLevelDisplayProfilesRequest) returns (BatchGetUserLevelDisplayProfilesResponse);
rpc ListLevelRewards(ListLevelRewardsRequest) returns (ListLevelRewardsResponse); rpc ListLevelRewards(ListLevelRewardsRequest) returns (ListLevelRewardsResponse);
rpc ConsumeLevelEvent(ConsumeLevelEventRequest) returns (ConsumeLevelEventResponse); rpc ConsumeLevelEvent(ConsumeLevelEventRequest) returns (ConsumeLevelEventResponse);
rpc IssueRegistrationLevelBadges(IssueRegistrationLevelBadgesRequest) returns (IssueRegistrationLevelBadgesResponse);
} }
// AchievementService owns App achievement reads, badge display and internal achievement event consumption. // AchievementService owns App achievement reads, badge display and internal achievement event consumption.

View File

@ -861,10 +861,12 @@ var TaskService_ServiceDesc = grpc.ServiceDesc{
} }
const ( const (
GrowthLevelService_GetMyLevelOverview_FullMethodName = "/hyapp.activity.v1.GrowthLevelService/GetMyLevelOverview" GrowthLevelService_GetMyLevelOverview_FullMethodName = "/hyapp.activity.v1.GrowthLevelService/GetMyLevelOverview"
GrowthLevelService_GetLevelTrack_FullMethodName = "/hyapp.activity.v1.GrowthLevelService/GetLevelTrack" GrowthLevelService_GetLevelTrack_FullMethodName = "/hyapp.activity.v1.GrowthLevelService/GetLevelTrack"
GrowthLevelService_ListLevelRewards_FullMethodName = "/hyapp.activity.v1.GrowthLevelService/ListLevelRewards" GrowthLevelService_BatchGetUserLevelDisplayProfiles_FullMethodName = "/hyapp.activity.v1.GrowthLevelService/BatchGetUserLevelDisplayProfiles"
GrowthLevelService_ConsumeLevelEvent_FullMethodName = "/hyapp.activity.v1.GrowthLevelService/ConsumeLevelEvent" GrowthLevelService_ListLevelRewards_FullMethodName = "/hyapp.activity.v1.GrowthLevelService/ListLevelRewards"
GrowthLevelService_ConsumeLevelEvent_FullMethodName = "/hyapp.activity.v1.GrowthLevelService/ConsumeLevelEvent"
GrowthLevelService_IssueRegistrationLevelBadges_FullMethodName = "/hyapp.activity.v1.GrowthLevelService/IssueRegistrationLevelBadges"
) )
// GrowthLevelServiceClient is the client API for GrowthLevelService service. // GrowthLevelServiceClient is the client API for GrowthLevelService service.
@ -875,8 +877,10 @@ const (
type GrowthLevelServiceClient interface { type GrowthLevelServiceClient interface {
GetMyLevelOverview(ctx context.Context, in *GetMyLevelOverviewRequest, opts ...grpc.CallOption) (*GetMyLevelOverviewResponse, error) GetMyLevelOverview(ctx context.Context, in *GetMyLevelOverviewRequest, opts ...grpc.CallOption) (*GetMyLevelOverviewResponse, error)
GetLevelTrack(ctx context.Context, in *GetLevelTrackRequest, opts ...grpc.CallOption) (*GetLevelTrackResponse, error) GetLevelTrack(ctx context.Context, in *GetLevelTrackRequest, opts ...grpc.CallOption) (*GetLevelTrackResponse, error)
BatchGetUserLevelDisplayProfiles(ctx context.Context, in *BatchGetUserLevelDisplayProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelDisplayProfilesResponse, error)
ListLevelRewards(ctx context.Context, in *ListLevelRewardsRequest, opts ...grpc.CallOption) (*ListLevelRewardsResponse, error) ListLevelRewards(ctx context.Context, in *ListLevelRewardsRequest, opts ...grpc.CallOption) (*ListLevelRewardsResponse, error)
ConsumeLevelEvent(ctx context.Context, in *ConsumeLevelEventRequest, opts ...grpc.CallOption) (*ConsumeLevelEventResponse, error) ConsumeLevelEvent(ctx context.Context, in *ConsumeLevelEventRequest, opts ...grpc.CallOption) (*ConsumeLevelEventResponse, error)
IssueRegistrationLevelBadges(ctx context.Context, in *IssueRegistrationLevelBadgesRequest, opts ...grpc.CallOption) (*IssueRegistrationLevelBadgesResponse, error)
} }
type growthLevelServiceClient struct { type growthLevelServiceClient struct {
@ -907,6 +911,16 @@ func (c *growthLevelServiceClient) GetLevelTrack(ctx context.Context, in *GetLev
return out, nil return out, nil
} }
func (c *growthLevelServiceClient) BatchGetUserLevelDisplayProfiles(ctx context.Context, in *BatchGetUserLevelDisplayProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelDisplayProfilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetUserLevelDisplayProfilesResponse)
err := c.cc.Invoke(ctx, GrowthLevelService_BatchGetUserLevelDisplayProfiles_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *growthLevelServiceClient) ListLevelRewards(ctx context.Context, in *ListLevelRewardsRequest, opts ...grpc.CallOption) (*ListLevelRewardsResponse, error) { func (c *growthLevelServiceClient) ListLevelRewards(ctx context.Context, in *ListLevelRewardsRequest, opts ...grpc.CallOption) (*ListLevelRewardsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLevelRewardsResponse) out := new(ListLevelRewardsResponse)
@ -927,6 +941,16 @@ func (c *growthLevelServiceClient) ConsumeLevelEvent(ctx context.Context, in *Co
return out, nil return out, nil
} }
func (c *growthLevelServiceClient) IssueRegistrationLevelBadges(ctx context.Context, in *IssueRegistrationLevelBadgesRequest, opts ...grpc.CallOption) (*IssueRegistrationLevelBadgesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(IssueRegistrationLevelBadgesResponse)
err := c.cc.Invoke(ctx, GrowthLevelService_IssueRegistrationLevelBadges_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GrowthLevelServiceServer is the server API for GrowthLevelService service. // GrowthLevelServiceServer is the server API for GrowthLevelService service.
// All implementations must embed UnimplementedGrowthLevelServiceServer // All implementations must embed UnimplementedGrowthLevelServiceServer
// for forward compatibility. // for forward compatibility.
@ -935,8 +959,10 @@ func (c *growthLevelServiceClient) ConsumeLevelEvent(ctx context.Context, in *Co
type GrowthLevelServiceServer interface { type GrowthLevelServiceServer interface {
GetMyLevelOverview(context.Context, *GetMyLevelOverviewRequest) (*GetMyLevelOverviewResponse, error) GetMyLevelOverview(context.Context, *GetMyLevelOverviewRequest) (*GetMyLevelOverviewResponse, error)
GetLevelTrack(context.Context, *GetLevelTrackRequest) (*GetLevelTrackResponse, error) GetLevelTrack(context.Context, *GetLevelTrackRequest) (*GetLevelTrackResponse, error)
BatchGetUserLevelDisplayProfiles(context.Context, *BatchGetUserLevelDisplayProfilesRequest) (*BatchGetUserLevelDisplayProfilesResponse, error)
ListLevelRewards(context.Context, *ListLevelRewardsRequest) (*ListLevelRewardsResponse, error) ListLevelRewards(context.Context, *ListLevelRewardsRequest) (*ListLevelRewardsResponse, error)
ConsumeLevelEvent(context.Context, *ConsumeLevelEventRequest) (*ConsumeLevelEventResponse, error) ConsumeLevelEvent(context.Context, *ConsumeLevelEventRequest) (*ConsumeLevelEventResponse, error)
IssueRegistrationLevelBadges(context.Context, *IssueRegistrationLevelBadgesRequest) (*IssueRegistrationLevelBadgesResponse, error)
mustEmbedUnimplementedGrowthLevelServiceServer() mustEmbedUnimplementedGrowthLevelServiceServer()
} }
@ -953,12 +979,18 @@ func (UnimplementedGrowthLevelServiceServer) GetMyLevelOverview(context.Context,
func (UnimplementedGrowthLevelServiceServer) GetLevelTrack(context.Context, *GetLevelTrackRequest) (*GetLevelTrackResponse, error) { func (UnimplementedGrowthLevelServiceServer) GetLevelTrack(context.Context, *GetLevelTrackRequest) (*GetLevelTrackResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetLevelTrack not implemented") return nil, status.Error(codes.Unimplemented, "method GetLevelTrack not implemented")
} }
func (UnimplementedGrowthLevelServiceServer) BatchGetUserLevelDisplayProfiles(context.Context, *BatchGetUserLevelDisplayProfilesRequest) (*BatchGetUserLevelDisplayProfilesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method BatchGetUserLevelDisplayProfiles not implemented")
}
func (UnimplementedGrowthLevelServiceServer) ListLevelRewards(context.Context, *ListLevelRewardsRequest) (*ListLevelRewardsResponse, error) { func (UnimplementedGrowthLevelServiceServer) ListLevelRewards(context.Context, *ListLevelRewardsRequest) (*ListLevelRewardsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLevelRewards not implemented") return nil, status.Error(codes.Unimplemented, "method ListLevelRewards not implemented")
} }
func (UnimplementedGrowthLevelServiceServer) ConsumeLevelEvent(context.Context, *ConsumeLevelEventRequest) (*ConsumeLevelEventResponse, error) { func (UnimplementedGrowthLevelServiceServer) ConsumeLevelEvent(context.Context, *ConsumeLevelEventRequest) (*ConsumeLevelEventResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ConsumeLevelEvent not implemented") return nil, status.Error(codes.Unimplemented, "method ConsumeLevelEvent not implemented")
} }
func (UnimplementedGrowthLevelServiceServer) IssueRegistrationLevelBadges(context.Context, *IssueRegistrationLevelBadgesRequest) (*IssueRegistrationLevelBadgesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method IssueRegistrationLevelBadges not implemented")
}
func (UnimplementedGrowthLevelServiceServer) mustEmbedUnimplementedGrowthLevelServiceServer() {} func (UnimplementedGrowthLevelServiceServer) mustEmbedUnimplementedGrowthLevelServiceServer() {}
func (UnimplementedGrowthLevelServiceServer) testEmbeddedByValue() {} func (UnimplementedGrowthLevelServiceServer) testEmbeddedByValue() {}
@ -1016,6 +1048,24 @@ func _GrowthLevelService_GetLevelTrack_Handler(srv interface{}, ctx context.Cont
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _GrowthLevelService_BatchGetUserLevelDisplayProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetUserLevelDisplayProfilesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GrowthLevelServiceServer).BatchGetUserLevelDisplayProfiles(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GrowthLevelService_BatchGetUserLevelDisplayProfiles_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GrowthLevelServiceServer).BatchGetUserLevelDisplayProfiles(ctx, req.(*BatchGetUserLevelDisplayProfilesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GrowthLevelService_ListLevelRewards_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _GrowthLevelService_ListLevelRewards_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLevelRewardsRequest) in := new(ListLevelRewardsRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -1052,6 +1102,24 @@ func _GrowthLevelService_ConsumeLevelEvent_Handler(srv interface{}, ctx context.
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _GrowthLevelService_IssueRegistrationLevelBadges_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(IssueRegistrationLevelBadgesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GrowthLevelServiceServer).IssueRegistrationLevelBadges(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GrowthLevelService_IssueRegistrationLevelBadges_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GrowthLevelServiceServer).IssueRegistrationLevelBadges(ctx, req.(*IssueRegistrationLevelBadgesRequest))
}
return interceptor(ctx, in, info, handler)
}
// GrowthLevelService_ServiceDesc is the grpc.ServiceDesc for GrowthLevelService service. // GrowthLevelService_ServiceDesc is the grpc.ServiceDesc for GrowthLevelService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -1067,6 +1135,10 @@ var GrowthLevelService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetLevelTrack", MethodName: "GetLevelTrack",
Handler: _GrowthLevelService_GetLevelTrack_Handler, Handler: _GrowthLevelService_GetLevelTrack_Handler,
}, },
{
MethodName: "BatchGetUserLevelDisplayProfiles",
Handler: _GrowthLevelService_BatchGetUserLevelDisplayProfiles_Handler,
},
{ {
MethodName: "ListLevelRewards", MethodName: "ListLevelRewards",
Handler: _GrowthLevelService_ListLevelRewards_Handler, Handler: _GrowthLevelService_ListLevelRewards_Handler,
@ -1075,6 +1147,10 @@ var GrowthLevelService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ConsumeLevelEvent", MethodName: "ConsumeLevelEvent",
Handler: _GrowthLevelService_ConsumeLevelEvent_Handler, Handler: _GrowthLevelService_ConsumeLevelEvent_Handler,
}, },
{
MethodName: "IssueRegistrationLevelBadges",
Handler: _GrowthLevelService_IssueRegistrationLevelBadges_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto", Metadata: "proto/activity/v1/activity.proto",

View File

@ -564,8 +564,10 @@ type LuckyGiftDrawResult struct {
CreatedAtMs int64 `protobuf:"varint,17,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` CreatedAtMs int64 `protobuf:"varint,17,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
WalletTransactionId string `protobuf:"bytes,18,opt,name=wallet_transaction_id,json=walletTransactionId,proto3" json:"wallet_transaction_id,omitempty"` WalletTransactionId string `protobuf:"bytes,18,opt,name=wallet_transaction_id,json=walletTransactionId,proto3" json:"wallet_transaction_id,omitempty"`
CoinBalanceAfter int64 `protobuf:"varint,19,opt,name=coin_balance_after,json=coinBalanceAfter,proto3" json:"coin_balance_after,omitempty"` CoinBalanceAfter int64 `protobuf:"varint,19,opt,name=coin_balance_after,json=coinBalanceAfter,proto3" json:"coin_balance_after,omitempty"`
unknownFields protoimpl.UnknownFields // target_user_id 是本次幸运礼物抽奖对应的收礼用户;多目标送礼用它关联结果。
sizeCache protoimpl.SizeCache TargetUserId int64 `protobuf:"varint,20,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *LuckyGiftDrawResult) Reset() { func (x *LuckyGiftDrawResult) Reset() {
@ -731,6 +733,13 @@ func (x *LuckyGiftDrawResult) GetCoinBalanceAfter() int64 {
return 0 return 0
} }
func (x *LuckyGiftDrawResult) GetTargetUserId() int64 {
if x != nil {
return x.TargetUserId
}
return 0
}
// RoomTreasureRewardItem 是后台配置给客户端展示的宝箱奖励候选项。 // RoomTreasureRewardItem 是后台配置给客户端展示的宝箱奖励候选项。
type RoomTreasureRewardItem struct { type RoomTreasureRewardItem struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -6770,9 +6779,12 @@ type SendGiftResponse struct {
GiftRank []*RankItem `protobuf:"bytes,4,rep,name=gift_rank,json=giftRank,proto3" json:"gift_rank,omitempty"` GiftRank []*RankItem `protobuf:"bytes,4,rep,name=gift_rank,json=giftRank,proto3" json:"gift_rank,omitempty"`
Room *RoomSnapshot `protobuf:"bytes,5,opt,name=room,proto3" json:"room,omitempty"` Room *RoomSnapshot `protobuf:"bytes,5,opt,name=room,proto3" json:"room,omitempty"`
Treasure *RoomTreasureState `protobuf:"bytes,6,opt,name=treasure,proto3" json:"treasure,omitempty"` Treasure *RoomTreasureState `protobuf:"bytes,6,opt,name=treasure,proto3" json:"treasure,omitempty"`
LuckyGift *LuckyGiftDrawResult `protobuf:"bytes,7,opt,name=lucky_gift,json=luckyGift,proto3" json:"lucky_gift,omitempty"` // lucky_gift 是本次送礼的聚合幸运礼物结果;多目标时倍率和返奖金额按所有目标累加。
unknownFields protoimpl.UnknownFields LuckyGift *LuckyGiftDrawResult `protobuf:"bytes,7,opt,name=lucky_gift,json=luckyGift,proto3" json:"lucky_gift,omitempty"`
sizeCache protoimpl.SizeCache // lucky_gifts 是每个收礼目标的独立抽奖结果;多目标客户端用 target_user_id 对齐明细表现。
LuckyGifts []*LuckyGiftDrawResult `protobuf:"bytes,8,rep,name=lucky_gifts,json=luckyGifts,proto3" json:"lucky_gifts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *SendGiftResponse) Reset() { func (x *SendGiftResponse) Reset() {
@ -6854,6 +6866,13 @@ func (x *SendGiftResponse) GetLuckyGift() *LuckyGiftDrawResult {
return nil return nil
} }
func (x *SendGiftResponse) GetLuckyGifts() []*LuckyGiftDrawResult {
if x != nil {
return x.LuckyGifts
}
return nil
}
// CheckSpeakPermissionRequest 让腾讯云 IM 发言回调或 gateway 在公屏前同步问房间业务态。 // CheckSpeakPermissionRequest 让腾讯云 IM 发言回调或 gateway 在公屏前同步问房间业务态。
type CheckSpeakPermissionRequest struct { type CheckSpeakPermissionRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -9022,7 +9041,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\x05score\x18\x02 \x01(\x03R\x05score\x12\x1d\n" + "\x05score\x18\x02 \x01(\x03R\x05score\x12\x1d\n" +
"\n" + "\n" +
"gift_value\x18\x03 \x01(\x03R\tgiftValue\x12\"\n" + "gift_value\x18\x03 \x01(\x03R\tgiftValue\x12\"\n" +
"\rupdated_at_ms\x18\x04 \x01(\x03R\vupdatedAtMs\"\x8a\x06\n" + "\rupdated_at_ms\x18\x04 \x01(\x03R\vupdatedAtMs\"\xb0\x06\n" +
"\x13LuckyGiftDrawResult\x12\x18\n" + "\x13LuckyGiftDrawResult\x12\x18\n" +
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x17\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x17\n" +
"\adraw_id\x18\x02 \x01(\tR\x06drawId\x12\x1d\n" + "\adraw_id\x18\x02 \x01(\tR\x06drawId\x12\x1d\n" +
@ -9044,7 +9063,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\x0fhigh_multiplier\x18\x10 \x01(\bR\x0ehighMultiplier\x12\"\n" + "\x0fhigh_multiplier\x18\x10 \x01(\bR\x0ehighMultiplier\x12\"\n" +
"\rcreated_at_ms\x18\x11 \x01(\x03R\vcreatedAtMs\x122\n" + "\rcreated_at_ms\x18\x11 \x01(\x03R\vcreatedAtMs\x122\n" +
"\x15wallet_transaction_id\x18\x12 \x01(\tR\x13walletTransactionId\x12,\n" + "\x15wallet_transaction_id\x18\x12 \x01(\tR\x13walletTransactionId\x12,\n" +
"\x12coin_balance_after\x18\x13 \x01(\x03R\x10coinBalanceAfter\"\xc0\x01\n" + "\x12coin_balance_after\x18\x13 \x01(\x03R\x10coinBalanceAfter\x12$\n" +
"\x0etarget_user_id\x18\x14 \x01(\x03R\ftargetUserId\"\xc0\x01\n" +
"\x16RoomTreasureRewardItem\x12$\n" + "\x16RoomTreasureRewardItem\x12$\n" +
"\x0ereward_item_id\x18\x01 \x01(\tR\frewardItemId\x12*\n" + "\x0ereward_item_id\x18\x01 \x01(\tR\frewardItemId\x12*\n" +
"\x11resource_group_id\x18\x02 \x01(\x03R\x0fresourceGroupId\x12\x16\n" + "\x11resource_group_id\x18\x02 \x01(\x03R\x0fresourceGroupId\x12\x16\n" +
@ -9543,7 +9563,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\x15target_host_region_id\x18\t \x01(\x03R\x12targetHostRegionId\x12<\n" + "\x15target_host_region_id\x18\t \x01(\x03R\x12targetHostRegionId\x12<\n" +
"\x1btarget_agency_owner_user_id\x18\n" + "\x1btarget_agency_owner_user_id\x18\n" +
" \x01(\x03R\x17targetAgencyOwnerUserId\x12(\n" + " \x01(\x03R\x17targetAgencyOwnerUserId\x12(\n" +
"\x10sender_region_id\x18\v \x01(\x03R\x0esenderRegionId\"\xfb\x02\n" + "\x10sender_region_id\x18\v \x01(\x03R\x0esenderRegionId\"\xc0\x03\n" +
"\x10SendGiftResponse\x124\n" + "\x10SendGiftResponse\x124\n" +
"\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" + "\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" +
"\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" + "\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" +
@ -9552,7 +9572,9 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\x04room\x18\x05 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\x12<\n" + "\x04room\x18\x05 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\x12<\n" +
"\btreasure\x18\x06 \x01(\v2 .hyapp.room.v1.RoomTreasureStateR\btreasure\x12A\n" + "\btreasure\x18\x06 \x01(\v2 .hyapp.room.v1.RoomTreasureStateR\btreasure\x12A\n" +
"\n" + "\n" +
"lucky_gift\x18\a \x01(\v2\".hyapp.room.v1.LuckyGiftDrawResultR\tluckyGift\"j\n" + "lucky_gift\x18\a \x01(\v2\".hyapp.room.v1.LuckyGiftDrawResultR\tluckyGift\x12C\n" +
"\vlucky_gifts\x18\b \x03(\v2\".hyapp.room.v1.LuckyGiftDrawResultR\n" +
"luckyGifts\"j\n" +
"\x1bCheckSpeakPermissionRequest\x12\x17\n" + "\x1bCheckSpeakPermissionRequest\x12\x17\n" +
"\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\x17\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x19\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x19\n" +
@ -10028,128 +10050,129 @@ var file_proto_room_v1_room_proto_depIdxs = []int32{
32, // 114: hyapp.room.v1.SendGiftResponse.room:type_name -> hyapp.room.v1.RoomSnapshot 32, // 114: hyapp.room.v1.SendGiftResponse.room:type_name -> hyapp.room.v1.RoomSnapshot
10, // 115: hyapp.room.v1.SendGiftResponse.treasure:type_name -> hyapp.room.v1.RoomTreasureState 10, // 115: hyapp.room.v1.SendGiftResponse.treasure:type_name -> hyapp.room.v1.RoomTreasureState
6, // 116: hyapp.room.v1.SendGiftResponse.lucky_gift:type_name -> hyapp.room.v1.LuckyGiftDrawResult 6, // 116: hyapp.room.v1.SendGiftResponse.lucky_gift:type_name -> hyapp.room.v1.LuckyGiftDrawResult
0, // 117: hyapp.room.v1.ListRoomsRequest.meta:type_name -> hyapp.room.v1.RequestMeta 6, // 117: hyapp.room.v1.SendGiftResponse.lucky_gifts:type_name -> hyapp.room.v1.LuckyGiftDrawResult
0, // 118: hyapp.room.v1.ListRoomFeedsRequest.meta:type_name -> hyapp.room.v1.RequestMeta 0, // 118: hyapp.room.v1.ListRoomsRequest.meta:type_name -> hyapp.room.v1.RequestMeta
99, // 119: hyapp.room.v1.ListRoomFeedsRequest.related_users:type_name -> hyapp.room.v1.RoomFeedRelatedUser 0, // 119: hyapp.room.v1.ListRoomFeedsRequest.meta:type_name -> hyapp.room.v1.RequestMeta
0, // 120: hyapp.room.v1.ListRoomGiftLeaderboardRequest.meta:type_name -> hyapp.room.v1.RequestMeta 99, // 120: hyapp.room.v1.ListRoomFeedsRequest.related_users:type_name -> hyapp.room.v1.RoomFeedRelatedUser
100, // 121: hyapp.room.v1.ListRoomsResponse.rooms:type_name -> hyapp.room.v1.RoomListItem 0, // 121: hyapp.room.v1.ListRoomGiftLeaderboardRequest.meta:type_name -> hyapp.room.v1.RequestMeta
100, // 122: hyapp.room.v1.RoomGiftLeaderboardItem.room:type_name -> hyapp.room.v1.RoomListItem 100, // 122: hyapp.room.v1.ListRoomsResponse.rooms:type_name -> hyapp.room.v1.RoomListItem
102, // 123: hyapp.room.v1.ListRoomGiftLeaderboardResponse.items:type_name -> hyapp.room.v1.RoomGiftLeaderboardItem 100, // 123: hyapp.room.v1.RoomGiftLeaderboardItem.room:type_name -> hyapp.room.v1.RoomListItem
0, // 124: hyapp.room.v1.GetMyRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta 102, // 124: hyapp.room.v1.ListRoomGiftLeaderboardResponse.items:type_name -> hyapp.room.v1.RoomGiftLeaderboardItem
100, // 125: hyapp.room.v1.GetMyRoomResponse.room:type_name -> hyapp.room.v1.RoomListItem 0, // 125: hyapp.room.v1.GetMyRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta
0, // 126: hyapp.room.v1.GetCurrentRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta 100, // 126: hyapp.room.v1.GetMyRoomResponse.room:type_name -> hyapp.room.v1.RoomListItem
0, // 127: hyapp.room.v1.GetRoomSnapshotRequest.meta:type_name -> hyapp.room.v1.RequestMeta 0, // 127: hyapp.room.v1.GetCurrentRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta
32, // 128: hyapp.room.v1.GetRoomSnapshotResponse.room:type_name -> hyapp.room.v1.RoomSnapshot 0, // 128: hyapp.room.v1.GetRoomSnapshotRequest.meta:type_name -> hyapp.room.v1.RequestMeta
0, // 129: hyapp.room.v1.GetRoomTreasureRequest.meta:type_name -> hyapp.room.v1.RequestMeta 32, // 129: hyapp.room.v1.GetRoomSnapshotResponse.room:type_name -> hyapp.room.v1.RoomSnapshot
11, // 130: hyapp.room.v1.GetRoomTreasureResponse.treasure:type_name -> hyapp.room.v1.RoomTreasureInfo 0, // 130: hyapp.room.v1.GetRoomTreasureRequest.meta:type_name -> hyapp.room.v1.RequestMeta
0, // 131: hyapp.room.v1.ListRoomOnlineUsersRequest.meta:type_name -> hyapp.room.v1.RequestMeta 11, // 131: hyapp.room.v1.GetRoomTreasureResponse.treasure:type_name -> hyapp.room.v1.RoomTreasureInfo
2, // 132: hyapp.room.v1.ListRoomOnlineUsersResponse.users:type_name -> hyapp.room.v1.RoomUser 0, // 132: hyapp.room.v1.ListRoomOnlineUsersRequest.meta:type_name -> hyapp.room.v1.RequestMeta
3, // 133: hyapp.room.v1.ListRoomOnlineUsersResponse.items:type_name -> hyapp.room.v1.RoomOnlineUser 2, // 133: hyapp.room.v1.ListRoomOnlineUsersResponse.users:type_name -> hyapp.room.v1.RoomUser
0, // 134: hyapp.room.v1.ListRoomBannedUsersRequest.meta:type_name -> hyapp.room.v1.RequestMeta 3, // 134: hyapp.room.v1.ListRoomOnlineUsersResponse.items:type_name -> hyapp.room.v1.RoomOnlineUser
114, // 135: hyapp.room.v1.ListRoomBannedUsersResponse.items:type_name -> hyapp.room.v1.RoomBannedUser 0, // 135: hyapp.room.v1.ListRoomBannedUsersRequest.meta:type_name -> hyapp.room.v1.RequestMeta
0, // 136: hyapp.room.v1.FollowRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta 114, // 136: hyapp.room.v1.ListRoomBannedUsersResponse.items:type_name -> hyapp.room.v1.RoomBannedUser
0, // 137: hyapp.room.v1.UnfollowRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta 0, // 137: hyapp.room.v1.FollowRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta
40, // 138: hyapp.room.v1.RoomCommandService.CreateRoom:input_type -> hyapp.room.v1.CreateRoomRequest 0, // 138: hyapp.room.v1.UnfollowRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta
42, // 139: hyapp.room.v1.RoomCommandService.UpdateRoomProfile:input_type -> hyapp.room.v1.UpdateRoomProfileRequest 40, // 139: hyapp.room.v1.RoomCommandService.CreateRoom:input_type -> hyapp.room.v1.CreateRoomRequest
34, // 140: hyapp.room.v1.RoomCommandService.SaveRoomBackground:input_type -> hyapp.room.v1.SaveRoomBackgroundRequest 42, // 140: hyapp.room.v1.RoomCommandService.UpdateRoomProfile:input_type -> hyapp.room.v1.UpdateRoomProfileRequest
38, // 141: hyapp.room.v1.RoomCommandService.SetRoomBackground:input_type -> hyapp.room.v1.SetRoomBackgroundRequest 34, // 141: hyapp.room.v1.RoomCommandService.SaveRoomBackground:input_type -> hyapp.room.v1.SaveRoomBackgroundRequest
45, // 142: hyapp.room.v1.RoomCommandService.JoinRoom:input_type -> hyapp.room.v1.JoinRoomRequest 38, // 142: hyapp.room.v1.RoomCommandService.SetRoomBackground:input_type -> hyapp.room.v1.SetRoomBackgroundRequest
47, // 143: hyapp.room.v1.RoomCommandService.RoomHeartbeat:input_type -> hyapp.room.v1.RoomHeartbeatRequest 45, // 143: hyapp.room.v1.RoomCommandService.JoinRoom:input_type -> hyapp.room.v1.JoinRoomRequest
49, // 144: hyapp.room.v1.RoomCommandService.LeaveRoom:input_type -> hyapp.room.v1.LeaveRoomRequest 47, // 144: hyapp.room.v1.RoomCommandService.RoomHeartbeat:input_type -> hyapp.room.v1.RoomHeartbeatRequest
51, // 145: hyapp.room.v1.RoomCommandService.CloseRoom:input_type -> hyapp.room.v1.CloseRoomRequest 49, // 145: hyapp.room.v1.RoomCommandService.LeaveRoom:input_type -> hyapp.room.v1.LeaveRoomRequest
58, // 146: hyapp.room.v1.RoomCommandService.AdminUpdateRoom:input_type -> hyapp.room.v1.AdminUpdateRoomRequest 51, // 146: hyapp.room.v1.RoomCommandService.CloseRoom:input_type -> hyapp.room.v1.CloseRoomRequest
60, // 147: hyapp.room.v1.RoomCommandService.AdminDeleteRoom:input_type -> hyapp.room.v1.AdminDeleteRoomRequest 58, // 147: hyapp.room.v1.RoomCommandService.AdminUpdateRoom:input_type -> hyapp.room.v1.AdminUpdateRoomRequest
16, // 148: hyapp.room.v1.RoomCommandService.AdminUpdateRoomTreasureConfig:input_type -> hyapp.room.v1.AdminUpdateRoomTreasureConfigRequest 60, // 148: hyapp.room.v1.RoomCommandService.AdminDeleteRoom:input_type -> hyapp.room.v1.AdminDeleteRoomRequest
21, // 149: hyapp.room.v1.RoomCommandService.AdminUpdateRoomSeatConfig:input_type -> hyapp.room.v1.AdminUpdateRoomSeatConfigRequest 16, // 149: hyapp.room.v1.RoomCommandService.AdminUpdateRoomTreasureConfig:input_type -> hyapp.room.v1.AdminUpdateRoomTreasureConfigRequest
27, // 150: hyapp.room.v1.RoomCommandService.AdminCreateRoomPin:input_type -> hyapp.room.v1.AdminCreateRoomPinRequest 21, // 150: hyapp.room.v1.RoomCommandService.AdminUpdateRoomSeatConfig:input_type -> hyapp.room.v1.AdminUpdateRoomSeatConfigRequest
29, // 151: hyapp.room.v1.RoomCommandService.AdminCancelRoomPin:input_type -> hyapp.room.v1.AdminCancelRoomPinRequest 27, // 151: hyapp.room.v1.RoomCommandService.AdminCreateRoomPin:input_type -> hyapp.room.v1.AdminCreateRoomPinRequest
62, // 152: hyapp.room.v1.RoomCommandService.MicUp:input_type -> hyapp.room.v1.MicUpRequest 29, // 152: hyapp.room.v1.RoomCommandService.AdminCancelRoomPin:input_type -> hyapp.room.v1.AdminCancelRoomPinRequest
64, // 153: hyapp.room.v1.RoomCommandService.MicDown:input_type -> hyapp.room.v1.MicDownRequest 62, // 153: hyapp.room.v1.RoomCommandService.MicUp:input_type -> hyapp.room.v1.MicUpRequest
66, // 154: hyapp.room.v1.RoomCommandService.ChangeMicSeat:input_type -> hyapp.room.v1.ChangeMicSeatRequest 64, // 154: hyapp.room.v1.RoomCommandService.MicDown:input_type -> hyapp.room.v1.MicDownRequest
68, // 155: hyapp.room.v1.RoomCommandService.ConfirmMicPublishing:input_type -> hyapp.room.v1.ConfirmMicPublishingRequest 66, // 155: hyapp.room.v1.RoomCommandService.ChangeMicSeat:input_type -> hyapp.room.v1.ChangeMicSeatRequest
70, // 156: hyapp.room.v1.RoomCommandService.SetMicMute:input_type -> hyapp.room.v1.SetMicMuteRequest 68, // 156: hyapp.room.v1.RoomCommandService.ConfirmMicPublishing:input_type -> hyapp.room.v1.ConfirmMicPublishingRequest
72, // 157: hyapp.room.v1.RoomCommandService.ApplyRTCEvent:input_type -> hyapp.room.v1.ApplyRTCEventRequest 70, // 157: hyapp.room.v1.RoomCommandService.SetMicMute:input_type -> hyapp.room.v1.SetMicMuteRequest
74, // 158: hyapp.room.v1.RoomCommandService.SetMicSeatLock:input_type -> hyapp.room.v1.SetMicSeatLockRequest 72, // 158: hyapp.room.v1.RoomCommandService.ApplyRTCEvent:input_type -> hyapp.room.v1.ApplyRTCEventRequest
76, // 159: hyapp.room.v1.RoomCommandService.SetChatEnabled:input_type -> hyapp.room.v1.SetChatEnabledRequest 74, // 159: hyapp.room.v1.RoomCommandService.SetMicSeatLock:input_type -> hyapp.room.v1.SetMicSeatLockRequest
78, // 160: hyapp.room.v1.RoomCommandService.SetRoomPassword:input_type -> hyapp.room.v1.SetRoomPasswordRequest 76, // 160: hyapp.room.v1.RoomCommandService.SetChatEnabled:input_type -> hyapp.room.v1.SetChatEnabledRequest
80, // 161: hyapp.room.v1.RoomCommandService.SetRoomAdmin:input_type -> hyapp.room.v1.SetRoomAdminRequest 78, // 161: hyapp.room.v1.RoomCommandService.SetRoomPassword:input_type -> hyapp.room.v1.SetRoomPasswordRequest
82, // 162: hyapp.room.v1.RoomCommandService.MuteUser:input_type -> hyapp.room.v1.MuteUserRequest 80, // 162: hyapp.room.v1.RoomCommandService.SetRoomAdmin:input_type -> hyapp.room.v1.SetRoomAdminRequest
84, // 163: hyapp.room.v1.RoomCommandService.KickUser:input_type -> hyapp.room.v1.KickUserRequest 82, // 163: hyapp.room.v1.RoomCommandService.MuteUser:input_type -> hyapp.room.v1.MuteUserRequest
86, // 164: hyapp.room.v1.RoomCommandService.UnbanUser:input_type -> hyapp.room.v1.UnbanUserRequest 84, // 164: hyapp.room.v1.RoomCommandService.KickUser:input_type -> hyapp.room.v1.KickUserRequest
88, // 165: hyapp.room.v1.RoomCommandService.SystemEvictUser:input_type -> hyapp.room.v1.SystemEvictUserRequest 86, // 165: hyapp.room.v1.RoomCommandService.UnbanUser:input_type -> hyapp.room.v1.UnbanUserRequest
90, // 166: hyapp.room.v1.RoomCommandService.SendGift:input_type -> hyapp.room.v1.SendGiftRequest 88, // 166: hyapp.room.v1.RoomCommandService.SystemEvictUser:input_type -> hyapp.room.v1.SystemEvictUserRequest
117, // 167: hyapp.room.v1.RoomCommandService.FollowRoom:input_type -> hyapp.room.v1.FollowRoomRequest 90, // 167: hyapp.room.v1.RoomCommandService.SendGift:input_type -> hyapp.room.v1.SendGiftRequest
119, // 168: hyapp.room.v1.RoomCommandService.UnfollowRoom:input_type -> hyapp.room.v1.UnfollowRoomRequest 117, // 168: hyapp.room.v1.RoomCommandService.FollowRoom:input_type -> hyapp.room.v1.FollowRoomRequest
92, // 169: hyapp.room.v1.RoomGuardService.CheckSpeakPermission:input_type -> hyapp.room.v1.CheckSpeakPermissionRequest 119, // 169: hyapp.room.v1.RoomCommandService.UnfollowRoom:input_type -> hyapp.room.v1.UnfollowRoomRequest
94, // 170: hyapp.room.v1.RoomGuardService.VerifyRoomPresence:input_type -> hyapp.room.v1.VerifyRoomPresenceRequest 92, // 170: hyapp.room.v1.RoomGuardService.CheckSpeakPermission:input_type -> hyapp.room.v1.CheckSpeakPermissionRequest
54, // 171: hyapp.room.v1.RoomQueryService.AdminListRooms:input_type -> hyapp.room.v1.AdminListRoomsRequest 94, // 171: hyapp.room.v1.RoomGuardService.VerifyRoomPresence:input_type -> hyapp.room.v1.VerifyRoomPresenceRequest
56, // 172: hyapp.room.v1.RoomQueryService.AdminGetRoom:input_type -> hyapp.room.v1.AdminGetRoomRequest 54, // 172: hyapp.room.v1.RoomQueryService.AdminListRooms:input_type -> hyapp.room.v1.AdminListRoomsRequest
14, // 173: hyapp.room.v1.RoomQueryService.AdminGetRoomTreasureConfig:input_type -> hyapp.room.v1.AdminGetRoomTreasureConfigRequest 56, // 173: hyapp.room.v1.RoomQueryService.AdminGetRoom:input_type -> hyapp.room.v1.AdminGetRoomRequest
19, // 174: hyapp.room.v1.RoomQueryService.AdminGetRoomSeatConfig:input_type -> hyapp.room.v1.AdminGetRoomSeatConfigRequest 14, // 174: hyapp.room.v1.RoomQueryService.AdminGetRoomTreasureConfig:input_type -> hyapp.room.v1.AdminGetRoomTreasureConfigRequest
25, // 175: hyapp.room.v1.RoomQueryService.AdminListRoomPins:input_type -> hyapp.room.v1.AdminListRoomPinsRequest 19, // 175: hyapp.room.v1.RoomQueryService.AdminGetRoomSeatConfig:input_type -> hyapp.room.v1.AdminGetRoomSeatConfigRequest
96, // 176: hyapp.room.v1.RoomQueryService.ListRooms:input_type -> hyapp.room.v1.ListRoomsRequest 25, // 176: hyapp.room.v1.RoomQueryService.AdminListRoomPins:input_type -> hyapp.room.v1.AdminListRoomPinsRequest
97, // 177: hyapp.room.v1.RoomQueryService.ListRoomFeeds:input_type -> hyapp.room.v1.ListRoomFeedsRequest 96, // 177: hyapp.room.v1.RoomQueryService.ListRooms:input_type -> hyapp.room.v1.ListRoomsRequest
98, // 178: hyapp.room.v1.RoomQueryService.ListRoomGiftLeaderboard:input_type -> hyapp.room.v1.ListRoomGiftLeaderboardRequest 97, // 178: hyapp.room.v1.RoomQueryService.ListRoomFeeds:input_type -> hyapp.room.v1.ListRoomFeedsRequest
104, // 179: hyapp.room.v1.RoomQueryService.GetMyRoom:input_type -> hyapp.room.v1.GetMyRoomRequest 98, // 179: hyapp.room.v1.RoomQueryService.ListRoomGiftLeaderboard:input_type -> hyapp.room.v1.ListRoomGiftLeaderboardRequest
106, // 180: hyapp.room.v1.RoomQueryService.GetCurrentRoom:input_type -> hyapp.room.v1.GetCurrentRoomRequest 104, // 180: hyapp.room.v1.RoomQueryService.GetMyRoom:input_type -> hyapp.room.v1.GetMyRoomRequest
108, // 181: hyapp.room.v1.RoomQueryService.GetRoomSnapshot:input_type -> hyapp.room.v1.GetRoomSnapshotRequest 106, // 181: hyapp.room.v1.RoomQueryService.GetCurrentRoom:input_type -> hyapp.room.v1.GetCurrentRoomRequest
36, // 182: hyapp.room.v1.RoomQueryService.ListRoomBackgrounds:input_type -> hyapp.room.v1.ListRoomBackgroundsRequest 108, // 182: hyapp.room.v1.RoomQueryService.GetRoomSnapshot:input_type -> hyapp.room.v1.GetRoomSnapshotRequest
110, // 183: hyapp.room.v1.RoomQueryService.GetRoomTreasure:input_type -> hyapp.room.v1.GetRoomTreasureRequest 36, // 183: hyapp.room.v1.RoomQueryService.ListRoomBackgrounds:input_type -> hyapp.room.v1.ListRoomBackgroundsRequest
112, // 184: hyapp.room.v1.RoomQueryService.ListRoomOnlineUsers:input_type -> hyapp.room.v1.ListRoomOnlineUsersRequest 110, // 184: hyapp.room.v1.RoomQueryService.GetRoomTreasure:input_type -> hyapp.room.v1.GetRoomTreasureRequest
115, // 185: hyapp.room.v1.RoomQueryService.ListRoomBannedUsers:input_type -> hyapp.room.v1.ListRoomBannedUsersRequest 112, // 185: hyapp.room.v1.RoomQueryService.ListRoomOnlineUsers:input_type -> hyapp.room.v1.ListRoomOnlineUsersRequest
41, // 186: hyapp.room.v1.RoomCommandService.CreateRoom:output_type -> hyapp.room.v1.CreateRoomResponse 115, // 186: hyapp.room.v1.RoomQueryService.ListRoomBannedUsers:input_type -> hyapp.room.v1.ListRoomBannedUsersRequest
43, // 187: hyapp.room.v1.RoomCommandService.UpdateRoomProfile:output_type -> hyapp.room.v1.UpdateRoomProfileResponse 41, // 187: hyapp.room.v1.RoomCommandService.CreateRoom:output_type -> hyapp.room.v1.CreateRoomResponse
35, // 188: hyapp.room.v1.RoomCommandService.SaveRoomBackground:output_type -> hyapp.room.v1.SaveRoomBackgroundResponse 43, // 188: hyapp.room.v1.RoomCommandService.UpdateRoomProfile:output_type -> hyapp.room.v1.UpdateRoomProfileResponse
39, // 189: hyapp.room.v1.RoomCommandService.SetRoomBackground:output_type -> hyapp.room.v1.SetRoomBackgroundResponse 35, // 189: hyapp.room.v1.RoomCommandService.SaveRoomBackground:output_type -> hyapp.room.v1.SaveRoomBackgroundResponse
46, // 190: hyapp.room.v1.RoomCommandService.JoinRoom:output_type -> hyapp.room.v1.JoinRoomResponse 39, // 190: hyapp.room.v1.RoomCommandService.SetRoomBackground:output_type -> hyapp.room.v1.SetRoomBackgroundResponse
48, // 191: hyapp.room.v1.RoomCommandService.RoomHeartbeat:output_type -> hyapp.room.v1.RoomHeartbeatResponse 46, // 191: hyapp.room.v1.RoomCommandService.JoinRoom:output_type -> hyapp.room.v1.JoinRoomResponse
50, // 192: hyapp.room.v1.RoomCommandService.LeaveRoom:output_type -> hyapp.room.v1.LeaveRoomResponse 48, // 192: hyapp.room.v1.RoomCommandService.RoomHeartbeat:output_type -> hyapp.room.v1.RoomHeartbeatResponse
52, // 193: hyapp.room.v1.RoomCommandService.CloseRoom:output_type -> hyapp.room.v1.CloseRoomResponse 50, // 193: hyapp.room.v1.RoomCommandService.LeaveRoom:output_type -> hyapp.room.v1.LeaveRoomResponse
59, // 194: hyapp.room.v1.RoomCommandService.AdminUpdateRoom:output_type -> hyapp.room.v1.AdminUpdateRoomResponse 52, // 194: hyapp.room.v1.RoomCommandService.CloseRoom:output_type -> hyapp.room.v1.CloseRoomResponse
61, // 195: hyapp.room.v1.RoomCommandService.AdminDeleteRoom:output_type -> hyapp.room.v1.AdminDeleteRoomResponse 59, // 195: hyapp.room.v1.RoomCommandService.AdminUpdateRoom:output_type -> hyapp.room.v1.AdminUpdateRoomResponse
17, // 196: hyapp.room.v1.RoomCommandService.AdminUpdateRoomTreasureConfig:output_type -> hyapp.room.v1.AdminUpdateRoomTreasureConfigResponse 61, // 196: hyapp.room.v1.RoomCommandService.AdminDeleteRoom:output_type -> hyapp.room.v1.AdminDeleteRoomResponse
22, // 197: hyapp.room.v1.RoomCommandService.AdminUpdateRoomSeatConfig:output_type -> hyapp.room.v1.AdminUpdateRoomSeatConfigResponse 17, // 197: hyapp.room.v1.RoomCommandService.AdminUpdateRoomTreasureConfig:output_type -> hyapp.room.v1.AdminUpdateRoomTreasureConfigResponse
28, // 198: hyapp.room.v1.RoomCommandService.AdminCreateRoomPin:output_type -> hyapp.room.v1.AdminCreateRoomPinResponse 22, // 198: hyapp.room.v1.RoomCommandService.AdminUpdateRoomSeatConfig:output_type -> hyapp.room.v1.AdminUpdateRoomSeatConfigResponse
30, // 199: hyapp.room.v1.RoomCommandService.AdminCancelRoomPin:output_type -> hyapp.room.v1.AdminCancelRoomPinResponse 28, // 199: hyapp.room.v1.RoomCommandService.AdminCreateRoomPin:output_type -> hyapp.room.v1.AdminCreateRoomPinResponse
63, // 200: hyapp.room.v1.RoomCommandService.MicUp:output_type -> hyapp.room.v1.MicUpResponse 30, // 200: hyapp.room.v1.RoomCommandService.AdminCancelRoomPin:output_type -> hyapp.room.v1.AdminCancelRoomPinResponse
65, // 201: hyapp.room.v1.RoomCommandService.MicDown:output_type -> hyapp.room.v1.MicDownResponse 63, // 201: hyapp.room.v1.RoomCommandService.MicUp:output_type -> hyapp.room.v1.MicUpResponse
67, // 202: hyapp.room.v1.RoomCommandService.ChangeMicSeat:output_type -> hyapp.room.v1.ChangeMicSeatResponse 65, // 202: hyapp.room.v1.RoomCommandService.MicDown:output_type -> hyapp.room.v1.MicDownResponse
69, // 203: hyapp.room.v1.RoomCommandService.ConfirmMicPublishing:output_type -> hyapp.room.v1.ConfirmMicPublishingResponse 67, // 203: hyapp.room.v1.RoomCommandService.ChangeMicSeat:output_type -> hyapp.room.v1.ChangeMicSeatResponse
71, // 204: hyapp.room.v1.RoomCommandService.SetMicMute:output_type -> hyapp.room.v1.SetMicMuteResponse 69, // 204: hyapp.room.v1.RoomCommandService.ConfirmMicPublishing:output_type -> hyapp.room.v1.ConfirmMicPublishingResponse
73, // 205: hyapp.room.v1.RoomCommandService.ApplyRTCEvent:output_type -> hyapp.room.v1.ApplyRTCEventResponse 71, // 205: hyapp.room.v1.RoomCommandService.SetMicMute:output_type -> hyapp.room.v1.SetMicMuteResponse
75, // 206: hyapp.room.v1.RoomCommandService.SetMicSeatLock:output_type -> hyapp.room.v1.SetMicSeatLockResponse 73, // 206: hyapp.room.v1.RoomCommandService.ApplyRTCEvent:output_type -> hyapp.room.v1.ApplyRTCEventResponse
77, // 207: hyapp.room.v1.RoomCommandService.SetChatEnabled:output_type -> hyapp.room.v1.SetChatEnabledResponse 75, // 207: hyapp.room.v1.RoomCommandService.SetMicSeatLock:output_type -> hyapp.room.v1.SetMicSeatLockResponse
79, // 208: hyapp.room.v1.RoomCommandService.SetRoomPassword:output_type -> hyapp.room.v1.SetRoomPasswordResponse 77, // 208: hyapp.room.v1.RoomCommandService.SetChatEnabled:output_type -> hyapp.room.v1.SetChatEnabledResponse
81, // 209: hyapp.room.v1.RoomCommandService.SetRoomAdmin:output_type -> hyapp.room.v1.SetRoomAdminResponse 79, // 209: hyapp.room.v1.RoomCommandService.SetRoomPassword:output_type -> hyapp.room.v1.SetRoomPasswordResponse
83, // 210: hyapp.room.v1.RoomCommandService.MuteUser:output_type -> hyapp.room.v1.MuteUserResponse 81, // 210: hyapp.room.v1.RoomCommandService.SetRoomAdmin:output_type -> hyapp.room.v1.SetRoomAdminResponse
85, // 211: hyapp.room.v1.RoomCommandService.KickUser:output_type -> hyapp.room.v1.KickUserResponse 83, // 211: hyapp.room.v1.RoomCommandService.MuteUser:output_type -> hyapp.room.v1.MuteUserResponse
87, // 212: hyapp.room.v1.RoomCommandService.UnbanUser:output_type -> hyapp.room.v1.UnbanUserResponse 85, // 212: hyapp.room.v1.RoomCommandService.KickUser:output_type -> hyapp.room.v1.KickUserResponse
89, // 213: hyapp.room.v1.RoomCommandService.SystemEvictUser:output_type -> hyapp.room.v1.SystemEvictUserResponse 87, // 213: hyapp.room.v1.RoomCommandService.UnbanUser:output_type -> hyapp.room.v1.UnbanUserResponse
91, // 214: hyapp.room.v1.RoomCommandService.SendGift:output_type -> hyapp.room.v1.SendGiftResponse 89, // 214: hyapp.room.v1.RoomCommandService.SystemEvictUser:output_type -> hyapp.room.v1.SystemEvictUserResponse
118, // 215: hyapp.room.v1.RoomCommandService.FollowRoom:output_type -> hyapp.room.v1.FollowRoomResponse 91, // 215: hyapp.room.v1.RoomCommandService.SendGift:output_type -> hyapp.room.v1.SendGiftResponse
120, // 216: hyapp.room.v1.RoomCommandService.UnfollowRoom:output_type -> hyapp.room.v1.UnfollowRoomResponse 118, // 216: hyapp.room.v1.RoomCommandService.FollowRoom:output_type -> hyapp.room.v1.FollowRoomResponse
93, // 217: hyapp.room.v1.RoomGuardService.CheckSpeakPermission:output_type -> hyapp.room.v1.CheckSpeakPermissionResponse 120, // 217: hyapp.room.v1.RoomCommandService.UnfollowRoom:output_type -> hyapp.room.v1.UnfollowRoomResponse
95, // 218: hyapp.room.v1.RoomGuardService.VerifyRoomPresence:output_type -> hyapp.room.v1.VerifyRoomPresenceResponse 93, // 218: hyapp.room.v1.RoomGuardService.CheckSpeakPermission:output_type -> hyapp.room.v1.CheckSpeakPermissionResponse
55, // 219: hyapp.room.v1.RoomQueryService.AdminListRooms:output_type -> hyapp.room.v1.AdminListRoomsResponse 95, // 219: hyapp.room.v1.RoomGuardService.VerifyRoomPresence:output_type -> hyapp.room.v1.VerifyRoomPresenceResponse
57, // 220: hyapp.room.v1.RoomQueryService.AdminGetRoom:output_type -> hyapp.room.v1.AdminGetRoomResponse 55, // 220: hyapp.room.v1.RoomQueryService.AdminListRooms:output_type -> hyapp.room.v1.AdminListRoomsResponse
15, // 221: hyapp.room.v1.RoomQueryService.AdminGetRoomTreasureConfig:output_type -> hyapp.room.v1.AdminGetRoomTreasureConfigResponse 57, // 221: hyapp.room.v1.RoomQueryService.AdminGetRoom:output_type -> hyapp.room.v1.AdminGetRoomResponse
20, // 222: hyapp.room.v1.RoomQueryService.AdminGetRoomSeatConfig:output_type -> hyapp.room.v1.AdminGetRoomSeatConfigResponse 15, // 222: hyapp.room.v1.RoomQueryService.AdminGetRoomTreasureConfig:output_type -> hyapp.room.v1.AdminGetRoomTreasureConfigResponse
26, // 223: hyapp.room.v1.RoomQueryService.AdminListRoomPins:output_type -> hyapp.room.v1.AdminListRoomPinsResponse 20, // 223: hyapp.room.v1.RoomQueryService.AdminGetRoomSeatConfig:output_type -> hyapp.room.v1.AdminGetRoomSeatConfigResponse
101, // 224: hyapp.room.v1.RoomQueryService.ListRooms:output_type -> hyapp.room.v1.ListRoomsResponse 26, // 224: hyapp.room.v1.RoomQueryService.AdminListRoomPins:output_type -> hyapp.room.v1.AdminListRoomPinsResponse
101, // 225: hyapp.room.v1.RoomQueryService.ListRoomFeeds:output_type -> hyapp.room.v1.ListRoomsResponse 101, // 225: hyapp.room.v1.RoomQueryService.ListRooms:output_type -> hyapp.room.v1.ListRoomsResponse
103, // 226: hyapp.room.v1.RoomQueryService.ListRoomGiftLeaderboard:output_type -> hyapp.room.v1.ListRoomGiftLeaderboardResponse 101, // 226: hyapp.room.v1.RoomQueryService.ListRoomFeeds:output_type -> hyapp.room.v1.ListRoomsResponse
105, // 227: hyapp.room.v1.RoomQueryService.GetMyRoom:output_type -> hyapp.room.v1.GetMyRoomResponse 103, // 227: hyapp.room.v1.RoomQueryService.ListRoomGiftLeaderboard:output_type -> hyapp.room.v1.ListRoomGiftLeaderboardResponse
107, // 228: hyapp.room.v1.RoomQueryService.GetCurrentRoom:output_type -> hyapp.room.v1.GetCurrentRoomResponse 105, // 228: hyapp.room.v1.RoomQueryService.GetMyRoom:output_type -> hyapp.room.v1.GetMyRoomResponse
109, // 229: hyapp.room.v1.RoomQueryService.GetRoomSnapshot:output_type -> hyapp.room.v1.GetRoomSnapshotResponse 107, // 229: hyapp.room.v1.RoomQueryService.GetCurrentRoom:output_type -> hyapp.room.v1.GetCurrentRoomResponse
37, // 230: hyapp.room.v1.RoomQueryService.ListRoomBackgrounds:output_type -> hyapp.room.v1.ListRoomBackgroundsResponse 109, // 230: hyapp.room.v1.RoomQueryService.GetRoomSnapshot:output_type -> hyapp.room.v1.GetRoomSnapshotResponse
111, // 231: hyapp.room.v1.RoomQueryService.GetRoomTreasure:output_type -> hyapp.room.v1.GetRoomTreasureResponse 37, // 231: hyapp.room.v1.RoomQueryService.ListRoomBackgrounds:output_type -> hyapp.room.v1.ListRoomBackgroundsResponse
113, // 232: hyapp.room.v1.RoomQueryService.ListRoomOnlineUsers:output_type -> hyapp.room.v1.ListRoomOnlineUsersResponse 111, // 232: hyapp.room.v1.RoomQueryService.GetRoomTreasure:output_type -> hyapp.room.v1.GetRoomTreasureResponse
116, // 233: hyapp.room.v1.RoomQueryService.ListRoomBannedUsers:output_type -> hyapp.room.v1.ListRoomBannedUsersResponse 113, // 233: hyapp.room.v1.RoomQueryService.ListRoomOnlineUsers:output_type -> hyapp.room.v1.ListRoomOnlineUsersResponse
186, // [186:234] is the sub-list for method output_type 116, // 234: hyapp.room.v1.RoomQueryService.ListRoomBannedUsers:output_type -> hyapp.room.v1.ListRoomBannedUsersResponse
138, // [138:186] is the sub-list for method input_type 187, // [187:235] is the sub-list for method output_type
138, // [138:138] is the sub-list for extension type_name 139, // [139:187] is the sub-list for method input_type
138, // [138:138] is the sub-list for extension extendee 139, // [139:139] is the sub-list for extension type_name
0, // [0:138] is the sub-list for field type_name 139, // [139:139] is the sub-list for extension extendee
0, // [0:139] is the sub-list for field type_name
} }
func init() { file_proto_room_v1_room_proto_init() } func init() { file_proto_room_v1_room_proto_init() }

View File

@ -96,6 +96,8 @@ message LuckyGiftDrawResult {
int64 created_at_ms = 17; int64 created_at_ms = 17;
string wallet_transaction_id = 18; string wallet_transaction_id = 18;
int64 coin_balance_after = 19; int64 coin_balance_after = 19;
// target_user_id
int64 target_user_id = 20;
} }
// RoomTreasureRewardItem // RoomTreasureRewardItem
@ -809,7 +811,10 @@ message SendGiftResponse {
repeated RankItem gift_rank = 4; repeated RankItem gift_rank = 4;
RoomSnapshot room = 5; RoomSnapshot room = 5;
RoomTreasureState treasure = 6; RoomTreasureState treasure = 6;
// lucky_gift
LuckyGiftDrawResult lucky_gift = 7; LuckyGiftDrawResult lucky_gift = 7;
// lucky_gifts target_user_id
repeated LuckyGiftDrawResult lucky_gifts = 8;
} }
// CheckSpeakPermissionRequest IM gateway // CheckSpeakPermissionRequest IM gateway

View File

@ -320,6 +320,18 @@ GET /api/v1/users/room-display-profiles:batch?user_ids=42,43
"entitlement_id": "ent-frame", "entitlement_id": "ent-frame",
"expires_at_ms": 1999999999999 "expires_at_ms": 1999999999999
}, },
"profile_card": {
"resource_id": 8104,
"resource_code": "profile_card_gold",
"resource_type": "profile_card",
"name": "Gold Profile Card",
"asset_url": "https://cdn.example/profile-card.png",
"preview_url": "",
"animation_url": "https://cdn.example/profile-card.mp4",
"metadata_json": "{\"profile_card_layout\":{\"source_width\":1136,\"source_height\":1680,\"content_top\":143,\"content_height\":1525}}",
"entitlement_id": "ent-profile-card",
"expires_at_ms": 1999999999999
},
"vehicle": { "vehicle": {
"resource_id": 8102, "resource_id": 8102,
"resource_code": "vehicle_gold", "resource_code": "vehicle_gold",
@ -341,6 +353,7 @@ GET /api/v1/users/room-display-profiles:batch?user_ids=42,43
说明: 说明:
- 房间、公屏、在线列表展示头像框用 `avatar_frame` - 房间、公屏、在线列表展示头像框用 `avatar_frame`
- 房间资料卡展示用 `profile_card`,从 `metadata_json.profile_card_layout` 解析 `content_top``content_height`
- 入场座驾展示用 `vehicle` - 入场座驾展示用 `vehicle`
- 徽章条展示用 `badges` - 徽章条展示用 `badges`
- 过期佩戴不会返回。 - 过期佩戴不会返回。
@ -470,6 +483,5 @@ Flutter 处理:
2. 背包页调 `/api/v1/users/me/resources?resource_type=xxx` 2. 背包页调 `/api/v1/users/me/resources?resource_type=xxx`
3. 点击佩戴调 `POST /api/v1/users/me/resources/{resource_id}/equip`,成功后刷新 `/appearance` 3. 点击佩戴调 `POST /api/v1/users/me/resources/{resource_id}/equip`,成功后刷新 `/appearance`
4. 点击取消佩戴调 `DELETE /api/v1/users/me/resources/{resource_type}/unequip`,成功后刷新 `/appearance` 4. 点击取消佩戴调 `DELETE /api/v1/users/me/resources/{resource_type}/unequip`,成功后刷新 `/appearance`
5. 房间、公屏、在线用户资料优先使用 `room-display-profiles:batch``avatar_frame``vehicle`、`badges` 5. 房间、公屏、在线用户资料优先使用 `room-display-profiles:batch``avatar_frame``profile_card`、`vehicle`、`badges`
6. 收到 `room_user_joined` IM 时,只按 `entry_vehicle` 播放入场座驾。 6. 收到 `room_user_joined` IM 时,只按 `entry_vehicle` 播放入场座驾。

View File

@ -376,6 +376,11 @@ Response `data`:
"level": {}, "level": {},
"badges": [], "badges": [],
"avatar_frame": {}, "avatar_frame": {},
"profile_card": {
"resource_id": 8104,
"animation_url": "https://cdn.example/profile-card.mp4",
"metadata_json": "{\"profile_card_layout\":{\"source_height\":1680,\"content_top\":143,\"content_height\":1525}}"
},
"vehicle": {}, "vehicle": {},
"charm": 10 "charm": 10
} }
@ -386,7 +391,7 @@ Response `data`:
Rules: Rules:
- 该接口专门服务房间麦位、公屏和礼物动效展示;不要复用“我的页”或用户主页重接口。 - 该接口专门服务房间麦位、公屏和礼物动效展示;不要复用“我的页”或用户主页重接口。
- 当前已接入用户基础资料,`vip/level/badges/avatar_frame/vehicle/charm` 保持稳定字段,后续接入对应 read model 后填充 - 当前已接入用户基础资料,`vip/level/badges/avatar_frame/profile_card/vehicle/charm` 保持稳定字段;资料卡高度从 `profile_card.metadata_json.profile_card_layout` 解析
### IM UserSig ### IM UserSig

View File

@ -0,0 +1,97 @@
-- Backfill level badge resource metadata and user level display projections.
-- Run this on a MySQL account that can read hyapp_activity and update hyapp_wallet.
-- If the conflict SELECT returns rows, copy the shared resource into separate resources first.
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP TEMPORARY TABLE IF EXISTS tmp_level_badge_resource_refs;
CREATE TEMPORARY TABLE tmp_level_badge_resource_refs AS
SELECT
app_code,
track,
level,
CAST(JSON_UNQUOTE(JSON_EXTRACT(display_config_json, '$.long_badge_resource_id')) AS UNSIGNED) AS resource_id
FROM hyapp_activity.growth_level_rules
WHERE status = 'active'
AND COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(display_config_json, '$.long_badge_resource_id')) AS UNSIGNED), 0) > 0;
SELECT
resource_id,
GROUP_CONCAT(DISTINCT track ORDER BY track) AS tracks,
COUNT(DISTINCT track) AS track_count
FROM tmp_level_badge_resource_refs
GROUP BY resource_id
HAVING COUNT(DISTINCT track) > 1;
UPDATE hyapp_wallet.resources AS r
JOIN (
SELECT app_code, resource_id, MIN(track) AS track, COUNT(DISTINCT track) AS track_count
FROM tmp_level_badge_resource_refs
GROUP BY app_code, resource_id
) AS refs
ON refs.app_code = r.app_code AND refs.resource_id = r.resource_id
SET r.metadata_json = JSON_SET(
COALESCE(r.metadata_json, JSON_OBJECT()),
'$.badge_form', 'strip',
'$.badge_kind', 'level',
'$.level_track', refs.track
),
r.updated_at_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000
WHERE refs.track_count = 1
AND r.resource_type = 'badge';
UPDATE hyapp_activity.user_level_display_profiles AS p
LEFT JOIN (
SELECT a.app_code, a.user_id, refs.level, refs.resource_id
FROM hyapp_activity.user_growth_level_accounts AS a
JOIN tmp_level_badge_resource_refs AS refs
ON refs.app_code = a.app_code AND refs.track = a.track AND refs.level <= a.current_level
LEFT JOIN tmp_level_badge_resource_refs AS higher
ON higher.app_code = a.app_code
AND higher.track = a.track
AND higher.level <= a.current_level
AND higher.level > refs.level
WHERE a.track = 'wealth' AND higher.resource_id IS NULL
) AS picked
ON picked.app_code = p.app_code AND picked.user_id = p.user_id
SET p.wealth_badge_resource_id = COALESCE(picked.resource_id, 0),
p.wealth_badge_source_level = COALESCE(picked.level, 0),
p.updated_at_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000;
UPDATE hyapp_activity.user_level_display_profiles AS p
LEFT JOIN (
SELECT a.app_code, a.user_id, refs.level, refs.resource_id
FROM hyapp_activity.user_growth_level_accounts AS a
JOIN tmp_level_badge_resource_refs AS refs
ON refs.app_code = a.app_code AND refs.track = a.track AND refs.level <= a.current_level
LEFT JOIN tmp_level_badge_resource_refs AS higher
ON higher.app_code = a.app_code
AND higher.track = a.track
AND higher.level <= a.current_level
AND higher.level > refs.level
WHERE a.track = 'game' AND higher.resource_id IS NULL
) AS picked
ON picked.app_code = p.app_code AND picked.user_id = p.user_id
SET p.game_badge_resource_id = COALESCE(picked.resource_id, 0),
p.game_badge_source_level = COALESCE(picked.level, 0),
p.updated_at_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000;
UPDATE hyapp_activity.user_level_display_profiles AS p
LEFT JOIN (
SELECT a.app_code, a.user_id, refs.level, refs.resource_id
FROM hyapp_activity.user_growth_level_accounts AS a
JOIN tmp_level_badge_resource_refs AS refs
ON refs.app_code = a.app_code AND refs.track = a.track AND refs.level <= a.current_level
LEFT JOIN tmp_level_badge_resource_refs AS higher
ON higher.app_code = a.app_code
AND higher.track = a.track
AND higher.level <= a.current_level
AND higher.level > refs.level
WHERE a.track = 'charm' AND higher.resource_id IS NULL
) AS picked
ON picked.app_code = p.app_code AND picked.user_id = p.user_id
SET p.charm_badge_resource_id = COALESCE(picked.resource_id, 0),
p.charm_badge_source_level = COALESCE(picked.level, 0),
p.updated_at_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000;

View File

@ -236,7 +236,7 @@ func main() {
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler), HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB), HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB),
Job: jobmodule.New(store, cfg, auditHandler), Job: jobmodule.New(store, cfg, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler), LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler), LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
Menu: menumodule.New(store, auditHandler), Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler), Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),

View File

@ -10,10 +10,12 @@ import (
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response" "hyapp-admin-server/internal/response"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -27,11 +29,12 @@ const (
type Handler struct { type Handler struct {
activity activityclient.Client activity activityclient.Client
wallet walletclient.Client
audit shared.OperationLogger audit shared.OperationLogger
} }
func New(activity activityclient.Client, audit shared.OperationLogger) *Handler { func New(activity activityclient.Client, wallet walletclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{activity: activity, audit: audit} return &Handler{activity: activity, wallet: wallet, audit: audit}
} }
type levelConfigDTO struct { type levelConfigDTO struct {
@ -208,7 +211,7 @@ func (h *Handler) UpdateTrack(c *gin.Context) {
// UpsertRule 配置某条轨道某一级的升级阈值、等级奖励资源组和每级长徽章素材。 // UpsertRule 配置某条轨道某一级的升级阈值、等级奖励资源组和每级长徽章素材。
func (h *Handler) UpsertRule(c *gin.Context) { func (h *Handler) UpsertRule(c *gin.Context) {
track := normalizeTrack(c.Param("track")) track := normalizeTrack(c.Param("track"))
level, ok := parsePositiveInt32(c.Param("level")) level, ok := parseNonNegativeInt32(c.Param("level"))
if !ok || !validTrack(track) { if !ok || !validTrack(track) {
response.BadRequest(c, "等级参数不正确") response.BadRequest(c, "等级参数不正确")
return return
@ -233,8 +236,8 @@ func (h *Handler) UpsertRule(c *gin.Context) {
if req.RequiredValue != nil { if req.RequiredValue != nil {
requiredValue = *req.RequiredValue requiredValue = *req.RequiredValue
} }
if requiredValue <= 0 { if requiredValue < 0 {
response.BadRequest(c, "升级所需数值必须大于 0") response.BadRequest(c, "升级所需数值不能小于 0")
return return
} }
name := current.GetName() name := current.GetName()
@ -279,6 +282,12 @@ func (h *Handler) UpsertRule(c *gin.Context) {
response.BadRequest(c, "展示配置 JSON 不正确") response.BadRequest(c, "展示配置 JSON 不正确")
return return
} }
if badgeID := longBadgeResourceID(displayConfig); badgeID > 0 {
if err := h.validateLevelLongBadgeResource(c, track, badgeID); err != nil {
response.BadRequest(c, err.Error())
return
}
}
resp, err := h.activity.UpsertLevelRule(c.Request.Context(), &activityv1.UpsertLevelRuleRequest{ resp, err := h.activity.UpsertLevelRule(c.Request.Context(), &activityv1.UpsertLevelRuleRequest{
Meta: h.meta(c), Meta: h.meta(c),
Track: track, Track: track,
@ -359,7 +368,7 @@ func (h *Handler) upsertTier(c *gin.Context, tierID int64) {
if req.MaxLevel != nil { if req.MaxLevel != nil {
maxLevel = *req.MaxLevel maxLevel = *req.MaxLevel
} }
if minLevel <= 0 || maxLevel < minLevel { if minLevel < 0 || maxLevel < minLevel {
response.BadRequest(c, "等级段范围不正确") response.BadRequest(c, "等级段范围不正确")
return return
} }
@ -453,6 +462,38 @@ func (h *Handler) loadConfig(c *gin.Context, track string, status string) (*acti
}) })
} }
func (h *Handler) validateLevelLongBadgeResource(c *gin.Context, track string, resourceID int64) error {
if resourceID <= 0 {
return nil
}
if h.wallet == nil {
return fmt.Errorf("资源服务不可用")
}
resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
})
if err != nil {
return fmt.Errorf("等级徽章资源不存在")
}
resource := resp.GetResource()
if resource.GetResourceType() != "badge" {
return fmt.Errorf("等级长徽章必须选择徽章资源")
}
metadata := levelBadgeMetadataFromJSON(resource.GetMetadataJson())
if metadata.BadgeForm != "strip" {
return fmt.Errorf("等级长徽章必须选择长徽章素材")
}
if metadata.BadgeKind != "level" {
return fmt.Errorf("等级长徽章必须选择等级徽章素材")
}
if metadata.LevelTrack != track {
return fmt.Errorf("等级长徽章所属等级不匹配")
}
return nil
}
func (h *Handler) currentTrack(c *gin.Context, track string) (trackDTO, bool, error) { func (h *Handler) currentTrack(c *gin.Context, track string) (trackDTO, bool, error) {
config, err := h.loadConfig(c, track, "") config, err := h.loadConfig(c, track, "")
if err != nil { if err != nil {
@ -669,9 +710,26 @@ func longBadgeResourceID(raw string) int64 {
return 0 return 0
} }
func parsePositiveInt32(raw string) (int32, bool) { type levelBadgeMetadata struct {
BadgeForm string `json:"badge_form"`
BadgeKind string `json:"badge_kind"`
LevelTrack string `json:"level_track"`
}
func levelBadgeMetadataFromJSON(raw string) levelBadgeMetadata {
payload := levelBadgeMetadata{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return levelBadgeMetadata{}
}
payload.BadgeForm = strings.ToLower(strings.TrimSpace(payload.BadgeForm))
payload.BadgeKind = strings.ToLower(strings.TrimSpace(payload.BadgeKind))
payload.LevelTrack = normalizeTrack(payload.LevelTrack)
return payload
}
func parseNonNegativeInt32(raw string) (int32, bool) {
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 32) value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 32)
if err != nil || value <= 0 { if err != nil || value < 0 {
return 0, false return 0, false
} }
return int32(value), true return int32(value), true

View File

@ -18,6 +18,8 @@ type resourceDTO struct {
CoinPrice int64 `json:"coinPrice"` CoinPrice int64 `json:"coinPrice"`
GiftPointAmount int64 `json:"giftPointAmount"` GiftPointAmount int64 `json:"giftPointAmount"`
BadgeForm string `json:"badgeForm,omitempty"` BadgeForm string `json:"badgeForm,omitempty"`
BadgeKind string `json:"badgeKind,omitempty"`
LevelTrack string `json:"levelTrack,omitempty"`
UsageScopes []string `json:"usageScopes"` UsageScopes []string `json:"usageScopes"`
AssetURL string `json:"assetUrl"` AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"` PreviewURL string `json:"previewUrl"`
@ -198,6 +200,8 @@ func resourceFromProto(item *walletv1.Resource) resourceDTO {
CoinPrice: item.GetCoinPrice(), CoinPrice: item.GetCoinPrice(),
GiftPointAmount: item.GetGiftPointAmount(), GiftPointAmount: item.GetGiftPointAmount(),
BadgeForm: badgeFormForResource(item.GetResourceType(), item.GetMetadataJson()), BadgeForm: badgeFormForResource(item.GetResourceType(), item.GetMetadataJson()),
BadgeKind: badgeKindForResource(item.GetResourceType(), item.GetMetadataJson()),
LevelTrack: badgeLevelTrackForResource(item.GetResourceType(), item.GetMetadataJson()),
UsageScopes: item.GetUsageScopes(), UsageScopes: item.GetUsageScopes(),
AssetURL: item.GetAssetUrl(), AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(), PreviewURL: item.GetPreviewUrl(),

View File

@ -2,6 +2,7 @@ package resource
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
) )
@ -92,19 +93,78 @@ func TestEmojiPackMetadataKeepsLimitedRegions(t *testing.T) {
} }
func TestBadgeResourceMetadataUsesSelectedForm(t *testing.T) { func TestBadgeResourceMetadataUsesSelectedForm(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeBadge, "long") metadata := resourceMetadataJSON(resourceTypeBadge, "long", "", "", "")
payload := badgeMetadataPayload{} payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil { if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("badge metadata json mismatch: %v", err) t.Fatalf("badge metadata json mismatch: %v", err)
} }
if payload.BadgeForm != badgeFormStrip || payload.DefaultSlot != "profile_strip" { if payload.BadgeForm != badgeFormStrip || payload.BadgeKind != badgeKindNormal || payload.DefaultSlot != "profile_strip" {
t.Fatalf("long badge metadata mismatch: %+v", payload) t.Fatalf("long badge metadata mismatch: %+v", payload)
} }
if got := badgeFormFromMetadata(resourceMetadataJSON(resourceTypeBadge, "short")); got != badgeFormTile { if got := badgeFormFromMetadata(resourceMetadataJSON(resourceTypeBadge, "short", "", "", "")); got != badgeFormTile {
t.Fatalf("short badge form mismatch: %s", got) t.Fatalf("short badge form mismatch: %s", got)
} }
} }
func TestBadgeResourceMetadataMarksLevelBadge(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeBadge, badgeFormStrip, "", badgeKindLevel, "wealth")
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("badge metadata json mismatch: %v", err)
}
if payload.BadgeKind != badgeKindLevel || payload.LevelTrack != "wealth" || payload.BadgeForm != badgeFormStrip {
t.Fatalf("level badge metadata mismatch: %+v", payload)
}
}
func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeProfileCard, "", `{
"profile_card_layout": {
"source_width": 1136,
"source_height": 1680,
"color_content_width": 750,
"content_top": 117,
"content_bottom": 1666,
"content_height": 1550,
"content_top_ratio": 0,
"content_height_ratio": 0,
"detect_version": 99
},
"debug": true
}`, "", "")
payload := profileCardMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("profile card metadata json mismatch: %v", err)
}
if payload.ProfileCardLayout == nil {
t.Fatalf("profile card layout should be kept")
}
if payload.ProfileCardLayout.ContentTop != 117 || payload.ProfileCardLayout.ContentHeight != 1550 {
t.Fatalf("profile card layout mismatch: %+v", payload.ProfileCardLayout)
}
if payload.ProfileCardLayout.DetectVersion != 1 {
t.Fatalf("profile card detect version should be normalized: %+v", payload.ProfileCardLayout)
}
if metadata == "{}" || strings.Contains(metadata, "debug") {
t.Fatalf("profile card metadata should keep only layout: %s", metadata)
}
}
func TestProfileCardResourceMetadataRejectsInvalidLayout(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeProfileCard, "", `{
"profile_card_layout": {
"source_width": 1136,
"source_height": 1680,
"content_top": 117,
"content_bottom": 1666,
"content_height": 1
}
}`, "", "")
if metadata != "{}" {
t.Fatalf("invalid profile card layout should be dropped: %s", metadata)
}
}
func TestValidateBadgeResourceRequiresBadgeForm(t *testing.T) { func TestValidateBadgeResourceRequiresBadgeForm(t *testing.T) {
if err := validateResourceBadgeForm(resourceRequest{ResourceType: resourceTypeBadge}); err == nil { if err := validateResourceBadgeForm(resourceRequest{ResourceType: resourceTypeBadge}); err == nil {
t.Fatalf("badge form should be required") t.Fatalf("badge form should be required")
@ -112,6 +172,12 @@ func TestValidateBadgeResourceRequiresBadgeForm(t *testing.T) {
if err := validateResourceBadgeForm(resourceRequest{ResourceType: resourceTypeBadge, BadgeForm: badgeFormTile}); err != nil { if err := validateResourceBadgeForm(resourceRequest{ResourceType: resourceTypeBadge, BadgeForm: badgeFormTile}); err != nil {
t.Fatalf("valid badge form rejected: %v", err) t.Fatalf("valid badge form rejected: %v", err)
} }
if err := validateResourceBadgeForm(resourceRequest{ResourceType: resourceTypeBadge, BadgeForm: badgeFormStrip, BadgeKind: badgeKindLevel}); err == nil {
t.Fatalf("level badge should require level track")
}
if err := validateResourceBadgeForm(resourceRequest{ResourceType: resourceTypeBadge, BadgeForm: badgeFormStrip, BadgeKind: badgeKindLevel, LevelTrack: "wealth"}); err != nil {
t.Fatalf("valid level badge rejected: %v", err)
}
if err := validateResourceBadgeForm(resourceRequest{ResourceType: "gift", BadgeForm: ""}); err != nil { if err := validateResourceBadgeForm(resourceRequest{ResourceType: "gift", BadgeForm: ""}); err != nil {
t.Fatalf("non-badge resource should not require badge form: %v", err) t.Fatalf("non-badge resource should not require badge form: %v", err)
} }

View File

@ -18,9 +18,10 @@ import (
const dayMillis int64 = 24 * 60 * 60 * 1000 const dayMillis int64 = 24 * 60 * 60 * 1000
const ( const (
resourceTypeCoin = "coin" resourceTypeCoin = "coin"
resourceTypeBadge = "badge" resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack" resourceTypeEmojiPack = "emoji_pack"
resourceTypeProfileCard = "profile_card"
) )
const ( const (
@ -33,6 +34,11 @@ const (
badgeFormTile = "tile" badgeFormTile = "tile"
) )
const (
badgeKindNormal = "normal"
badgeKindLevel = "level"
)
const ( const (
defaultEmojiPackCategory = "默认" defaultEmojiPackCategory = "默认"
emojiPackPricingFree = "free" emojiPackPricingFree = "free"
@ -49,6 +55,9 @@ type resourceRequest struct {
CoinPrice int64 `json:"coinPrice"` CoinPrice int64 `json:"coinPrice"`
GiftPointAmount int64 `json:"giftPointAmount"` GiftPointAmount int64 `json:"giftPointAmount"`
BadgeForm string `json:"badgeForm"` BadgeForm string `json:"badgeForm"`
BadgeKind string `json:"badgeKind"`
LevelTrack string `json:"levelTrack"`
MetadataJSON string `json:"metadataJson"`
ManagerGrantEnabled *bool `json:"managerGrantEnabled"` ManagerGrantEnabled *bool `json:"managerGrantEnabled"`
AssetURL string `json:"assetUrl"` AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"` PreviewURL string `json:"previewUrl"`
@ -75,9 +84,27 @@ type emojiPackMetadataPayload struct {
type badgeMetadataPayload struct { type badgeMetadataPayload struct {
BadgeForm string `json:"badge_form"` BadgeForm string `json:"badge_form"`
BadgeKind string `json:"badge_kind,omitempty"`
LevelTrack string `json:"level_track,omitempty"`
DefaultSlot string `json:"default_slot,omitempty"` DefaultSlot string `json:"default_slot,omitempty"`
} }
type profileCardMetadataPayload struct {
ProfileCardLayout *profileCardLayoutMetadataPayload `json:"profile_card_layout,omitempty"`
}
type profileCardLayoutMetadataPayload struct {
SourceWidth int64 `json:"source_width"`
SourceHeight int64 `json:"source_height"`
ColorContentWidth int64 `json:"color_content_width"`
ContentTop int64 `json:"content_top"`
ContentBottom int64 `json:"content_bottom"`
ContentHeight int64 `json:"content_height"`
ContentTopRatio float64 `json:"content_top_ratio"`
ContentHeightRatio float64 `json:"content_height_ratio"`
DetectVersion int64 `json:"detect_version"`
}
type resourceGroupRequest struct { type resourceGroupRequest struct {
GroupCode string `json:"groupCode"` GroupCode string `json:"groupCode"`
Name string `json:"name"` Name string `json:"name"`
@ -169,7 +196,7 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
resourceType := normalizeResourceType(r.ResourceType) resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm) metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack)
return &walletv1.CreateResourceRequest{ return &walletv1.CreateResourceRequest{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
@ -238,7 +265,7 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
resourceType := normalizeResourceType(r.ResourceType) resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm) metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack)
return &walletv1.UpdateResourceRequest{ return &walletv1.UpdateResourceRequest{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
@ -500,6 +527,13 @@ func validateResourceBadgeForm(req resourceRequest) error {
if normalizeBadgeForm(req.BadgeForm) == "" { if normalizeBadgeForm(req.BadgeForm) == "" {
return fmt.Errorf("请选择徽章属性") return fmt.Errorf("请选择徽章属性")
} }
badgeKind := normalizeBadgeKind(req.BadgeKind)
if badgeKind == "" {
return fmt.Errorf("请选择徽章类型")
}
if badgeKind == badgeKindLevel && !validLevelTrack(req.LevelTrack) {
return fmt.Errorf("请选择等级徽章所属等级")
}
return nil return nil
} }
@ -516,11 +550,19 @@ func resourceGrantStrategy(resourceType string) string {
} }
} }
func resourceMetadataJSON(resourceType string, badgeForm string) string { func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string) string {
if resourceType != resourceTypeBadge { switch resourceType {
case resourceTypeBadge:
return badgeMetadataJSON(badgeForm, badgeKind, levelTrack)
case resourceTypeProfileCard:
return profileCardMetadataJSON(metadataJSON)
default:
return "{}" return "{}"
} }
payload := badgeMetadataPayload{BadgeForm: normalizeBadgeForm(badgeForm)} }
func badgeMetadataJSON(badgeForm string, badgeKind string, levelTrack string) string {
payload := badgeMetadataPayload{BadgeForm: normalizeBadgeForm(badgeForm), BadgeKind: normalizeBadgeKind(badgeKind)}
switch payload.BadgeForm { switch payload.BadgeForm {
case badgeFormStrip: case badgeFormStrip:
payload.DefaultSlot = "profile_strip" payload.DefaultSlot = "profile_strip"
@ -529,6 +571,12 @@ func resourceMetadataJSON(resourceType string, badgeForm string) string {
default: default:
return "{}" return "{}"
} }
if payload.BadgeKind == badgeKindLevel {
payload.LevelTrack = normalizeLevelTrack(levelTrack)
if payload.LevelTrack == "" {
return "{}"
}
}
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { if err != nil {
return "{}" return "{}"
@ -536,6 +584,66 @@ func resourceMetadataJSON(resourceType string, badgeForm string) string {
return string(body) return string(body)
} }
func profileCardMetadataJSON(metadataJSON string) string {
raw := strings.TrimSpace(metadataJSON)
if raw == "" {
return "{}"
}
payload := profileCardMetadataPayload{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "{}"
}
layout := sanitizeProfileCardLayoutMetadata(payload.ProfileCardLayout)
if layout == nil {
return "{}"
}
// 资料卡上传解析只允许写入布局字段;不要把后台临时调试数据透给 app。
body, err := json.Marshal(profileCardMetadataPayload{ProfileCardLayout: layout})
if err != nil {
return "{}"
}
return string(body)
}
func sanitizeProfileCardLayoutMetadata(layout *profileCardLayoutMetadataPayload) *profileCardLayoutMetadataPayload {
if layout == nil {
return nil
}
if layout.SourceWidth <= 0 || layout.SourceHeight <= 0 {
return nil
}
if layout.ContentTop < 0 || layout.ContentBottom < layout.ContentTop || layout.ContentBottom >= layout.SourceHeight {
return nil
}
contentHeight := layout.ContentBottom - layout.ContentTop + 1
if layout.ContentHeight != contentHeight {
return nil
}
colorContentWidth := layout.ColorContentWidth
if colorContentWidth <= 0 || colorContentWidth > layout.SourceWidth {
colorContentWidth = layout.SourceWidth
}
// 比例字段由整数边界重新计算,避免前端浮点误差或脏数据进入客户端展示协议。
return &profileCardLayoutMetadataPayload{
SourceWidth: layout.SourceWidth,
SourceHeight: layout.SourceHeight,
ColorContentWidth: colorContentWidth,
ContentTop: layout.ContentTop,
ContentBottom: layout.ContentBottom,
ContentHeight: contentHeight,
ContentTopRatio: profileCardRatio(layout.ContentTop, layout.SourceHeight),
ContentHeightRatio: profileCardRatio(contentHeight, layout.SourceHeight),
DetectVersion: 1,
}
}
func profileCardRatio(value int64, total int64) float64 {
if total <= 0 {
return 0
}
return float64(value) / float64(total)
}
func normalizeBadgeForm(value string) string { func normalizeBadgeForm(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) { switch strings.ToLower(strings.TrimSpace(value)) {
case badgeFormStrip, "long": case badgeFormStrip, "long":
@ -547,6 +655,34 @@ func normalizeBadgeForm(value string) string {
} }
} }
func normalizeBadgeKind(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", badgeKindNormal:
return badgeKindNormal
case badgeKindLevel:
return badgeKindLevel
default:
return ""
}
}
func normalizeLevelTrack(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if validLevelTrack(value) {
return value
}
return ""
}
func validLevelTrack(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "wealth", "game", "charm":
return true
default:
return false
}
}
func badgeFormFromMetadata(metadataJSON string) string { func badgeFormFromMetadata(metadataJSON string) string {
payload := badgeMetadataPayload{} payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil { if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
@ -555,6 +691,22 @@ func badgeFormFromMetadata(metadataJSON string) string {
return normalizeBadgeForm(payload.BadgeForm) return normalizeBadgeForm(payload.BadgeForm)
} }
func badgeKindFromMetadata(metadataJSON string) string {
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return badgeKindNormal
}
return normalizeBadgeKind(payload.BadgeKind)
}
func badgeLevelTrackFromMetadata(metadataJSON string) string {
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return ""
}
return normalizeLevelTrack(payload.LevelTrack)
}
func badgeFormForResource(resourceType string, metadataJSON string) string { func badgeFormForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeBadge { if normalizeResourceType(resourceType) != resourceTypeBadge {
return "" return ""
@ -562,6 +714,24 @@ func badgeFormForResource(resourceType string, metadataJSON string) string {
return badgeFormFromMetadata(metadataJSON) return badgeFormFromMetadata(metadataJSON)
} }
func badgeKindForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeBadge {
return ""
}
kind := badgeKindFromMetadata(metadataJSON)
if kind == "" {
return badgeKindNormal
}
return kind
}
func badgeLevelTrackForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeBadge {
return ""
}
return badgeLevelTrackFromMetadata(metadataJSON)
}
func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) { func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) {
normalizedRegionIDs := normalizeRegionIDs(regionIDs) normalizedRegionIDs := normalizeRegionIDs(regionIDs)
metadata := emojiPackMetadataPayload{ metadata := emojiPackMetadataPayload{

View File

@ -60,6 +60,7 @@ rocketmq:
wallet_outbox: wallet_outbox:
enabled: true enabled: true
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"
realtime_topic: "hyapp_wallet_realtime_outbox"
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox" first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox" red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16

View File

@ -59,6 +59,7 @@ rocketmq:
wallet_outbox: wallet_outbox:
enabled: true enabled: true
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"
realtime_topic: "hyapp_wallet_realtime_outbox"
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox" first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox" red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16

View File

@ -59,6 +59,7 @@ rocketmq:
wallet_outbox: wallet_outbox:
enabled: false enabled: false
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"
realtime_topic: ""
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox" first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox" red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16

View File

@ -716,18 +716,48 @@ CREATE TABLE IF NOT EXISTS user_level_display_profiles (
wealth_tier_id BIGINT NOT NULL DEFAULT 0 COMMENT '财富档位 ID', wealth_tier_id BIGINT NOT NULL DEFAULT 0 COMMENT '财富档位 ID',
wealth_avatar_frame_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '财富头像框资源 ID', wealth_avatar_frame_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '财富头像框资源 ID',
wealth_badge_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '财富徽章资源 ID', wealth_badge_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '财富徽章资源 ID',
wealth_badge_source_level INT NOT NULL DEFAULT 0 COMMENT '财富徽章来源等级',
game_level INT NOT NULL DEFAULT 0 COMMENT '游戏等级', game_level INT NOT NULL DEFAULT 0 COMMENT '游戏等级',
game_tier_id BIGINT NOT NULL DEFAULT 0 COMMENT '游戏档位 ID', game_tier_id BIGINT NOT NULL DEFAULT 0 COMMENT '游戏档位 ID',
game_avatar_frame_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '游戏头像框资源 ID', game_avatar_frame_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '游戏头像框资源 ID',
game_badge_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '游戏徽章资源 ID', game_badge_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '游戏徽章资源 ID',
game_badge_source_level INT NOT NULL DEFAULT 0 COMMENT '游戏徽章来源等级',
charm_level INT NOT NULL DEFAULT 0 COMMENT '魅力等级', charm_level INT NOT NULL DEFAULT 0 COMMENT '魅力等级',
charm_tier_id BIGINT NOT NULL DEFAULT 0 COMMENT '魅力档位 ID', charm_tier_id BIGINT NOT NULL DEFAULT 0 COMMENT '魅力档位 ID',
charm_avatar_frame_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '魅力头像框资源 ID', charm_avatar_frame_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '魅力头像框资源 ID',
charm_badge_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '魅力徽章资源 ID', charm_badge_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '魅力徽章资源 ID',
charm_badge_source_level INT NOT NULL DEFAULT 0 COMMENT '魅力徽章来源等级',
updated_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) PRIMARY KEY (app_code, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户等级展示资料表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户等级展示资料表';
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_level_display_profiles' AND COLUMN_NAME = 'wealth_badge_source_level') = 0,
'ALTER TABLE user_level_display_profiles ADD COLUMN wealth_badge_source_level INT NOT NULL DEFAULT 0 COMMENT ''财富徽章来源等级'' AFTER wealth_badge_resource_id',
'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_level_display_profiles' AND COLUMN_NAME = 'game_badge_source_level') = 0,
'ALTER TABLE user_level_display_profiles ADD COLUMN game_badge_source_level INT NOT NULL DEFAULT 0 COMMENT ''游戏徽章来源等级'' AFTER game_badge_resource_id',
'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_level_display_profiles' AND COLUMN_NAME = 'charm_badge_source_level') = 0,
'ALTER TABLE user_level_display_profiles ADD COLUMN charm_badge_source_level INT NOT NULL DEFAULT 0 COMMENT ''魅力徽章来源等级'' AFTER charm_badge_resource_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
INSERT INTO growth_level_tracks (app_code, track, name, status, sort_order, display_config_json, created_at_ms, updated_at_ms) INSERT INTO growth_level_tracks (app_code, track, name, status, sort_order, display_config_json, created_at_ms, updated_at_ms)
VALUES VALUES
('lalu', 'wealth', '财富等级', 'active', 10, JSON_OBJECT(), 0, 0), ('lalu', 'wealth', '财富等级', 'active', 10, JSON_OBJECT(), 0, 0),

View File

@ -232,7 +232,7 @@ func New(cfg config.Config) (*App, error) {
_ = repository.Close() _ = repository.Close()
return nil, err return nil, err
} }
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := consumer.Subscribe(redPacketWalletOutboxTopic(cfg.RocketMQ.WalletOutbox), walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := redPacketEventFromWalletMessage(message.Body) event, ok, err := redPacketEventFromWalletMessage(message.Body)
if err != nil || !ok { if err != nil || !ok {
return err return err
@ -389,6 +389,14 @@ func walletOutboxConsumerConfig(cfg config.RocketMQConfig, group string) rocketm
return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes) return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes)
} }
func redPacketWalletOutboxTopic(cfg config.WalletOutboxMQConfig) string {
// 钱包实时 topic 显式配置后,红包 worker 只消费实时通道,避免继续被普通账务 topic 的高频消息拖慢。
if cfg.RealtimeTopic != "" {
return cfg.RealtimeTopic
}
return cfg.Topic
}
func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsume int32) rocketmqx.ConsumerConfig { func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsume int32) rocketmqx.ConsumerConfig {
return rocketmqx.ConsumerConfig{ return rocketmqx.ConsumerConfig{
EndpointConfig: rocketmqx.EndpointConfig{ EndpointConfig: rocketmqx.EndpointConfig{

View File

@ -145,6 +145,7 @@ type RoomOutboxMQConfig struct {
type WalletOutboxMQConfig struct { type WalletOutboxMQConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Topic string `yaml:"topic"` Topic string `yaml:"topic"`
RealtimeTopic string `yaml:"realtime_topic"`
FirstRechargeConsumerGroup string `yaml:"first_recharge_consumer_group"` FirstRechargeConsumerGroup string `yaml:"first_recharge_consumer_group"`
RedPacketBroadcastConsumerGroup string `yaml:"red_packet_broadcast_consumer_group"` RedPacketBroadcastConsumerGroup string `yaml:"red_packet_broadcast_consumer_group"`
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
@ -222,6 +223,7 @@ func defaultRocketMQConfig() RocketMQConfig {
WalletOutbox: WalletOutboxMQConfig{ WalletOutbox: WalletOutboxMQConfig{
Enabled: false, Enabled: false,
Topic: "hyapp_wallet_outbox", Topic: "hyapp_wallet_outbox",
RealtimeTopic: "",
FirstRechargeConsumerGroup: "hyapp-activity-first-recharge-wallet-outbox", FirstRechargeConsumerGroup: "hyapp-activity-first-recharge-wallet-outbox",
RedPacketBroadcastConsumerGroup: "hyapp-activity-red-packet-wallet-outbox", RedPacketBroadcastConsumerGroup: "hyapp-activity-red-packet-wallet-outbox",
ConsumerMaxReconsumeTimes: 16, ConsumerMaxReconsumeTimes: 16,
@ -351,6 +353,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" { if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" {
cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic
} }
cfg.WalletOutbox.RealtimeTopic = strings.TrimSpace(cfg.WalletOutbox.RealtimeTopic)
if cfg.WalletOutbox.FirstRechargeConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.FirstRechargeConsumerGroup); cfg.WalletOutbox.FirstRechargeConsumerGroup == "" { if cfg.WalletOutbox.FirstRechargeConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.FirstRechargeConsumerGroup); cfg.WalletOutbox.FirstRechargeConsumerGroup == "" {
cfg.WalletOutbox.FirstRechargeConsumerGroup = defaults.WalletOutbox.FirstRechargeConsumerGroup cfg.WalletOutbox.FirstRechargeConsumerGroup = defaults.WalletOutbox.FirstRechargeConsumerGroup
} }

View File

@ -20,4 +20,7 @@ func TestLoadTencentExampleEnablesRoomOutboxMQ(t *testing.T) {
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Topic == "" || cfg.RocketMQ.RoomOutbox.ConsumerGroup == "" { if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Topic == "" || cfg.RocketMQ.RoomOutbox.ConsumerGroup == "" {
t.Fatalf("tencent example must configure room outbox MQ consumer: %+v", cfg.RocketMQ) t.Fatalf("tencent example must configure room outbox MQ consumer: %+v", cfg.RocketMQ)
} }
if cfg.RocketMQ.WalletOutbox.RealtimeTopic != "hyapp_wallet_realtime_outbox" {
t.Fatalf("red packet worker should consume realtime wallet topic in tencent example: %+v", cfg.RocketMQ.WalletOutbox)
}
} }

View File

@ -100,10 +100,49 @@ type TrackOverview struct {
NextLevelRequiredValue int64 NextLevelRequiredValue int64
DisplayAvatarFrameResourceID int64 DisplayAvatarFrameResourceID int64
DisplayBadgeResourceID int64 DisplayBadgeResourceID int64
DisplayBadgeSourceLevel int32
RewardPendingCount int64 RewardPendingCount int64
SortOrder int32 SortOrder int32
} }
// LevelDisplayTrackProfile 是房间资料、资料卡这类轻量展示面读取的单轨道投影。
type LevelDisplayTrackProfile struct {
Track string
Level int32
TierID int64
AvatarFrameResourceID int64
BadgeResourceID int64
BadgeSourceLevel int32
UpdatedAtMS int64
}
// UserLevelDisplayProfile 固定携带 wealth/game/charm 三条轨道,避免展示层按数组顺序猜含义。
type UserLevelDisplayProfile struct {
UserID int64
Wealth LevelDisplayTrackProfile
Game LevelDisplayTrackProfile
Charm LevelDisplayTrackProfile
}
// LevelDisplayProfiles 是批量用户展示投影的只读快照。
type LevelDisplayProfiles struct {
Profiles []UserLevelDisplayProfile
ServerTimeMS int64
}
// RegistrationLevelBadgeGrant 是注册后按等级规则补发的 1 级等级徽章发放结果。
type RegistrationLevelBadgeGrant struct {
Track string
Level int32
ResourceID int64
GrantID string
}
type RegistrationLevelBadgeGrants struct {
Grants []RegistrationLevelBadgeGrant
ServerTimeMS int64
}
// TrackDetail 返回一个轨道的完整阈值、等级段和当前用户进度。 // TrackDetail 返回一个轨道的完整阈值、等级段和当前用户进度。
type TrackDetail struct { type TrackDetail struct {
Overview TrackOverview Overview TrackOverview

View File

@ -3,6 +3,8 @@ package growth
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"strconv"
"strings" "strings"
"time" "time"
@ -17,10 +19,16 @@ import (
const levelRewardReason = "growth_level_reward" const levelRewardReason = "growth_level_reward"
const (
registrationLevelBadgeReason = "growth_level_registration_badge"
registrationLevelBadgeSourceLevel = int32(1)
)
// Repository 是等级体系的 activity MySQL 持久化边界。 // Repository 是等级体系的 activity MySQL 持久化边界。
type Repository interface { type Repository interface {
ListLevelOverview(ctx context.Context, userID int64, nowMS int64) (domain.Overview, error) ListLevelOverview(ctx context.Context, userID int64, nowMS int64) (domain.Overview, error)
GetLevelTrack(ctx context.Context, userID int64, track string, nowMS int64) (domain.TrackDetail, error) GetLevelTrack(ctx context.Context, userID int64, track string, nowMS int64) (domain.TrackDetail, error)
BatchGetUserLevelDisplayProfiles(ctx context.Context, userIDs []int64, nowMS int64) (domain.LevelDisplayProfiles, error)
ListLevelConfig(ctx context.Context, query domain.ConfigQuery, nowMS int64) (domain.Config, error) ListLevelConfig(ctx context.Context, query domain.ConfigQuery, nowMS int64) (domain.Config, error)
ListLevelRewards(ctx context.Context, query domain.RewardQuery) ([]domain.RewardJob, int64, error) ListLevelRewards(ctx context.Context, query domain.RewardQuery) ([]domain.RewardJob, int64, error)
ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent, nowMS int64) (domain.EventResult, error) ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent, nowMS int64) (domain.EventResult, error)
@ -34,6 +42,7 @@ type Repository interface {
// WalletClient 是等级奖励发放的唯一外部副作用。 // WalletClient 是等级奖励发放的唯一外部副作用。
type WalletClient interface { type WalletClient interface {
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
} }
@ -75,6 +84,76 @@ func (s *Service) GetLevelTrack(ctx context.Context, userID int64, track string)
return s.repository.GetLevelTrack(ctx, userID, track, s.now().UnixMilli()) return s.repository.GetLevelTrack(ctx, userID, track, s.now().UnixMilli())
} }
func (s *Service) BatchGetUserLevelDisplayProfiles(ctx context.Context, userIDs []int64) (domain.LevelDisplayProfiles, error) {
if err := s.requireRepository(); err != nil {
return domain.LevelDisplayProfiles{}, err
}
normalized := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if _, exists := seen[userID]; exists {
continue
}
seen[userID] = struct{}{}
normalized = append(normalized, userID)
}
if len(normalized) == 0 {
return domain.LevelDisplayProfiles{Profiles: []domain.UserLevelDisplayProfile{}, ServerTimeMS: s.now().UnixMilli()}, nil
}
return s.repository.BatchGetUserLevelDisplayProfiles(ctx, normalized, s.now().UnixMilli())
}
func (s *Service) IssueRegistrationLevelBadges(ctx context.Context, userID int64, commandID string) (domain.RegistrationLevelBadgeGrants, error) {
if err := s.requireRepository(); err != nil {
return domain.RegistrationLevelBadgeGrants{}, err
}
if s.wallet == nil {
return domain.RegistrationLevelBadgeGrants{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
commandID = strings.TrimSpace(commandID)
if userID <= 0 || commandID == "" {
return domain.RegistrationLevelBadgeGrants{}, xerr.New(xerr.InvalidArgument, "registration level badge command is incomplete")
}
nowMS := s.now().UnixMilli()
config, err := s.repository.ListLevelConfig(ctx, domain.ConfigQuery{Status: domain.StatusActive}, nowMS)
if err != nil {
return domain.RegistrationLevelBadgeGrants{}, err
}
grants := make([]domain.RegistrationLevelBadgeGrant, 0, 3)
for _, track := range []string{domain.TrackWealth, domain.TrackGame, domain.TrackCharm} {
resourceID := levelRuleBadgeForExactLevel(config.Rules, track, registrationLevelBadgeSourceLevel)
if resourceID <= 0 {
// 没有配置 1 级等级徽章时跳过该轨道;注册主流程和其它轨道不受影响。
continue
}
// wallet 的 command_id 是实际发放幂等边界;按轨道拆分后,部分成功的重试只会补齐失败轨道。
resp, err := s.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
CommandId: fmt.Sprintf("%s:%s", commandID, track),
AppCode: appcode.FromContext(ctx),
TargetUserId: userID,
ResourceId: resourceID,
Quantity: 1,
DurationMs: 0,
Reason: registrationLevelBadgeReason,
OperatorUserId: userID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if err != nil {
return domain.RegistrationLevelBadgeGrants{}, err
}
grants = append(grants, domain.RegistrationLevelBadgeGrant{
Track: track,
Level: registrationLevelBadgeSourceLevel,
ResourceID: resourceID,
GrantID: resp.GetGrant().GetGrantId(),
})
}
return domain.RegistrationLevelBadgeGrants{Grants: grants, ServerTimeMS: nowMS}, nil
}
func (s *Service) ListLevelConfig(ctx context.Context, query domain.ConfigQuery) (domain.Config, error) { func (s *Service) ListLevelConfig(ctx context.Context, query domain.ConfigQuery) (domain.Config, error) {
if err := s.requireRepository(); err != nil { if err := s.requireRepository(); err != nil {
return domain.Config{}, err return domain.Config{}, err
@ -93,6 +172,16 @@ func (s *Service) ListLevelConfig(ctx context.Context, query domain.ConfigQuery)
return s.repository.ListLevelConfig(ctx, query, s.now().UnixMilli()) return s.repository.ListLevelConfig(ctx, query, s.now().UnixMilli())
} }
func levelRuleBadgeForExactLevel(rules []domain.Rule, track string, level int32) int64 {
for _, rule := range rules {
if rule.Track != track || rule.Level != level || rule.Status != domain.StatusActive {
continue
}
return longBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
}
return 0
}
func (s *Service) ListLevelRewards(ctx context.Context, query domain.RewardQuery) ([]domain.RewardJob, int64, error) { func (s *Service) ListLevelRewards(ctx context.Context, query domain.RewardQuery) ([]domain.RewardJob, int64, error) {
if err := s.requireRepository(); err != nil { if err := s.requireRepository(); err != nil {
return nil, 0, err return nil, 0, err
@ -212,7 +301,7 @@ func (s *Service) UpsertLevelRule(ctx context.Context, command domain.RuleComman
command.Name = strings.TrimSpace(command.Name) command.Name = strings.TrimSpace(command.Name)
command.Status = normalizeStatus(command.Status) command.Status = normalizeStatus(command.Status)
command.DisplayConfigJSON = normalizeJSON(command.DisplayConfigJSON) command.DisplayConfigJSON = normalizeJSON(command.DisplayConfigJSON)
if !validTrack(command.Track) || command.Level <= 0 || command.RequiredValue <= 0 || command.Name == "" || !validStatus(command.Status) || command.OperatorAdminID <= 0 { if !validTrack(command.Track) || command.Level < 0 || command.RequiredValue < 0 || command.Name == "" || !validStatus(command.Status) || command.OperatorAdminID <= 0 {
return domain.Rule{}, false, xerr.New(xerr.InvalidArgument, "level rule command is invalid") return domain.Rule{}, false, xerr.New(xerr.InvalidArgument, "level rule command is invalid")
} }
if command.RewardResourceGroupID < 0 { if command.RewardResourceGroupID < 0 {
@ -229,7 +318,7 @@ func (s *Service) UpsertLevelTier(ctx context.Context, command domain.TierComman
command.Name = strings.TrimSpace(command.Name) command.Name = strings.TrimSpace(command.Name)
command.Status = normalizeStatus(command.Status) command.Status = normalizeStatus(command.Status)
command.DisplayConfigJSON = normalizeJSON(command.DisplayConfigJSON) command.DisplayConfigJSON = normalizeJSON(command.DisplayConfigJSON)
if !validTrack(command.Track) || command.MinLevel <= 0 || command.MaxLevel < command.MinLevel || command.Name == "" || !validStatus(command.Status) || command.OperatorAdminID <= 0 { if !validTrack(command.Track) || command.MinLevel < 0 || command.MaxLevel < command.MinLevel || command.Name == "" || !validStatus(command.Status) || command.OperatorAdminID <= 0 {
return domain.Tier{}, false, xerr.New(xerr.InvalidArgument, "level tier command is invalid") return domain.Tier{}, false, xerr.New(xerr.InvalidArgument, "level tier command is invalid")
} }
if command.DisplayAvatarFrameResourceID < 0 || command.DisplayBadgeResourceID < 0 || command.RewardResourceGroupID < 0 { if command.DisplayAvatarFrameResourceID < 0 || command.DisplayBadgeResourceID < 0 || command.RewardResourceGroupID < 0 {
@ -356,6 +445,29 @@ func normalizeJSON(value string) string {
return value return value
} }
func longBadgeResourceIDFromDisplayConfig(raw string) int64 {
payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return 0
}
value, ok := payload["long_badge_resource_id"]
if !ok {
return 0
}
switch typed := value.(type) {
case float64:
if typed > 0 {
return int64(typed)
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil && parsed > 0 {
return parsed
}
}
return 0
}
func validTrack(value string) bool { func validTrack(value string) bool {
switch value { switch value {
case domain.TrackWealth, domain.TrackGame, domain.TrackCharm: case domain.TrackWealth, domain.TrackGame, domain.TrackCharm:

View File

@ -61,9 +61,16 @@ func TestGrowthLevelEventRewardFlow(t *testing.T) {
t.Fatalf("GetMyLevelOverview failed: %v", err) t.Fatalf("GetMyLevelOverview failed: %v", err)
} }
wealth := findTrackOverview(overview.Tracks, domain.TrackWealth) wealth := findTrackOverview(overview.Tracks, domain.TrackWealth)
if wealth.Level != 2 || wealth.TierID == 0 || wealth.TotalValue != 250 || wealth.DisplayAvatarFrameResourceID != 502 || wealth.DisplayBadgeResourceID != 602 || wealth.RewardPendingCount != 2 { if wealth.Level != 2 || wealth.TierID == 0 || wealth.TotalValue != 250 || wealth.DisplayAvatarFrameResourceID != 502 || wealth.DisplayBadgeResourceID != 702 || wealth.DisplayBadgeSourceLevel != 2 || wealth.RewardPendingCount != 2 {
t.Fatalf("wealth overview mismatch: %+v", wealth) t.Fatalf("wealth overview mismatch: %+v", wealth)
} }
profiles, err := svc.BatchGetUserLevelDisplayProfiles(ctx, []int64{10001})
if err != nil {
t.Fatalf("BatchGetUserLevelDisplayProfiles failed: %v", err)
}
if len(profiles.Profiles) != 1 || profiles.Profiles[0].Wealth.BadgeResourceID != 702 || profiles.Profiles[0].Wealth.BadgeSourceLevel != 2 {
t.Fatalf("display profile should use highest configured rule badge: %+v", profiles.Profiles)
}
claimed, processed, success, failure, hasMore, err := svc.ProcessLevelRewardBatch(ctx, "run-1", "worker-1", 10, time.Second) claimed, processed, success, failure, hasMore, err := svc.ProcessLevelRewardBatch(ctx, "run-1", "worker-1", 10, time.Second)
if err != nil { if err != nil {
@ -154,6 +161,61 @@ func TestListLevelConfigReturnsAdminRulesAndTiers(t *testing.T) {
} }
} }
func TestZeroLevelRuleAndTierAreValid(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth,
Level: 0,
RequiredValue: 0,
Name: "wealth 0",
Status: domain.StatusActive,
SortOrder: 0,
DisplayConfigJSON: `{"long_badge_resource_id":700}`,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level rule 0 failed: created=%v err=%v", created, err)
}
if _, created, err := svc.UpsertLevelTier(ctx, domain.TierCommand{
Track: domain.TrackWealth,
MinLevel: 0,
MaxLevel: 9,
Name: "wealth 0-9",
Status: domain.StatusActive,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed tier 0 failed: created=%v err=%v", created, err)
}
detail, err := svc.GetLevelTrack(ctx, 30001, domain.TrackWealth)
if err != nil {
t.Fatalf("GetLevelTrack failed: %v", err)
}
if detail.Overview.Level != 0 || detail.Overview.CurrentLevelRequiredValue != 0 || detail.Overview.DisplayBadgeResourceID != 700 || detail.Overview.DisplayBadgeSourceLevel != 0 {
t.Fatalf("zero level overview mismatch: %+v", detail.Overview)
}
}
func TestIssueRegistrationLevelBadgesGrantsLevelOneBadges(t *testing.T) {
svc, wallet := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
seedGrowthRules(t, ctx, svc, domain.TrackGame)
seedGrowthRules(t, ctx, svc, domain.TrackCharm)
grants, err := svc.IssueRegistrationLevelBadges(ctx, 30001, "registration_level_badges:lalu:30001")
if err != nil {
t.Fatalf("IssueRegistrationLevelBadges failed: %v", err)
}
if len(grants.Grants) != 3 || len(wallet.resourceGrants) != 3 {
t.Fatalf("registration level badge grants mismatch: grants=%+v wallet=%d", grants.Grants, len(wallet.resourceGrants))
}
for _, req := range wallet.resourceGrants {
if req.GetTargetUserId() != 30001 || req.GetResourceId() != 701 || req.GetQuantity() != 1 || req.GetGrantSource() != domain.GrantSourceGrowthLevel {
t.Fatalf("wallet grant request mismatch: %+v", req)
}
}
}
func newGrowthService(t *testing.T) (*growthservice.Service, *fakeGrowthWallet) { func newGrowthService(t *testing.T) (*growthservice.Service, *fakeGrowthWallet) {
t.Helper() t.Helper()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
@ -173,6 +235,7 @@ func seedGrowthRules(t *testing.T, ctx context.Context, svc *growthservice.Servi
Status: domain.StatusActive, Status: domain.StatusActive,
RewardResourceGroupID: 0, RewardResourceGroupID: 0,
SortOrder: 1, SortOrder: 1,
DisplayConfigJSON: `{"long_badge_resource_id":701}`,
OperatorAdminID: 90001, OperatorAdminID: 90001,
}); err != nil || !created { }); err != nil || !created {
t.Fatalf("seed level rule 1 failed: created=%v err=%v", created, err) t.Fatalf("seed level rule 1 failed: created=%v err=%v", created, err)
@ -185,6 +248,7 @@ func seedGrowthRules(t *testing.T, ctx context.Context, svc *growthservice.Servi
Status: domain.StatusActive, Status: domain.StatusActive,
RewardResourceGroupID: 9002, RewardResourceGroupID: 9002,
SortOrder: 2, SortOrder: 2,
DisplayConfigJSON: `{"long_badge_resource_id":702}`,
OperatorAdminID: 90001, OperatorAdminID: 90001,
}); err != nil || !created { }); err != nil || !created {
t.Fatalf("seed level rule 2 failed: created=%v err=%v", created, err) t.Fatalf("seed level rule 2 failed: created=%v err=%v", created, err)
@ -231,7 +295,18 @@ func findTrackOverview(items []domain.TrackOverview, track string) domain.TrackO
} }
type fakeGrowthWallet struct { type fakeGrowthWallet struct {
grants []*walletv1.GrantResourceGroupRequest grants []*walletv1.GrantResourceGroupRequest
resourceGrants []*walletv1.GrantResourceRequest
}
func (f *fakeGrowthWallet) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
f.resourceGrants = append(f.resourceGrants, req)
return &walletv1.ResourceGrantResponse{
Grant: &walletv1.ResourceGrant{
GrantId: fmt.Sprintf("resource-grant-%d", len(f.resourceGrants)),
Status: "succeeded",
},
}, nil
} }
func (f *fakeGrowthWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) { func (f *fakeGrowthWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {

View File

@ -3,8 +3,10 @@ package mysql
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@ -63,6 +65,66 @@ func (r *Repository) GetLevelTrack(ctx context.Context, userID int64, track stri
return domain.TrackDetail{Overview: overview, Rules: rules, Tiers: tiers, ServerTimeMS: nowMS}, nil return domain.TrackDetail{Overview: overview, Rules: rules, Tiers: tiers, ServerTimeMS: nowMS}, nil
} }
// BatchGetUserLevelDisplayProfiles 返回资料卡和房间列表需要的等级展示投影。
func (r *Repository) BatchGetUserLevelDisplayProfiles(ctx context.Context, userIDs []int64, nowMS int64) (domain.LevelDisplayProfiles, error) {
if r == nil || r.db == nil {
return domain.LevelDisplayProfiles{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
userIDs = uniquePositiveInt64s(userIDs)
if len(userIDs) == 0 {
return domain.LevelDisplayProfiles{Profiles: []domain.UserLevelDisplayProfile{}, ServerTimeMS: nowMS}, nil
}
profiles := make(map[int64]domain.UserLevelDisplayProfile, len(userIDs))
for _, userID := range userIDs {
profiles[userID] = defaultLevelDisplayProfile(userID)
}
args := make([]any, 0, len(userIDs)+1)
args = append(args, appcode.FromContext(ctx))
for _, userID := range userIDs {
args = append(args, userID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT user_id,
wealth_level, wealth_tier_id, wealth_avatar_frame_resource_id, wealth_badge_resource_id, wealth_badge_source_level,
game_level, game_tier_id, game_avatar_frame_resource_id, game_badge_resource_id, game_badge_source_level,
charm_level, charm_tier_id, charm_avatar_frame_resource_id, charm_badge_resource_id, charm_badge_source_level,
updated_at_ms
FROM user_level_display_profiles
WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`, args...)
if err != nil {
return domain.LevelDisplayProfiles{}, err
}
defer rows.Close()
for rows.Next() {
var item domain.UserLevelDisplayProfile
var updatedAtMS int64
if err := rows.Scan(
&item.UserID,
&item.Wealth.Level, &item.Wealth.TierID, &item.Wealth.AvatarFrameResourceID, &item.Wealth.BadgeResourceID, &item.Wealth.BadgeSourceLevel,
&item.Game.Level, &item.Game.TierID, &item.Game.AvatarFrameResourceID, &item.Game.BadgeResourceID, &item.Game.BadgeSourceLevel,
&item.Charm.Level, &item.Charm.TierID, &item.Charm.AvatarFrameResourceID, &item.Charm.BadgeResourceID, &item.Charm.BadgeSourceLevel,
&updatedAtMS,
); err != nil {
return domain.LevelDisplayProfiles{}, err
}
item.Wealth.Track = domain.TrackWealth
item.Game.Track = domain.TrackGame
item.Charm.Track = domain.TrackCharm
item.Wealth.UpdatedAtMS = updatedAtMS
item.Game.UpdatedAtMS = updatedAtMS
item.Charm.UpdatedAtMS = updatedAtMS
profiles[item.UserID] = item
}
if err := rows.Err(); err != nil {
return domain.LevelDisplayProfiles{}, err
}
result := make([]domain.UserLevelDisplayProfile, 0, len(userIDs))
for _, userID := range userIDs {
result = append(result, profiles[userID])
}
return domain.LevelDisplayProfiles{Profiles: result, ServerTimeMS: nowMS}, nil
}
// ListLevelConfig 返回后台等级配置页需要的轨道、单级阈值和等级段展示配置。 // ListLevelConfig 返回后台等级配置页需要的轨道、单级阈值和等级段展示配置。
func (r *Repository) ListLevelConfig(ctx context.Context, query domain.ConfigQuery, nowMS int64) (domain.Config, error) { func (r *Repository) ListLevelConfig(ctx context.Context, query domain.ConfigQuery, nowMS int64) (domain.Config, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
@ -186,7 +248,8 @@ func (r *Repository) ConsumeLevelEvent(ctx context.Context, event domain.ValueEv
} }
rewardJobCount = count rewardJobCount = count
} }
if err := r.upsertLevelDisplayProfile(ctx, tx, event.UserID, event.Track, nextLevel, nextTier, nowMS); err != nil { badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel)
if err := r.upsertLevelDisplayProfile(ctx, tx, event.UserID, event.Track, nextLevel, nextTier, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
return domain.EventResult{}, err return domain.EventResult{}, err
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
@ -405,6 +468,7 @@ func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track
return domain.TrackOverview{}, err return domain.TrackOverview{}, err
} }
tier := tierByID(tiers, account.CurrentTierID) tier := tierByID(tiers, account.CurrentTierID)
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, account.CurrentLevel)
currentRequired, nextLevel, nextRequired := levelProgress(rules, account.CurrentLevel) currentRequired, nextLevel, nextRequired := levelProgress(rules, account.CurrentLevel)
pendingCount, err := r.pendingLevelRewardCount(ctx, userID, track.Track) pendingCount, err := r.pendingLevelRewardCount(ctx, userID, track.Track)
if err != nil { if err != nil {
@ -420,7 +484,8 @@ func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track
NextLevel: nextLevel, NextLevel: nextLevel,
NextLevelRequiredValue: nextRequired, NextLevelRequiredValue: nextRequired,
DisplayAvatarFrameResourceID: tier.DisplayAvatarFrameResourceID, DisplayAvatarFrameResourceID: tier.DisplayAvatarFrameResourceID,
DisplayBadgeResourceID: tier.DisplayBadgeResourceID, DisplayBadgeResourceID: badgeResourceID,
DisplayBadgeSourceLevel: badgeSourceLevel,
RewardPendingCount: pendingCount, RewardPendingCount: pendingCount,
SortOrder: track.SortOrder, SortOrder: track.SortOrder,
}, nil }, nil
@ -532,7 +597,7 @@ func (r *Repository) insertLevelRewardJob(ctx context.Context, tx *sql.Tx, userI
return affected > 0, err return affected > 0, err
} }
func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx, userID int64, track string, level int32, tier domain.Tier, nowMS int64) error { func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx, userID int64, track string, level int32, tier domain.Tier, badgeResourceID int64, badgeSourceLevel int32, nowMS int64) error {
columnPrefix := "" columnPrefix := ""
switch track { switch track {
case domain.TrackWealth: case domain.TrackWealth:
@ -546,18 +611,19 @@ func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx,
} }
query := fmt.Sprintf(` query := fmt.Sprintf(`
INSERT INTO user_level_display_profiles ( INSERT INTO user_level_display_profiles (
app_code, user_id, %s_level, %s_tier_id, %s_avatar_frame_resource_id, %s_badge_resource_id, updated_at_ms app_code, user_id, %s_level, %s_tier_id, %s_avatar_frame_resource_id, %s_badge_resource_id, %s_badge_source_level, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
%s_level = VALUES(%s_level), %s_level = VALUES(%s_level),
%s_tier_id = VALUES(%s_tier_id), %s_tier_id = VALUES(%s_tier_id),
%s_avatar_frame_resource_id = VALUES(%s_avatar_frame_resource_id), %s_avatar_frame_resource_id = VALUES(%s_avatar_frame_resource_id),
%s_badge_resource_id = VALUES(%s_badge_resource_id), %s_badge_resource_id = VALUES(%s_badge_resource_id),
%s_badge_source_level = VALUES(%s_badge_source_level),
updated_at_ms = VALUES(updated_at_ms)`, updated_at_ms = VALUES(updated_at_ms)`,
columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix,
columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix,
) )
_, err := tx.ExecContext(ctx, query, appcode.FromContext(ctx), userID, level, tier.TierID, tier.DisplayAvatarFrameResourceID, tier.DisplayBadgeResourceID, nowMS) _, err := tx.ExecContext(ctx, query, appcode.FromContext(ctx), userID, level, tier.TierID, tier.DisplayAvatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS)
return err return err
} }
@ -779,7 +845,7 @@ func levelForValue(rules []domain.Rule, totalValue int64) int32 {
} }
func tierForLevel(tiers []domain.Tier, level int32) domain.Tier { func tierForLevel(tiers []domain.Tier, level int32) domain.Tier {
if level <= 0 { if level < 0 {
return domain.Tier{} return domain.Tier{}
} }
for _, tier := range tiers { for _, tier := range tiers {
@ -819,6 +885,92 @@ func levelProgress(rules []domain.Rule, currentLevel int32) (int64, int32, int64
return currentRequired, nextLevel, nextRequired return currentRequired, nextLevel, nextRequired
} }
func levelRuleBadgeForLevel(rules []domain.Rule, currentLevel int32) (int64, int32) {
if currentLevel < 0 {
return 0, 0
}
badgeResourceID := int64(0)
badgeSourceLevel := int32(0)
for _, rule := range rules {
if rule.Level > currentLevel || rule.Level < badgeSourceLevel {
continue
}
candidate := longBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
if candidate <= 0 {
continue
}
// 等级徽章按当前等级以内最高配置生效;后续低等级配置不再覆盖更高等级展示。
badgeResourceID = candidate
badgeSourceLevel = rule.Level
}
return badgeResourceID, badgeSourceLevel
}
func longBadgeResourceIDFromDisplayConfig(raw string) int64 {
payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return 0
}
value, ok := payload["long_badge_resource_id"]
if !ok {
return 0
}
switch typed := value.(type) {
case float64:
if typed > 0 {
return int64(typed)
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil && parsed > 0 {
return parsed
}
}
return 0
}
func defaultLevelDisplayProfile(userID int64) domain.UserLevelDisplayProfile {
return domain.UserLevelDisplayProfile{
UserID: userID,
Wealth: domain.LevelDisplayTrackProfile{
Track: domain.TrackWealth,
},
Game: domain.LevelDisplayTrackProfile{
Track: domain.TrackGame,
},
Charm: domain.LevelDisplayTrackProfile{
Track: domain.TrackCharm,
},
}
}
func uniquePositiveInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
items := make([]string, count)
for index := range items {
items[index] = "?"
}
return strings.Join(items, ",")
}
func scanLevelTracks(rows *sql.Rows) ([]domain.Track, error) { func scanLevelTracks(rows *sql.Rows) ([]domain.Track, error) {
items := make([]domain.Track, 0) items := make([]domain.Track, 0)
for rows.Next() { for rows.Next() {

View File

@ -58,6 +58,22 @@ func (s *GrowthLevelServer) GetLevelTrack(ctx context.Context, req *activityv1.G
return resp, nil return resp, nil
} }
func (s *GrowthLevelServer) BatchGetUserLevelDisplayProfiles(ctx context.Context, req *activityv1.BatchGetUserLevelDisplayProfilesRequest) (*activityv1.BatchGetUserLevelDisplayProfilesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
profiles, err := s.svc.BatchGetUserLevelDisplayProfiles(ctx, req.GetUserIds())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.BatchGetUserLevelDisplayProfilesResponse{
Profiles: make([]*activityv1.UserLevelDisplayProfile, 0, len(profiles.Profiles)),
ServerTimeMs: profiles.ServerTimeMS,
}
for _, item := range profiles.Profiles {
resp.Profiles = append(resp.Profiles, userLevelDisplayProfileToProto(item))
}
return resp, nil
}
func (s *GrowthLevelServer) ListLevelRewards(ctx context.Context, req *activityv1.ListLevelRewardsRequest) (*activityv1.ListLevelRewardsResponse, error) { func (s *GrowthLevelServer) ListLevelRewards(ctx context.Context, req *activityv1.ListLevelRewardsRequest) (*activityv1.ListLevelRewardsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
rewards, total, err := s.svc.ListLevelRewards(ctx, domain.RewardQuery{ rewards, total, err := s.svc.ListLevelRewards(ctx, domain.RewardQuery{
@ -104,6 +120,22 @@ func (s *GrowthLevelServer) ConsumeLevelEvent(ctx context.Context, req *activity
}, nil }, nil
} }
func (s *GrowthLevelServer) IssueRegistrationLevelBadges(ctx context.Context, req *activityv1.IssueRegistrationLevelBadgesRequest) (*activityv1.IssueRegistrationLevelBadgesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
grants, err := s.svc.IssueRegistrationLevelBadges(ctx, req.GetUserId(), req.GetCommandId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.IssueRegistrationLevelBadgesResponse{
Grants: make([]*activityv1.RegistrationLevelBadgeGrant, 0, len(grants.Grants)),
ServerTimeMs: grants.ServerTimeMS,
}
for _, item := range grants.Grants {
resp.Grants = append(resp.Grants, registrationLevelBadgeGrantToProto(item))
}
return resp, nil
}
// AdminGrowthLevelServer 暴露后台等级配置入口,业务约束仍由 service/repository 校验。 // AdminGrowthLevelServer 暴露后台等级配置入口,业务约束仍由 service/repository 校验。
type AdminGrowthLevelServer struct { type AdminGrowthLevelServer struct {
activityv1.UnimplementedAdminGrowthLevelServiceServer activityv1.UnimplementedAdminGrowthLevelServiceServer
@ -257,11 +289,33 @@ func levelTrackOverviewToProto(item domain.TrackOverview) *activityv1.LevelTrack
NextLevelRequiredValue: item.NextLevelRequiredValue, NextLevelRequiredValue: item.NextLevelRequiredValue,
DisplayAvatarFrameResourceId: item.DisplayAvatarFrameResourceID, DisplayAvatarFrameResourceId: item.DisplayAvatarFrameResourceID,
DisplayBadgeResourceId: item.DisplayBadgeResourceID, DisplayBadgeResourceId: item.DisplayBadgeResourceID,
DisplayBadgeSourceLevel: item.DisplayBadgeSourceLevel,
RewardPendingCount: item.RewardPendingCount, RewardPendingCount: item.RewardPendingCount,
SortOrder: item.SortOrder, SortOrder: item.SortOrder,
} }
} }
func userLevelDisplayProfileToProto(item domain.UserLevelDisplayProfile) *activityv1.UserLevelDisplayProfile {
return &activityv1.UserLevelDisplayProfile{
UserId: item.UserID,
Wealth: levelDisplayTrackProfileToProto(item.Wealth),
Game: levelDisplayTrackProfileToProto(item.Game),
Charm: levelDisplayTrackProfileToProto(item.Charm),
}
}
func levelDisplayTrackProfileToProto(item domain.LevelDisplayTrackProfile) *activityv1.LevelDisplayTrackProfile {
return &activityv1.LevelDisplayTrackProfile{
Track: item.Track,
Level: item.Level,
TierId: item.TierID,
AvatarFrameResourceId: item.AvatarFrameResourceID,
BadgeResourceId: item.BadgeResourceID,
BadgeSourceLevel: item.BadgeSourceLevel,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func levelRewardToProto(item domain.RewardJob) *activityv1.LevelRewardJob { func levelRewardToProto(item domain.RewardJob) *activityv1.LevelRewardJob {
return &activityv1.LevelRewardJob{ return &activityv1.LevelRewardJob{
RewardJobId: item.RewardJobID, RewardJobId: item.RewardJobID,
@ -279,3 +333,12 @@ func levelRewardToProto(item domain.RewardJob) *activityv1.LevelRewardJob {
UpdatedAtMs: item.UpdatedAtMS, UpdatedAtMs: item.UpdatedAtMS,
} }
} }
func registrationLevelBadgeGrantToProto(item domain.RegistrationLevelBadgeGrant) *activityv1.RegistrationLevelBadgeGrant {
return &activityv1.RegistrationLevelBadgeGrant{
Track: item.Track,
Level: item.Level,
ResourceId: item.ResourceID,
GrantId: item.GrantID,
}
}

View File

@ -11,6 +11,7 @@ import (
type GrowthLevelClient interface { type GrowthLevelClient interface {
GetMyLevelOverview(ctx context.Context, req *activityv1.GetMyLevelOverviewRequest) (*activityv1.GetMyLevelOverviewResponse, error) GetMyLevelOverview(ctx context.Context, req *activityv1.GetMyLevelOverviewRequest) (*activityv1.GetMyLevelOverviewResponse, error)
GetLevelTrack(ctx context.Context, req *activityv1.GetLevelTrackRequest) (*activityv1.GetLevelTrackResponse, error) GetLevelTrack(ctx context.Context, req *activityv1.GetLevelTrackRequest) (*activityv1.GetLevelTrackResponse, error)
BatchGetUserLevelDisplayProfiles(ctx context.Context, req *activityv1.BatchGetUserLevelDisplayProfilesRequest) (*activityv1.BatchGetUserLevelDisplayProfilesResponse, error)
ListLevelRewards(ctx context.Context, req *activityv1.ListLevelRewardsRequest) (*activityv1.ListLevelRewardsResponse, error) ListLevelRewards(ctx context.Context, req *activityv1.ListLevelRewardsRequest) (*activityv1.ListLevelRewardsResponse, error)
} }
@ -30,6 +31,10 @@ func (c *grpcGrowthLevelClient) GetLevelTrack(ctx context.Context, req *activity
return c.client.GetLevelTrack(ctx, req) return c.client.GetLevelTrack(ctx, req)
} }
func (c *grpcGrowthLevelClient) BatchGetUserLevelDisplayProfiles(ctx context.Context, req *activityv1.BatchGetUserLevelDisplayProfilesRequest) (*activityv1.BatchGetUserLevelDisplayProfilesResponse, error) {
return c.client.BatchGetUserLevelDisplayProfiles(ctx, req)
}
func (c *grpcGrowthLevelClient) ListLevelRewards(ctx context.Context, req *activityv1.ListLevelRewardsRequest) (*activityv1.ListLevelRewardsResponse, error) { func (c *grpcGrowthLevelClient) ListLevelRewards(ctx context.Context, req *activityv1.ListLevelRewardsRequest) (*activityv1.ListLevelRewardsResponse, error) {
return c.client.ListLevelRewards(ctx, req) return c.client.ListLevelRewards(ctx, req)
} }

View File

@ -25,6 +25,7 @@ type levelTrackOverviewData struct {
NextLevelRequiredValue int64 `json:"next_level_required_value"` NextLevelRequiredValue int64 `json:"next_level_required_value"`
DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"` DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"`
DisplayBadgeResourceID int64 `json:"display_badge_resource_id"` DisplayBadgeResourceID int64 `json:"display_badge_resource_id"`
DisplayBadgeSourceLevel int32 `json:"display_badge_source_level"`
RewardPendingCount int64 `json:"reward_pending_count"` RewardPendingCount int64 `json:"reward_pending_count"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
} }
@ -190,6 +191,7 @@ func levelTrackOverviewFromProto(item *activityv1.LevelTrackOverview) levelTrack
NextLevelRequiredValue: item.GetNextLevelRequiredValue(), NextLevelRequiredValue: item.GetNextLevelRequiredValue(),
DisplayAvatarFrameResourceID: item.GetDisplayAvatarFrameResourceId(), DisplayAvatarFrameResourceID: item.GetDisplayAvatarFrameResourceId(),
DisplayBadgeResourceID: item.GetDisplayBadgeResourceId(), DisplayBadgeResourceID: item.GetDisplayBadgeResourceId(),
DisplayBadgeSourceLevel: item.GetDisplayBadgeSourceLevel(),
RewardPendingCount: item.GetRewardPendingCount(), RewardPendingCount: item.GetRewardPendingCount(),
SortOrder: item.GetSortOrder(), SortOrder: item.GetSortOrder(),
} }

View File

@ -425,64 +425,65 @@ type fakeUserHostClient struct {
} }
type fakeWalletClient struct { type fakeWalletClient struct {
last *walletv1.GetBalancesRequest last *walletv1.GetBalancesRequest
balanceRequests []*walletv1.GetBalancesRequest balanceRequests []*walletv1.GetBalancesRequest
resp *walletv1.GetBalancesResponse resp *walletv1.GetBalancesResponse
balancesByUserID map[int64]*walletv1.GetBalancesResponse balancesByUserID map[int64]*walletv1.GetBalancesResponse
err error err error
lastOverview *walletv1.GetWalletOverviewRequest lastOverview *walletv1.GetWalletOverviewRequest
overviewResp *walletv1.GetWalletOverviewResponse overviewResp *walletv1.GetWalletOverviewResponse
overviewErr error overviewErr error
lastValueSummary *walletv1.GetWalletValueSummaryRequest lastValueSummary *walletv1.GetWalletValueSummaryRequest
valueSummaryResp *walletv1.GetWalletValueSummaryResponse valueSummaryResp *walletv1.GetWalletValueSummaryResponse
valueSummaryErr error valueSummaryErr error
lastGiftWall *walletv1.GetUserGiftWallRequest lastGiftWall *walletv1.GetUserGiftWallRequest
giftWallResp *walletv1.GetUserGiftWallResponse giftWallResp *walletv1.GetUserGiftWallResponse
lastRechargeProducts *walletv1.ListRechargeProductsRequest lastRechargeProducts *walletv1.ListRechargeProductsRequest
rechargeProductsResp *walletv1.ListRechargeProductsResponse rechargeProductsResp *walletv1.ListRechargeProductsResponse
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
lastTransactions *walletv1.ListWalletTransactionsRequest lastTransactions *walletv1.ListWalletTransactionsRequest
transactionsResp *walletv1.ListWalletTransactionsResponse transactionsResp *walletv1.ListWalletTransactionsResponse
lastWithdrawal *walletv1.ApplyWithdrawalRequest lastWithdrawal *walletv1.ApplyWithdrawalRequest
withdrawalResp *walletv1.ApplyWithdrawalResponse withdrawalResp *walletv1.ApplyWithdrawalResponse
lastVipPackages *walletv1.ListVipPackagesRequest lastVipPackages *walletv1.ListVipPackagesRequest
vipPackagesResp *walletv1.ListVipPackagesResponse vipPackagesResp *walletv1.ListVipPackagesResponse
lastMyVip *walletv1.GetMyVipRequest lastMyVip *walletv1.GetMyVipRequest
myVipResp *walletv1.GetMyVipResponse myVipResp *walletv1.GetMyVipResponse
vipErr error vipErr error
lastPurchaseVip *walletv1.PurchaseVipRequest lastPurchaseVip *walletv1.PurchaseVipRequest
purchaseVipResp *walletv1.PurchaseVipResponse purchaseVipResp *walletv1.PurchaseVipResponse
lastTransfer *walletv1.TransferCoinFromSellerRequest lastTransfer *walletv1.TransferCoinFromSellerRequest
transferResp *walletv1.TransferCoinFromSellerResponse transferResp *walletv1.TransferCoinFromSellerResponse
transferErr error transferErr error
lastListResources *walletv1.ListResourcesRequest lastListResources *walletv1.ListResourcesRequest
listResourcesResp *walletv1.ListResourcesResponse listResourcesResp *walletv1.ListResourcesResponse
lastGetResource *walletv1.GetResourceRequest lastGetResource *walletv1.GetResourceRequest
resourcesByID map[int64]*walletv1.Resource resourcesByID map[int64]*walletv1.Resource
lastGetResourceGroup *walletv1.GetResourceGroupRequest lastGetResourceGroup *walletv1.GetResourceGroupRequest
resourceGroupsByID map[int64]*walletv1.ResourceGroup resourceGroupsByID map[int64]*walletv1.ResourceGroup
lastResourceShop *walletv1.ListResourceShopItemsRequest lastResourceShop *walletv1.ListResourceShopItemsRequest
resourceShopResp *walletv1.ListResourceShopItemsResponse resourceShopResp *walletv1.ListResourceShopItemsResponse
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
lastListGiftConfigs *walletv1.ListGiftConfigsRequest lastListGiftConfigs *walletv1.ListGiftConfigsRequest
listGiftConfigsResp *walletv1.ListGiftConfigsResponse listGiftConfigsResp *walletv1.ListGiftConfigsResponse
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse
lastGrantResource *walletv1.GrantResourceRequest lastGrantResource *walletv1.GrantResourceRequest
grantResourceResp *walletv1.ResourceGrantResponse grantResourceResp *walletv1.ResourceGrantResponse
grantResourceErr error grantResourceErr error
lastBatchEquipped *walletv1.BatchGetUserEquippedResourcesRequest lastBatchEquipped *walletv1.BatchGetUserEquippedResourcesRequest
batchEquippedResp *walletv1.BatchGetUserEquippedResourcesResponse batchEquippedRequests []*walletv1.BatchGetUserEquippedResourcesRequest
lastListRedPackets *walletv1.ListRedPacketsRequest batchEquippedResp *walletv1.BatchGetUserEquippedResourcesResponse
listRedPacketsResp *walletv1.ListRedPacketsResponse lastListRedPackets *walletv1.ListRedPacketsRequest
lastGetRedPacket *walletv1.GetRedPacketRequest listRedPacketsResp *walletv1.ListRedPacketsResponse
getRedPacketResp *walletv1.GetRedPacketResponse lastGetRedPacket *walletv1.GetRedPacketRequest
lastClaimRedPacket *walletv1.ClaimRedPacketRequest getRedPacketResp *walletv1.GetRedPacketResponse
claimRedPacketResp *walletv1.ClaimRedPacketResponse lastClaimRedPacket *walletv1.ClaimRedPacketRequest
claimRedPacketResp *walletv1.ClaimRedPacketResponse
} }
type fakeMessageInboxClient struct { type fakeMessageInboxClient struct {
@ -1474,6 +1475,7 @@ func (f *fakeWalletClient) UnequipUserResource(context.Context, *walletv1.Unequi
func (f *fakeWalletClient) BatchGetUserEquippedResources(_ context.Context, req *walletv1.BatchGetUserEquippedResourcesRequest) (*walletv1.BatchGetUserEquippedResourcesResponse, error) { func (f *fakeWalletClient) BatchGetUserEquippedResources(_ context.Context, req *walletv1.BatchGetUserEquippedResourcesRequest) (*walletv1.BatchGetUserEquippedResourcesResponse, error) {
f.lastBatchEquipped = req f.lastBatchEquipped = req
f.batchEquippedRequests = append(f.batchEquippedRequests, req)
if f.err != nil { if f.err != nil {
return nil, f.err return nil, f.err
} }
@ -1839,14 +1841,17 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
GiftId: "rose", GiftId: "rose",
RuleVersion: 12, RuleVersion: 12,
ExperiencePool: "novice", ExperiencePool: "novice",
SelectedTierId: "novice_2x", SelectedTierId: "batch",
MultiplierPpm: 2_000_000, MultiplierPpm: 5_000_000,
EffectiveRewardCoins: 200, EffectiveRewardCoins: 500,
RewardStatus: "granted", RewardStatus: "granted",
WalletTransactionId: "wallet_tx_lucky",
CoinBalanceAfter: 8800, CoinBalanceAfter: 8800,
CreatedAtMs: 1_779_258_000_000, CreatedAtMs: 1_779_258_000_000,
}, },
LuckyGifts: []*roomv1.LuckyGiftDrawResult{
{Enabled: true, DrawId: "lucky_draw_1", CommandId: "cmd-gift-lucky:target:43", PoolId: "super_lucky", GiftId: "rose", MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", WalletTransactionId: "wallet_tx_lucky_1", TargetUserId: 43},
{Enabled: true, DrawId: "lucky_draw_2", CommandId: "cmd-gift-lucky:target:44", PoolId: "super_lucky", GiftId: "rose", MultiplierPpm: 3_000_000, EffectiveRewardCoins: 300, RewardStatus: "granted", WalletTransactionId: "wallet_tx_lucky_2", TargetUserId: 44},
},
}} }}
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-lucky","target_user_id":43,"gift_id":"rose","gift_count":1,"pool_id":"super_lucky"}`))) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-lucky","target_user_id":43,"gift_id":"rose","gift_count":1,"pool_id":"super_lucky"}`)))
@ -1870,14 +1875,22 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
WalletTransactionID string `json:"wallet_transaction_id"` WalletTransactionID string `json:"wallet_transaction_id"`
CoinBalanceAfter int64 `json:"coin_balance_after"` CoinBalanceAfter int64 `json:"coin_balance_after"`
} `json:"lucky_gift"` } `json:"lucky_gift"`
LuckyGifts []struct {
TargetUserID int64 `json:"target_user_id"`
MultiplierPPM int64 `json:"multiplier_ppm"`
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
} `json:"lucky_gifts"`
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String()) t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
} }
if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 2_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 200 || envelope.Data.LuckyGift.RewardStatus != "granted" || envelope.Data.LuckyGift.WalletTransactionID != "wallet_tx_lucky" || envelope.Data.LuckyGift.CoinBalanceAfter != 8800 { if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 5_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 500 || envelope.Data.LuckyGift.RewardStatus != "granted" || envelope.Data.LuckyGift.WalletTransactionID != "" || envelope.Data.LuckyGift.CoinBalanceAfter != 8800 {
t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift) t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift)
} }
if len(envelope.Data.LuckyGifts) != 2 || envelope.Data.LuckyGifts[0].TargetUserID != 43 || envelope.Data.LuckyGifts[0].MultiplierPPM != 2_000_000 || envelope.Data.LuckyGifts[1].TargetUserID != 44 || envelope.Data.LuckyGifts[1].MultiplierPPM != 3_000_000 {
t.Fatalf("lucky_gifts response mismatch: %+v", envelope.Data.LuckyGifts)
}
} }
func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) { func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {
@ -1964,8 +1977,35 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
RoomShortId: "100101", RoomShortId: "100101",
}, },
}} }}
profileCardResource := &walletv1.Resource{
ResourceId: 8104,
ResourceCode: "profile_card_gold",
ResourceType: "profile_card",
Name: "Gold Profile Card",
AssetUrl: "https://cdn.example/profile-card.png",
AnimationUrl: "https://cdn.example/profile-card.mp4",
MetadataJson: `{"profile_card_layout":{"source_width":1136,"source_height":1680,"content_top":143,"content_bottom":1667,"content_height":1525}}`,
GrantStrategy: "extend_expiry",
Status: "active",
}
walletClient := &fakeWalletClient{
batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{
Users: []*walletv1.UserEquippedResources{{
UserId: 42,
Resources: []*walletv1.UserResourceEntitlement{{
UserId: 42,
EntitlementId: "ent-profile-card",
ResourceId: 8104,
Resource: profileCardResource,
Equipped: true,
ExpiresAtMs: 1999999999999,
}},
}},
},
}
profileClient := &fakeUserProfileClient{} profileClient := &fakeUserProfileClient{}
handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient) handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient)
handler.SetWalletClient(walletClient)
handler.SetTencentRTC(TencentRTCConfig{ handler.SetTencentRTC(TencentRTCConfig{
Enabled: true, Enabled: true,
SDKAppID: 1400000000, SDKAppID: 1400000000,
@ -2017,6 +2057,34 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
if profileClient.lastBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastBatch.GetUserIds()), ",") != "101,42,102,301" { if profileClient.lastBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastBatch.GetUserIds()), ",") != "101,42,102,301" {
t.Fatalf("join response must batch only first-screen profile users: %+v", profileClient.lastBatch) t.Fatalf("join response must batch only first-screen profile users: %+v", profileClient.lastBatch)
} }
hasRoomDisplayResourceQuery := false
for _, req := range walletClient.batchEquippedRequests {
if strings.Join(req.GetResourceTypes(), ",") == "avatar_frame,profile_card,vehicle" {
hasRoomDisplayResourceQuery = true
break
}
}
if !hasRoomDisplayResourceQuery {
t.Fatalf("join response must batch room wearable resources including profile_card: %+v", walletClient.batchEquippedRequests)
}
profiles, profilesOK := data["profiles"].([]any)
if !profilesOK || len(profiles) == 0 {
t.Fatalf("join response must include room display profiles: %+v", data["profiles"])
}
var currentProfile map[string]any
for _, item := range profiles {
profile, ok := item.(map[string]any)
if !ok || profile["user_id"] != "42" {
continue
}
currentProfile = profile
break
}
profileCard, profileCardOK := currentProfile["profile_card"].(map[string]any)
metadataJSON, _ := profileCard["metadata_json"].(string)
if !profileCardOK || profileCard["resource_id"] != float64(8104) || !strings.Contains(metadataJSON, `"content_top":143`) {
t.Fatalf("join profile must include profile_card metadata_json: %+v", currentProfile)
}
} }
func TestListRoomsUsesUserRegionFromUserService(t *testing.T) { func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
@ -4038,6 +4106,17 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
GrantStrategy: "set_active_flag", GrantStrategy: "set_active_flag",
Status: "active", Status: "active",
} }
profileCardResource := &walletv1.Resource{
ResourceId: 8104,
ResourceCode: "profile_card_gold",
ResourceType: "profile_card",
Name: "Gold Profile Card",
AssetUrl: "https://cdn.example/profile-card.png",
AnimationUrl: "https://cdn.example/profile-card.mp4",
MetadataJson: `{"profile_card_layout":{"source_width":1136,"source_height":1680,"content_top":117,"content_bottom":1666,"content_height":1550}}`,
GrantStrategy: "extend_expiry",
Status: "active",
}
badgeResource := &walletv1.Resource{ badgeResource := &walletv1.Resource{
ResourceId: 8103, ResourceId: 8103,
ResourceCode: "badge_host", ResourceCode: "badge_host",
@ -4054,6 +4133,7 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
Resources: []*walletv1.UserResourceEntitlement{ Resources: []*walletv1.UserResourceEntitlement{
{UserId: 42, EntitlementId: "ent-frame", ResourceId: 8101, Resource: frameResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-frame", ResourceId: 8101, Resource: frameResource, Equipped: true, ExpiresAtMs: 1999999999999},
{UserId: 42, EntitlementId: "ent-vehicle", ResourceId: 8102, Resource: vehicleResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-vehicle", ResourceId: 8102, Resource: vehicleResource, Equipped: true, ExpiresAtMs: 1999999999999},
{UserId: 42, EntitlementId: "ent-profile-card", ResourceId: 8104, Resource: profileCardResource, Equipped: true, ExpiresAtMs: 1999999999999},
{UserId: 42, EntitlementId: "ent-badge", ResourceId: 8103, Resource: badgeResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-badge", ResourceId: 8103, Resource: badgeResource, Equipped: true, ExpiresAtMs: 1999999999999},
}, },
}}, }},
@ -4100,6 +4180,10 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
ResourceID int64 `json:"resource_id"` ResourceID int64 `json:"resource_id"`
AnimationURL string `json:"animation_url"` AnimationURL string `json:"animation_url"`
} `json:"vehicle"` } `json:"vehicle"`
ProfileCard struct {
ResourceID int64 `json:"resource_id"`
MetadataJSON string `json:"metadata_json"`
} `json:"profile_card"`
EquippedBadges []struct { EquippedBadges []struct {
ResourceID int64 `json:"resource_id"` ResourceID int64 `json:"resource_id"`
} `json:"equipped_badges"` } `json:"equipped_badges"`
@ -4120,7 +4204,7 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
if achievementClient.lastBadges == nil || achievementClient.lastBadges.GetUserId() != 42 { if achievementClient.lastBadges == nil || achievementClient.lastBadges.GetUserId() != 42 {
t.Fatalf("appearance should query displayed badges: %+v", achievementClient.lastBadges) t.Fatalf("appearance should query displayed badges: %+v", achievementClient.lastBadges)
} }
if envelope.Code != httpkit.CodeOK || envelope.Data.UserID != "42" || envelope.Data.AvatarFrame.AnimationURL != "https://cdn.example/frame.svga" || envelope.Data.Vehicle.ResourceID != 8102 || len(envelope.Data.EquippedBadges) != 1 || len(envelope.Data.Badges) != 1 || envelope.Data.Badges[0].Resource.AssetURL != "https://cdn.example/badge.png" { if envelope.Code != httpkit.CodeOK || envelope.Data.UserID != "42" || envelope.Data.AvatarFrame.AnimationURL != "https://cdn.example/frame.svga" || envelope.Data.Vehicle.ResourceID != 8102 || envelope.Data.ProfileCard.ResourceID != 8104 || !strings.Contains(envelope.Data.ProfileCard.MetadataJSON, `"content_top":117`) || len(envelope.Data.EquippedBadges) != 1 || len(envelope.Data.Badges) != 1 || envelope.Data.Badges[0].Resource.AssetURL != "https://cdn.example/badge.png" {
t.Fatalf("appearance response mismatch: %+v", envelope) t.Fatalf("appearance response mismatch: %+v", envelope)
} }
} }

View File

@ -18,6 +18,7 @@ type Handler struct {
userSocialClient client.UserSocialClient userSocialClient client.UserSocialClient
userHostClient client.UserHostClient userHostClient client.UserHostClient
walletClient client.WalletClient walletClient client.WalletClient
growthLevelClient client.GrowthLevelClient
achievementClient client.AchievementClient achievementClient client.AchievementClient
rtcTokenConfig tencentrtc.TokenConfig rtcTokenConfig tencentrtc.TokenConfig
} }
@ -30,6 +31,7 @@ type Config struct {
UserSocialClient client.UserSocialClient UserSocialClient client.UserSocialClient
UserHostClient client.UserHostClient UserHostClient client.UserHostClient
WalletClient client.WalletClient WalletClient client.WalletClient
GrowthLevelClient client.GrowthLevelClient
AchievementClient client.AchievementClient AchievementClient client.AchievementClient
RTCTokenConfig tencentrtc.TokenConfig RTCTokenConfig tencentrtc.TokenConfig
} }
@ -43,6 +45,7 @@ func New(config Config) *Handler {
userSocialClient: config.UserSocialClient, userSocialClient: config.UserSocialClient,
userHostClient: config.UserHostClient, userHostClient: config.UserHostClient,
walletClient: config.WalletClient, walletClient: config.WalletClient,
growthLevelClient: config.GrowthLevelClient,
achievementClient: config.AchievementClient, achievementClient: config.AchievementClient,
rtcTokenConfig: config.RTCTokenConfig, rtcTokenConfig: config.RTCTokenConfig,
} }

View File

@ -3,6 +3,7 @@ package roomapi
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"hyapp/services/gateway-service/internal/transport/http/httpkit" "hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http" "net/http"
"strconv" "strconv"
@ -938,37 +939,11 @@ func (h *Handler) joinRoomRTCData(roomID string, viewerUserID int64) roomRTCData
return rtc return rtc
} }
func (h *Handler) roomInitialProfiles(request *http.Request, snapshot *roomv1.RoomSnapshot, viewerUserID int64) []roomUserProfileData { func (h *Handler) roomInitialProfiles(request *http.Request, snapshot *roomv1.RoomSnapshot, viewerUserID int64) []roomDisplayProfileData {
if h.userProfileClient == nil {
return nil
}
userIDs := roomInitialProfileUserIDs(snapshot, viewerUserID, joinRoomContributionRankLimit) userIDs := roomInitialProfileUserIDs(snapshot, viewerUserID, joinRoomContributionRankLimit)
if len(userIDs) == 0 { // 进房首屏资料和 /users/room-display-profiles:batch 使用同一份聚合逻辑,
return nil // 避免头像框、资料卡、徽章等展示资源在不同房间入口字段不一致。
} return h.roomDisplayProfiles(request, userIDs)
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
// 用户资料只服务首屏展示,失败时让客户端用 /users/profiles:batch 补偿,避免阻塞进房。
return nil
}
profiles := make([]roomUserProfileData, 0, len(userIDs))
for _, userID := range userIDs {
user := resp.GetUsers()[userID]
if user == nil {
continue
}
profiles = append(profiles, roomUserProfileData{
UserID: strconv.FormatInt(user.GetUserId(), 10),
DisplayUserID: user.GetDisplayUserId(),
Username: user.GetUsername(),
Avatar: user.GetAvatar(),
})
}
return profiles
} }
// roomHeartbeat 把客户端显式心跳转换为 room-service presence refresh 命令。 // roomHeartbeat 把客户端显式心跳转换为 room-service presence refresh 命令。
@ -1444,9 +1419,10 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
// 房间资料是展示增强,失败时保留主房间数据,让客户端后续重试 batch 接口。 // 房间资料是展示增强,失败时保留主房间数据,让客户端后续重试 batch 接口。
return nil return nil
} }
// 房间资料高频读取,头像框/座驾用 wallet 批量查badge 当前 activity 只有单用户查询,单独降级处理。 // 房间资料高频读取,头像框/资料卡/座驾用 wallet 批量查badge 当前 activity 只有单用户查询,单独降级处理。
appearanceMap := h.roomAppearanceResourceMap(request, userIDs) appearanceMap := h.roomAppearanceResourceMap(request, userIDs)
badgeMap := h.roomBadgeDisplayMap(request, userIDs) badgeMap := h.roomBadgeDisplayMap(request, userIDs)
levelMap := h.roomLevelDisplayProfileMap(request, userIDs)
profiles := make(map[int64]roomDisplayProfileData, len(resp.GetUsers())) profiles := make(map[int64]roomDisplayProfileData, len(resp.GetUsers()))
for _, userID := range userIDs { for _, userID := range userIDs {
user := resp.GetUsers()[userID] user := resp.GetUsers()[userID]
@ -1454,6 +1430,7 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
continue continue
} }
appearance := appearanceMap[userID] appearance := appearanceMap[userID]
levels := levelMap[userID]
profiles[userID] = roomDisplayProfileData{ profiles[userID] = roomDisplayProfileData{
UserID: formatOptionalUserID(user.GetUserId()), UserID: formatOptionalUserID(user.GetUserId()),
Username: user.GetUsername(), Username: user.GetUsername(),
@ -1466,9 +1443,11 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
CountryDisplayName: strings.TrimSpace(user.GetCountryDisplayName()), CountryDisplayName: strings.TrimSpace(user.GetCountryDisplayName()),
CountryFlag: roomProfileCountryFlag(user.GetCountry()), CountryFlag: roomProfileCountryFlag(user.GetCountry()),
VIP: map[string]any{}, VIP: map[string]any{},
Level: map[string]any{}, Level: levels.Levels,
LevelBadges: levels.Badges,
Badges: badgeMap[userID], Badges: badgeMap[userID],
AvatarFrame: appearance.AvatarFrame, AvatarFrame: appearance.AvatarFrame,
ProfileCard: appearance.ProfileCard,
Vehicle: appearance.Vehicle, Vehicle: appearance.Vehicle,
Charm: 0, Charm: 0,
} }
@ -1483,17 +1462,33 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
profile.AvatarFrame = map[string]any{} profile.AvatarFrame = map[string]any{}
profiles[userID] = profile profiles[userID] = profile
} }
if profiles[userID].Level == nil {
profile := profiles[userID]
profile.Level = roomDefaultLevelDisplay()
profiles[userID] = profile
}
if profiles[userID].LevelBadges == nil {
profile := profiles[userID]
profile.LevelBadges = map[string]any{}
profiles[userID] = profile
}
if profiles[userID].Vehicle == nil { if profiles[userID].Vehicle == nil {
profile := profiles[userID] profile := profiles[userID]
profile.Vehicle = map[string]any{} profile.Vehicle = map[string]any{}
profiles[userID] = profile profiles[userID] = profile
} }
if profiles[userID].ProfileCard == nil {
profile := profiles[userID]
profile.ProfileCard = map[string]any{}
profiles[userID] = profile
}
} }
return profiles return profiles
} }
type roomAppearanceResources struct { type roomAppearanceResources struct {
AvatarFrame map[string]any AvatarFrame map[string]any
ProfileCard map[string]any
Vehicle map[string]any Vehicle map[string]any
} }
@ -1507,7 +1502,7 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int
RequestId: httpkit.RequestIDFromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
UserIds: userIDs, UserIds: userIDs,
ResourceTypes: []string{"avatar_frame", "vehicle"}, ResourceTypes: []string{"avatar_frame", "profile_card", "vehicle"},
}) })
if err != nil { if err != nil {
// 装扮是展示增强,失败不影响房间主数据返回。 // 装扮是展示增强,失败不影响房间主数据返回。
@ -1524,6 +1519,8 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int
switch item.GetResource().GetResourceType() { switch item.GetResource().GetResourceType() {
case "avatar_frame": case "avatar_frame":
current.AvatarFrame = resource current.AvatarFrame = resource
case "profile_card":
current.ProfileCard = resource
case "vehicle": case "vehicle":
current.Vehicle = resource current.Vehicle = resource
} }
@ -1533,6 +1530,174 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int
return result return result
} }
type roomLevelDisplayResources struct {
Levels map[string]any
Badges map[string]any
}
func (h *Handler) roomLevelDisplayProfileMap(request *http.Request, userIDs []int64) map[int64]roomLevelDisplayResources {
result := make(map[int64]roomLevelDisplayResources, len(userIDs))
for _, userID := range userIDs {
result[userID] = roomLevelDisplayResources{Levels: roomDefaultLevelDisplay(), Badges: map[string]any{}}
}
if h.growthLevelClient != nil && len(userIDs) > 0 {
resp, err := h.growthLevelClient.BatchGetUserLevelDisplayProfiles(request.Context(), &activityv1.BatchGetUserLevelDisplayProfilesRequest{
Meta: httpkit.ActivityMeta(request),
UserIds: userIDs,
})
if err == nil {
resourceIDs := make([]int64, 0, len(resp.GetProfiles())*3)
for _, profile := range resp.GetProfiles() {
resourceIDs = append(resourceIDs, profile.GetWealth().GetBadgeResourceId())
resourceIDs = append(resourceIDs, profile.GetGame().GetBadgeResourceId())
resourceIDs = append(resourceIDs, profile.GetCharm().GetBadgeResourceId())
}
resources := h.roomResourcesByID(request, resourceIDs)
for _, profile := range resp.GetProfiles() {
if profile.GetUserId() <= 0 {
continue
}
current := roomLevelDisplayResources{Levels: roomDefaultLevelDisplay(), Badges: map[string]any{}}
roomApplyLevelDisplayTrack(current, profile.GetWealth(), resources)
roomApplyLevelDisplayTrack(current, profile.GetGame(), resources)
roomApplyLevelDisplayTrack(current, profile.GetCharm(), resources)
result[profile.GetUserId()] = current
}
}
}
h.fillRoomLevelBadgeEntitlementFallback(request, userIDs, result)
return result
}
func (h *Handler) fillRoomLevelBadgeEntitlementFallback(request *http.Request, userIDs []int64, result map[int64]roomLevelDisplayResources) {
if h.walletClient == nil || len(userIDs) == 0 {
return
}
for _, userID := range userIDs {
current := result[userID]
if !roomLevelBadgesNeedFallback(current.Badges) {
continue
}
// wallet 暂无按多用户批量查拥有资源 RPC这里仅在 activity 投影缺轨道时逐用户读取 active badge 权益。
// 已有投影仍优先返回,避免注册 1 级权益覆盖用户后续升级后的等级徽章。
resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: userID,
ResourceType: "badge",
ActiveOnly: true,
})
if err != nil {
continue
}
for _, item := range resp.GetResources() {
roomApplyLevelBadgeEntitlement(current, item)
if !roomLevelBadgesNeedFallback(current.Badges) {
break
}
}
result[userID] = current
}
}
func roomLevelBadgesNeedFallback(levelBadges map[string]any) bool {
if levelBadges == nil {
return true
}
for _, track := range []string{"wealth", "game", "charm"} {
if _, ok := levelBadges[track]; !ok {
return true
}
}
return false
}
func roomApplyLevelBadgeEntitlement(current roomLevelDisplayResources, item *walletv1.UserResourceEntitlement) {
if item == nil || current.Badges == nil {
return
}
resource := roomAppearanceResourceFromEntitlement(item)
track := strings.TrimSpace(fmt.Sprint(resource["level_track"]))
if track == "" || fmt.Sprint(resource["badge_kind"]) != "level" || fmt.Sprint(resource["badge_form"]) != "strip" {
return
}
if _, exists := current.Badges[track]; exists {
return
}
resourceID, _ := resource["resource_id"].(int64)
if resourceID <= 0 {
return
}
current.Badges[track] = map[string]any{
"track": track,
"source_type": "wallet_entitlement",
"source_level": roomLevelValue(current.Levels, track),
"badge_kind": "level",
"resource_id": resourceID,
"entitlement_id": item.GetEntitlementId(),
"updated_at_ms": item.GetUpdatedAtMs(),
"resource": resource,
}
}
func roomLevelValue(levels map[string]any, track string) int64 {
trackLevel, ok := levels[track].(map[string]any)
if !ok {
return 0
}
switch value := trackLevel["level"].(type) {
case int64:
return value
case int32:
return int64(value)
case int:
return int64(value)
case float64:
return int64(value)
default:
return 0
}
}
func roomApplyLevelDisplayTrack(current roomLevelDisplayResources, item *activityv1.LevelDisplayTrackProfile, resources map[int64]map[string]any) {
if item == nil || strings.TrimSpace(item.GetTrack()) == "" {
return
}
track := strings.TrimSpace(item.GetTrack())
current.Levels[track] = map[string]any{
"level": item.GetLevel(),
"tier_id": item.GetTierId(),
"badge_resource_id": item.GetBadgeResourceId(),
"badge_source_level": item.GetBadgeSourceLevel(),
"avatar_frame_id": item.GetAvatarFrameResourceId(),
"updated_at_ms": item.GetUpdatedAtMs(),
"display_badge_source": "level_rule",
}
if item.GetBadgeResourceId() <= 0 {
return
}
badge := map[string]any{
"track": track,
"source_type": "level_rule",
"source_level": item.GetBadgeSourceLevel(),
"badge_kind": "level",
"resource_id": item.GetBadgeResourceId(),
"updated_at_ms": item.GetUpdatedAtMs(),
}
if resource, ok := resources[item.GetBadgeResourceId()]; ok {
badge["resource"] = resource
}
current.Badges[track] = badge
}
func roomDefaultLevelDisplay() map[string]any {
return map[string]any{
"wealth": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
"game": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
"charm": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
}
}
func (h *Handler) roomBadgeDisplayMap(request *http.Request, userIDs []int64) map[int64][]map[string]any { func (h *Handler) roomBadgeDisplayMap(request *http.Request, userIDs []int64) map[int64][]map[string]any {
result := make(map[int64][]map[string]any, len(userIDs)) result := make(map[int64][]map[string]any, len(userIDs))
if h.achievementClient == nil || len(userIDs) == 0 { if h.achievementClient == nil || len(userIDs) == 0 {
@ -1624,7 +1789,7 @@ func roomAppearanceResourceFromProto(item *walletv1.Resource, entitlementID stri
if item == nil || item.GetResourceId() <= 0 { if item == nil || item.GetResourceId() <= 0 {
return map[string]any{} return map[string]any{}
} }
return map[string]any{ resource := map[string]any{
"resource_id": item.GetResourceId(), "resource_id": item.GetResourceId(),
"resource_code": item.GetResourceCode(), "resource_code": item.GetResourceCode(),
"resource_type": item.GetResourceType(), "resource_type": item.GetResourceType(),
@ -1636,6 +1801,35 @@ func roomAppearanceResourceFromProto(item *walletv1.Resource, entitlementID stri
"entitlement_id": entitlementID, "entitlement_id": entitlementID,
"expires_at_ms": expiresAtMS, "expires_at_ms": expiresAtMS,
} }
if item.GetResourceType() == "badge" {
badgeForm, badgeKind, levelTrack := roomBadgeMetadataFields(item.GetMetadataJson())
resource["badge_form"] = badgeForm
resource["badge_kind"] = badgeKind
resource["level_track"] = levelTrack
}
return resource
}
func roomBadgeMetadataFields(raw string) (string, string, string) {
payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return "", "normal", ""
}
badgeForm := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_form"])))
if badgeForm != "strip" && badgeForm != "tile" {
badgeForm = ""
}
badgeKind := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_kind"])))
if badgeKind != "level" {
badgeKind = "normal"
}
levelTrack := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["level_track"])))
switch levelTrack {
case "wealth", "game", "charm":
default:
levelTrack = ""
}
return badgeForm, badgeKind, levelTrack
} }
func roomProfileAgeFromBirth(birth string, now time.Time) int32 { func roomProfileAgeFromBirth(birth string, now time.Time) int32 {

View File

@ -48,15 +48,15 @@ type createRoomData struct {
} }
type joinRoomData struct { type joinRoomData struct {
Result roomCommandResultData `json:"result"` Result roomCommandResultData `json:"result"`
Room roomInitialData `json:"room"` Room roomInitialData `json:"room"`
Viewer roomViewerData `json:"viewer"` Viewer roomViewerData `json:"viewer"`
Seats []roomSeatData `json:"seats"` Seats []roomSeatData `json:"seats"`
ContributionRank []roomRankItemData `json:"contribution_rank"` ContributionRank []roomRankItemData `json:"contribution_rank"`
Profiles []roomUserProfileData `json:"profiles"` Profiles []roomDisplayProfileData `json:"profiles"`
IM roomIMData `json:"im"` IM roomIMData `json:"im"`
RTC roomRTCData `json:"rtc"` RTC roomRTCData `json:"rtc"`
ServerTimeMS int64 `json:"server_time_ms"` ServerTimeMS int64 `json:"server_time_ms"`
} }
type roomDetailData struct { type roomDetailData struct {
@ -151,8 +151,10 @@ type roomDisplayProfileData struct {
CountryFlag string `json:"country_flag"` CountryFlag string `json:"country_flag"`
VIP map[string]any `json:"vip"` VIP map[string]any `json:"vip"`
Level map[string]any `json:"level"` Level map[string]any `json:"level"`
LevelBadges map[string]any `json:"level_badges"`
Badges []map[string]any `json:"badges"` Badges []map[string]any `json:"badges"`
AvatarFrame map[string]any `json:"avatar_frame"` AvatarFrame map[string]any `json:"avatar_frame"`
ProfileCard map[string]any `json:"profile_card"`
Vehicle map[string]any `json:"vehicle"` Vehicle map[string]any `json:"vehicle"`
Charm int64 `json:"charm"` Charm int64 `json:"charm"`
} }
@ -339,13 +341,6 @@ type roomRankItemData struct {
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"` UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
} }
type roomUserProfileData struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id,omitempty"`
Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"`
}
type roomIMData struct { type roomIMData struct {
GroupID string `json:"group_id"` GroupID string `json:"group_id"`
NeedJoinGroup bool `json:"need_join_group"` NeedJoinGroup bool `json:"need_join_group"`

View File

@ -29,6 +29,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
UserSocialClient: h.userSocialClient, UserSocialClient: h.userSocialClient,
UserHostClient: h.userHostClient, UserHostClient: h.userHostClient,
WalletClient: h.walletClient, WalletClient: h.walletClient,
GrowthLevelClient: h.growthLevelClient,
AchievementClient: h.achievementClient, AchievementClient: h.achievementClient,
RTCTokenConfig: tencentrtc.TokenConfig{ RTCTokenConfig: tencentrtc.TokenConfig{
Enabled: h.tencentRTC.Enabled, Enabled: h.tencentRTC.Enabled,
@ -55,6 +56,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
UserCountryClient: h.userCountryClient, UserCountryClient: h.userCountryClient,
UserHostClient: h.userHostClient, UserHostClient: h.userHostClient,
WalletClient: h.walletClient, WalletClient: h.walletClient,
GrowthLevelClient: h.growthLevelClient,
AchievementClient: h.achievementClient, AchievementClient: h.achievementClient,
BroadcastClient: h.broadcastClient, BroadcastClient: h.broadcastClient,
}) })

View File

@ -1,9 +1,12 @@
package userapi package userapi
import ( import (
"encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
@ -27,6 +30,8 @@ type appearanceData struct {
AvatarFrame map[string]any `json:"avatar_frame"` AvatarFrame map[string]any `json:"avatar_frame"`
ProfileCard map[string]any `json:"profile_card"` ProfileCard map[string]any `json:"profile_card"`
Vehicle map[string]any `json:"vehicle"` Vehicle map[string]any `json:"vehicle"`
Level map[string]any `json:"level"`
LevelBadges map[string]any `json:"level_badges"`
EquippedBadges []map[string]any `json:"equipped_badges"` EquippedBadges []map[string]any `json:"equipped_badges"`
Badges []map[string]any `json:"badges"` Badges []map[string]any `json:"badges"`
BadgeProfile appearanceBadgeData `json:"badge_profile"` BadgeProfile appearanceBadgeData `json:"badge_profile"`
@ -79,6 +84,8 @@ func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appear
AvatarFrame: map[string]any{}, AvatarFrame: map[string]any{},
ProfileCard: map[string]any{}, ProfileCard: map[string]any{},
Vehicle: map[string]any{}, Vehicle: map[string]any{},
Level: defaultAppearanceLevelDisplay(),
LevelBadges: map[string]any{},
EquippedBadges: []map[string]any{}, EquippedBadges: []map[string]any{},
Badges: []map[string]any{}, Badges: []map[string]any{},
Resources: []map[string]any{}, Resources: []map[string]any{},
@ -125,10 +132,172 @@ func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appear
data.BadgeProfile = badgeProfile data.BadgeProfile = badgeProfile
// 顶层 badges 给高频展示位直接消费;完整 profile 仍保留在 badge_profile 中。 // 顶层 badges 给高频展示位直接消费;完整 profile 仍保留在 badge_profile 中。
data.Badges = badgeProfile.StripBadges data.Badges = badgeProfile.StripBadges
level, levelBadges := h.levelDisplayForUser(request, userID)
data.Level = level
data.LevelBadges = levelBadges
h.fillAppearanceLevelBadgeEntitlementFallback(request, userID, data.Level, data.LevelBadges)
data.ServerTimeMS = badgeProfile.ServerTimeMS data.ServerTimeMS = badgeProfile.ServerTimeMS
return data, nil return data, nil
} }
func (h *Handler) fillAppearanceLevelBadgeEntitlementFallback(request *http.Request, userID int64, levels map[string]any, levelBadges map[string]any) {
if h.walletClient == nil || userID <= 0 || !appearanceLevelBadgesNeedFallback(levelBadges) {
return
}
// activity 的等级投影仍是首选来源;这里只在投影缺轨道时读取 wallet active 权益。
// 注册补发的一等级徽章当前已经落在权益表里,这层兜底让老用户不等等级事件也能展示素材。
resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: userID,
ResourceType: appearanceResourceBadge,
ActiveOnly: true,
})
if err != nil {
return
}
for _, item := range resp.GetResources() {
appearanceApplyLevelBadgeEntitlement(levels, levelBadges, item)
if !appearanceLevelBadgesNeedFallback(levelBadges) {
return
}
}
}
func appearanceLevelBadgesNeedFallback(levelBadges map[string]any) bool {
if levelBadges == nil {
return true
}
for _, track := range []string{"wealth", "game", "charm"} {
if _, ok := levelBadges[track]; !ok {
return true
}
}
return false
}
func appearanceApplyLevelBadgeEntitlement(levels map[string]any, levelBadges map[string]any, item *walletv1.UserResourceEntitlement) {
if item == nil || levelBadges == nil {
return
}
resource := appearanceResourceFromEntitlement(item)
track := strings.TrimSpace(fmt.Sprint(resource["level_track"]))
if track == "" || fmt.Sprint(resource["badge_kind"]) != "level" || fmt.Sprint(resource["badge_form"]) != "strip" {
return
}
if _, exists := levelBadges[track]; exists {
return
}
resourceID, _ := resource["resource_id"].(int64)
if resourceID <= 0 {
return
}
levelBadges[track] = map[string]any{
"track": track,
"source_type": "wallet_entitlement",
"source_level": appearanceLevelValue(levels, track),
"badge_kind": "level",
"resource_id": resourceID,
"entitlement_id": item.GetEntitlementId(),
"updated_at_ms": item.GetUpdatedAtMs(),
"resource": resource,
}
}
func appearanceLevelValue(levels map[string]any, track string) int64 {
trackLevel, ok := levels[track].(map[string]any)
if !ok {
return 0
}
switch value := trackLevel["level"].(type) {
case int64:
return value
case int32:
return int64(value)
case int:
return int64(value)
case float64:
return int64(value)
default:
return 0
}
}
func (h *Handler) levelDisplayForUser(request *http.Request, userID int64) (map[string]any, map[string]any) {
levels := defaultAppearanceLevelDisplay()
levelBadges := map[string]any{}
if h.growthLevelClient == nil {
return levels, levelBadges
}
resp, err := h.growthLevelClient.BatchGetUserLevelDisplayProfiles(request.Context(), &activityv1.BatchGetUserLevelDisplayProfilesRequest{
Meta: httpkit.ActivityMeta(request),
UserIds: []int64{userID},
})
if err != nil {
// 等级徽章只是展示增强activity 短暂失败时保留装扮和普通徽章结果。
return levels, levelBadges
}
resourceIDs := make([]int64, 0, 3)
var profile *activityv1.UserLevelDisplayProfile
for _, item := range resp.GetProfiles() {
if item.GetUserId() == userID {
profile = item
break
}
}
if profile == nil {
return levels, levelBadges
}
resourceIDs = append(resourceIDs, profile.GetWealth().GetBadgeResourceId(), profile.GetGame().GetBadgeResourceId(), profile.GetCharm().GetBadgeResourceId())
resources := map[int64]map[string]any{}
if resolved, err := h.appearanceResourcesByID(request, resourceIDs); err == nil {
resources = resolved
}
applyAppearanceLevelTrack(levels, levelBadges, profile.GetWealth(), resources)
applyAppearanceLevelTrack(levels, levelBadges, profile.GetGame(), resources)
applyAppearanceLevelTrack(levels, levelBadges, profile.GetCharm(), resources)
return levels, levelBadges
}
func applyAppearanceLevelTrack(levels map[string]any, levelBadges map[string]any, item *activityv1.LevelDisplayTrackProfile, resources map[int64]map[string]any) {
if item == nil || strings.TrimSpace(item.GetTrack()) == "" {
return
}
track := strings.TrimSpace(item.GetTrack())
levels[track] = map[string]any{
"level": item.GetLevel(),
"tier_id": item.GetTierId(),
"badge_resource_id": item.GetBadgeResourceId(),
"badge_source_level": item.GetBadgeSourceLevel(),
"avatar_frame_id": item.GetAvatarFrameResourceId(),
"updated_at_ms": item.GetUpdatedAtMs(),
"display_badge_source": "level_rule",
}
if item.GetBadgeResourceId() <= 0 {
return
}
badge := map[string]any{
"track": track,
"source_type": "level_rule",
"source_level": item.GetBadgeSourceLevel(),
"badge_kind": "level",
"resource_id": item.GetBadgeResourceId(),
"updated_at_ms": item.GetUpdatedAtMs(),
}
if resource, ok := resources[item.GetBadgeResourceId()]; ok {
badge["resource"] = resource
}
levelBadges[track] = badge
}
func defaultAppearanceLevelDisplay() map[string]any {
return map[string]any{
"wealth": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
"game": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
"charm": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
}
}
func (h *Handler) badgeProfileForUser(request *http.Request, userID int64) (appearanceBadgeData, error) { func (h *Handler) badgeProfileForUser(request *http.Request, userID int64) (appearanceBadgeData, error) {
result := appearanceBadgeData{ result := appearanceBadgeData{
StripBadges: []map[string]any{}, StripBadges: []map[string]any{},
@ -197,7 +366,7 @@ func appearanceResourceFromProto(item *walletv1.Resource, entitlementID string,
if item == nil || item.GetResourceId() <= 0 { if item == nil || item.GetResourceId() <= 0 {
return map[string]any{} return map[string]any{}
} }
return map[string]any{ resource := map[string]any{
"resource_id": item.GetResourceId(), "resource_id": item.GetResourceId(),
"resource_code": item.GetResourceCode(), "resource_code": item.GetResourceCode(),
"resource_type": item.GetResourceType(), "resource_type": item.GetResourceType(),
@ -209,6 +378,35 @@ func appearanceResourceFromProto(item *walletv1.Resource, entitlementID string,
"entitlement_id": entitlementID, "entitlement_id": entitlementID,
"expires_at_ms": expiresAtMS, "expires_at_ms": expiresAtMS,
} }
if item.GetResourceType() == appearanceResourceBadge {
badgeForm, badgeKind, levelTrack := appearanceBadgeMetadataFields(item.GetMetadataJson())
resource["badge_form"] = badgeForm
resource["badge_kind"] = badgeKind
resource["level_track"] = levelTrack
}
return resource
}
func appearanceBadgeMetadataFields(raw string) (string, string, string) {
payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return "", "normal", ""
}
badgeForm := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_form"])))
if badgeForm != "strip" && badgeForm != "tile" {
badgeForm = ""
}
badgeKind := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_kind"])))
if badgeKind != "level" {
badgeKind = "normal"
}
levelTrack := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["level_track"])))
switch levelTrack {
case "wealth", "game", "charm":
default:
levelTrack = ""
}
return badgeForm, badgeKind, levelTrack
} }
func badgeItemsFromProto(items []*activityv1.BadgeDisplayItem, resources map[int64]map[string]any) []map[string]any { func badgeItemsFromProto(items []*activityv1.BadgeDisplayItem, resources map[int64]map[string]any) []map[string]any {

View File

@ -16,6 +16,7 @@ type Handler struct {
userCountryClient client.UserCountryQueryClient userCountryClient client.UserCountryQueryClient
userHostClient client.UserHostClient userHostClient client.UserHostClient
walletClient client.WalletClient walletClient client.WalletClient
growthLevelClient client.GrowthLevelClient
achievementClient client.AchievementClient achievementClient client.AchievementClient
broadcastClient client.BroadcastClient broadcastClient client.BroadcastClient
} }
@ -27,6 +28,7 @@ type Config struct {
UserCountryClient client.UserCountryQueryClient UserCountryClient client.UserCountryQueryClient
UserHostClient client.UserHostClient UserHostClient client.UserHostClient
WalletClient client.WalletClient WalletClient client.WalletClient
GrowthLevelClient client.GrowthLevelClient
AchievementClient client.AchievementClient AchievementClient client.AchievementClient
BroadcastClient client.BroadcastClient BroadcastClient client.BroadcastClient
} }
@ -39,6 +41,7 @@ func New(config Config) *Handler {
userCountryClient: config.UserCountryClient, userCountryClient: config.UserCountryClient,
userHostClient: config.UserHostClient, userHostClient: config.UserHostClient,
walletClient: config.WalletClient, walletClient: config.WalletClient,
growthLevelClient: config.GrowthLevelClient,
achievementClient: config.AchievementClient, achievementClient: config.AchievementClient,
broadcastClient: config.BroadcastClient, broadcastClient: config.BroadcastClient,
} }

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"google.golang.org/protobuf/proto"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
@ -68,10 +69,6 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
// 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。 // 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid") return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid")
} }
if len(cmd.TargetUserIDs) > 1 && cmd.PoolID != "" {
// 幸运礼物 activity 协议当前只有单个 target_user_id多目标先只开放普通礼物避免抽奖事实错绑接收方。
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "lucky gift requires one target user")
}
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, cmd.RoomID()) roomMeta, exists, err := s.repository.GetRoomMeta(ctx, cmd.RoomID())
if err != nil { if err != nil {
return mutationResult{}, nil, err return mutationResult{}, nil, err
@ -111,6 +108,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
var walletDebitMS int64 var walletDebitMS int64
settledCommand := cmd settledCommand := cmd
var luckyGift *roomv1.LuckyGiftDrawResult var luckyGift *roomv1.LuckyGiftDrawResult
var luckyGifts []*roomv1.LuckyGiftDrawResult
if err := s.withLuckyGiftSendLock(ctx, luckyEnabled, cmd.ActorUserID(), func() error { if err := s.withLuckyGiftSendLock(ctx, luckyEnabled, cmd.ActorUserID(), func() error {
// 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。 // 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。
walletStartedAt := time.Now() walletStartedAt := time.Now()
@ -131,40 +129,20 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
settledCommand.GiftTypeCode = billing.GetGiftTypeCode() settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded() settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded()
settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey() settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey()
if !luckyEnabled && len(cmd.TargetUserIDs) == 1 { if !luckyEnabled {
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。 // 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode()) luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
} }
if luckyEnabled { if luckyEnabled {
// 抽奖必须发生在扣费成功之后activity-service 只接收钱包结算后的 coin_spent不信任客户端价格。 luckyGifts, err = s.executeLuckyGiftDraws(ctx, req.GetMeta(), cmd, roomMeta, targetBillings, now)
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
LuckyGift: &activityv1.LuckyGiftMeta{
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
CommandId: cmd.ID(),
UserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
// 目前没有独立设备 ID 字段,优先用 session_id没有时退回 command_id保证风控 scope 不为空。
DeviceId: luckyGiftDeviceID(cmd),
RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
CoinSpent: billing.GetCoinSpent(),
// 房间链路统一使用 UTC epoch ms不能把本地时区时间传给活动风控窗口。
PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
},
})
if err != nil { if err != nil {
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。 // 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
return err return err
} }
if drawResp == nil { if len(luckyGifts) > 0 {
return xerr.New(xerr.Unavailable, "lucky gift draw response is empty") // lucky_gifts 保留每个收礼目标的独立抽奖事实lucky_gift 返回整次送礼的聚合表现,复用已有批量送礼的倍率累加语义。
luckyGift = aggregateLuckyGiftResults(cmd.ID(), luckyGifts)
} }
// 同步返回只给当前送礼用户做即时表现;金币余额以 activity/wallet 快路径回执为准,房间开奖结果仍由 activity outbox 补偿。
luckyGift = luckyGiftResultFromProto(drawResp.GetResult())
} }
return nil return nil
}); err != nil { }); err != nil {
@ -247,6 +225,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
roomHeat: current.Heat, roomHeat: current.Heat,
giftRank: cloneProtoRank(current.GiftRank), giftRank: cloneProtoRank(current.GiftRank),
luckyGift: luckyGift, luckyGift: luckyGift,
luckyGifts: luckyGifts,
commandPayload: commandPayload, commandPayload: commandPayload,
walletDebitMS: walletDebitMS, walletDebitMS: walletDebitMS,
roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{ roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
@ -302,6 +281,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
Room: result.snapshot, Room: result.snapshot,
Treasure: result.snapshot.GetTreasure(), Treasure: result.snapshot.GetTreasure(),
LuckyGift: result.luckyGift, LuckyGift: result.luckyGift,
LuckyGifts: result.luckyGifts,
}, nil }, nil
} }
@ -369,6 +349,44 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
return resp.GetAggregate(), targetBillings, nil return resp.GetAggregate(), targetBillings, nil
} }
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) {
results := make([]*roomv1.LuckyGiftDrawResult, 0, len(targetBillings))
for _, targetBilling := range targetBillings {
if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
}
// 抽奖必须按每个收礼目标独立落事实command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
LuckyGift: &activityv1.LuckyGiftMeta{
Meta: activityMetaFromRoom(ctx, meta),
CommandId: targetBilling.CommandID,
UserId: cmd.ActorUserID(),
TargetUserId: targetBilling.TargetUserID,
// 目前没有独立设备 ID 字段,优先用 session_id没有时退回目标子 command_id保证每次抽奖 scope 不为空。
DeviceId: luckyGiftDeviceID(cmd, targetBilling.CommandID),
RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
CoinSpent: targetBilling.Billing.GetCoinSpent(),
// 房间链路统一使用 UTC epoch ms不能把本地时区时间传给活动风控窗口。
PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
},
})
if err != nil {
return nil, err
}
if drawResp == nil || drawResp.GetResult() == nil {
return nil, xerr.New(xerr.Unavailable, "lucky gift draw response is empty")
}
// 同步返回只给当前送礼用户做即时表现;金币余额以 activity/wallet 快路径回执为准,房间开奖结果仍由 activity outbox 补偿。
results = append(results, luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID))
}
return results, nil
}
func roomGiftLeaderboardCoinSpent(billing *walletv1.DebitGiftResponse) int64 { func roomGiftLeaderboardCoinSpent(billing *walletv1.DebitGiftResponse) int64 {
if billing == nil { if billing == nil {
return 0 return 0
@ -419,10 +437,13 @@ func activityMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *activi
} }
} }
func luckyGiftDeviceID(cmd command.SendGift) string { func luckyGiftDeviceID(cmd command.SendGift, fallbackCommandID string) string {
if value := strings.TrimSpace(cmd.SessionID); value != "" { if value := strings.TrimSpace(cmd.SessionID); value != "" {
return value return value
} }
if value := strings.TrimSpace(fallbackCommandID); value != "" {
return value
}
return cmd.ID() return cmd.ID()
} }
@ -433,7 +454,7 @@ func luckyGiftAnchorID(roomMeta RoomMeta) string {
return fmt.Sprintf("%d", roomMeta.OwnerUserID) return fmt.Sprintf("%d", roomMeta.OwnerUserID)
} }
func luckyGiftResultFromProto(result *activityv1.LuckyGiftDrawResult) *roomv1.LuckyGiftDrawResult { func luckyGiftResultFromProto(result *activityv1.LuckyGiftDrawResult, targetUserID int64) *roomv1.LuckyGiftDrawResult {
if result == nil { if result == nil {
return nil return nil
} }
@ -457,9 +478,66 @@ func luckyGiftResultFromProto(result *activityv1.LuckyGiftDrawResult) *roomv1.Lu
CreatedAtMs: result.GetCreatedAtMs(), CreatedAtMs: result.GetCreatedAtMs(),
WalletTransactionId: result.GetWalletTransactionId(), WalletTransactionId: result.GetWalletTransactionId(),
CoinBalanceAfter: result.GetCoinBalanceAfter(), CoinBalanceAfter: result.GetCoinBalanceAfter(),
TargetUserId: targetUserID,
} }
} }
func aggregateLuckyGiftResults(commandID string, results []*roomv1.LuckyGiftDrawResult) *roomv1.LuckyGiftDrawResult {
if len(results) == 0 {
return nil
}
if len(results) == 1 {
return results[0]
}
aggregate := proto.Clone(results[0]).(*roomv1.LuckyGiftDrawResult)
aggregate.CommandId = commandID
aggregate.SelectedTierId = "batch"
aggregate.TargetUserId = 0
aggregate.WalletTransactionId = ""
aggregate.MultiplierPpm = 0
aggregate.BaseRewardCoins = 0
aggregate.RoomAtmosphereRewardCoins = 0
aggregate.ActivitySubsidyCoins = 0
aggregate.EffectiveRewardCoins = 0
aggregate.RewardStatus = "granted"
aggregate.StageFeedback = false
aggregate.HighMultiplier = false
aggregate.CoinBalanceAfter = 0
for _, result := range results {
if result == nil {
continue
}
aggregate.RuleVersion = result.GetRuleVersion()
aggregate.ExperiencePool = result.GetExperiencePool()
aggregate.CreatedAtMs = result.GetCreatedAtMs()
aggregate.MultiplierPpm += result.GetMultiplierPpm()
aggregate.BaseRewardCoins += result.GetBaseRewardCoins()
aggregate.RoomAtmosphereRewardCoins += result.GetRoomAtmosphereRewardCoins()
aggregate.ActivitySubsidyCoins += result.GetActivitySubsidyCoins()
aggregate.EffectiveRewardCoins += result.GetEffectiveRewardCoins()
aggregate.StageFeedback = aggregate.StageFeedback || result.GetStageFeedback()
aggregate.HighMultiplier = aggregate.HighMultiplier || result.GetHighMultiplier()
if result.GetCoinBalanceAfter() > 0 {
// 多目标会产生多笔返奖交易;聚合字段不能表达多个交易号,只保留最后一次快路径回执后的余额给客户端刷新资产。
aggregate.CoinBalanceAfter = result.GetCoinBalanceAfter()
}
switch result.GetRewardStatus() {
case "pending":
aggregate.RewardStatus = "pending"
case "failed":
if aggregate.RewardStatus != "pending" {
aggregate.RewardStatus = "failed"
}
case "granted":
default:
if aggregate.RewardStatus == "" {
aggregate.RewardStatus = result.GetRewardStatus()
}
}
}
return aggregate
}
func normalizeGiftTargetType(raw string) string { func normalizeGiftTargetType(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
switch raw { switch raw {

View File

@ -5,6 +5,7 @@ import (
"testing" "testing"
"time" "time"
"google.golang.org/protobuf/proto"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
@ -16,8 +17,9 @@ import (
) )
type luckyGiftTestClient struct { type luckyGiftTestClient struct {
checks []*activityv1.CheckLuckyGiftRequest checks []*activityv1.CheckLuckyGiftRequest
draws []*activityv1.ExecuteLuckyGiftDrawRequest draws []*activityv1.ExecuteLuckyGiftDrawRequest
drawResults []*activityv1.LuckyGiftDrawResult
} }
func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) { func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
@ -35,6 +37,23 @@ func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *activityv1.
func (c *luckyGiftTestClient) ExecuteLuckyGiftDraw(_ context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) { func (c *luckyGiftTestClient) ExecuteLuckyGiftDraw(_ context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
c.draws = append(c.draws, req) c.draws = append(c.draws, req)
meta := req.GetLuckyGift() meta := req.GetLuckyGift()
if len(c.drawResults) > 0 {
index := len(c.draws) - 1
if index >= len(c.drawResults) {
index = len(c.drawResults) - 1
}
result := proto.Clone(c.drawResults[index]).(*activityv1.LuckyGiftDrawResult)
if result.CommandId == "" {
result.CommandId = meta.GetCommandId()
}
if result.PoolId == "" {
result.PoolId = meta.GetPoolId()
}
if result.GiftId == "" {
result.GiftId = meta.GetGiftId()
}
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: result}, nil
}
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: &activityv1.LuckyGiftDrawResult{ return &activityv1.ExecuteLuckyGiftDrawResponse{Result: &activityv1.LuckyGiftDrawResult{
DrawId: "lucky_draw_test", DrawId: "lucky_draw_test",
CommandId: meta.GetCommandId(), CommandId: meta.GetCommandId(),
@ -63,7 +82,32 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
HeatValue: 100, HeatValue: 100,
GiftTypeCode: "super_lucky", GiftTypeCode: "super_lucky",
}}} }}}
luckyGift := &luckyGiftTestClient{} luckyGift := &luckyGiftTestClient{drawResults: []*activityv1.LuckyGiftDrawResult{
{
DrawId: "lucky_draw_target_202",
SelectedTierId: "novice_2x",
MultiplierPpm: 2_000_000,
BaseRewardCoins: 200,
EffectiveRewardCoins: 200,
RewardStatus: "granted",
WalletTransactionId: "wallet_tx_lucky_202",
CoinBalanceAfter: 8800,
CreatedAtMs: 1_779_258_000_000,
},
{
DrawId: "lucky_draw_target_303",
SelectedTierId: "normal_3x",
MultiplierPpm: 3_000_000,
BaseRewardCoins: 300,
EffectiveRewardCoins: 300,
RewardStatus: "granted",
WalletTransactionId: "wallet_tx_lucky_303",
CoinBalanceAfter: 9100,
CreatedAtMs: 1_779_258_000_001,
StageFeedback: true,
HighMultiplier: true,
},
}}
svc := roomservice.New(roomservice.Config{ svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-test", NodeID: "node-lucky-test",
LeaseTTL: 10 * time.Second, LeaseTTL: 10 * time.Second,
@ -99,6 +143,9 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 || resp.GetLuckyGift().GetRewardStatus() != "granted" || resp.GetLuckyGift().GetWalletTransactionId() != "wallet_tx_lucky" || resp.GetLuckyGift().GetCoinBalanceAfter() != 8800 { if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 || resp.GetLuckyGift().GetRewardStatus() != "granted" || resp.GetLuckyGift().GetWalletTransactionId() != "wallet_tx_lucky" || resp.GetLuckyGift().GetCoinBalanceAfter() != 8800 {
t.Fatalf("lucky gift result mismatch: %+v", resp.GetLuckyGift()) t.Fatalf("lucky gift result mismatch: %+v", resp.GetLuckyGift())
} }
if len(resp.GetLuckyGifts()) != 1 || resp.GetLuckyGifts()[0].GetTargetUserId() != viewerID {
t.Fatalf("single-target lucky_gifts response mismatch: %+v", resp.GetLuckyGifts())
}
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" || luckyGift.checks[0].GetGiftId() != "rose" { if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" || luckyGift.checks[0].GetGiftId() != "rose" {
t.Fatalf("lucky check request mismatch: %+v", luckyGift.checks) t.Fatalf("lucky check request mismatch: %+v", luckyGift.checks)
} }
@ -114,3 +161,76 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta) t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
} }
} }
func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-lucky-202", CoinSpent: 100, ChargeAmount: 100, GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "super_lucky"},
{BillingReceiptId: "receipt-lucky-303", CoinSpent: 200, ChargeAmount: 200, GiftPointAdded: 200, HeatValue: 200, GiftTypeCode: "super_lucky"},
}}
luckyGift := &luckyGiftTestClient{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-multi-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
roomID := "room-lucky-gift-multi"
ownerID := int64(101)
firstTargetID := int64(202)
secondTargetID := int64(303)
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9001)
joinTreasureRoom(t, ctx, svc, roomID, firstTargetID)
joinTreasureRoom(t, ctx, svc, roomID, secondTargetID)
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-lucky-multi",
CommandId: "cmd-lucky-multi",
ActorUserId: ownerID,
RoomId: roomID,
SessionId: "device-session-multi",
AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
},
TargetType: "user",
TargetUserIds: []int64{firstTargetID, secondTargetID},
GiftId: "rose",
GiftCount: 1,
PoolId: "super_lucky",
})
if err != nil {
t.Fatalf("send multi-target lucky gift failed: %v", err)
}
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" {
t.Fatalf("multi-target lucky gift should check pool once: %+v", luckyGift.checks)
}
if len(luckyGift.draws) != 2 {
t.Fatalf("multi-target lucky gift should draw once per target, got %d", len(luckyGift.draws))
}
firstDraw := luckyGift.draws[0].GetLuckyGift()
secondDraw := luckyGift.draws[1].GetLuckyGift()
if firstDraw.GetCommandId() != "cmd-lucky-multi:target:202" || firstDraw.GetTargetUserId() != firstTargetID || firstDraw.GetCoinSpent() != 100 {
t.Fatalf("first target lucky draw mismatch: %+v", firstDraw)
}
if secondDraw.GetCommandId() != "cmd-lucky-multi:target:303" || secondDraw.GetTargetUserId() != secondTargetID || secondDraw.GetCoinSpent() != 200 {
t.Fatalf("second target lucky draw mismatch: %+v", secondDraw)
}
if resp.GetLuckyGift().GetCommandId() != "cmd-lucky-multi" ||
resp.GetLuckyGift().GetTargetUserId() != 0 ||
resp.GetLuckyGift().GetSelectedTierId() != "batch" ||
resp.GetLuckyGift().GetMultiplierPpm() != 5_000_000 ||
resp.GetLuckyGift().GetEffectiveRewardCoins() != 500 ||
resp.GetLuckyGift().GetWalletTransactionId() != "" ||
resp.GetLuckyGift().GetCoinBalanceAfter() != 9100 ||
!resp.GetLuckyGift().GetStageFeedback() ||
!resp.GetLuckyGift().GetHighMultiplier() ||
len(resp.GetLuckyGifts()) != 2 {
t.Fatalf("multi-target lucky response must include aggregate and list results: %+v", resp)
}
if resp.GetLuckyGifts()[0].GetCommandId() != "cmd-lucky-multi:target:202" || resp.GetLuckyGifts()[0].GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGifts()[1].GetCommandId() != "cmd-lucky-multi:target:303" || resp.GetLuckyGifts()[1].GetMultiplierPpm() != 3_000_000 || resp.GetLuckyGifts()[1].GetTargetUserId() != secondTargetID {
t.Fatalf("multi-target lucky_gifts response mismatch: %+v", resp.GetLuckyGifts())
}
}

View File

@ -113,6 +113,8 @@ type mutationResult struct {
giftRank []*roomv1.RankItem giftRank []*roomv1.RankItem
// luckyGift 是扣费成功后同步执行的幸运礼物抽奖结果;普通礼物保持 nil。 // luckyGift 是扣费成功后同步执行的幸运礼物抽奖结果;普通礼物保持 nil。
luckyGift *roomv1.LuckyGiftDrawResult luckyGift *roomv1.LuckyGiftDrawResult
// luckyGifts 是多目标幸运礼物的逐接收方抽奖结果;单目标时只包含 luckyGift。
luckyGifts []*roomv1.LuckyGiftDrawResult
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log用于恢复。 // commandPayload 允许少数命令把外部依赖的结算快照写入 command log用于恢复。
commandPayload []byte commandPayload []byte
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行。 // syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行。

View File

@ -168,6 +168,7 @@ func New(cfg config.Config) (*App, error) {
} }
var activityConn *grpc.ClientConn var activityConn *grpc.ClientConn
var registrationRewardIssuer authservice.RegistrationRewardIssuer var registrationRewardIssuer authservice.RegistrationRewardIssuer
var registrationLevelBadgeIssuer authservice.RegistrationLevelBadgeIssuer
if cfg.ActivityService.Enabled { if cfg.ActivityService.Enabled {
activityConn, err = grpcclient.Dial(cfg.ActivityService.Addr, grpcclient.Config{ activityConn, err = grpcclient.Dial(cfg.ActivityService.Addr, grpcclient.Config{
DefaultTimeout: time.Duration(cfg.ActivityService.RequestTimeoutMs) * time.Millisecond, DefaultTimeout: time.Duration(cfg.ActivityService.RequestTimeoutMs) * time.Millisecond,
@ -183,7 +184,9 @@ func New(cfg config.Config) (*App, error) {
_ = mysqlRepo.Close() _ = mysqlRepo.Close()
return nil, err return nil, err
} }
registrationRewardIssuer = activityclient.NewGRPC(activityConn) activityClient := activityclient.NewGRPC(activityConn)
registrationRewardIssuer = activityClient
registrationLevelBadgeIssuer = activityClient
} }
// auth service 负责登录、session 和 token用户主数据和短号事务仍通过各自领域存储完成。 // auth service 负责登录、session 和 token用户主数据和短号事务仍通过各自领域存储完成。
@ -214,6 +217,7 @@ func New(cfg config.Config) (*App, error) {
authservice.WithIPDecisionCache(ipDecisionCache), authservice.WithIPDecisionCache(ipDecisionCache),
authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders(cfg.LoginRisk.Providers)), authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders(cfg.LoginRisk.Providers)),
authservice.WithRegistrationRewardIssuer(registrationRewardIssuer), authservice.WithRegistrationRewardIssuer(registrationRewardIssuer),
authservice.WithRegistrationLevelBadgeIssuer(registrationLevelBadgeIssuer),
authservice.WithIMAccountImporter(imAccountImporter), authservice.WithIMAccountImporter(imAccountImporter),
) )
// user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。 // user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。

View File

@ -11,19 +11,41 @@ import (
) )
type Client struct { type Client struct {
client activityv1.RegistrationRewardServiceClient registrationRewardClient activityv1.RegistrationRewardServiceClient
growthLevelClient activityv1.GrowthLevelServiceClient
} }
func NewGRPC(conn grpc.ClientConnInterface) *Client { func NewGRPC(conn grpc.ClientConnInterface) *Client {
return &Client{client: activityv1.NewRegistrationRewardServiceClient(conn)} return &Client{
registrationRewardClient: activityv1.NewRegistrationRewardServiceClient(conn),
growthLevelClient: activityv1.NewGrowthLevelServiceClient(conn),
}
} }
// IssueRegistrationReward 只发起注册奖励幂等命令;可领取判断和实际发放都在 activity-service。 // IssueRegistrationReward 只发起注册奖励幂等命令;可领取判断和实际发放都在 activity-service。
func (c *Client) IssueRegistrationReward(ctx context.Context, command authservice.RegistrationRewardCommand) error { func (c *Client) IssueRegistrationReward(ctx context.Context, command authservice.RegistrationRewardCommand) error {
if c == nil || c.client == nil { if c == nil || c.registrationRewardClient == nil {
return nil return nil
} }
_, err := c.client.IssueRegistrationReward(ctx, &activityv1.IssueRegistrationRewardRequest{ _, err := c.registrationRewardClient.IssueRegistrationReward(ctx, &activityv1.IssueRegistrationRewardRequest{
Meta: &activityv1.RequestMeta{
RequestId: command.RequestID,
Caller: "user-service",
AppCode: command.AppCode,
SentAtMs: time.Now().UnixMilli(),
},
UserId: command.UserID,
CommandId: command.CommandID,
})
return err
}
// IssueRegistrationLevelBadges 发起注册等级徽章发放;资源选择和钱包发放都在 activity-service。
func (c *Client) IssueRegistrationLevelBadges(ctx context.Context, command authservice.RegistrationLevelBadgeCommand) error {
if c == nil || c.growthLevelClient == nil {
return nil
}
_, err := c.growthLevelClient.IssueRegistrationLevelBadges(ctx, &activityv1.IssueRegistrationLevelBadgesRequest{
Meta: &activityv1.RequestMeta{ Meta: &activityv1.RequestMeta{
RequestId: command.RequestID, RequestId: command.RequestID,
Caller: "user-service", Caller: "user-service",

View File

@ -110,6 +110,19 @@ type RegistrationRewardIssuer interface {
IssueRegistrationReward(ctx context.Context, command RegistrationRewardCommand) error IssueRegistrationReward(ctx context.Context, command RegistrationRewardCommand) error
} }
// RegistrationLevelBadgeCommand 是三方首次注册成功后发往 activity-service 的等级徽章发放命令。
type RegistrationLevelBadgeCommand struct {
AppCode string
RequestID string
UserID int64
CommandID string
}
// RegistrationLevelBadgeIssuer 隔离注册等级徽章发放 RPC失败不能回滚用户注册主事务。
type RegistrationLevelBadgeIssuer interface {
IssueRegistrationLevelBadges(ctx context.Context, command RegistrationLevelBadgeCommand) error
}
// IMAccountImporter 把 user-service 用户导入腾讯 IM 账号体系。 // IMAccountImporter 把 user-service 用户导入腾讯 IM 账号体系。
type IMAccountImporter interface { type IMAccountImporter interface {
ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error
@ -199,6 +212,8 @@ type Service struct {
ipGeoProviders []IPGeoProvider ipGeoProviders []IPGeoProvider
// registrationRewardIssuer 在注册事务提交后异步式触发奖励;失败只记录日志,不回滚用户注册。 // registrationRewardIssuer 在注册事务提交后异步式触发奖励;失败只记录日志,不回滚用户注册。
registrationRewardIssuer RegistrationRewardIssuer registrationRewardIssuer RegistrationRewardIssuer
// registrationLevelBadgeIssuer 在注册事务提交后补发三条等级轨道的 1 级徽章。
registrationLevelBadgeIssuer RegistrationLevelBadgeIssuer
// imAccountImporter 在用户创建成功后补齐腾讯 IM 账号UserSig 入口仍会兜底补偿历史账号。 // imAccountImporter 在用户创建成功后补齐腾讯 IM 账号UserSig 入口仍会兜底补偿历史账号。
imAccountImporter IMAccountImporter imAccountImporter IMAccountImporter
} }
@ -282,6 +297,15 @@ func WithRegistrationRewardIssuer(issuer RegistrationRewardIssuer) Option {
} }
} }
// WithRegistrationLevelBadgeIssuer 注入注册等级徽章发放 clientnil 表示本环境不启用该集成。
func WithRegistrationLevelBadgeIssuer(issuer RegistrationLevelBadgeIssuer) Option {
return func(s *Service) {
if issuer != nil {
s.registrationLevelBadgeIssuer = issuer
}
}
}
// WithIMAccountImporter 注入腾讯 IM 账号导入边界nil 表示当前环境不触发真实 IM 副作用。 // WithIMAccountImporter 注入腾讯 IM 账号导入边界nil 表示当前环境不触发真实 IM 副作用。
func WithIMAccountImporter(importer IMAccountImporter) Option { func WithIMAccountImporter(importer IMAccountImporter) Option {
return func(s *Service) { return func(s *Service) {

View File

@ -177,6 +177,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
s.importIMAccountAfterCommit(ctx, user, meta) s.importIMAccountAfterCommit(ctx, user, meta)
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(provider), registration.Platform, registration.Language, registration.Timezone) s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(provider), registration.Platform, registration.Language, registration.Timezone)
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta) s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta)
s.issueRegistrationLevelBadgesAfterCommit(ctx, registration.AppCode, user.UserID, meta)
return token, true, nil return token, true, nil
} }
if xerr.IsCode(err, xerr.DisplayUserIDExists) { if xerr.IsCode(err, xerr.DisplayUserIDExists) {
@ -225,6 +226,27 @@ func (s *Service) issueRegistrationRewardAfterCommit(ctx context.Context, appCod
} }
} }
func (s *Service) issueRegistrationLevelBadgesAfterCommit(ctx context.Context, appCode string, userID int64, meta Meta) {
if s.registrationLevelBadgeIssuer == nil {
return
}
commandID := fmt.Sprintf("registration_level_badges:%s:%d", appcode.Normalize(appCode), userID)
if err := s.registrationLevelBadgeIssuer.IssueRegistrationLevelBadges(ctx, RegistrationLevelBadgeCommand{
AppCode: appcode.Normalize(appCode),
RequestID: meta.RequestID,
UserID: userID,
CommandID: commandID,
}); err != nil {
// 等级徽章是注册后置权益;用户注册事务已经提交,只能依赖同 command_id 重试或补发修复。
logx.Warn(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", appcode.Normalize(appCode))), "registration_level_badges_issue_failed",
slog.String("component", "user_auth"),
slog.Int64("user_id", userID),
slog.String("command_id", commandID),
slog.String("error", err.Error()),
)
}
}
func normalizeThirdPartyRegistration(registration authdomain.ThirdPartyRegistration, meta Meta) authdomain.ThirdPartyRegistration { func normalizeThirdPartyRegistration(registration authdomain.ThirdPartyRegistration, meta Meta) authdomain.ThirdPartyRegistration {
// 注册资料来自公网入口,进入领域前统一裁剪空白,避免数据库里出现不可见差异。 // 注册资料来自公网入口,进入领域前统一裁剪空白,避免数据库里出现不可见差异。
registration.Username = strings.TrimSpace(registration.Username) registration.Username = strings.TrimSpace(registration.Username)

View File

@ -38,6 +38,10 @@ rocketmq:
enabled: true enabled: true
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"
producer_group: "hyapp-wallet-outbox-producer" producer_group: "hyapp-wallet-outbox-producer"
realtime_outbox:
enabled: true
topic: "hyapp_wallet_realtime_outbox"
producer_group: "hyapp-wallet-realtime-outbox-producer"
google_play: google_play:
enabled: false enabled: false
package_name: "" package_name: ""
@ -50,8 +54,22 @@ google_play:
outbox_worker: outbox_worker:
enabled: true enabled: true
poll_interval: "1s" poll_interval: "1s"
batch_size: 100 batch_size: 200
concurrency: 4
publish_timeout: "3s" publish_timeout: "3s"
max_retry_count: 10 max_retry_count: 10
initial_backoff: "5s" initial_backoff: "5s"
max_backoff: "5m" max_backoff: "5m"
realtime_outbox_worker:
enabled: true
poll_interval: "500ms"
batch_size: 50
concurrency: 2
publish_timeout: "3s"
max_retry_count: 10
initial_backoff: "2s"
max_backoff: "1m"
event_types:
- "WalletRedPacketCreated"
- "WalletRedPacketClaimed"
- "WalletRedPacketRefunded"

View File

@ -38,6 +38,10 @@ rocketmq:
enabled: true enabled: true
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"
producer_group: "hyapp-wallet-outbox-producer" producer_group: "hyapp-wallet-outbox-producer"
realtime_outbox:
enabled: true
topic: "hyapp_wallet_realtime_outbox"
producer_group: "hyapp-wallet-realtime-outbox-producer"
google_play: google_play:
enabled: false enabled: false
package_name: "com.example.hyapp" package_name: "com.example.hyapp"
@ -50,8 +54,22 @@ google_play:
outbox_worker: outbox_worker:
enabled: true enabled: true
poll_interval: "1s" poll_interval: "1s"
batch_size: 100 batch_size: 200
concurrency: 8
publish_timeout: "3s" publish_timeout: "3s"
max_retry_count: 10 max_retry_count: 10
initial_backoff: "5s" initial_backoff: "5s"
max_backoff: "5m" max_backoff: "5m"
realtime_outbox_worker:
enabled: true
poll_interval: "500ms"
batch_size: 50
concurrency: 4
publish_timeout: "3s"
max_retry_count: 10
initial_backoff: "2s"
max_backoff: "1m"
event_types:
- "WalletRedPacketCreated"
- "WalletRedPacketClaimed"
- "WalletRedPacketRefunded"

View File

@ -38,6 +38,10 @@ rocketmq:
enabled: false enabled: false
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"
producer_group: "hyapp-wallet-outbox-producer" producer_group: "hyapp-wallet-outbox-producer"
realtime_outbox:
enabled: false
topic: "hyapp_wallet_realtime_outbox"
producer_group: "hyapp-wallet-realtime-outbox-producer"
google_play: google_play:
enabled: false enabled: false
package_name: "" package_name: ""
@ -51,7 +55,21 @@ outbox_worker:
enabled: false enabled: false
poll_interval: "1s" poll_interval: "1s"
batch_size: 100 batch_size: 100
concurrency: 1
publish_timeout: "3s" publish_timeout: "3s"
max_retry_count: 10 max_retry_count: 10
initial_backoff: "5s" initial_backoff: "5s"
max_backoff: "5m" max_backoff: "5m"
realtime_outbox_worker:
enabled: false
poll_interval: "500ms"
batch_size: 50
concurrency: 2
publish_timeout: "3s"
max_retry_count: 10
initial_backoff: "2s"
max_backoff: "1m"
event_types:
- "WalletRedPacketCreated"
- "WalletRedPacketClaimed"
- "WalletRedPacketRefunded"

View File

@ -75,6 +75,8 @@ CREATE TABLE IF NOT EXISTS wallet_outbox (
PRIMARY KEY (app_code, event_id), PRIMARY KEY (app_code, event_id),
KEY idx_wallet_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id), KEY idx_wallet_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_wallet_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id), KEY idx_wallet_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_wallet_outbox_event_pending (app_code, status, event_type, next_retry_at_ms, created_at_ms, event_id),
KEY idx_wallet_outbox_event_claim (app_code, status, event_type, lock_until_ms, created_at_ms, event_id),
KEY idx_wallet_outbox_retention (app_code, status, updated_at_ms, event_id), KEY idx_wallet_outbox_retention (app_code, status, updated_at_ms, event_id),
KEY idx_wallet_outbox_event_created (app_code, event_type, created_at_ms, event_id), KEY idx_wallet_outbox_event_created (app_code, event_type, created_at_ms, event_id),
KEY idx_wallet_outbox_asset_event_created (app_code, asset_type, event_type, created_at_ms, event_id), KEY idx_wallet_outbox_asset_event_created (app_code, asset_type, event_type, created_at_ms, event_id),
@ -346,6 +348,25 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt; EXECUTE stmt;
DEALLOCATE PREPARE stmt; DEALLOCATE PREPARE stmt;
-- 实时红包/房间 UI 事件按 event_type 独立 claim缺这个索引会在通用账务 backlog 上扫描,仍然可能延迟弹窗。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_event_pending') = 0,
'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_event_pending (app_code, status, event_type, next_retry_at_ms, created_at_ms, event_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_event_claim') = 0,
'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_event_claim (app_code, status, event_type, lock_until_ms, created_at_ms, event_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF( SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_retention') = 0, (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_outbox' AND INDEX_NAME = 'idx_wallet_outbox_retention') = 0,
'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_retention (app_code, status, updated_at_ms, event_id)', 'ALTER TABLE wallet_outbox ADD INDEX idx_wallet_outbox_retention (app_code, status, updated_at_ms, event_id)',

View File

@ -31,23 +31,26 @@ import (
// App 装配 wallet-service gRPC 入口。 // App 装配 wallet-service gRPC 入口。
type App struct { type App struct {
server *grpc.Server server *grpc.Server
listener net.Listener listener net.Listener
health *grpchealth.ServingChecker health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server healthHTTP *healthhttp.Server
mysqlRepo *mysqlstorage.Repository mysqlRepo *mysqlstorage.Repository
walletSvc *walletservice.Service walletSvc *walletservice.Service
activityConn *grpc.ClientConn activityConn *grpc.ClientConn
outboxProducer *rocketmqx.Producer outboxProducer *rocketmqx.Producer
projectionConsumer *rocketmqx.Consumer realtimeOutboxProducer *rocketmqx.Producer
outboxWorkerCfg config.OutboxWorkerConfig projectionConsumer *rocketmqx.Consumer
projectionWorkerCfg config.ProjectionWorkerConfig outboxWorkerCfg config.OutboxWorkerConfig
walletOutboxTopic string realtimeOutboxWorkerCfg config.OutboxWorkerConfig
nodeID string projectionWorkerCfg config.ProjectionWorkerConfig
redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig walletOutboxTopic string
stopWorker context.CancelFunc realtimeWalletOutboxTopic string
workers sync.WaitGroup nodeID string
closeOnce sync.Once redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig
stopWorker context.CancelFunc
workers sync.WaitGroup
closeOnce sync.Once
} }
// New 初始化 wallet-service。 // New 初始化 wallet-service。
@ -74,7 +77,7 @@ func New(cfg config.Config) (*App, error) {
} }
var outboxProducer *rocketmqx.Producer var outboxProducer *rocketmqx.Producer
if cfg.RocketMQ.WalletOutbox.Enabled { if cfg.RocketMQ.WalletOutbox.Enabled {
outboxProducer, err = rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ)) outboxProducer, err = rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ProducerGroup))
if err != nil { if err != nil {
_ = activityConn.Close() _ = activityConn.Close()
_ = listener.Close() _ = listener.Close()
@ -82,15 +85,24 @@ func New(cfg config.Config) (*App, error) {
return nil, err return nil, err
} }
} }
var realtimeOutboxProducer *rocketmqx.Producer
if cfg.RocketMQ.RealtimeOutbox.Enabled {
realtimeOutboxProducer, err = rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.RealtimeOutbox.ProducerGroup))
if err != nil {
shutdownProducers(outboxProducer)
_ = activityConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, err
}
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("wallet-service"))) server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("wallet-service")))
svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn)) svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn))
if cfg.GooglePlay.Enabled { if cfg.GooglePlay.Enabled {
googleClient, err := googleplay.New(cfg.GooglePlay) googleClient, err := googleplay.New(cfg.GooglePlay)
if err != nil { if err != nil {
if outboxProducer != nil { shutdownProducers(outboxProducer, realtimeOutboxProducer)
_ = outboxProducer.Shutdown()
}
_ = activityConn.Close() _ = activityConn.Close()
_ = listener.Close() _ = listener.Close()
_ = repository.Close() _ = repository.Close()
@ -107,9 +119,7 @@ func New(cfg config.Config) (*App, error) {
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil { if err != nil {
if outboxProducer != nil { shutdownProducers(outboxProducer, realtimeOutboxProducer)
_ = outboxProducer.Shutdown()
}
_ = activityConn.Close() _ = activityConn.Close()
_ = listener.Close() _ = listener.Close()
_ = repository.Close() _ = repository.Close()
@ -117,9 +127,7 @@ func New(cfg config.Config) (*App, error) {
} }
projectionConsumer, err := newWalletProjectionConsumer(cfg, svc) projectionConsumer, err := newWalletProjectionConsumer(cfg, svc)
if err != nil { if err != nil {
if outboxProducer != nil { shutdownProducers(outboxProducer, realtimeOutboxProducer)
_ = outboxProducer.Shutdown()
}
_ = activityConn.Close() _ = activityConn.Close()
_ = listener.Close() _ = listener.Close()
_ = repository.Close() _ = repository.Close()
@ -127,20 +135,23 @@ func New(cfg config.Config) (*App, error) {
} }
return &App{ return &App{
server: server, server: server,
listener: listener, listener: listener,
health: health, health: health,
healthHTTP: healthHTTP, healthHTTP: healthHTTP,
mysqlRepo: repository, mysqlRepo: repository,
walletSvc: svc, walletSvc: svc,
activityConn: activityConn, activityConn: activityConn,
outboxProducer: outboxProducer, outboxProducer: outboxProducer,
projectionConsumer: projectionConsumer, realtimeOutboxProducer: realtimeOutboxProducer,
outboxWorkerCfg: cfg.OutboxWorker, projectionConsumer: projectionConsumer,
projectionWorkerCfg: cfg.ProjectionWorker, outboxWorkerCfg: cfg.OutboxWorker,
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic, realtimeOutboxWorkerCfg: cfg.RealtimeOutboxWorker,
nodeID: cfg.NodeID, projectionWorkerCfg: cfg.ProjectionWorker,
redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker, walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,
nodeID: cfg.NodeID,
redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker,
}, nil }, nil
} }
@ -189,12 +200,16 @@ func (a *App) runBackgroundWorkers() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
a.stopWorker = cancel a.stopWorker = cancel
if a.outboxWorkerCfg.Enabled && a.outboxProducer != nil { if a.outboxWorkerCfg.Enabled && a.outboxProducer != nil {
outboxWorkerID := "wallet-outbox-" + a.nodeID excludedRealtimeTypes := []string(nil)
a.workers.Add(1) if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil {
go func() { // 普通账务 worker 在实时通道可用时主动跳过红包 UI 事件,避免两个 worker 抢同一行,
defer a.workers.Done() // 也避免红包事实继续排在 WalletBalanceChanged 和礼物流水之后。
a.runWalletOutboxWorker(ctx, outboxWorkerID) excludedRealtimeTypes = a.realtimeOutboxWorkerCfg.EventTypes
}() }
a.startWalletOutboxWorkers(ctx, "wallet-outbox", a.outboxWorkerCfg, a.outboxProducer, a.walletOutboxTopic, nil, excludedRealtimeTypes)
}
if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil {
a.startWalletOutboxWorkers(ctx, "wallet-realtime-outbox", a.realtimeOutboxWorkerCfg, a.realtimeOutboxProducer, a.realtimeWalletOutboxTopic, a.realtimeOutboxWorkerCfg.EventTypes, nil)
} }
if a.redPacketExpiryWorkerCfg.Enabled { if a.redPacketExpiryWorkerCfg.Enabled {
redPacketWorkerID := "red-packet-expiry-" + a.nodeID redPacketWorkerID := "red-packet-expiry-" + a.nodeID
@ -228,12 +243,25 @@ func (a *App) runBackgroundWorkers() {
} }
} }
func (a *App) runWalletOutboxWorker(ctx context.Context, workerID string) { func (a *App) startWalletOutboxWorkers(ctx context.Context, workerName string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) {
options := a.outboxWorkerCfg if options.Concurrency <= 0 {
options.Concurrency = 1
}
for workerIndex := 1; workerIndex <= options.Concurrency; workerIndex++ {
workerID := fmt.Sprintf("%s-%s-%02d", workerName, a.nodeID, workerIndex)
a.workers.Add(1)
go func() {
defer a.workers.Done()
a.runWalletOutboxWorker(ctx, workerID, options, producer, topic, includeEventTypes, excludeEventTypes)
}()
}
}
func (a *App) runWalletOutboxWorker(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) {
ticker := time.NewTicker(options.PollInterval) ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop() defer ticker.Stop()
for { for {
processed, err := a.processWalletOutboxBatch(ctx, workerID, options) processed, err := a.processWalletOutboxBatch(ctx, workerID, options, producer, topic, includeEventTypes, excludeEventTypes)
if err != nil && !errors.Is(err, context.Canceled) { if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "wallet_outbox_publish_failed", err, slog.String("worker_id", workerID)) logx.Error(ctx, "wallet_outbox_publish_failed", err, slog.String("worker_id", workerID))
} }
@ -248,11 +276,14 @@ func (a *App) runWalletOutboxWorker(ctx context.Context, workerID string) {
} }
} }
func (a *App) processWalletOutboxBatch(ctx context.Context, workerID string, options config.OutboxWorkerConfig) (int, error) { func (a *App) processWalletOutboxBatch(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) (int, error) {
if a.mysqlRepo == nil || a.outboxProducer == nil { if a.mysqlRepo == nil || producer == nil {
return 0, nil return 0, nil
} }
records, err := a.mysqlRepo.ClaimPendingWalletOutbox(ctx, workerID, options.BatchSize, time.Now().UTC().Add(options.PublishTimeout).UnixMilli()) records, err := a.mysqlRepo.ClaimPendingWalletOutboxFiltered(ctx, workerID, options.BatchSize, time.Now().UTC().Add(options.PublishTimeout).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{
IncludeEventTypes: includeEventTypes,
ExcludeEventTypes: excludeEventTypes,
})
if err != nil { if err != nil {
return 0, err return 0, err
} }
@ -267,7 +298,7 @@ func (a *App) processWalletOutboxBatch(ctx context.Context, workerID string, opt
continue continue
} }
publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
err := a.publishWalletOutboxRecord(publishCtx, record) err := a.publishWalletOutboxRecord(publishCtx, producer, topic, record)
cancel() cancel()
if err != nil { if err != nil {
nextRetryAtMS := time.Now().UTC().Add(walletOutboxBackoff(record.RetryCount+1, options)).UnixMilli() nextRetryAtMS := time.Now().UTC().Add(walletOutboxBackoff(record.RetryCount+1, options)).UnixMilli()
@ -289,7 +320,7 @@ func (a *App) processWalletOutboxBatch(ctx context.Context, workerID string, opt
return len(records), nil return len(records), nil
} }
func (a *App) publishWalletOutboxRecord(ctx context.Context, record mysqlstorage.WalletOutboxRecord) error { func (a *App) publishWalletOutboxRecord(ctx context.Context, producer *rocketmqx.Producer, topic string, record mysqlstorage.WalletOutboxRecord) error {
body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{ body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
AppCode: record.AppCode, AppCode: record.AppCode,
EventID: record.EventID, EventID: record.EventID,
@ -306,8 +337,8 @@ func (a *App) publishWalletOutboxRecord(ctx context.Context, record mysqlstorage
if err != nil { if err != nil {
return err return err
} }
return a.outboxProducer.SendSync(ctx, rocketmqx.Message{ return producer.SendSync(ctx, rocketmqx.Message{
Topic: a.walletOutboxTopic, Topic: topic,
Tag: walletmq.TagWalletOutboxEvent, Tag: walletmq.TagWalletOutboxEvent,
Keys: []string{record.EventID, record.TransactionID, record.CommandID}, Keys: []string{record.EventID, record.TransactionID, record.CommandID},
Body: body, Body: body,
@ -349,14 +380,20 @@ func (a *App) startMQ() error {
return err return err
} }
} }
if a.projectionConsumer != nil { if a.realtimeOutboxProducer != nil {
if err := a.projectionConsumer.Start(); err != nil { if err := a.realtimeOutboxProducer.Start(); err != nil {
if a.outboxProducer != nil { if a.outboxProducer != nil {
_ = a.outboxProducer.Shutdown() _ = a.outboxProducer.Shutdown()
} }
return err return err
} }
} }
if a.projectionConsumer != nil {
if err := a.projectionConsumer.Start(); err != nil {
shutdownProducers(a.outboxProducer, a.realtimeOutboxProducer)
return err
}
}
return nil return nil
} }
@ -371,6 +408,11 @@ func (a *App) shutdownMQ() {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error())) logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
} }
} }
if a.realtimeOutboxProducer != nil {
if err := a.realtimeOutboxProducer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_realtime_producer_shutdown_failed", slog.String("error", err.Error()))
}
}
} }
func newWalletProjectionConsumer(cfg config.Config, svc *walletservice.Service) (*rocketmqx.Consumer, error) { func newWalletProjectionConsumer(cfg config.Config, svc *walletservice.Service) (*rocketmqx.Consumer, error) {
@ -413,7 +455,7 @@ func rocketMQProjectionConsumerConfig(cfg config.RocketMQConfig, projection conf
} }
} }
func rocketMQProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig { func rocketMQProducerConfig(cfg config.RocketMQConfig, groupName string) rocketmqx.ProducerConfig {
return rocketmqx.ProducerConfig{ return rocketmqx.ProducerConfig{
EndpointConfig: rocketmqx.EndpointConfig{ EndpointConfig: rocketmqx.EndpointConfig{
NameServers: cfg.NameServers, NameServers: cfg.NameServers,
@ -424,12 +466,20 @@ func rocketMQProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig
Namespace: cfg.Namespace, Namespace: cfg.Namespace,
VIPChannel: cfg.VIPChannel, VIPChannel: cfg.VIPChannel,
}, },
GroupName: cfg.WalletOutbox.ProducerGroup, GroupName: groupName,
SendTimeout: cfg.SendTimeout, SendTimeout: cfg.SendTimeout,
Retry: cfg.Retry, Retry: cfg.Retry,
} }
} }
func shutdownProducers(producers ...*rocketmqx.Producer) {
for _, producer := range producers {
if producer != nil {
_ = producer.Shutdown()
}
}
}
func (a *App) runHealthHTTP() { func (a *App) runHealthHTTP() {
if a.healthHTTP == nil { if a.healthHTTP == nil {
return return

View File

@ -29,6 +29,8 @@ type Config struct {
GooglePlay GooglePlayConfig `yaml:"google_play"` GooglePlay GooglePlayConfig `yaml:"google_play"`
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。 // OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"` OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
// RealtimeOutboxWorker 控制红包等实时 UI 事件到独立 MQ topic 的补偿投递。
RealtimeOutboxWorker OutboxWorkerConfig `yaml:"realtime_outbox_worker"`
// ProjectionWorker 控制 wallet_outbox MQ 到本服务读模型的投影消费。 // ProjectionWorker 控制 wallet_outbox MQ 到本服务读模型的投影消费。
ProjectionWorker ProjectionWorkerConfig `yaml:"projection_worker"` ProjectionWorker ProjectionWorkerConfig `yaml:"projection_worker"`
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。 // MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
@ -57,6 +59,7 @@ type RocketMQConfig struct {
SendTimeout time.Duration `yaml:"send_timeout"` SendTimeout time.Duration `yaml:"send_timeout"`
Retry int `yaml:"retry"` Retry int `yaml:"retry"`
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"` WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
RealtimeOutbox WalletOutboxMQConfig `yaml:"realtime_outbox"`
} }
// WalletOutboxMQConfig 保存 wallet_outbox topic 的生产者配置。 // WalletOutboxMQConfig 保存 wallet_outbox topic 的生产者配置。
@ -71,10 +74,12 @@ type OutboxWorkerConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
PollInterval time.Duration `yaml:"poll_interval"` PollInterval time.Duration `yaml:"poll_interval"`
BatchSize int `yaml:"batch_size"` BatchSize int `yaml:"batch_size"`
Concurrency int `yaml:"concurrency"`
PublishTimeout time.Duration `yaml:"publish_timeout"` PublishTimeout time.Duration `yaml:"publish_timeout"`
MaxRetryCount int `yaml:"max_retry_count"` MaxRetryCount int `yaml:"max_retry_count"`
InitialBackoff time.Duration `yaml:"initial_backoff"` InitialBackoff time.Duration `yaml:"initial_backoff"`
MaxBackoff time.Duration `yaml:"max_backoff"` MaxBackoff time.Duration `yaml:"max_backoff"`
EventTypes []string `yaml:"event_types"`
} }
// ProjectionWorkerConfig 控制礼物墙和 badge 展示读模型从 wallet_outbox MQ 投影的节奏。 // ProjectionWorkerConfig 控制礼物墙和 badge 展示读模型从 wallet_outbox MQ 投影的节奏。
@ -127,11 +132,23 @@ func Default() Config {
Enabled: false, Enabled: false,
PollInterval: time.Second, PollInterval: time.Second,
BatchSize: 100, BatchSize: 100,
Concurrency: 1,
PublishTimeout: 3 * time.Second, PublishTimeout: 3 * time.Second,
MaxRetryCount: 10, MaxRetryCount: 10,
InitialBackoff: 5 * time.Second, InitialBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute, MaxBackoff: 5 * time.Minute,
}, },
RealtimeOutboxWorker: OutboxWorkerConfig{
Enabled: false,
PollInterval: time.Second,
BatchSize: 50,
Concurrency: 2,
PublishTimeout: 3 * time.Second,
MaxRetryCount: 10,
InitialBackoff: 2 * time.Second,
MaxBackoff: time.Minute,
EventTypes: defaultRealtimeOutboxEventTypes(),
},
ProjectionWorker: ProjectionWorkerConfig{ ProjectionWorker: ProjectionWorkerConfig{
Enabled: false, Enabled: false,
PollInterval: 60 * time.Second, PollInterval: 60 * time.Second,
@ -159,6 +176,11 @@ func defaultRocketMQConfig() RocketMQConfig {
Topic: "hyapp_wallet_outbox", Topic: "hyapp_wallet_outbox",
ProducerGroup: "hyapp-wallet-outbox-producer", ProducerGroup: "hyapp-wallet-outbox-producer",
}, },
RealtimeOutbox: WalletOutboxMQConfig{
Enabled: false,
Topic: "hyapp_wallet_realtime_outbox",
ProducerGroup: "hyapp-wallet-realtime-outbox-producer",
},
} }
} }
@ -230,30 +252,17 @@ func Load(path string) (Config, error) {
return Config{}, errors.New("google_play service account is required") return Config{}, errors.New("google_play service account is required")
} }
} }
if cfg.OutboxWorker.PollInterval <= 0 { cfg.OutboxWorker = normalizeOutboxWorkerConfig(cfg.OutboxWorker, Default().OutboxWorker)
cfg.OutboxWorker.PollInterval = Default().OutboxWorker.PollInterval
}
if cfg.OutboxWorker.BatchSize <= 0 {
cfg.OutboxWorker.BatchSize = Default().OutboxWorker.BatchSize
}
if cfg.OutboxWorker.PublishTimeout <= 0 {
cfg.OutboxWorker.PublishTimeout = Default().OutboxWorker.PublishTimeout
}
if cfg.OutboxWorker.MaxRetryCount <= 0 {
cfg.OutboxWorker.MaxRetryCount = Default().OutboxWorker.MaxRetryCount
}
if cfg.OutboxWorker.InitialBackoff <= 0 {
cfg.OutboxWorker.InitialBackoff = Default().OutboxWorker.InitialBackoff
}
if cfg.OutboxWorker.MaxBackoff <= 0 {
cfg.OutboxWorker.MaxBackoff = Default().OutboxWorker.MaxBackoff
}
if cfg.OutboxWorker.MaxBackoff < cfg.OutboxWorker.InitialBackoff {
cfg.OutboxWorker.MaxBackoff = cfg.OutboxWorker.InitialBackoff
}
if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled { if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.enabled") return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.enabled")
} }
cfg.RealtimeOutboxWorker = normalizeOutboxWorkerConfig(cfg.RealtimeOutboxWorker, Default().RealtimeOutboxWorker)
if len(cfg.RealtimeOutboxWorker.EventTypes) == 0 {
cfg.RealtimeOutboxWorker.EventTypes = defaultRealtimeOutboxEventTypes()
}
if cfg.RealtimeOutboxWorker.Enabled && !cfg.RocketMQ.RealtimeOutbox.Enabled {
return Config{}, errors.New("realtime_outbox_worker requires rocketmq.realtime_outbox.enabled")
}
if cfg.ProjectionWorker.PollInterval <= 0 { if cfg.ProjectionWorker.PollInterval <= 0 {
cfg.ProjectionWorker.PollInterval = Default().ProjectionWorker.PollInterval cfg.ProjectionWorker.PollInterval = Default().ProjectionWorker.PollInterval
} }
@ -299,7 +308,13 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.WalletOutbox.ProducerGroup = strings.TrimSpace(cfg.WalletOutbox.ProducerGroup); cfg.WalletOutbox.ProducerGroup == "" { if cfg.WalletOutbox.ProducerGroup = strings.TrimSpace(cfg.WalletOutbox.ProducerGroup); cfg.WalletOutbox.ProducerGroup == "" {
cfg.WalletOutbox.ProducerGroup = defaults.WalletOutbox.ProducerGroup cfg.WalletOutbox.ProducerGroup = defaults.WalletOutbox.ProducerGroup
} }
if cfg.WalletOutbox.Enabled { if cfg.RealtimeOutbox.Topic = strings.TrimSpace(cfg.RealtimeOutbox.Topic); cfg.RealtimeOutbox.Topic == "" {
cfg.RealtimeOutbox.Topic = defaults.RealtimeOutbox.Topic
}
if cfg.RealtimeOutbox.ProducerGroup = strings.TrimSpace(cfg.RealtimeOutbox.ProducerGroup); cfg.RealtimeOutbox.ProducerGroup == "" {
cfg.RealtimeOutbox.ProducerGroup = defaults.RealtimeOutbox.ProducerGroup
}
if cfg.WalletOutbox.Enabled || cfg.RealtimeOutbox.Enabled {
cfg.Enabled = true cfg.Enabled = true
} }
if cfg.Enabled { if cfg.Enabled {
@ -313,12 +328,60 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
return cfg, nil return cfg, nil
} }
func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig, defaults OutboxWorkerConfig) OutboxWorkerConfig {
if cfg.PollInterval <= 0 {
cfg.PollInterval = defaults.PollInterval
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = defaults.BatchSize
}
if cfg.BatchSize > 500 {
cfg.BatchSize = 500
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = defaults.Concurrency
}
if cfg.Concurrency > 64 {
cfg.Concurrency = 64
}
if cfg.PublishTimeout <= 0 {
cfg.PublishTimeout = defaults.PublishTimeout
}
if cfg.MaxRetryCount <= 0 {
cfg.MaxRetryCount = defaults.MaxRetryCount
}
if cfg.InitialBackoff <= 0 {
cfg.InitialBackoff = defaults.InitialBackoff
}
if cfg.MaxBackoff <= 0 {
cfg.MaxBackoff = defaults.MaxBackoff
}
if cfg.MaxBackoff < cfg.InitialBackoff {
cfg.MaxBackoff = cfg.InitialBackoff
}
cfg.EventTypes = normalizeStringSlice(cfg.EventTypes)
return cfg
}
func normalizeStringSlice(values []string) []string { func normalizeStringSlice(values []string) []string {
normalized := make([]string, 0, len(values)) normalized := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values { for _, value := range values {
if value = strings.TrimSpace(value); value != "" { if value = strings.TrimSpace(value); value != "" {
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value) normalized = append(normalized, value)
} }
} }
return normalized return normalized
} }
func defaultRealtimeOutboxEventTypes() []string {
return []string{
"WalletRedPacketCreated",
"WalletRedPacketClaimed",
"WalletRedPacketRefunded",
}
}

View File

@ -0,0 +1,38 @@
package config
import "testing"
func TestLoadLocalKeepsOutboxMQDisabled(t *testing.T) {
cfg, err := Load("../../configs/config.yaml")
if err != nil {
t.Fatalf("Load local config failed: %v", err)
}
if cfg.RocketMQ.Enabled || cfg.RocketMQ.WalletOutbox.Enabled || cfg.RocketMQ.RealtimeOutbox.Enabled {
t.Fatalf("local config must not require RocketMQ: %+v", cfg.RocketMQ)
}
if cfg.OutboxWorker.Concurrency != 1 || cfg.RealtimeOutboxWorker.Concurrency != 2 {
t.Fatalf("local outbox worker defaults mismatch: outbox=%+v realtime=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker)
}
}
func TestLoadTencentExampleSplitsRealtimeOutboxAndRaisesThroughput(t *testing.T) {
cfg, err := Load("../../configs/config.tencent.example.yaml")
if err != nil {
t.Fatalf("Load tencent example failed: %v", err)
}
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.WalletOutbox.Enabled || !cfg.RocketMQ.RealtimeOutbox.Enabled {
t.Fatalf("tencent example must enable both wallet outbox producers: %+v", cfg.RocketMQ)
}
if cfg.RocketMQ.WalletOutbox.Topic == cfg.RocketMQ.RealtimeOutbox.Topic {
t.Fatalf("realtime outbox topic must be separated from generic wallet topic: %+v", cfg.RocketMQ)
}
if cfg.OutboxWorker.BatchSize != 200 || cfg.OutboxWorker.Concurrency != 8 {
t.Fatalf("generic outbox throughput config mismatch: %+v", cfg.OutboxWorker)
}
if cfg.RealtimeOutboxWorker.BatchSize != 50 || cfg.RealtimeOutboxWorker.Concurrency != 4 {
t.Fatalf("realtime outbox worker config mismatch: %+v", cfg.RealtimeOutboxWorker)
}
if len(cfg.RealtimeOutboxWorker.EventTypes) != 3 || cfg.RealtimeOutboxWorker.EventTypes[0] != "WalletRedPacketCreated" {
t.Fatalf("realtime outbox event types mismatch: %+v", cfg.RealtimeOutboxWorker.EventTypes)
}
}

View File

@ -1212,6 +1212,67 @@ func TestRedPacketLifecycleUsesRealMySQL(t *testing.T) {
} }
} }
func TestWalletOutboxClaimSeparatesRealtimeEventTypes(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(19101, 1000)
svc := walletservice.New(repository)
if _, err := svc.UpdateRedPacketConfig(context.Background(), ledger.RedPacketConfig{
AppCode: "lalu",
Enabled: true,
CountTiers: []int32{5},
AmountTiers: []int64{10},
DelayedOpenSeconds: 1,
ExpireSeconds: 2,
DailySendLimit: 5,
UpdatedByAdminID: 9001,
}); err != nil {
t.Fatalf("UpdateRedPacketConfig failed: %v", err)
}
receipt, err := svc.CreateRedPacket(context.Background(), ledger.RedPacketCreateCommand{
AppCode: "lalu",
CommandID: "cmd-red-packet-realtime-outbox",
SenderUserID: 19101,
RoomID: "room-191",
RegionID: 191,
PacketType: ledger.RedPacketTypeNormal,
TotalAmount: 10,
PacketCount: 5,
})
if err != nil {
t.Fatalf("CreateRedPacket failed: %v", err)
}
ctx := appcode.WithContext(context.Background(), "lalu")
realtimeTypes := []string{"WalletRedPacketCreated", "WalletRedPacketClaimed", "WalletRedPacketRefunded"}
realtimeRecords, err := repository.ClaimPendingWalletOutboxFiltered(ctx, "wallet-realtime-outbox-test", 10, time.Now().Add(time.Minute).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{
IncludeEventTypes: realtimeTypes,
})
if err != nil {
t.Fatalf("ClaimPendingWalletOutboxFiltered realtime failed: %v", err)
}
if len(realtimeRecords) != 1 || realtimeRecords[0].EventType != "WalletRedPacketCreated" {
t.Fatalf("realtime claim should only pick red packet fact, got %+v", realtimeRecords)
}
normalRecords, err := repository.ClaimPendingWalletOutboxFiltered(ctx, "wallet-normal-outbox-test", 10, time.Now().Add(time.Minute).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{
ExcludeEventTypes: realtimeTypes,
})
if err != nil {
t.Fatalf("ClaimPendingWalletOutboxFiltered normal failed: %v", err)
}
if len(normalRecords) == 0 {
t.Fatalf("normal claim should still pick non-realtime wallet facts for packet %s", receipt.Packet.PacketID)
}
for _, record := range normalRecords {
if record.EventType == "WalletRedPacketCreated" || record.EventType == "WalletRedPacketClaimed" || record.EventType == "WalletRedPacketRefunded" {
t.Fatalf("normal claim must exclude realtime red packet fact: %+v", normalRecords)
}
}
if got := repository.CountRows("wallet_outbox", "event_id = ? AND status = ? AND worker_id = ?", realtimeRecords[0].EventID, "delivering", "wallet-realtime-outbox-test"); got != 1 {
t.Fatalf("realtime fact should be locked by realtime worker, got %d", got)
}
}
func TestWalletOutboxClaimUsesRealMySQLStatusFlow(t *testing.T) { func TestWalletOutboxClaimUsesRealMySQLStatusFlow(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
repository.SetAssetBalance(30001, ledger.AssetCoinSellerCoin, 1000) repository.SetAssetBalance(30001, ledger.AssetCoinSellerCoin, 1000)

View File

@ -68,6 +68,14 @@ type WalletOutboxRecord struct {
LockUntilMS int64 LockUntilMS int64
} }
// WalletOutboxClaimFilter narrows one MQ fanout lane by event type.
type WalletOutboxClaimFilter struct {
// IncludeEventTypes lets a realtime worker own only the configured UI event facts.
IncludeEventTypes []string
// ExcludeEventTypes lets the generic wallet worker skip facts owned by the realtime lane.
ExcludeEventTypes []string
}
// Open 创建 MySQL 连接池并做启动 ping。 // Open 创建 MySQL 连接池并做启动 ping。
func Open(ctx context.Context, dsn string) (*Repository, error) { func Open(ctx context.Context, dsn string) (*Repository, error) {
if strings.TrimSpace(dsn) == "" { if strings.TrimSpace(dsn) == "" {
@ -1893,6 +1901,11 @@ func (r *Repository) insertWalletOutbox(ctx context.Context, tx *sql.Tx, events
// ClaimPendingWalletOutbox atomically claims committed wallet facts for MQ fanout. // ClaimPendingWalletOutbox atomically claims committed wallet facts for MQ fanout.
func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]WalletOutboxRecord, error) { func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]WalletOutboxRecord, error) {
return r.ClaimPendingWalletOutboxFiltered(ctx, workerID, limit, lockUntilMS, WalletOutboxClaimFilter{})
}
// ClaimPendingWalletOutboxFiltered atomically claims committed wallet facts for one MQ fanout lane.
func (r *Repository) ClaimPendingWalletOutboxFiltered(ctx context.Context, workerID string, limit int, lockUntilMS int64, filter WalletOutboxClaimFilter) ([]WalletOutboxRecord, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
} }
@ -1912,6 +1925,8 @@ func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID stri
} }
appCode := appcode.FromContext(ctx) appCode := appcode.FromContext(ctx)
eventFilterSQL, eventFilterArgs := walletOutboxEventFilterSQL(filter)
pendingIndex, claimIndex := walletOutboxClaimIndexes(filter)
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1924,45 +1939,21 @@ func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID stri
args []any args []any
}{ }{
{ {
query: ` query: walletOutboxClaimQuery(pendingIndex, `
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms,
retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_pending)
WHERE app_code = ? AND status = ?
AND next_retry_at_ms IS NULL AND next_retry_at_ms IS NULL
AND lock_until_ms IS NULL AND lock_until_ms IS NULL`, eventFilterSQL),
ORDER BY created_at_ms ASC, event_id ASC args: append([]any{appCode, outboxStatusPending}, eventFilterArgs...),
LIMIT ?
FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outboxStatusPending},
}, },
{ {
query: ` query: walletOutboxClaimQuery(pendingIndex, `
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms,
retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_pending)
WHERE app_code = ? AND status = ?
AND next_retry_at_ms <= ? AND next_retry_at_ms <= ?
AND lock_until_ms IS NULL AND lock_until_ms IS NULL`, eventFilterSQL),
ORDER BY created_at_ms ASC, event_id ASC args: append([]any{appCode, outboxStatusRetryable, nowMS}, eventFilterArgs...),
LIMIT ?
FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outboxStatusRetryable, nowMS},
}, },
{ {
query: ` query: walletOutboxClaimQuery(claimIndex, `
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, AND lock_until_ms <= ?`, eventFilterSQL),
available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms, args: append([]any{appCode, outboxStatusDelivering, nowMS}, eventFilterArgs...),
retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_claim)
WHERE app_code = ? AND status = ?
AND lock_until_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ?
FOR UPDATE SKIP LOCKED`,
args: []any{appCode, outboxStatusDelivering, nowMS},
}, },
} }
for _, branch := range claimBranches { for _, branch := range claimBranches {
@ -2003,6 +1994,73 @@ func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID stri
return records, nil return records, nil
} }
func walletOutboxClaimQuery(indexName string, statusCondition string, eventFilterSQL string) string {
return `
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms,
retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
FROM wallet_outbox FORCE INDEX (` + indexName + `)
WHERE app_code = ? AND status = ?` + statusCondition + eventFilterSQL + `
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ?
FOR UPDATE SKIP LOCKED`
}
func walletOutboxEventFilterSQL(filter WalletOutboxClaimFilter) (string, []any) {
include := normalizeWalletOutboxEventTypes(filter.IncludeEventTypes)
if len(include) > 0 {
return " AND event_type IN (" + walletOutboxPlaceholders(len(include)) + ")", walletOutboxEventArgs(include)
}
exclude := normalizeWalletOutboxEventTypes(filter.ExcludeEventTypes)
if len(exclude) > 0 {
return " AND event_type NOT IN (" + walletOutboxPlaceholders(len(exclude)) + ")", walletOutboxEventArgs(exclude)
}
return "", nil
}
func walletOutboxClaimIndexes(filter WalletOutboxClaimFilter) (string, string) {
if len(normalizeWalletOutboxEventTypes(filter.IncludeEventTypes)) > 0 {
return "idx_wallet_outbox_event_pending", "idx_wallet_outbox_event_claim"
}
return "idx_wallet_outbox_pending", "idx_wallet_outbox_claim"
}
func normalizeWalletOutboxEventTypes(values []string) []string {
normalized := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
return normalized
}
func walletOutboxPlaceholders(count int) string {
if count <= 0 {
return ""
}
placeholders := make([]string, count)
for index := range placeholders {
placeholders[index] = "?"
}
return strings.Join(placeholders, ",")
}
func walletOutboxEventArgs(values []string) []any {
args := make([]any, 0, len(values))
for _, value := range values {
args = append(args, value)
}
return args
}
func queryWalletOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]WalletOutboxRecord, error) { func queryWalletOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]WalletOutboxRecord, error) {
rows, err := tx.QueryContext(ctx, query, args...) rows, err := tx.QueryContext(ctx, query, args...)
if err != nil { if err != nil {

View File

@ -0,0 +1,158 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"sort"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const registrationLevelBadgeReason = "growth_level_registration_badge"
func main() {
var appCode string
var activityDSN string
var userDSN string
var walletAddr string
var dryRun bool
var onlyUserID int64
flag.StringVar(&appCode, "app_code", "lalu", "app_code to backfill")
flag.StringVar(&activityDSN, "activity_dsn", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC", "activity mysql dsn")
flag.StringVar(&userDSN, "user_dsn", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC", "user mysql dsn")
flag.StringVar(&walletAddr, "wallet_addr", "127.0.0.1:13004", "wallet-service grpc address")
flag.BoolVar(&dryRun, "dry_run", true, "print planned grants without calling wallet-service")
flag.Int64Var(&onlyUserID, "user_id", 0, "optional single user_id")
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
activityDB, err := sql.Open("mysql", activityDSN)
if err != nil {
log.Fatalf("open activity db: %v", err)
}
defer activityDB.Close()
userDB, err := sql.Open("mysql", userDSN)
if err != nil {
log.Fatalf("open user db: %v", err)
}
defer userDB.Close()
badges, err := levelOneBadgeResources(ctx, activityDB, appCode)
if err != nil {
log.Fatalf("query level badges: %v", err)
}
users, err := targetUsers(ctx, userDB, appCode, onlyUserID)
if err != nil {
log.Fatalf("query users: %v", err)
}
tracks := []string{"wealth", "game", "charm"}
for _, track := range tracks {
if badges[track] <= 0 {
log.Printf("skip track=%s: level 1 long badge is not configured", track)
}
}
if dryRun {
log.Printf("dry_run=true app_code=%s users=%d badges=%v", appCode, len(users), badges)
return
}
conn, err := grpc.NewClient(walletAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("dial wallet-service: %v", err)
}
defer conn.Close()
wallet := walletv1.NewWalletServiceClient(conn)
total := 0
for _, userID := range users {
for _, track := range tracks {
resourceID := badges[track]
if resourceID <= 0 {
continue
}
// wallet-service 用 command_id 做完整请求幂等;同一个用户同一轨道重复执行只会返回首次发放结果。
commandID := fmt.Sprintf("registration_level_badges:%s:%d:%s", appCode, userID, track)
_, err := wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
CommandId: commandID,
AppCode: appCode,
TargetUserId: userID,
ResourceId: resourceID,
Quantity: 1,
DurationMs: 0,
Reason: registrationLevelBadgeReason,
OperatorUserId: userID,
GrantSource: "growth_level",
})
if err != nil {
log.Fatalf("grant failed user_id=%d track=%s resource_id=%d: %v", userID, track, resourceID, err)
}
total++
}
}
log.Printf("done app_code=%s users=%d grant_calls=%d", appCode, len(users), total)
}
func levelOneBadgeResources(ctx context.Context, db *sql.DB, appCode string) (map[string]int64, error) {
rows, err := db.QueryContext(ctx, `
SELECT track,
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(display_config_json, '$.long_badge_resource_id')) AS SIGNED), 0) AS resource_id
FROM growth_level_rules
WHERE app_code = ? AND status = 'active' AND level = 1
ORDER BY track ASC`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
result := map[string]int64{}
for rows.Next() {
var track string
var resourceID int64
if err := rows.Scan(&track, &resourceID); err != nil {
return nil, err
}
track = strings.ToLower(strings.TrimSpace(track))
if resourceID > 0 {
result[track] = resourceID
}
}
return result, rows.Err()
}
func targetUsers(ctx context.Context, db *sql.DB, appCode string, onlyUserID int64) ([]int64, error) {
if onlyUserID > 0 {
return []int64{onlyUserID}, nil
}
rows, err := db.QueryContext(ctx, `
SELECT user_id
FROM users
WHERE app_code = ? AND status = 'active'
ORDER BY user_id ASC`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]int64, 0)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, err
}
users = append(users, userID)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(users, func(i, j int) bool { return users[i] < users[j] })
return users, nil
}