修改幸运礼物
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;
|
||||
}
|
||||
|
||||
message ResourceShopItem {
|
||||
string app_code = 1;
|
||||
int64 shop_item_id = 2;
|
||||
int64 resource_id = 3;
|
||||
Resource resource = 4;
|
||||
string status = 5;
|
||||
int32 duration_days = 6;
|
||||
string price_type = 7;
|
||||
int64 coin_price = 8;
|
||||
int64 effective_from_ms = 9;
|
||||
int64 effective_to_ms = 10;
|
||||
int32 sort_order = 11;
|
||||
int64 created_by_user_id = 12;
|
||||
int64 updated_by_user_id = 13;
|
||||
int64 created_at_ms = 14;
|
||||
int64 updated_at_ms = 15;
|
||||
}
|
||||
|
||||
message UserResourceEntitlement {
|
||||
string app_code = 1;
|
||||
string entitlement_id = 2;
|
||||
@ -592,6 +610,55 @@ message ListResourceGrantsResponse {
|
||||
int64 total = 2;
|
||||
}
|
||||
|
||||
message ResourceShopItemInput {
|
||||
int64 shop_item_id = 1;
|
||||
int64 resource_id = 2;
|
||||
string status = 3;
|
||||
int32 duration_days = 4;
|
||||
int64 effective_from_ms = 5;
|
||||
int64 effective_to_ms = 6;
|
||||
int32 sort_order = 7;
|
||||
}
|
||||
|
||||
message ListResourceShopItemsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
string resource_type = 3;
|
||||
string status = 4;
|
||||
string keyword = 5;
|
||||
int32 page = 6;
|
||||
int32 page_size = 7;
|
||||
bool active_only = 8;
|
||||
}
|
||||
|
||||
message ListResourceShopItemsResponse {
|
||||
repeated ResourceShopItem items = 1;
|
||||
int64 total = 2;
|
||||
}
|
||||
|
||||
message UpsertResourceShopItemsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
repeated ResourceShopItemInput items = 3;
|
||||
int64 operator_user_id = 4;
|
||||
}
|
||||
|
||||
message UpsertResourceShopItemsResponse {
|
||||
repeated ResourceShopItem items = 1;
|
||||
}
|
||||
|
||||
message SetResourceShopItemStatusRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 shop_item_id = 3;
|
||||
string status = 4;
|
||||
int64 operator_user_id = 5;
|
||||
}
|
||||
|
||||
message ResourceShopItemResponse {
|
||||
ResourceShopItem item = 1;
|
||||
}
|
||||
|
||||
message RechargeBill {
|
||||
string app_code = 1;
|
||||
string transaction_id = 2;
|
||||
@ -1275,6 +1342,9 @@ service WalletService {
|
||||
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
|
||||
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
|
||||
rpc ListResourceGrants(ListResourceGrantsRequest) returns (ListResourceGrantsResponse);
|
||||
rpc ListResourceShopItems(ListResourceShopItemsRequest) returns (ListResourceShopItemsResponse);
|
||||
rpc UpsertResourceShopItems(UpsertResourceShopItemsRequest) returns (UpsertResourceShopItemsResponse);
|
||||
rpc SetResourceShopItemStatus(SetResourceShopItemStatusRequest) returns (ResourceShopItemResponse);
|
||||
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
|
||||
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
|
||||
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
|
||||
|
||||
@ -45,6 +45,9 @@ const (
|
||||
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
||||
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
||||
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
|
||||
WalletService_ListResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopItems"
|
||||
WalletService_UpsertResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertResourceShopItems"
|
||||
WalletService_SetResourceShopItemStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceShopItemStatus"
|
||||
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
|
||||
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
|
||||
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
|
||||
@ -108,6 +111,9 @@ type WalletServiceClient interface {
|
||||
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
|
||||
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
|
||||
ListResourceGrants(ctx context.Context, in *ListResourceGrantsRequest, opts ...grpc.CallOption) (*ListResourceGrantsResponse, error)
|
||||
ListResourceShopItems(ctx context.Context, in *ListResourceShopItemsRequest, opts ...grpc.CallOption) (*ListResourceShopItemsResponse, error)
|
||||
UpsertResourceShopItems(ctx context.Context, in *UpsertResourceShopItemsRequest, opts ...grpc.CallOption) (*UpsertResourceShopItemsResponse, error)
|
||||
SetResourceShopItemStatus(ctx context.Context, in *SetResourceShopItemStatusRequest, opts ...grpc.CallOption) (*ResourceShopItemResponse, error)
|
||||
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
|
||||
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
|
||||
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
|
||||
@ -407,6 +413,36 @@ func (c *walletServiceClient) ListResourceGrants(ctx context.Context, in *ListRe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListResourceShopItems(ctx context.Context, in *ListResourceShopItemsRequest, opts ...grpc.CallOption) (*ListResourceShopItemsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListResourceShopItemsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ListResourceShopItems_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) UpsertResourceShopItems(ctx context.Context, in *UpsertResourceShopItemsRequest, opts ...grpc.CallOption) (*UpsertResourceShopItemsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpsertResourceShopItemsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_UpsertResourceShopItems_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) SetResourceShopItemStatus(ctx context.Context, in *SetResourceShopItemStatusRequest, opts ...grpc.CallOption) (*ResourceShopItemResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ResourceShopItemResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_SetResourceShopItemStatus_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRechargeBillsResponse)
|
||||
@ -729,6 +765,9 @@ type WalletServiceServer interface {
|
||||
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
|
||||
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
|
||||
ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error)
|
||||
ListResourceShopItems(context.Context, *ListResourceShopItemsRequest) (*ListResourceShopItemsResponse, error)
|
||||
UpsertResourceShopItems(context.Context, *UpsertResourceShopItemsRequest) (*UpsertResourceShopItemsResponse, error)
|
||||
SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error)
|
||||
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
|
||||
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
|
||||
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
|
||||
@ -846,6 +885,15 @@ func (UnimplementedWalletServiceServer) EquipUserResource(context.Context, *Equi
|
||||
func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListResourceGrants not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListResourceShopItems(context.Context, *ListResourceShopItemsRequest) (*ListResourceShopItemsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListResourceShopItems not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) UpsertResourceShopItems(context.Context, *UpsertResourceShopItemsRequest) (*UpsertResourceShopItemsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpsertResourceShopItems not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetResourceShopItemStatus not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListRechargeBills not implemented")
|
||||
}
|
||||
@ -1422,6 +1470,60 @@ func _WalletService_ListResourceGrants_Handler(srv interface{}, ctx context.Cont
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListResourceShopItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListResourceShopItemsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ListResourceShopItems(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ListResourceShopItems_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ListResourceShopItems(ctx, req.(*ListResourceShopItemsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_UpsertResourceShopItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpsertResourceShopItemsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).UpsertResourceShopItems(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_UpsertResourceShopItems_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).UpsertResourceShopItems(ctx, req.(*UpsertResourceShopItemsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_SetResourceShopItemStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetResourceShopItemStatusRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).SetResourceShopItemStatus(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_SetResourceShopItemStatus_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).SetResourceShopItemStatus(ctx, req.(*SetResourceShopItemStatusRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRechargeBillsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -2055,6 +2157,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListResourceGrants",
|
||||
Handler: _WalletService_ListResourceGrants_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListResourceShopItems",
|
||||
Handler: _WalletService_ListResourceShopItems_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpsertResourceShopItems",
|
||||
Handler: _WalletService_UpsertResourceShopItems_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetResourceShopItemStatus",
|
||||
Handler: _WalletService_SetResourceShopItemStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRechargeBills",
|
||||
Handler: _WalletService_ListRechargeBills_Handler,
|
||||
|
||||
@ -26,6 +26,8 @@ const (
|
||||
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
|
||||
// sendC2CMsgCommand 是服务端给单个用户发自定义消息的 REST 命令;钱包余额变更这类私有事件只能走单聊面。
|
||||
sendC2CMsgCommand = "v4/openim/sendmsg"
|
||||
// accountImportCommand 把内部 user_id 导入腾讯 IM 账号体系;C2C 发送和 SDK 登录依赖该账号存在。
|
||||
accountImportCommand = "v4/im_open_login_svc/account_import"
|
||||
// destroyGroupCommand 用于真实 IM smoke test 或明确运维动作的临时群清理,业务代码不应自动解散播报群。
|
||||
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
|
||||
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
|
||||
@ -83,6 +85,13 @@ type CustomUserMessage struct {
|
||||
PayloadJSON json.RawMessage
|
||||
}
|
||||
|
||||
// AccountProfile 表达腾讯 IM 账号导入所需的最小用户资料。
|
||||
type AccountProfile struct {
|
||||
UserID int64
|
||||
Nick string
|
||||
FaceURL string
|
||||
}
|
||||
|
||||
// RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。
|
||||
type RoomEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
@ -293,6 +302,29 @@ func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message Custo
|
||||
return response.err()
|
||||
}
|
||||
|
||||
// ImportAccount 确保内部 user_id 已存在于腾讯 IM 账号体系。
|
||||
// 腾讯接口本身没有本地事务语义,因此调用方应在用户事务提交后调用,并允许后续 UserSig 入口补偿。
|
||||
func (c *RESTClient) ImportAccount(ctx context.Context, profile AccountProfile) error {
|
||||
if profile.UserID <= 0 {
|
||||
return fmt.Errorf("im account import is incomplete")
|
||||
}
|
||||
|
||||
request := accountImportRequest{
|
||||
UserID: FormatUserID(profile.UserID),
|
||||
Nick: strings.TrimSpace(profile.Nick),
|
||||
FaceURL: strings.TrimSpace(profile.FaceURL),
|
||||
}
|
||||
var response restResponse
|
||||
if err := c.post(ctx, accountImportCommand, request, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
if response.ErrorCode == 0 || isAccountAlreadyImported(response) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return response.err()
|
||||
}
|
||||
|
||||
// DestroyGroup 解散一个腾讯云 IM 群。
|
||||
// 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。
|
||||
func (c *RESTClient) DestroyGroup(ctx context.Context, groupID string) error {
|
||||
@ -437,6 +469,15 @@ func (r restResponse) err() error {
|
||||
return fmt.Errorf("tencent im rest failed: code=%d info=%s", r.ErrorCode, r.ErrorInfo)
|
||||
}
|
||||
|
||||
func isAccountAlreadyImported(response restResponse) bool {
|
||||
// 腾讯不同版本和地域对重复导入的错误码/文案不完全一致;账号已存在是幂等成功。
|
||||
if response.ErrorCode == 70107 {
|
||||
return true
|
||||
}
|
||||
info := strings.ToLower(strings.TrimSpace(response.ErrorInfo))
|
||||
return strings.Contains(info, "already") || strings.Contains(info, "exist")
|
||||
}
|
||||
|
||||
type createGroupRequest struct {
|
||||
OwnerAccount string `json:"Owner_Account,omitempty"`
|
||||
Type string `json:"Type"`
|
||||
@ -463,6 +504,12 @@ type sendC2CMsgRequest struct {
|
||||
MsgBody []messageElement `json:"MsgBody"`
|
||||
}
|
||||
|
||||
type accountImportRequest struct {
|
||||
UserID string `json:"UserID"`
|
||||
Nick string `json:"Nick,omitempty"`
|
||||
FaceURL string `json:"FaceUrl,omitempty"`
|
||||
}
|
||||
|
||||
type destroyGroupRequest struct {
|
||||
GroupID string `json:"GroupId"`
|
||||
}
|
||||
|
||||
@ -86,14 +86,10 @@ func TestRealTencentIMCreateSendAndDestroyGroup(t *testing.T) {
|
||||
}
|
||||
|
||||
func realImportAccount(ctx context.Context, client *RESTClient, userID int64) error {
|
||||
var response restResponse
|
||||
if err := client.post(ctx, "v4/im_open_login_svc/account_import", map[string]any{
|
||||
"UserID": strconv.FormatInt(userID, 10),
|
||||
"Nick": "codex-im-smoke",
|
||||
}, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
return response.err()
|
||||
return client.ImportAccount(ctx, AccountProfile{
|
||||
UserID: userID,
|
||||
Nick: "codex-im-smoke",
|
||||
})
|
||||
}
|
||||
|
||||
func realAddGroupMember(ctx context.Context, client *RESTClient, groupID string, userID int64) error {
|
||||
|
||||
@ -166,6 +166,48 @@ func TestRESTClientPublishUserCustomMessageBuildsC2CPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClientImportAccountBuildsTencentRequest(t *testing.T) {
|
||||
var capturedPath string
|
||||
var capturedBody map[string]any
|
||||
client := newTestRESTClient(t, func(request *http.Request) (*http.Response, error) {
|
||||
capturedPath = request.URL.Path
|
||||
if err := json.NewDecoder(request.Body).Decode(&capturedBody); err != nil {
|
||||
t.Fatalf("decode request body failed: %v", err)
|
||||
}
|
||||
return okRESTResponse(), nil
|
||||
})
|
||||
|
||||
if err := client.ImportAccount(context.Background(), AccountProfile{
|
||||
UserID: 10002,
|
||||
Nick: "Lingxian",
|
||||
FaceURL: "https://cdn.example/avatar.png",
|
||||
}); err != nil {
|
||||
t.Fatalf("ImportAccount failed: %v", err)
|
||||
}
|
||||
|
||||
if capturedPath != "/"+accountImportCommand {
|
||||
t.Fatalf("path mismatch: got %q", capturedPath)
|
||||
}
|
||||
if capturedBody["UserID"] != "10002" || capturedBody["Nick"] != "Lingxian" || capturedBody["FaceUrl"] != "https://cdn.example/avatar.png" {
|
||||
t.Fatalf("unexpected account import body: %+v", capturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClientImportAccountTreatsExistingAccountAsSuccess(t *testing.T) {
|
||||
client := newTestRESTClient(t, func(_ *http.Request) (*http.Response, error) {
|
||||
payload := []byte(`{"ActionStatus":"FAIL","ErrorCode":70107,"ErrorInfo":"account already exists"}`)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(payload)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err := client.ImportAccount(context.Background(), AccountProfile{UserID: 10002}); err != nil {
|
||||
t.Fatalf("existing account must be idempotent success: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRESTClientDestroyGroupBuildsTencentRequest 锁定临时群清理的 REST 请求。
|
||||
func TestRESTClientDestroyGroupBuildsTencentRequest(t *testing.T) {
|
||||
var capturedPath string
|
||||
|
||||
@ -281,6 +281,8 @@
|
||||
| `resource:view` | `menu` | 资源列表、资源详情 |
|
||||
| `resource:create` | `button` | 新增资源 |
|
||||
| `resource:update` | `button` | 编辑资源、启用、禁用 |
|
||||
| `resource-shop:view` | `menu` | 道具商店售卖列表 |
|
||||
| `resource-shop:update` | `button` | 添加售卖资源、配置售卖天数和启用、禁用 |
|
||||
| `resource-group:view` | `menu` | 资源组列表、资源组详情 |
|
||||
| `resource-group:create` | `button` | 新增资源组 |
|
||||
| `resource-group:update` | `button` | 编辑资源组、替换组成员、启用、禁用 |
|
||||
|
||||
@ -29,6 +29,9 @@ type Client interface {
|
||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
|
||||
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
|
||||
UpsertResourceShopItems(ctx context.Context, req *walletv1.UpsertResourceShopItemsRequest) (*walletv1.UpsertResourceShopItemsResponse, error)
|
||||
SetResourceShopItemStatus(ctx context.Context, req *walletv1.SetResourceShopItemStatusRequest) (*walletv1.ResourceShopItemResponse, error)
|
||||
AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error)
|
||||
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
|
||||
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
|
||||
@ -129,6 +132,18 @@ func (c *GRPCClient) ListResourceGrants(ctx context.Context, req *walletv1.ListR
|
||||
return c.client.ListResourceGrants(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
|
||||
return c.client.ListResourceShopItems(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpsertResourceShopItems(ctx context.Context, req *walletv1.UpsertResourceShopItemsRequest) (*walletv1.UpsertResourceShopItemsResponse, error) {
|
||||
return c.client.UpsertResourceShopItems(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) SetResourceShopItemStatus(ctx context.Context, req *walletv1.SetResourceShopItemStatusRequest) (*walletv1.ResourceShopItemResponse, error) {
|
||||
return c.client.SetResourceShopItemStatus(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
|
||||
return c.client.AdminCreditAsset(ctx, req)
|
||||
}
|
||||
|
||||
@ -160,6 +160,24 @@ type operatorDTO struct {
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
type resourceShopItemDTO struct {
|
||||
AppCode string `json:"appCode"`
|
||||
ShopItemID int64 `json:"shopItemId"`
|
||||
ResourceID int64 `json:"resourceId"`
|
||||
Resource resourceDTO `json:"resource"`
|
||||
Status string `json:"status"`
|
||||
DurationDays int32 `json:"durationDays"`
|
||||
PriceType string `json:"priceType"`
|
||||
CoinPrice int64 `json:"coinPrice"`
|
||||
EffectiveFromMS int64 `json:"effectiveFromMs"`
|
||||
EffectiveToMS int64 `json:"effectiveToMs"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
CreatedByUserID int64 `json:"createdByUserId"`
|
||||
UpdatedByUserID int64 `json:"updatedByUserId"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func resourceFromProto(item *walletv1.Resource) resourceDTO {
|
||||
if item == nil {
|
||||
return resourceDTO{}
|
||||
@ -237,6 +255,29 @@ func resourcePointerFromProto(item *walletv1.Resource) *resourceDTO {
|
||||
return &data
|
||||
}
|
||||
|
||||
func resourceShopItemFromProto(item *walletv1.ResourceShopItem) resourceShopItemDTO {
|
||||
if item == nil {
|
||||
return resourceShopItemDTO{}
|
||||
}
|
||||
return resourceShopItemDTO{
|
||||
AppCode: item.GetAppCode(),
|
||||
ShopItemID: item.GetShopItemId(),
|
||||
ResourceID: item.GetResourceId(),
|
||||
Resource: resourceFromProto(item.GetResource()),
|
||||
Status: item.GetStatus(),
|
||||
DurationDays: item.GetDurationDays(),
|
||||
PriceType: item.GetPriceType(),
|
||||
CoinPrice: item.GetCoinPrice(),
|
||||
EffectiveFromMS: item.GetEffectiveFromMs(),
|
||||
EffectiveToMS: item.GetEffectiveToMs(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
CreatedByUserID: item.GetCreatedByUserId(),
|
||||
UpdatedByUserID: item.GetUpdatedByUserId(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func giftFromProto(gift *walletv1.GiftConfig) giftDTO {
|
||||
if gift == nil {
|
||||
return giftDTO{}
|
||||
|
||||
@ -510,6 +510,58 @@ func (h *Handler) ListResourceGrants(c *gin.Context) {
|
||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||
}
|
||||
|
||||
func (h *Handler) ListResourceShopItems(c *gin.Context) {
|
||||
options := shared.ListOptions(c)
|
||||
resp, err := h.wallet.ListResourceShopItems(c.Request.Context(), &walletv1.ListResourceShopItemsRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
ResourceType: strings.TrimSpace(c.Query("resource_type")),
|
||||
Status: options.Status,
|
||||
Keyword: options.Keyword,
|
||||
Page: int32(options.Page),
|
||||
PageSize: int32(options.PageSize),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取道具商店列表失败")
|
||||
return
|
||||
}
|
||||
items := make([]resourceShopItemDTO, 0, len(resp.GetItems()))
|
||||
for _, item := range resp.GetItems() {
|
||||
items = append(items, resourceShopItemFromProto(item))
|
||||
}
|
||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||
}
|
||||
|
||||
func (h *Handler) UpsertResourceShopItems(c *gin.Context) {
|
||||
var req resourceShopItemsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "道具商店参数不正确")
|
||||
return
|
||||
}
|
||||
resp, err := h.wallet.UpsertResourceShopItems(c.Request.Context(), req.upsertProto(c))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
items := make([]resourceShopItemDTO, 0, len(resp.GetItems()))
|
||||
resourceIDs := make([]string, 0, len(resp.GetItems()))
|
||||
for _, item := range resp.GetItems() {
|
||||
dto := resourceShopItemFromProto(item)
|
||||
items = append(items, dto)
|
||||
resourceIDs = append(resourceIDs, fmt.Sprintf("%d", dto.ResourceID))
|
||||
}
|
||||
h.auditLog(c, "upsert-resource-shop-items", "resource_shop_items", "batch", "success", strings.Join(resourceIDs, ","))
|
||||
response.OK(c, items)
|
||||
}
|
||||
|
||||
func (h *Handler) EnableResourceShopItem(c *gin.Context) {
|
||||
h.setResourceShopItemStatus(c, "active")
|
||||
}
|
||||
|
||||
func (h *Handler) DisableResourceShopItem(c *gin.Context) {
|
||||
h.setResourceShopItemStatus(c, "disabled")
|
||||
}
|
||||
|
||||
func (h *Handler) setResourceStatus(c *gin.Context, status string) {
|
||||
resourceID, ok := parseID(c, "resource_id")
|
||||
if !ok {
|
||||
@ -574,6 +626,27 @@ func (h *Handler) setGiftStatus(c *gin.Context, status string) {
|
||||
response.OK(c, gift)
|
||||
}
|
||||
|
||||
func (h *Handler) setResourceShopItemStatus(c *gin.Context, status string) {
|
||||
shopItemID, ok := parseID(c, "shop_item_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp, err := h.wallet.SetResourceShopItemStatus(c.Request.Context(), &walletv1.SetResourceShopItemStatusRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
ShopItemId: shopItemID,
|
||||
Status: status,
|
||||
OperatorUserId: actorID(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
item := resourceShopItemFromProto(resp.GetItem())
|
||||
h.auditLog(c, "set-resource-shop-item-status", "resource_shop_items", fmt.Sprintf("%d", item.ShopItemID), "success", status)
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) auditLog(c *gin.Context, action string, resource string, resourceID string, status string, detail string) {
|
||||
shared.OperationLogWithResourceID(c, h.audit, action, resource, resourceID, status, detail)
|
||||
}
|
||||
|
||||
@ -150,6 +150,20 @@ type grantGroupRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type resourceShopItemsRequest struct {
|
||||
Items []resourceShopItemRequest `json:"items"`
|
||||
}
|
||||
|
||||
type resourceShopItemRequest struct {
|
||||
ShopItemID int64 `json:"shopItemId"`
|
||||
ResourceID int64 `json:"resourceId"`
|
||||
Status string `json:"status"`
|
||||
DurationDays int32 `json:"durationDays"`
|
||||
EffectiveFromMS int64 `json:"effectiveFromMs"`
|
||||
EffectiveToMS int64 `json:"effectiveToMs"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
}
|
||||
|
||||
func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceRequest {
|
||||
resourceType := normalizeResourceType(r.ResourceType)
|
||||
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
|
||||
@ -347,6 +361,15 @@ func (r giftTypeItemRequest) upsertProto(c *gin.Context) *walletv1.UpsertGiftTyp
|
||||
}.upsertProto(c, r.TabKey)
|
||||
}
|
||||
|
||||
func (r resourceShopItemsRequest) upsertProto(c *gin.Context) *walletv1.UpsertResourceShopItemsRequest {
|
||||
return &walletv1.UpsertResourceShopItemsRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
Items: resourceShopItemInputs(r.Items),
|
||||
OperatorUserId: actorID(c),
|
||||
}
|
||||
}
|
||||
|
||||
func managerGrantEnabledOrDefault(value *bool) *bool {
|
||||
enabled := true
|
||||
if value != nil {
|
||||
@ -380,6 +403,22 @@ func groupItemInputs(items []resourceGroupItemRequest) []*walletv1.ResourceGroup
|
||||
return out
|
||||
}
|
||||
|
||||
func resourceShopItemInputs(items []resourceShopItemRequest) []*walletv1.ResourceShopItemInput {
|
||||
out := make([]*walletv1.ResourceShopItemInput, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, &walletv1.ResourceShopItemInput{
|
||||
ShopItemId: item.ShopItemID,
|
||||
ResourceId: item.ResourceID,
|
||||
Status: strings.TrimSpace(item.Status),
|
||||
DurationDays: item.DurationDays,
|
||||
EffectiveFromMs: item.EffectiveFromMS,
|
||||
EffectiveToMs: item.EffectiveToMS,
|
||||
SortOrder: item.SortOrder,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeResourceType(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
@ -42,4 +42,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource)
|
||||
protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup)
|
||||
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)
|
||||
|
||||
protected.GET("/admin/resource-shop/items", middleware.RequirePermission("resource-shop:view"), h.ListResourceShopItems)
|
||||
protected.POST("/admin/resource-shop/items", middleware.RequirePermission("resource-shop:update"), h.UpsertResourceShopItems)
|
||||
protected.POST("/admin/resource-shop/items/:shop_item_id/enable", middleware.RequirePermission("resource-shop:update"), h.EnableResourceShopItem)
|
||||
protected.POST("/admin/resource-shop/items/:shop_item_id/disable", middleware.RequirePermission("resource-shop:update"), h.DisableResourceShopItem)
|
||||
}
|
||||
|
||||
@ -41,6 +41,8 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "资源查看", Code: "resource:view", Kind: "menu"},
|
||||
{Name: "资源创建", Code: "resource:create", Kind: "button"},
|
||||
{Name: "资源更新", Code: "resource:update", Kind: "button"},
|
||||
{Name: "道具商店查看", Code: "resource-shop:view", Kind: "menu"},
|
||||
{Name: "道具商店更新", Code: "resource-shop:update", Kind: "button"},
|
||||
{Name: "资源组查看", Code: "resource-group:view", Kind: "menu"},
|
||||
{Name: "资源组创建", Code: "resource-group:create", Kind: "button"},
|
||||
{Name: "资源组更新", Code: "resource-group:update", Kind: "button"},
|
||||
@ -228,10 +230,11 @@ func (s *Store) seedMenus() error {
|
||||
{ParentID: &appConfigID, Title: "内购配置", Code: "payment-recharge-products", Path: "/app-config/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 69, Visible: true},
|
||||
{ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 70, Visible: true},
|
||||
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
|
||||
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 68, Visible: true},
|
||||
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 69, Visible: true},
|
||||
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 70, Visible: true},
|
||||
{ParentID: &resourceID, Title: "表情包列表", Code: "emoji-pack-list", Path: "/emoji-packs", Icon: "image", PermissionCode: "emoji-pack:view", Sort: 71, Visible: true},
|
||||
{ParentID: &resourceID, Title: "道具商店", Code: "resource-shop-list", Path: "/resource-shop", Icon: "storefront", PermissionCode: "resource-shop:view", Sort: 68, Visible: true},
|
||||
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 69, Visible: true},
|
||||
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 70, Visible: true},
|
||||
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 71, Visible: true},
|
||||
{ParentID: &resourceID, Title: "表情包列表", Code: "emoji-pack-list", Path: "/emoji-packs", Icon: "image", PermissionCode: "emoji-pack:view", Sort: 72, Visible: true},
|
||||
{ParentID: &operationsID, Title: "金币流水", Code: "operation-coin-ledger", Path: "/operations/coin-ledger", Icon: "receipt", PermissionCode: "coin-ledger:view", Sort: 68, Visible: true},
|
||||
{ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 69, Visible: true},
|
||||
{ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 70, Visible: true},
|
||||
@ -468,6 +471,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"app-config:view", "app-config:update",
|
||||
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
|
||||
"resource:view", "resource:create", "resource:update",
|
||||
"resource-shop:view", "resource-shop:update",
|
||||
"resource-group:view", "resource-group:create", "resource-group:update",
|
||||
"resource-grant:view", "resource-grant:create",
|
||||
"gift:view", "gift:create", "gift:update", "gift:status",
|
||||
@ -492,7 +496,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"upload:create",
|
||||
}
|
||||
case "auditor":
|
||||
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"}
|
||||
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"}
|
||||
case "readonly":
|
||||
return []string{
|
||||
"overview:view",
|
||||
@ -506,6 +510,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"app-config:view",
|
||||
"app-version:view",
|
||||
"resource:view",
|
||||
"resource-shop:view",
|
||||
"resource-group:view",
|
||||
"resource-grant:view",
|
||||
"gift:view",
|
||||
@ -550,6 +555,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"level-config:view", "level-config:update",
|
||||
"region-block:view", "region-block:update",
|
||||
"resource:view", "resource:create", "resource:update",
|
||||
"resource-shop:view", "resource-shop:update",
|
||||
"resource-group:view", "resource-group:create", "resource-group:update",
|
||||
"resource-grant:view", "resource-grant:create",
|
||||
"gift:view", "gift:create", "gift:update", "gift:status",
|
||||
@ -569,7 +575,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"vip-config:view", "vip-config:update", "vip-config:grant",
|
||||
}
|
||||
case "auditor", "readonly":
|
||||
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"}
|
||||
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
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")
|
||||
budgetSourcesJSON := luckyBudgetSourcesJSON(candidate)
|
||||
rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback)
|
||||
if err := r.applyLuckyDraw(ctx, tx, luckyApplyInput{
|
||||
AppCode: appCode,
|
||||
DrawID: drawID,
|
||||
@ -399,6 +400,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
||||
Atmosphere: atmosphere,
|
||||
ActivityDay: activityDay,
|
||||
BudgetSourcesJSON: budgetSourcesJSON,
|
||||
RewardStatus: rewardStatus,
|
||||
NowMS: nowMS,
|
||||
}); err != nil {
|
||||
return domain.DrawResult{}, err
|
||||
@ -421,7 +423,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
||||
ActivitySubsidyCoins: candidate.ActivityReward,
|
||||
EffectiveRewardCoins: candidate.effectiveReward(),
|
||||
BudgetSourcesJSON: budgetSourcesJSON,
|
||||
RewardStatus: domain.StatusPending,
|
||||
RewardStatus: rewardStatus,
|
||||
RTPWindowIndex: globalWindow.WindowIndex,
|
||||
GiftRTPWindowIndex: giftWindow.WindowIndex,
|
||||
GlobalBaseRTPPPM: luckyRTPPPM(globalWindow.WagerCoins, globalWindow.ActualPayoutCoins+candidate.BaseReward),
|
||||
@ -449,6 +451,7 @@ type luckyApplyInput struct {
|
||||
Atmosphere luckyPool
|
||||
ActivityDay string
|
||||
BudgetSourcesJSON string
|
||||
RewardStatus string
|
||||
NowMS int64
|
||||
}
|
||||
|
||||
@ -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.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
|
||||
input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier,
|
||||
string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot), domain.StatusPending, "", "",
|
||||
string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot), input.RewardStatus, "", "",
|
||||
input.Command.PaidAtMS, input.NowMS, input.NowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if input.Candidate.effectiveReward() > 0 || input.StageFeedback || input.Candidate.Presentation {
|
||||
if luckyDrawNeedsOutbox(input.Candidate, input.StageFeedback) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event_type": "lucky_gift_drawn",
|
||||
"event_id": "lucky_gift_drawn:" + input.DrawID,
|
||||
@ -1592,19 +1595,15 @@ func luckyDedupeRewardCandidates(candidates []luckyCandidate) []luckyCandidate {
|
||||
}
|
||||
|
||||
func luckyDesiredBaseReward(globalWindow, giftWindow luckyRTPWindow) int64 {
|
||||
return minInt64(luckyWindowAveragePayout(globalWindow), luckyWindowAveragePayout(giftWindow))
|
||||
return minInt64(luckyWindowBaseDeficit(globalWindow), luckyWindowBaseDeficit(giftWindow))
|
||||
}
|
||||
|
||||
func luckyWindowAveragePayout(window luckyRTPWindow) int64 {
|
||||
func luckyWindowBaseDeficit(window luckyRTPWindow) int64 {
|
||||
remaining := window.remainingPayout()
|
||||
if remaining <= 0 {
|
||||
return 0
|
||||
}
|
||||
draws := window.remainingDraws()
|
||||
if draws <= 0 {
|
||||
return remaining
|
||||
}
|
||||
return remaining / draws
|
||||
return remaining
|
||||
}
|
||||
|
||||
func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64) {
|
||||
@ -1706,6 +1705,19 @@ func luckyBudgetSourcesJSON(candidate luckyCandidate) string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func luckyInitialRewardStatus(candidate luckyCandidate, stageFeedback bool) string {
|
||||
// 没有金币返还、也没有房间展示副作用时,抽奖事实已经终态化;不能留给不存在的 outbox 消费。
|
||||
if !luckyDrawNeedsOutbox(candidate, stageFeedback) {
|
||||
return domain.StatusGranted
|
||||
}
|
||||
return domain.StatusPending
|
||||
}
|
||||
|
||||
func luckyDrawNeedsOutbox(candidate luckyCandidate, stageFeedback bool) bool {
|
||||
// outbox 只承载真实异步副作用:钱包入账、房间 IM 展示或阶段反馈展示。
|
||||
return candidate.effectiveReward() > 0 || stageFeedback || candidate.Presentation
|
||||
}
|
||||
|
||||
func luckyCorrectionTier(reward int64) string {
|
||||
if reward == 0 {
|
||||
return "none"
|
||||
|
||||
@ -58,13 +58,13 @@ func TestLuckyRTPWindowAddsCurrentSpendToTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuckyAutoWeightedBaseCandidatesFollowsWindowAverage(t *testing.T) {
|
||||
func TestLuckyAutoWeightedBaseCandidatesUsesCurrentWindowDeficit(t *testing.T) {
|
||||
candidates := []luckyCandidate{
|
||||
{TierID: "none", BaseReward: 0},
|
||||
{TierID: "half", BaseReward: 500},
|
||||
{TierID: "one", BaseReward: 1000},
|
||||
}
|
||||
window := luckyRTPWindow{ControlDraws: 100, TargetPayoutCoins: 95_000}
|
||||
window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 950}
|
||||
|
||||
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
|
||||
|
||||
@ -78,3 +78,56 @@ func TestLuckyAutoWeightedBaseCandidatesFollowsWindowAverage(t *testing.T) {
|
||||
t.Fatalf("expected 1x to carry 90%% probability, got %#v", weighted[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuckyAutoWeightedBaseCandidatesDoesNotDiluteDeficitAcrossLargeWindow(t *testing.T) {
|
||||
candidates := []luckyCandidate{
|
||||
{TierID: "none", BaseReward: 0},
|
||||
{TierID: "rebate_0_5x", BaseReward: 5},
|
||||
{TierID: "rebate_1x", BaseReward: 10},
|
||||
}
|
||||
window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 9}
|
||||
|
||||
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
|
||||
|
||||
if len(weighted) != 2 {
|
||||
t.Fatalf("expected deficit to interpolate between reward tiers, got %#v", weighted)
|
||||
}
|
||||
if weighted[0].TierID != "rebate_0_5x" || weighted[0].Weight != 200_000 {
|
||||
t.Fatalf("expected 0.5x to carry 20%% probability, got %#v", weighted[0])
|
||||
}
|
||||
if weighted[1].TierID != "rebate_1x" || weighted[1].Weight != 800_000 {
|
||||
t.Fatalf("expected 1x to carry 80%% probability, got %#v", weighted[1])
|
||||
}
|
||||
}
|
||||
|
||||
func 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
|
||||
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
||||
admin_identifier: "900100100"
|
||||
admin_user_sig_ttl: "24h"
|
||||
user_sig_ttl: "24h"
|
||||
endpoint: "adminapisgp.im.qcloud.com"
|
||||
request_timeout: "5s"
|
||||
callback_url: ""
|
||||
callback_auth_token: ""
|
||||
|
||||
|
||||
@ -87,8 +87,13 @@ tencent_im:
|
||||
secret_key: "TENCENT_IM_SECRET_KEY"
|
||||
# 腾讯云 IM App 管理员账号;必须和 activity/room 服务端 REST 配置一致。
|
||||
admin_identifier: "TENCENT_IM_ADMIN_IDENTIFIER"
|
||||
# gateway 补偿导入 IM 账号时使用的 admin UserSig 有效期。
|
||||
admin_user_sig_ttl: "24h"
|
||||
# 客户端 UserSig 有效期;过短会增加刷新频率,过长会放大泄露窗口。
|
||||
user_sig_ttl: "24h"
|
||||
# 腾讯云 IM REST 地域入口;海外应用需要使用对应地域域名。
|
||||
endpoint: "adminapisgp.im.qcloud.com"
|
||||
request_timeout: "5s"
|
||||
# 腾讯云 IM 控制台配置的回调地址,用于入群前、发言前等业务守卫。
|
||||
callback_url: "https://api.example.com/api/v1/tencent-im/callback"
|
||||
# 腾讯云 IM 回调鉴权 Token,必须和控制台配置保持一致。
|
||||
|
||||
@ -87,8 +87,11 @@ tencent_im:
|
||||
sdk_app_id: 20036101
|
||||
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
||||
admin_identifier: "900100100"
|
||||
admin_user_sig_ttl: "24h"
|
||||
# 客户端 UserSig 有效期;线上建议 12h-7d,按登录刷新策略决定。
|
||||
user_sig_ttl: "24h"
|
||||
endpoint: "adminapisgp.im.qcloud.com"
|
||||
request_timeout: "5s"
|
||||
# 腾讯云 IM 控制台回调 URL。需要公网可访问;本地为空表示暂不启用回调。
|
||||
callback_url: ""
|
||||
# 腾讯云 IM 回调鉴权 Token。启用回调鉴权后必须填写。
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
"hyapp/pkg/grpcclient"
|
||||
"hyapp/pkg/tencentcos"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/services/gateway-service/internal/appconfig"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/client"
|
||||
@ -130,6 +131,24 @@ func New(cfg config.Config) (*App, error) {
|
||||
AdminIdentifier: cfg.TencentIM.AdminIdentifier,
|
||||
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
|
||||
}, userProfileClient)
|
||||
if cfg.TencentIM.SDKAppID > 0 && strings.TrimSpace(cfg.TencentIM.SecretKey) != "" {
|
||||
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||||
if err != nil {
|
||||
if appConfigReader != nil {
|
||||
_ = appConfigReader.Close()
|
||||
}
|
||||
if leaderboardWalletDB != nil {
|
||||
_ = leaderboardWalletDB.Close()
|
||||
}
|
||||
_ = roomConn.Close()
|
||||
_ = userConn.Close()
|
||||
_ = walletConn.Close()
|
||||
_ = activityConn.Close()
|
||||
_ = gameConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
handler.SetTencentIMAccountImporter(gatewayTencentIMAccountImporter{client: imClient})
|
||||
}
|
||||
handler.SetRoomQueryClient(roomQueryClient)
|
||||
handler.SetUserDeviceClient(userDeviceClient)
|
||||
handler.SetUserCountryQueryClient(userCountryQueryClient)
|
||||
@ -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 服务。
|
||||
func (a *App) Run() error {
|
||||
a.health.MarkHTTPServing()
|
||||
|
||||
@ -26,6 +26,7 @@ type WalletClient interface {
|
||||
ListResources(ctx context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error)
|
||||
GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error)
|
||||
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error)
|
||||
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
|
||||
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
|
||||
ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error)
|
||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
@ -111,6 +112,10 @@ func (c *grpcWalletClient) GetResourceGroup(ctx context.Context, req *walletv1.G
|
||||
return c.client.GetResourceGroup(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
|
||||
return c.client.ListResourceShopItems(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||
return c.client.ListGiftConfigs(ctx, req)
|
||||
}
|
||||
|
||||
@ -2,12 +2,14 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/configx"
|
||||
"hyapp/pkg/grpcclient"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/pkg/tencentrtc"
|
||||
)
|
||||
|
||||
@ -158,12 +160,39 @@ type TencentIMConfig struct {
|
||||
UserSigTTL time.Duration `yaml:"user_sig_ttl"`
|
||||
// AdminIdentifier 是服务端 REST 发播报和系统消息的管理员账号,回调用它识别服务端发送者。
|
||||
AdminIdentifier string `yaml:"admin_identifier"`
|
||||
// AdminUserSigTTL 控制 gateway 补偿导入 IM 账号时的 admin UserSig 有效期。
|
||||
AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"`
|
||||
// Endpoint 是腾讯 IM REST 地域入口,例如 adminapisgp.im.qcloud.com。
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
// RequestTimeout 是 gateway 调腾讯 IM REST 的请求超时。
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
// CallbackURL 是在腾讯云 IM 控制台配置的服务端回调地址。
|
||||
CallbackURL string `yaml:"callback_url"`
|
||||
// CallbackAuthToken 是腾讯云 IM 回调鉴权 token,只用于校验回调来源。
|
||||
CallbackAuthToken string `yaml:"callback_auth_token"`
|
||||
}
|
||||
|
||||
// RESTConfig 转换成腾讯 IM REST client 配置,用于 /im/usersig 入口补偿导入账号。
|
||||
func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig {
|
||||
timeout := c.RequestTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
ttl := c.AdminUserSigTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 24 * time.Hour
|
||||
}
|
||||
return tencentim.RESTConfig{
|
||||
SDKAppID: c.SDKAppID,
|
||||
SecretKey: c.SecretKey,
|
||||
AdminIdentifier: c.AdminIdentifier,
|
||||
AdminUserSigTTL: ttl,
|
||||
Endpoint: c.Endpoint,
|
||||
GroupType: tencentim.DefaultGroupType,
|
||||
HTTPClient: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// TencentRTCConfig 描述 gateway 给客户端签发腾讯 RTC 进房 UserSig 的配置。
|
||||
type TencentRTCConfig struct {
|
||||
// Enabled 控制 RTC token endpoint 是否可以对外签发,配置缺失时必须 fail-closed。
|
||||
@ -277,7 +306,10 @@ func Default() Config {
|
||||
},
|
||||
TencentIM: TencentIMConfig{
|
||||
UserSigTTL: 24 * time.Hour,
|
||||
AdminUserSigTTL: 24 * time.Hour,
|
||||
AdminIdentifier: "administrator",
|
||||
RequestTimeout: 5 * time.Second,
|
||||
Endpoint: tencentim.DefaultEndpoint,
|
||||
},
|
||||
TencentRTC: TencentRTCConfig{
|
||||
UserSigTTL: 2 * time.Hour,
|
||||
@ -419,6 +451,16 @@ func (cfg *Config) Normalize() error {
|
||||
if cfg.TencentIM.AdminIdentifier == "" {
|
||||
cfg.TencentIM.AdminIdentifier = "administrator"
|
||||
}
|
||||
cfg.TencentIM.Endpoint = strings.TrimSpace(cfg.TencentIM.Endpoint)
|
||||
if cfg.TencentIM.Endpoint == "" {
|
||||
cfg.TencentIM.Endpoint = tencentim.DefaultEndpoint
|
||||
}
|
||||
if cfg.TencentIM.AdminUserSigTTL <= 0 {
|
||||
cfg.TencentIM.AdminUserSigTTL = 24 * time.Hour
|
||||
}
|
||||
if cfg.TencentIM.RequestTimeout <= 0 {
|
||||
cfg.TencentIM.RequestTimeout = 5 * time.Second
|
||||
}
|
||||
cfg.TencentIM.CallbackURL = strings.TrimSpace(cfg.TencentIM.CallbackURL)
|
||||
cfg.TencentIM.CallbackAuthToken = strings.TrimSpace(cfg.TencentIM.CallbackAuthToken)
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@ type Handler struct {
|
||||
appConfigReader appapi.ConfigReader
|
||||
leaderboardWalletDB *sql.DB
|
||||
tencentIM TencentIMConfig
|
||||
tencentIMImporter TencentIMAccountImporter
|
||||
tencentRTC TencentRTCConfig
|
||||
objectUploader ObjectUploader
|
||||
authRateLimitConfig AuthRateLimitConfig
|
||||
@ -64,6 +65,11 @@ type TencentIMConfig struct {
|
||||
CallbackAuthToken string
|
||||
}
|
||||
|
||||
// TencentIMAccountImporter 是 gateway 在签发 UserSig 前补偿导入账号的最小边界。
|
||||
type TencentIMAccountImporter interface {
|
||||
ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error
|
||||
}
|
||||
|
||||
// TencentRTCConfig 是 gateway transport 层签发客户端腾讯 RTC 进房票据所需的配置。
|
||||
type TencentRTCConfig struct {
|
||||
Enabled bool
|
||||
@ -130,6 +136,11 @@ func (h *Handler) SetTencentRTC(config TencentRTCConfig) {
|
||||
h.tencentRTC = config
|
||||
}
|
||||
|
||||
// SetTencentIMAccountImporter 注入腾讯 IM 账号导入边界;nil 表示仅签发 UserSig,不做 REST 补偿。
|
||||
func (h *Handler) SetTencentIMAccountImporter(importer TencentIMAccountImporter) {
|
||||
h.tencentIMImporter = importer
|
||||
}
|
||||
|
||||
// SetObjectUploader 注入对象存储上传实现;生产环境使用腾讯云 COS。
|
||||
func (h *Handler) SetObjectUploader(uploader ObjectUploader) {
|
||||
h.objectUploader = uploader
|
||||
@ -299,11 +310,28 @@ func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *htt
|
||||
SecretKey: h.tencentIM.SecretKey,
|
||||
TTL: h.tencentIM.UserSigTTL,
|
||||
}, tencentim.FormatUserID(userID), time.Now().UTC())
|
||||
if err != nil {
|
||||
if h.tencentIMImporter != nil {
|
||||
_ = h.tencentIMImporter.ImportAccount(request.Context(), userID, user.GetUsername(), user.GetAvatar())
|
||||
result, err = tencentim.GenerateUserSig(tencentim.UserSigConfig{
|
||||
SDKAppID: h.tencentIM.SDKAppID,
|
||||
SecretKey: h.tencentIM.SecretKey,
|
||||
TTL: h.tencentIM.UserSigTTL,
|
||||
}, tencentim.FormatUserID(userID), time.Now().UTC())
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。
|
||||
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
if h.tencentIMImporter != nil {
|
||||
if err := h.tencentIMImporter.ImportAccount(request.Context(), userID, user.GetUsername(), user.GetAvatar()); err != nil {
|
||||
// UserSig 只证明签名有效;账号未导入时 SDK C2C 仍会失败,因此这里必须 fail-closed。
|
||||
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId())
|
||||
if !ok {
|
||||
|
||||
@ -46,6 +46,7 @@ type AppHandlers struct {
|
||||
ListAppBanners http.HandlerFunc
|
||||
GetAppVersion http.HandlerFunc
|
||||
GetResourceGroup http.HandlerFunc
|
||||
ListResourceShopItems http.HandlerFunc
|
||||
ListGifts http.HandlerFunc
|
||||
ListGiftTabs http.HandlerFunc
|
||||
ListEmojiPacks http.HandlerFunc
|
||||
@ -250,6 +251,7 @@ func (r routes) registerAppRoutes() {
|
||||
r.public("/app/banners", "", h.ListAppBanners)
|
||||
r.public("/app/version", http.MethodGet, h.GetAppVersion)
|
||||
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)
|
||||
r.public("/resource-shop/items", http.MethodGet, h.ListResourceShopItems)
|
||||
r.public("/gifts", "", h.ListGifts)
|
||||
r.public("/gift-tabs", http.MethodGet, h.ListGiftTabs)
|
||||
r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks)
|
||||
|
||||
@ -28,6 +28,10 @@ func (h *Handler) GetResourceGroup(writer http.ResponseWriter, request *http.Req
|
||||
h.getResourceGroup(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) ListResourceShopItems(writer http.ResponseWriter, request *http.Request) {
|
||||
h.listResourceShopItems(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) ListGifts(writer http.ResponseWriter, request *http.Request) {
|
||||
h.listGifts(writer, request)
|
||||
}
|
||||
|
||||
@ -69,6 +69,20 @@ type resourceGroupData struct {
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type resourceShopItemData struct {
|
||||
ShopItemID int64 `json:"shop_item_id"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
Resource resourceData `json:"resource"`
|
||||
Status string `json:"status"`
|
||||
DurationDays int32 `json:"duration_days"`
|
||||
PriceType string `json:"price_type"`
|
||||
CoinPrice int64 `json:"coin_price"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
EffectiveToMS int64 `json:"effective_to_ms"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type giftConfigData struct {
|
||||
GiftID string `json:"gift_id"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
@ -196,6 +210,36 @@ func (h *Handler) getResourceGroup(writer http.ResponseWriter, request *http.Req
|
||||
httpkit.WriteOK(writer, request, group)
|
||||
}
|
||||
|
||||
func (h *Handler) listResourceShopItems(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
page, pageSize, ok := resourcePage(request)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
resp, err := h.walletClient.ListResourceShopItems(request.Context(), &walletv1.ListResourceShopItemsRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
ResourceType: strings.TrimSpace(request.URL.Query().Get("resource_type")),
|
||||
Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
ActiveOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
items := make([]resourceShopItemData, 0, len(resp.GetItems()))
|
||||
for _, item := range resp.GetItems() {
|
||||
items = append(items, resourceShopItemFromProto(item))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) listGifts(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
@ -463,6 +507,25 @@ func resourcePointerFromProto(item *walletv1.Resource) *resourceData {
|
||||
return &data
|
||||
}
|
||||
|
||||
func resourceShopItemFromProto(item *walletv1.ResourceShopItem) resourceShopItemData {
|
||||
if item == nil {
|
||||
return resourceShopItemData{}
|
||||
}
|
||||
return resourceShopItemData{
|
||||
ShopItemID: item.GetShopItemId(),
|
||||
ResourceID: item.GetResourceId(),
|
||||
Resource: resourceFromProto(item.GetResource()),
|
||||
Status: item.GetStatus(),
|
||||
DurationDays: item.GetDurationDays(),
|
||||
PriceType: item.GetPriceType(),
|
||||
CoinPrice: item.GetCoinPrice(),
|
||||
EffectiveFromMS: item.GetEffectiveFromMs(),
|
||||
EffectiveToMS: item.GetEffectiveToMs(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func giftFromProto(gift *walletv1.GiftConfig) giftConfigData {
|
||||
if gift == nil {
|
||||
return giftConfigData{}
|
||||
|
||||
@ -272,6 +272,20 @@ type fakeUserProfileClient struct {
|
||||
regionByUserID map[int64]int64
|
||||
}
|
||||
|
||||
type fakeTencentIMAccountImporter struct {
|
||||
lastUserID int64
|
||||
lastNickname string
|
||||
lastFaceURL string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTencentIMAccountImporter) ImportAccount(_ context.Context, userID int64, nickname string, faceURL string) error {
|
||||
f.lastUserID = userID
|
||||
f.lastNickname = nickname
|
||||
f.lastFaceURL = faceURL
|
||||
return f.err
|
||||
}
|
||||
|
||||
type fakeUserIdentityClient struct {
|
||||
lastResolve *userv1.ResolveDisplayUserIDRequest
|
||||
resolveByDisplay map[string]int64
|
||||
@ -383,6 +397,8 @@ type fakeWalletClient struct {
|
||||
resourcesByID map[int64]*walletv1.Resource
|
||||
lastGetResourceGroup *walletv1.GetResourceGroupRequest
|
||||
resourceGroupsByID map[int64]*walletv1.ResourceGroup
|
||||
lastResourceShop *walletv1.ListResourceShopItemsRequest
|
||||
resourceShopResp *walletv1.ListResourceShopItemsResponse
|
||||
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
|
||||
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
|
||||
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
|
||||
@ -1211,6 +1227,17 @@ func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.Get
|
||||
return &walletv1.GetResourceGroupResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) ListResourceShopItems(_ context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
|
||||
f.lastResourceShop = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.resourceShopResp != nil {
|
||||
return f.resourceShopResp, nil
|
||||
}
|
||||
return &walletv1.ListResourceShopItemsResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||
f.lastListGiftConfigs = req
|
||||
if f.err != nil {
|
||||
@ -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) {
|
||||
walletClient := &fakeWalletClient{
|
||||
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{
|
||||
@ -4545,6 +4646,40 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
|
||||
assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-missing")
|
||||
}
|
||||
|
||||
func TestTencentIMUserSigEnsuresImportedAccount(t *testing.T) {
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
importer := &fakeTencentIMAccountImporter{}
|
||||
handler := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
|
||||
SDKAppID: 1400000000,
|
||||
SecretKey: "secret",
|
||||
UserSigTTL: time.Hour,
|
||||
}, profileClient)
|
||||
handler.SetTencentIMAccountImporter(importer)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-usersig-import")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if importer.lastUserID != 42 || importer.lastNickname != "hy" || importer.lastFaceURL != "https://cdn.example/avatar.png" {
|
||||
t.Fatalf("unexpected imported account: %+v", importer)
|
||||
}
|
||||
|
||||
importer.err = errors.New("tencent unavailable")
|
||||
failedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
|
||||
failedRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
failedRequest.Header.Set("X-Request-ID", "req-usersig-import-failed")
|
||||
failedRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(failedRecorder, failedRequest)
|
||||
|
||||
assertEnvelope(t, failedRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-import-failed")
|
||||
}
|
||||
|
||||
func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) {
|
||||
previousNow := roomapi.TimeNow
|
||||
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
|
||||
|
||||
@ -134,6 +134,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
ListAppBanners: appAPI.ListAppBanners,
|
||||
GetAppVersion: appAPI.GetAppVersion,
|
||||
GetResourceGroup: resourceAPI.GetResourceGroup,
|
||||
ListResourceShopItems: resourceAPI.ListResourceShopItems,
|
||||
ListGifts: resourceAPI.ListGifts,
|
||||
ListGiftTabs: resourceAPI.ListGiftTabs,
|
||||
ListEmojiPacks: resourceAPI.ListEmojiPacks,
|
||||
|
||||
@ -76,13 +76,13 @@ activity_service:
|
||||
addr: "activity-service:13006"
|
||||
request_timeout_ms: 3000
|
||||
tencent_im:
|
||||
# Docker 本地默认关闭真实腾讯 IM 账号踢下线;开启后必须和 gateway/room 使用同一个 IM 应用。
|
||||
enabled: false
|
||||
sdk_app_id: 0
|
||||
secret_key: ""
|
||||
admin_identifier: "administrator"
|
||||
# Docker 本地联调腾讯 IM 账号导入和封禁踢下线;必须和 gateway/room 使用同一个 IM 应用。
|
||||
enabled: true
|
||||
sdk_app_id: 20036101
|
||||
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
||||
admin_identifier: "900100100"
|
||||
admin_user_sig_ttl_sec: 86400
|
||||
endpoint: "console.tim.qq.com"
|
||||
endpoint: "adminapisgp.im.qcloud.com"
|
||||
request_timeout_ms: 5000
|
||||
jwt:
|
||||
issuer: "hyapp"
|
||||
|
||||
@ -79,7 +79,7 @@ activity_service:
|
||||
addr: "activity-service.internal:13006"
|
||||
request_timeout_ms: 3000
|
||||
tencent_im:
|
||||
# 线上开启后,用户封禁会同步调用腾讯云 IM 账号踢下线。
|
||||
# 线上开启后,新用户会导入腾讯云 IM,用户封禁会同步调用腾讯云 IM 账号踢下线。
|
||||
enabled: true
|
||||
# 必须和 gateway-service/room-service 使用同一个 IM 应用。
|
||||
sdk_app_id: 1400000000
|
||||
|
||||
@ -76,13 +76,13 @@ activity_service:
|
||||
addr: "127.0.0.1:13006"
|
||||
request_timeout_ms: 3000
|
||||
tencent_im:
|
||||
# 本地默认关闭真实腾讯 IM 账号踢下线;开启后必须和 gateway/room 使用同一个 IM 应用。
|
||||
enabled: false
|
||||
sdk_app_id: 0
|
||||
secret_key: ""
|
||||
admin_identifier: "administrator"
|
||||
# 本地联调腾讯 IM 账号导入和封禁踢下线;必须和 gateway/room 使用同一个 IM 应用。
|
||||
enabled: true
|
||||
sdk_app_id: 20036101
|
||||
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
|
||||
admin_identifier: "900100100"
|
||||
admin_user_sig_ttl_sec: 86400
|
||||
endpoint: "console.tim.qq.com"
|
||||
endpoint: "adminapisgp.im.qcloud.com"
|
||||
request_timeout_ms: 5000
|
||||
jwt:
|
||||
issuer: "hyapp"
|
||||
|
||||
@ -136,6 +136,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
}
|
||||
}
|
||||
var imLoginKicker userservice.IMLoginKicker
|
||||
var imAccountImporter authservice.IMAccountImporter
|
||||
if cfg.TencentIM.Enabled {
|
||||
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||||
if err != nil {
|
||||
@ -147,6 +148,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
imLoginKicker = imClient
|
||||
imAccountImporter = tencentIMAccountImporter{client: imClient}
|
||||
}
|
||||
var roomConn *grpc.ClientConn
|
||||
var roomEvictor userservice.RoomEvictor
|
||||
@ -212,6 +214,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
authservice.WithIPDecisionCache(ipDecisionCache),
|
||||
authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders(cfg.LoginRisk.Providers)),
|
||||
authservice.WithRegistrationRewardIssuer(registrationRewardIssuer),
|
||||
authservice.WithIMAccountImporter(imAccountImporter),
|
||||
)
|
||||
// user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。
|
||||
userSvc := userservice.New(userRepo,
|
||||
@ -503,6 +506,18 @@ func (a *App) closeHealthHTTP() {
|
||||
_ = a.healthHTTP.Close(ctx)
|
||||
}
|
||||
|
||||
type tencentIMAccountImporter struct {
|
||||
client *tencentim.RESTClient
|
||||
}
|
||||
|
||||
func (i tencentIMAccountImporter) ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error {
|
||||
return i.client.ImportAccount(ctx, tencentim.AccountProfile{
|
||||
UserID: userID,
|
||||
Nick: nickname,
|
||||
FaceURL: faceURL,
|
||||
})
|
||||
}
|
||||
|
||||
func newMQConsumers(cfg config.Config, inviteSvc *inviteservice.Service, micTimeSvc *mictimeservice.Service) ([]*rocketmqx.Consumer, error) {
|
||||
consumers := make([]*rocketmqx.Consumer, 0, 2)
|
||||
if cfg.InviteRechargeWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
||||
|
||||
26
services/user-service/internal/service/auth/im_account.go
Normal file
26
services/user-service/internal/service/auth/im_account.go
Normal file
@ -0,0 +1,26 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
)
|
||||
|
||||
func (s *Service) importIMAccountAfterCommit(ctx context.Context, user userdomain.User, meta Meta) {
|
||||
if s.imAccountImporter == nil || user.UserID <= 0 {
|
||||
return
|
||||
}
|
||||
if err := s.imAccountImporter.ImportAccount(ctx, user.UserID, strings.TrimSpace(user.Username), strings.TrimSpace(user.Avatar)); err != nil {
|
||||
// 用户创建事务已经提交;账号导入失败不能回滚用户,只能依赖 /im/usersig 兜底补偿和日志排查。
|
||||
logx.Warn(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", appcode.Normalize(meta.AppCode))),
|
||||
"im_account_import_failed",
|
||||
slog.String("component", "user_auth"),
|
||||
slog.Int64("user_id", user.UserID),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -99,6 +99,7 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
|
||||
if tokenErr != nil {
|
||||
return authdomain.Token{}, tokenErr
|
||||
}
|
||||
s.importIMAccountAfterCommit(ctx, user, meta)
|
||||
s.enqueueLoginIPRiskJob(ctx, meta, token, "account", registration.Platform, registration.Language, registration.Timezone)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@ -110,6 +110,11 @@ type RegistrationRewardIssuer interface {
|
||||
IssueRegistrationReward(ctx context.Context, command RegistrationRewardCommand) error
|
||||
}
|
||||
|
||||
// IMAccountImporter 把 user-service 用户导入腾讯 IM 账号体系。
|
||||
type IMAccountImporter interface {
|
||||
ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error
|
||||
}
|
||||
|
||||
// Config 保存认证底座需要的稳定参数。
|
||||
type Config struct {
|
||||
// Issuer 写入 access token 的 iss claim。
|
||||
@ -194,6 +199,8 @@ type Service struct {
|
||||
ipGeoProviders []IPGeoProvider
|
||||
// registrationRewardIssuer 在注册事务提交后异步式触发奖励;失败只记录日志,不回滚用户注册。
|
||||
registrationRewardIssuer RegistrationRewardIssuer
|
||||
// imAccountImporter 在用户创建成功后补齐腾讯 IM 账号;UserSig 入口仍会兜底补偿历史账号。
|
||||
imAccountImporter IMAccountImporter
|
||||
}
|
||||
|
||||
// Option 调整 auth service 的依赖和策略。
|
||||
@ -275,6 +282,15 @@ func WithRegistrationRewardIssuer(issuer RegistrationRewardIssuer) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithIMAccountImporter 注入腾讯 IM 账号导入边界;nil 表示当前环境不触发真实 IM 副作用。
|
||||
func WithIMAccountImporter(importer IMAccountImporter) Option {
|
||||
return func(s *Service) {
|
||||
if importer != nil {
|
||||
s.imAccountImporter = importer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthRepository 注入纯认证持久化实现。
|
||||
func WithAuthRepository(repository AuthRepository) Option {
|
||||
return func(s *Service) {
|
||||
|
||||
@ -78,6 +78,19 @@ func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int)
|
||||
return a.values[attempt]
|
||||
}
|
||||
|
||||
type fakeIMAccountImporter struct {
|
||||
userID int64
|
||||
nickname string
|
||||
faceURL string
|
||||
}
|
||||
|
||||
func (f *fakeIMAccountImporter) ImportAccount(_ context.Context, userID int64, nickname string, faceURL string) error {
|
||||
f.userID = userID
|
||||
f.nickname = nickname
|
||||
f.faceURL = faceURL
|
||||
return nil
|
||||
}
|
||||
|
||||
func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
|
||||
base := []userservice.Option{
|
||||
userservice.WithIdentityRepository(repository),
|
||||
@ -230,7 +243,8 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
now := time.UnixMilli(2000)
|
||||
svc := newAuthService(repository, &now, []int64{900011}, []string{"100011"})
|
||||
imImporter := &fakeIMAccountImporter{}
|
||||
svc := newAuthService(repository, &now, []int64{900011}, []string{"100011"}, authservice.WithIMAccountImporter(imImporter))
|
||||
|
||||
token, err := svc.QuickCreateAccount(ctx, "secret-pass", authdomain.ThirdPartyRegistration{
|
||||
Username: "Lingxian Test",
|
||||
@ -259,6 +273,9 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
|
||||
if user.Username != "Lingxian Test" || user.Country != "SG" || !user.ProfileCompleted {
|
||||
t.Fatalf("quick user profile mismatch: %+v", user)
|
||||
}
|
||||
if imImporter.userID != 900011 || imImporter.nickname != "Lingxian Test" || imImporter.faceURL != "https://cdn.example/avatar.png" {
|
||||
t.Fatalf("quick account must import IM account: %+v", imImporter)
|
||||
}
|
||||
|
||||
loginToken, err := svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "quick-dev", authservice.Meta{RequestID: "req-quick-login", AppCode: "lalu"})
|
||||
if err != nil {
|
||||
@ -521,7 +538,8 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "CN")
|
||||
now := time.UnixMilli(1000)
|
||||
svc := newAuthService(repository, &now, []int64{900003}, []string{"100003"})
|
||||
imImporter := &fakeIMAccountImporter{}
|
||||
svc := newAuthService(repository, &now, []int64{900003}, []string{"100003"}, authservice.WithIMAccountImporter(imImporter))
|
||||
if err := repository.CreateUserWithIdentity(ctx, userdomain.User{
|
||||
AppCode: "lalu",
|
||||
UserID: 800001,
|
||||
@ -591,6 +609,9 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
|
||||
if user.Avatar == "" || user.BirthDate != "2000-01-02" || user.RegisterSource != "campaign-a" || user.RegisterInstallChannel != "app_store" || user.RegisterCampaign != "spring_launch" || user.RegisterPlatform != "ios" || user.Language != "zh-CN" || user.Timezone != "Asia/Shanghai" {
|
||||
t.Fatalf("registration profile fields mismatch: %+v", user)
|
||||
}
|
||||
if imImporter.userID != token.UserID || imImporter.nickname != "hy" || imImporter.faceURL != "https://cdn.example/avatar.png" {
|
||||
t.Fatalf("third-party registration must import IM account: %+v", imImporter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *testing.T) {
|
||||
|
||||
@ -174,6 +174,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
|
||||
if tokenErr != nil {
|
||||
return authdomain.Token{}, false, tokenErr
|
||||
}
|
||||
s.importIMAccountAfterCommit(ctx, user, meta)
|
||||
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(provider), registration.Platform, registration.Language, registration.Timezone)
|
||||
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta)
|
||||
return token, true, nil
|
||||
|
||||
@ -405,6 +405,24 @@ CREATE TABLE IF NOT EXISTS resource_group_items (
|
||||
KEY idx_resource_group_items_group (app_code, group_id, sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资源分组明细表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_shop_items (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
shop_item_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '道具商店条目 ID',
|
||||
resource_id BIGINT NOT NULL COMMENT '售卖资源 ID',
|
||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||
duration_days INT NOT NULL COMMENT '售卖天数,只允许 1/3/7',
|
||||
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间,UTC epoch ms,0 表示立即生效',
|
||||
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,UTC epoch ms,0 表示长期有效',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
|
||||
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
|
||||
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
UNIQUE KEY uk_resource_shop_items_resource (app_code, resource_id),
|
||||
KEY idx_resource_shop_items_status_sort (app_code, status, sort_order),
|
||||
KEY idx_resource_shop_items_effective (app_code, status, effective_from_ms, effective_to_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='道具商店售卖资源表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gift_type_configs (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码,供 gift_configs 引用',
|
||||
|
||||
@ -71,6 +71,10 @@ const (
|
||||
|
||||
PriceTypeCoin = "coin"
|
||||
PriceTypeFree = "free"
|
||||
|
||||
ShopDurationOneDay int32 = 1
|
||||
ShopDurationThreeDays int32 = 3
|
||||
ShopDurationSevenDays int32 = 7
|
||||
)
|
||||
|
||||
type Resource struct {
|
||||
@ -168,6 +172,24 @@ type GiftTypeConfig struct {
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
type ResourceShopItem struct {
|
||||
AppCode string
|
||||
ShopItemID int64
|
||||
ResourceID int64
|
||||
Resource Resource
|
||||
Status string
|
||||
DurationDays int32
|
||||
PriceType string
|
||||
CoinPrice int64
|
||||
EffectiveFromMS int64
|
||||
EffectiveToMS int64
|
||||
SortOrder int32
|
||||
CreatedByUserID int64
|
||||
UpdatedByUserID int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
type UserResourceEntitlement struct {
|
||||
AppCode string
|
||||
EntitlementID string
|
||||
@ -380,6 +402,39 @@ type ListResourceGrantsQuery struct {
|
||||
PageSize int32
|
||||
}
|
||||
|
||||
type ResourceShopItemInput struct {
|
||||
ShopItemID int64
|
||||
ResourceID int64
|
||||
Status string
|
||||
DurationDays int32
|
||||
EffectiveFromMS int64
|
||||
EffectiveToMS int64
|
||||
SortOrder int32
|
||||
}
|
||||
|
||||
type ListResourceShopItemsQuery struct {
|
||||
AppCode string
|
||||
ResourceType string
|
||||
Status string
|
||||
Keyword string
|
||||
Page int32
|
||||
PageSize int32
|
||||
ActiveOnly bool
|
||||
}
|
||||
|
||||
type ResourceShopItemsCommand struct {
|
||||
AppCode string
|
||||
Items []ResourceShopItemInput
|
||||
OperatorUserID int64
|
||||
}
|
||||
|
||||
type ResourceShopItemStatusCommand struct {
|
||||
AppCode string
|
||||
ShopItemID int64
|
||||
Status string
|
||||
OperatorUserID int64
|
||||
}
|
||||
|
||||
// BadgeGrantOutbox is a wallet resource grant event relayed to activity-service badge display projection.
|
||||
type BadgeGrantOutbox struct {
|
||||
AppCode string
|
||||
@ -434,6 +489,24 @@ func ValidResourceType(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func ResourceTypeSellableInShop(value string) bool {
|
||||
switch NormalizeResourceType(value) {
|
||||
case TypeAvatarFrame, TypeProfileCard, TypeVehicle, TypeChatBubble, TypeBadge, TypeMicSeatIcon, TypeMicSeatAnimation:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidShopDurationDays(value int32) bool {
|
||||
switch value {
|
||||
case ShopDurationOneDay, ShopDurationThreeDays, ShopDurationSevenDays:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidStatus(value string) bool {
|
||||
switch NormalizeStatus(value) {
|
||||
case StatusActive, StatusDisabled:
|
||||
|
||||
@ -207,3 +207,37 @@ func (s *Service) ListResourceGrants(ctx context.Context, query resourcedomain.L
|
||||
ctx = appcode.WithContext(ctx, query.AppCode)
|
||||
return s.repository.ListResourceGrants(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) {
|
||||
if s.repository == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
query.AppCode = appcode.Normalize(query.AppCode)
|
||||
ctx = appcode.WithContext(ctx, query.AppCode)
|
||||
if strings.TrimSpace(query.ResourceType) != "" {
|
||||
resourceType := resourcedomain.NormalizeResourceType(query.ResourceType)
|
||||
if !resourcedomain.ResourceTypeSellableInShop(resourceType) {
|
||||
return nil, 0, xerr.New(xerr.InvalidArgument, "resource_type is invalid")
|
||||
}
|
||||
query.ResourceType = resourceType
|
||||
}
|
||||
return s.repository.ListResourceShopItems(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) {
|
||||
if s.repository == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
ctx = appcode.WithContext(ctx, command.AppCode)
|
||||
return s.repository.UpsertResourceShopItems(ctx, command)
|
||||
}
|
||||
|
||||
func (s *Service) SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) {
|
||||
if s.repository == nil {
|
||||
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
ctx = appcode.WithContext(ctx, command.AppCode)
|
||||
return s.repository.SetResourceShopItemStatus(ctx, command)
|
||||
}
|
||||
|
||||
@ -74,6 +74,9 @@ type Repository interface {
|
||||
ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error)
|
||||
EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error)
|
||||
ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error)
|
||||
ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error)
|
||||
UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error)
|
||||
SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error)
|
||||
ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error)
|
||||
ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error)
|
||||
MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error
|
||||
|
||||
@ -943,6 +943,151 @@ func (r *Repository) ListResourceGrants(ctx context.Context, query resourcedomai
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
// ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖视图;价格始终由资源一日价乘以售卖天数派生。
|
||||
func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, query.AppCode)
|
||||
query.AppCode = appcode.FromContext(ctx)
|
||||
query = normalizeResourceShopItemsQuery(query)
|
||||
where, args := resourceShopItemsWhereSQL(query)
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM resource_shop_items si
|
||||
JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id
|
||||
`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+where+`
|
||||
ORDER BY si.sort_order ASC, si.shop_item_id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]resourcedomain.ResourceShopItem, 0, query.PageSize)
|
||||
for rows.Next() {
|
||||
item, err := scanResourceShopItem(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertResourceShopItems 批量添加或重配售卖资源;同一个资源在商店只保留一个当前售卖配置。
|
||||
func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, command.AppCode)
|
||||
command.AppCode = appcode.FromContext(ctx)
|
||||
command = normalizeResourceShopItemsCommand(command)
|
||||
if err := validateResourceShopItemsCommand(command); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
resourceIDs := make([]int64, 0, len(command.Items))
|
||||
seen := make(map[int64]struct{}, len(command.Items))
|
||||
for _, item := range command.Items {
|
||||
if _, exists := seen[item.ResourceID]; exists {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "resource shop contains duplicate resource")
|
||||
}
|
||||
seen[item.ResourceID] = struct{}{}
|
||||
resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateResourceShopResource(resource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO resource_shop_items (
|
||||
app_code, resource_id, status, duration_days, effective_from_ms, effective_to_ms, sort_order,
|
||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
duration_days = VALUES(duration_days),
|
||||
effective_from_ms = VALUES(effective_from_ms),
|
||||
effective_to_ms = VALUES(effective_to_ms),
|
||||
sort_order = VALUES(sort_order),
|
||||
updated_by_user_id = VALUES(updated_by_user_id),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
command.AppCode, item.ResourceID, item.Status, item.DurationDays, item.EffectiveFromMS, item.EffectiveToMS, item.SortOrder,
|
||||
command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resourceIDs = append(resourceIDs, item.ResourceID)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.listResourceShopItemsByResourceIDs(ctx, resourceIDs)
|
||||
}
|
||||
|
||||
func (r *Repository) SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, command.AppCode)
|
||||
command.AppCode = appcode.FromContext(ctx)
|
||||
command.Status = resourcedomain.NormalizeStatus(command.Status)
|
||||
if command.ShopItemID <= 0 {
|
||||
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "shop_item_id is required")
|
||||
}
|
||||
if !resourcedomain.ValidStatus(command.Status) {
|
||||
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "status is invalid")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopItem{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopItem{}, err
|
||||
}
|
||||
if command.Status == resourcedomain.StatusActive {
|
||||
if err := validateResourceShopResource(item.Resource); err != nil {
|
||||
return resourcedomain.ResourceShopItem{}, err
|
||||
}
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE resource_shop_items
|
||||
SET status = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND shop_item_id = ?`,
|
||||
command.Status, command.OperatorUserID, nowMs, command.AppCode, command.ShopItemID,
|
||||
)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopItem{}, err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil {
|
||||
return resourcedomain.ResourceShopItem{}, err
|
||||
} else if affected == 0 {
|
||||
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return resourcedomain.ResourceShopItem{}, err
|
||||
}
|
||||
return r.getResourceShopItem(ctx, command.ShopItemID)
|
||||
}
|
||||
|
||||
func (r *Repository) GetResourceGrant(ctx context.Context, grantID string) (resourcedomain.ResourceGrant, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
@ -1559,6 +1704,53 @@ func (r *Repository) getResourceForUpdate(ctx context.Context, tx *sql.Tx, resou
|
||||
return resource, err
|
||||
}
|
||||
|
||||
func (r *Repository) getResourceShopItem(ctx context.Context, shopItemID int64) (resourcedomain.ResourceShopItem, error) {
|
||||
return r.getResourceShopItemWithQuerier(ctx, r.db, shopItemID, false)
|
||||
}
|
||||
|
||||
func (r *Repository) getResourceShopItemTx(ctx context.Context, tx *sql.Tx, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) {
|
||||
return r.getResourceShopItemWithQuerier(ctx, tx, shopItemID, lock)
|
||||
}
|
||||
|
||||
func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier sqlRowQuerier, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) {
|
||||
query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.shop_item_id = ?`
|
||||
if lock {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), shopItemID)
|
||||
item, err := scanResourceShopItem(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (r *Repository) listResourceShopItemsByResourceIDs(ctx context.Context, resourceIDs []int64) ([]resourcedomain.ResourceShopItem, error) {
|
||||
resourceIDs = compactPositiveInt64s(resourceIDs)
|
||||
if len(resourceIDs) == 0 {
|
||||
return []resourcedomain.ResourceShopItem{}, nil
|
||||
}
|
||||
args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(resourceIDs)...)
|
||||
rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+`
|
||||
WHERE si.app_code = ? AND si.resource_id IN (`+placeholders(len(resourceIDs))+`)
|
||||
ORDER BY si.sort_order ASC, si.shop_item_id DESC`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]resourcedomain.ResourceShopItem, 0, len(resourceIDs))
|
||||
for rows.Next() {
|
||||
item, err := scanResourceShopItem(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) getResourceGroup(ctx context.Context, querier sqlRowQuerier, groupID int64, lock bool) (resourcedomain.ResourceGroup, error) {
|
||||
query := `
|
||||
SELECT app_code, group_id, group_code, name, status, description, sort_order,
|
||||
@ -1800,6 +1992,57 @@ func scanResource(scanner scanTarget) (resourcedomain.Resource, error) {
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func scanResourceShopItem(scanner scanTarget) (resourcedomain.ResourceShopItem, error) {
|
||||
var item resourcedomain.ResourceShopItem
|
||||
var resource resourcedomain.Resource
|
||||
var scopesJSON string
|
||||
if err := scanner.Scan(
|
||||
&item.AppCode,
|
||||
&item.ShopItemID,
|
||||
&item.ResourceID,
|
||||
&item.Status,
|
||||
&item.DurationDays,
|
||||
&item.PriceType,
|
||||
&item.CoinPrice,
|
||||
&item.EffectiveFromMS,
|
||||
&item.EffectiveToMS,
|
||||
&item.SortOrder,
|
||||
&item.CreatedByUserID,
|
||||
&item.UpdatedByUserID,
|
||||
&item.CreatedAtMS,
|
||||
&item.UpdatedAtMS,
|
||||
&resource.AppCode,
|
||||
&resource.ResourceID,
|
||||
&resource.ResourceCode,
|
||||
&resource.ResourceType,
|
||||
&resource.Name,
|
||||
&resource.Status,
|
||||
&resource.Grantable,
|
||||
&resource.ManagerGrantEnabled,
|
||||
&resource.GrantStrategy,
|
||||
&resource.WalletAssetType,
|
||||
&resource.WalletAssetAmount,
|
||||
&resource.PriceType,
|
||||
&resource.CoinPrice,
|
||||
&resource.GiftPointAmount,
|
||||
&scopesJSON,
|
||||
&resource.AssetURL,
|
||||
&resource.PreviewURL,
|
||||
&resource.AnimationURL,
|
||||
&resource.MetadataJSON,
|
||||
&resource.SortOrder,
|
||||
&resource.CreatedByUserID,
|
||||
&resource.UpdatedByUserID,
|
||||
&resource.CreatedAtMS,
|
||||
&resource.UpdatedAtMS,
|
||||
); err != nil {
|
||||
return resourcedomain.ResourceShopItem{}, err
|
||||
}
|
||||
resource.UsageScopes = parseStringArray(scopesJSON)
|
||||
item.Resource = resource
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanResourceGroup(scanner scanTarget) (resourcedomain.ResourceGroup, error) {
|
||||
var group resourcedomain.ResourceGroup
|
||||
err := scanner.Scan(
|
||||
@ -2057,6 +2300,19 @@ func giftTypeConfigSelectSQL() string {
|
||||
`
|
||||
}
|
||||
|
||||
func resourceShopItemSelectSQL() string {
|
||||
return `
|
||||
SELECT si.app_code, si.shop_item_id, si.resource_id, si.status, si.duration_days,
|
||||
r.price_type,
|
||||
CASE WHEN r.price_type = 'coin' THEN r.coin_price * si.duration_days ELSE 0 END AS coin_price,
|
||||
si.effective_from_ms, si.effective_to_ms, si.sort_order,
|
||||
si.created_by_user_id, si.updated_by_user_id, si.created_at_ms, si.updated_at_ms,
|
||||
` + resourceColumnsWithAlias("r") + `
|
||||
FROM resource_shop_items si
|
||||
JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id
|
||||
`
|
||||
}
|
||||
|
||||
func userResourceSelectSQL() string {
|
||||
return `
|
||||
SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
|
||||
@ -2159,6 +2415,31 @@ func giftTypeConfigWhereSQL(query resourcedomain.ListGiftTypeConfigsQuery) (stri
|
||||
return where, args
|
||||
}
|
||||
|
||||
func resourceShopItemsWhereSQL(query resourcedomain.ListResourceShopItemsQuery) (string, []any) {
|
||||
where := `WHERE si.app_code = ? AND r.resource_type IN ('avatar_frame', 'profile_card', 'vehicle', 'chat_bubble', 'badge', 'mic_seat_icon', 'mic_seat_animation')`
|
||||
args := []any{query.AppCode}
|
||||
if query.ActiveOnly {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
where += ` AND si.status = 'active' AND r.status = 'active'
|
||||
AND (si.effective_from_ms = 0 OR si.effective_from_ms <= ?)
|
||||
AND (si.effective_to_ms = 0 OR si.effective_to_ms > ?)`
|
||||
args = append(args, nowMs, nowMs)
|
||||
} else if query.Status != "" {
|
||||
where += ` AND si.status = ?`
|
||||
args = append(args, query.Status)
|
||||
}
|
||||
if query.ResourceType != "" {
|
||||
where += ` AND r.resource_type = ?`
|
||||
args = append(args, query.ResourceType)
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
like := "%" + query.Keyword + "%"
|
||||
where += ` AND (r.resource_code LIKE ? OR r.name LIKE ?)`
|
||||
args = append(args, like, like)
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func normalizeResourceListQuery(query resourcedomain.ListResourcesQuery) resourcedomain.ListResourcesQuery {
|
||||
query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
|
||||
if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) {
|
||||
@ -2198,6 +2479,17 @@ func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery)
|
||||
return query
|
||||
}
|
||||
|
||||
func normalizeResourceShopItemsQuery(query resourcedomain.ListResourceShopItemsQuery) resourcedomain.ListResourceShopItemsQuery {
|
||||
query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
|
||||
if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(query.ResourceType) {
|
||||
query.ResourceType = "__invalid__"
|
||||
}
|
||||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||
query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
|
||||
return query
|
||||
}
|
||||
|
||||
func normalizePage(page int32, pageSize int32) (int32, int32) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@ -2215,6 +2507,41 @@ func resourceOffset(page int32, pageSize int32) int32 {
|
||||
return (page - 1) * pageSize
|
||||
}
|
||||
|
||||
func placeholders(count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
items := make([]string, count)
|
||||
for index := range items {
|
||||
items[index] = "?"
|
||||
}
|
||||
return strings.Join(items, ",")
|
||||
}
|
||||
|
||||
func int64AnyArgs(values []int64) []any {
|
||||
args := make([]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
args = append(args, value)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func compactPositiveInt64s(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
out := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapResourceWriteError(err error) error {
|
||||
if isMySQLDuplicateError(err) {
|
||||
return xerr.New(xerr.Conflict, "resource_code already exists")
|
||||
@ -2282,6 +2609,47 @@ func validateResourceCommand(command resourcedomain.ResourceCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) resourcedomain.ResourceShopItemsCommand {
|
||||
for index := range command.Items {
|
||||
command.Items[index].Status = resourcedomain.NormalizeStatus(command.Items[index].Status)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
func validateResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) error {
|
||||
if len(command.Items) == 0 || command.OperatorUserID < 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "resource shop command is incomplete")
|
||||
}
|
||||
for _, item := range command.Items {
|
||||
if item.ResourceID <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "resource_id is required")
|
||||
}
|
||||
if !resourcedomain.ValidStatus(item.Status) {
|
||||
return xerr.New(xerr.InvalidArgument, "status is invalid")
|
||||
}
|
||||
if !resourcedomain.ValidShopDurationDays(item.DurationDays) {
|
||||
return xerr.New(xerr.InvalidArgument, "resource shop duration is invalid")
|
||||
}
|
||||
if item.EffectiveFromMS < 0 || item.EffectiveToMS < 0 || (item.EffectiveFromMS > 0 && item.EffectiveToMS > 0 && item.EffectiveToMS <= item.EffectiveFromMS) {
|
||||
return xerr.New(xerr.InvalidArgument, "resource shop effective time is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateResourceShopResource(resource resourcedomain.Resource) error {
|
||||
if !resourcedomain.ResourceTypeSellableInShop(resource.ResourceType) {
|
||||
return xerr.New(xerr.InvalidArgument, "resource_type cannot be sold in shop")
|
||||
}
|
||||
if resource.Status != resourcedomain.StatusActive {
|
||||
return xerr.New(xerr.Conflict, "resource is disabled")
|
||||
}
|
||||
if resourcedomain.NormalizePriceType(resource.PriceType) != resourcedomain.PriceTypeCoin || resource.CoinPrice <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "resource coin price is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeResourceGroupCommand(command resourcedomain.ResourceGroupCommand) resourcedomain.ResourceGroupCommand {
|
||||
command.GroupCode = strings.TrimSpace(command.GroupCode)
|
||||
command.Name = strings.TrimSpace(command.Name)
|
||||
|
||||
@ -287,6 +287,55 @@ func (s *Server) ListResourceGrants(ctx context.Context, req *walletv1.ListResou
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
|
||||
items, total, err := s.svc.ListResourceShopItems(ctx, resourcedomain.ListResourceShopItemsQuery{
|
||||
AppCode: req.GetAppCode(),
|
||||
ResourceType: req.GetResourceType(),
|
||||
Status: req.GetStatus(),
|
||||
Keyword: req.GetKeyword(),
|
||||
Page: req.GetPage(),
|
||||
PageSize: req.GetPageSize(),
|
||||
ActiveOnly: req.GetActiveOnly(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
resp := &walletv1.ListResourceShopItemsResponse{Items: make([]*walletv1.ResourceShopItem, 0, len(items)), Total: total}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, resourceShopItemToProto(item))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) UpsertResourceShopItems(ctx context.Context, req *walletv1.UpsertResourceShopItemsRequest) (*walletv1.UpsertResourceShopItemsResponse, error) {
|
||||
items, err := s.svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
Items: resourceShopItemInputs(req.GetItems()),
|
||||
OperatorUserID: req.GetOperatorUserId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
resp := &walletv1.UpsertResourceShopItemsResponse{Items: make([]*walletv1.ResourceShopItem, 0, len(items))}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, resourceShopItemToProto(item))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) SetResourceShopItemStatus(ctx context.Context, req *walletv1.SetResourceShopItemStatusRequest) (*walletv1.ResourceShopItemResponse, error) {
|
||||
item, err := s.svc.SetResourceShopItemStatus(ctx, resourcedomain.ResourceShopItemStatusCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
ShopItemID: req.GetShopItemId(),
|
||||
Status: req.GetStatus(),
|
||||
OperatorUserID: req.GetOperatorUserId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &walletv1.ResourceShopItemResponse{Item: resourceShopItemToProto(item)}, nil
|
||||
}
|
||||
|
||||
func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedomain.ResourceCommand {
|
||||
return resourcedomain.ResourceCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
@ -433,6 +482,25 @@ func groupItemInputs(items []*walletv1.ResourceGroupItemInput) []resourcedomain.
|
||||
return out
|
||||
}
|
||||
|
||||
func resourceShopItemInputs(items []*walletv1.ResourceShopItemInput) []resourcedomain.ResourceShopItemInput {
|
||||
out := make([]resourcedomain.ResourceShopItemInput, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, resourcedomain.ResourceShopItemInput{
|
||||
ShopItemID: item.GetShopItemId(),
|
||||
ResourceID: item.GetResourceId(),
|
||||
Status: item.GetStatus(),
|
||||
DurationDays: item.GetDurationDays(),
|
||||
EffectiveFromMS: item.GetEffectiveFromMs(),
|
||||
EffectiveToMS: item.GetEffectiveToMs(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resourceToProto(resource resourcedomain.Resource) *walletv1.Resource {
|
||||
return &walletv1.Resource{
|
||||
AppCode: appcode.Normalize(resource.AppCode),
|
||||
@ -462,6 +530,26 @@ func resourceToProto(resource resourcedomain.Resource) *walletv1.Resource {
|
||||
}
|
||||
}
|
||||
|
||||
func resourceShopItemToProto(item resourcedomain.ResourceShopItem) *walletv1.ResourceShopItem {
|
||||
return &walletv1.ResourceShopItem{
|
||||
AppCode: appcode.Normalize(item.AppCode),
|
||||
ShopItemId: item.ShopItemID,
|
||||
ResourceId: item.ResourceID,
|
||||
Resource: resourceToProto(item.Resource),
|
||||
Status: item.Status,
|
||||
DurationDays: item.DurationDays,
|
||||
PriceType: item.PriceType,
|
||||
CoinPrice: item.CoinPrice,
|
||||
EffectiveFromMs: item.EffectiveFromMS,
|
||||
EffectiveToMs: item.EffectiveToMS,
|
||||
SortOrder: item.SortOrder,
|
||||
CreatedByUserId: item.CreatedByUserID,
|
||||
UpdatedByUserId: item.UpdatedByUserID,
|
||||
CreatedAtMs: item.CreatedAtMS,
|
||||
UpdatedAtMs: item.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func managerGrantEnabledOrDefault(value *bool) bool {
|
||||
if value == nil {
|
||||
// 新建/编辑入口如果没显式携带开关,仍按产品规则默认允许经理中心赠送。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user