相关装扮接口
This commit is contained in:
parent
3c7648f98e
commit
86ae6db83f
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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() }
|
||||||
|
|||||||
@ -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 在公屏前同步问房间业务态。
|
||||||
|
|||||||
@ -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` 播放入场座驾。
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
97
scripts/mysql/backfill_level_badge_metadata.sql
Normal file
97
scripts/mysql/backfill_level_badge_metadata.sql
Normal 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;
|
||||||
|
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 异步执行。
|
||||||
|
|||||||
@ -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/靓号用例,不进入房间高频流程。
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 注入注册等级徽章发放 client;nil 表示本环境不启用该集成。
|
||||||
|
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) {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)',
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
38
services/wallet-service/internal/config/config_test.go
Normal file
38
services/wallet-service/internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
158
tools/grant-registration-level-badges/main.go
Normal file
158
tools/grant-registration-level-badges/main.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user