Compare commits
6 Commits
a0ee5962df
...
636a0085d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
636a0085d0 | ||
|
|
e7b6ed9fb7 | ||
|
|
113c5096e0 | ||
|
|
db036098bc | ||
|
|
463c208640 | ||
|
|
a68d465272 |
@ -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" +
|
||||
|
||||
@ -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
@ -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 我的页和入口显隐使用的轻量角色摘要。
|
||||
// 它把 host、Agency、manager、BD 和币商身份收敛到一次 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);
|
||||
|
||||
@ -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
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
149
docs/flutter对接/币商列表Flutter对接.md
Normal file
149
docs/flutter对接/币商列表Flutter对接.md
Normal 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` | 币商用户长 ID,JSON 中按字符串返回,避免 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`,按登录失效、参数错误、服务错误分别处理。
|
||||
@ -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 丢失、页面重进或余额不一致时,重新拉取礼物面板和钱包余额纠正状态。
|
||||
|
||||
|
||||
171
docs/flutter对接/幸运礼物接口闭环Flutter对接.md
Normal file
171
docs/flutter对接/幸运礼物接口闭环Flutter对接.md
Normal 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`,更新金币余额。
|
||||
@ -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 写 WalletBalanceChanged,notice-service 投递私信余额通知
|
||||
7. lucky gift worker 投递房间群 IM lucky_gift_drawn
|
||||
8. activity-service 标记 reward_status=granted、outbox=delivered
|
||||
```
|
||||
|
||||
`command_id` 是抽奖幂等键。重复 `ExecuteLuckyGiftDraw` 会返回同一条 `lucky_draw_records`,不会重复消耗 RTP、奖池或预算。
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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` | 编辑资源组、替换组成员、启用、禁用 |
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 '显示范围,多选逗号分隔:首页、房间内或充值页';
|
||||
69
server/admin/migrations/026_resource_shop_navigation.sql
Normal file
69
server/admin/migrations/026_resource_shop_navigation.sql
Normal 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';
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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: ""
|
||||
|
||||
|
||||
@ -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,必须和控制台配置保持一致。
|
||||
|
||||
@ -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。启用回调鉴权后必须填写。
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
26
services/user-service/internal/service/auth/im_account.go
Normal file
26
services/user-service/internal/service/auth/im_account.go
Normal 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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 ms,0 表示立即生效',
|
||||
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,UTC epoch ms,0 表示长期有效',
|
||||
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 引用',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
// 新建/编辑入口如果没显式携带开关,仍按产品规则默认允许经理中心赠送。
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user