修改幸运礼物
This commit is contained in:
parent
db036098bc
commit
113c5096e0
File diff suppressed because it is too large
Load Diff
@ -222,6 +222,24 @@ message GiftTypeConfig {
|
|||||||
int64 updated_at_ms = 10;
|
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 {
|
message UserResourceEntitlement {
|
||||||
string app_code = 1;
|
string app_code = 1;
|
||||||
string entitlement_id = 2;
|
string entitlement_id = 2;
|
||||||
@ -592,6 +610,55 @@ message ListResourceGrantsResponse {
|
|||||||
int64 total = 2;
|
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 {
|
message RechargeBill {
|
||||||
string app_code = 1;
|
string app_code = 1;
|
||||||
string transaction_id = 2;
|
string transaction_id = 2;
|
||||||
@ -1275,6 +1342,9 @@ service WalletService {
|
|||||||
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
|
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
|
||||||
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
|
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
|
||||||
rpc ListResourceGrants(ListResourceGrantsRequest) returns (ListResourceGrantsResponse);
|
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 ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
|
||||||
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
|
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
|
||||||
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
|
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
|
||||||
|
|||||||
@ -45,6 +45,9 @@ const (
|
|||||||
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
||||||
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
||||||
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
|
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_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
|
||||||
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
|
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
|
||||||
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
|
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
|
||||||
@ -108,6 +111,9 @@ type WalletServiceClient interface {
|
|||||||
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
|
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
|
||||||
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
|
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
|
||||||
ListResourceGrants(ctx context.Context, in *ListResourceGrantsRequest, opts ...grpc.CallOption) (*ListResourceGrantsResponse, 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)
|
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
|
||||||
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
|
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
|
||||||
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
|
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
|
||||||
@ -407,6 +413,36 @@ func (c *walletServiceClient) ListResourceGrants(ctx context.Context, in *ListRe
|
|||||||
return out, nil
|
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) {
|
func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ListRechargeBillsResponse)
|
out := new(ListRechargeBillsResponse)
|
||||||
@ -729,6 +765,9 @@ type WalletServiceServer interface {
|
|||||||
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
|
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
|
||||||
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
|
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
|
||||||
ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, 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)
|
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
|
||||||
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
|
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
|
||||||
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
|
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
|
||||||
@ -846,6 +885,15 @@ func (UnimplementedWalletServiceServer) EquipUserResource(context.Context, *Equi
|
|||||||
func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error) {
|
func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method ListResourceGrants not implemented")
|
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) {
|
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method ListRechargeBills not implemented")
|
return nil, status.Error(codes.Unimplemented, "method ListRechargeBills not implemented")
|
||||||
}
|
}
|
||||||
@ -1422,6 +1470,60 @@ func _WalletService_ListResourceGrants_Handler(srv interface{}, ctx context.Cont
|
|||||||
return interceptor(ctx, in, info, handler)
|
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) {
|
func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ListRechargeBillsRequest)
|
in := new(ListRechargeBillsRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -2055,6 +2157,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ListResourceGrants",
|
MethodName: "ListResourceGrants",
|
||||||
Handler: _WalletService_ListResourceGrants_Handler,
|
Handler: _WalletService_ListResourceGrants_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListResourceShopItems",
|
||||||
|
Handler: _WalletService_ListResourceShopItems_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "UpsertResourceShopItems",
|
||||||
|
Handler: _WalletService_UpsertResourceShopItems_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SetResourceShopItemStatus",
|
||||||
|
Handler: _WalletService_SetResourceShopItemStatus_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ListRechargeBills",
|
MethodName: "ListRechargeBills",
|
||||||
Handler: _WalletService_ListRechargeBills_Handler,
|
Handler: _WalletService_ListRechargeBills_Handler,
|
||||||
|
|||||||
@ -26,6 +26,8 @@ const (
|
|||||||
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
|
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
|
||||||
// sendC2CMsgCommand 是服务端给单个用户发自定义消息的 REST 命令;钱包余额变更这类私有事件只能走单聊面。
|
// sendC2CMsgCommand 是服务端给单个用户发自定义消息的 REST 命令;钱包余额变更这类私有事件只能走单聊面。
|
||||||
sendC2CMsgCommand = "v4/openim/sendmsg"
|
sendC2CMsgCommand = "v4/openim/sendmsg"
|
||||||
|
// accountImportCommand 把内部 user_id 导入腾讯 IM 账号体系;C2C 发送和 SDK 登录依赖该账号存在。
|
||||||
|
accountImportCommand = "v4/im_open_login_svc/account_import"
|
||||||
// destroyGroupCommand 用于真实 IM smoke test 或明确运维动作的临时群清理,业务代码不应自动解散播报群。
|
// destroyGroupCommand 用于真实 IM smoke test 或明确运维动作的临时群清理,业务代码不应自动解散播报群。
|
||||||
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
|
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
|
||||||
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
|
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
|
||||||
@ -83,6 +85,13 @@ type CustomUserMessage struct {
|
|||||||
PayloadJSON json.RawMessage
|
PayloadJSON json.RawMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccountProfile 表达腾讯 IM 账号导入所需的最小用户资料。
|
||||||
|
type AccountProfile struct {
|
||||||
|
UserID int64
|
||||||
|
Nick string
|
||||||
|
FaceURL string
|
||||||
|
}
|
||||||
|
|
||||||
// RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。
|
// RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。
|
||||||
type RoomEvent struct {
|
type RoomEvent struct {
|
||||||
EventID string `json:"event_id"`
|
EventID string `json:"event_id"`
|
||||||
@ -293,6 +302,29 @@ func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message Custo
|
|||||||
return response.err()
|
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 群。
|
// DestroyGroup 解散一个腾讯云 IM 群。
|
||||||
// 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。
|
// 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。
|
||||||
func (c *RESTClient) DestroyGroup(ctx context.Context, groupID string) error {
|
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)
|
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 {
|
type createGroupRequest struct {
|
||||||
OwnerAccount string `json:"Owner_Account,omitempty"`
|
OwnerAccount string `json:"Owner_Account,omitempty"`
|
||||||
Type string `json:"Type"`
|
Type string `json:"Type"`
|
||||||
@ -463,6 +504,12 @@ type sendC2CMsgRequest struct {
|
|||||||
MsgBody []messageElement `json:"MsgBody"`
|
MsgBody []messageElement `json:"MsgBody"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type accountImportRequest struct {
|
||||||
|
UserID string `json:"UserID"`
|
||||||
|
Nick string `json:"Nick,omitempty"`
|
||||||
|
FaceURL string `json:"FaceUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type destroyGroupRequest struct {
|
type destroyGroupRequest struct {
|
||||||
GroupID string `json:"GroupId"`
|
GroupID string `json:"GroupId"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,14 +86,10 @@ func TestRealTencentIMCreateSendAndDestroyGroup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func realImportAccount(ctx context.Context, client *RESTClient, userID int64) error {
|
func realImportAccount(ctx context.Context, client *RESTClient, userID int64) error {
|
||||||
var response restResponse
|
return client.ImportAccount(ctx, AccountProfile{
|
||||||
if err := client.post(ctx, "v4/im_open_login_svc/account_import", map[string]any{
|
UserID: userID,
|
||||||
"UserID": strconv.FormatInt(userID, 10),
|
Nick: "codex-im-smoke",
|
||||||
"Nick": "codex-im-smoke",
|
})
|
||||||
}, &response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return response.err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func realAddGroupMember(ctx context.Context, client *RESTClient, groupID string, userID int64) error {
|
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 请求。
|
// TestRESTClientDestroyGroupBuildsTencentRequest 锁定临时群清理的 REST 请求。
|
||||||
func TestRESTClientDestroyGroupBuildsTencentRequest(t *testing.T) {
|
func TestRESTClientDestroyGroupBuildsTencentRequest(t *testing.T) {
|
||||||
var capturedPath string
|
var capturedPath string
|
||||||
|
|||||||
@ -281,6 +281,8 @@
|
|||||||
| `resource:view` | `menu` | 资源列表、资源详情 |
|
| `resource:view` | `menu` | 资源列表、资源详情 |
|
||||||
| `resource:create` | `button` | 新增资源 |
|
| `resource:create` | `button` | 新增资源 |
|
||||||
| `resource:update` | `button` | 编辑资源、启用、禁用 |
|
| `resource:update` | `button` | 编辑资源、启用、禁用 |
|
||||||
|
| `resource-shop:view` | `menu` | 道具商店售卖列表 |
|
||||||
|
| `resource-shop:update` | `button` | 添加售卖资源、配置售卖天数和启用、禁用 |
|
||||||
| `resource-group:view` | `menu` | 资源组列表、资源组详情 |
|
| `resource-group:view` | `menu` | 资源组列表、资源组详情 |
|
||||||
| `resource-group:create` | `button` | 新增资源组 |
|
| `resource-group:create` | `button` | 新增资源组 |
|
||||||
| `resource-group:update` | `button` | 编辑资源组、替换组成员、启用、禁用 |
|
| `resource-group:update` | `button` | 编辑资源组、替换组成员、启用、禁用 |
|
||||||
|
|||||||
@ -29,6 +29,9 @@ type Client interface {
|
|||||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
||||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||||
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, 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)
|
AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error)
|
||||||
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
|
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
|
||||||
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, 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)
|
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) {
|
func (c *GRPCClient) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
|
||||||
return c.client.AdminCreditAsset(ctx, req)
|
return c.client.AdminCreditAsset(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -160,6 +160,24 @@ type operatorDTO struct {
|
|||||||
Avatar string `json:"avatar,omitempty"`
|
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 {
|
func resourceFromProto(item *walletv1.Resource) resourceDTO {
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return resourceDTO{}
|
return resourceDTO{}
|
||||||
@ -237,6 +255,29 @@ func resourcePointerFromProto(item *walletv1.Resource) *resourceDTO {
|
|||||||
return &data
|
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 {
|
func giftFromProto(gift *walletv1.GiftConfig) giftDTO {
|
||||||
if gift == nil {
|
if gift == nil {
|
||||||
return giftDTO{}
|
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()})
|
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) {
|
func (h *Handler) setResourceStatus(c *gin.Context, status string) {
|
||||||
resourceID, ok := parseID(c, "resource_id")
|
resourceID, ok := parseID(c, "resource_id")
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -574,6 +626,27 @@ func (h *Handler) setGiftStatus(c *gin.Context, status string) {
|
|||||||
response.OK(c, gift)
|
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) {
|
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)
|
shared.OperationLogWithResourceID(c, h.audit, action, resource, resourceID, status, detail)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,6 +150,20 @@ type grantGroupRequest struct {
|
|||||||
Reason string `json:"reason"`
|
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 {
|
func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceRequest {
|
||||||
resourceType := normalizeResourceType(r.ResourceType)
|
resourceType := normalizeResourceType(r.ResourceType)
|
||||||
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
|
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
|
||||||
@ -347,6 +361,15 @@ func (r giftTypeItemRequest) upsertProto(c *gin.Context) *walletv1.UpsertGiftTyp
|
|||||||
}.upsertProto(c, r.TabKey)
|
}.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 {
|
func managerGrantEnabledOrDefault(value *bool) *bool {
|
||||||
enabled := true
|
enabled := true
|
||||||
if value != nil {
|
if value != nil {
|
||||||
@ -380,6 +403,22 @@ func groupItemInputs(items []resourceGroupItemRequest) []*walletv1.ResourceGroup
|
|||||||
return out
|
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 {
|
func normalizeResourceType(value string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(value))
|
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/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource)
|
||||||
protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup)
|
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-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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,8 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "资源查看", Code: "resource:view", Kind: "menu"},
|
{Name: "资源查看", Code: "resource:view", Kind: "menu"},
|
||||||
{Name: "资源创建", Code: "resource:create", Kind: "button"},
|
{Name: "资源创建", Code: "resource:create", Kind: "button"},
|
||||||
{Name: "资源更新", Code: "resource:update", 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:view", Kind: "menu"},
|
||||||
{Name: "资源组创建", Code: "resource-group:create", Kind: "button"},
|
{Name: "资源组创建", Code: "resource-group:create", Kind: "button"},
|
||||||
{Name: "资源组更新", Code: "resource-group:update", 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: "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: &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-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: "resource-shop-list", Path: "/resource-shop", Icon: "storefront", PermissionCode: "resource-shop: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-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group: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: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift: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-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-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: "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},
|
{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-config:view", "app-config:update",
|
||||||
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
|
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
|
||||||
"resource:view", "resource:create", "resource:update",
|
"resource:view", "resource:create", "resource:update",
|
||||||
|
"resource-shop:view", "resource-shop:update",
|
||||||
"resource-group:view", "resource-group:create", "resource-group:update",
|
"resource-group:view", "resource-group:create", "resource-group:update",
|
||||||
"resource-grant:view", "resource-grant:create",
|
"resource-grant:view", "resource-grant:create",
|
||||||
"gift:view", "gift:create", "gift:update", "gift:status",
|
"gift:view", "gift:create", "gift:update", "gift:status",
|
||||||
@ -492,7 +496,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"upload:create",
|
"upload:create",
|
||||||
}
|
}
|
||||||
case "auditor":
|
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":
|
case "readonly":
|
||||||
return []string{
|
return []string{
|
||||||
"overview:view",
|
"overview:view",
|
||||||
@ -506,6 +510,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"app-config:view",
|
"app-config:view",
|
||||||
"app-version:view",
|
"app-version:view",
|
||||||
"resource:view",
|
"resource:view",
|
||||||
|
"resource-shop:view",
|
||||||
"resource-group:view",
|
"resource-group:view",
|
||||||
"resource-grant:view",
|
"resource-grant:view",
|
||||||
"gift:view",
|
"gift:view",
|
||||||
@ -550,6 +555,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"level-config:view", "level-config:update",
|
"level-config:view", "level-config:update",
|
||||||
"region-block:view", "region-block:update",
|
"region-block:view", "region-block:update",
|
||||||
"resource:view", "resource:create", "resource:update",
|
"resource:view", "resource:create", "resource:update",
|
||||||
|
"resource-shop:view", "resource-shop:update",
|
||||||
"resource-group:view", "resource-group:create", "resource-group:update",
|
"resource-group:view", "resource-group:create", "resource-group:update",
|
||||||
"resource-grant:view", "resource-grant:create",
|
"resource-grant:view", "resource-grant:create",
|
||||||
"gift:view", "gift:create", "gift:update", "gift:status",
|
"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",
|
"vip-config:view", "vip-config:update", "vip-config:grant",
|
||||||
}
|
}
|
||||||
case "auditor", "readonly":
|
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:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
||||||
@ -382,6 +382,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
|||||||
|
|
||||||
drawID := idgen.New("lucky_draw")
|
drawID := idgen.New("lucky_draw")
|
||||||
budgetSourcesJSON := luckyBudgetSourcesJSON(candidate)
|
budgetSourcesJSON := luckyBudgetSourcesJSON(candidate)
|
||||||
|
rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback)
|
||||||
if err := r.applyLuckyDraw(ctx, tx, luckyApplyInput{
|
if err := r.applyLuckyDraw(ctx, tx, luckyApplyInput{
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
DrawID: drawID,
|
DrawID: drawID,
|
||||||
@ -399,6 +400,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
|||||||
Atmosphere: atmosphere,
|
Atmosphere: atmosphere,
|
||||||
ActivityDay: activityDay,
|
ActivityDay: activityDay,
|
||||||
BudgetSourcesJSON: budgetSourcesJSON,
|
BudgetSourcesJSON: budgetSourcesJSON,
|
||||||
|
RewardStatus: rewardStatus,
|
||||||
NowMS: nowMS,
|
NowMS: nowMS,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
@ -421,7 +423,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
|||||||
ActivitySubsidyCoins: candidate.ActivityReward,
|
ActivitySubsidyCoins: candidate.ActivityReward,
|
||||||
EffectiveRewardCoins: candidate.effectiveReward(),
|
EffectiveRewardCoins: candidate.effectiveReward(),
|
||||||
BudgetSourcesJSON: budgetSourcesJSON,
|
BudgetSourcesJSON: budgetSourcesJSON,
|
||||||
RewardStatus: domain.StatusPending,
|
RewardStatus: rewardStatus,
|
||||||
RTPWindowIndex: globalWindow.WindowIndex,
|
RTPWindowIndex: globalWindow.WindowIndex,
|
||||||
GiftRTPWindowIndex: giftWindow.WindowIndex,
|
GiftRTPWindowIndex: giftWindow.WindowIndex,
|
||||||
GlobalBaseRTPPPM: luckyRTPPPM(globalWindow.WagerCoins, globalWindow.ActualPayoutCoins+candidate.BaseReward),
|
GlobalBaseRTPPPM: luckyRTPPPM(globalWindow.WagerCoins, globalWindow.ActualPayoutCoins+candidate.BaseReward),
|
||||||
@ -449,6 +451,7 @@ type luckyApplyInput struct {
|
|||||||
Atmosphere luckyPool
|
Atmosphere luckyPool
|
||||||
ActivityDay string
|
ActivityDay string
|
||||||
BudgetSourcesJSON string
|
BudgetSourcesJSON string
|
||||||
|
RewardStatus string
|
||||||
NowMS int64
|
NowMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -564,12 +567,12 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
|
|||||||
input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex,
|
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.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
|
||||||
input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier,
|
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,
|
input.Command.PaidAtMS, input.NowMS, input.NowMS,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if input.Candidate.effectiveReward() > 0 || input.StageFeedback || input.Candidate.Presentation {
|
if luckyDrawNeedsOutbox(input.Candidate, input.StageFeedback) {
|
||||||
payload, _ := json.Marshal(map[string]any{
|
payload, _ := json.Marshal(map[string]any{
|
||||||
"event_type": "lucky_gift_drawn",
|
"event_type": "lucky_gift_drawn",
|
||||||
"event_id": "lucky_gift_drawn:" + input.DrawID,
|
"event_id": "lucky_gift_drawn:" + input.DrawID,
|
||||||
@ -1592,19 +1595,15 @@ func luckyDedupeRewardCandidates(candidates []luckyCandidate) []luckyCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func luckyDesiredBaseReward(globalWindow, giftWindow luckyRTPWindow) int64 {
|
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()
|
remaining := window.remainingPayout()
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
draws := window.remainingDraws()
|
return remaining
|
||||||
if draws <= 0 {
|
|
||||||
return remaining
|
|
||||||
}
|
|
||||||
return remaining / draws
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64) {
|
func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64) {
|
||||||
@ -1706,6 +1705,19 @@ func luckyBudgetSourcesJSON(candidate luckyCandidate) string {
|
|||||||
return string(raw)
|
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 {
|
func luckyCorrectionTier(reward int64) string {
|
||||||
if reward == 0 {
|
if reward == 0 {
|
||||||
return "none"
|
return "none"
|
||||||
|
|||||||
@ -58,13 +58,13 @@ func TestLuckyRTPWindowAddsCurrentSpendToTarget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLuckyAutoWeightedBaseCandidatesFollowsWindowAverage(t *testing.T) {
|
func TestLuckyAutoWeightedBaseCandidatesUsesCurrentWindowDeficit(t *testing.T) {
|
||||||
candidates := []luckyCandidate{
|
candidates := []luckyCandidate{
|
||||||
{TierID: "none", BaseReward: 0},
|
{TierID: "none", BaseReward: 0},
|
||||||
{TierID: "half", BaseReward: 500},
|
{TierID: "half", BaseReward: 500},
|
||||||
{TierID: "one", BaseReward: 1000},
|
{TierID: "one", BaseReward: 1000},
|
||||||
}
|
}
|
||||||
window := luckyRTPWindow{ControlDraws: 100, TargetPayoutCoins: 95_000}
|
window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 950}
|
||||||
|
|
||||||
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
|
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
|
||||||
|
|
||||||
@ -78,3 +78,56 @@ func TestLuckyAutoWeightedBaseCandidatesFollowsWindowAverage(t *testing.T) {
|
|||||||
t.Fatalf("expected 1x to carry 90%% probability, got %#v", weighted[1])
|
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 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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -87,7 +87,10 @@ tencent_im:
|
|||||||
sdk_app_id: 20036101
|
sdk_app_id: 20036101
|
||||||
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
||||||
admin_identifier: "900100100"
|
admin_identifier: "900100100"
|
||||||
|
admin_user_sig_ttl: "24h"
|
||||||
user_sig_ttl: "24h"
|
user_sig_ttl: "24h"
|
||||||
|
endpoint: "adminapisgp.im.qcloud.com"
|
||||||
|
request_timeout: "5s"
|
||||||
callback_url: ""
|
callback_url: ""
|
||||||
callback_auth_token: ""
|
callback_auth_token: ""
|
||||||
|
|
||||||
|
|||||||
@ -87,8 +87,13 @@ tencent_im:
|
|||||||
secret_key: "TENCENT_IM_SECRET_KEY"
|
secret_key: "TENCENT_IM_SECRET_KEY"
|
||||||
# 腾讯云 IM App 管理员账号;必须和 activity/room 服务端 REST 配置一致。
|
# 腾讯云 IM App 管理员账号;必须和 activity/room 服务端 REST 配置一致。
|
||||||
admin_identifier: "TENCENT_IM_ADMIN_IDENTIFIER"
|
admin_identifier: "TENCENT_IM_ADMIN_IDENTIFIER"
|
||||||
|
# gateway 补偿导入 IM 账号时使用的 admin UserSig 有效期。
|
||||||
|
admin_user_sig_ttl: "24h"
|
||||||
# 客户端 UserSig 有效期;过短会增加刷新频率,过长会放大泄露窗口。
|
# 客户端 UserSig 有效期;过短会增加刷新频率,过长会放大泄露窗口。
|
||||||
user_sig_ttl: "24h"
|
user_sig_ttl: "24h"
|
||||||
|
# 腾讯云 IM REST 地域入口;海外应用需要使用对应地域域名。
|
||||||
|
endpoint: "adminapisgp.im.qcloud.com"
|
||||||
|
request_timeout: "5s"
|
||||||
# 腾讯云 IM 控制台配置的回调地址,用于入群前、发言前等业务守卫。
|
# 腾讯云 IM 控制台配置的回调地址,用于入群前、发言前等业务守卫。
|
||||||
callback_url: "https://api.example.com/api/v1/tencent-im/callback"
|
callback_url: "https://api.example.com/api/v1/tencent-im/callback"
|
||||||
# 腾讯云 IM 回调鉴权 Token,必须和控制台配置保持一致。
|
# 腾讯云 IM 回调鉴权 Token,必须和控制台配置保持一致。
|
||||||
|
|||||||
@ -87,8 +87,11 @@ tencent_im:
|
|||||||
sdk_app_id: 20036101
|
sdk_app_id: 20036101
|
||||||
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
||||||
admin_identifier: "900100100"
|
admin_identifier: "900100100"
|
||||||
|
admin_user_sig_ttl: "24h"
|
||||||
# 客户端 UserSig 有效期;线上建议 12h-7d,按登录刷新策略决定。
|
# 客户端 UserSig 有效期;线上建议 12h-7d,按登录刷新策略决定。
|
||||||
user_sig_ttl: "24h"
|
user_sig_ttl: "24h"
|
||||||
|
endpoint: "adminapisgp.im.qcloud.com"
|
||||||
|
request_timeout: "5s"
|
||||||
# 腾讯云 IM 控制台回调 URL。需要公网可访问;本地为空表示暂不启用回调。
|
# 腾讯云 IM 控制台回调 URL。需要公网可访问;本地为空表示暂不启用回调。
|
||||||
callback_url: ""
|
callback_url: ""
|
||||||
# 腾讯云 IM 回调鉴权 Token。启用回调鉴权后必须填写。
|
# 腾讯云 IM 回调鉴权 Token。启用回调鉴权后必须填写。
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"hyapp/pkg/grpcclient"
|
"hyapp/pkg/grpcclient"
|
||||||
"hyapp/pkg/tencentcos"
|
"hyapp/pkg/tencentcos"
|
||||||
|
"hyapp/pkg/tencentim"
|
||||||
"hyapp/services/gateway-service/internal/appconfig"
|
"hyapp/services/gateway-service/internal/appconfig"
|
||||||
"hyapp/services/gateway-service/internal/auth"
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
"hyapp/services/gateway-service/internal/client"
|
"hyapp/services/gateway-service/internal/client"
|
||||||
@ -130,6 +131,24 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
AdminIdentifier: cfg.TencentIM.AdminIdentifier,
|
AdminIdentifier: cfg.TencentIM.AdminIdentifier,
|
||||||
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
|
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
|
||||||
}, userProfileClient)
|
}, 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.SetRoomQueryClient(roomQueryClient)
|
||||||
handler.SetUserDeviceClient(userDeviceClient)
|
handler.SetUserDeviceClient(userDeviceClient)
|
||||||
handler.SetUserCountryQueryClient(userCountryQueryClient)
|
handler.SetUserCountryQueryClient(userCountryQueryClient)
|
||||||
@ -374,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 服务。
|
// Run 启动 HTTP 服务。
|
||||||
func (a *App) Run() error {
|
func (a *App) Run() error {
|
||||||
a.health.MarkHTTPServing()
|
a.health.MarkHTTPServing()
|
||||||
|
|||||||
@ -26,6 +26,7 @@ type WalletClient interface {
|
|||||||
ListResources(ctx context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error)
|
ListResources(ctx context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error)
|
||||||
GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error)
|
GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error)
|
||||||
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, 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)
|
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
|
||||||
ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error)
|
ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error)
|
||||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, 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)
|
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) {
|
func (c *grpcWalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||||
return c.client.ListGiftConfigs(ctx, req)
|
return c.client.ListGiftConfigs(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,14 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/configx"
|
"hyapp/pkg/configx"
|
||||||
"hyapp/pkg/grpcclient"
|
"hyapp/pkg/grpcclient"
|
||||||
"hyapp/pkg/logx"
|
"hyapp/pkg/logx"
|
||||||
|
"hyapp/pkg/tencentim"
|
||||||
"hyapp/pkg/tencentrtc"
|
"hyapp/pkg/tencentrtc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -158,12 +160,39 @@ type TencentIMConfig struct {
|
|||||||
UserSigTTL time.Duration `yaml:"user_sig_ttl"`
|
UserSigTTL time.Duration `yaml:"user_sig_ttl"`
|
||||||
// AdminIdentifier 是服务端 REST 发播报和系统消息的管理员账号,回调用它识别服务端发送者。
|
// AdminIdentifier 是服务端 REST 发播报和系统消息的管理员账号,回调用它识别服务端发送者。
|
||||||
AdminIdentifier string `yaml:"admin_identifier"`
|
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 是在腾讯云 IM 控制台配置的服务端回调地址。
|
||||||
CallbackURL string `yaml:"callback_url"`
|
CallbackURL string `yaml:"callback_url"`
|
||||||
// CallbackAuthToken 是腾讯云 IM 回调鉴权 token,只用于校验回调来源。
|
// CallbackAuthToken 是腾讯云 IM 回调鉴权 token,只用于校验回调来源。
|
||||||
CallbackAuthToken string `yaml:"callback_auth_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 的配置。
|
// TencentRTCConfig 描述 gateway 给客户端签发腾讯 RTC 进房 UserSig 的配置。
|
||||||
type TencentRTCConfig struct {
|
type TencentRTCConfig struct {
|
||||||
// Enabled 控制 RTC token endpoint 是否可以对外签发,配置缺失时必须 fail-closed。
|
// Enabled 控制 RTC token endpoint 是否可以对外签发,配置缺失时必须 fail-closed。
|
||||||
@ -277,7 +306,10 @@ func Default() Config {
|
|||||||
},
|
},
|
||||||
TencentIM: TencentIMConfig{
|
TencentIM: TencentIMConfig{
|
||||||
UserSigTTL: 24 * time.Hour,
|
UserSigTTL: 24 * time.Hour,
|
||||||
|
AdminUserSigTTL: 24 * time.Hour,
|
||||||
AdminIdentifier: "administrator",
|
AdminIdentifier: "administrator",
|
||||||
|
RequestTimeout: 5 * time.Second,
|
||||||
|
Endpoint: tencentim.DefaultEndpoint,
|
||||||
},
|
},
|
||||||
TencentRTC: TencentRTCConfig{
|
TencentRTC: TencentRTCConfig{
|
||||||
UserSigTTL: 2 * time.Hour,
|
UserSigTTL: 2 * time.Hour,
|
||||||
@ -419,6 +451,16 @@ func (cfg *Config) Normalize() error {
|
|||||||
if cfg.TencentIM.AdminIdentifier == "" {
|
if cfg.TencentIM.AdminIdentifier == "" {
|
||||||
cfg.TencentIM.AdminIdentifier = "administrator"
|
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.CallbackURL = strings.TrimSpace(cfg.TencentIM.CallbackURL)
|
||||||
cfg.TencentIM.CallbackAuthToken = strings.TrimSpace(cfg.TencentIM.CallbackAuthToken)
|
cfg.TencentIM.CallbackAuthToken = strings.TrimSpace(cfg.TencentIM.CallbackAuthToken)
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,7 @@ type Handler struct {
|
|||||||
appConfigReader appapi.ConfigReader
|
appConfigReader appapi.ConfigReader
|
||||||
leaderboardWalletDB *sql.DB
|
leaderboardWalletDB *sql.DB
|
||||||
tencentIM TencentIMConfig
|
tencentIM TencentIMConfig
|
||||||
|
tencentIMImporter TencentIMAccountImporter
|
||||||
tencentRTC TencentRTCConfig
|
tencentRTC TencentRTCConfig
|
||||||
objectUploader ObjectUploader
|
objectUploader ObjectUploader
|
||||||
authRateLimitConfig AuthRateLimitConfig
|
authRateLimitConfig AuthRateLimitConfig
|
||||||
@ -64,6 +65,11 @@ type TencentIMConfig struct {
|
|||||||
CallbackAuthToken string
|
CallbackAuthToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TencentIMAccountImporter 是 gateway 在签发 UserSig 前补偿导入账号的最小边界。
|
||||||
|
type TencentIMAccountImporter interface {
|
||||||
|
ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error
|
||||||
|
}
|
||||||
|
|
||||||
// TencentRTCConfig 是 gateway transport 层签发客户端腾讯 RTC 进房票据所需的配置。
|
// TencentRTCConfig 是 gateway transport 层签发客户端腾讯 RTC 进房票据所需的配置。
|
||||||
type TencentRTCConfig struct {
|
type TencentRTCConfig struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
@ -130,6 +136,11 @@ func (h *Handler) SetTencentRTC(config TencentRTCConfig) {
|
|||||||
h.tencentRTC = config
|
h.tencentRTC = config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTencentIMAccountImporter 注入腾讯 IM 账号导入边界;nil 表示仅签发 UserSig,不做 REST 补偿。
|
||||||
|
func (h *Handler) SetTencentIMAccountImporter(importer TencentIMAccountImporter) {
|
||||||
|
h.tencentIMImporter = importer
|
||||||
|
}
|
||||||
|
|
||||||
// SetObjectUploader 注入对象存储上传实现;生产环境使用腾讯云 COS。
|
// SetObjectUploader 注入对象存储上传实现;生产环境使用腾讯云 COS。
|
||||||
func (h *Handler) SetObjectUploader(uploader ObjectUploader) {
|
func (h *Handler) SetObjectUploader(uploader ObjectUploader) {
|
||||||
h.objectUploader = uploader
|
h.objectUploader = uploader
|
||||||
@ -299,11 +310,28 @@ func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *htt
|
|||||||
SecretKey: h.tencentIM.SecretKey,
|
SecretKey: h.tencentIM.SecretKey,
|
||||||
TTL: h.tencentIM.UserSigTTL,
|
TTL: h.tencentIM.UserSigTTL,
|
||||||
}, tencentim.FormatUserID(userID), time.Now().UTC())
|
}, 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 {
|
if err != nil {
|
||||||
// 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。
|
// 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。
|
||||||
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
||||||
return
|
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())
|
joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId())
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@ -46,6 +46,7 @@ type AppHandlers struct {
|
|||||||
ListAppBanners http.HandlerFunc
|
ListAppBanners http.HandlerFunc
|
||||||
GetAppVersion http.HandlerFunc
|
GetAppVersion http.HandlerFunc
|
||||||
GetResourceGroup http.HandlerFunc
|
GetResourceGroup http.HandlerFunc
|
||||||
|
ListResourceShopItems http.HandlerFunc
|
||||||
ListGifts http.HandlerFunc
|
ListGifts http.HandlerFunc
|
||||||
ListGiftTabs http.HandlerFunc
|
ListGiftTabs http.HandlerFunc
|
||||||
ListEmojiPacks http.HandlerFunc
|
ListEmojiPacks http.HandlerFunc
|
||||||
@ -250,6 +251,7 @@ func (r routes) registerAppRoutes() {
|
|||||||
r.public("/app/banners", "", h.ListAppBanners)
|
r.public("/app/banners", "", h.ListAppBanners)
|
||||||
r.public("/app/version", http.MethodGet, h.GetAppVersion)
|
r.public("/app/version", http.MethodGet, h.GetAppVersion)
|
||||||
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)
|
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)
|
||||||
|
r.public("/resource-shop/items", http.MethodGet, h.ListResourceShopItems)
|
||||||
r.public("/gifts", "", h.ListGifts)
|
r.public("/gifts", "", h.ListGifts)
|
||||||
r.public("/gift-tabs", http.MethodGet, h.ListGiftTabs)
|
r.public("/gift-tabs", http.MethodGet, h.ListGiftTabs)
|
||||||
r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks)
|
r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks)
|
||||||
|
|||||||
@ -28,6 +28,10 @@ func (h *Handler) GetResourceGroup(writer http.ResponseWriter, request *http.Req
|
|||||||
h.getResourceGroup(writer, request)
|
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) {
|
func (h *Handler) ListGifts(writer http.ResponseWriter, request *http.Request) {
|
||||||
h.listGifts(writer, request)
|
h.listGifts(writer, request)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,20 @@ type resourceGroupData struct {
|
|||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
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 {
|
type giftConfigData struct {
|
||||||
GiftID string `json:"gift_id"`
|
GiftID string `json:"gift_id"`
|
||||||
ResourceID int64 `json:"resource_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)
|
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) {
|
func (h *Handler) listGifts(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.walletClient == nil {
|
if h.walletClient == nil {
|
||||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
@ -463,6 +507,25 @@ func resourcePointerFromProto(item *walletv1.Resource) *resourceData {
|
|||||||
return &data
|
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 {
|
func giftFromProto(gift *walletv1.GiftConfig) giftConfigData {
|
||||||
if gift == nil {
|
if gift == nil {
|
||||||
return giftConfigData{}
|
return giftConfigData{}
|
||||||
|
|||||||
@ -272,6 +272,20 @@ type fakeUserProfileClient struct {
|
|||||||
regionByUserID map[int64]int64
|
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 {
|
type fakeUserIdentityClient struct {
|
||||||
lastResolve *userv1.ResolveDisplayUserIDRequest
|
lastResolve *userv1.ResolveDisplayUserIDRequest
|
||||||
resolveByDisplay map[string]int64
|
resolveByDisplay map[string]int64
|
||||||
@ -383,6 +397,8 @@ type fakeWalletClient struct {
|
|||||||
resourcesByID map[int64]*walletv1.Resource
|
resourcesByID map[int64]*walletv1.Resource
|
||||||
lastGetResourceGroup *walletv1.GetResourceGroupRequest
|
lastGetResourceGroup *walletv1.GetResourceGroupRequest
|
||||||
resourceGroupsByID map[int64]*walletv1.ResourceGroup
|
resourceGroupsByID map[int64]*walletv1.ResourceGroup
|
||||||
|
lastResourceShop *walletv1.ListResourceShopItemsRequest
|
||||||
|
resourceShopResp *walletv1.ListResourceShopItemsResponse
|
||||||
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
|
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
|
||||||
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
|
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
|
||||||
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
|
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
|
||||||
@ -1211,6 +1227,17 @@ func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.Get
|
|||||||
return &walletv1.GetResourceGroupResponse{}, nil
|
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) {
|
func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||||
f.lastListGiftConfigs = req
|
f.lastListGiftConfigs = req
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
@ -3474,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) {
|
func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) {
|
||||||
walletClient := &fakeWalletClient{
|
walletClient := &fakeWalletClient{
|
||||||
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{
|
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{
|
||||||
@ -4545,6 +4646,40 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
|
|||||||
assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-missing")
|
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) {
|
func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) {
|
||||||
previousNow := roomapi.TimeNow
|
previousNow := roomapi.TimeNow
|
||||||
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
|
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
|
||||||
|
|||||||
@ -134,6 +134,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
|||||||
ListAppBanners: appAPI.ListAppBanners,
|
ListAppBanners: appAPI.ListAppBanners,
|
||||||
GetAppVersion: appAPI.GetAppVersion,
|
GetAppVersion: appAPI.GetAppVersion,
|
||||||
GetResourceGroup: resourceAPI.GetResourceGroup,
|
GetResourceGroup: resourceAPI.GetResourceGroup,
|
||||||
|
ListResourceShopItems: resourceAPI.ListResourceShopItems,
|
||||||
ListGifts: resourceAPI.ListGifts,
|
ListGifts: resourceAPI.ListGifts,
|
||||||
ListGiftTabs: resourceAPI.ListGiftTabs,
|
ListGiftTabs: resourceAPI.ListGiftTabs,
|
||||||
ListEmojiPacks: resourceAPI.ListEmojiPacks,
|
ListEmojiPacks: resourceAPI.ListEmojiPacks,
|
||||||
|
|||||||
@ -76,13 +76,13 @@ activity_service:
|
|||||||
addr: "activity-service:13006"
|
addr: "activity-service:13006"
|
||||||
request_timeout_ms: 3000
|
request_timeout_ms: 3000
|
||||||
tencent_im:
|
tencent_im:
|
||||||
# Docker 本地默认关闭真实腾讯 IM 账号踢下线;开启后必须和 gateway/room 使用同一个 IM 应用。
|
# Docker 本地联调腾讯 IM 账号导入和封禁踢下线;必须和 gateway/room 使用同一个 IM 应用。
|
||||||
enabled: false
|
enabled: true
|
||||||
sdk_app_id: 0
|
sdk_app_id: 20036101
|
||||||
secret_key: ""
|
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
||||||
admin_identifier: "administrator"
|
admin_identifier: "900100100"
|
||||||
admin_user_sig_ttl_sec: 86400
|
admin_user_sig_ttl_sec: 86400
|
||||||
endpoint: "console.tim.qq.com"
|
endpoint: "adminapisgp.im.qcloud.com"
|
||||||
request_timeout_ms: 5000
|
request_timeout_ms: 5000
|
||||||
jwt:
|
jwt:
|
||||||
issuer: "hyapp"
|
issuer: "hyapp"
|
||||||
|
|||||||
@ -79,7 +79,7 @@ activity_service:
|
|||||||
addr: "activity-service.internal:13006"
|
addr: "activity-service.internal:13006"
|
||||||
request_timeout_ms: 3000
|
request_timeout_ms: 3000
|
||||||
tencent_im:
|
tencent_im:
|
||||||
# 线上开启后,用户封禁会同步调用腾讯云 IM 账号踢下线。
|
# 线上开启后,新用户会导入腾讯云 IM,用户封禁会同步调用腾讯云 IM 账号踢下线。
|
||||||
enabled: true
|
enabled: true
|
||||||
# 必须和 gateway-service/room-service 使用同一个 IM 应用。
|
# 必须和 gateway-service/room-service 使用同一个 IM 应用。
|
||||||
sdk_app_id: 1400000000
|
sdk_app_id: 1400000000
|
||||||
|
|||||||
@ -76,13 +76,13 @@ activity_service:
|
|||||||
addr: "127.0.0.1:13006"
|
addr: "127.0.0.1:13006"
|
||||||
request_timeout_ms: 3000
|
request_timeout_ms: 3000
|
||||||
tencent_im:
|
tencent_im:
|
||||||
# 本地默认关闭真实腾讯 IM 账号踢下线;开启后必须和 gateway/room 使用同一个 IM 应用。
|
# 本地联调腾讯 IM 账号导入和封禁踢下线;必须和 gateway/room 使用同一个 IM 应用。
|
||||||
enabled: false
|
enabled: true
|
||||||
sdk_app_id: 0
|
sdk_app_id: 20036101
|
||||||
secret_key: ""
|
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
||||||
admin_identifier: "administrator"
|
admin_identifier: "900100100"
|
||||||
admin_user_sig_ttl_sec: 86400
|
admin_user_sig_ttl_sec: 86400
|
||||||
endpoint: "console.tim.qq.com"
|
endpoint: "adminapisgp.im.qcloud.com"
|
||||||
request_timeout_ms: 5000
|
request_timeout_ms: 5000
|
||||||
jwt:
|
jwt:
|
||||||
issuer: "hyapp"
|
issuer: "hyapp"
|
||||||
|
|||||||
@ -136,6 +136,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var imLoginKicker userservice.IMLoginKicker
|
var imLoginKicker userservice.IMLoginKicker
|
||||||
|
var imAccountImporter authservice.IMAccountImporter
|
||||||
if cfg.TencentIM.Enabled {
|
if cfg.TencentIM.Enabled {
|
||||||
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -147,6 +148,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
imLoginKicker = imClient
|
imLoginKicker = imClient
|
||||||
|
imAccountImporter = tencentIMAccountImporter{client: imClient}
|
||||||
}
|
}
|
||||||
var roomConn *grpc.ClientConn
|
var roomConn *grpc.ClientConn
|
||||||
var roomEvictor userservice.RoomEvictor
|
var roomEvictor userservice.RoomEvictor
|
||||||
@ -212,6 +214,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
authservice.WithIPDecisionCache(ipDecisionCache),
|
authservice.WithIPDecisionCache(ipDecisionCache),
|
||||||
authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders(cfg.LoginRisk.Providers)),
|
authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders(cfg.LoginRisk.Providers)),
|
||||||
authservice.WithRegistrationRewardIssuer(registrationRewardIssuer),
|
authservice.WithRegistrationRewardIssuer(registrationRewardIssuer),
|
||||||
|
authservice.WithIMAccountImporter(imAccountImporter),
|
||||||
)
|
)
|
||||||
// user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。
|
// user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。
|
||||||
userSvc := userservice.New(userRepo,
|
userSvc := userservice.New(userRepo,
|
||||||
@ -503,6 +506,18 @@ func (a *App) closeHealthHTTP() {
|
|||||||
_ = a.healthHTTP.Close(ctx)
|
_ = 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) {
|
func newMQConsumers(cfg config.Config, inviteSvc *inviteservice.Service, micTimeSvc *mictimeservice.Service) ([]*rocketmqx.Consumer, error) {
|
||||||
consumers := make([]*rocketmqx.Consumer, 0, 2)
|
consumers := make([]*rocketmqx.Consumer, 0, 2)
|
||||||
if cfg.InviteRechargeWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
if cfg.InviteRechargeWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
|
|||||||
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 {
|
if tokenErr != nil {
|
||||||
return authdomain.Token{}, tokenErr
|
return authdomain.Token{}, tokenErr
|
||||||
}
|
}
|
||||||
|
s.importIMAccountAfterCommit(ctx, user, meta)
|
||||||
s.enqueueLoginIPRiskJob(ctx, meta, token, "account", registration.Platform, registration.Language, registration.Timezone)
|
s.enqueueLoginIPRiskJob(ctx, meta, token, "account", registration.Platform, registration.Language, registration.Timezone)
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -110,6 +110,11 @@ type RegistrationRewardIssuer interface {
|
|||||||
IssueRegistrationReward(ctx context.Context, command RegistrationRewardCommand) error
|
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 保存认证底座需要的稳定参数。
|
// Config 保存认证底座需要的稳定参数。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Issuer 写入 access token 的 iss claim。
|
// Issuer 写入 access token 的 iss claim。
|
||||||
@ -194,6 +199,8 @@ type Service struct {
|
|||||||
ipGeoProviders []IPGeoProvider
|
ipGeoProviders []IPGeoProvider
|
||||||
// registrationRewardIssuer 在注册事务提交后异步式触发奖励;失败只记录日志,不回滚用户注册。
|
// registrationRewardIssuer 在注册事务提交后异步式触发奖励;失败只记录日志,不回滚用户注册。
|
||||||
registrationRewardIssuer RegistrationRewardIssuer
|
registrationRewardIssuer RegistrationRewardIssuer
|
||||||
|
// imAccountImporter 在用户创建成功后补齐腾讯 IM 账号;UserSig 入口仍会兜底补偿历史账号。
|
||||||
|
imAccountImporter IMAccountImporter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option 调整 auth service 的依赖和策略。
|
// 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 注入纯认证持久化实现。
|
// WithAuthRepository 注入纯认证持久化实现。
|
||||||
func WithAuthRepository(repository AuthRepository) Option {
|
func WithAuthRepository(repository AuthRepository) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
|
|||||||
@ -78,6 +78,19 @@ func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int)
|
|||||||
return a.values[attempt]
|
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 {
|
func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
|
||||||
base := []userservice.Option{
|
base := []userservice.Option{
|
||||||
userservice.WithIdentityRepository(repository),
|
userservice.WithIdentityRepository(repository),
|
||||||
@ -230,7 +243,8 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
|
|||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
seedCountry(t, repository, "SG")
|
seedCountry(t, repository, "SG")
|
||||||
now := time.UnixMilli(2000)
|
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{
|
token, err := svc.QuickCreateAccount(ctx, "secret-pass", authdomain.ThirdPartyRegistration{
|
||||||
Username: "Lingxian Test",
|
Username: "Lingxian Test",
|
||||||
@ -259,6 +273,9 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
|
|||||||
if user.Username != "Lingxian Test" || user.Country != "SG" || !user.ProfileCompleted {
|
if user.Username != "Lingxian Test" || user.Country != "SG" || !user.ProfileCompleted {
|
||||||
t.Fatalf("quick user profile mismatch: %+v", user)
|
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"})
|
loginToken, err := svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "quick-dev", authservice.Meta{RequestID: "req-quick-login", AppCode: "lalu"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -521,7 +538,8 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
|
|||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
seedCountry(t, repository, "CN")
|
seedCountry(t, repository, "CN")
|
||||||
now := time.UnixMilli(1000)
|
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{
|
if err := repository.CreateUserWithIdentity(ctx, userdomain.User{
|
||||||
AppCode: "lalu",
|
AppCode: "lalu",
|
||||||
UserID: 800001,
|
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" {
|
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)
|
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) {
|
func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *testing.T) {
|
||||||
|
|||||||
@ -174,6 +174,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
|
|||||||
if tokenErr != nil {
|
if tokenErr != nil {
|
||||||
return authdomain.Token{}, false, tokenErr
|
return authdomain.Token{}, false, tokenErr
|
||||||
}
|
}
|
||||||
|
s.importIMAccountAfterCommit(ctx, user, meta)
|
||||||
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(provider), registration.Platform, registration.Language, registration.Timezone)
|
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(provider), registration.Platform, registration.Language, registration.Timezone)
|
||||||
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta)
|
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta)
|
||||||
return token, true, nil
|
return token, true, 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)
|
KEY idx_resource_group_items_group (app_code, group_id, sort_order)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资源分组明细表';
|
) 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 (
|
CREATE TABLE IF NOT EXISTS gift_type_configs (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码,供 gift_configs 引用',
|
type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码,供 gift_configs 引用',
|
||||||
|
|||||||
@ -71,6 +71,10 @@ const (
|
|||||||
|
|
||||||
PriceTypeCoin = "coin"
|
PriceTypeCoin = "coin"
|
||||||
PriceTypeFree = "free"
|
PriceTypeFree = "free"
|
||||||
|
|
||||||
|
ShopDurationOneDay int32 = 1
|
||||||
|
ShopDurationThreeDays int32 = 3
|
||||||
|
ShopDurationSevenDays int32 = 7
|
||||||
)
|
)
|
||||||
|
|
||||||
type Resource struct {
|
type Resource struct {
|
||||||
@ -168,6 +172,24 @@ type GiftTypeConfig struct {
|
|||||||
UpdatedAtMS int64
|
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 {
|
type UserResourceEntitlement struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
EntitlementID string
|
EntitlementID string
|
||||||
@ -380,6 +402,39 @@ type ListResourceGrantsQuery struct {
|
|||||||
PageSize int32
|
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.
|
// BadgeGrantOutbox is a wallet resource grant event relayed to activity-service badge display projection.
|
||||||
type BadgeGrantOutbox struct {
|
type BadgeGrantOutbox struct {
|
||||||
AppCode string
|
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 {
|
func ValidStatus(value string) bool {
|
||||||
switch NormalizeStatus(value) {
|
switch NormalizeStatus(value) {
|
||||||
case StatusActive, StatusDisabled:
|
case StatusActive, StatusDisabled:
|
||||||
|
|||||||
@ -207,3 +207,37 @@ func (s *Service) ListResourceGrants(ctx context.Context, query resourcedomain.L
|
|||||||
ctx = appcode.WithContext(ctx, query.AppCode)
|
ctx = appcode.WithContext(ctx, query.AppCode)
|
||||||
return s.repository.ListResourceGrants(ctx, query)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -74,6 +74,9 @@ type Repository interface {
|
|||||||
ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error)
|
ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error)
|
||||||
EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error)
|
EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error)
|
||||||
ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, 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)
|
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)
|
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
|
MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error
|
||||||
|
|||||||
@ -943,6 +943,151 @@ func (r *Repository) ListResourceGrants(ctx context.Context, query resourcedomai
|
|||||||
return items, total, rows.Err()
|
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) {
|
func (r *Repository) GetResourceGrant(ctx context.Context, grantID string) (resourcedomain.ResourceGrant, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
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
|
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) {
|
func (r *Repository) getResourceGroup(ctx context.Context, querier sqlRowQuerier, groupID int64, lock bool) (resourcedomain.ResourceGroup, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT app_code, group_id, group_code, name, status, description, sort_order,
|
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
|
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) {
|
func scanResourceGroup(scanner scanTarget) (resourcedomain.ResourceGroup, error) {
|
||||||
var group resourcedomain.ResourceGroup
|
var group resourcedomain.ResourceGroup
|
||||||
err := scanner.Scan(
|
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 {
|
func userResourceSelectSQL() string {
|
||||||
return `
|
return `
|
||||||
SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
|
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
|
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 {
|
func normalizeResourceListQuery(query resourcedomain.ListResourcesQuery) resourcedomain.ListResourcesQuery {
|
||||||
query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
|
query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
|
||||||
if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) {
|
if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) {
|
||||||
@ -2198,6 +2479,17 @@ func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery)
|
|||||||
return query
|
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) {
|
func normalizePage(page int32, pageSize int32) (int32, int32) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
@ -2215,6 +2507,41 @@ func resourceOffset(page int32, pageSize int32) int32 {
|
|||||||
return (page - 1) * pageSize
|
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 {
|
func mapResourceWriteError(err error) error {
|
||||||
if isMySQLDuplicateError(err) {
|
if isMySQLDuplicateError(err) {
|
||||||
return xerr.New(xerr.Conflict, "resource_code already exists")
|
return xerr.New(xerr.Conflict, "resource_code already exists")
|
||||||
@ -2282,6 +2609,47 @@ func validateResourceCommand(command resourcedomain.ResourceCommand) error {
|
|||||||
return nil
|
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 {
|
func normalizeResourceGroupCommand(command resourcedomain.ResourceGroupCommand) resourcedomain.ResourceGroupCommand {
|
||||||
command.GroupCode = strings.TrimSpace(command.GroupCode)
|
command.GroupCode = strings.TrimSpace(command.GroupCode)
|
||||||
command.Name = strings.TrimSpace(command.Name)
|
command.Name = strings.TrimSpace(command.Name)
|
||||||
|
|||||||
@ -287,6 +287,55 @@ func (s *Server) ListResourceGrants(ctx context.Context, req *walletv1.ListResou
|
|||||||
return resp, nil
|
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 {
|
func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedomain.ResourceCommand {
|
||||||
return resourcedomain.ResourceCommand{
|
return resourcedomain.ResourceCommand{
|
||||||
AppCode: req.GetAppCode(),
|
AppCode: req.GetAppCode(),
|
||||||
@ -433,6 +482,25 @@ func groupItemInputs(items []*walletv1.ResourceGroupItemInput) []resourcedomain.
|
|||||||
return out
|
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 {
|
func resourceToProto(resource resourcedomain.Resource) *walletv1.Resource {
|
||||||
return &walletv1.Resource{
|
return &walletv1.Resource{
|
||||||
AppCode: appcode.Normalize(resource.AppCode),
|
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 {
|
func managerGrantEnabledOrDefault(value *bool) bool {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
// 新建/编辑入口如果没显式携带开关,仍按产品规则默认允许经理中心赠送。
|
// 新建/编辑入口如果没显式携带开关,仍按产品规则默认允许经理中心赠送。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user