修改幸运礼物

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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