Compare commits

...

6 Commits

Author SHA1 Message Date
zhx
636a0085d0 幸运礼物修复 2026-05-26 06:07:29 +08:00
zhx
e7b6ed9fb7 飘屏后端决定 2026-05-26 05:57:08 +08:00
zhx
113c5096e0 修改幸运礼物 2026-05-26 05:16:18 +08:00
zhx
db036098bc 修复报错信息 2026-05-26 04:16:58 +08:00
zhx
463c208640 币商列表 2026-05-26 03:40:01 +08:00
zhx
a68d465272 幸运礼物 2026-05-26 03:06:02 +08:00
95 changed files with 6281 additions and 1125 deletions

View File

@ -9944,19 +9944,23 @@ func (x *UpsertAchievementDefinitionResponse) GetCreated() bool {
}
type LuckyGiftMeta struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
DeviceId string `protobuf:"bytes,4,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
RoomId string `protobuf:"bytes,5,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
AnchorId string `protobuf:"bytes,6,opt,name=anchor_id,json=anchorId,proto3" json:"anchor_id,omitempty"`
GiftId string `protobuf:"bytes,7,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
CoinSpent int64 `protobuf:"varint,8,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"`
PaidAtMs int64 `protobuf:"varint,9,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"`
PoolId string `protobuf:"bytes,10,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
DeviceId string `protobuf:"bytes,4,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
RoomId string `protobuf:"bytes,5,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
AnchorId string `protobuf:"bytes,6,opt,name=anchor_id,json=anchorId,proto3" json:"anchor_id,omitempty"`
GiftId string `protobuf:"bytes,7,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
CoinSpent int64 `protobuf:"varint,8,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"`
PaidAtMs int64 `protobuf:"varint,9,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"`
PoolId string `protobuf:"bytes,10,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
TargetUserId int64 `protobuf:"varint,11,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
GiftCount int32 `protobuf:"varint,12,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
// visible_region_id 来自 room-service 的房间可见区域,用于中奖后的区域 IM 飘屏。
VisibleRegionId int64 `protobuf:"varint,13,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LuckyGiftMeta) Reset() {
@ -10059,6 +10063,27 @@ func (x *LuckyGiftMeta) GetPoolId() string {
return ""
}
func (x *LuckyGiftMeta) GetTargetUserId() int64 {
if x != nil {
return x.TargetUserId
}
return 0
}
func (x *LuckyGiftMeta) GetGiftCount() int32 {
if x != nil {
return x.GiftCount
}
return 0
}
func (x *LuckyGiftMeta) GetVisibleRegionId() int64 {
if x != nil {
return x.VisibleRegionId
}
return 0
}
type LuckyGiftTier struct {
state protoimpl.MessageState `protogen:"open.v1"`
Pool string `protobuf:"bytes,1,opt,name=pool,proto3" json:"pool,omitempty"`
@ -12599,7 +12624,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"conditions\"\x8b\x01\n" +
"#UpsertAchievementDefinitionResponse\x12J\n" +
"\vachievement\x18\x01 \x01(\v2(.hyapp.activity.v1.AchievementDefinitionR\vachievement\x12\x18\n" +
"\acreated\x18\x02 \x01(\bR\acreated\"\xbd\x02\n" +
"\acreated\x18\x02 \x01(\bR\acreated\"\xae\x03\n" +
"\rLuckyGiftMeta\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x1d\n" +
"\n" +
@ -12614,7 +12639,11 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\n" +
"paid_at_ms\x18\t \x01(\x03R\bpaidAtMs\x12\x17\n" +
"\apool_id\x18\n" +
" \x01(\tR\x06poolId\"\xe0\x01\n" +
" \x01(\tR\x06poolId\x12$\n" +
"\x0etarget_user_id\x18\v \x01(\x03R\ftargetUserId\x12\x1d\n" +
"\n" +
"gift_count\x18\f \x01(\x05R\tgiftCount\x12*\n" +
"\x11visible_region_id\x18\r \x01(\x03R\x0fvisibleRegionId\"\xe0\x01\n" +
"\rLuckyGiftTier\x12\x12\n" +
"\x04pool\x18\x01 \x01(\tR\x04pool\x12\x17\n" +
"\atier_id\x18\x02 \x01(\tR\x06tierId\x12!\n" +

View File

@ -1123,6 +1123,10 @@ message LuckyGiftMeta {
int64 coin_spent = 8;
int64 paid_at_ms = 9;
string pool_id = 10;
int64 target_user_id = 11;
int32 gift_count = 12;
// visible_region_id room-service IM
int64 visible_region_id = 13;
}
message LuckyGiftTier {

File diff suppressed because it is too large Load Diff

View File

@ -56,6 +56,24 @@ message CoinSellerProfile {
int64 updated_at_ms = 6;
}
// CoinSellerListItem App
message CoinSellerListItem {
int64 user_id = 1;
string display_user_id = 2;
string username = 3;
string avatar = 4;
int64 country_id = 5;
string country_code = 6;
string country_name = 7;
string country_display_name = 8;
int64 region_id = 9;
string region_code = 10;
string region_name = 11;
string status = 12;
string merchant_asset_type = 13;
int64 updated_at_ms = 14;
}
// UserRoleSummary App 使
// hostAgencymanagerBD user-service RPC gateway fanout
message UserRoleSummary {
@ -243,6 +261,15 @@ message GetCoinSellerProfileResponse {
CoinSellerProfile coin_seller_profile = 1;
}
message ListActiveCoinSellersInMyRegionRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
message ListActiveCoinSellersInMyRegionResponse {
repeated CoinSellerListItem coin_sellers = 1;
}
message GetUserRoleSummaryRequest {
RequestMeta meta = 1;
int64 user_id = 2;
@ -402,6 +429,7 @@ service UserHostService {
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse);
rpc ListActiveCoinSellersInMyRegion(ListActiveCoinSellersInMyRegionRequest) returns (ListActiveCoinSellersInMyRegionResponse);
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
rpc CheckBusinessCapability(CheckBusinessCapabilityRequest) returns (CheckBusinessCapabilityResponse);
rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);

View File

@ -19,20 +19,21 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
UserHostService_SearchAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/SearchAgencies"
UserHostService_ApplyToAgency_FullMethodName = "/hyapp.user.v1.UserHostService/ApplyToAgency"
UserHostService_ReviewAgencyApplication_FullMethodName = "/hyapp.user.v1.UserHostService/ReviewAgencyApplication"
UserHostService_KickAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostService/KickAgencyHost"
UserHostService_InviteAgency_FullMethodName = "/hyapp.user.v1.UserHostService/InviteAgency"
UserHostService_InviteBD_FullMethodName = "/hyapp.user.v1.UserHostService/InviteBD"
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability"
UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers"
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
UserHostService_SearchAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/SearchAgencies"
UserHostService_ApplyToAgency_FullMethodName = "/hyapp.user.v1.UserHostService/ApplyToAgency"
UserHostService_ReviewAgencyApplication_FullMethodName = "/hyapp.user.v1.UserHostService/ReviewAgencyApplication"
UserHostService_KickAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostService/KickAgencyHost"
UserHostService_InviteAgency_FullMethodName = "/hyapp.user.v1.UserHostService/InviteAgency"
UserHostService_InviteBD_FullMethodName = "/hyapp.user.v1.UserHostService/InviteBD"
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName = "/hyapp.user.v1.UserHostService/ListActiveCoinSellersInMyRegion"
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability"
UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers"
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
)
// UserHostServiceClient is the client API for UserHostService service.
@ -51,6 +52,7 @@ type UserHostServiceClient interface {
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, error)
GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error)
CheckBusinessCapability(ctx context.Context, in *CheckBusinessCapabilityRequest, opts ...grpc.CallOption) (*CheckBusinessCapabilityResponse, error)
GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error)
@ -165,6 +167,16 @@ func (c *userHostServiceClient) GetCoinSellerProfile(ctx context.Context, in *Ge
return out, nil
}
func (c *userHostServiceClient) ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListActiveCoinSellersInMyRegionResponse)
err := c.cc.Invoke(ctx, UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetUserRoleSummaryResponse)
@ -221,6 +233,7 @@ type UserHostServiceServer interface {
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error)
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error)
GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error)
@ -265,6 +278,9 @@ func (UnimplementedUserHostServiceServer) GetBDProfile(context.Context, *GetBDPr
func (UnimplementedUserHostServiceServer) GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetCoinSellerProfile not implemented")
}
func (UnimplementedUserHostServiceServer) ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListActiveCoinSellersInMyRegion not implemented")
}
func (UnimplementedUserHostServiceServer) GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetUserRoleSummary not implemented")
}
@ -478,6 +494,24 @@ func _UserHostService_GetCoinSellerProfile_Handler(srv interface{}, ctx context.
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ListActiveCoinSellersInMyRegion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListActiveCoinSellersInMyRegionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).ListActiveCoinSellersInMyRegion(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).ListActiveCoinSellersInMyRegion(ctx, req.(*ListActiveCoinSellersInMyRegionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_GetUserRoleSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserRoleSummaryRequest)
if err := dec(in); err != nil {
@ -597,6 +631,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetCoinSellerProfile",
Handler: _UserHostService_GetCoinSellerProfile_Handler,
},
{
MethodName: "ListActiveCoinSellersInMyRegion",
Handler: _UserHostService_ListActiveCoinSellersInMyRegion_Handler,
},
{
MethodName: "GetUserRoleSummary",
Handler: _UserHostService_GetUserRoleSummary_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -222,6 +222,24 @@ message GiftTypeConfig {
int64 updated_at_ms = 10;
}
message ResourceShopItem {
string app_code = 1;
int64 shop_item_id = 2;
int64 resource_id = 3;
Resource resource = 4;
string status = 5;
int32 duration_days = 6;
string price_type = 7;
int64 coin_price = 8;
int64 effective_from_ms = 9;
int64 effective_to_ms = 10;
int32 sort_order = 11;
int64 created_by_user_id = 12;
int64 updated_by_user_id = 13;
int64 created_at_ms = 14;
int64 updated_at_ms = 15;
}
message UserResourceEntitlement {
string app_code = 1;
string entitlement_id = 2;
@ -592,6 +610,55 @@ message ListResourceGrantsResponse {
int64 total = 2;
}
message ResourceShopItemInput {
int64 shop_item_id = 1;
int64 resource_id = 2;
string status = 3;
int32 duration_days = 4;
int64 effective_from_ms = 5;
int64 effective_to_ms = 6;
int32 sort_order = 7;
}
message ListResourceShopItemsRequest {
string request_id = 1;
string app_code = 2;
string resource_type = 3;
string status = 4;
string keyword = 5;
int32 page = 6;
int32 page_size = 7;
bool active_only = 8;
}
message ListResourceShopItemsResponse {
repeated ResourceShopItem items = 1;
int64 total = 2;
}
message UpsertResourceShopItemsRequest {
string request_id = 1;
string app_code = 2;
repeated ResourceShopItemInput items = 3;
int64 operator_user_id = 4;
}
message UpsertResourceShopItemsResponse {
repeated ResourceShopItem items = 1;
}
message SetResourceShopItemStatusRequest {
string request_id = 1;
string app_code = 2;
int64 shop_item_id = 3;
string status = 4;
int64 operator_user_id = 5;
}
message ResourceShopItemResponse {
ResourceShopItem item = 1;
}
message RechargeBill {
string app_code = 1;
string transaction_id = 2;
@ -1049,6 +1116,27 @@ message CreditTaskRewardResponse {
int64 granted_at_ms = 4;
}
// CreditLuckyGiftRewardRequest activity-service outbox worker
message CreditLuckyGiftRewardRequest {
string command_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int64 amount = 4;
string draw_id = 5;
string room_id = 6;
string gift_id = 7;
string pool_id = 8;
string reason = 9;
}
// CreditLuckyGiftRewardResponse COIN
message CreditLuckyGiftRewardResponse {
string transaction_id = 1;
AssetBalance balance = 2;
int64 amount = 3;
int64 granted_at_ms = 4;
}
// ApplyGameCoinChangeRequest game-service
message ApplyGameCoinChangeRequest {
string request_id = 1;
@ -1254,6 +1342,9 @@ service WalletService {
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
rpc ListResourceGrants(ListResourceGrantsRequest) returns (ListResourceGrantsResponse);
rpc ListResourceShopItems(ListResourceShopItemsRequest) returns (ListResourceShopItemsResponse);
rpc UpsertResourceShopItems(UpsertResourceShopItemsRequest) returns (UpsertResourceShopItemsResponse);
rpc SetResourceShopItemStatus(SetResourceShopItemStatusRequest) returns (ResourceShopItemResponse);
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
@ -1274,6 +1365,7 @@ service WalletService {
rpc ListAdminVipLevels(ListAdminVipLevelsRequest) returns (ListAdminVipLevelsResponse);
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
rpc ApplyGameCoinChange(ApplyGameCoinChangeRequest) returns (ApplyGameCoinChangeResponse);
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);

View File

@ -45,6 +45,9 @@ const (
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
WalletService_ListResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopItems"
WalletService_UpsertResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertResourceShopItems"
WalletService_SetResourceShopItemStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceShopItemStatus"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
@ -65,6 +68,7 @@ const (
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
WalletService_ApplyGameCoinChange_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyGameCoinChange"
WalletService_GetRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacketConfig"
WalletService_UpdateRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRedPacketConfig"
@ -107,6 +111,9 @@ type WalletServiceClient interface {
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
ListResourceGrants(ctx context.Context, in *ListResourceGrantsRequest, opts ...grpc.CallOption) (*ListResourceGrantsResponse, error)
ListResourceShopItems(ctx context.Context, in *ListResourceShopItemsRequest, opts ...grpc.CallOption) (*ListResourceShopItemsResponse, error)
UpsertResourceShopItems(ctx context.Context, in *UpsertResourceShopItemsRequest, opts ...grpc.CallOption) (*UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(ctx context.Context, in *SetResourceShopItemStatusRequest, opts ...grpc.CallOption) (*ResourceShopItemResponse, error)
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
@ -127,6 +134,7 @@ type WalletServiceClient interface {
ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error)
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
CreditLuckyGiftReward(ctx context.Context, in *CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*CreditLuckyGiftRewardResponse, error)
ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error)
GetRedPacketConfig(ctx context.Context, in *GetRedPacketConfigRequest, opts ...grpc.CallOption) (*GetRedPacketConfigResponse, error)
UpdateRedPacketConfig(ctx context.Context, in *UpdateRedPacketConfigRequest, opts ...grpc.CallOption) (*UpdateRedPacketConfigResponse, error)
@ -405,6 +413,36 @@ func (c *walletServiceClient) ListResourceGrants(ctx context.Context, in *ListRe
return out, nil
}
func (c *walletServiceClient) ListResourceShopItems(ctx context.Context, in *ListResourceShopItemsRequest, opts ...grpc.CallOption) (*ListResourceShopItemsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListResourceShopItemsResponse)
err := c.cc.Invoke(ctx, WalletService_ListResourceShopItems_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UpsertResourceShopItems(ctx context.Context, in *UpsertResourceShopItemsRequest, opts ...grpc.CallOption) (*UpsertResourceShopItemsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpsertResourceShopItemsResponse)
err := c.cc.Invoke(ctx, WalletService_UpsertResourceShopItems_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) SetResourceShopItemStatus(ctx context.Context, in *SetResourceShopItemStatusRequest, opts ...grpc.CallOption) (*ResourceShopItemResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResourceShopItemResponse)
err := c.cc.Invoke(ctx, WalletService_SetResourceShopItemStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRechargeBillsResponse)
@ -605,6 +643,16 @@ func (c *walletServiceClient) CreditTaskReward(ctx context.Context, in *CreditTa
return out, nil
}
func (c *walletServiceClient) CreditLuckyGiftReward(ctx context.Context, in *CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*CreditLuckyGiftRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreditLuckyGiftRewardResponse)
err := c.cc.Invoke(ctx, WalletService_CreditLuckyGiftReward_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyGameCoinChangeResponse)
@ -717,6 +765,9 @@ type WalletServiceServer interface {
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error)
ListResourceShopItems(context.Context, *ListResourceShopItemsRequest) (*ListResourceShopItemsResponse, error)
UpsertResourceShopItems(context.Context, *UpsertResourceShopItemsRequest) (*UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error)
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
@ -737,6 +788,7 @@ type WalletServiceServer interface {
ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error)
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error)
GetRedPacketConfig(context.Context, *GetRedPacketConfigRequest) (*GetRedPacketConfigResponse, error)
UpdateRedPacketConfig(context.Context, *UpdateRedPacketConfigRequest) (*UpdateRedPacketConfigResponse, error)
@ -833,6 +885,15 @@ func (UnimplementedWalletServiceServer) EquipUserResource(context.Context, *Equi
func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListResourceGrants not implemented")
}
func (UnimplementedWalletServiceServer) ListResourceShopItems(context.Context, *ListResourceShopItemsRequest) (*ListResourceShopItemsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListResourceShopItems not implemented")
}
func (UnimplementedWalletServiceServer) UpsertResourceShopItems(context.Context, *UpsertResourceShopItemsRequest) (*UpsertResourceShopItemsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpsertResourceShopItems not implemented")
}
func (UnimplementedWalletServiceServer) SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetResourceShopItemStatus not implemented")
}
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRechargeBills not implemented")
}
@ -893,6 +954,9 @@ func (UnimplementedWalletServiceServer) UpdateAdminVipLevels(context.Context, *U
func (UnimplementedWalletServiceServer) CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreditTaskReward not implemented")
}
func (UnimplementedWalletServiceServer) CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreditLuckyGiftReward not implemented")
}
func (UnimplementedWalletServiceServer) ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyGameCoinChange not implemented")
}
@ -1406,6 +1470,60 @@ func _WalletService_ListResourceGrants_Handler(srv interface{}, ctx context.Cont
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListResourceShopItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListResourceShopItemsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListResourceShopItems(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListResourceShopItems_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListResourceShopItems(ctx, req.(*ListResourceShopItemsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpsertResourceShopItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertResourceShopItemsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).UpsertResourceShopItems(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_UpsertResourceShopItems_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).UpsertResourceShopItems(ctx, req.(*UpsertResourceShopItemsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_SetResourceShopItemStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetResourceShopItemStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).SetResourceShopItemStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_SetResourceShopItemStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).SetResourceShopItemStatus(ctx, req.(*SetResourceShopItemStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRechargeBillsRequest)
if err := dec(in); err != nil {
@ -1766,6 +1884,24 @@ func _WalletService_CreditTaskReward_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreditLuckyGiftReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreditLuckyGiftRewardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).CreditLuckyGiftReward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_CreditLuckyGiftReward_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).CreditLuckyGiftReward(ctx, req.(*CreditLuckyGiftRewardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ApplyGameCoinChange_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyGameCoinChangeRequest)
if err := dec(in); err != nil {
@ -2021,6 +2157,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListResourceGrants",
Handler: _WalletService_ListResourceGrants_Handler,
},
{
MethodName: "ListResourceShopItems",
Handler: _WalletService_ListResourceShopItems_Handler,
},
{
MethodName: "UpsertResourceShopItems",
Handler: _WalletService_UpsertResourceShopItems_Handler,
},
{
MethodName: "SetResourceShopItemStatus",
Handler: _WalletService_SetResourceShopItemStatus_Handler,
},
{
MethodName: "ListRechargeBills",
Handler: _WalletService_ListRechargeBills_Handler,
@ -2101,6 +2249,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CreditTaskReward",
Handler: _WalletService_CreditTaskReward_Handler,
},
{
MethodName: "CreditLuckyGiftReward",
Handler: _WalletService_CreditLuckyGiftReward_Handler,
},
{
MethodName: "ApplyGameCoinChange",
Handler: _WalletService_ApplyGameCoinChange_Handler,

View File

@ -0,0 +1,149 @@
# 币商列表 Flutter 对接
本文描述 Flutter App 获取当前用户所在区域启用币商列表的接口。币商身份、状态和区域过滤由 `user-service` 维护App 只展示 gateway 返回的列表,不传 `region_id`,也不在本地缓存为跨区域通用数据。
## 接口
`GET /api/v1/wallet/coin-sellers`
登录接口,需要 `Authorization: Bearer <access_token>`。接口按当前登录用户的 `users.region_id` 查询同区域、状态为 `active`、资产类型为 `COIN_SELLER_COIN` 的币商。
请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | App 登录 access token。 |
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
| `X-Request-ID` | 否 | 客户端链路 ID服务端也会生成并在响应中返回。 |
Query无。不要传 `region_id``country_id``user_id`
示例:
```http
GET /api/v1/wallet/coin-sellers
Authorization: Bearer <access_token>
X-App-Code: lalu
```
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"items": [
{
"user_id": "801",
"display_user_id": "900801",
"username": "Seller One",
"avatar": "https://cdn.example/avatar.png",
"country_id": 86,
"country_code": "CN",
"country_name": "China",
"country_display_name": "中国",
"region_id": 30,
"region_code": "east-asia",
"region_name": "East Asia",
"status": "active",
"merchant_asset_type": "COIN_SELLER_COIN",
"updated_at_ms": 1700000001000
}
],
"total": 1
}
}
```
字段规则:
| 字段 | 说明 |
| --- | --- |
| `user_id` | 币商用户长 IDJSON 中按字符串返回,避免 Dart/JS 大整数精度问题。 |
| `display_user_id` | 当前展示短号,可用于转账页展示和输入确认。 |
| `username` | 币商昵称;为空时客户端可展示短号。 |
| `avatar` | 币商头像 URL为空时展示默认头像。 |
| `country_id` | 币商用户当前国家 ID。 |
| `country_code` | 国家编码,例如 `CN`。 |
| `country_name` | 英文或标准国家名。 |
| `country_display_name` | 客户端展示优先使用的国家名。 |
| `region_id` | 币商用户当前区域 ID应与当前用户区域一致。 |
| `status` | 当前只会返回 `active`。 |
| `merchant_asset_type` | 当前固定为 `COIN_SELLER_COIN`。 |
| `updated_at_ms` | 币商身份最近更新时间Unix epoch milliseconds。 |
## Flutter 解析示例
```dart
class CoinSellerList {
CoinSellerList({
required this.items,
required this.total,
});
final List<CoinSeller> items;
final int total;
factory CoinSellerList.fromJson(Map<String, dynamic> json) {
return CoinSellerList(
items: (json['items'] as List<dynamic>? ?? const [])
.map((item) => CoinSeller.fromJson(item as Map<String, dynamic>))
.toList(),
total: (json['total'] as num?)?.toInt() ?? 0,
);
}
}
class CoinSeller {
CoinSeller({
required this.userId,
required this.displayUserId,
required this.username,
required this.avatar,
required this.countryId,
required this.countryDisplayName,
required this.regionId,
required this.status,
required this.merchantAssetType,
required this.updatedAtMs,
});
final String userId;
final String displayUserId;
final String username;
final String avatar;
final int countryId;
final String countryDisplayName;
final int regionId;
final String status;
final String merchantAssetType;
final int updatedAtMs;
String get title => username.isNotEmpty ? username : displayUserId;
factory CoinSeller.fromJson(Map<String, dynamic> json) {
return CoinSeller(
userId: json['user_id'] as String? ?? '',
displayUserId: json['display_user_id'] as String? ?? '',
username: json['username'] as String? ?? '',
avatar: json['avatar'] as String? ?? '',
countryId: (json['country_id'] as num?)?.toInt() ?? 0,
countryDisplayName: json['country_display_name'] as String? ?? '',
regionId: (json['region_id'] as num?)?.toInt() ?? 0,
status: json['status'] as String? ?? '',
merchantAssetType: json['merchant_asset_type'] as String? ?? '',
updatedAtMs: (json['updated_at_ms'] as num?)?.toInt() ?? 0,
);
}
}
```
## 客户端处理
1. 先解析外层 envelope只有 `code == "OK"` 时读取 `data.items`
2. 空列表表示当前区域暂无启用币商;展示空状态,不切换到其他区域。
3. 点击币商进入转账或联系流程时,使用 `display_user_id` 做用户可见确认,实际后续接口仍以服务端解析结果为准。
4. 用户切换国家或完成资料变更后重新拉取本接口,不复用旧区域列表。
5. 请求失败时记录 `request_id`,按登录失效、参数错误、服务错误分别处理。

View File

@ -60,19 +60,14 @@ App 不要展示后台 RTP 数值,不要展示实时概率,不要本地推
| --- | --- |
| `POST /api/v1/rooms/gift/send` 支持 `pool_id` 入参 | 已有 |
| 房间送礼 IM `room_gift_sent` 可携带 `pool_id` | 已有 |
| `POST /api/v1/activities/lucky-gifts/check` | 已有 |
| `activity-service` 内部 gRPC `CheckLuckyGift` / `ExecuteLuckyGiftDraw` | 已有 |
| 后台按 `pool_id` 配置多奖池 | 已有 |
| 送礼响应直接返回抽奖结果 `data.lucky_gift` | 已有 |
| 房间 IM `lucky_gift_drawn` | 已有 |
| 中奖返奖入账并触发 `WalletBalanceChanged` | 已有 |
Flutter 完整体验还需要后端补齐:
| 能力 | 建议 |
| --- | --- |
| App 查询某个奖池是否可抽 | 新增 HTTP `POST /api/v1/activities/lucky-gifts/check` |
| 送礼响应直接返回抽奖结果 | 在 `/rooms/gift/send` 成功 `data` 中增加 `lucky_gift` |
| 中奖房间表现 | 新增房间 IM `lucky_gift_drawn` |
| 返奖到账 | 钱包侧新增幸运礼物返奖入账后继续发 `WalletBalanceChanged` |
下面文档按最终 Flutter 契约编写;已存在字段可直接对接,标注为“建议新增”的字段需要后端继续落地。
简洁版接入清单见 [幸运礼物接口闭环Flutter对接.md](/Users/hy/Documents/hy/hyapp-server/docs/flutter对接/幸运礼物接口闭环Flutter对接.md)。
## 客户端流程
@ -585,4 +580,3 @@ Payload 示例:
6. 监听房间 IM `room_gift_sent``lucky_gift_drawn`,同步其他用户看到的表现。
7. 监听 C2C IM `WalletBalanceChanged`,更新金币余额。
8. 任意 IM 丢失、页面重进或余额不一致时,重新拉取礼物面板和钱包余额纠正状态。

View File

@ -0,0 +1,171 @@
# 幸运礼物 Flutter 对接
本文只描述 App 需要接入的 HTTP、送礼返回、错误处理和腾讯云 IM 自定义消息。所有接口响应都使用 gateway 业务 envelope
```json
{
"code": "OK",
"message": "",
"request_id": "req_xxx",
"data": {}
}
```
## 1. 送礼前检查
`POST /api/v1/activities/lucky-gifts/check`
请求头:
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` |
| `X-App-Code` | 否 | 不传时使用默认 App |
请求体:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID |
| `gift_id` | string | 是 | 实际送出的礼物 ID例如 `rose` |
| `pool_id` | string | 是 | 幸运礼物奖池 ID例如 `lucky``super_lucky` |
返回 `data`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 是否可按幸运礼物发送 |
| `reason` | string | `enabled``not_configured``disabled` |
| `room_id` | string | 请求房间 ID |
| `gift_id` | string | 请求礼物 ID |
| `pool_id` | string | 生效奖池 ID |
| `gift_price` | int | 单次抽奖价格,金币 |
| `rule_version` | int | 后端规则版本 |
| `target_rtp_ppm` | int | RTP 目标ppm |
| `experience_pool` | string | `novice``intermediate``advanced` |
客户端处理:
- `enabled=true`:允许用户继续点击送礼。
- `enabled=false`:不要走幸运礼物送礼;可降级普通礼物或提示当前不可用。
- 检查结果只用于展示和拦截,最终仍以送礼接口为准。
## 2. 发送礼物
`POST /api/v1/rooms/gift/send`
请求体关键字段:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 房间 ID |
| `target_user_id` | int/string | 是 | 收礼用户 ID |
| `gift_id` | string | 是 | 礼物 ID例如 `rose` |
| `gift_count` | int | 是 | 数量,必须大于 0 |
| `pool_id` | string | 幸运礼物必填 | `lucky``super_lucky` |
| `client_sequence` | string | 建议 | 客户端幂等序列 |
返回 `data.lucky_gift`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `draw_id` | string | 抽奖 ID客户端去重锚点 |
| `command_id` | string | 送礼命令 ID |
| `pool_id` | string | 生效奖池 |
| `gift_id` | string | 礼物 ID |
| `rule_version` | int | 抽奖规则版本 |
| `experience_pool` | string | 体验池 |
| `selected_tier_id` | string | 命中奖档 |
| `multiplier_ppm` | int | 倍率,`2000000` 表示 2 倍 |
| `base_reward_coins` | int | RTP 基础返奖 |
| `room_atmosphere_reward_coins` | int | 房间气氛池返奖 |
| `activity_subsidy_coins` | int | 活动补贴返奖 |
| `effective_reward_coins` | int | 用户实际获得金币 |
| `reward_status` | string | 初始通常为 `pending`,到账由后端 outbox 补偿 |
| `stage_feedback` | bool | 是否阶段反馈,可播放轻量中奖表现 |
| `high_multiplier` | bool | 是否高倍 |
| `created_at_ms` | int | UTC epoch ms |
客户端处理:
- `data.lucky_gift != null`:立即调用 `_applyLuckyGiftResult()`,播放中奖内容和数量 SVGA。
- `effective_reward_coins > 0`:展示中奖金额;余额不要本地相加,以 `WalletBalanceChanged` 或余额刷新为准。
- `data.lucky_gift == null`:按普通礼物表现处理。
## 3. 错误处理
| code | 场景 | 客户端处理 |
| --- | --- | --- |
| `INVALID_ARGUMENT` | 参数缺失、数量非法、pool_id 缺失 | 停止提交,提示用户重试或刷新礼物面板 |
| `UNAUTHENTICATED` | token 失效 | 重新登录 |
| `INSUFFICIENT_BALANCE` | 金币不足 | 打开充值入口或提示余额不足 |
| `RULE_NOT_ACTIVE` | 幸运礼物规则未启用 | 降级普通礼物或刷新礼物配置 |
| `UPSTREAM_ERROR`/`UNAVAILABLE` | 内部服务或 IM/钱包临时不可用 | 不重复扣本地余额;允许用户稍后重试 |
| `IDEMPOTENCY_CONFLICT`/`CONFLICT` | 同一客户端命令参数变化 | 重新生成 `client_sequence` 后再提交 |
送礼失败时后端不会返回 `lucky_gift`,客户端不要播放中奖 UI。送礼成功但返奖到账延迟时房间 IM 和钱包 IM 会继续补偿。
## 4. 房间 IM
腾讯云 IM 群自定义消息:
| 字段 | 值 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `Desc` | `lucky_gift_drawn` |
| `Ext` | `room_system_message` |
| `Data` | JSON 字符串 |
`Data` 示例:
```json
{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_xxx",
"draw_id": "lucky_draw_xxx",
"command_id": "cmd_xxx",
"pool_id": "super_lucky",
"room_id": "room_1",
"gift_id": "rose",
"gift_count": 1,
"user_id": 42,
"sender_user_id": 42,
"target_user_id": 99,
"coin_spent": 100,
"multiplier_ppm": 2000000,
"effective_reward_coins": 200,
"stage_feedback": true,
"high_multiplier": false,
"created_at_ms": 1760000000000
}
```
处理规则:
- 只处理 `Desc == "lucky_gift_drawn"``event_type == "lucky_gift_drawn"`
- 用 `event_id``draw_id` 去重;同一抽奖不要重复播放。
- 当前用户已通过 HTTP 送礼响应播放过同一 `draw_id` 时,收到 IM 只更新状态,不重复播放。
## 5. 钱包余额 IM
中奖金币到账后wallet-service 会写 `WalletBalanceChanged`notice-service 通过腾讯云 IM C2C 投递。
| 字段 | 值 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `Desc` | `WalletBalanceChanged` |
| `Ext` | `wallet_notice` |
客户端处理:
- 只处理 `Desc == "WalletBalanceChanged"`
- 用 `event_id` 去重。
- 当 `asset_type == "COIN"` 时,用 `available_after` 更新金币余额;字段缺失时调用 `GET /api/v1/wallet/me/balances` 刷新。
## 6. 接入顺序
1. 礼物面板拿到 `pool_id` 后调用 check。
2. check 可用后调用送礼接口,并把 `pool_id` 放入请求。
3. 送礼响应含 `data.lucky_gift` 时立即播放本机中奖 UI。
4. 房间内监听 `lucky_gift_drawn`,给其他用户同步中奖 UI。
5. 私聊监听 `WalletBalanceChanged`,更新金币余额。

View File

@ -18,8 +18,11 @@
| 风控计数 | done | user hour/day、device day、room hour、anchor day |
| 房间气氛池 | done | 独立预算,不污染基础 RTP |
| 活动预算池 | done | 独立补贴,不污染基础 RTP |
| 返奖入账 | done | `activity_outbox` worker 调 `wallet-service.CreditLuckyGiftReward`,钱包 outbox 继续触发余额私信 |
| 房间中奖 IM | done | `activity_outbox` worker 投递腾讯云 IM 群消息 `lucky_gift_drawn` |
| App 检查接口 | done | gateway 暴露 `POST /api/v1/activities/lucky-gifts/check` |
暂不在 `activity-service` 事务内调用钱包或房间 RPC。抽奖事务只写事实和 `activity_outbox`;后续由 outbox dispatcher 或房间链路消费结果。
`activity-service` 抽奖事务内仍不调用钱包、房间 RPC 或腾讯云 IM。抽奖事务只写事实和 `activity_outbox`;事务外由 lucky gift worker 抢占 outbox先幂等返奖再投递房间中奖 IM最后标记 draw 和 outbox 状态
## Call Flow
@ -27,9 +30,12 @@
room-service SendGift
1. CheckLuckyGift(meta, user_id, room_id, gift_id, pool_id)
2. wallet-service 扣费成功
3. ExecuteLuckyGiftDraw(command_id, user_id, device_id, room_id, anchor_id, gift_id, pool_id, coin_spent, paid_at_ms)
3. ExecuteLuckyGiftDraw(command_id, user_id, target_user_id, gift_count, device_id, room_id, anchor_id, gift_id, pool_id, coin_spent, paid_at_ms)
4. activity-service 本地事务写 draw fact + outbox
5. 事务后由钱包/房间表现链路处理 reward_status=pending 的结果
5. lucky gift worker 幂等调用 wallet-service.CreditLuckyGiftReward
6. wallet-service 写 WalletBalanceChangednotice-service 投递私信余额通知
7. lucky gift worker 投递房间群 IM lucky_gift_drawn
8. activity-service 标记 reward_status=granted、outbox=delivered
```
`command_id` 是抽奖幂等键。重复 `ExecuteLuckyGiftDraw` 会返回同一条 `lucky_draw_records`,不会重复消耗 RTP、奖池或预算。

View File

@ -26,6 +26,8 @@ const (
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
// sendC2CMsgCommand 是服务端给单个用户发自定义消息的 REST 命令;钱包余额变更这类私有事件只能走单聊面。
sendC2CMsgCommand = "v4/openim/sendmsg"
// accountImportCommand 把内部 user_id 导入腾讯 IM 账号体系C2C 发送和 SDK 登录依赖该账号存在。
accountImportCommand = "v4/im_open_login_svc/account_import"
// destroyGroupCommand 用于真实 IM smoke test 或明确运维动作的临时群清理,业务代码不应自动解散播报群。
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
@ -83,6 +85,13 @@ type CustomUserMessage struct {
PayloadJSON json.RawMessage
}
// AccountProfile 表达腾讯 IM 账号导入所需的最小用户资料。
type AccountProfile struct {
UserID int64
Nick string
FaceURL string
}
// RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。
type RoomEvent struct {
EventID string `json:"event_id"`
@ -293,6 +302,29 @@ func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message Custo
return response.err()
}
// ImportAccount 确保内部 user_id 已存在于腾讯 IM 账号体系。
// 腾讯接口本身没有本地事务语义,因此调用方应在用户事务提交后调用,并允许后续 UserSig 入口补偿。
func (c *RESTClient) ImportAccount(ctx context.Context, profile AccountProfile) error {
if profile.UserID <= 0 {
return fmt.Errorf("im account import is incomplete")
}
request := accountImportRequest{
UserID: FormatUserID(profile.UserID),
Nick: strings.TrimSpace(profile.Nick),
FaceURL: strings.TrimSpace(profile.FaceURL),
}
var response restResponse
if err := c.post(ctx, accountImportCommand, request, &response); err != nil {
return err
}
if response.ErrorCode == 0 || isAccountAlreadyImported(response) {
return nil
}
return response.err()
}
// DestroyGroup 解散一个腾讯云 IM 群。
// 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。
func (c *RESTClient) DestroyGroup(ctx context.Context, groupID string) error {
@ -437,6 +469,15 @@ func (r restResponse) err() error {
return fmt.Errorf("tencent im rest failed: code=%d info=%s", r.ErrorCode, r.ErrorInfo)
}
func isAccountAlreadyImported(response restResponse) bool {
// 腾讯不同版本和地域对重复导入的错误码/文案不完全一致;账号已存在是幂等成功。
if response.ErrorCode == 70107 {
return true
}
info := strings.ToLower(strings.TrimSpace(response.ErrorInfo))
return strings.Contains(info, "already") || strings.Contains(info, "exist")
}
type createGroupRequest struct {
OwnerAccount string `json:"Owner_Account,omitempty"`
Type string `json:"Type"`
@ -463,6 +504,12 @@ type sendC2CMsgRequest struct {
MsgBody []messageElement `json:"MsgBody"`
}
type accountImportRequest struct {
UserID string `json:"UserID"`
Nick string `json:"Nick,omitempty"`
FaceURL string `json:"FaceUrl,omitempty"`
}
type destroyGroupRequest struct {
GroupID string `json:"GroupId"`
}

View File

@ -86,14 +86,10 @@ func TestRealTencentIMCreateSendAndDestroyGroup(t *testing.T) {
}
func realImportAccount(ctx context.Context, client *RESTClient, userID int64) error {
var response restResponse
if err := client.post(ctx, "v4/im_open_login_svc/account_import", map[string]any{
"UserID": strconv.FormatInt(userID, 10),
"Nick": "codex-im-smoke",
}, &response); err != nil {
return err
}
return response.err()
return client.ImportAccount(ctx, AccountProfile{
UserID: userID,
Nick: "codex-im-smoke",
})
}
func realAddGroupMember(ctx context.Context, client *RESTClient, groupID string, userID int64) error {

View File

@ -166,6 +166,48 @@ func TestRESTClientPublishUserCustomMessageBuildsC2CPayload(t *testing.T) {
}
}
func TestRESTClientImportAccountBuildsTencentRequest(t *testing.T) {
var capturedPath string
var capturedBody map[string]any
client := newTestRESTClient(t, func(request *http.Request) (*http.Response, error) {
capturedPath = request.URL.Path
if err := json.NewDecoder(request.Body).Decode(&capturedBody); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
return okRESTResponse(), nil
})
if err := client.ImportAccount(context.Background(), AccountProfile{
UserID: 10002,
Nick: "Lingxian",
FaceURL: "https://cdn.example/avatar.png",
}); err != nil {
t.Fatalf("ImportAccount failed: %v", err)
}
if capturedPath != "/"+accountImportCommand {
t.Fatalf("path mismatch: got %q", capturedPath)
}
if capturedBody["UserID"] != "10002" || capturedBody["Nick"] != "Lingxian" || capturedBody["FaceUrl"] != "https://cdn.example/avatar.png" {
t.Fatalf("unexpected account import body: %+v", capturedBody)
}
}
func TestRESTClientImportAccountTreatsExistingAccountAsSuccess(t *testing.T) {
client := newTestRESTClient(t, func(_ *http.Request) (*http.Response, error) {
payload := []byte(`{"ActionStatus":"FAIL","ErrorCode":70107,"ErrorInfo":"account already exists"}`)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(payload)),
Header: make(http.Header),
}, nil
})
if err := client.ImportAccount(context.Background(), AccountProfile{UserID: 10002}); err != nil {
t.Fatalf("existing account must be idempotent success: %v", err)
}
}
// TestRESTClientDestroyGroupBuildsTencentRequest 锁定临时群清理的 REST 请求。
func TestRESTClientDestroyGroupBuildsTencentRequest(t *testing.T) {
var capturedPath string

View File

@ -57,7 +57,7 @@ var catalog = map[Code]Spec{
DisplayUserIDLeaseExpired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDLeaseExpired, "conflict"),
DisplayUserIDPaymentRequired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPaymentRequired, "conflict"),
CountryChangeCooldown: spec(codes.FailedPrecondition, httpStatusConflict, CountryChangeCooldown, "conflict"),
CountryChangeCooldown: spec(codes.FailedPrecondition, httpStatusConflict, CountryChangeCooldown, "country change is cooling down"),
CountryNotFound: spec(codes.NotFound, httpStatusNotFound, CountryNotFound, "not found"),
RegionNotFound: spec(codes.NotFound, httpStatusNotFound, RegionNotFound, "not found"),
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "conflict"),

View File

@ -78,6 +78,14 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(LedgerConflict),
publicMessage: "conflict",
},
{
name: "country cooldown keeps actionable app message",
code: CountryChangeCooldown,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(CountryChangeCooldown),
publicMessage: "country change is cooling down",
},
}
for _, test := range tests {

View File

@ -281,6 +281,8 @@
| `resource:view` | `menu` | 资源列表、资源详情 |
| `resource:create` | `button` | 新增资源 |
| `resource:update` | `button` | 编辑资源、启用、禁用 |
| `resource-shop:view` | `menu` | 道具商店售卖列表 |
| `resource-shop:update` | `button` | 添加售卖资源、配置售卖天数和启用、禁用 |
| `resource-group:view` | `menu` | 资源组列表、资源组详情 |
| `resource-group:create` | `button` | 新增资源组 |
| `resource-group:update` | `button` | 编辑资源组、替换组成员、启用、禁用 |

View File

@ -29,6 +29,9 @@ type Client interface {
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
UpsertResourceShopItems(ctx context.Context, req *walletv1.UpsertResourceShopItemsRequest) (*walletv1.UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(ctx context.Context, req *walletv1.SetResourceShopItemStatusRequest) (*walletv1.ResourceShopItemResponse, error)
AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
@ -129,6 +132,18 @@ func (c *GRPCClient) ListResourceGrants(ctx context.Context, req *walletv1.ListR
return c.client.ListResourceGrants(ctx, req)
}
func (c *GRPCClient) ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
return c.client.ListResourceShopItems(ctx, req)
}
func (c *GRPCClient) UpsertResourceShopItems(ctx context.Context, req *walletv1.UpsertResourceShopItemsRequest) (*walletv1.UpsertResourceShopItemsResponse, error) {
return c.client.UpsertResourceShopItems(ctx, req)
}
func (c *GRPCClient) SetResourceShopItemStatus(ctx context.Context, req *walletv1.SetResourceShopItemStatusRequest) (*walletv1.ResourceShopItemResponse, error) {
return c.client.SetResourceShopItemStatus(ctx, req)
}
func (c *GRPCClient) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
return c.client.AdminCreditAsset(ctx, req)
}

View File

@ -97,7 +97,7 @@ type AppBanner struct {
CoverURL string `gorm:"size:1024;not null" json:"coverUrl"`
RoomSmallImageURL string `gorm:"column:room_small_image_url;size:1024;not null;default:'';comment:房间内小屏图 URL" json:"roomSmallImageUrl"`
BannerType string `gorm:"size:16;not null" json:"bannerType"`
DisplayScope string `gorm:"size:32;index:idx_admin_app_banners_display,not null;default:home;comment:显示范围:首页、房间内或充值页" json:"displayScope"`
DisplayScope string `gorm:"size:128;index:idx_admin_app_banners_display,not null;default:home;comment:显示范围,多选逗号分隔:首页、房间内或充值页" json:"displayScope"`
Param string `gorm:"size:2048" json:"param"`
Status string `gorm:"size:24;index:idx_admin_app_banners_app_sort,not null;default:active" json:"status"`
Platform string `gorm:"size:24;index:idx_admin_app_banners_scope,not null" json:"platform"`

View File

@ -11,19 +11,20 @@ type h5LinkPayload struct {
}
type bannerRequest struct {
CoverURL string `json:"coverUrl" binding:"required"`
RoomSmallImageURL string `json:"roomSmallImageUrl"`
BannerType string `json:"bannerType" binding:"required"`
DisplayScope string `json:"displayScope"`
Param string `json:"param"`
Status string `json:"status" binding:"required"`
Platform string `json:"platform" binding:"required"`
SortOrder int `json:"sortOrder"`
RegionID int64 `json:"regionId"`
CountryCode string `json:"countryCode"`
Description string `json:"description"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
CoverURL string `json:"coverUrl" binding:"required"`
RoomSmallImageURL string `json:"roomSmallImageUrl"`
BannerType string `json:"bannerType" binding:"required"`
DisplayScope string `json:"displayScope"`
DisplayScopes []string `json:"displayScopes"`
Param string `json:"param"`
Status string `json:"status" binding:"required"`
Platform string `json:"platform" binding:"required"`
SortOrder int `json:"sortOrder"`
RegionID int64 `json:"regionId"`
CountryCode string `json:"countryCode"`
Description string `json:"description"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
}
type appVersionRequest struct {

View File

@ -25,6 +25,8 @@ const (
bannerStatusExpired = "expired"
)
var bannerDisplayScopeOrder = []string{bannerDisplayScopeHome, bannerDisplayScopeRoom, bannerDisplayScopeRecharge}
type AppConfigService struct {
store *repository.Store
}
@ -37,23 +39,24 @@ type H5Link struct {
}
type AppBanner struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
CoverURL string `json:"coverUrl"`
RoomSmallImageURL string `json:"roomSmallImageUrl"`
BannerType string `json:"bannerType"`
DisplayScope string `json:"displayScope"`
Param string `json:"param"`
Status string `json:"status"`
Platform string `json:"platform"`
SortOrder int `json:"sortOrder"`
RegionID int64 `json:"regionId"`
CountryCode string `json:"countryCode"`
Description string `json:"description"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
ID uint `json:"id"`
AppCode string `json:"appCode"`
CoverURL string `json:"coverUrl"`
RoomSmallImageURL string `json:"roomSmallImageUrl"`
BannerType string `json:"bannerType"`
DisplayScope string `json:"displayScope"`
DisplayScopes []string `json:"displayScopes"`
Param string `json:"param"`
Status string `json:"status"`
Platform string `json:"platform"`
SortOrder int `json:"sortOrder"`
RegionID int64 `json:"regionId"`
CountryCode string `json:"countryCode"`
Description string `json:"description"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppVersion struct {
@ -391,12 +394,13 @@ func h5LinkFromModel(config model.AppConfig) H5Link {
}
func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner, error) {
displayScopes := normalizeBannerDisplayScopes(req.DisplayScopes, req.DisplayScope)
item := model.AppBanner{
AppCode: appctx.Normalize(appCode),
CoverURL: strings.TrimSpace(req.CoverURL),
RoomSmallImageURL: strings.TrimSpace(req.RoomSmallImageURL),
BannerType: normalizeBannerType(req.BannerType),
DisplayScope: normalizeBannerDisplayScope(req.DisplayScope),
DisplayScope: bannerDisplayScopeValue(displayScopes),
Param: strings.TrimSpace(req.Param),
Status: normalizeBannerStatus(req.Status),
Platform: normalizeBannerPlatform(req.Platform),
@ -413,10 +417,10 @@ func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner,
if containsWhitespace(item.CoverURL) {
return model.AppBanner{}, errors.New("banner cover cannot contain whitespace")
}
if item.DisplayScope != bannerDisplayScopeHome && item.DisplayScope != bannerDisplayScopeRoom && item.DisplayScope != bannerDisplayScopeRecharge {
if !validBannerDisplayScopes(displayScopes) {
return model.AppBanner{}, errors.New("banner display scope is invalid")
}
if item.DisplayScope == bannerDisplayScopeRoom {
if bannerDisplayScopesContain(displayScopes, bannerDisplayScopeRoom) {
if item.RoomSmallImageURL == "" || len(item.RoomSmallImageURL) > 1024 {
return model.AppBanner{}, errors.New("room small image is required")
}
@ -424,7 +428,7 @@ func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner,
return model.AppBanner{}, errors.New("room small image cannot contain whitespace")
}
} else {
// 非房间内投放不保留房间小屏图,避免客户端误用其它显示范围的专属素材。
// 未投放到房间内时不保留小屏图,避免客户端误用房间专属素材。
item.RoomSmallImageURL = ""
}
if item.BannerType != "h5" && item.BannerType != "app" {
@ -476,6 +480,7 @@ func appBannerFromModel(item model.AppBanner) AppBanner {
RoomSmallImageURL: item.RoomSmallImageURL,
BannerType: item.BannerType,
DisplayScope: item.DisplayScope,
DisplayScopes: bannerDisplayScopeList(item.DisplayScope),
Param: item.Param,
Status: item.Status,
Platform: item.Platform,
@ -599,6 +604,71 @@ func normalizeBannerDisplayScope(value string) string {
}
}
func normalizeBannerDisplayScopes(values []string, fallback string) []string {
seen := make(map[string]struct{}, len(bannerDisplayScopeOrder))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
scope := normalizeBannerDisplayScope(part)
if scope == "" {
continue
}
seen[scope] = struct{}{}
}
}
if len(seen) == 0 {
for _, part := range strings.Split(fallback, ",") {
scope := normalizeBannerDisplayScope(part)
if scope == "" {
continue
}
seen[scope] = struct{}{}
}
}
if len(seen) == 0 {
seen[bannerDisplayScopeHome] = struct{}{}
}
out := make([]string, 0, len(seen))
for _, scope := range bannerDisplayScopeOrder {
if _, ok := seen[scope]; ok {
out = append(out, scope)
delete(seen, scope)
}
}
for scope := range seen {
out = append(out, scope)
}
return out
}
func bannerDisplayScopeList(value string) []string {
return normalizeBannerDisplayScopes(nil, value)
}
func bannerDisplayScopeValue(scopes []string) string {
return strings.Join(scopes, ",")
}
func validBannerDisplayScopes(scopes []string) bool {
if len(scopes) == 0 {
return false
}
for _, scope := range scopes {
if scope != bannerDisplayScopeHome && scope != bannerDisplayScopeRoom && scope != bannerDisplayScopeRecharge {
return false
}
}
return true
}
func bannerDisplayScopesContain(scopes []string, target string) bool {
for _, scope := range scopes {
if scope == target {
return true
}
}
return false
}
func normalizeBannerPlatform(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

View File

@ -3,6 +3,8 @@ package appconfig
import (
"testing"
"time"
"hyapp-admin-server/internal/model"
)
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
@ -52,7 +54,7 @@ func TestBannerModelFromRequestKeepsRoomScopeScheduleFields(t *testing.T) {
CoverURL: "https://cdn.example.com/banner.png",
RoomSmallImageURL: "https://cdn.example.com/banner-small.png",
BannerType: "h5",
DisplayScope: bannerDisplayScopeRoom,
DisplayScopes: []string{bannerDisplayScopeHome, bannerDisplayScopeRoom},
Param: "https://h5.example.com/activity",
Status: bannerStatusActive,
Platform: "android",
@ -62,7 +64,7 @@ func TestBannerModelFromRequestKeepsRoomScopeScheduleFields(t *testing.T) {
if err != nil {
t.Fatalf("banner model should be valid: %v", err)
}
if item.DisplayScope != bannerDisplayScopeRoom || item.RoomSmallImageURL == "" {
if item.DisplayScope != "home,room" || item.RoomSmallImageURL == "" {
t.Fatalf("room display fields mismatch: %+v", item)
}
if item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs || item.Status != bannerStatusActive {
@ -91,6 +93,30 @@ func TestBannerModelFromRequestExpiresEndedActiveBanner(t *testing.T) {
}
}
func TestAppBannerFromModelReturnsDisplayScopes(t *testing.T) {
item := appBannerFromModel(bannerModel("home,room,recharge"))
if item.DisplayScope != "home,room,recharge" {
t.Fatalf("display scope should keep stored value: %+v", item)
}
if len(item.DisplayScopes) != 3 || item.DisplayScopes[0] != bannerDisplayScopeHome || item.DisplayScopes[1] != bannerDisplayScopeRoom || item.DisplayScopes[2] != bannerDisplayScopeRecharge {
t.Fatalf("display scopes mismatch: %+v", item.DisplayScopes)
}
}
func bannerModel(displayScope string) model.AppBanner {
return model.AppBanner{
ID: 1,
AppCode: "lalu",
CoverURL: "https://cdn.example.com/banner.png",
BannerType: "h5",
DisplayScope: displayScope,
Param: "https://h5.example.com/activity",
Status: bannerStatusActive,
Platform: "android",
}
}
func TestExploreTabModelFromRequestValidatesRequiredFields(t *testing.T) {
item, err := exploreTabModelFromRequest("lalu", exploreTabRequest{
Enabled: true,

View File

@ -160,6 +160,24 @@ type operatorDTO struct {
Avatar string `json:"avatar,omitempty"`
}
type resourceShopItemDTO struct {
AppCode string `json:"appCode"`
ShopItemID int64 `json:"shopItemId"`
ResourceID int64 `json:"resourceId"`
Resource resourceDTO `json:"resource"`
Status string `json:"status"`
DurationDays int32 `json:"durationDays"`
PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"`
EffectiveFromMS int64 `json:"effectiveFromMs"`
EffectiveToMS int64 `json:"effectiveToMs"`
SortOrder int32 `json:"sortOrder"`
CreatedByUserID int64 `json:"createdByUserId"`
UpdatedByUserID int64 `json:"updatedByUserId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func resourceFromProto(item *walletv1.Resource) resourceDTO {
if item == nil {
return resourceDTO{}
@ -237,6 +255,29 @@ func resourcePointerFromProto(item *walletv1.Resource) *resourceDTO {
return &data
}
func resourceShopItemFromProto(item *walletv1.ResourceShopItem) resourceShopItemDTO {
if item == nil {
return resourceShopItemDTO{}
}
return resourceShopItemDTO{
AppCode: item.GetAppCode(),
ShopItemID: item.GetShopItemId(),
ResourceID: item.GetResourceId(),
Resource: resourceFromProto(item.GetResource()),
Status: item.GetStatus(),
DurationDays: item.GetDurationDays(),
PriceType: item.GetPriceType(),
CoinPrice: item.GetCoinPrice(),
EffectiveFromMS: item.GetEffectiveFromMs(),
EffectiveToMS: item.GetEffectiveToMs(),
SortOrder: item.GetSortOrder(),
CreatedByUserID: item.GetCreatedByUserId(),
UpdatedByUserID: item.GetUpdatedByUserId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func giftFromProto(gift *walletv1.GiftConfig) giftDTO {
if gift == nil {
return giftDTO{}

View File

@ -510,6 +510,58 @@ func (h *Handler) ListResourceGrants(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) ListResourceShopItems(c *gin.Context) {
options := shared.ListOptions(c)
resp, err := h.wallet.ListResourceShopItems(c.Request.Context(), &walletv1.ListResourceShopItemsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceType: strings.TrimSpace(c.Query("resource_type")),
Status: options.Status,
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取道具商店列表失败")
return
}
items := make([]resourceShopItemDTO, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
items = append(items, resourceShopItemFromProto(item))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) UpsertResourceShopItems(c *gin.Context) {
var req resourceShopItemsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "道具商店参数不正确")
return
}
resp, err := h.wallet.UpsertResourceShopItems(c.Request.Context(), req.upsertProto(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
items := make([]resourceShopItemDTO, 0, len(resp.GetItems()))
resourceIDs := make([]string, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
dto := resourceShopItemFromProto(item)
items = append(items, dto)
resourceIDs = append(resourceIDs, fmt.Sprintf("%d", dto.ResourceID))
}
h.auditLog(c, "upsert-resource-shop-items", "resource_shop_items", "batch", "success", strings.Join(resourceIDs, ","))
response.OK(c, items)
}
func (h *Handler) EnableResourceShopItem(c *gin.Context) {
h.setResourceShopItemStatus(c, "active")
}
func (h *Handler) DisableResourceShopItem(c *gin.Context) {
h.setResourceShopItemStatus(c, "disabled")
}
func (h *Handler) setResourceStatus(c *gin.Context, status string) {
resourceID, ok := parseID(c, "resource_id")
if !ok {
@ -574,6 +626,27 @@ func (h *Handler) setGiftStatus(c *gin.Context, status string) {
response.OK(c, gift)
}
func (h *Handler) setResourceShopItemStatus(c *gin.Context, status string) {
shopItemID, ok := parseID(c, "shop_item_id")
if !ok {
return
}
resp, err := h.wallet.SetResourceShopItemStatus(c.Request.Context(), &walletv1.SetResourceShopItemStatusRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ShopItemId: shopItemID,
Status: status,
OperatorUserId: actorID(c),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := resourceShopItemFromProto(resp.GetItem())
h.auditLog(c, "set-resource-shop-item-status", "resource_shop_items", fmt.Sprintf("%d", item.ShopItemID), "success", status)
response.OK(c, item)
}
func (h *Handler) auditLog(c *gin.Context, action string, resource string, resourceID string, status string, detail string) {
shared.OperationLogWithResourceID(c, h.audit, action, resource, resourceID, status, detail)
}

View File

@ -150,6 +150,20 @@ type grantGroupRequest struct {
Reason string `json:"reason"`
}
type resourceShopItemsRequest struct {
Items []resourceShopItemRequest `json:"items"`
}
type resourceShopItemRequest struct {
ShopItemID int64 `json:"shopItemId"`
ResourceID int64 `json:"resourceId"`
Status string `json:"status"`
DurationDays int32 `json:"durationDays"`
EffectiveFromMS int64 `json:"effectiveFromMs"`
EffectiveToMS int64 `json:"effectiveToMs"`
SortOrder int32 `json:"sortOrder"`
}
func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceRequest {
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
@ -347,6 +361,15 @@ func (r giftTypeItemRequest) upsertProto(c *gin.Context) *walletv1.UpsertGiftTyp
}.upsertProto(c, r.TabKey)
}
func (r resourceShopItemsRequest) upsertProto(c *gin.Context) *walletv1.UpsertResourceShopItemsRequest {
return &walletv1.UpsertResourceShopItemsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Items: resourceShopItemInputs(r.Items),
OperatorUserId: actorID(c),
}
}
func managerGrantEnabledOrDefault(value *bool) *bool {
enabled := true
if value != nil {
@ -380,6 +403,22 @@ func groupItemInputs(items []resourceGroupItemRequest) []*walletv1.ResourceGroup
return out
}
func resourceShopItemInputs(items []resourceShopItemRequest) []*walletv1.ResourceShopItemInput {
out := make([]*walletv1.ResourceShopItemInput, 0, len(items))
for _, item := range items {
out = append(out, &walletv1.ResourceShopItemInput{
ShopItemId: item.ShopItemID,
ResourceId: item.ResourceID,
Status: strings.TrimSpace(item.Status),
DurationDays: item.DurationDays,
EffectiveFromMs: item.EffectiveFromMS,
EffectiveToMs: item.EffectiveToMS,
SortOrder: item.SortOrder,
})
}
return out
}
func normalizeResourceType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

View File

@ -42,4 +42,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource)
protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup)
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)
protected.GET("/admin/resource-shop/items", middleware.RequirePermission("resource-shop:view"), h.ListResourceShopItems)
protected.POST("/admin/resource-shop/items", middleware.RequirePermission("resource-shop:update"), h.UpsertResourceShopItems)
protected.POST("/admin/resource-shop/items/:shop_item_id/enable", middleware.RequirePermission("resource-shop:update"), h.EnableResourceShopItem)
protected.POST("/admin/resource-shop/items/:shop_item_id/disable", middleware.RequirePermission("resource-shop:update"), h.DisableResourceShopItem)
}

View File

@ -23,7 +23,7 @@ func (s *Store) ListAppBanners(options AppBannerListOptions) ([]model.AppBanner,
query = query.Where("status = ?", strings.TrimSpace(options.Status))
}
if options.DisplayScope != "" {
query = query.Where("display_scope = ?", strings.TrimSpace(options.DisplayScope))
query = query.Where("FIND_IN_SET(?, display_scope) > 0", strings.TrimSpace(options.DisplayScope))
}
if options.Platform != "" {
query = query.Where("platform = ?", strings.TrimSpace(options.Platform))

View File

@ -41,6 +41,8 @@ var defaultPermissions = []model.Permission{
{Name: "资源查看", Code: "resource:view", Kind: "menu"},
{Name: "资源创建", Code: "resource:create", Kind: "button"},
{Name: "资源更新", Code: "resource:update", Kind: "button"},
{Name: "道具商店查看", Code: "resource-shop:view", Kind: "menu"},
{Name: "道具商店更新", Code: "resource-shop:update", Kind: "button"},
{Name: "资源组查看", Code: "resource-group:view", Kind: "menu"},
{Name: "资源组创建", Code: "resource-group:create", Kind: "button"},
{Name: "资源组更新", Code: "resource-group:update", Kind: "button"},
@ -228,10 +230,11 @@ func (s *Store) seedMenus() error {
{ParentID: &appConfigID, Title: "内购配置", Code: "payment-recharge-products", Path: "/app-config/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 69, Visible: true},
{ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 70, Visible: true},
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 68, Visible: true},
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 69, Visible: true},
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 70, Visible: true},
{ParentID: &resourceID, Title: "表情包列表", Code: "emoji-pack-list", Path: "/emoji-packs", Icon: "image", PermissionCode: "emoji-pack:view", Sort: 71, Visible: true},
{ParentID: &resourceID, Title: "道具商店", Code: "resource-shop-list", Path: "/resource-shop", Icon: "storefront", PermissionCode: "resource-shop:view", Sort: 68, Visible: true},
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 69, Visible: true},
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 70, Visible: true},
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 71, Visible: true},
{ParentID: &resourceID, Title: "表情包列表", Code: "emoji-pack-list", Path: "/emoji-packs", Icon: "image", PermissionCode: "emoji-pack:view", Sort: 72, Visible: true},
{ParentID: &operationsID, Title: "金币流水", Code: "operation-coin-ledger", Path: "/operations/coin-ledger", Icon: "receipt", PermissionCode: "coin-ledger:view", Sort: 68, Visible: true},
{ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 69, Visible: true},
{ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 70, Visible: true},
@ -468,6 +471,7 @@ func defaultRolePermissionCodes(code string) []string {
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"resource:view", "resource:create", "resource:update",
"resource-shop:view", "resource-shop:update",
"resource-group:view", "resource-group:create", "resource-group:update",
"resource-grant:view", "resource-grant:create",
"gift:view", "gift:create", "gift:update", "gift:status",
@ -492,7 +496,7 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
@ -506,6 +510,7 @@ func defaultRolePermissionCodes(code string) []string {
"app-config:view",
"app-version:view",
"resource:view",
"resource-shop:view",
"resource-group:view",
"resource-grant:view",
"gift:view",
@ -550,6 +555,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"level-config:view", "level-config:update",
"region-block:view", "region-block:update",
"resource:view", "resource:create", "resource:update",
"resource-shop:view", "resource-shop:update",
"resource-group:view", "resource-group:create", "resource-group:update",
"resource-grant:view", "resource-grant:create",
"gift:view", "gift:create", "gift:update", "gift:status",
@ -569,7 +575,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"vip-config:view", "vip-config:update", "vip-config:grant",
}
case "auditor", "readonly":
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"}
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"}
default:
return nil
}

View File

@ -0,0 +1,6 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Banner 显示范围支持多选display_scope 以稳定逗号分隔值保存,例如 home,room,recharge。
ALTER TABLE admin_app_banners
MODIFY COLUMN display_scope VARCHAR(128) NOT NULL DEFAULT 'home' COMMENT '显示范围,多选逗号分隔:首页、房间内或充值页';

View File

@ -0,0 +1,69 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('道具商店查看', 'resource-shop:view', 'menu', '', @now_ms, @now_ms),
('道具商店更新', 'resource-shop:update', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
(NULL, '资源管理', 'resources', '', 'inventory', '', 67, TRUE, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '道具商店', 'resource-shop-list', '/resource-shop', 'storefront', 'resource-shop:view', 68, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'resources'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
UPDATE admin_menus
SET sort = CASE code
WHEN 'resource-group-list' THEN 69
WHEN 'gift-list' THEN 70
WHEN 'resource-grant-list' THEN 71
WHEN 'emoji-pack-list' THEN 72
ELSE sort
END,
updated_at_ms = @now_ms
WHERE code IN ('resource-group-list', 'gift-list', 'resource-grant-list', 'emoji-pack-list');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'platform-admin'
AND admin_permission.code IN ('resource-shop:view', 'resource-shop:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'ops-admin'
AND admin_permission.code IN ('resource-shop:view', 'resource-shop:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('auditor', 'readonly')
AND admin_permission.code = 'resource-shop:view';

View File

@ -17,6 +17,13 @@ first_recharge_reward_worker:
enabled: false
red_packet_broadcast_worker:
enabled: false
lucky_gift_worker:
enabled: true
worker_poll_interval: "1s"
worker_batch_size: 100
worker_lock_ttl: "30s"
worker_max_retry: 8
publish_timeout: "5s"
tencent_im:
# Docker 本地联调全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置。
enabled: true

View File

@ -17,6 +17,13 @@ first_recharge_reward_worker:
enabled: true
red_packet_broadcast_worker:
enabled: true
lucky_gift_worker:
enabled: true
worker_poll_interval: "1s"
worker_batch_size: 100
worker_lock_ttl: "30s"
worker_max_retry: 8
publish_timeout: "5s"
tencent_im:
# 腾讯云 IM 应用配置;必须和 gateway 的 UserSig、room-service 房间群配置属于同一个 SDKAppID。
enabled: true

View File

@ -17,6 +17,13 @@ first_recharge_reward_worker:
enabled: false
red_packet_broadcast_worker:
enabled: false
lucky_gift_worker:
enabled: true
worker_poll_interval: "1s"
worker_batch_size: 100
worker_lock_ttl: "30s"
worker_max_retry: 8
publish_timeout: "5s"
tencent_im:
# activity-service 只负责全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置。
enabled: true

View File

@ -38,6 +38,9 @@ CREATE TABLE IF NOT EXISTS activity_outbox (
status VARCHAR(32) NOT NULL COMMENT '业务状态',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at_ms BIGINT NOT NULL COMMENT '下一次重试时间UTC epoch ms',
locked_by VARCHAR(128) NOT NULL DEFAULT '' COMMENT '当前锁定 worker',
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '锁过期时间UTC epoch ms',
last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次失败原因',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, outbox_id),
@ -194,6 +197,8 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records (
pool_snapshot_json JSON NOT NULL COMMENT '奖池快照',
rtp_snapshot_json JSON NOT NULL COMMENT 'RTP 快照',
reward_status VARCHAR(32) NOT NULL COMMENT 'pending/granted/failed',
reward_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '返奖钱包交易 ID',
reward_failure_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '返奖失败原因',
paid_at_ms BIGINT NOT NULL COMMENT '扣费时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',

View File

@ -49,9 +49,12 @@ type App struct {
healthHTTP *healthhttp.Server
mysqlRepo *mysqlstorage.Repository
broadcast *broadcastservice.Service
luckyGift *luckygiftservice.Service
firstRechargeReward *firstrechargeservice.Service
broadcastWorkerEnabled bool
luckyGiftWorkerEnabled bool
workerNodeID string
luckyGiftWorkerOptions luckygiftservice.WorkerOptions
mqConsumers []*rocketmqx.Consumer
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
@ -105,10 +108,10 @@ func New(cfg config.Config) (*App, error) {
sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
growthSvc := growthservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
achievementSvc := achievementservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
luckyGiftSvc := luckygiftservice.New(repository)
var tencentClient *tencentim.RESTClient
var broadcastPublisher broadcastservice.Publisher
if cfg.TencentIM.Enabled {
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
tencentClient, err = tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
if err != nil {
_ = walletConn.Close()
_ = userConn.Close()
@ -134,6 +137,11 @@ func New(cfg config.Config) (*App, error) {
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
}, repository, broadcastPublisher, client.NewGRPCRegionSource(userConn))
luckyGiftSvc := luckygiftservice.New(repository,
luckygiftservice.WithWallet(walletv1.NewWalletServiceClient(walletConn)),
luckygiftservice.WithRoomPublisher(tencentClient),
luckygiftservice.WithRegionBroadcaster(broadcastSvc),
)
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(svc))
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc))
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc, growthSvc, achievementSvc))
@ -263,9 +271,12 @@ func New(cfg config.Config) (*App, error) {
healthHTTP: healthHTTP,
mysqlRepo: repository,
broadcast: broadcastSvc,
luckyGift: luckyGiftSvc,
firstRechargeReward: firstRechargeRewardSvc,
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
luckyGiftWorkerEnabled: cfg.LuckyGiftWorker.Enabled,
workerNodeID: cfg.NodeID,
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
mqConsumers: mqConsumers,
userConn: userConn,
walletConn: walletConn,
@ -287,6 +298,11 @@ func (a *App) Run() error {
a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{})
})
}
if a.luckyGiftWorkerEnabled && a.luckyGift != nil && a.workerCtx != nil {
a.workerWG.Go(func() {
a.luckyGift.RunWorker(a.workerCtx, a.luckyGiftWorkerOptions)
})
}
err := a.server.Serve(a.listener)
if a.workerStop != nil {
a.workerStop()
@ -481,3 +497,14 @@ func redPacketRegionIDFromWalletPayload(payload string) int64 {
return 0
}
}
func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luckygiftservice.WorkerOptions {
return luckygiftservice.WorkerOptions{
WorkerID: nodeID + "-lucky-gift",
PollInterval: cfg.WorkerPollInterval,
BatchSize: cfg.WorkerBatchSize,
LockTTL: cfg.WorkerLockTTL,
MaxRetry: cfg.WorkerMaxRetry,
PublishTimeout: cfg.PublishTimeout,
}
}

View File

@ -29,6 +29,8 @@ type Config struct {
FirstRechargeRewardWorker FirstRechargeRewardWorkerConfig `yaml:"first_recharge_reward_worker"`
// RedPacketBroadcastWorker 控制红包创建事实转区域飘屏 outbox 的本地消费。
RedPacketBroadcastWorker RedPacketBroadcastWorkerConfig `yaml:"red_packet_broadcast_worker"`
// LuckyGiftWorker 控制幸运礼物 draw outbox 的返奖和房间 IM 补偿。
LuckyGiftWorker LuckyGiftWorkerConfig `yaml:"lucky_gift_worker"`
// TencentIM 是 activity-service 发送全局/区域播报群消息的 REST 配置。
TencentIM TencentIMConfig `yaml:"tencent_im"`
// Broadcast 控制 IM 播报群 reconciler、outbox worker 和贵重礼物阈值。
@ -65,6 +67,16 @@ type RedPacketBroadcastWorkerConfig struct {
Enabled bool `yaml:"enabled"`
}
// LuckyGiftWorkerConfig 保存幸运礼物抽奖副作用补偿策略。
type LuckyGiftWorkerConfig struct {
Enabled bool `yaml:"enabled"`
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
WorkerBatchSize int `yaml:"worker_batch_size"`
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
WorkerMaxRetry int `yaml:"worker_max_retry"`
PublishTimeout time.Duration `yaml:"publish_timeout"`
}
// TencentIMConfig 描述 activity-service 调用腾讯云 IM REST API 的配置。
type TencentIMConfig struct {
Enabled bool `yaml:"enabled"`
@ -153,6 +165,14 @@ func Default() Config {
RedPacketBroadcastWorker: RedPacketBroadcastWorkerConfig{
Enabled: false,
},
LuckyGiftWorker: LuckyGiftWorkerConfig{
Enabled: false,
WorkerPollInterval: time.Second,
WorkerBatchSize: 100,
WorkerLockTTL: 30 * time.Second,
WorkerMaxRetry: 8,
PublishTimeout: 5 * time.Second,
},
TencentIM: TencentIMConfig{
Enabled: false,
AdminIdentifier: "administrator",
@ -271,6 +291,21 @@ func Load(path string) (Config, error) {
if cfg.Broadcast.WorkerMaxRetry <= 0 {
cfg.Broadcast.WorkerMaxRetry = 8
}
if cfg.LuckyGiftWorker.WorkerPollInterval <= 0 {
cfg.LuckyGiftWorker.WorkerPollInterval = time.Second
}
if cfg.LuckyGiftWorker.WorkerBatchSize <= 0 {
cfg.LuckyGiftWorker.WorkerBatchSize = 100
}
if cfg.LuckyGiftWorker.WorkerLockTTL <= 0 {
cfg.LuckyGiftWorker.WorkerLockTTL = 30 * time.Second
}
if cfg.LuckyGiftWorker.WorkerMaxRetry <= 0 {
cfg.LuckyGiftWorker.WorkerMaxRetry = 8
}
if cfg.LuckyGiftWorker.PublishTimeout <= 0 {
cfg.LuckyGiftWorker.PublishTimeout = 5 * time.Second
}
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
if err != nil {
return Config{}, err
@ -279,6 +314,9 @@ func Load(path string) (Config, error) {
if (cfg.FirstRechargeRewardWorker.Enabled || cfg.RedPacketBroadcastWorker.Enabled) && !cfg.RocketMQ.WalletOutbox.Enabled {
return Config{}, errors.New("wallet outbox workers require rocketmq.wallet_outbox.enabled")
}
if cfg.LuckyGiftWorker.Enabled && !cfg.TencentIM.Enabled {
return Config{}, errors.New("lucky gift worker requires tencent_im.enabled")
}
return cfg, nil
}

View File

@ -14,6 +14,8 @@ const (
TypeRedPacket = "red_packet"
// TypeRoomTreasure 是语音房宝箱满能量后的开箱倒计时播报。
TypeRoomTreasure = "room_treasure"
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前默认 1 倍及以上即进入播报 outbox。
TypeLuckyGiftBigWin = "lucky_gift_big_win"
// StatusPending 表示消息已持久化,尚未被 worker claim。
StatusPending = "pending"

View File

@ -8,6 +8,8 @@ const (
StatusPaused = "paused"
StatusClosed = "closed"
StatusPending = "pending"
StatusGranted = "granted"
StatusFailed = "failed"
PoolNovice = "novice"
PoolIntermediate = "intermediate"
@ -82,15 +84,18 @@ type CheckCommand struct {
}
type DrawCommand struct {
CommandID string
PoolID string
UserID int64
DeviceID string
RoomID string
AnchorID string
GiftID string
CoinSpent int64
PaidAtMS int64
CommandID string
PoolID string
UserID int64
TargetUserID int64
DeviceID string
RoomID string
AnchorID string
GiftID string
GiftCount int32
CoinSpent int64
PaidAtMS int64
VisibleRegionID int64
}
type CheckResult struct {
@ -157,3 +162,13 @@ type DrawQuery struct {
Page int32
PageSize int32
}
// DrawOutbox 是幸运礼物抽奖事务写出的异步副作用载体worker 只消费这个持久事实。
type DrawOutbox struct {
AppCode string
OutboxID string
EventType string
PayloadJSON string
RetryCount int
CreatedAtMS int64
}

View File

@ -122,8 +122,8 @@ func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64
if len(values) == 0 {
values = defaultMultiplierPPMs()
}
seen := map[int64]bool{0: true}
out := []int64{0}
seen := map[int64]bool{}
out := make([]int64, 0, len(values))
for _, value := range values {
if value < 0 || seen[value] {
continue
@ -131,6 +131,9 @@ func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64
seen[value] = true
out = append(out, value)
}
if len(out) == 0 {
out = defaultMultiplierPPMs()
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}

View File

@ -41,6 +41,19 @@ func TestNormalizeTiersBuildsMultiplierDrivenTiers(t *testing.T) {
}
}
func TestNormalizeTiersDoesNotForceZeroMultiplier(t *testing.T) {
tiers, multipliers := normalizeTiers(nil, []int64{1_000_000, 2_000_000}, 500, 100)
if len(multipliers) != 2 || multipliers[0] != 1_000_000 || multipliers[1] != 2_000_000 {
t.Fatalf("expected positive-only multiplier list to stay positive-only, got %#v", multipliers)
}
for _, tier := range tiers {
if tier.MultiplierPPM == 0 || tier.RewardCoins == 0 {
t.Fatalf("positive-only test config must not generate none tier: %#v", tier)
}
}
}
func TestValidateConfigRejectsMultiplierListBelowTargetRTP(t *testing.T) {
config := DefaultConfig("hyapp", "pool_low")
config.MultiplierPPMs = []int64{0, 500_000}

View File

@ -2,12 +2,21 @@ package luckygift
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
domain "hyapp/services/activity-service/internal/domain/luckygift"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
)
// Repository 是幸运礼物唯一持久化边界RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。
@ -19,16 +28,66 @@ type Repository interface {
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error)
MarkLuckyGiftOutboxDelivered(ctx context.Context, event domain.DrawOutbox, nowMS int64) error
MarkLuckyGiftOutboxRetryable(ctx context.Context, event domain.DrawOutbox, retryCount int, nextRetryAtMS int64, lastErr string, nowMS int64) error
MarkLuckyGiftOutboxFailed(ctx context.Context, event domain.DrawOutbox, retryCount int, lastErr string, nowMS int64) error
MarkLuckyGiftDrawGranted(ctx context.Context, appCode string, drawID string, walletTransactionID string, nowMS int64) error
MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error
}
// WalletClient 是幸运礼物返奖唯一账务依赖;实际余额变更通知继续由 wallet outbox -> notice-service 投递。
type WalletClient interface {
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
}
// RoomPublisher 只负责投递房间群自定义消息,不拥有房间状态。
type RoomPublisher interface {
PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
}
// RegionBroadcaster 只写 activity-service 自有的区域播报 outbox真正发腾讯 IM 仍由 broadcast worker 补偿。
type RegionBroadcaster interface {
PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error)
}
// Service 承载幸运礼物查询、抽奖命令和后台规则配置。
type Service struct {
repository Repository
now func() time.Time
repository Repository
wallet WalletClient
publisher RoomPublisher
broadcaster RegionBroadcaster
now func() time.Time
}
func New(repository Repository) *Service {
return &Service{repository: repository, now: time.Now}
type Option func(*Service)
func WithWallet(client WalletClient) Option {
return func(s *Service) {
s.wallet = client
}
}
func WithRoomPublisher(publisher RoomPublisher) Option {
return func(s *Service) {
s.publisher = publisher
}
}
func WithRegionBroadcaster(broadcaster RegionBroadcaster) Option {
return func(s *Service) {
s.broadcaster = broadcaster
}
}
func New(repository Repository, options ...Option) *Service {
service := &Service{repository: repository, now: time.Now}
for _, option := range options {
if option != nil {
option(service)
}
}
return service
}
func (s *Service) SetClock(now func() time.Time) {
@ -63,12 +122,156 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
if cmd.CommandID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
}
if cmd.TargetUserID < 0 || cmd.GiftCount < 0 {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
}
if cmd.PaidAtMS <= 0 {
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
return s.repository.ExecuteLuckyGiftDraw(ctx, cmd, s.now().UTC().UnixMilli())
}
type WorkerOptions struct {
WorkerID string
PollInterval time.Duration
BatchSize int
LockTTL time.Duration
MaxRetry int
PublishTimeout time.Duration
}
func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
options = normalizeWorkerOptions(options)
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
for {
if _, err := s.ProcessPendingDrawOutbox(ctx, options); err != nil && ctx.Err() == nil {
// 批处理失败不删除 outbox锁过期后下一轮或其他实例会继续补偿。
logx.Error(ctx, "lucky_gift_outbox_batch_failed", err, slog.String("worker_id", options.WorkerID))
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *Service) ProcessPendingDrawOutbox(ctx context.Context, options WorkerOptions) (int, error) {
if err := s.requireRepository(); err != nil {
return 0, err
}
options = normalizeWorkerOptions(options)
events, err := s.repository.ClaimPendingLuckyGiftOutbox(ctx, options.WorkerID, s.now().UTC().UnixMilli(), options.LockTTL, options.BatchSize)
if err != nil {
return 0, err
}
processed := 0
for _, event := range events {
if err := s.processDrawOutbox(ctx, event, options); err != nil {
return processed, err
}
processed++
}
return processed, nil
}
func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox, options WorkerOptions) error {
payload, err := decodeDrawOutboxPayload(event.PayloadJSON)
if err != nil {
return s.markOutboxFailed(ctx, event, options, err, false)
}
eventCtx := appcode.WithContext(ctx, event.AppCode)
walletTransactionID := ""
credited := false
if payload.EffectiveRewardCoins > 0 {
if s.wallet == nil {
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift wallet client is not configured"), false)
}
resp, err := s.wallet.CreditLuckyGiftReward(eventCtx, &walletv1.CreditLuckyGiftRewardRequest{
CommandId: "lucky_reward:" + payload.DrawID,
AppCode: event.AppCode,
TargetUserId: payload.UserID,
Amount: payload.EffectiveRewardCoins,
DrawId: payload.DrawID,
RoomId: payload.RoomID,
GiftId: payload.GiftID,
PoolId: payload.PoolID,
Reason: "lucky_gift_reward",
})
if err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, false)
}
walletTransactionID = resp.GetTransactionId()
credited = true
}
if s.publisher == nil {
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift room publisher is not configured"), credited)
}
if shouldPublishLuckyGiftRegionBroadcast(payload) {
if s.broadcaster == nil {
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift region broadcaster is not configured"), credited)
}
if err := s.publishLuckyGiftRegionBroadcast(eventCtx, payload); err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, credited)
}
}
if err := s.publishLuckyGiftDrawn(eventCtx, payload, options); err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, credited)
}
nowMS := s.now().UTC().UnixMilli()
if err := s.repository.MarkLuckyGiftDrawGranted(eventCtx, event.AppCode, payload.DrawID, walletTransactionID, nowMS); err != nil {
return err
}
return s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, nowMS)
}
func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDrawnPayload, options WorkerOptions) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
defer cancel()
return s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
GroupID: payload.RoomID,
EventID: payload.EventID,
Desc: "lucky_gift_drawn",
Ext: "room_system_message",
PayloadJSON: body,
})
}
func (s *Service) publishLuckyGiftRegionBroadcast(ctx context.Context, payload luckyGiftDrawnPayload) error {
if payload.VisibleRegionID <= 0 {
return nil
}
body, err := json.Marshal(luckyGiftRegionBroadcastPayload(payload, s.now().UTC().UnixMilli()))
if err != nil {
return err
}
_, err = s.broadcaster.PublishRegionBroadcast(ctx, broadcastservice.PublishInput{
EventID: "lucky_gift_big_win:" + payload.DrawID,
BroadcastType: broadcastdomain.TypeLuckyGiftBigWin,
RegionID: payload.VisibleRegionID,
PayloadJSON: string(body),
})
return err
}
func (s *Service) markOutboxFailed(ctx context.Context, event domain.DrawOutbox, options WorkerOptions, cause error, alreadyCredited bool) error {
nowMS := s.now().UTC().UnixMilli()
nextRetryCount := event.RetryCount + 1
if nextRetryCount >= options.MaxRetry {
if !alreadyCredited {
_ = s.repository.MarkLuckyGiftDrawFailed(ctx, event.AppCode, drawIDFromPayload(event.PayloadJSON), cause.Error(), nowMS)
}
return s.repository.MarkLuckyGiftOutboxFailed(ctx, event, nextRetryCount, cause.Error(), nowMS)
}
nextRetryAtMS := nowMS + luckyGiftBackoff(nextRetryCount).Milliseconds()
return s.repository.MarkLuckyGiftOutboxRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMS)
}
func (s *Service) GetConfig(ctx context.Context, poolID string) (domain.Config, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
@ -143,3 +346,138 @@ func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {
query.Status = strings.TrimSpace(query.Status)
return query
}
type luckyGiftDrawnPayload struct {
EventType string `json:"event_type"`
EventID string `json:"event_id"`
AppCode string `json:"app_code"`
DrawID string `json:"draw_id"`
CommandID string `json:"command_id"`
PoolID string `json:"pool_id"`
RoomID string `json:"room_id"`
GiftID string `json:"gift_id"`
GiftCount int32 `json:"gift_count"`
UserID int64 `json:"user_id"`
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
VisibleRegionID int64 `json:"visible_region_id"`
CoinSpent int64 `json:"coin_spent"`
RuleVersion int64 `json:"rule_version"`
ExperiencePool string `json:"experience_pool"`
SelectedTierID string `json:"selected_tier_id"`
MultiplierPPM int64 `json:"multiplier_ppm"`
BaseRewardCoins int64 `json:"base_reward_coins"`
RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"`
ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"`
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
StageFeedback bool `json:"stage_feedback"`
HighMultiplier bool `json:"high_multiplier"`
CreatedAtMS int64 `json:"created_at_ms"`
}
func shouldPublishLuckyGiftRegionBroadcast(payload luckyGiftDrawnPayload) bool {
// 当前产品策略先把 1 倍作为默认区域飘屏阈值;后续接后台配置时只替换这里的阈值来源。
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= 1_000_000
}
func luckyGiftRegionBroadcastPayload(payload luckyGiftDrawnPayload, sentAtMS int64) map[string]any {
return map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_big_win:" + payload.DrawID,
"broadcast_type": broadcastdomain.TypeLuckyGiftBigWin,
"scope": broadcastdomain.ScopeRegion,
"app_code": payload.AppCode,
"region_id": payload.VisibleRegionID,
"visible_region_id": payload.VisibleRegionID,
"draw_id": payload.DrawID,
"command_id": payload.CommandID,
"pool_id": payload.PoolID,
"room_id": payload.RoomID,
"gift_id": payload.GiftID,
"gift_count": payload.GiftCount,
"user_id": payload.UserID,
"sender_user_id": payload.SenderUserID,
"target_user_id": payload.TargetUserID,
"coin_spent": payload.CoinSpent,
"rule_version": payload.RuleVersion,
"experience_pool": payload.ExperiencePool,
"selected_tier_id": payload.SelectedTierID,
"multiplier_ppm": payload.MultiplierPPM,
"base_reward_coins": payload.BaseRewardCoins,
"room_atmosphere_reward_coins": payload.RoomAtmosphereRewardCoins,
"activity_subsidy_coins": payload.ActivitySubsidyCoins,
"effective_reward_coins": payload.EffectiveRewardCoins,
"stage_feedback": payload.StageFeedback,
"high_multiplier": payload.HighMultiplier,
"created_at_ms": payload.CreatedAtMS,
"sent_at_ms": sentAtMS,
"action": map[string]any{
"type": "enter_room",
"room_id": payload.RoomID,
},
}
}
func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error) {
var payload luckyGiftDrawnPayload
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
return luckyGiftDrawnPayload{}, fmt.Errorf("invalid lucky gift outbox payload: %w", err)
}
if payload.EventType == "" {
payload.EventType = "lucky_gift_drawn"
}
if payload.EventID == "" && payload.DrawID != "" {
payload.EventID = "lucky_gift_drawn:" + payload.DrawID
}
if payload.SenderUserID == 0 {
payload.SenderUserID = payload.UserID
}
if payload.DrawID == "" || payload.EventID == "" || payload.RoomID == "" || payload.GiftID == "" || payload.UserID <= 0 {
return luckyGiftDrawnPayload{}, fmt.Errorf("lucky gift outbox payload is incomplete")
}
return payload, nil
}
func drawIDFromPayload(payloadJSON string) string {
payload, err := decodeDrawOutboxPayload(payloadJSON)
if err != nil {
return ""
}
return payload.DrawID
}
func normalizeWorkerOptions(options WorkerOptions) WorkerOptions {
options.WorkerID = strings.TrimSpace(options.WorkerID)
if options.WorkerID == "" {
options.WorkerID = "lucky-gift-worker"
}
if options.PollInterval <= 0 {
options.PollInterval = time.Second
}
if options.BatchSize <= 0 {
options.BatchSize = 100
}
if options.BatchSize > 500 {
options.BatchSize = 500
}
if options.LockTTL <= 0 {
options.LockTTL = 30 * time.Second
}
if options.MaxRetry <= 0 {
options.MaxRetry = 8
}
if options.PublishTimeout <= 0 {
options.PublishTimeout = 5 * time.Second
}
return options
}
func luckyGiftBackoff(retryCount int) time.Duration {
if retryCount < 1 {
retryCount = 1
}
if retryCount > 6 {
retryCount = 6
}
return time.Duration(1<<retryCount) * time.Second
}

View File

@ -2,10 +2,17 @@ package luckygift
import (
"context"
"encoding/json"
"testing"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/tencentim"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
domain "hyapp/services/activity-service/internal/domain/luckygift"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
)
func TestGetConfigReturnsPoolDefault(t *testing.T) {
@ -38,9 +45,76 @@ func TestUpsertConfigPreservesPoolScope(t *testing.T) {
}
}
func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomIM(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_test",
"app_code": "lalu",
"draw_id": "lucky_draw_test",
"command_id": "cmd-gift",
"pool_id": "super_lucky",
"user_id": 42,
"sender_user_id": 42,
"target_user_id": 99,
"visible_region_id": 210,
"room_id": "room-1",
"gift_id": "rose",
"gift_count": 1,
"coin_spent": 100,
"effective_reward_coins": 300,
"multiplier_ppm": 3000000,
"stage_feedback": true,
"created_at_ms": 1760000000000,
})
repository := &fakeLuckyGiftRepository{
outbox: []domain.DrawOutbox{{
AppCode: "lalu",
OutboxID: "lucky_lucky_draw_test",
EventType: "LuckyGiftDrawn",
PayloadJSON: string(payload),
}},
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil {
t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
}
if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed)
}
if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 {
t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
}
if publisher.last.GroupID != "room-1" || publisher.last.Desc != "lucky_gift_drawn" || publisher.last.EventID != "lucky_gift_drawn:lucky_draw_test" {
t.Fatalf("room im message mismatch: %+v", publisher.last)
}
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_test" || broadcaster.last.BroadcastType != broadcastdomain.TypeLuckyGiftBigWin || broadcaster.last.RegionID != 210 {
t.Fatalf("region lucky broadcast mismatch: %+v", broadcaster.last)
}
var broadcastPayload map[string]any
if err := json.Unmarshal([]byte(broadcaster.last.PayloadJSON), &broadcastPayload); err != nil {
t.Fatalf("decode region broadcast payload failed: %v", err)
}
if broadcastPayload["event_type"] != "lucky_gift_drawn" || broadcastPayload["broadcast_type"] != broadcastdomain.TypeLuckyGiftBigWin || broadcastPayload["room_id"] != "room-1" {
t.Fatalf("region broadcast payload mismatch: %+v", broadcastPayload)
}
if repository.grantedDrawID != "lucky_draw_test" || repository.deliveredOutboxID != "lucky_lucky_draw_test" {
t.Fatalf("repository settlement mismatch: granted=%s delivered=%s", repository.grantedDrawID, repository.deliveredOutboxID)
}
}
type fakeLuckyGiftRepository struct {
getConfigCalls int
upserted domain.Config
outbox []domain.DrawOutbox
grantedDrawID string
deliveredOutboxID string
}
func (r *fakeLuckyGiftRepository) GetLuckyGiftConfig(context.Context, string) (domain.Config, bool, error) {
@ -72,3 +146,56 @@ func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.Dra
func (r *fakeLuckyGiftRepository) GetLuckyGiftDrawSummary(context.Context, domain.DrawQuery) (domain.DrawSummary, error) {
return domain.DrawSummary{}, nil
}
func (r *fakeLuckyGiftRepository) ClaimPendingLuckyGiftOutbox(context.Context, string, int64, time.Duration, int) ([]domain.DrawOutbox, error) {
return r.outbox, nil
}
func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxDelivered(_ context.Context, event domain.DrawOutbox, _ int64) error {
r.deliveredOutboxID = event.OutboxID
return nil
}
func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxRetryable(context.Context, domain.DrawOutbox, int, int64, string, int64) error {
return nil
}
func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxFailed(context.Context, domain.DrawOutbox, int, string, int64) error {
return nil
}
func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawGranted(_ context.Context, _ string, drawID string, _ string, _ int64) error {
r.grantedDrawID = drawID
return nil
}
func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawFailed(context.Context, string, string, string, int64) error {
return nil
}
type fakeLuckyGiftWallet struct {
last *walletv1.CreditLuckyGiftRewardRequest
}
func (w *fakeLuckyGiftWallet) CreditLuckyGiftReward(_ context.Context, req *walletv1.CreditLuckyGiftRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error) {
w.last = req
return &walletv1.CreditLuckyGiftRewardResponse{TransactionId: "wallet_tx_lucky", Amount: req.GetAmount()}, nil
}
type fakeLuckyGiftPublisher struct {
last tencentim.CustomGroupMessage
}
func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
p.last = message
return nil
}
type fakeLuckyGiftRegionBroadcaster struct {
last broadcastservice.PublishInput
}
func (b *fakeLuckyGiftRegionBroadcaster) PublishRegionBroadcast(_ context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error) {
b.last = input
return broadcastdomain.PublishResult{EventID: input.EventID, Status: broadcastdomain.StatusPending, Created: true}, nil
}

View File

@ -382,6 +382,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
drawID := idgen.New("lucky_draw")
budgetSourcesJSON := luckyBudgetSourcesJSON(candidate)
rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback)
if err := r.applyLuckyDraw(ctx, tx, luckyApplyInput{
AppCode: appCode,
DrawID: drawID,
@ -399,6 +400,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
Atmosphere: atmosphere,
ActivityDay: activityDay,
BudgetSourcesJSON: budgetSourcesJSON,
RewardStatus: rewardStatus,
NowMS: nowMS,
}); err != nil {
return domain.DrawResult{}, err
@ -421,7 +423,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
ActivitySubsidyCoins: candidate.ActivityReward,
EffectiveRewardCoins: candidate.effectiveReward(),
BudgetSourcesJSON: budgetSourcesJSON,
RewardStatus: domain.StatusPending,
RewardStatus: rewardStatus,
RTPWindowIndex: globalWindow.WindowIndex,
GiftRTPWindowIndex: giftWindow.WindowIndex,
GlobalBaseRTPPPM: luckyRTPPPM(globalWindow.WagerCoins, globalWindow.ActualPayoutCoins+candidate.BaseReward),
@ -449,6 +451,7 @@ type luckyApplyInput struct {
Atmosphere luckyPool
ActivityDay string
BudgetSourcesJSON string
RewardStatus string
NowMS int64
}
@ -556,33 +559,52 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index,
selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier,
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status,
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status, reward_transaction_id, reward_failure_reason,
paid_at_ms, created_at_ms, updated_at_ms
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
input.AppCode, input.DrawID, input.Command.CommandID, input.Command.UserID, input.Command.DeviceID,
input.Command.RoomID, input.Command.AnchorID, input.Config.GiftID, input.Command.GiftID, input.Command.CoinSpent,
input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex,
input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier,
string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot), domain.StatusPending,
string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot), input.RewardStatus, "", "",
input.Command.PaidAtMS, input.NowMS, input.NowMS,
); err != nil {
return err
}
if input.Candidate.effectiveReward() > 0 || input.StageFeedback || input.Candidate.Presentation {
if luckyDrawNeedsOutbox(input.Candidate, input.StageFeedback) {
payload, _ := json.Marshal(map[string]any{
"draw_id": input.DrawID,
"pool_id": input.Config.GiftID,
"user_id": input.Command.UserID,
"room_id": input.Command.RoomID,
"gift_id": input.Command.GiftID,
"effective_reward_coins": input.Candidate.effectiveReward(),
"stage_feedback": input.StageFeedback,
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:" + input.DrawID,
"app_code": input.AppCode,
"draw_id": input.DrawID,
"command_id": input.Command.CommandID,
"pool_id": input.Config.GiftID,
"user_id": input.Command.UserID,
"sender_user_id": input.Command.UserID,
"target_user_id": input.Command.TargetUserID,
"visible_region_id": input.Command.VisibleRegionID,
"room_id": input.Command.RoomID,
"gift_id": input.Command.GiftID,
"gift_count": input.Command.GiftCount,
"coin_spent": input.Command.CoinSpent,
"rule_version": input.Config.RuleVersion,
"experience_pool": input.ExperiencePool,
"selected_tier_id": input.Candidate.TierID,
"multiplier_ppm": input.Candidate.MultiplierPPM,
"base_reward_coins": input.Candidate.BaseReward,
"room_atmosphere_reward_coins": input.Candidate.RoomReward,
"activity_subsidy_coins": input.Candidate.ActivityReward,
"effective_reward_coins": input.Candidate.effectiveReward(),
"stage_feedback": input.StageFeedback,
"high_multiplier": input.Candidate.HighMultiplier,
"created_at_ms": input.NowMS,
})
if _, err := tx.ExecContext(ctx, `
INSERT INTO activity_outbox (
app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, 'LuckyGiftDrawn', ?, 'pending', 0, ?, ?, ?)`,
app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms,
locked_by, lock_until_ms, last_error, created_at_ms, updated_at_ms
) VALUES (?, ?, 'LuckyGiftDrawn', ?, 'pending', 0, ?, '', 0, '', ?, ?)`,
input.AppCode, "lucky_"+input.DrawID, payload, input.NowMS, input.NowMS, input.NowMS,
); err != nil {
return err
@ -591,6 +613,155 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
return nil
}
// ClaimPendingLuckyGiftOutbox 抢占幸运礼物抽奖副作用。抽奖事实已在同一事务落库worker 只补偿钱包和房间 IM。
func (r *Repository) ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return nil, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if batchSize <= 0 {
batchSize = 100
}
if batchSize > 500 {
batchSize = 500
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `
SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms
FROM activity_outbox
WHERE event_type = 'LuckyGiftDrawn'
AND (
(status IN ('pending', 'retryable') AND next_retry_at_ms <= ?)
OR (status = 'delivering' AND lock_until_ms <= ?)
)
ORDER BY created_at_ms ASC, outbox_id ASC
LIMIT ?
FOR UPDATE`, nowMS, nowMS, batchSize)
if err != nil {
return nil, err
}
defer rows.Close()
events := make([]domain.DrawOutbox, 0, batchSize)
for rows.Next() {
var event domain.DrawOutbox
if err := rows.Scan(&event.AppCode, &event.OutboxID, &event.EventType, &event.PayloadJSON, &event.RetryCount, &event.CreatedAtMS); err != nil {
return nil, err
}
events = append(events, event)
}
if err := rows.Err(); err != nil {
return nil, err
}
lockUntilMS := nowMS + lockTTL.Milliseconds()
for _, event := range events {
if _, err := tx.ExecContext(ctx, `
UPDATE activity_outbox
SET status = 'delivering', locked_by = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND outbox_id = ?`,
workerID, lockUntilMS, nowMS, event.AppCode, event.OutboxID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return events, nil
}
func (r *Repository) MarkLuckyGiftOutboxDelivered(ctx context.Context, event domain.DrawOutbox, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE activity_outbox
SET status = 'delivered', locked_by = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
WHERE app_code = ? AND outbox_id = ?`,
nowMS, appcode.Normalize(event.AppCode), event.OutboxID,
)
return err
}
func (r *Repository) MarkLuckyGiftOutboxRetryable(ctx context.Context, event domain.DrawOutbox, retryCount int, nextRetryAtMS int64, lastErr string, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE activity_outbox
SET status = 'retryable', retry_count = ?, next_retry_at_ms = ?, locked_by = '', lock_until_ms = 0,
last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND outbox_id = ?`,
retryCount, nextRetryAtMS, trimLuckyLastError(lastErr), nowMS, appcode.Normalize(event.AppCode), event.OutboxID,
)
return err
}
func (r *Repository) MarkLuckyGiftOutboxFailed(ctx context.Context, event domain.DrawOutbox, retryCount int, lastErr string, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE activity_outbox
SET status = 'failed', retry_count = ?, locked_by = '', lock_until_ms = 0, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND outbox_id = ?`,
retryCount, trimLuckyLastError(lastErr), nowMS, appcode.Normalize(event.AppCode), event.OutboxID,
)
return err
}
func (r *Repository) MarkLuckyGiftDrawGranted(ctx context.Context, appCode string, drawID string, walletTransactionID string, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE lucky_draw_records
SET reward_status = ?, reward_transaction_id = ?, reward_failure_reason = '', updated_at_ms = ?
WHERE app_code = ? AND draw_id = ?`,
domain.StatusGranted, walletTransactionID, nowMS, appcode.Normalize(appCode), drawID,
)
return err
}
func (r *Repository) MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error {
if r == nil || r.db == nil || strings.TrimSpace(drawID) == "" {
return nil
}
_, err := r.db.ExecContext(ctx, `
UPDATE lucky_draw_records
SET reward_status = ?, reward_failure_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND draw_id = ? AND reward_status = ?`,
domain.StatusFailed, trimLuckyFailureReason(failureReason), nowMS, appcode.Normalize(appCode), drawID, domain.StatusPending,
)
return err
}
func trimLuckyLastError(value string) string {
value = strings.TrimSpace(value)
if len(value) > 512 {
return value[:512]
}
return value
}
func trimLuckyFailureReason(value string) string {
value = strings.TrimSpace(value)
if len(value) > 255 {
return value[:255]
}
return value
}
func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -1155,22 +1326,17 @@ func (r *Repository) updateLuckyRiskCounters(ctx context.Context, tx *sql.Tx, ap
func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, stageFeedback bool, globalWindow, giftWindow luckyRTPWindow, platformPool, roomPool, giftPool, atmosphere luckyPool, activityRemaining int64, counters map[string]luckyCounter, minFutureMax int64) (luckyCandidate, map[string]bool, error) {
minRequired := maxInt64(globalWindow.minRequired(minFutureMax), giftWindow.minRequired(minFutureMax))
rtpMaxAllowed := minInt64(globalWindow.remainingPayout(), giftWindow.remainingPayout())
poolCapacity := luckyWeightedPoolCapacity(platformPool, roomPool, giftPool, config)
riskCapacity := luckyRiskCapacity(config, counters)
baseMaxAllowed := minInt64(rtpMaxAllowed, minInt64(poolCapacity, riskCapacity))
baseMaxAllowed := minInt64(poolCapacity, riskCapacity)
limited := map[string]bool{}
baseCandidates := make([]luckyCandidate, 0, len(config.Tiers))
hasZeroCandidate := false
var totalWeight int64
for _, tier := range config.Tiers {
if tier.Pool != pool || !tier.Enabled {
continue
}
if tier.RewardCoins == 0 {
hasZeroCandidate = true
}
highMultiplier := tier.HighWaterOnly || tier.RewardCoins >= config.GiftPrice*config.HighMultiplier
if highMultiplier && !config.LargeTierEnabled {
limited["large_tier_disabled"] = true
@ -1201,9 +1367,6 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
HighMultiplier: highMultiplier,
})
}
if minRequired == 0 && !hasZeroCandidate {
baseCandidates = append(baseCandidates, luckyCandidate{TierID: pool + "_none", Source: domain.SourceBaseRTP, Pool: pool})
}
candidates := luckyAutoWeightedBaseCandidates(baseCandidates, minRequired, globalWindow, giftWindow)
for _, candidate := range candidates {
totalWeight += candidate.Weight
@ -1425,19 +1588,15 @@ func luckyDedupeRewardCandidates(candidates []luckyCandidate) []luckyCandidate {
}
func luckyDesiredBaseReward(globalWindow, giftWindow luckyRTPWindow) int64 {
return minInt64(luckyWindowAveragePayout(globalWindow), luckyWindowAveragePayout(giftWindow))
return minInt64(luckyWindowBaseDeficit(globalWindow), luckyWindowBaseDeficit(giftWindow))
}
func luckyWindowAveragePayout(window luckyRTPWindow) int64 {
func luckyWindowBaseDeficit(window luckyRTPWindow) int64 {
remaining := window.remainingPayout()
if remaining <= 0 {
return 0
}
draws := window.remainingDraws()
if draws <= 0 {
return remaining
}
return remaining / draws
return remaining
}
func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64) {
@ -1507,9 +1666,6 @@ func luckyMultiplierPPMsFromTiers(tiers []domain.Tier, giftPrice int64) []int64
seen[multiplier] = true
out = append(out, multiplier)
}
if !seen[0] {
out = append(out, 0)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
@ -1539,6 +1695,19 @@ func luckyBudgetSourcesJSON(candidate luckyCandidate) string {
return string(raw)
}
func luckyInitialRewardStatus(candidate luckyCandidate, stageFeedback bool) string {
// 没有金币返还、也没有房间展示副作用时,抽奖事实已经终态化;不能留给不存在的 outbox 消费。
if !luckyDrawNeedsOutbox(candidate, stageFeedback) {
return domain.StatusGranted
}
return domain.StatusPending
}
func luckyDrawNeedsOutbox(candidate luckyCandidate, stageFeedback bool) bool {
// outbox 只承载真实异步副作用:钱包入账、房间 IM 展示或阶段反馈展示。
return candidate.effectiveReward() > 0 || stageFeedback || candidate.Presentation
}
func luckyCorrectionTier(reward int64) string {
if reward == 0 {
return "none"

View File

@ -58,13 +58,13 @@ func TestLuckyRTPWindowAddsCurrentSpendToTarget(t *testing.T) {
}
}
func TestLuckyAutoWeightedBaseCandidatesFollowsWindowAverage(t *testing.T) {
func TestLuckyAutoWeightedBaseCandidatesUsesCurrentWindowDeficit(t *testing.T) {
candidates := []luckyCandidate{
{TierID: "none", BaseReward: 0},
{TierID: "half", BaseReward: 500},
{TierID: "one", BaseReward: 1000},
}
window := luckyRTPWindow{ControlDraws: 100, TargetPayoutCoins: 95_000}
window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 950}
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
@ -78,3 +78,130 @@ func TestLuckyAutoWeightedBaseCandidatesFollowsWindowAverage(t *testing.T) {
t.Fatalf("expected 1x to carry 90%% probability, got %#v", weighted[1])
}
}
func TestLuckyAutoWeightedBaseCandidatesDoesNotDiluteDeficitAcrossLargeWindow(t *testing.T) {
candidates := []luckyCandidate{
{TierID: "none", BaseReward: 0},
{TierID: "rebate_0_5x", BaseReward: 5},
{TierID: "rebate_1x", BaseReward: 10},
}
window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 9}
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
if len(weighted) != 2 {
t.Fatalf("expected deficit to interpolate between reward tiers, got %#v", weighted)
}
if weighted[0].TierID != "rebate_0_5x" || weighted[0].Weight != 200_000 {
t.Fatalf("expected 0.5x to carry 20%% probability, got %#v", weighted[0])
}
if weighted[1].TierID != "rebate_1x" || weighted[1].Weight != 800_000 {
t.Fatalf("expected 1x to carry 80%% probability, got %#v", weighted[1])
}
}
func TestLuckyAutoWeightedBaseCandidatesInterpolatesSingleDrawBelowOneX(t *testing.T) {
candidates := []luckyCandidate{
{TierID: "none", BaseReward: 0},
{TierID: "rebate_1x", BaseReward: 100},
}
window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 95}
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
if len(weighted) != 2 {
t.Fatalf("expected 0x and 1x interpolation candidates, got %#v", weighted)
}
if weighted[0].TierID != "none" || weighted[0].Weight != 50_000 {
t.Fatalf("expected 0x to keep 5%% probability, got %#v", weighted[0])
}
if weighted[1].TierID != "rebate_1x" || weighted[1].Weight != 950_000 {
t.Fatalf("expected 1x to carry 95%% probability, got %#v", weighted[1])
}
}
func TestSelectLuckyCandidateDoesNotInjectNoneTier(t *testing.T) {
config := domain.Config{
GiftID: domain.DefaultPoolID,
PoolID: domain.DefaultPoolID,
GiftPrice: 10,
TargetRTPPPM: 950_000,
HighMultiplier: 100,
LargeTierEnabled: true,
PlatformPoolWeightPPM: 200_000,
RoomPoolWeightPPM: 300_000,
GiftPoolWeightPPM: 500_000,
MaxSinglePayout: 10_000,
UserHourlyPayoutCap: 10_000,
UserDailyPayoutCap: 10_000,
DeviceDailyPayoutCap: 10_000,
RoomHourlyPayoutCap: 10_000,
AnchorDailyPayoutCap: 10_000,
Tiers: []domain.Tier{
{Pool: domain.PoolAdvanced, TierID: "advanced_1x", RewardCoins: 10, MultiplierPPM: 1_000_000, Enabled: true},
{Pool: domain.PoolAdvanced, TierID: "advanced_2x", RewardCoins: 20, MultiplierPPM: 2_000_000, Enabled: true},
},
}
window := luckyRTPWindow{ControlDraws: 100, TargetRTPPPM: 950_000}
pool := luckyPool{Balance: 1_000_000}
counters := map[string]luckyCounter{
luckyCounterKey("user", "hour"): {},
luckyCounterKey("user", "day"): {},
luckyCounterKey("device", "day"): {},
luckyCounterKey("room", "hour"): {},
luckyCounterKey("anchor", "day"): {},
}
candidate, _, err := (&Repository{}).selectLuckyCandidate(
config,
domain.PoolAdvanced,
false,
window,
window,
pool,
pool,
pool,
luckyPool{},
0,
counters,
20,
)
if err != nil {
t.Fatalf("select positive-only candidate failed: %v", err)
}
if candidate.BaseReward <= 0 || candidate.MultiplierPPM <= 0 {
t.Fatalf("positive-only config should not draw implicit none tier: %#v", candidate)
}
}
func TestLuckyInitialRewardStatusGrantsZeroRewardWithoutOutbox(t *testing.T) {
if status := luckyInitialRewardStatus(luckyCandidate{TierID: "none"}, false); status != domain.StatusGranted {
t.Fatalf("zero reward without presentation should be granted, got %s", status)
}
if luckyDrawNeedsOutbox(luckyCandidate{TierID: "none"}, false) {
t.Fatalf("zero reward without presentation must not emit outbox")
}
}
func TestLuckyInitialRewardStatusKeepsAsyncSideEffectsPending(t *testing.T) {
tests := []struct {
name string
candidate luckyCandidate
stageFeedback bool
}{
{name: "wallet credit", candidate: luckyCandidate{TierID: "hit", BaseReward: 10}},
{name: "presentation candidate", candidate: luckyCandidate{TierID: "stage", Presentation: true}},
{name: "stage feedback", candidate: luckyCandidate{TierID: "none"}, stageFeedback: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if status := luckyInitialRewardStatus(test.candidate, test.stageFeedback); status != domain.StatusPending {
t.Fatalf("async side effect should stay pending, got %s", status)
}
if !luckyDrawNeedsOutbox(test.candidate, test.stageFeedback) {
t.Fatalf("async side effect should emit outbox")
}
})
}
}

View File

@ -45,6 +45,15 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureLuckyDrawPoolID(ctx); err != nil {
return err
}
if err := r.ensureLuckyGiftOutboxColumns(ctx); err != nil {
return err
}
if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil {
return err
}
if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil {
return err
}
return nil
}
@ -94,6 +103,84 @@ func (r *Repository) ensureLuckyDrawPoolID(ctx context.Context) error {
return nil
}
func (r *Repository) ensureLuckyGiftOutboxColumns(ctx context.Context) error {
additions := []struct {
name string
sql string
}{
{"locked_by", `ALTER TABLE activity_outbox ADD COLUMN locked_by VARCHAR(128) NOT NULL DEFAULT '' COMMENT '当前锁定 worker' AFTER next_retry_at_ms`},
{"lock_until_ms", `ALTER TABLE activity_outbox ADD COLUMN lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '锁过期时间UTC epoch ms' AFTER locked_by`},
{"last_error", `ALTER TABLE activity_outbox ADD COLUMN last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次失败原因' AFTER lock_until_ms`},
}
for _, addition := range additions {
exists, err := r.columnExists(ctx, "activity_outbox", addition.name)
if err != nil {
return err
}
if !exists {
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) ensureLuckyDrawRewardSettlementColumns(ctx context.Context) error {
additions := []struct {
name string
sql string
}{
{"reward_transaction_id", `ALTER TABLE lucky_draw_records ADD COLUMN reward_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '返奖钱包交易 ID' AFTER reward_status`},
{"reward_failure_reason", `ALTER TABLE lucky_draw_records ADD COLUMN reward_failure_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '返奖失败原因' AFTER reward_transaction_id`},
}
for _, addition := range additions {
exists, err := r.columnExists(ctx, "lucky_draw_records", addition.name)
if err != nil {
return err
}
if !exists {
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) ensureDefaultLuckyGiftRules(ctx context.Context) error {
// 本地和 Docker 开发库通常保留 MySQL volume这里补齐缺省 lucky/super_lucky 契约,但不覆盖后台已发布规则。
const seedNowMS int64 = 1779259000000
const seedTiersJSON = `[
{"pool":"novice","tier_id":"novice_none","reward_coins":0,"multiplier_ppm":0,"weight":0,"high_water_only":false,"enabled":true},
{"pool":"novice","tier_id":"novice_1x","reward_coins":100,"multiplier_ppm":1000000,"weight":0,"high_water_only":false,"enabled":true},
{"pool":"novice","tier_id":"novice_2x","reward_coins":200,"multiplier_ppm":2000000,"weight":0,"high_water_only":false,"enabled":true},
{"pool":"intermediate","tier_id":"intermediate_none","reward_coins":0,"multiplier_ppm":0,"weight":0,"high_water_only":false,"enabled":true},
{"pool":"intermediate","tier_id":"intermediate_1x","reward_coins":100,"multiplier_ppm":1000000,"weight":0,"high_water_only":false,"enabled":true},
{"pool":"intermediate","tier_id":"intermediate_2x","reward_coins":200,"multiplier_ppm":2000000,"weight":0,"high_water_only":false,"enabled":true},
{"pool":"advanced","tier_id":"advanced_none","reward_coins":0,"multiplier_ppm":0,"weight":0,"high_water_only":false,"enabled":true},
{"pool":"advanced","tier_id":"advanced_1x","reward_coins":100,"multiplier_ppm":1000000,"weight":0,"high_water_only":false,"enabled":true},
{"pool":"advanced","tier_id":"advanced_2x","reward_coins":200,"multiplier_ppm":2000000,"weight":0,"high_water_only":false,"enabled":true}
]`
_, err := r.db.ExecContext(ctx, `
INSERT IGNORE INTO lucky_gift_rules (
app_code, gift_id, enabled, rule_version, gift_price, target_rtp_ppm, pool_rate_ppm,
global_window_draws, gift_window_draws, novice_draw_limit, intermediate_draw_limit,
high_multiplier, high_water_pool_multiple, platform_pool_weight_ppm, room_pool_weight_ppm,
gift_pool_weight_ppm, initial_platform_pool, initial_gift_pool, initial_room_pool,
platform_reserve, gift_reserve, room_reserve, max_single_payout, user_hourly_payout_cap,
user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap,
room_atmosphere_rate_ppm, room_atmosphere_initial, room_atmosphere_reserve,
activity_budget, activity_daily_limit, large_tier_enabled, tiers_json,
updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES
('lalu', 'lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, ?, 0, ?, ?),
('lalu', 'super_lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, ?, 0, ?, ?)`,
seedTiersJSON, seedNowMS, seedNowMS, seedTiersJSON, seedNowMS, seedNowMS,
)
return err
}
func (r *Repository) columnExists(ctx context.Context, table string, column string) (bool, error) {
database, err := r.currentDatabase(ctx)
if err != nil {

View File

@ -48,15 +48,18 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
meta := req.GetLuckyGift()
ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode())
result, err := s.svc.Draw(ctx, domain.DrawCommand{
CommandID: meta.GetCommandId(),
PoolID: meta.GetPoolId(),
UserID: meta.GetUserId(),
DeviceID: meta.GetDeviceId(),
RoomID: meta.GetRoomId(),
AnchorID: meta.GetAnchorId(),
GiftID: meta.GetGiftId(),
CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(),
CommandID: meta.GetCommandId(),
PoolID: meta.GetPoolId(),
UserID: meta.GetUserId(),
TargetUserID: meta.GetTargetUserId(),
DeviceID: meta.GetDeviceId(),
RoomID: meta.GetRoomId(),
AnchorID: meta.GetAnchorId(),
GiftID: meta.GetGiftId(),
GiftCount: meta.GetGiftCount(),
CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)

View File

@ -87,7 +87,10 @@ tencent_im:
sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
admin_user_sig_ttl: "24h"
user_sig_ttl: "24h"
endpoint: "adminapisgp.im.qcloud.com"
request_timeout: "5s"
callback_url: ""
callback_auth_token: ""

View File

@ -87,8 +87,13 @@ tencent_im:
secret_key: "TENCENT_IM_SECRET_KEY"
# 腾讯云 IM App 管理员账号;必须和 activity/room 服务端 REST 配置一致。
admin_identifier: "TENCENT_IM_ADMIN_IDENTIFIER"
# gateway 补偿导入 IM 账号时使用的 admin UserSig 有效期。
admin_user_sig_ttl: "24h"
# 客户端 UserSig 有效期;过短会增加刷新频率,过长会放大泄露窗口。
user_sig_ttl: "24h"
# 腾讯云 IM REST 地域入口;海外应用需要使用对应地域域名。
endpoint: "adminapisgp.im.qcloud.com"
request_timeout: "5s"
# 腾讯云 IM 控制台配置的回调地址,用于入群前、发言前等业务守卫。
callback_url: "https://api.example.com/api/v1/tencent-im/callback"
# 腾讯云 IM 回调鉴权 Token必须和控制台配置保持一致。

View File

@ -87,8 +87,11 @@ tencent_im:
sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
admin_user_sig_ttl: "24h"
# 客户端 UserSig 有效期;线上建议 12h-7d按登录刷新策略决定。
user_sig_ttl: "24h"
endpoint: "adminapisgp.im.qcloud.com"
request_timeout: "5s"
# 腾讯云 IM 控制台回调 URL。需要公网可访问本地为空表示暂不启用回调。
callback_url: ""
# 腾讯云 IM 回调鉴权 Token。启用回调鉴权后必须填写。

View File

@ -16,6 +16,7 @@ import (
"google.golang.org/grpc"
"hyapp/pkg/grpcclient"
"hyapp/pkg/tencentcos"
"hyapp/pkg/tencentim"
"hyapp/services/gateway-service/internal/appconfig"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/client"
@ -99,6 +100,7 @@ func New(cfg config.Config) (*App, error) {
var registrationRewardClient client.RegistrationRewardClient = client.NewGRPCRegistrationRewardClient(activityConn)
var firstRechargeRewardClient client.FirstRechargeRewardClient = client.NewGRPCFirstRechargeRewardClient(activityConn)
var sevenDayCheckInClient client.SevenDayCheckInClient = client.NewGRPCSevenDayCheckInClient(activityConn)
var luckyGiftClient client.LuckyGiftClient = client.NewGRPCLuckyGiftClient(activityConn)
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
appConfigReader, err := openAppConfigReader(cfg.AppConfig)
@ -129,6 +131,24 @@ func New(cfg config.Config) (*App, error) {
AdminIdentifier: cfg.TencentIM.AdminIdentifier,
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
}, userProfileClient)
if cfg.TencentIM.SDKAppID > 0 && strings.TrimSpace(cfg.TencentIM.SecretKey) != "" {
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
if err != nil {
if appConfigReader != nil {
_ = appConfigReader.Close()
}
if leaderboardWalletDB != nil {
_ = leaderboardWalletDB.Close()
}
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
_ = gameConn.Close()
return nil, err
}
handler.SetTencentIMAccountImporter(gatewayTencentIMAccountImporter{client: imClient})
}
handler.SetRoomQueryClient(roomQueryClient)
handler.SetUserDeviceClient(userDeviceClient)
handler.SetUserCountryQueryClient(userCountryQueryClient)
@ -143,6 +163,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetRegistrationRewardClient(registrationRewardClient)
handler.SetFirstRechargeRewardClient(firstRechargeRewardClient)
handler.SetSevenDayCheckInClient(sevenDayCheckInClient)
handler.SetLuckyGiftClient(luckyGiftClient)
handler.SetBroadcastClient(broadcastClient)
handler.SetGameClient(gameClient)
handler.SetLeaderboardWalletDB(leaderboardWalletDB)
@ -372,6 +393,18 @@ func joinClose(left func() error, right func() error) func() error {
}
}
type gatewayTencentIMAccountImporter struct {
client *tencentim.RESTClient
}
func (i gatewayTencentIMAccountImporter) ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error {
return i.client.ImportAccount(ctx, tencentim.AccountProfile{
UserID: userID,
Nick: nickname,
FaceURL: faceURL,
})
}
// Run 启动 HTTP 服务。
func (a *App) Run() error {
a.health.MarkHTTPServing()

View File

@ -22,7 +22,7 @@ const listExploreTabsSQL = `
const listAppBannersSQL = `
SELECT id, app_code, cover_url, room_small_image_url, banner_type, display_scope, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms
FROM admin_app_banners
WHERE app_code = ? AND status = 'active' AND display_scope = ?
WHERE app_code = ? AND status = 'active' AND FIND_IN_SET(?, display_scope) > 0
AND (? = '' OR platform = ?)
AND (region_id = 0 OR region_id = ?)
AND (country_code = '' OR country_code = ?)
@ -66,20 +66,21 @@ type BannerQuery struct {
// Banner 是 gateway 下发给 App 指定显示范围的 banner 配置。
type Banner struct {
ID uint `json:"id"`
CoverURL string `json:"cover_url"`
RoomSmallImageURL string `json:"room_small_image_url,omitempty"`
BannerType string `json:"type"`
DisplayScope string `json:"display_scope"`
Param string `json:"param"`
Platform string `json:"platform"`
SortOrder int `json:"sort_order"`
RegionID int64 `json:"region_id,omitempty"`
CountryCode string `json:"country_code,omitempty"`
Description string `json:"description,omitempty"`
StartsAtMs int64 `json:"starts_at_ms,omitempty"`
EndsAtMs int64 `json:"ends_at_ms,omitempty"`
UpdatedAtMs int64 `json:"updated_at_ms"`
ID uint `json:"id"`
CoverURL string `json:"cover_url"`
RoomSmallImageURL string `json:"room_small_image_url,omitempty"`
BannerType string `json:"type"`
DisplayScope string `json:"display_scope"`
DisplayScopes []string `json:"display_scopes,omitempty"`
Param string `json:"param"`
Platform string `json:"platform"`
SortOrder int `json:"sort_order"`
RegionID int64 `json:"region_id,omitempty"`
CountryCode string `json:"country_code,omitempty"`
Description string `json:"description,omitempty"`
StartsAtMs int64 `json:"starts_at_ms,omitempty"`
EndsAtMs int64 `json:"ends_at_ms,omitempty"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// VersionQuery 是 App 检查更新的公开筛选条件。
@ -237,6 +238,7 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban
return nil, err
}
item.UpdatedAtMs = updatedAtMS
item.DisplayScopes = bannerDisplayScopeList(item.DisplayScope)
items = append(items, item)
}
if err := rows.Err(); err != nil {
@ -311,6 +313,11 @@ func (r StaticReader) ListBanners(context.Context, BannerQuery) ([]Banner, error
out := make([]Banner, len(r.Banners))
copy(out, r.Banners)
for index := range out {
if len(out[index].DisplayScopes) == 0 {
out[index].DisplayScopes = bannerDisplayScopeList(out[index].DisplayScope)
}
}
return out, nil
}
@ -361,3 +368,20 @@ func NormalizeBannerDisplayScope(value string) string {
return ""
}
}
func bannerDisplayScopeList(value string) []string {
seen := make(map[string]struct{}, 3)
for _, part := range strings.Split(value, ",") {
scope := NormalizeBannerDisplayScope(part)
if scope != "" {
seen[scope] = struct{}{}
}
}
out := make([]string, 0, len(seen))
for _, scope := range []string{"home", "room", "recharge"} {
if _, ok := seen[scope]; ok {
out = append(out, scope)
}
}
return out
}

View File

@ -17,3 +17,16 @@ func TestNormalizeBannerDisplayScopeAcceptsChineseLabels(t *testing.T) {
}
}
}
func TestBannerDisplayScopeListParsesMultiScopeValue(t *testing.T) {
got := bannerDisplayScopeList("room,home,recharge")
want := []string{"home", "room", "recharge"}
if len(got) != len(want) {
t.Fatalf("scope count mismatch: got %+v want %+v", got, want)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("scope %d mismatch: got %+v want %+v", index, got, want)
}
}
}

View File

@ -0,0 +1,25 @@
package client
import (
"context"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
)
// LuckyGiftClient abstracts gateway access to activity-service lucky gift eligibility checks.
type LuckyGiftClient interface {
CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error)
}
type grpcLuckyGiftClient struct {
client activityv1.LuckyGiftServiceClient
}
func NewGRPCLuckyGiftClient(conn *grpc.ClientConn) LuckyGiftClient {
return &grpcLuckyGiftClient{client: activityv1.NewLuckyGiftServiceClient(conn)}
}
func (c *grpcLuckyGiftClient) CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
return c.client.CheckLuckyGift(ctx, req)
}

View File

@ -71,6 +71,7 @@ type UserHostClient interface {
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error)
GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error)
CheckBusinessCapability(ctx context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error)
}
@ -316,6 +317,10 @@ func (c *grpcUserHostClient) GetCoinSellerProfile(ctx context.Context, req *user
return c.client.GetCoinSellerProfile(ctx, req)
}
func (c *grpcUserHostClient) ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error) {
return c.client.ListActiveCoinSellersInMyRegion(ctx, req)
}
func (c *grpcUserHostClient) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
return c.client.GetUserRoleSummary(ctx, req)
}

View File

@ -26,6 +26,7 @@ type WalletClient interface {
ListResources(ctx context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error)
GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error)
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error)
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error)
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
@ -111,6 +112,10 @@ func (c *grpcWalletClient) GetResourceGroup(ctx context.Context, req *walletv1.G
return c.client.GetResourceGroup(ctx, req)
}
func (c *grpcWalletClient) ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
return c.client.ListResourceShopItems(ctx, req)
}
func (c *grpcWalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
return c.client.ListGiftConfigs(ctx, req)
}

View File

@ -2,12 +2,14 @@ package config
import (
"fmt"
"net/http"
"strings"
"time"
"hyapp/pkg/configx"
"hyapp/pkg/grpcclient"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/tencentrtc"
)
@ -158,12 +160,39 @@ type TencentIMConfig struct {
UserSigTTL time.Duration `yaml:"user_sig_ttl"`
// AdminIdentifier 是服务端 REST 发播报和系统消息的管理员账号,回调用它识别服务端发送者。
AdminIdentifier string `yaml:"admin_identifier"`
// AdminUserSigTTL 控制 gateway 补偿导入 IM 账号时的 admin UserSig 有效期。
AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"`
// Endpoint 是腾讯 IM REST 地域入口,例如 adminapisgp.im.qcloud.com。
Endpoint string `yaml:"endpoint"`
// RequestTimeout 是 gateway 调腾讯 IM REST 的请求超时。
RequestTimeout time.Duration `yaml:"request_timeout"`
// CallbackURL 是在腾讯云 IM 控制台配置的服务端回调地址。
CallbackURL string `yaml:"callback_url"`
// CallbackAuthToken 是腾讯云 IM 回调鉴权 token只用于校验回调来源。
CallbackAuthToken string `yaml:"callback_auth_token"`
}
// RESTConfig 转换成腾讯 IM REST client 配置,用于 /im/usersig 入口补偿导入账号。
func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig {
timeout := c.RequestTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
ttl := c.AdminUserSigTTL
if ttl <= 0 {
ttl = 24 * time.Hour
}
return tencentim.RESTConfig{
SDKAppID: c.SDKAppID,
SecretKey: c.SecretKey,
AdminIdentifier: c.AdminIdentifier,
AdminUserSigTTL: ttl,
Endpoint: c.Endpoint,
GroupType: tencentim.DefaultGroupType,
HTTPClient: &http.Client{Timeout: timeout},
}
}
// TencentRTCConfig 描述 gateway 给客户端签发腾讯 RTC 进房 UserSig 的配置。
type TencentRTCConfig struct {
// Enabled 控制 RTC token endpoint 是否可以对外签发,配置缺失时必须 fail-closed。
@ -277,7 +306,10 @@ func Default() Config {
},
TencentIM: TencentIMConfig{
UserSigTTL: 24 * time.Hour,
AdminUserSigTTL: 24 * time.Hour,
AdminIdentifier: "administrator",
RequestTimeout: 5 * time.Second,
Endpoint: tencentim.DefaultEndpoint,
},
TencentRTC: TencentRTCConfig{
UserSigTTL: 2 * time.Hour,
@ -419,6 +451,16 @@ func (cfg *Config) Normalize() error {
if cfg.TencentIM.AdminIdentifier == "" {
cfg.TencentIM.AdminIdentifier = "administrator"
}
cfg.TencentIM.Endpoint = strings.TrimSpace(cfg.TencentIM.Endpoint)
if cfg.TencentIM.Endpoint == "" {
cfg.TencentIM.Endpoint = tencentim.DefaultEndpoint
}
if cfg.TencentIM.AdminUserSigTTL <= 0 {
cfg.TencentIM.AdminUserSigTTL = 24 * time.Hour
}
if cfg.TencentIM.RequestTimeout <= 0 {
cfg.TencentIM.RequestTimeout = 5 * time.Second
}
cfg.TencentIM.CallbackURL = strings.TrimSpace(cfg.TencentIM.CallbackURL)
cfg.TencentIM.CallbackAuthToken = strings.TrimSpace(cfg.TencentIM.CallbackAuthToken)

View File

@ -20,6 +20,7 @@ type Handler struct {
registrationReward client.RegistrationRewardClient
firstRechargeReward client.FirstRechargeRewardClient
sevenDayCheckIn client.SevenDayCheckInClient
luckyGift client.LuckyGiftClient
}
type Config struct {
@ -33,6 +34,7 @@ type Config struct {
RegistrationReward client.RegistrationRewardClient
FirstRechargeReward client.FirstRechargeRewardClient
SevenDayCheckIn client.SevenDayCheckInClient
LuckyGift client.LuckyGiftClient
}
func New(config Config) *Handler {
@ -47,6 +49,7 @@ func New(config Config) *Handler {
registrationReward: config.RegistrationReward,
firstRechargeReward: config.FirstRechargeReward,
sevenDayCheckIn: config.SevenDayCheckIn,
luckyGift: config.LuckyGift,
}
}
@ -59,6 +62,7 @@ func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus,
SignSevenDayCheckIn: h.signSevenDayCheckIn,
ListUserLeaderboards: h.listUserLeaderboards,
CheckLuckyGift: h.checkLuckyGift,
}
}

View File

@ -0,0 +1,71 @@
package activityapi
import (
"encoding/json"
"net/http"
"strings"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type luckyGiftCheckRequest struct {
RoomID string `json:"room_id"`
GiftID string `json:"gift_id"`
PoolID string `json:"pool_id"`
}
type luckyGiftCheckData struct {
Enabled bool `json:"enabled"`
Reason string `json:"reason"`
RoomID string `json:"room_id"`
GiftID string `json:"gift_id"`
PoolID string `json:"pool_id"`
GiftPrice int64 `json:"gift_price"`
RuleVersion int64 `json:"rule_version"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
ExperiencePool string `json:"experience_pool"`
}
// checkLuckyGift 只做送礼前资格检查;扣费、抽奖和返奖都必须继续走 room-service 送礼主链路。
func (h *Handler) checkLuckyGift(writer http.ResponseWriter, request *http.Request) {
if h.luckyGift == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body luckyGiftCheckRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid request body")
return
}
body.RoomID = strings.TrimSpace(body.RoomID)
body.GiftID = strings.TrimSpace(body.GiftID)
body.PoolID = strings.TrimSpace(body.PoolID)
if body.RoomID == "" || body.GiftID == "" || body.PoolID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "room_id, gift_id and pool_id are required")
return
}
resp, err := h.luckyGift.CheckLuckyGift(request.Context(), &activityv1.CheckLuckyGiftRequest{
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
RoomId: body.RoomID,
GiftId: body.GiftID,
PoolId: body.PoolID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, luckyGiftCheckData{
Enabled: resp.GetEnabled(),
Reason: resp.GetReason(),
RoomID: body.RoomID,
GiftID: resp.GetGiftId(),
PoolID: resp.GetPoolId(),
GiftPrice: resp.GetGiftPrice(),
RuleVersion: resp.GetRuleVersion(),
TargetRTPPPM: resp.GetTargetRtpPpm(),
ExperiencePool: resp.GetExperiencePool(),
})
}

View File

@ -0,0 +1,64 @@
package activityapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
)
func TestCheckLuckyGiftForwardsEligibilityRequest(t *testing.T) {
client := &fakeLuckyGiftClient{resp: &activityv1.CheckLuckyGiftResponse{
Enabled: true,
Reason: "enabled",
GiftId: "rose",
PoolId: "super_lucky",
GiftPrice: 100,
RuleVersion: 3,
TargetRtpPpm: 600000,
ExperiencePool: "novice",
}}
handler := New(Config{LuckyGift: client})
request := httptest.NewRequest(http.MethodPost, "/api/v1/activities/lucky-gifts/check", strings.NewReader(`{"room_id":"room-1","gift_id":"rose","pool_id":"super_lucky"}`))
request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "lalu"), 42))
recorder := httptest.NewRecorder()
handler.checkLuckyGift(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: %d body=%s", recorder.Code, recorder.Body.String())
}
if client.last == nil || client.last.GetUserId() != 42 || client.last.GetRoomId() != "room-1" || client.last.GetGiftId() != "rose" || client.last.GetPoolId() != "super_lucky" {
t.Fatalf("check request mismatch: %+v", client.last)
}
var envelope struct {
Code string `json:"code"`
Data struct {
Enabled bool `json:"enabled"`
Reason string `json:"reason"`
ExperiencePool string `json:"experience_pool"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != "OK" || !envelope.Data.Enabled || envelope.Data.ExperiencePool != "novice" {
t.Fatalf("response mismatch: %+v", envelope)
}
}
type fakeLuckyGiftClient struct {
last *activityv1.CheckLuckyGiftRequest
resp *activityv1.CheckLuckyGiftResponse
}
func (c *fakeLuckyGiftClient) CheckLuckyGift(_ context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
c.last = req
return c.resp, nil
}

View File

@ -41,11 +41,13 @@ type Handler struct {
registrationReward client.RegistrationRewardClient
firstRechargeReward client.FirstRechargeRewardClient
sevenDayCheckIn client.SevenDayCheckInClient
luckyGift client.LuckyGiftClient
broadcastClient client.BroadcastClient
gameClient client.GameClient
appConfigReader appapi.ConfigReader
leaderboardWalletDB *sql.DB
tencentIM TencentIMConfig
tencentIMImporter TencentIMAccountImporter
tencentRTC TencentRTCConfig
objectUploader ObjectUploader
authRateLimitConfig AuthRateLimitConfig
@ -63,6 +65,11 @@ type TencentIMConfig struct {
CallbackAuthToken string
}
// TencentIMAccountImporter 是 gateway 在签发 UserSig 前补偿导入账号的最小边界。
type TencentIMAccountImporter interface {
ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error
}
// TencentRTCConfig 是 gateway transport 层签发客户端腾讯 RTC 进房票据所需的配置。
type TencentRTCConfig struct {
Enabled bool
@ -129,6 +136,11 @@ func (h *Handler) SetTencentRTC(config TencentRTCConfig) {
h.tencentRTC = config
}
// SetTencentIMAccountImporter 注入腾讯 IM 账号导入边界nil 表示仅签发 UserSig不做 REST 补偿。
func (h *Handler) SetTencentIMAccountImporter(importer TencentIMAccountImporter) {
h.tencentIMImporter = importer
}
// SetObjectUploader 注入对象存储上传实现;生产环境使用腾讯云 COS。
func (h *Handler) SetObjectUploader(uploader ObjectUploader) {
h.objectUploader = uploader
@ -204,6 +216,11 @@ func (h *Handler) SetSevenDayCheckInClient(sevenDayCheckIn client.SevenDayCheckI
h.sevenDayCheckIn = sevenDayCheckIn
}
// SetLuckyGiftClient 注入 activity-service 幸运礼物资格检查 client。
func (h *Handler) SetLuckyGiftClient(luckyGift client.LuckyGiftClient) {
h.luckyGift = luckyGift
}
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
h.broadcastClient = broadcastClient
@ -293,11 +310,28 @@ func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *htt
SecretKey: h.tencentIM.SecretKey,
TTL: h.tencentIM.UserSigTTL,
}, tencentim.FormatUserID(userID), time.Now().UTC())
if err != nil {
if h.tencentIMImporter != nil {
_ = h.tencentIMImporter.ImportAccount(request.Context(), userID, user.GetUsername(), user.GetAvatar())
result, err = tencentim.GenerateUserSig(tencentim.UserSigConfig{
SDKAppID: h.tencentIM.SDKAppID,
SecretKey: h.tencentIM.SecretKey,
TTL: h.tencentIM.UserSigTTL,
}, tencentim.FormatUserID(userID), time.Now().UTC())
}
}
if err != nil {
// 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
if h.tencentIMImporter != nil {
if err := h.tencentIMImporter.ImportAccount(request.Context(), userID, user.GetUsername(), user.GetAvatar()); err != nil {
// UserSig 只证明签名有效;账号未导入时 SDK C2C 仍会失败,因此这里必须 fail-closed。
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
}
joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId())
if !ok {

View File

@ -46,6 +46,7 @@ type AppHandlers struct {
ListAppBanners http.HandlerFunc
GetAppVersion http.HandlerFunc
GetResourceGroup http.HandlerFunc
ListResourceShopItems http.HandlerFunc
ListGifts http.HandlerFunc
ListGiftTabs http.HandlerFunc
ListEmojiPacks http.HandlerFunc
@ -138,6 +139,7 @@ type TaskHandlers struct {
GetSevenDayCheckInStatus http.HandlerFunc
SignSevenDayCheckIn http.HandlerFunc
ListUserLeaderboards http.HandlerFunc
CheckLuckyGift http.HandlerFunc
}
type LevelHandlers struct {
@ -161,6 +163,7 @@ type WalletHandlers struct {
ApplyWithdrawal http.HandlerFunc
ListCoinTransactions http.HandlerFunc
ListWalletTransactions http.HandlerFunc
ListCoinSellers http.HandlerFunc
TransferCoinFromSeller http.HandlerFunc
GetRedPacketConfig http.HandlerFunc
ListRoomRedPackets http.HandlerFunc
@ -248,6 +251,7 @@ func (r routes) registerAppRoutes() {
r.public("/app/banners", "", h.ListAppBanners)
r.public("/app/version", http.MethodGet, h.GetAppVersion)
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)
r.public("/resource-shop/items", http.MethodGet, h.ListResourceShopItems)
r.public("/gifts", "", h.ListGifts)
r.public("/gift-tabs", http.MethodGet, h.ListGiftTabs)
r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks)
@ -343,6 +347,7 @@ func (r routes) registerTaskRoutes() {
r.profile("/activities/seven-day-checkin", http.MethodGet, h.GetSevenDayCheckInStatus)
r.profile("/activities/seven-day-checkin/sign", http.MethodPost, h.SignSevenDayCheckIn)
r.profile("/activities/user-leaderboards", http.MethodGet, h.ListUserLeaderboards)
r.profile("/activities/lucky-gifts/check", http.MethodPost, h.CheckLuckyGift)
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)
r.profile("/tasks/claim", http.MethodPost, h.ClaimTaskReward)
}
@ -371,6 +376,7 @@ func (r routes) registerWalletRoutes() {
r.profile("/wallet/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
r.profile("/wallet/transactions", http.MethodGet, h.ListWalletTransactions)
r.profile("/wallet/coin-sellers", http.MethodGet, h.ListCoinSellers)
r.profile("/wallet/coin-seller/transfer", "", h.TransferCoinFromSeller)
r.profile("/red-packets/config", http.MethodGet, h.GetRedPacketConfig)
r.profile("/rooms/{room_id}/red-packets", http.MethodGet, h.ListRoomRedPackets)

View File

@ -28,6 +28,10 @@ func (h *Handler) GetResourceGroup(writer http.ResponseWriter, request *http.Req
h.getResourceGroup(writer, request)
}
func (h *Handler) ListResourceShopItems(writer http.ResponseWriter, request *http.Request) {
h.listResourceShopItems(writer, request)
}
func (h *Handler) ListGifts(writer http.ResponseWriter, request *http.Request) {
h.listGifts(writer, request)
}

View File

@ -69,6 +69,20 @@ type resourceGroupData struct {
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type resourceShopItemData struct {
ShopItemID int64 `json:"shop_item_id"`
ResourceID int64 `json:"resource_id"`
Resource resourceData `json:"resource"`
Status string `json:"status"`
DurationDays int32 `json:"duration_days"`
PriceType string `json:"price_type"`
CoinPrice int64 `json:"coin_price"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
SortOrder int32 `json:"sort_order"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type giftConfigData struct {
GiftID string `json:"gift_id"`
ResourceID int64 `json:"resource_id"`
@ -196,6 +210,36 @@ func (h *Handler) getResourceGroup(writer http.ResponseWriter, request *http.Req
httpkit.WriteOK(writer, request, group)
}
func (h *Handler) listResourceShopItems(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := resourcePage(request)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ListResourceShopItems(request.Context(), &walletv1.ListResourceShopItemsRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
ResourceType: strings.TrimSpace(request.URL.Query().Get("resource_type")),
Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")),
Page: page,
PageSize: pageSize,
ActiveOnly: true,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]resourceShopItemData, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
items = append(items, resourceShopItemFromProto(item))
}
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
}
func (h *Handler) listGifts(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -463,6 +507,25 @@ func resourcePointerFromProto(item *walletv1.Resource) *resourceData {
return &data
}
func resourceShopItemFromProto(item *walletv1.ResourceShopItem) resourceShopItemData {
if item == nil {
return resourceShopItemData{}
}
return resourceShopItemData{
ShopItemID: item.GetShopItemId(),
ResourceID: item.GetResourceId(),
Resource: resourceFromProto(item.GetResource()),
Status: item.GetStatus(),
DurationDays: item.GetDurationDays(),
PriceType: item.GetPriceType(),
CoinPrice: item.GetCoinPrice(),
EffectiveFromMS: item.GetEffectiveFromMs(),
EffectiveToMS: item.GetEffectiveToMs(),
SortOrder: item.GetSortOrder(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func giftFromProto(gift *walletv1.GiftConfig) giftConfigData {
if gift == nil {
return giftConfigData{}

View File

@ -272,6 +272,20 @@ type fakeUserProfileClient struct {
regionByUserID map[int64]int64
}
type fakeTencentIMAccountImporter struct {
lastUserID int64
lastNickname string
lastFaceURL string
err error
}
func (f *fakeTencentIMAccountImporter) ImportAccount(_ context.Context, userID int64, nickname string, faceURL string) error {
f.lastUserID = userID
f.lastNickname = nickname
f.lastFaceURL = faceURL
return f.err
}
type fakeUserIdentityClient struct {
lastResolve *userv1.ResolveDisplayUserIDRequest
resolveByDisplay map[string]int64
@ -319,27 +333,30 @@ type fakeUserCountryQueryClient struct {
}
type fakeUserHostClient struct {
lastSearchAgencies *userv1.SearchAgenciesRequest
searchAgenciesResp *userv1.SearchAgenciesResponse
searchAgenciesErr error
lastApplyAgency *userv1.ApplyToAgencyRequest
applyAgencyResp *userv1.ApplyToAgencyResponse
applyAgencyErr error
last *userv1.GetCoinSellerProfileRequest
profile *userv1.CoinSellerProfile
err error
lastHost *userv1.GetHostProfileRequest
hostProfile *userv1.HostProfile
hostErr error
lastBD *userv1.GetBDProfileRequest
bdProfile *userv1.BDProfile
bdErr error
lastRoleSummary *userv1.GetUserRoleSummaryRequest
roleSummary *userv1.UserRoleSummary
roleSummaryErr error
lastCapability *userv1.CheckBusinessCapabilityRequest
capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error
lastSearchAgencies *userv1.SearchAgenciesRequest
searchAgenciesResp *userv1.SearchAgenciesResponse
searchAgenciesErr error
lastApplyAgency *userv1.ApplyToAgencyRequest
applyAgencyResp *userv1.ApplyToAgencyResponse
applyAgencyErr error
last *userv1.GetCoinSellerProfileRequest
profile *userv1.CoinSellerProfile
err error
lastListCoinSellers *userv1.ListActiveCoinSellersInMyRegionRequest
listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse
listCoinSellersErr error
lastHost *userv1.GetHostProfileRequest
hostProfile *userv1.HostProfile
hostErr error
lastBD *userv1.GetBDProfileRequest
bdProfile *userv1.BDProfile
bdErr error
lastRoleSummary *userv1.GetUserRoleSummaryRequest
roleSummary *userv1.UserRoleSummary
roleSummaryErr error
lastCapability *userv1.CheckBusinessCapabilityRequest
capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error
}
type fakeWalletClient struct {
@ -380,6 +397,8 @@ type fakeWalletClient struct {
resourcesByID map[int64]*walletv1.Resource
lastGetResourceGroup *walletv1.GetResourceGroupRequest
resourceGroupsByID map[int64]*walletv1.ResourceGroup
lastResourceShop *walletv1.ListResourceShopItemsRequest
resourceShopResp *walletv1.ListResourceShopItemsResponse
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
@ -971,6 +990,17 @@ func (f *fakeUserHostClient) GetCoinSellerProfile(_ context.Context, req *userv1
return &userv1.GetCoinSellerProfileResponse{}, nil
}
func (f *fakeUserHostClient) ListActiveCoinSellersInMyRegion(_ context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error) {
f.lastListCoinSellers = req
if f.listCoinSellersErr != nil {
return nil, f.listCoinSellersErr
}
if f.listCoinSellersResp != nil {
return f.listCoinSellersResp, nil
}
return &userv1.ListActiveCoinSellersInMyRegionResponse{}, nil
}
func (f *fakeUserHostClient) GetUserRoleSummary(_ context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
f.lastRoleSummary = req
if f.roleSummaryErr != nil {
@ -1197,6 +1227,17 @@ func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.Get
return &walletv1.GetResourceGroupResponse{}, nil
}
func (f *fakeWalletClient) ListResourceShopItems(_ context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
f.lastResourceShop = req
if f.err != nil {
return nil, f.err
}
if f.resourceShopResp != nil {
return f.resourceShopResp, nil
}
return &walletv1.ListResourceShopItemsResponse{}, nil
}
func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
f.lastListGiftConfigs = req
if f.err != nil {
@ -2422,7 +2463,8 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
CoverURL: "https://cdn.example.com/banner.png",
RoomSmallImageURL: "https://cdn.example.com/banner-small.png",
BannerType: "h5",
DisplayScope: "room",
DisplayScope: "home,room",
DisplayScopes: []string{"home", "room"},
Param: "https://h5.example.com/activity",
Platform: "android",
SortOrder: 10,
@ -2468,9 +2510,13 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
if !ok || first["cover_url"] != "https://cdn.example.com/banner.png" || first["type"] != "h5" || first["param"] != "https://h5.example.com/activity" {
t.Fatalf("banner item mismatch: %+v", first)
}
if first["display_scope"] != "room" || first["room_small_image_url"] != "https://cdn.example.com/banner-small.png" {
if first["display_scope"] != "home,room" || first["room_small_image_url"] != "https://cdn.example.com/banner-small.png" {
t.Fatalf("banner display fields mismatch: %+v", first)
}
displayScopes, ok := first["display_scopes"].([]any)
if !ok || len(displayScopes) != 2 || displayScopes[0] != "home" || displayScopes[1] != "room" {
t.Fatalf("banner display scopes mismatch: %+v", first)
}
if first["platform"] != "android" || first["sort_order"].(float64) != 10 || first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000002000 {
t.Fatalf("banner metadata mismatch: %+v", first)
}
@ -2636,6 +2682,53 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
}
}
func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
{
UserId: 801,
DisplayUserId: "900801",
Username: "Seller One",
Avatar: "https://cdn.example/avatar.png",
CountryId: 86,
CountryCode: "CN",
CountryName: "China",
CountryDisplayName: "中国",
RegionId: 30,
RegionCode: "east-asia",
RegionName: "East Asia",
Status: "active",
MerchantAssetType: "COIN_SELLER_COIN",
UpdatedAtMs: 1700000001000,
},
}}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/coin-sellers", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-coin-sellers")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 {
t.Fatalf("coin seller list request mismatch: %+v", hostClient.lastListCoinSellers)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := response.Data.(map[string]any)
items := data["items"].([]any)
first := items[0].(map[string]any)
if data["total"] != float64(1) || first["user_id"] != "801" || first["display_user_id"] != "900801" || first["username"] != "Seller One" || first["country_id"] != float64(86) || first["status"] != "active" {
t.Fatalf("coin seller list response mismatch: %+v", response)
}
}
func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) {
hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{
ApplicationId: 8001,
@ -3408,6 +3501,80 @@ func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) {
}
}
func TestListResourceShopItemsReturnsActiveSaleItems(t *testing.T) {
walletClient := &fakeWalletClient{
resourceShopResp: &walletv1.ListResourceShopItemsResponse{
Items: []*walletv1.ResourceShopItem{
{
ShopItemId: 9001,
ResourceId: 7001,
Status: "active",
DurationDays: 3,
PriceType: "coin",
CoinPrice: 300,
EffectiveFromMs: 1_700_000_000_000,
EffectiveToMs: 1_700_086_400_000,
SortOrder: 10,
Resource: &walletv1.Resource{
ResourceId: 7001,
ResourceCode: "avatar_frame_gold",
ResourceType: "avatar_frame",
Name: "Gold Frame",
Status: "active",
AssetUrl: "https://cdn.example/frame.png",
PreviewUrl: "https://cdn.example/frame-preview.png",
PriceType: "coin",
CoinPrice: 100,
},
},
},
Total: 1,
},
}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/resource-shop/items?resource_type=avatar_frame&page=1&page_size=10", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("resource shop status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var envelope struct {
Code string `json:"code"`
Data struct {
Items []struct {
ShopItemID int64 `json:"shop_item_id"`
ResourceID int64 `json:"resource_id"`
DurationDays int32 `json:"duration_days"`
CoinPrice int64 `json:"coin_price"`
EffectiveFromMS int64 `json:"effective_from_ms"`
Resource struct {
ResourceCode string `json:"resource_code"`
ResourceType string `json:"resource_type"`
CoinPrice int64 `json:"coin_price"`
PreviewURL string `json:"preview_url"`
} `json:"resource"`
} `json:"items"`
Total int64 `json:"total"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode resource shop failed: %v", err)
}
if walletClient.lastResourceShop == nil || walletClient.lastResourceShop.GetResourceType() != "avatar_frame" || !walletClient.lastResourceShop.GetActiveOnly() || walletClient.lastResourceShop.GetPageSize() != 10 {
t.Fatalf("resource shop request mismatch: %+v", walletClient.lastResourceShop)
}
if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 {
t.Fatalf("resource shop envelope mismatch: %+v", envelope)
}
first := envelope.Data.Items[0]
if first.ShopItemID != 9001 || first.DurationDays != 3 || first.CoinPrice != 300 || first.Resource.ResourceCode != "avatar_frame_gold" || first.Resource.CoinPrice != 100 || first.Resource.PreviewURL == "" {
t.Fatalf("resource shop item mismatch: %+v", first)
}
}
func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) {
walletClient := &fakeWalletClient{
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{
@ -3962,7 +4129,7 @@ func TestChangeCountryUsesAuthenticatedUserIDAndMapsCooldown(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusConflict, string(xerr.CountryChangeCooldown), "req-country-cooldown")
assertEnvelopeMessage(t, recorder, http.StatusConflict, string(xerr.CountryChangeCooldown), "country change is cooling down", "req-country-cooldown")
}
func TestChangeCountryRemovesOldRegionBroadcastMember(t *testing.T) {
@ -4479,6 +4646,40 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-missing")
}
func TestTencentIMUserSigEnsuresImportedAccount(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 1001}
importer := &fakeTencentIMAccountImporter{}
handler := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
SDKAppID: 1400000000,
SecretKey: "secret",
UserSigTTL: time.Hour,
}, profileClient)
handler.SetTencentIMAccountImporter(importer)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-usersig-import")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if importer.lastUserID != 42 || importer.lastNickname != "hy" || importer.lastFaceURL != "https://cdn.example/avatar.png" {
t.Fatalf("unexpected imported account: %+v", importer)
}
importer.err = errors.New("tencent unavailable")
failedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
failedRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
failedRequest.Header.Set("X-Request-ID", "req-usersig-import-failed")
failedRecorder := httptest.NewRecorder()
router.ServeHTTP(failedRecorder, failedRequest)
assertEnvelope(t, failedRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-import-failed")
}
func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) {
previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }

View File

@ -68,6 +68,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
RegistrationReward: h.registrationReward,
FirstRechargeReward: h.firstRechargeReward,
SevenDayCheckIn: h.sevenDayCheckIn,
LuckyGift: h.luckyGift,
})
gameAPI := gameapi.New(gameapi.Config{
GameClient: h.gameClient,
@ -133,6 +134,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
ListAppBanners: appAPI.ListAppBanners,
GetAppVersion: appAPI.GetAppVersion,
GetResourceGroup: resourceAPI.GetResourceGroup,
ListResourceShopItems: resourceAPI.ListResourceShopItems,
ListGifts: resourceAPI.ListGifts,
ListGiftTabs: resourceAPI.ListGiftTabs,
ListEmojiPacks: resourceAPI.ListEmojiPacks,

View File

@ -0,0 +1,80 @@
package walletapi
import (
"net/http"
"strconv"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type coinSellerData struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar,omitempty"`
CountryID int64 `json:"country_id"`
CountryCode string `json:"country_code,omitempty"`
CountryName string `json:"country_name,omitempty"`
CountryDisplayName string `json:"country_display_name,omitempty"`
RegionID int64 `json:"region_id"`
RegionCode string `json:"region_code,omitempty"`
RegionName string `json:"region_name,omitempty"`
Status string `json:"status"`
MerchantAssetType string `json:"merchant_asset_type"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// listCoinSellers 返回当前用户所在区域内所有启用币商。
// gateway 不接受 region_id 参数,避免客户端绕过 user-service 的当前区域边界。
func (h *Handler) listCoinSellers(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userHostClient.ListActiveCoinSellersInMyRegion(request.Context(), &userv1.ListActiveCoinSellersInMyRegionRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]coinSellerData, 0, len(resp.GetCoinSellers()))
for _, item := range resp.GetCoinSellers() {
items = append(items, coinSellerFromProto(item))
}
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
}
func coinSellerFromProto(item *userv1.CoinSellerListItem) coinSellerData {
if item == nil {
return coinSellerData{}
}
return coinSellerData{
UserID: int64IDString(item.GetUserId()),
DisplayUserID: item.GetDisplayUserId(),
Username: item.GetUsername(),
Avatar: item.GetAvatar(),
CountryID: item.GetCountryId(),
CountryCode: item.GetCountryCode(),
CountryName: item.GetCountryName(),
CountryDisplayName: item.GetCountryDisplayName(),
RegionID: item.GetRegionId(),
RegionCode: item.GetRegionCode(),
RegionName: item.GetRegionName(),
Status: item.GetStatus(),
MerchantAssetType: item.GetMerchantAssetType(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func int64IDString(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatInt(value, 10)
}

View File

@ -45,6 +45,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
ApplyWithdrawal: h.applyWithdrawal,
ListCoinTransactions: h.listCoinTransactions,
ListWalletTransactions: h.listWalletTransactions,
ListCoinSellers: h.listCoinSellers,
TransferCoinFromSeller: h.transferCoinFromSeller,
GetRedPacketConfig: h.getRedPacketConfig,
ListRoomRedPackets: h.listRoomRedPackets,

View File

@ -126,16 +126,19 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
if luckyEnabled {
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
LuckyGift: &activityv1.LuckyGiftMeta{
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
CommandId: cmd.ID(),
UserId: cmd.ActorUserID(),
DeviceId: luckyGiftDeviceID(cmd),
RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID,
CoinSpent: billing.GetCoinSpent(),
PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID,
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
CommandId: cmd.ID(),
UserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
DeviceId: luckyGiftDeviceID(cmd),
RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
CoinSpent: billing.GetCoinSpent(),
PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
},
})
if err != nil {

View File

@ -104,7 +104,11 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
t.Fatalf("expected one lucky draw, got %d", len(luckyGift.draws))
}
drawMeta := luckyGift.draws[0].GetLuckyGift()
if drawMeta.GetCommandId() != "cmd-lucky" || drawMeta.GetCoinSpent() != 100 || drawMeta.GetDeviceId() != "device-session-1" || drawMeta.GetAnchorId() != "101" {
if drawMeta.GetCommandId() != "cmd-lucky" ||
drawMeta.GetCoinSpent() != 100 ||
drawMeta.GetDeviceId() != "device-session-1" ||
drawMeta.GetAnchorId() != "101" ||
drawMeta.GetVisibleRegionId() != 9001 {
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
}
}

View File

@ -76,13 +76,13 @@ activity_service:
addr: "activity-service:13006"
request_timeout_ms: 3000
tencent_im:
# Docker 本地默认关闭真实腾讯 IM 账号踢下线;开启后必须和 gateway/room 使用同一个 IM 应用。
enabled: false
sdk_app_id: 0
secret_key: ""
admin_identifier: "administrator"
# Docker 本地联调腾讯 IM 账号导入和封禁踢下线;必须和 gateway/room 使用同一个 IM 应用。
enabled: true
sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
admin_user_sig_ttl_sec: 86400
endpoint: "console.tim.qq.com"
endpoint: "adminapisgp.im.qcloud.com"
request_timeout_ms: 5000
jwt:
issuer: "hyapp"

View File

@ -79,7 +79,7 @@ activity_service:
addr: "activity-service.internal:13006"
request_timeout_ms: 3000
tencent_im:
# 线上开启后,用户封禁会同步调用腾讯云 IM 账号踢下线。
# 线上开启后,新用户会导入腾讯云 IM用户封禁会同步调用腾讯云 IM 账号踢下线。
enabled: true
# 必须和 gateway-service/room-service 使用同一个 IM 应用。
sdk_app_id: 1400000000

View File

@ -76,13 +76,13 @@ activity_service:
addr: "127.0.0.1:13006"
request_timeout_ms: 3000
tencent_im:
# 本地默认关闭真实腾讯 IM 账号踢下线;开启后必须和 gateway/room 使用同一个 IM 应用。
enabled: false
sdk_app_id: 0
secret_key: ""
admin_identifier: "administrator"
# 本地联调腾讯 IM 账号导入和封禁踢下线;必须和 gateway/room 使用同一个 IM 应用。
enabled: true
sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
admin_user_sig_ttl_sec: 86400
endpoint: "console.tim.qq.com"
endpoint: "adminapisgp.im.qcloud.com"
request_timeout_ms: 5000
jwt:
issuer: "hyapp"

View File

@ -136,6 +136,7 @@ func New(cfg config.Config) (*App, error) {
}
}
var imLoginKicker userservice.IMLoginKicker
var imAccountImporter authservice.IMAccountImporter
if cfg.TencentIM.Enabled {
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
if err != nil {
@ -147,6 +148,7 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
imLoginKicker = imClient
imAccountImporter = tencentIMAccountImporter{client: imClient}
}
var roomConn *grpc.ClientConn
var roomEvictor userservice.RoomEvictor
@ -212,6 +214,7 @@ func New(cfg config.Config) (*App, error) {
authservice.WithIPDecisionCache(ipDecisionCache),
authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders(cfg.LoginRisk.Providers)),
authservice.WithRegistrationRewardIssuer(registrationRewardIssuer),
authservice.WithIMAccountImporter(imAccountImporter),
)
// user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。
userSvc := userservice.New(userRepo,
@ -503,6 +506,18 @@ func (a *App) closeHealthHTTP() {
_ = a.healthHTTP.Close(ctx)
}
type tencentIMAccountImporter struct {
client *tencentim.RESTClient
}
func (i tencentIMAccountImporter) ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error {
return i.client.ImportAccount(ctx, tencentim.AccountProfile{
UserID: userID,
Nick: nickname,
FaceURL: faceURL,
})
}
func newMQConsumers(cfg config.Config, inviteSvc *inviteservice.Service, micTimeSvc *mictimeservice.Service) ([]*rocketmqx.Consumer, error) {
consumers := make([]*rocketmqx.Consumer, 0, 2)
if cfg.InviteRechargeWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {

View File

@ -178,6 +178,25 @@ type CoinSellerProfile struct {
UpdatedAtMs int64
}
// CoinSellerListItem 是 App 充值入口需要的启用币商展示资料。
// 区域来自 users 当前事实,币商状态来自 coin_seller_profiles不在 gateway 拼装。
type CoinSellerListItem struct {
UserID int64
DisplayUserID string
Username string
Avatar string
CountryID int64
CountryCode string
CountryName string
CountryDisplayName string
RegionID int64
RegionCode string
RegionName string
Status string
MerchantAssetType string
UpdatedAtMs int64
}
// UserRoleSummary 是面向 App 入口显隐的聚合 read model。
// 这里不承载团队统计或薪资指标,只表达当前用户是否拥有可用身份和待处理邀请数量。
type UserRoleSummary struct {

View File

@ -0,0 +1,26 @@
package auth
import (
"context"
"log/slog"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
userdomain "hyapp/services/user-service/internal/domain/user"
)
func (s *Service) importIMAccountAfterCommit(ctx context.Context, user userdomain.User, meta Meta) {
if s.imAccountImporter == nil || user.UserID <= 0 {
return
}
if err := s.imAccountImporter.ImportAccount(ctx, user.UserID, strings.TrimSpace(user.Username), strings.TrimSpace(user.Avatar)); err != nil {
// 用户创建事务已经提交;账号导入失败不能回滚用户,只能依赖 /im/usersig 兜底补偿和日志排查。
logx.Warn(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", appcode.Normalize(meta.AppCode))),
"im_account_import_failed",
slog.String("component", "user_auth"),
slog.Int64("user_id", user.UserID),
slog.String("error", err.Error()),
)
}
}

View File

@ -99,6 +99,7 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
if tokenErr != nil {
return authdomain.Token{}, tokenErr
}
s.importIMAccountAfterCommit(ctx, user, meta)
s.enqueueLoginIPRiskJob(ctx, meta, token, "account", registration.Platform, registration.Language, registration.Timezone)
return token, nil
}

View File

@ -110,6 +110,11 @@ type RegistrationRewardIssuer interface {
IssueRegistrationReward(ctx context.Context, command RegistrationRewardCommand) error
}
// IMAccountImporter 把 user-service 用户导入腾讯 IM 账号体系。
type IMAccountImporter interface {
ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error
}
// Config 保存认证底座需要的稳定参数。
type Config struct {
// Issuer 写入 access token 的 iss claim。
@ -194,6 +199,8 @@ type Service struct {
ipGeoProviders []IPGeoProvider
// registrationRewardIssuer 在注册事务提交后异步式触发奖励;失败只记录日志,不回滚用户注册。
registrationRewardIssuer RegistrationRewardIssuer
// imAccountImporter 在用户创建成功后补齐腾讯 IM 账号UserSig 入口仍会兜底补偿历史账号。
imAccountImporter IMAccountImporter
}
// Option 调整 auth service 的依赖和策略。
@ -275,6 +282,15 @@ func WithRegistrationRewardIssuer(issuer RegistrationRewardIssuer) Option {
}
}
// WithIMAccountImporter 注入腾讯 IM 账号导入边界nil 表示当前环境不触发真实 IM 副作用。
func WithIMAccountImporter(importer IMAccountImporter) Option {
return func(s *Service) {
if importer != nil {
s.imAccountImporter = importer
}
}
}
// WithAuthRepository 注入纯认证持久化实现。
func WithAuthRepository(repository AuthRepository) Option {
return func(s *Service) {

View File

@ -78,6 +78,19 @@ func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int)
return a.values[attempt]
}
type fakeIMAccountImporter struct {
userID int64
nickname string
faceURL string
}
func (f *fakeIMAccountImporter) ImportAccount(_ context.Context, userID int64, nickname string, faceURL string) error {
f.userID = userID
f.nickname = nickname
f.faceURL = faceURL
return nil
}
func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
base := []userservice.Option{
userservice.WithIdentityRepository(repository),
@ -230,7 +243,8 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(2000)
svc := newAuthService(repository, &now, []int64{900011}, []string{"100011"})
imImporter := &fakeIMAccountImporter{}
svc := newAuthService(repository, &now, []int64{900011}, []string{"100011"}, authservice.WithIMAccountImporter(imImporter))
token, err := svc.QuickCreateAccount(ctx, "secret-pass", authdomain.ThirdPartyRegistration{
Username: "Lingxian Test",
@ -259,6 +273,9 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
if user.Username != "Lingxian Test" || user.Country != "SG" || !user.ProfileCompleted {
t.Fatalf("quick user profile mismatch: %+v", user)
}
if imImporter.userID != 900011 || imImporter.nickname != "Lingxian Test" || imImporter.faceURL != "https://cdn.example/avatar.png" {
t.Fatalf("quick account must import IM account: %+v", imImporter)
}
loginToken, err := svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "quick-dev", authservice.Meta{RequestID: "req-quick-login", AppCode: "lalu"})
if err != nil {
@ -521,7 +538,8 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900003}, []string{"100003"})
imImporter := &fakeIMAccountImporter{}
svc := newAuthService(repository, &now, []int64{900003}, []string{"100003"}, authservice.WithIMAccountImporter(imImporter))
if err := repository.CreateUserWithIdentity(ctx, userdomain.User{
AppCode: "lalu",
UserID: 800001,
@ -591,6 +609,9 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
if user.Avatar == "" || user.BirthDate != "2000-01-02" || user.RegisterSource != "campaign-a" || user.RegisterInstallChannel != "app_store" || user.RegisterCampaign != "spring_launch" || user.RegisterPlatform != "ios" || user.Language != "zh-CN" || user.Timezone != "Asia/Shanghai" {
t.Fatalf("registration profile fields mismatch: %+v", user)
}
if imImporter.userID != token.UserID || imImporter.nickname != "hy" || imImporter.faceURL != "https://cdn.example/avatar.png" {
t.Fatalf("third-party registration must import IM account: %+v", imImporter)
}
}
func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *testing.T) {

View File

@ -174,6 +174,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
if tokenErr != nil {
return authdomain.Token{}, false, tokenErr
}
s.importIMAccountAfterCommit(ctx, user, meta)
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(provider), registration.Platform, registration.Language, registration.Timezone)
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta)
return token, true, nil

View File

@ -32,6 +32,7 @@ type Repository interface {
GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, error)
GetBDProfile(ctx context.Context, userID int64) (hostdomain.BDProfile, error)
GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, error)
GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error)
HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error)
ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error)
@ -484,6 +485,19 @@ func (s *Service) GetCoinSellerProfile(ctx context.Context, userID int64) (hostd
return s.repository.GetCoinSellerProfile(ctx, userID)
}
// ListActiveCoinSellersInMyRegion 返回当前用户所在区域的所有启用币商。
func (s *Service) ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
// 当前区域属于 user-service 用户主事实gateway 不能传入 region_id 来扩大查询范围。
return s.repository.ListActiveCoinSellersInMyRegion(ctx, userID)
}
// GetUserRoleSummary 读取 App 我的页使用的轻量身份摘要,避免 gateway 为入口显隐发起多个角色 RPC。
func (s *Service) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) {
if s.repository == nil {

View File

@ -539,6 +539,100 @@ func TestAdminCreateCoinSellerCanCoexistWithBDAndAgency(t *testing.T) {
}
}
func TestListActiveCoinSellersInMyRegionFiltersByCurrentUserRegion(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
repository.PutCountry(userdomain.Country{
CountryID: 86,
CountryCode: "CN",
CountryName: "China",
CountryDisplayName: "中国",
Enabled: true,
})
repository.PutRegion(userdomain.Region{
RegionID: 70,
RegionCode: "east-asia",
Name: "East Asia",
Status: userdomain.RegionStatusActive,
Countries: []string{"CN"},
})
repository.PutRegion(userdomain.Region{
RegionID: 71,
RegionCode: "west-asia",
Name: "West Asia",
Status: userdomain.RegionStatusActive,
Countries: []string{"CN"},
})
seedActiveUser(t, repository, 940, 70)
repository.PutUser(userdomain.User{
UserID: 941,
DefaultDisplayUserID: displayID(941),
CurrentDisplayUserID: displayID(941),
Username: "Seller One",
Avatar: "https://cdn.example/seller-one.png",
Country: "CN",
RegionID: 70,
ProfileCompleted: true,
ProfileCompletedAtMs: 1,
OnboardingStatus: userdomain.OnboardingStatusCompleted,
Status: userdomain.StatusActive,
})
repository.PutUser(userdomain.User{
UserID: 942,
DefaultDisplayUserID: displayID(942),
CurrentDisplayUserID: displayID(942),
Username: "Disabled Seller",
Country: "CN",
RegionID: 70,
ProfileCompleted: true,
ProfileCompletedAtMs: 1,
OnboardingStatus: userdomain.OnboardingStatusCompleted,
Status: userdomain.StatusActive,
})
repository.PutUser(userdomain.User{
UserID: 943,
DefaultDisplayUserID: displayID(943),
CurrentDisplayUserID: displayID(943),
Username: "Other Region Seller",
Country: "CN",
RegionID: 71,
ProfileCompleted: true,
ProfileCompletedAtMs: 1,
OnboardingStatus: userdomain.OnboardingStatusCompleted,
Status: userdomain.StatusActive,
})
svc := newHostService(repository, 9000, 10000)
for _, userID := range []int64{941, 942, 943} {
if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
CommandID: fmt.Sprintf("admin-create-coin-seller-%d", userID),
AdminUserID: 1,
TargetUserID: userID,
}); err != nil {
t.Fatalf("CreateCoinSeller(%d) failed: %v", userID, err)
}
}
if _, err := svc.SetCoinSellerStatus(ctx, hostservice.SetCoinSellerStatusInput{
CommandID: "admin-disable-coin-seller-942",
AdminUserID: 1,
TargetUserID: 942,
Status: hostdomain.CoinSellerStatusDisabled,
}); err != nil {
t.Fatalf("SetCoinSellerStatus failed: %v", err)
}
items, err := svc.ListActiveCoinSellersInMyRegion(ctx, 940)
if err != nil {
t.Fatalf("ListActiveCoinSellersInMyRegion failed: %v", err)
}
if len(items) != 1 {
t.Fatalf("only same-region active sellers should be returned: %+v", items)
}
item := items[0]
if item.UserID != 941 || item.Username != "Seller One" || item.Avatar == "" || item.CountryID != 86 || item.CountryCode != "CN" || item.RegionID != 70 || item.RegionCode != "east-asia" || item.Status != hostdomain.CoinSellerStatusActive {
t.Fatalf("coin seller item mismatch: %+v", item)
}
}
func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)

View File

@ -74,6 +74,82 @@ func (r *Repository) GetCoinSellerProfile(ctx context.Context, userID int64) (ho
return queryCoinSellerProfile(ctx, r.db, "WHERE user_id = ?", userID)
}
// ListActiveCoinSellersInMyRegion 返回当前用户所在区域的启用币商列表。
// 该查询从 users.region_id 出发过滤,避免把区域条件交给 gateway 或客户端传入。
func (r *Repository) ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, error) {
regionID, err := r.userRegion(ctx, r.db, userID, "")
if err != nil {
return nil, err
}
if regionID <= 0 {
// 币商列表是区域内充值入口,缺少当前区域时不能降级成全局列表。
return nil, xerr.New(xerr.InvalidArgument, "user region is required")
}
if err := requireActiveRegion(ctx, r.db, regionID); err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, `
SELECT
u.user_id,
u.current_display_user_id,
COALESCE(u.username, ''),
COALESCE(u.avatar, ''),
COALESCE(country.country_id, 0),
COALESCE(u.country, ''),
COALESCE(country.country_name, ''),
COALESCE(country.country_display_name, ''),
u.region_id,
COALESCE(region.region_code, ''),
COALESCE(region.name, ''),
seller.status,
seller.merchant_asset_type,
seller.updated_at_ms
FROM users u
INNER JOIN coin_seller_profiles seller
ON seller.app_code = u.app_code AND seller.user_id = u.user_id
LEFT JOIN countries country
ON country.app_code = u.app_code AND country.country_code = u.country
LEFT JOIN regions region
ON region.app_code = u.app_code AND region.region_id = u.region_id
WHERE u.app_code = ?
AND u.region_id = ?
AND u.status = 'active'
AND seller.status = ?
AND seller.merchant_asset_type = ?
ORDER BY seller.updated_at_ms DESC, u.user_id DESC
`, appcode.FromContext(ctx), regionID, hostdomain.CoinSellerStatusActive, hostdomain.CoinSellerMerchantAssetType)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]hostdomain.CoinSellerListItem, 0)
for rows.Next() {
var item hostdomain.CoinSellerListItem
if err := rows.Scan(
&item.UserID,
&item.DisplayUserID,
&item.Username,
&item.Avatar,
&item.CountryID,
&item.CountryCode,
&item.CountryName,
&item.CountryDisplayName,
&item.RegionID,
&item.RegionCode,
&item.RegionName,
&item.Status,
&item.MerchantAssetType,
&item.UpdatedAtMs,
); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// GetUserRoleSummary 用一组索引点查构造我的页角色摘要。
// 摘要只表达入口显隐需要的当前身份,不加载成员列表、团队统计或薪资数据。
func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) {

View File

@ -342,6 +342,29 @@ func toProtoCoinSellerProfile(profile hostdomain.CoinSellerProfile) *userv1.Coin
}
}
// toProtoCoinSellerListItem 把区域币商列表项转换为 App 读取投影。
func toProtoCoinSellerListItem(item hostdomain.CoinSellerListItem) *userv1.CoinSellerListItem {
if item.UserID == 0 {
return nil
}
return &userv1.CoinSellerListItem{
UserId: item.UserID,
DisplayUserId: item.DisplayUserID,
Username: item.Username,
Avatar: item.Avatar,
CountryId: item.CountryID,
CountryCode: item.CountryCode,
CountryName: item.CountryName,
CountryDisplayName: item.CountryDisplayName,
RegionId: item.RegionID,
RegionCode: item.RegionCode,
RegionName: item.RegionName,
Status: item.Status,
MerchantAssetType: item.MerchantAssetType,
UpdatedAtMs: item.UpdatedAtMs,
}
}
// toProtoUserRoleSummary 把 host domain 入口显隐摘要转换为 gRPC 投影。
func toProtoUserRoleSummary(summary hostdomain.UserRoleSummary) *userv1.UserRoleSummary {
if summary.UserID == 0 {

View File

@ -206,6 +206,24 @@ func (s *Server) GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSe
return &userv1.GetCoinSellerProfileResponse{CoinSellerProfile: toProtoCoinSellerProfile(profile)}, nil
}
// ListActiveCoinSellersInMyRegion 返回当前用户同区域启用币商。
func (s *Server) ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
items, err := s.hostSvc.ListActiveCoinSellersInMyRegion(ctx, req.GetUserId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: make([]*userv1.CoinSellerListItem, 0, len(items))}
for _, item := range items {
resp.CoinSellers = append(resp.CoinSellers, toProtoCoinSellerListItem(item))
}
return resp, nil
}
// GetUserRoleSummary 返回 App 入口显隐所需的角色摘要,替代 gateway 多角色 fanout。
func (s *Server) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
if s.hostSvc == nil {

View File

@ -405,6 +405,24 @@ CREATE TABLE IF NOT EXISTS resource_group_items (
KEY idx_resource_group_items_group (app_code, group_id, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资源分组明细表';
CREATE TABLE IF NOT EXISTS resource_shop_items (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
shop_item_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '道具商店条目 ID',
resource_id BIGINT NOT NULL COMMENT '售卖资源 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
duration_days INT NOT NULL COMMENT '售卖天数,只允许 1/3/7',
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间UTC epoch ms0 表示立即生效',
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间UTC epoch ms0 表示长期有效',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_resource_shop_items_resource (app_code, resource_id),
KEY idx_resource_shop_items_status_sort (app_code, status, sort_order),
KEY idx_resource_shop_items_effective (app_code, status, effective_from_ms, effective_to_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='道具商店售卖资源表';
CREATE TABLE IF NOT EXISTS gift_type_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码,供 gift_configs 引用',

View File

@ -553,6 +553,27 @@ type TaskRewardReceipt struct {
GrantedAtMS int64
}
// LuckyGiftRewardCommand 是 activity-service 用抽奖 draw_id 发起的 COIN 入账命令。
type LuckyGiftRewardCommand struct {
AppCode string
CommandID string
TargetUserID int64
Amount int64
DrawID string
RoomID string
GiftID string
PoolID string
Reason string
}
// LuckyGiftRewardReceipt 是幸运礼物返奖入账后的稳定回执。
type LuckyGiftRewardReceipt struct {
TransactionID string
Balance AssetBalance
Amount int64
GrantedAtMS int64
}
// GameCoinChangeCommand 是 game-service 对钱包发起的游戏专用金币改账命令。
type GameCoinChangeCommand struct {
AppCode string

View File

@ -71,6 +71,10 @@ const (
PriceTypeCoin = "coin"
PriceTypeFree = "free"
ShopDurationOneDay int32 = 1
ShopDurationThreeDays int32 = 3
ShopDurationSevenDays int32 = 7
)
type Resource struct {
@ -168,6 +172,24 @@ type GiftTypeConfig struct {
UpdatedAtMS int64
}
type ResourceShopItem struct {
AppCode string
ShopItemID int64
ResourceID int64
Resource Resource
Status string
DurationDays int32
PriceType string
CoinPrice int64
EffectiveFromMS int64
EffectiveToMS int64
SortOrder int32
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
}
type UserResourceEntitlement struct {
AppCode string
EntitlementID string
@ -380,6 +402,39 @@ type ListResourceGrantsQuery struct {
PageSize int32
}
type ResourceShopItemInput struct {
ShopItemID int64
ResourceID int64
Status string
DurationDays int32
EffectiveFromMS int64
EffectiveToMS int64
SortOrder int32
}
type ListResourceShopItemsQuery struct {
AppCode string
ResourceType string
Status string
Keyword string
Page int32
PageSize int32
ActiveOnly bool
}
type ResourceShopItemsCommand struct {
AppCode string
Items []ResourceShopItemInput
OperatorUserID int64
}
type ResourceShopItemStatusCommand struct {
AppCode string
ShopItemID int64
Status string
OperatorUserID int64
}
// BadgeGrantOutbox is a wallet resource grant event relayed to activity-service badge display projection.
type BadgeGrantOutbox struct {
AppCode string
@ -434,6 +489,24 @@ func ValidResourceType(value string) bool {
}
}
func ResourceTypeSellableInShop(value string) bool {
switch NormalizeResourceType(value) {
case TypeAvatarFrame, TypeProfileCard, TypeVehicle, TypeChatBubble, TypeBadge, TypeMicSeatIcon, TypeMicSeatAnimation:
return true
default:
return false
}
}
func ValidShopDurationDays(value int32) bool {
switch value {
case ShopDurationOneDay, ShopDurationThreeDays, ShopDurationSevenDays:
return true
default:
return false
}
}
func ValidStatus(value string) bool {
switch NormalizeStatus(value) {
case StatusActive, StatusDisabled:

View File

@ -207,3 +207,37 @@ func (s *Service) ListResourceGrants(ctx context.Context, query resourcedomain.L
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.ListResourceGrants(ctx, query)
}
func (s *Service) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) {
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
ctx = appcode.WithContext(ctx, query.AppCode)
if strings.TrimSpace(query.ResourceType) != "" {
resourceType := resourcedomain.NormalizeResourceType(query.ResourceType)
if !resourcedomain.ResourceTypeSellableInShop(resourceType) {
return nil, 0, xerr.New(xerr.InvalidArgument, "resource_type is invalid")
}
query.ResourceType = resourceType
}
return s.repository.ListResourceShopItems(ctx, query)
}
func (s *Service) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.UpsertResourceShopItems(ctx, command)
}
func (s *Service) SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) {
if s.repository == nil {
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.SetResourceShopItemStatus(ctx, command)
}

View File

@ -23,6 +23,7 @@ type Repository interface {
AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error)
TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error)
CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error)
ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error)
GetRedPacketConfig(ctx context.Context, appCode string) (ledger.RedPacketConfig, error)
UpdateRedPacketConfig(ctx context.Context, config ledger.RedPacketConfig) (ledger.RedPacketConfig, error)
@ -73,6 +74,9 @@ type Repository interface {
ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error)
EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error)
ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error)
ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error)
UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error)
SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error)
ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error)
ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error)
MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error
@ -269,6 +273,28 @@ func (s *Service) CreditTaskReward(ctx context.Context, command ledger.TaskRewar
return s.repository.CreditTaskReward(ctx, command)
}
// CreditLuckyGiftReward 发放幸运礼物中奖金币;抽奖资格和金额已由 activity-service 决策,钱包只做幂等入账。
func (s *Service) CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) {
if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.DrawID == "" || command.RoomID == "" || command.GiftID == "" || command.PoolID == "" {
return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.InvalidArgument, "lucky gift reward command is incomplete")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required")
}
if s.repository == nil {
return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.DrawID = strings.TrimSpace(command.DrawID)
command.RoomID = strings.TrimSpace(command.RoomID)
command.GiftID = strings.TrimSpace(command.GiftID)
command.PoolID = strings.TrimSpace(command.PoolID)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.CreditLuckyGiftReward(ctx, command)
}
// ApplyGameCoinChange 是游戏平台唯一的 COIN 改账入口,方向由 op_type 控制。
func (s *Service) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) {
if command.CommandID == "" || command.UserID <= 0 || command.PlatformCode == "" || command.GameID == "" || command.ProviderOrderID == "" {

View File

@ -519,6 +519,43 @@ func TestCreditTaskRewardIsIdempotent(t *testing.T) {
}
}
// TestCreditLuckyGiftRewardIsIdempotent 验证幸运礼物返奖只按 draw_id 派一次,并产出余额通知事实。
func TestCreditLuckyGiftRewardIsIdempotent(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
command := ledger.LuckyGiftRewardCommand{
CommandID: "lucky_reward:lucky_draw_1",
TargetUserID: 21002,
Amount: 188,
DrawID: "lucky_draw_1",
RoomID: "room-1",
GiftID: "rose",
PoolID: "super_lucky",
Reason: "lucky_gift_reward",
}
first, err := svc.CreditLuckyGiftReward(context.Background(), command)
if err != nil {
t.Fatalf("CreditLuckyGiftReward failed: %v", err)
}
second, err := svc.CreditLuckyGiftReward(context.Background(), command)
if err != nil {
t.Fatalf("CreditLuckyGiftReward retry failed: %v", err)
}
if first.TransactionID == "" || first.TransactionID != second.TransactionID || second.Balance.AvailableAmount != 188 {
t.Fatalf("lucky reward receipt mismatch: first=%+v second=%+v", first, second)
}
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", command.CommandID, "lucky_gift_reward"); got != 1 {
t.Fatalf("lucky reward should write one transaction, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletBalanceChanged"); got != 1 {
t.Fatalf("lucky reward should write one balance change event, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletLuckyGiftRewardCredited"); got != 1 {
t.Fatalf("lucky reward should write one reward fact event, got %d", got)
}
}
// TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin 验证币商专用金币和玩家普通金币不会混账。
func TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin(t *testing.T) {
repository := mysqltest.NewRepository(t)

View File

@ -25,6 +25,7 @@ const (
bizTypeGiftDebit = "gift_debit"
bizTypeManualCredit = "manual_credit"
bizTypeTaskReward = "task_reward"
bizTypeLuckyGiftReward = "lucky_gift_reward"
bizTypeCoinSellerTransfer = "coin_seller_transfer"
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
@ -534,6 +535,80 @@ func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRe
return receiptFromTaskRewardMetadata(transactionID, metadata), nil
}
// CreditLuckyGiftReward 在同一事务里完成幸运礼物中奖 COIN 入账、交易分录和钱包 outbox。
func (r *Repository) CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) {
if r == nil || r.db == nil {
return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := luckyGiftRewardRequestHash(command)
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeLuckyGiftReward); err != nil || exists {
if err != nil || !exists {
return ledger.LuckyGiftRewardReceipt{}, err
}
return r.receiptForLuckyGiftRewardTransaction(ctx, tx, txRow.TransactionID)
}
nowMs := time.Now().UnixMilli()
account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs)
if err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
balanceAfter := account.AvailableAmount + command.Amount
metadata := luckyGiftRewardMetadata{
AppCode: command.AppCode,
TargetUserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
Amount: command.Amount,
DrawID: command.DrawID,
RoomID: command.RoomID,
GiftID: command.GiftID,
PoolID: command.PoolID,
Reason: command.Reason,
BalanceAfter: balanceAfter,
GrantedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeLuckyGiftReward, requestHash, command.DrawID, metadata, nowMs); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: command.Amount,
FrozenDelta: 0,
AvailableAfter: balanceAfter,
FrozenAfter: account.FrozenAmount,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
luckyGiftRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
}); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil
}
// ApplyGameCoinChange 在同一事务内完成游戏 COIN 改账、分录和 outbox。
func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) {
if r == nil || r.db == nil {
@ -1548,6 +1623,20 @@ type taskRewardMetadata struct {
GrantedAtMS int64 `json:"granted_at_ms"`
}
type luckyGiftRewardMetadata struct {
AppCode string `json:"app_code"`
TargetUserID int64 `json:"target_user_id"`
AssetType string `json:"asset_type"`
Amount int64 `json:"amount"`
DrawID string `json:"draw_id"`
RoomID string `json:"room_id"`
GiftID string `json:"gift_id"`
PoolID string `json:"pool_id"`
Reason string `json:"reason"`
BalanceAfter int64 `json:"balance_after"`
GrantedAtMS int64 `json:"granted_at_ms"`
}
type gameCoinMetadata struct {
AppCode string `json:"app_code"`
UserID int64 `json:"user_id"`
@ -1654,6 +1743,38 @@ func receiptFromTaskRewardMetadata(transactionID string, metadata taskRewardMeta
}
}
func (r *Repository) receiptForLuckyGiftRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.LuckyGiftRewardReceipt, error) {
var metadataJSON string
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(CAST(metadata_json AS CHAR), '{}')
FROM wallet_transactions
WHERE app_code = ? AND transaction_id = ?`,
appcode.FromContext(ctx),
transactionID,
).Scan(&metadataJSON); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
var metadata luckyGiftRewardMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil
}
func receiptFromLuckyGiftRewardMetadata(transactionID string, metadata luckyGiftRewardMetadata) ledger.LuckyGiftRewardReceipt {
return ledger.LuckyGiftRewardReceipt{
TransactionID: transactionID,
Balance: ledger.AssetBalance{
AppCode: metadata.AppCode,
UserID: metadata.TargetUserID,
AssetType: metadata.AssetType,
AvailableAmount: metadata.BalanceAfter,
},
Amount: metadata.Amount,
GrantedAtMS: metadata.GrantedAtMS,
}
}
func (r *Repository) receiptForGameTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.GameCoinChangeReceipt, error) {
var metadataJSON string
if err := tx.QueryRowContext(ctx,
@ -1836,6 +1957,32 @@ func taskRewardCreditedEvent(transactionID string, commandID string, metadata ta
}
}
func luckyGiftRewardCreditedEvent(transactionID string, commandID string, metadata luckyGiftRewardMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletLuckyGiftRewardCredited", metadata.TargetUserID, ledger.AssetCoin),
EventType: "WalletLuckyGiftRewardCredited",
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: metadata.Amount,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"user_id": metadata.TargetUserID,
"draw_id": metadata.DrawID,
"room_id": metadata.RoomID,
"gift_id": metadata.GiftID,
"pool_id": metadata.PoolID,
"amount": metadata.Amount,
"reason": metadata.Reason,
"created_at_ms": nowMs,
},
CreatedAtMS: nowMs,
}
}
func coinSellerStockCreditedEvent(transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) walletOutboxEvent {
eventType := "WalletCoinSellerCoinCompensated"
if metadata.StockType == ledger.StockTypeUSDTPurchase {
@ -1965,6 +2112,19 @@ func taskRewardRequestHash(command ledger.TaskRewardCommand) string {
))
}
func luckyGiftRewardRequestHash(command ledger.LuckyGiftRewardCommand) string {
return stableHash(fmt.Sprintf("lucky_gift_reward|%s|%d|%d|%s|%s|%s|%s|%s",
appcode.Normalize(command.AppCode),
command.TargetUserID,
command.Amount,
strings.TrimSpace(command.DrawID),
strings.TrimSpace(command.RoomID),
strings.TrimSpace(command.GiftID),
strings.TrimSpace(command.PoolID),
strings.TrimSpace(command.Reason),
))
}
func gameBizTypeAndDelta(opType string, coinAmount int64) (string, int64, error) {
switch ledger.NormalizeGameOpType(opType) {
case ledger.GameOpDebit:

View File

@ -943,6 +943,151 @@ func (r *Repository) ListResourceGrants(ctx context.Context, query resourcedomai
return items, total, rows.Err()
}
// ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖视图;价格始终由资源一日价乘以售卖天数派生。
func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, query.AppCode)
query.AppCode = appcode.FromContext(ctx)
query = normalizeResourceShopItemsQuery(query)
where, args := resourceShopItemsWhereSQL(query)
var total int64
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM resource_shop_items si
JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id
`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+where+`
ORDER BY si.sort_order ASC, si.shop_item_id DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]resourcedomain.ResourceShopItem, 0, query.PageSize)
for rows.Next() {
item, err := scanResourceShopItem(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, rows.Err()
}
// UpsertResourceShopItems 批量添加或重配售卖资源;同一个资源在商店只保留一个当前售卖配置。
func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
command = normalizeResourceShopItemsCommand(command)
if err := validateResourceShopItemsCommand(command); err != nil {
return nil, err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
nowMs := time.Now().UnixMilli()
resourceIDs := make([]int64, 0, len(command.Items))
seen := make(map[int64]struct{}, len(command.Items))
for _, item := range command.Items {
if _, exists := seen[item.ResourceID]; exists {
return nil, xerr.New(xerr.InvalidArgument, "resource shop contains duplicate resource")
}
seen[item.ResourceID] = struct{}{}
resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID)
if err != nil {
return nil, err
}
if err := validateResourceShopResource(resource); err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO resource_shop_items (
app_code, resource_id, status, duration_days, effective_from_ms, effective_to_ms, sort_order,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
duration_days = VALUES(duration_days),
effective_from_ms = VALUES(effective_from_ms),
effective_to_ms = VALUES(effective_to_ms),
sort_order = VALUES(sort_order),
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms)`,
command.AppCode, item.ResourceID, item.Status, item.DurationDays, item.EffectiveFromMS, item.EffectiveToMS, item.SortOrder,
command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
); err != nil {
return nil, err
}
resourceIDs = append(resourceIDs, item.ResourceID)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return r.listResourceShopItemsByResourceIDs(ctx, resourceIDs)
}
func (r *Repository) SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) {
if r == nil || r.db == nil {
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
command.Status = resourcedomain.NormalizeStatus(command.Status)
if command.ShopItemID <= 0 {
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "shop_item_id is required")
}
if !resourcedomain.ValidStatus(command.Status) {
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "status is invalid")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return resourcedomain.ResourceShopItem{}, err
}
defer func() { _ = tx.Rollback() }()
item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true)
if err != nil {
return resourcedomain.ResourceShopItem{}, err
}
if command.Status == resourcedomain.StatusActive {
if err := validateResourceShopResource(item.Resource); err != nil {
return resourcedomain.ResourceShopItem{}, err
}
}
nowMs := time.Now().UnixMilli()
result, err := tx.ExecContext(ctx, `
UPDATE resource_shop_items
SET status = ?, updated_by_user_id = ?, updated_at_ms = ?
WHERE app_code = ? AND shop_item_id = ?`,
command.Status, command.OperatorUserID, nowMs, command.AppCode, command.ShopItemID,
)
if err != nil {
return resourcedomain.ResourceShopItem{}, err
}
if affected, err := result.RowsAffected(); err != nil {
return resourcedomain.ResourceShopItem{}, err
} else if affected == 0 {
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found")
}
if err := tx.Commit(); err != nil {
return resourcedomain.ResourceShopItem{}, err
}
return r.getResourceShopItem(ctx, command.ShopItemID)
}
func (r *Repository) GetResourceGrant(ctx context.Context, grantID string) (resourcedomain.ResourceGrant, error) {
if r == nil || r.db == nil {
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -1559,6 +1704,53 @@ func (r *Repository) getResourceForUpdate(ctx context.Context, tx *sql.Tx, resou
return resource, err
}
func (r *Repository) getResourceShopItem(ctx context.Context, shopItemID int64) (resourcedomain.ResourceShopItem, error) {
return r.getResourceShopItemWithQuerier(ctx, r.db, shopItemID, false)
}
func (r *Repository) getResourceShopItemTx(ctx context.Context, tx *sql.Tx, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) {
return r.getResourceShopItemWithQuerier(ctx, tx, shopItemID, lock)
}
func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier sqlRowQuerier, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) {
query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.shop_item_id = ?`
if lock {
query += ` FOR UPDATE`
}
row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), shopItemID)
item, err := scanResourceShopItem(row)
if errors.Is(err, sql.ErrNoRows) {
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found")
}
return item, err
}
func (r *Repository) listResourceShopItemsByResourceIDs(ctx context.Context, resourceIDs []int64) ([]resourcedomain.ResourceShopItem, error) {
resourceIDs = compactPositiveInt64s(resourceIDs)
if len(resourceIDs) == 0 {
return []resourcedomain.ResourceShopItem{}, nil
}
args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(resourceIDs)...)
rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+`
WHERE si.app_code = ? AND si.resource_id IN (`+placeholders(len(resourceIDs))+`)
ORDER BY si.sort_order ASC, si.shop_item_id DESC`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]resourcedomain.ResourceShopItem, 0, len(resourceIDs))
for rows.Next() {
item, err := scanResourceShopItem(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (r *Repository) getResourceGroup(ctx context.Context, querier sqlRowQuerier, groupID int64, lock bool) (resourcedomain.ResourceGroup, error) {
query := `
SELECT app_code, group_id, group_code, name, status, description, sort_order,
@ -1800,6 +1992,57 @@ func scanResource(scanner scanTarget) (resourcedomain.Resource, error) {
return resource, nil
}
func scanResourceShopItem(scanner scanTarget) (resourcedomain.ResourceShopItem, error) {
var item resourcedomain.ResourceShopItem
var resource resourcedomain.Resource
var scopesJSON string
if err := scanner.Scan(
&item.AppCode,
&item.ShopItemID,
&item.ResourceID,
&item.Status,
&item.DurationDays,
&item.PriceType,
&item.CoinPrice,
&item.EffectiveFromMS,
&item.EffectiveToMS,
&item.SortOrder,
&item.CreatedByUserID,
&item.UpdatedByUserID,
&item.CreatedAtMS,
&item.UpdatedAtMS,
&resource.AppCode,
&resource.ResourceID,
&resource.ResourceCode,
&resource.ResourceType,
&resource.Name,
&resource.Status,
&resource.Grantable,
&resource.ManagerGrantEnabled,
&resource.GrantStrategy,
&resource.WalletAssetType,
&resource.WalletAssetAmount,
&resource.PriceType,
&resource.CoinPrice,
&resource.GiftPointAmount,
&scopesJSON,
&resource.AssetURL,
&resource.PreviewURL,
&resource.AnimationURL,
&resource.MetadataJSON,
&resource.SortOrder,
&resource.CreatedByUserID,
&resource.UpdatedByUserID,
&resource.CreatedAtMS,
&resource.UpdatedAtMS,
); err != nil {
return resourcedomain.ResourceShopItem{}, err
}
resource.UsageScopes = parseStringArray(scopesJSON)
item.Resource = resource
return item, nil
}
func scanResourceGroup(scanner scanTarget) (resourcedomain.ResourceGroup, error) {
var group resourcedomain.ResourceGroup
err := scanner.Scan(
@ -2057,6 +2300,19 @@ func giftTypeConfigSelectSQL() string {
`
}
func resourceShopItemSelectSQL() string {
return `
SELECT si.app_code, si.shop_item_id, si.resource_id, si.status, si.duration_days,
r.price_type,
CASE WHEN r.price_type = 'coin' THEN r.coin_price * si.duration_days ELSE 0 END AS coin_price,
si.effective_from_ms, si.effective_to_ms, si.sort_order,
si.created_by_user_id, si.updated_by_user_id, si.created_at_ms, si.updated_at_ms,
` + resourceColumnsWithAlias("r") + `
FROM resource_shop_items si
JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id
`
}
func userResourceSelectSQL() string {
return `
SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
@ -2159,6 +2415,31 @@ func giftTypeConfigWhereSQL(query resourcedomain.ListGiftTypeConfigsQuery) (stri
return where, args
}
func resourceShopItemsWhereSQL(query resourcedomain.ListResourceShopItemsQuery) (string, []any) {
where := `WHERE si.app_code = ? AND r.resource_type IN ('avatar_frame', 'profile_card', 'vehicle', 'chat_bubble', 'badge', 'mic_seat_icon', 'mic_seat_animation')`
args := []any{query.AppCode}
if query.ActiveOnly {
nowMs := time.Now().UnixMilli()
where += ` AND si.status = 'active' AND r.status = 'active'
AND (si.effective_from_ms = 0 OR si.effective_from_ms <= ?)
AND (si.effective_to_ms = 0 OR si.effective_to_ms > ?)`
args = append(args, nowMs, nowMs)
} else if query.Status != "" {
where += ` AND si.status = ?`
args = append(args, query.Status)
}
if query.ResourceType != "" {
where += ` AND r.resource_type = ?`
args = append(args, query.ResourceType)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
where += ` AND (r.resource_code LIKE ? OR r.name LIKE ?)`
args = append(args, like, like)
}
return where, args
}
func normalizeResourceListQuery(query resourcedomain.ListResourcesQuery) resourcedomain.ListResourcesQuery {
query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) {
@ -2198,6 +2479,17 @@ func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery)
return query
}
func normalizeResourceShopItemsQuery(query resourcedomain.ListResourceShopItemsQuery) resourcedomain.ListResourceShopItemsQuery {
query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(query.ResourceType) {
query.ResourceType = "__invalid__"
}
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.Keyword = strings.TrimSpace(query.Keyword)
query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
return query
}
func normalizePage(page int32, pageSize int32) (int32, int32) {
if page < 1 {
page = 1
@ -2215,6 +2507,41 @@ func resourceOffset(page int32, pageSize int32) int32 {
return (page - 1) * pageSize
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
items := make([]string, count)
for index := range items {
items[index] = "?"
}
return strings.Join(items, ",")
}
func int64AnyArgs(values []int64) []any {
args := make([]any, 0, len(values))
for _, value := range values {
args = append(args, value)
}
return args
}
func compactPositiveInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func mapResourceWriteError(err error) error {
if isMySQLDuplicateError(err) {
return xerr.New(xerr.Conflict, "resource_code already exists")
@ -2282,6 +2609,47 @@ func validateResourceCommand(command resourcedomain.ResourceCommand) error {
return nil
}
func normalizeResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) resourcedomain.ResourceShopItemsCommand {
for index := range command.Items {
command.Items[index].Status = resourcedomain.NormalizeStatus(command.Items[index].Status)
}
return command
}
func validateResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) error {
if len(command.Items) == 0 || command.OperatorUserID < 0 {
return xerr.New(xerr.InvalidArgument, "resource shop command is incomplete")
}
for _, item := range command.Items {
if item.ResourceID <= 0 {
return xerr.New(xerr.InvalidArgument, "resource_id is required")
}
if !resourcedomain.ValidStatus(item.Status) {
return xerr.New(xerr.InvalidArgument, "status is invalid")
}
if !resourcedomain.ValidShopDurationDays(item.DurationDays) {
return xerr.New(xerr.InvalidArgument, "resource shop duration is invalid")
}
if item.EffectiveFromMS < 0 || item.EffectiveToMS < 0 || (item.EffectiveFromMS > 0 && item.EffectiveToMS > 0 && item.EffectiveToMS <= item.EffectiveFromMS) {
return xerr.New(xerr.InvalidArgument, "resource shop effective time is invalid")
}
}
return nil
}
func validateResourceShopResource(resource resourcedomain.Resource) error {
if !resourcedomain.ResourceTypeSellableInShop(resource.ResourceType) {
return xerr.New(xerr.InvalidArgument, "resource_type cannot be sold in shop")
}
if resource.Status != resourcedomain.StatusActive {
return xerr.New(xerr.Conflict, "resource is disabled")
}
if resourcedomain.NormalizePriceType(resource.PriceType) != resourcedomain.PriceTypeCoin || resource.CoinPrice <= 0 {
return xerr.New(xerr.InvalidArgument, "resource coin price is required")
}
return nil
}
func normalizeResourceGroupCommand(command resourcedomain.ResourceGroupCommand) resourcedomain.ResourceGroupCommand {
command.GroupCode = strings.TrimSpace(command.GroupCode)
command.Name = strings.TrimSpace(command.Name)

View File

@ -287,6 +287,55 @@ func (s *Server) ListResourceGrants(ctx context.Context, req *walletv1.ListResou
return resp, nil
}
func (s *Server) ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
items, total, err := s.svc.ListResourceShopItems(ctx, resourcedomain.ListResourceShopItemsQuery{
AppCode: req.GetAppCode(),
ResourceType: req.GetResourceType(),
Status: req.GetStatus(),
Keyword: req.GetKeyword(),
Page: req.GetPage(),
PageSize: req.GetPageSize(),
ActiveOnly: req.GetActiveOnly(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &walletv1.ListResourceShopItemsResponse{Items: make([]*walletv1.ResourceShopItem, 0, len(items)), Total: total}
for _, item := range items {
resp.Items = append(resp.Items, resourceShopItemToProto(item))
}
return resp, nil
}
func (s *Server) UpsertResourceShopItems(ctx context.Context, req *walletv1.UpsertResourceShopItemsRequest) (*walletv1.UpsertResourceShopItemsResponse, error) {
items, err := s.svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
AppCode: req.GetAppCode(),
Items: resourceShopItemInputs(req.GetItems()),
OperatorUserID: req.GetOperatorUserId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &walletv1.UpsertResourceShopItemsResponse{Items: make([]*walletv1.ResourceShopItem, 0, len(items))}
for _, item := range items {
resp.Items = append(resp.Items, resourceShopItemToProto(item))
}
return resp, nil
}
func (s *Server) SetResourceShopItemStatus(ctx context.Context, req *walletv1.SetResourceShopItemStatusRequest) (*walletv1.ResourceShopItemResponse, error) {
item, err := s.svc.SetResourceShopItemStatus(ctx, resourcedomain.ResourceShopItemStatusCommand{
AppCode: req.GetAppCode(),
ShopItemID: req.GetShopItemId(),
Status: req.GetStatus(),
OperatorUserID: req.GetOperatorUserId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.ResourceShopItemResponse{Item: resourceShopItemToProto(item)}, nil
}
func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedomain.ResourceCommand {
return resourcedomain.ResourceCommand{
AppCode: req.GetAppCode(),
@ -433,6 +482,25 @@ func groupItemInputs(items []*walletv1.ResourceGroupItemInput) []resourcedomain.
return out
}
func resourceShopItemInputs(items []*walletv1.ResourceShopItemInput) []resourcedomain.ResourceShopItemInput {
out := make([]resourcedomain.ResourceShopItemInput, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
out = append(out, resourcedomain.ResourceShopItemInput{
ShopItemID: item.GetShopItemId(),
ResourceID: item.GetResourceId(),
Status: item.GetStatus(),
DurationDays: item.GetDurationDays(),
EffectiveFromMS: item.GetEffectiveFromMs(),
EffectiveToMS: item.GetEffectiveToMs(),
SortOrder: item.GetSortOrder(),
})
}
return out
}
func resourceToProto(resource resourcedomain.Resource) *walletv1.Resource {
return &walletv1.Resource{
AppCode: appcode.Normalize(resource.AppCode),
@ -462,6 +530,26 @@ func resourceToProto(resource resourcedomain.Resource) *walletv1.Resource {
}
}
func resourceShopItemToProto(item resourcedomain.ResourceShopItem) *walletv1.ResourceShopItem {
return &walletv1.ResourceShopItem{
AppCode: appcode.Normalize(item.AppCode),
ShopItemId: item.ShopItemID,
ResourceId: item.ResourceID,
Resource: resourceToProto(item.Resource),
Status: item.Status,
DurationDays: item.DurationDays,
PriceType: item.PriceType,
CoinPrice: item.CoinPrice,
EffectiveFromMs: item.EffectiveFromMS,
EffectiveToMs: item.EffectiveToMS,
SortOrder: item.SortOrder,
CreatedByUserId: item.CreatedByUserID,
UpdatedByUserId: item.UpdatedByUserID,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func managerGrantEnabledOrDefault(value *bool) bool {
if value == nil {
// 新建/编辑入口如果没显式携带开关,仍按产品规则默认允许经理中心赠送。

View File

@ -651,6 +651,38 @@ func (s *Server) CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskR
}, nil
}
// CreditLuckyGiftReward 处理 activity-service 幸运礼物抽奖 outbox 的中奖金币入账。
func (s *Server) CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest) (*walletv1.CreditLuckyGiftRewardResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
receipt, err := s.svc.CreditLuckyGiftReward(ctx, ledger.LuckyGiftRewardCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
TargetUserID: req.GetTargetUserId(),
Amount: req.GetAmount(),
DrawID: req.GetDrawId(),
RoomID: req.GetRoomId(),
GiftID: req.GetGiftId(),
PoolID: req.GetPoolId(),
Reason: req.GetReason(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.CreditLuckyGiftRewardResponse{
TransactionId: receipt.TransactionID,
Amount: receipt.Amount,
GrantedAtMs: receipt.GrantedAtMS,
Balance: &walletv1.AssetBalance{
AppCode: receipt.Balance.AppCode,
AssetType: receipt.Balance.AssetType,
AvailableAmount: receipt.Balance.AvailableAmount,
FrozenAmount: receipt.Balance.FrozenAmount,
Version: receipt.Balance.Version,
},
}, nil
}
// ApplyGameCoinChange 处理 game-service 发起的游戏专用金币改账。
func (s *Server) ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())