修改幸运礼物

This commit is contained in:
zhx 2026-05-26 05:16:18 +08:00
parent db036098bc
commit 113c5096e0
43 changed files with 2948 additions and 659 deletions

File diff suppressed because it is too large Load Diff

View File

@ -222,6 +222,24 @@ message GiftTypeConfig {
int64 updated_at_ms = 10; int64 updated_at_ms = 10;
} }
message ResourceShopItem {
string app_code = 1;
int64 shop_item_id = 2;
int64 resource_id = 3;
Resource resource = 4;
string status = 5;
int32 duration_days = 6;
string price_type = 7;
int64 coin_price = 8;
int64 effective_from_ms = 9;
int64 effective_to_ms = 10;
int32 sort_order = 11;
int64 created_by_user_id = 12;
int64 updated_by_user_id = 13;
int64 created_at_ms = 14;
int64 updated_at_ms = 15;
}
message UserResourceEntitlement { message UserResourceEntitlement {
string app_code = 1; string app_code = 1;
string entitlement_id = 2; string entitlement_id = 2;
@ -592,6 +610,55 @@ message ListResourceGrantsResponse {
int64 total = 2; int64 total = 2;
} }
message ResourceShopItemInput {
int64 shop_item_id = 1;
int64 resource_id = 2;
string status = 3;
int32 duration_days = 4;
int64 effective_from_ms = 5;
int64 effective_to_ms = 6;
int32 sort_order = 7;
}
message ListResourceShopItemsRequest {
string request_id = 1;
string app_code = 2;
string resource_type = 3;
string status = 4;
string keyword = 5;
int32 page = 6;
int32 page_size = 7;
bool active_only = 8;
}
message ListResourceShopItemsResponse {
repeated ResourceShopItem items = 1;
int64 total = 2;
}
message UpsertResourceShopItemsRequest {
string request_id = 1;
string app_code = 2;
repeated ResourceShopItemInput items = 3;
int64 operator_user_id = 4;
}
message UpsertResourceShopItemsResponse {
repeated ResourceShopItem items = 1;
}
message SetResourceShopItemStatusRequest {
string request_id = 1;
string app_code = 2;
int64 shop_item_id = 3;
string status = 4;
int64 operator_user_id = 5;
}
message ResourceShopItemResponse {
ResourceShopItem item = 1;
}
message RechargeBill { message RechargeBill {
string app_code = 1; string app_code = 1;
string transaction_id = 2; string transaction_id = 2;
@ -1275,6 +1342,9 @@ service WalletService {
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse); rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse); rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
rpc ListResourceGrants(ListResourceGrantsRequest) returns (ListResourceGrantsResponse); rpc ListResourceGrants(ListResourceGrantsRequest) returns (ListResourceGrantsResponse);
rpc ListResourceShopItems(ListResourceShopItemsRequest) returns (ListResourceShopItemsResponse);
rpc UpsertResourceShopItems(UpsertResourceShopItemsRequest) returns (UpsertResourceShopItemsResponse);
rpc SetResourceShopItemStatus(SetResourceShopItemStatusRequest) returns (ResourceShopItemResponse);
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse); rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse); rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse); rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);

View File

@ -45,6 +45,9 @@ const (
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources" WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource" WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants" WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
WalletService_ListResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopItems"
WalletService_UpsertResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertResourceShopItems"
WalletService_SetResourceShopItemStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceShopItemStatus"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills" WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview" WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary" WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
@ -108,6 +111,9 @@ type WalletServiceClient interface {
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error) ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error) EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
ListResourceGrants(ctx context.Context, in *ListResourceGrantsRequest, opts ...grpc.CallOption) (*ListResourceGrantsResponse, error) ListResourceGrants(ctx context.Context, in *ListResourceGrantsRequest, opts ...grpc.CallOption) (*ListResourceGrantsResponse, error)
ListResourceShopItems(ctx context.Context, in *ListResourceShopItemsRequest, opts ...grpc.CallOption) (*ListResourceShopItemsResponse, error)
UpsertResourceShopItems(ctx context.Context, in *UpsertResourceShopItemsRequest, opts ...grpc.CallOption) (*UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(ctx context.Context, in *SetResourceShopItemStatusRequest, opts ...grpc.CallOption) (*ResourceShopItemResponse, error)
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error) ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error) GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error) GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
@ -407,6 +413,36 @@ func (c *walletServiceClient) ListResourceGrants(ctx context.Context, in *ListRe
return out, nil return out, nil
} }
func (c *walletServiceClient) ListResourceShopItems(ctx context.Context, in *ListResourceShopItemsRequest, opts ...grpc.CallOption) (*ListResourceShopItemsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListResourceShopItemsResponse)
err := c.cc.Invoke(ctx, WalletService_ListResourceShopItems_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UpsertResourceShopItems(ctx context.Context, in *UpsertResourceShopItemsRequest, opts ...grpc.CallOption) (*UpsertResourceShopItemsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpsertResourceShopItemsResponse)
err := c.cc.Invoke(ctx, WalletService_UpsertResourceShopItems_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) SetResourceShopItemStatus(ctx context.Context, in *SetResourceShopItemStatusRequest, opts ...grpc.CallOption) (*ResourceShopItemResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResourceShopItemResponse)
err := c.cc.Invoke(ctx, WalletService_SetResourceShopItemStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error) { func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRechargeBillsResponse) out := new(ListRechargeBillsResponse)
@ -729,6 +765,9 @@ type WalletServiceServer interface {
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error) EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error) ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error)
ListResourceShopItems(context.Context, *ListResourceShopItemsRequest) (*ListResourceShopItemsResponse, error)
UpsertResourceShopItems(context.Context, *UpsertResourceShopItemsRequest) (*UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error)
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error) GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error) GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
@ -846,6 +885,15 @@ func (UnimplementedWalletServiceServer) EquipUserResource(context.Context, *Equi
func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error) { func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListResourceGrants not implemented") return nil, status.Error(codes.Unimplemented, "method ListResourceGrants not implemented")
} }
func (UnimplementedWalletServiceServer) ListResourceShopItems(context.Context, *ListResourceShopItemsRequest) (*ListResourceShopItemsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListResourceShopItems not implemented")
}
func (UnimplementedWalletServiceServer) UpsertResourceShopItems(context.Context, *UpsertResourceShopItemsRequest) (*UpsertResourceShopItemsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpsertResourceShopItems not implemented")
}
func (UnimplementedWalletServiceServer) SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetResourceShopItemStatus not implemented")
}
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) { func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRechargeBills not implemented") return nil, status.Error(codes.Unimplemented, "method ListRechargeBills not implemented")
} }
@ -1422,6 +1470,60 @@ func _WalletService_ListResourceGrants_Handler(srv interface{}, ctx context.Cont
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _WalletService_ListResourceShopItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListResourceShopItemsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListResourceShopItems(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListResourceShopItems_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListResourceShopItems(ctx, req.(*ListResourceShopItemsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpsertResourceShopItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertResourceShopItemsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).UpsertResourceShopItems(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_UpsertResourceShopItems_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).UpsertResourceShopItems(ctx, req.(*UpsertResourceShopItemsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_SetResourceShopItemStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetResourceShopItemStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).SetResourceShopItemStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_SetResourceShopItemStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).SetResourceShopItemStatus(ctx, req.(*SetResourceShopItemStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRechargeBillsRequest) in := new(ListRechargeBillsRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -2055,6 +2157,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListResourceGrants", MethodName: "ListResourceGrants",
Handler: _WalletService_ListResourceGrants_Handler, Handler: _WalletService_ListResourceGrants_Handler,
}, },
{
MethodName: "ListResourceShopItems",
Handler: _WalletService_ListResourceShopItems_Handler,
},
{
MethodName: "UpsertResourceShopItems",
Handler: _WalletService_UpsertResourceShopItems_Handler,
},
{
MethodName: "SetResourceShopItemStatus",
Handler: _WalletService_SetResourceShopItemStatus_Handler,
},
{ {
MethodName: "ListRechargeBills", MethodName: "ListRechargeBills",
Handler: _WalletService_ListRechargeBills_Handler, Handler: _WalletService_ListRechargeBills_Handler,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -382,6 +382,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
drawID := idgen.New("lucky_draw") drawID := idgen.New("lucky_draw")
budgetSourcesJSON := luckyBudgetSourcesJSON(candidate) budgetSourcesJSON := luckyBudgetSourcesJSON(candidate)
rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback)
if err := r.applyLuckyDraw(ctx, tx, luckyApplyInput{ if err := r.applyLuckyDraw(ctx, tx, luckyApplyInput{
AppCode: appCode, AppCode: appCode,
DrawID: drawID, DrawID: drawID,
@ -399,6 +400,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
Atmosphere: atmosphere, Atmosphere: atmosphere,
ActivityDay: activityDay, ActivityDay: activityDay,
BudgetSourcesJSON: budgetSourcesJSON, BudgetSourcesJSON: budgetSourcesJSON,
RewardStatus: rewardStatus,
NowMS: nowMS, NowMS: nowMS,
}); err != nil { }); err != nil {
return domain.DrawResult{}, err return domain.DrawResult{}, err
@ -421,7 +423,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
ActivitySubsidyCoins: candidate.ActivityReward, ActivitySubsidyCoins: candidate.ActivityReward,
EffectiveRewardCoins: candidate.effectiveReward(), EffectiveRewardCoins: candidate.effectiveReward(),
BudgetSourcesJSON: budgetSourcesJSON, BudgetSourcesJSON: budgetSourcesJSON,
RewardStatus: domain.StatusPending, RewardStatus: rewardStatus,
RTPWindowIndex: globalWindow.WindowIndex, RTPWindowIndex: globalWindow.WindowIndex,
GiftRTPWindowIndex: giftWindow.WindowIndex, GiftRTPWindowIndex: giftWindow.WindowIndex,
GlobalBaseRTPPPM: luckyRTPPPM(globalWindow.WagerCoins, globalWindow.ActualPayoutCoins+candidate.BaseReward), GlobalBaseRTPPPM: luckyRTPPPM(globalWindow.WagerCoins, globalWindow.ActualPayoutCoins+candidate.BaseReward),
@ -449,6 +451,7 @@ type luckyApplyInput struct {
Atmosphere luckyPool Atmosphere luckyPool
ActivityDay string ActivityDay string
BudgetSourcesJSON string BudgetSourcesJSON string
RewardStatus string
NowMS int64 NowMS int64
} }
@ -564,12 +567,12 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex, input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex,
input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward, input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier, input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier,
string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot), domain.StatusPending, "", "", string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot), input.RewardStatus, "", "",
input.Command.PaidAtMS, input.NowMS, input.NowMS, input.Command.PaidAtMS, input.NowMS, input.NowMS,
); err != nil { ); err != nil {
return err return err
} }
if input.Candidate.effectiveReward() > 0 || input.StageFeedback || input.Candidate.Presentation { if luckyDrawNeedsOutbox(input.Candidate, input.StageFeedback) {
payload, _ := json.Marshal(map[string]any{ payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn", "event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:" + input.DrawID, "event_id": "lucky_gift_drawn:" + input.DrawID,
@ -1592,19 +1595,15 @@ func luckyDedupeRewardCandidates(candidates []luckyCandidate) []luckyCandidate {
} }
func luckyDesiredBaseReward(globalWindow, giftWindow luckyRTPWindow) int64 { func luckyDesiredBaseReward(globalWindow, giftWindow luckyRTPWindow) int64 {
return minInt64(luckyWindowAveragePayout(globalWindow), luckyWindowAveragePayout(giftWindow)) return minInt64(luckyWindowBaseDeficit(globalWindow), luckyWindowBaseDeficit(giftWindow))
} }
func luckyWindowAveragePayout(window luckyRTPWindow) int64 { func luckyWindowBaseDeficit(window luckyRTPWindow) int64 {
remaining := window.remainingPayout() remaining := window.remainingPayout()
if remaining <= 0 { if remaining <= 0 {
return 0 return 0
} }
draws := window.remainingDraws() return remaining
if draws <= 0 {
return remaining
}
return remaining / draws
} }
func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64) { func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64) {
@ -1706,6 +1705,19 @@ func luckyBudgetSourcesJSON(candidate luckyCandidate) string {
return string(raw) return string(raw)
} }
func luckyInitialRewardStatus(candidate luckyCandidate, stageFeedback bool) string {
// 没有金币返还、也没有房间展示副作用时,抽奖事实已经终态化;不能留给不存在的 outbox 消费。
if !luckyDrawNeedsOutbox(candidate, stageFeedback) {
return domain.StatusGranted
}
return domain.StatusPending
}
func luckyDrawNeedsOutbox(candidate luckyCandidate, stageFeedback bool) bool {
// outbox 只承载真实异步副作用:钱包入账、房间 IM 展示或阶段反馈展示。
return candidate.effectiveReward() > 0 || stageFeedback || candidate.Presentation
}
func luckyCorrectionTier(reward int64) string { func luckyCorrectionTier(reward int64) string {
if reward == 0 { if reward == 0 {
return "none" return "none"

View File

@ -58,13 +58,13 @@ func TestLuckyRTPWindowAddsCurrentSpendToTarget(t *testing.T) {
} }
} }
func TestLuckyAutoWeightedBaseCandidatesFollowsWindowAverage(t *testing.T) { func TestLuckyAutoWeightedBaseCandidatesUsesCurrentWindowDeficit(t *testing.T) {
candidates := []luckyCandidate{ candidates := []luckyCandidate{
{TierID: "none", BaseReward: 0}, {TierID: "none", BaseReward: 0},
{TierID: "half", BaseReward: 500}, {TierID: "half", BaseReward: 500},
{TierID: "one", BaseReward: 1000}, {TierID: "one", BaseReward: 1000},
} }
window := luckyRTPWindow{ControlDraws: 100, TargetPayoutCoins: 95_000} window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 950}
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window) weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
@ -78,3 +78,56 @@ func TestLuckyAutoWeightedBaseCandidatesFollowsWindowAverage(t *testing.T) {
t.Fatalf("expected 1x to carry 90%% probability, got %#v", weighted[1]) t.Fatalf("expected 1x to carry 90%% probability, got %#v", weighted[1])
} }
} }
func TestLuckyAutoWeightedBaseCandidatesDoesNotDiluteDeficitAcrossLargeWindow(t *testing.T) {
candidates := []luckyCandidate{
{TierID: "none", BaseReward: 0},
{TierID: "rebate_0_5x", BaseReward: 5},
{TierID: "rebate_1x", BaseReward: 10},
}
window := luckyRTPWindow{ControlDraws: 100_000, TargetPayoutCoins: 9}
weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window)
if len(weighted) != 2 {
t.Fatalf("expected deficit to interpolate between reward tiers, got %#v", weighted)
}
if weighted[0].TierID != "rebate_0_5x" || weighted[0].Weight != 200_000 {
t.Fatalf("expected 0.5x to carry 20%% probability, got %#v", weighted[0])
}
if weighted[1].TierID != "rebate_1x" || weighted[1].Weight != 800_000 {
t.Fatalf("expected 1x to carry 80%% probability, got %#v", weighted[1])
}
}
func TestLuckyInitialRewardStatusGrantsZeroRewardWithoutOutbox(t *testing.T) {
if status := luckyInitialRewardStatus(luckyCandidate{TierID: "none"}, false); status != domain.StatusGranted {
t.Fatalf("zero reward without presentation should be granted, got %s", status)
}
if luckyDrawNeedsOutbox(luckyCandidate{TierID: "none"}, false) {
t.Fatalf("zero reward without presentation must not emit outbox")
}
}
func TestLuckyInitialRewardStatusKeepsAsyncSideEffectsPending(t *testing.T) {
tests := []struct {
name string
candidate luckyCandidate
stageFeedback bool
}{
{name: "wallet credit", candidate: luckyCandidate{TierID: "hit", BaseReward: 10}},
{name: "presentation candidate", candidate: luckyCandidate{TierID: "stage", Presentation: true}},
{name: "stage feedback", candidate: luckyCandidate{TierID: "none"}, stageFeedback: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if status := luckyInitialRewardStatus(test.candidate, test.stageFeedback); status != domain.StatusPending {
t.Fatalf("async side effect should stay pending, got %s", status)
}
if !luckyDrawNeedsOutbox(test.candidate, test.stageFeedback) {
t.Fatalf("async side effect should emit outbox")
}
})
}
}

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"hyapp/pkg/grpcclient" "hyapp/pkg/grpcclient"
"hyapp/pkg/tencentcos" "hyapp/pkg/tencentcos"
"hyapp/pkg/tencentim"
"hyapp/services/gateway-service/internal/appconfig" "hyapp/services/gateway-service/internal/appconfig"
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/client" "hyapp/services/gateway-service/internal/client"
@ -130,6 +131,24 @@ func New(cfg config.Config) (*App, error) {
AdminIdentifier: cfg.TencentIM.AdminIdentifier, AdminIdentifier: cfg.TencentIM.AdminIdentifier,
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken, CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
}, userProfileClient) }, userProfileClient)
if cfg.TencentIM.SDKAppID > 0 && strings.TrimSpace(cfg.TencentIM.SecretKey) != "" {
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
if err != nil {
if appConfigReader != nil {
_ = appConfigReader.Close()
}
if leaderboardWalletDB != nil {
_ = leaderboardWalletDB.Close()
}
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
_ = gameConn.Close()
return nil, err
}
handler.SetTencentIMAccountImporter(gatewayTencentIMAccountImporter{client: imClient})
}
handler.SetRoomQueryClient(roomQueryClient) handler.SetRoomQueryClient(roomQueryClient)
handler.SetUserDeviceClient(userDeviceClient) handler.SetUserDeviceClient(userDeviceClient)
handler.SetUserCountryQueryClient(userCountryQueryClient) handler.SetUserCountryQueryClient(userCountryQueryClient)
@ -374,6 +393,18 @@ func joinClose(left func() error, right func() error) func() error {
} }
} }
type gatewayTencentIMAccountImporter struct {
client *tencentim.RESTClient
}
func (i gatewayTencentIMAccountImporter) ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error {
return i.client.ImportAccount(ctx, tencentim.AccountProfile{
UserID: userID,
Nick: nickname,
FaceURL: faceURL,
})
}
// Run 启动 HTTP 服务。 // Run 启动 HTTP 服务。
func (a *App) Run() error { func (a *App) Run() error {
a.health.MarkHTTPServing() a.health.MarkHTTPServing()

View File

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

View File

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

View File

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

View File

@ -46,6 +46,7 @@ type AppHandlers struct {
ListAppBanners http.HandlerFunc ListAppBanners http.HandlerFunc
GetAppVersion http.HandlerFunc GetAppVersion http.HandlerFunc
GetResourceGroup http.HandlerFunc GetResourceGroup http.HandlerFunc
ListResourceShopItems http.HandlerFunc
ListGifts http.HandlerFunc ListGifts http.HandlerFunc
ListGiftTabs http.HandlerFunc ListGiftTabs http.HandlerFunc
ListEmojiPacks http.HandlerFunc ListEmojiPacks http.HandlerFunc
@ -250,6 +251,7 @@ func (r routes) registerAppRoutes() {
r.public("/app/banners", "", h.ListAppBanners) r.public("/app/banners", "", h.ListAppBanners)
r.public("/app/version", http.MethodGet, h.GetAppVersion) r.public("/app/version", http.MethodGet, h.GetAppVersion)
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup) r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)
r.public("/resource-shop/items", http.MethodGet, h.ListResourceShopItems)
r.public("/gifts", "", h.ListGifts) r.public("/gifts", "", h.ListGifts)
r.public("/gift-tabs", http.MethodGet, h.ListGiftTabs) r.public("/gift-tabs", http.MethodGet, h.ListGiftTabs)
r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks) r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks)

View File

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

View File

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

View File

@ -272,6 +272,20 @@ type fakeUserProfileClient struct {
regionByUserID map[int64]int64 regionByUserID map[int64]int64
} }
type fakeTencentIMAccountImporter struct {
lastUserID int64
lastNickname string
lastFaceURL string
err error
}
func (f *fakeTencentIMAccountImporter) ImportAccount(_ context.Context, userID int64, nickname string, faceURL string) error {
f.lastUserID = userID
f.lastNickname = nickname
f.lastFaceURL = faceURL
return f.err
}
type fakeUserIdentityClient struct { type fakeUserIdentityClient struct {
lastResolve *userv1.ResolveDisplayUserIDRequest lastResolve *userv1.ResolveDisplayUserIDRequest
resolveByDisplay map[string]int64 resolveByDisplay map[string]int64
@ -383,6 +397,8 @@ type fakeWalletClient struct {
resourcesByID map[int64]*walletv1.Resource resourcesByID map[int64]*walletv1.Resource
lastGetResourceGroup *walletv1.GetResourceGroupRequest lastGetResourceGroup *walletv1.GetResourceGroupRequest
resourceGroupsByID map[int64]*walletv1.ResourceGroup resourceGroupsByID map[int64]*walletv1.ResourceGroup
lastResourceShop *walletv1.ListResourceShopItemsRequest
resourceShopResp *walletv1.ListResourceShopItemsResponse
lastListGiftConfigs *walletv1.ListGiftConfigsRequest lastListGiftConfigs *walletv1.ListGiftConfigsRequest
listGiftConfigsResp *walletv1.ListGiftConfigsResponse listGiftConfigsResp *walletv1.ListGiftConfigsResponse
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
@ -1211,6 +1227,17 @@ func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.Get
return &walletv1.GetResourceGroupResponse{}, nil return &walletv1.GetResourceGroupResponse{}, nil
} }
func (f *fakeWalletClient) ListResourceShopItems(_ context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
f.lastResourceShop = req
if f.err != nil {
return nil, f.err
}
if f.resourceShopResp != nil {
return f.resourceShopResp, nil
}
return &walletv1.ListResourceShopItemsResponse{}, nil
}
func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) { func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
f.lastListGiftConfigs = req f.lastListGiftConfigs = req
if f.err != nil { if f.err != nil {
@ -3474,6 +3501,80 @@ func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) {
} }
} }
func TestListResourceShopItemsReturnsActiveSaleItems(t *testing.T) {
walletClient := &fakeWalletClient{
resourceShopResp: &walletv1.ListResourceShopItemsResponse{
Items: []*walletv1.ResourceShopItem{
{
ShopItemId: 9001,
ResourceId: 7001,
Status: "active",
DurationDays: 3,
PriceType: "coin",
CoinPrice: 300,
EffectiveFromMs: 1_700_000_000_000,
EffectiveToMs: 1_700_086_400_000,
SortOrder: 10,
Resource: &walletv1.Resource{
ResourceId: 7001,
ResourceCode: "avatar_frame_gold",
ResourceType: "avatar_frame",
Name: "Gold Frame",
Status: "active",
AssetUrl: "https://cdn.example/frame.png",
PreviewUrl: "https://cdn.example/frame-preview.png",
PriceType: "coin",
CoinPrice: 100,
},
},
},
Total: 1,
},
}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/resource-shop/items?resource_type=avatar_frame&page=1&page_size=10", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("resource shop status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var envelope struct {
Code string `json:"code"`
Data struct {
Items []struct {
ShopItemID int64 `json:"shop_item_id"`
ResourceID int64 `json:"resource_id"`
DurationDays int32 `json:"duration_days"`
CoinPrice int64 `json:"coin_price"`
EffectiveFromMS int64 `json:"effective_from_ms"`
Resource struct {
ResourceCode string `json:"resource_code"`
ResourceType string `json:"resource_type"`
CoinPrice int64 `json:"coin_price"`
PreviewURL string `json:"preview_url"`
} `json:"resource"`
} `json:"items"`
Total int64 `json:"total"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode resource shop failed: %v", err)
}
if walletClient.lastResourceShop == nil || walletClient.lastResourceShop.GetResourceType() != "avatar_frame" || !walletClient.lastResourceShop.GetActiveOnly() || walletClient.lastResourceShop.GetPageSize() != 10 {
t.Fatalf("resource shop request mismatch: %+v", walletClient.lastResourceShop)
}
if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 {
t.Fatalf("resource shop envelope mismatch: %+v", envelope)
}
first := envelope.Data.Items[0]
if first.ShopItemID != 9001 || first.DurationDays != 3 || first.CoinPrice != 300 || first.Resource.ResourceCode != "avatar_frame_gold" || first.Resource.CoinPrice != 100 || first.Resource.PreviewURL == "" {
t.Fatalf("resource shop item mismatch: %+v", first)
}
}
func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) { func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) {
walletClient := &fakeWalletClient{ walletClient := &fakeWalletClient{
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{ listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{
@ -4545,6 +4646,40 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-missing") assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-missing")
} }
func TestTencentIMUserSigEnsuresImportedAccount(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 1001}
importer := &fakeTencentIMAccountImporter{}
handler := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
SDKAppID: 1400000000,
SecretKey: "secret",
UserSigTTL: time.Hour,
}, profileClient)
handler.SetTencentIMAccountImporter(importer)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-usersig-import")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if importer.lastUserID != 42 || importer.lastNickname != "hy" || importer.lastFaceURL != "https://cdn.example/avatar.png" {
t.Fatalf("unexpected imported account: %+v", importer)
}
importer.err = errors.New("tencent unavailable")
failedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
failedRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
failedRequest.Header.Set("X-Request-ID", "req-usersig-import-failed")
failedRecorder := httptest.NewRecorder()
router.ServeHTTP(failedRecorder, failedRequest)
assertEnvelope(t, failedRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-import-failed")
}
func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) { func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) {
previousNow := roomapi.TimeNow previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) } roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }

View File

@ -134,6 +134,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
ListAppBanners: appAPI.ListAppBanners, ListAppBanners: appAPI.ListAppBanners,
GetAppVersion: appAPI.GetAppVersion, GetAppVersion: appAPI.GetAppVersion,
GetResourceGroup: resourceAPI.GetResourceGroup, GetResourceGroup: resourceAPI.GetResourceGroup,
ListResourceShopItems: resourceAPI.ListResourceShopItems,
ListGifts: resourceAPI.ListGifts, ListGifts: resourceAPI.ListGifts,
ListGiftTabs: resourceAPI.ListGiftTabs, ListGiftTabs: resourceAPI.ListGiftTabs,
ListEmojiPacks: resourceAPI.ListEmojiPacks, ListEmojiPacks: resourceAPI.ListEmojiPacks,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -74,6 +74,9 @@ type Repository interface {
ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error)
EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error)
ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error) ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error)
ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error)
UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error)
SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error)
ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error)
ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error) ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error)
MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error

View File

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

View File

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