抽奖信息
This commit is contained in:
parent
7883bb8217
commit
23a44d5f6f
File diff suppressed because it is too large
Load Diff
@ -273,6 +273,11 @@ message AdminHumanRoomRobotCountryPool {
|
||||
repeated int64 robot_user_ids = 2;
|
||||
}
|
||||
|
||||
message AdminHumanRoomRobotCountryRule {
|
||||
string country_code = 1;
|
||||
int32 max_room_count = 2;
|
||||
}
|
||||
|
||||
message AdminHumanRoomRobotConfig {
|
||||
string app_code = 1;
|
||||
bool enabled = 2;
|
||||
@ -301,6 +306,10 @@ message AdminHumanRoomRobotConfig {
|
||||
int32 room_target_max_online = 25;
|
||||
// allowed_owner_ids 为空表示扫描所有真人房;非空时只扫描房主展示短号/内部 owner_user_id 命中的房间。
|
||||
repeated string allowed_owner_ids = 26;
|
||||
// country_limit_enabled 打开后只按 country_rules 中配置的国家进真人房。
|
||||
bool country_limit_enabled = 27;
|
||||
// country_rules 配置每个国家最多同时进入几个真人房;空集合表示不进入任何国家。
|
||||
repeated AdminHumanRoomRobotCountryRule country_rules = 28;
|
||||
}
|
||||
|
||||
message AdminGetHumanRoomRobotConfigRequest {
|
||||
@ -1009,6 +1018,10 @@ message SendGiftRequest {
|
||||
int64 sender_region_id = 11;
|
||||
// sender_country_id 是 gateway 从 user-service 当前送礼用户资料注入的真实国家,用于统计国家维度。
|
||||
int64 sender_country_id = 12;
|
||||
// entitlement_id 非空表示从用户 Bag 礼物库存中消费,不走金币扣费。
|
||||
string entitlement_id = 13;
|
||||
// source 当前支持 bag;为空按普通金币礼物兼容。
|
||||
string source = 14;
|
||||
}
|
||||
|
||||
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -25,6 +25,10 @@ message DebitGiftRequest {
|
||||
int64 target_agency_owner_user_id = 12;
|
||||
// sender_region_id 是送礼用户所属区域快照;不能用房间 visible_region_id 替代。
|
||||
int64 sender_region_id = 13;
|
||||
// entitlement_id 非空表示本次送礼从用户背包 gift 权益扣库存,不扣 COIN。
|
||||
string entitlement_id = 14;
|
||||
// charge_source 区分 coin/bag;为空按 coin 兼容旧客户端。
|
||||
string charge_source = 15;
|
||||
}
|
||||
|
||||
// DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。
|
||||
@ -51,6 +55,8 @@ message DebitGiftResponse {
|
||||
string gift_icon_url = 15;
|
||||
string gift_animation_url = 16;
|
||||
repeated string gift_effect_types = 17;
|
||||
string entitlement_id = 18;
|
||||
string charge_source = 19;
|
||||
}
|
||||
|
||||
// DebitGiftTarget 是一笔批量送礼中的单个接收方账务快照。
|
||||
@ -76,6 +82,8 @@ message BatchDebitGiftRequest {
|
||||
int64 region_id = 8;
|
||||
int64 sender_region_id = 9;
|
||||
repeated DebitGiftTarget targets = 10;
|
||||
string entitlement_id = 11;
|
||||
string charge_source = 12;
|
||||
}
|
||||
|
||||
// BatchDebitGiftReceipt 保留每个目标独立交易的回执,房间事件按它逐目标落事实。
|
||||
@ -1892,6 +1900,40 @@ message CronBatchResponse {
|
||||
bool has_more = 5;
|
||||
}
|
||||
|
||||
message ResourceShopPurchaseOrder {
|
||||
string app_code = 1;
|
||||
string order_id = 2;
|
||||
string command_id = 3;
|
||||
int64 user_id = 4;
|
||||
int64 shop_item_id = 5;
|
||||
int64 resource_id = 6;
|
||||
Resource resource = 7;
|
||||
int32 duration_days = 8;
|
||||
int64 price_coin = 9;
|
||||
string status = 10;
|
||||
string wallet_transaction_id = 11;
|
||||
string resource_grant_id = 12;
|
||||
string entitlement_id = 13;
|
||||
int64 created_at_ms = 14;
|
||||
int64 updated_at_ms = 15;
|
||||
}
|
||||
|
||||
message ListResourceShopPurchaseOrdersRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
string resource_type = 4;
|
||||
string status = 5;
|
||||
string keyword = 6;
|
||||
int32 page = 7;
|
||||
int32 page_size = 8;
|
||||
}
|
||||
|
||||
message ListResourceShopPurchaseOrdersResponse {
|
||||
repeated ResourceShopPurchaseOrder orders = 1;
|
||||
int64 total = 2;
|
||||
}
|
||||
|
||||
// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。
|
||||
service WalletCronService {
|
||||
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
@ -1940,6 +1982,7 @@ service WalletService {
|
||||
rpc ListResourceShopItems(ListResourceShopItemsRequest) returns (ListResourceShopItemsResponse);
|
||||
rpc UpsertResourceShopItems(UpsertResourceShopItemsRequest) returns (UpsertResourceShopItemsResponse);
|
||||
rpc SetResourceShopItemStatus(SetResourceShopItemStatusRequest) returns (ResourceShopItemResponse);
|
||||
rpc ListResourceShopPurchaseOrders(ListResourceShopPurchaseOrdersRequest) returns (ListResourceShopPurchaseOrdersResponse);
|
||||
rpc PurchaseResourceShopItem(PurchaseResourceShopItemRequest) returns (PurchaseResourceShopItemResponse);
|
||||
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
|
||||
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
|
||||
|
||||
@ -240,6 +240,7 @@ const (
|
||||
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_ListResourceShopPurchaseOrders_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopPurchaseOrders"
|
||||
WalletService_PurchaseResourceShopItem_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseResourceShopItem"
|
||||
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
|
||||
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
|
||||
@ -331,6 +332,7 @@ type WalletServiceClient interface {
|
||||
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)
|
||||
ListResourceShopPurchaseOrders(ctx context.Context, in *ListResourceShopPurchaseOrdersRequest, opts ...grpc.CallOption) (*ListResourceShopPurchaseOrdersResponse, error)
|
||||
PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error)
|
||||
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
|
||||
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
|
||||
@ -775,6 +777,16 @@ func (c *walletServiceClient) SetResourceShopItemStatus(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListResourceShopPurchaseOrders(ctx context.Context, in *ListResourceShopPurchaseOrdersRequest, opts ...grpc.CallOption) (*ListResourceShopPurchaseOrdersResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListResourceShopPurchaseOrdersResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ListResourceShopPurchaseOrders_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(PurchaseResourceShopItemResponse)
|
||||
@ -1260,6 +1272,7 @@ type WalletServiceServer interface {
|
||||
ListResourceShopItems(context.Context, *ListResourceShopItemsRequest) (*ListResourceShopItemsResponse, error)
|
||||
UpsertResourceShopItems(context.Context, *UpsertResourceShopItemsRequest) (*UpsertResourceShopItemsResponse, error)
|
||||
SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error)
|
||||
ListResourceShopPurchaseOrders(context.Context, *ListResourceShopPurchaseOrdersRequest) (*ListResourceShopPurchaseOrdersResponse, error)
|
||||
PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error)
|
||||
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
|
||||
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
|
||||
@ -1431,6 +1444,9 @@ func (UnimplementedWalletServiceServer) UpsertResourceShopItems(context.Context,
|
||||
func (UnimplementedWalletServiceServer) SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetResourceShopItemStatus not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListResourceShopPurchaseOrders(context.Context, *ListResourceShopPurchaseOrdersRequest) (*ListResourceShopPurchaseOrdersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListResourceShopPurchaseOrders not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method PurchaseResourceShopItem not implemented")
|
||||
}
|
||||
@ -2286,6 +2302,24 @@ func _WalletService_SetResourceShopItemStatus_Handler(srv interface{}, ctx conte
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListResourceShopPurchaseOrders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListResourceShopPurchaseOrdersRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ListResourceShopPurchaseOrders(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ListResourceShopPurchaseOrders_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ListResourceShopPurchaseOrders(ctx, req.(*ListResourceShopPurchaseOrdersRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_PurchaseResourceShopItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(PurchaseResourceShopItemRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -3241,6 +3275,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "SetResourceShopItemStatus",
|
||||
Handler: _WalletService_SetResourceShopItemStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListResourceShopPurchaseOrders",
|
||||
Handler: _WalletService_ListResourceShopPurchaseOrders_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "PurchaseResourceShopItem",
|
||||
Handler: _WalletService_PurchaseResourceShopItem_Handler,
|
||||
|
||||
@ -187,11 +187,18 @@ type HumanRoomRobotConfig struct {
|
||||
LuckyPauseMaxMS int64
|
||||
MaxGiftSenders int64
|
||||
AllowedOwnerIDs []string
|
||||
CountryLimitEnabled bool
|
||||
CountryRules []HumanRoomRobotCountryRule
|
||||
UpdatedByAdminID uint64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
type HumanRoomRobotCountryRule struct {
|
||||
CountryCode string
|
||||
MaxRoomCount int32
|
||||
}
|
||||
|
||||
type UpdateHumanRoomRobotConfigRequest struct {
|
||||
Config HumanRoomRobotConfig
|
||||
AdminID uint64
|
||||
@ -748,10 +755,17 @@ func humanRoomRobotConfigFromProto(input *roomv1.AdminHumanRoomRobotConfig) Huma
|
||||
LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(),
|
||||
MaxGiftSenders: input.GetMaxGiftSenders(),
|
||||
AllowedOwnerIDs: append([]string(nil), input.GetAllowedOwnerIds()...),
|
||||
CountryLimitEnabled: input.GetCountryLimitEnabled(),
|
||||
UpdatedByAdminID: input.GetUpdatedByAdminId(),
|
||||
CreatedAtMS: input.GetCreatedAtMs(),
|
||||
UpdatedAtMS: input.GetUpdatedAtMs(),
|
||||
}
|
||||
for _, rule := range input.GetCountryRules() {
|
||||
config.CountryRules = append(config.CountryRules, HumanRoomRobotCountryRule{
|
||||
CountryCode: rule.GetCountryCode(),
|
||||
MaxRoomCount: rule.GetMaxRoomCount(),
|
||||
})
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
@ -779,10 +793,17 @@ func humanRoomRobotConfigToProto(input HumanRoomRobotConfig) *roomv1.AdminHumanR
|
||||
LuckyPauseMaxMs: input.LuckyPauseMaxMS,
|
||||
MaxGiftSenders: input.MaxGiftSenders,
|
||||
AllowedOwnerIds: append([]string(nil), input.AllowedOwnerIDs...),
|
||||
CountryLimitEnabled: input.CountryLimitEnabled,
|
||||
UpdatedByAdminId: input.UpdatedByAdminID,
|
||||
CreatedAtMs: input.CreatedAtMS,
|
||||
UpdatedAtMs: input.UpdatedAtMS,
|
||||
}
|
||||
for _, rule := range input.CountryRules {
|
||||
out.CountryRules = append(out.CountryRules, &roomv1.AdminHumanRoomRobotCountryRule{
|
||||
CountryCode: rule.CountryCode,
|
||||
MaxRoomCount: rule.MaxRoomCount,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@ type Client interface {
|
||||
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)
|
||||
ListResourceShopPurchaseOrders(ctx context.Context, req *walletv1.ListResourceShopPurchaseOrdersRequest) (*walletv1.ListResourceShopPurchaseOrdersResponse, 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)
|
||||
@ -158,6 +159,10 @@ func (c *GRPCClient) SetResourceShopItemStatus(ctx context.Context, req *walletv
|
||||
return c.client.SetResourceShopItemStatus(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListResourceShopPurchaseOrders(ctx context.Context, req *walletv1.ListResourceShopPurchaseOrdersRequest) (*walletv1.ListResourceShopPurchaseOrdersResponse, error) {
|
||||
return c.client.ListResourceShopPurchaseOrders(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
|
||||
return c.client.AdminCreditAsset(ctx, req)
|
||||
}
|
||||
|
||||
@ -35,6 +35,32 @@ func (h *Handler) GetConfig(c *gin.Context) {
|
||||
response.OK(c, config)
|
||||
}
|
||||
|
||||
func (h *Handler) ListRelationships(c *gin.Context) {
|
||||
items, err := h.service.ListRelationships(c.Request.Context(), appctx.FromContext(c.Request.Context()), cpListOptionsFromContext(c))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidArgument) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ServerError(c, "获取用户关系列表失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, items)
|
||||
}
|
||||
|
||||
func (h *Handler) ListApplications(c *gin.Context) {
|
||||
items, err := h.service.ListApplications(c.Request.Context(), appctx.FromContext(c.Request.Context()), cpListOptionsFromContext(c))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidArgument) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ServerError(c, "获取CP申请列表失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, items)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateConfig(c *gin.Context) {
|
||||
var req updateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@ -57,3 +83,22 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
|
||||
fmt.Sprintf("relations=%d", len(config.Relations)))
|
||||
response.OK(c, config)
|
||||
}
|
||||
|
||||
func cpListOptionsFromContext(c *gin.Context) cpListOptions {
|
||||
options := shared.ListOptions(c)
|
||||
return cpListOptions{
|
||||
Page: options.Page,
|
||||
PageSize: options.PageSize,
|
||||
Status: options.Status,
|
||||
RelationType: firstQuery(c, "relation_type", "relationType"),
|
||||
}
|
||||
}
|
||||
|
||||
func firstQuery(c *gin.Context, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := c.Query(key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
354
server/admin/internal/modules/cprelation/list.go
Normal file
354
server/admin/internal/modules/cprelation/list.go
Normal file
@ -0,0 +1,354 @@
|
||||
package cprelation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
cpListDefaultPageSize = 50
|
||||
cpListMaxPageSize = 100
|
||||
dayMS = int64(24 * 60 * 60 * 1000)
|
||||
)
|
||||
|
||||
var (
|
||||
relationshipStatuses = []string{"active", "ended"}
|
||||
applicationStatuses = []string{"pending", "accepted", "rejected", "expired", "blocked"}
|
||||
)
|
||||
|
||||
type cpListOptions struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Status string
|
||||
RelationType string
|
||||
}
|
||||
|
||||
type cpUserDTO struct {
|
||||
UserID int64 `json:"userId,string"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
type cpGiftSnapshotDTO struct {
|
||||
GiftID string `json:"giftId"`
|
||||
GiftName string `json:"giftName"`
|
||||
GiftIconURL string `json:"giftIconUrl"`
|
||||
GiftAnimationURL string `json:"giftAnimationUrl"`
|
||||
GiftCount int64 `json:"giftCount"`
|
||||
GiftValue int64 `json:"giftValue"`
|
||||
BillingReceiptID string `json:"billingReceiptId"`
|
||||
}
|
||||
|
||||
type cpRelationshipDTO struct {
|
||||
RelationshipID string `json:"relationshipId"`
|
||||
RelationType string `json:"relationType"`
|
||||
Status string `json:"status"`
|
||||
UserA cpUserDTO `json:"userA"`
|
||||
UserB cpUserDTO `json:"userB"`
|
||||
IntimacyValue int64 `json:"intimacyValue"`
|
||||
Level int32 `json:"level"`
|
||||
DurationDays int64 `json:"durationDays"`
|
||||
Gift *cpGiftSnapshotDTO `json:"gift,omitempty"`
|
||||
FormedAtMS int64 `json:"formedAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
EndedAtMS int64 `json:"endedAtMs"`
|
||||
}
|
||||
|
||||
type cpRelationshipPageDTO struct {
|
||||
Items []cpRelationshipDTO `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int64 `json:"total"`
|
||||
ServerTimeMS int64 `json:"serverTimeMs"`
|
||||
}
|
||||
|
||||
type cpApplicationDTO struct {
|
||||
ApplicationID string `json:"applicationId"`
|
||||
RelationType string `json:"relationType"`
|
||||
Status string `json:"status"`
|
||||
Requester cpUserDTO `json:"requester"`
|
||||
Target cpUserDTO `json:"target"`
|
||||
Gift cpGiftSnapshotDTO `json:"gift"`
|
||||
RoomID string `json:"roomId"`
|
||||
RoomRegionID int64 `json:"roomRegionId"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
ExpiresAtMS int64 `json:"expiresAtMs"`
|
||||
DecidedAtMS int64 `json:"decidedAtMs"`
|
||||
}
|
||||
|
||||
type cpApplicationPageDTO struct {
|
||||
Items []cpApplicationDTO `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int64 `json:"total"`
|
||||
ServerTimeMS int64 `json:"serverTimeMs"`
|
||||
}
|
||||
|
||||
// ListRelationships 给管理端按全站维度分页读取关系;这里不能复用 App 侧按单用户读取的仓储,否则会漏掉“所有用户关系”这个运营视角。
|
||||
func (s *Service) ListRelationships(ctx context.Context, appCode string, options cpListOptions) (cpRelationshipPageDTO, error) {
|
||||
options = normalizeCPListOptions(options)
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
whereSQL, args, err := relationshipListWhere(appCode, options)
|
||||
if err != nil {
|
||||
return cpRelationshipPageDTO{}, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return cpRelationshipPageDTO{}, errorsUserDBNotConfigured()
|
||||
}
|
||||
total, err := s.countRows(ctx, "SELECT COUNT(*) FROM user_cp_relationships r WHERE "+whereSQL, args...)
|
||||
if err != nil {
|
||||
return cpRelationshipPageDTO{}, err
|
||||
}
|
||||
queryArgs := append(append([]any{}, args...), options.PageSize, cpListOffset(options))
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
r.relationship_id, r.relation_type, r.status,
|
||||
r.user_a_id, COALESCE(user_a.current_display_user_id, ''), COALESCE(user_a.username, ''), COALESCE(user_a.avatar, ''),
|
||||
r.user_b_id, COALESCE(user_b.current_display_user_id, ''), COALESCE(user_b.username, ''), COALESCE(user_b.avatar, ''),
|
||||
r.intimacy_value, r.level_no, r.formed_at_ms, r.updated_at_ms, r.ended_at_ms,
|
||||
COALESCE(source.gift_id, ''), COALESCE(source.gift_name, ''), COALESCE(source.gift_icon_url, ''),
|
||||
COALESCE(source.gift_animation_url, ''), COALESCE(source.gift_count, 0), COALESCE(source.gift_value, 0),
|
||||
COALESCE(source.billing_receipt_id, '')
|
||||
FROM user_cp_relationships r
|
||||
LEFT JOIN users user_a ON user_a.app_code = r.app_code AND user_a.user_id = r.user_a_id
|
||||
LEFT JOIN users user_b ON user_b.app_code = r.app_code AND user_b.user_id = r.user_b_id
|
||||
LEFT JOIN user_cp_applications source ON source.app_code = r.app_code AND source.application_id = r.source_application_id
|
||||
WHERE `+whereSQL+`
|
||||
ORDER BY r.formed_at_ms DESC, r.relationship_id DESC
|
||||
LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return cpRelationshipPageDTO{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := scanRelationshipRows(rows, nowMS)
|
||||
if err != nil {
|
||||
return cpRelationshipPageDTO{}, err
|
||||
}
|
||||
return cpRelationshipPageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total, ServerTimeMS: nowMS}, nil
|
||||
}
|
||||
|
||||
// ListApplications 读取所有用户的申请记录;读取前先沿用 App 侧懒过期语义,避免后台把已过期申请继续展示为 pending。
|
||||
func (s *Service) ListApplications(ctx context.Context, appCode string, options cpListOptions) (cpApplicationPageDTO, error) {
|
||||
options = normalizeCPListOptions(options)
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
whereSQL, args, err := applicationListWhere(appCode, options)
|
||||
if err != nil {
|
||||
return cpApplicationPageDTO{}, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return cpApplicationPageDTO{}, errorsUserDBNotConfigured()
|
||||
}
|
||||
if err := s.expirePendingApplications(ctx, appCode, nowMS); err != nil {
|
||||
return cpApplicationPageDTO{}, err
|
||||
}
|
||||
total, err := s.countRows(ctx, "SELECT COUNT(*) FROM user_cp_applications a WHERE "+whereSQL, args...)
|
||||
if err != nil {
|
||||
return cpApplicationPageDTO{}, err
|
||||
}
|
||||
queryArgs := append(append([]any{}, args...), options.PageSize, cpListOffset(options))
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
a.application_id, a.relation_type, a.status,
|
||||
a.requester_user_id, COALESCE(requester.current_display_user_id, ''), COALESCE(requester.username, ''), COALESCE(requester.avatar, ''),
|
||||
a.target_user_id, COALESCE(target.current_display_user_id, ''), COALESCE(target.username, ''), COALESCE(target.avatar, ''),
|
||||
a.gift_id, a.gift_name, a.gift_icon_url, a.gift_animation_url, a.gift_count, a.gift_value, a.billing_receipt_id,
|
||||
a.room_id, a.room_region_id, a.created_at_ms, a.updated_at_ms, a.expires_at_ms, a.decided_at_ms
|
||||
FROM user_cp_applications a
|
||||
LEFT JOIN users requester ON requester.app_code = a.app_code AND requester.user_id = a.requester_user_id
|
||||
LEFT JOIN users target ON target.app_code = a.app_code AND target.user_id = a.target_user_id
|
||||
WHERE `+whereSQL+`
|
||||
ORDER BY a.created_at_ms DESC, a.application_id DESC
|
||||
LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return cpApplicationPageDTO{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := scanApplicationRows(rows)
|
||||
if err != nil {
|
||||
return cpApplicationPageDTO{}, err
|
||||
}
|
||||
return cpApplicationPageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total, ServerTimeMS: nowMS}, nil
|
||||
}
|
||||
|
||||
func (s *Service) expirePendingApplications(ctx context.Context, appCode string, nowMS int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE user_cp_applications
|
||||
SET status = 'expired', updated_at_ms = ?
|
||||
WHERE app_code = ? AND status = 'pending' AND expires_at_ms > 0 AND expires_at_ms <= ?`,
|
||||
nowMS, appCode, nowMS,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanRelationshipRows(rows *sql.Rows, nowMS int64) ([]cpRelationshipDTO, error) {
|
||||
items := []cpRelationshipDTO{}
|
||||
for rows.Next() {
|
||||
var item cpRelationshipDTO
|
||||
var gift cpGiftSnapshotDTO
|
||||
if err := rows.Scan(
|
||||
&item.RelationshipID, &item.RelationType, &item.Status,
|
||||
&item.UserA.UserID, &item.UserA.DisplayUserID, &item.UserA.Username, &item.UserA.Avatar,
|
||||
&item.UserB.UserID, &item.UserB.DisplayUserID, &item.UserB.Username, &item.UserB.Avatar,
|
||||
&item.IntimacyValue, &item.Level, &item.FormedAtMS, &item.UpdatedAtMS, &item.EndedAtMS,
|
||||
&gift.GiftID, &gift.GiftName, &gift.GiftIconURL, &gift.GiftAnimationURL, &gift.GiftCount, &gift.GiftValue, &gift.BillingReceiptID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.UserA = normalizeCPUser(item.UserA)
|
||||
item.UserB = normalizeCPUser(item.UserB)
|
||||
item.DurationDays = relationshipDurationDays(item.FormedAtMS, item.EndedAtMS, nowMS)
|
||||
if !gift.empty() {
|
||||
item.Gift = &gift
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func scanApplicationRows(rows *sql.Rows) ([]cpApplicationDTO, error) {
|
||||
items := []cpApplicationDTO{}
|
||||
for rows.Next() {
|
||||
var item cpApplicationDTO
|
||||
if err := rows.Scan(
|
||||
&item.ApplicationID, &item.RelationType, &item.Status,
|
||||
&item.Requester.UserID, &item.Requester.DisplayUserID, &item.Requester.Username, &item.Requester.Avatar,
|
||||
&item.Target.UserID, &item.Target.DisplayUserID, &item.Target.Username, &item.Target.Avatar,
|
||||
&item.Gift.GiftID, &item.Gift.GiftName, &item.Gift.GiftIconURL, &item.Gift.GiftAnimationURL, &item.Gift.GiftCount, &item.Gift.GiftValue, &item.Gift.BillingReceiptID,
|
||||
&item.RoomID, &item.RoomRegionID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.ExpiresAtMS, &item.DecidedAtMS,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Requester = normalizeCPUser(item.Requester)
|
||||
item.Target = normalizeCPUser(item.Target)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func relationshipListWhere(appCode string, options cpListOptions) (string, []any, error) {
|
||||
where := []string{"r.app_code = ?"}
|
||||
args := []any{appCode}
|
||||
if err := appendRelationFilter(&where, &args, "r", options.RelationType); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := appendStatusFilter(&where, &args, "r", options.Status, relationshipStatuses, "关系状态不正确"); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return strings.Join(where, " AND "), args, nil
|
||||
}
|
||||
|
||||
func applicationListWhere(appCode string, options cpListOptions) (string, []any, error) {
|
||||
where := []string{"a.app_code = ?"}
|
||||
args := []any{appCode}
|
||||
if err := appendRelationFilter(&where, &args, "a", options.RelationType); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := appendStatusFilter(&where, &args, "a", options.Status, applicationStatuses, "申请状态不正确"); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return strings.Join(where, " AND "), args, nil
|
||||
}
|
||||
|
||||
func appendRelationFilter(where *[]string, args *[]any, alias string, value string) error {
|
||||
relationType := normalizeCPFilterValue(value)
|
||||
if relationType == "" {
|
||||
return nil
|
||||
}
|
||||
if !slices.Contains(relationTypes, relationType) {
|
||||
return fmt.Errorf("%w: 关系类型不正确", ErrInvalidArgument)
|
||||
}
|
||||
*where = append(*where, alias+".relation_type = ?")
|
||||
*args = append(*args, relationType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendStatusFilter(where *[]string, args *[]any, alias string, value string, allowed []string, message string) error {
|
||||
status := normalizeCPFilterValue(value)
|
||||
if status == "" {
|
||||
return nil
|
||||
}
|
||||
if !slices.Contains(allowed, status) {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidArgument, message)
|
||||
}
|
||||
*where = append(*where, alias+".status = ?")
|
||||
*args = append(*args, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeCPListOptions(options cpListOptions) cpListOptions {
|
||||
if options.Page < 1 {
|
||||
options.Page = 1
|
||||
}
|
||||
if options.PageSize < 1 {
|
||||
options.PageSize = cpListDefaultPageSize
|
||||
}
|
||||
if options.PageSize > cpListMaxPageSize {
|
||||
options.PageSize = cpListMaxPageSize
|
||||
}
|
||||
options.Status = normalizeCPFilterValue(options.Status)
|
||||
options.RelationType = normalizeCPFilterValue(options.RelationType)
|
||||
return options
|
||||
}
|
||||
|
||||
func normalizeCPFilterValue(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "all" {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func cpListOffset(options cpListOptions) int {
|
||||
return (options.Page - 1) * options.PageSize
|
||||
}
|
||||
|
||||
func (s *Service) countRows(ctx context.Context, query string, args ...any) (int64, error) {
|
||||
var total int64
|
||||
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func normalizeCPUser(user cpUserDTO) cpUserDTO {
|
||||
if strings.TrimSpace(user.DisplayUserID) == "" && user.UserID > 0 {
|
||||
user.DisplayUserID = strconv.FormatInt(user.UserID, 10)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func relationshipDurationDays(formedAtMS int64, endedAtMS int64, nowMS int64) int64 {
|
||||
if formedAtMS <= 0 {
|
||||
return 0
|
||||
}
|
||||
endAtMS := nowMS
|
||||
if endedAtMS > 0 {
|
||||
endAtMS = endedAtMS
|
||||
}
|
||||
if endAtMS < formedAtMS {
|
||||
return 0
|
||||
}
|
||||
// 运营侧“天数”按自然关系天展示:刚建立也算第 1 天,避免表格出现 0 天这种难以理解的状态。
|
||||
return ((endAtMS - formedAtMS) / dayMS) + 1
|
||||
}
|
||||
|
||||
func (gift cpGiftSnapshotDTO) empty() bool {
|
||||
return strings.TrimSpace(gift.GiftID) == "" &&
|
||||
strings.TrimSpace(gift.GiftName) == "" &&
|
||||
strings.TrimSpace(gift.GiftIconURL) == "" &&
|
||||
strings.TrimSpace(gift.GiftAnimationURL) == "" &&
|
||||
strings.TrimSpace(gift.BillingReceiptID) == "" &&
|
||||
gift.GiftCount == 0 &&
|
||||
gift.GiftValue == 0
|
||||
}
|
||||
|
||||
func errorsUserDBNotConfigured() error {
|
||||
return fmt.Errorf("user db is not configured")
|
||||
}
|
||||
@ -11,5 +11,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
return
|
||||
}
|
||||
protected.GET("/admin/activity/cp/config", middleware.RequirePermission("cp-config:view"), h.GetConfig)
|
||||
protected.GET("/admin/activity/cp/relationships", middleware.RequirePermission("cp-config:view"), h.ListRelationships)
|
||||
protected.GET("/admin/activity/cp/applications", middleware.RequirePermission("cp-config:view"), h.ListApplications)
|
||||
protected.PUT("/admin/activity/cp/config", middleware.RequirePermission("cp-config:update"), h.UpdateConfig)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package cprelation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -44,6 +45,92 @@ func TestNormalizeRelationsRequiresIncreasingLevelThresholds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCPListOptionsBoundsPageSize(t *testing.T) {
|
||||
options := normalizeCPListOptions(cpListOptions{Page: -3, PageSize: 1000, Status: "ALL", RelationType: " Brother "})
|
||||
if options.Page != 1 {
|
||||
t.Fatalf("page = %d, want 1", options.Page)
|
||||
}
|
||||
if options.PageSize != cpListMaxPageSize {
|
||||
t.Fatalf("page size = %d, want max %d", options.PageSize, cpListMaxPageSize)
|
||||
}
|
||||
if options.Status != "" {
|
||||
t.Fatalf("status = %q, want empty all filter", options.Status)
|
||||
}
|
||||
if options.RelationType != relationTypeBrother {
|
||||
t.Fatalf("relation type = %q, want brother", options.RelationType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationshipListWhereValidatesFilters(t *testing.T) {
|
||||
whereSQL, args, err := relationshipListWhere("lalu", cpListOptions{RelationType: relationTypeSister, Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatalf("relationshipListWhere() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(whereSQL, "r.app_code = ?") ||
|
||||
!strings.Contains(whereSQL, "r.relation_type = ?") ||
|
||||
!strings.Contains(whereSQL, "r.status = ?") {
|
||||
t.Fatalf("relationship where sql missing filters: %s", whereSQL)
|
||||
}
|
||||
wantArgs := []any{"lalu", relationTypeSister, statusActive}
|
||||
if len(args) != len(wantArgs) {
|
||||
t.Fatalf("args len = %d, want %d: %#v", len(args), len(wantArgs), args)
|
||||
}
|
||||
for index := range wantArgs {
|
||||
if args[index] != wantArgs[index] {
|
||||
t.Fatalf("args[%d] = %#v, want %#v", index, args[index], wantArgs[index])
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, err := relationshipListWhere("lalu", cpListOptions{RelationType: "enemy"}); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("invalid relation type error = %v, want ErrInvalidArgument", err)
|
||||
}
|
||||
if _, _, err := relationshipListWhere("lalu", cpListOptions{Status: "pending"}); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("invalid relationship status error = %v, want ErrInvalidArgument", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationListWhereValidatesApplicationStatuses(t *testing.T) {
|
||||
whereSQL, args, err := applicationListWhere("lalu", cpListOptions{RelationType: relationTypeCP, Status: "pending"})
|
||||
if err != nil {
|
||||
t.Fatalf("applicationListWhere() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(whereSQL, "a.relation_type = ?") || !strings.Contains(whereSQL, "a.status = ?") {
|
||||
t.Fatalf("application where sql missing filters: %s", whereSQL)
|
||||
}
|
||||
if len(args) != 3 || args[0] != "lalu" || args[1] != relationTypeCP || args[2] != "pending" {
|
||||
t.Fatalf("application args mismatch: %#v", args)
|
||||
}
|
||||
if _, _, err := applicationListWhere("lalu", cpListOptions{Status: "active"}); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("invalid application status error = %v, want ErrInvalidArgument", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationshipDurationDaysIsInclusive(t *testing.T) {
|
||||
const formedAtMS int64 = 1700000000000
|
||||
if got := relationshipDurationDays(formedAtMS, 0, formedAtMS); got != 1 {
|
||||
t.Fatalf("same moment duration = %d, want 1", got)
|
||||
}
|
||||
if got := relationshipDurationDays(formedAtMS, 0, formedAtMS+dayMS*2+1); got != 3 {
|
||||
t.Fatalf("third day duration = %d, want 3", got)
|
||||
}
|
||||
if got := relationshipDurationDays(formedAtMS, formedAtMS+dayMS, formedAtMS+dayMS*9); got != 2 {
|
||||
t.Fatalf("ended relationship duration = %d, want 2", got)
|
||||
}
|
||||
if got := relationshipDurationDays(formedAtMS, formedAtMS-1, formedAtMS+dayMS); got != 0 {
|
||||
t.Fatalf("invalid ended duration = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPGiftSnapshotEmpty(t *testing.T) {
|
||||
if !((&cpGiftSnapshotDTO{}).empty()) {
|
||||
t.Fatalf("zero gift should be empty")
|
||||
}
|
||||
gift := cpGiftSnapshotDTO{GiftValue: 100}
|
||||
if gift.empty() {
|
||||
t.Fatalf("gift with value should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func relationInput(relationType string, maxCount int32) relationDTO {
|
||||
return relationDTO{
|
||||
RelationType: relationType,
|
||||
|
||||
@ -145,10 +145,13 @@ type grantDTO struct {
|
||||
}
|
||||
|
||||
type grantUserDTO struct {
|
||||
UserID int64 `json:"userId,string"`
|
||||
DisplayUserID string `json:"displayUserId,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
UserID int64 `json:"userId,string"`
|
||||
DisplayUserID string `json:"displayUserId,omitempty"`
|
||||
DefaultDisplayUserID string `json:"defaultDisplayUserId,omitempty"`
|
||||
PrettyDisplayUserID string `json:"prettyDisplayUserId,omitempty"`
|
||||
PrettyID string `json:"prettyId,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
type operatorDTO struct {
|
||||
@ -178,6 +181,25 @@ type resourceShopItemDTO struct {
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type resourceShopPurchaseOrderDTO struct {
|
||||
AppCode string `json:"appCode"`
|
||||
OrderID string `json:"orderId"`
|
||||
CommandID string `json:"commandId"`
|
||||
UserID int64 `json:"userId,string"`
|
||||
User *grantUserDTO `json:"user,omitempty"`
|
||||
ShopItemID int64 `json:"shopItemId"`
|
||||
ResourceID int64 `json:"resourceId"`
|
||||
Resource resourceDTO `json:"resource"`
|
||||
DurationDays int32 `json:"durationDays"`
|
||||
PriceCoin int64 `json:"priceCoin"`
|
||||
Status string `json:"status"`
|
||||
WalletTransactionID string `json:"walletTransactionId"`
|
||||
ResourceGrantID string `json:"resourceGrantId"`
|
||||
EntitlementID string `json:"entitlementId"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func resourceFromProto(item *walletv1.Resource) resourceDTO {
|
||||
if item == nil {
|
||||
return resourceDTO{}
|
||||
@ -279,6 +301,29 @@ func resourceShopItemFromProto(item *walletv1.ResourceShopItem) resourceShopItem
|
||||
}
|
||||
}
|
||||
|
||||
func resourceShopPurchaseOrderFromProto(item *walletv1.ResourceShopPurchaseOrder) resourceShopPurchaseOrderDTO {
|
||||
if item == nil {
|
||||
return resourceShopPurchaseOrderDTO{}
|
||||
}
|
||||
return resourceShopPurchaseOrderDTO{
|
||||
AppCode: item.GetAppCode(),
|
||||
OrderID: item.GetOrderId(),
|
||||
CommandID: item.GetCommandId(),
|
||||
UserID: item.GetUserId(),
|
||||
ShopItemID: item.GetShopItemId(),
|
||||
ResourceID: item.GetResourceId(),
|
||||
Resource: resourceFromProto(item.GetResource()),
|
||||
DurationDays: item.GetDurationDays(),
|
||||
PriceCoin: item.GetPriceCoin(),
|
||||
Status: item.GetStatus(),
|
||||
WalletTransactionID: item.GetWalletTransactionId(),
|
||||
ResourceGrantID: item.GetResourceGrantId(),
|
||||
EntitlementID: item.GetEntitlementId(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func giftFromProto(gift *walletv1.GiftConfig) giftDTO {
|
||||
if gift == nil {
|
||||
return giftDTO{}
|
||||
|
||||
@ -695,6 +695,55 @@ func (h *Handler) ListResourceShopItems(c *gin.Context) {
|
||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||
}
|
||||
|
||||
func (h *Handler) ListResourceShopPurchaseOrders(c *gin.Context) {
|
||||
options := shared.ListOptions(c)
|
||||
appCode := appctx.FromContext(c.Request.Context())
|
||||
userID, ok := optionalInt64Query(c, "user_id", "userId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userKeyword := strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user"))
|
||||
if userID == 0 && userKeyword != "" {
|
||||
resolvedUserID, filtered, err := h.resolveResourceShopPurchaseUserID(c.Request.Context(), appCode, userKeyword)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
response.OK(c, response.Page{Items: []resourceShopPurchaseOrderDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
|
||||
return
|
||||
}
|
||||
response.ServerError(c, "查询购买用户失败")
|
||||
return
|
||||
}
|
||||
if filtered {
|
||||
userID = resolvedUserID
|
||||
}
|
||||
}
|
||||
ctx, cancel := h.walletRequestContext(c)
|
||||
defer cancel()
|
||||
resp, err := h.wallet.ListResourceShopPurchaseOrders(ctx, &walletv1.ListResourceShopPurchaseOrdersRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appCode,
|
||||
UserId: userID,
|
||||
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([]resourceShopPurchaseOrderDTO, 0, len(resp.GetOrders()))
|
||||
for _, item := range resp.GetOrders() {
|
||||
items = append(items, resourceShopPurchaseOrderFromProto(item))
|
||||
}
|
||||
if err := h.enrichResourceShopPurchaseUsers(c.Request.Context(), items); err != nil {
|
||||
response.ServerError(c, "获取购买用户信息失败")
|
||||
return
|
||||
}
|
||||
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 {
|
||||
|
||||
@ -2,10 +2,13 @@ package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -48,10 +51,36 @@ func (h *Handler) queryGrantTargetUsers(ctx context.Context, ids []int64) (map[i
|
||||
if len(ids) == 0 || h.userDB == nil {
|
||||
return out, nil
|
||||
}
|
||||
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
|
||||
nowMs := time.Now().UnixMilli()
|
||||
args := append([]any{nowMs, nowMs, appctx.FromContext(ctx)}, int64Args(ids)...)
|
||||
rows, err := h.userDB.QueryContext(ctx, `
|
||||
SELECT user_id,
|
||||
current_display_user_id,
|
||||
COALESCE(default_display_user_id, ''),
|
||||
COALESCE((
|
||||
SELECT lease.display_user_id
|
||||
FROM pretty_display_user_id_leases lease
|
||||
WHERE lease.app_code = users.app_code
|
||||
AND lease.user_id = users.user_id
|
||||
AND lease.status = 'active'
|
||||
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
|
||||
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
|
||||
LIMIT 1
|
||||
), ''),
|
||||
COALESCE((
|
||||
SELECT pdi.pretty_id
|
||||
FROM pretty_display_user_id_leases lease
|
||||
JOIN pretty_display_ids pdi
|
||||
ON pdi.app_code = lease.app_code
|
||||
AND pdi.assigned_lease_id = lease.lease_id
|
||||
AND pdi.status = 'assigned'
|
||||
WHERE lease.app_code = users.app_code
|
||||
AND lease.user_id = users.user_id
|
||||
AND lease.status = 'active'
|
||||
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
|
||||
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
|
||||
LIMIT 1
|
||||
), ''),
|
||||
COALESCE(username, ''),
|
||||
COALESCE(avatar, '')
|
||||
FROM users
|
||||
@ -64,7 +93,7 @@ func (h *Handler) queryGrantTargetUsers(ctx context.Context, ids []int64) (map[i
|
||||
|
||||
for rows.Next() {
|
||||
var user grantUserDTO
|
||||
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
|
||||
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.DefaultDisplayUserID, &user.PrettyDisplayUserID, &user.PrettyID, &user.Username, &user.Avatar); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[user.UserID] = user
|
||||
@ -86,6 +115,79 @@ func applyGrantTargetUsers(grants []grantDTO, users map[int64]grantUserDTO) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) enrichResourceShopPurchaseUsers(ctx context.Context, orders []resourceShopPurchaseOrderDTO) error {
|
||||
ids := collectResourceShopPurchaseUserIDs(orders)
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
users := map[int64]grantUserDTO{}
|
||||
if h.userDB != nil {
|
||||
var err error
|
||||
users, err = h.queryGrantTargetUsers(ctx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for index := range orders {
|
||||
userID := orders[index].UserID
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if user, ok := users[userID]; ok {
|
||||
orders[index].User = &user
|
||||
continue
|
||||
}
|
||||
orders[index].User = &grantUserDTO{UserID: userID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectResourceShopPurchaseUserIDs(orders []resourceShopPurchaseOrderDTO) []int64 {
|
||||
seen := make(map[int64]struct{}, len(orders))
|
||||
ids := make([]int64, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
if order.UserID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[order.UserID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[order.UserID] = struct{}{}
|
||||
ids = append(ids, order.UserID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (h *Handler) resolveResourceShopPurchaseUserID(ctx context.Context, appCode string, keyword string) (int64, bool, error) {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
if h == nil || h.userDB == nil {
|
||||
return 0, false, fmt.Errorf("user mysql is not configured")
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
|
||||
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
|
||||
args := append([]any{appCode}, matchArgs...)
|
||||
args = append(args, orderArgs...)
|
||||
row := h.userDB.QueryRowContext(ctx, `
|
||||
SELECT u.user_id
|
||||
FROM users u
|
||||
WHERE u.app_code = ? AND `+matchSQL+`
|
||||
ORDER BY
|
||||
`+orderSQL+`,
|
||||
u.user_id DESC
|
||||
LIMIT 1`,
|
||||
args...,
|
||||
)
|
||||
var userID int64
|
||||
if err := row.Scan(&userID); err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
return userID, true, nil
|
||||
}
|
||||
|
||||
func (h *Handler) enrichGrantOperators(ctx context.Context, grants []grantDTO) error {
|
||||
// operator_user_id 在 wallet 发放事实里只保存稳定 ID;后台列表按来源补展示资料,避免把 admin 用户和 app 用户混成同一张表。
|
||||
adminIDs, managerIDs := collectGrantOperatorIDs(grants)
|
||||
|
||||
@ -46,6 +46,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.GET("/admin/resource-grants/target", middleware.RequirePermission("resource-grant:create"), h.LookupResourceGrantTarget)
|
||||
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)
|
||||
|
||||
protected.GET("/admin/resource-shop/purchase-orders", middleware.RequirePermission("resource-shop:view"), h.ListResourceShopPurchaseOrders)
|
||||
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)
|
||||
|
||||
@ -68,3 +68,16 @@ func TestHumanRoomRobotAllowedOwnerIDsAcceptsCommaStringAndArray(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanRoomRobotCountryRulesDecode(t *testing.T) {
|
||||
var req updateHumanRoomRobotConfigRequest
|
||||
if err := json.Unmarshal([]byte(`{"countryLimitEnabled":true,"countryRules":[{"countryCode":"sa","maxRoomCount":2}]}`), &req); err != nil {
|
||||
t.Fatalf("unmarshal human room robot country rules failed: %v", err)
|
||||
}
|
||||
if !req.CountryLimitEnabled || len(req.CountryRules) != 1 {
|
||||
t.Fatalf("country rules not decoded: %+v", req)
|
||||
}
|
||||
if req.CountryRules[0].CountryCode != "sa" || req.CountryRules[0].MaxRoomCount != 2 {
|
||||
t.Fatalf("country rule mismatch: %+v", req.CountryRules[0])
|
||||
}
|
||||
}
|
||||
|
||||
@ -139,27 +139,29 @@ type updateRoomWhitelistRequest struct {
|
||||
}
|
||||
|
||||
type updateHumanRoomRobotConfigRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
|
||||
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
|
||||
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
|
||||
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
|
||||
RobotStayMinMS int64 `json:"robotStayMinMs"`
|
||||
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
|
||||
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
|
||||
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
|
||||
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
|
||||
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
|
||||
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
|
||||
GiftIDs []string `json:"giftIds"`
|
||||
LuckyGiftIDs []string `json:"luckyGiftIds"`
|
||||
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
|
||||
LuckyComboMin int64 `json:"luckyComboMin"`
|
||||
LuckyComboMax int64 `json:"luckyComboMax"`
|
||||
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
|
||||
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
|
||||
MaxGiftSenders int64 `json:"maxGiftSenders"`
|
||||
AllowedOwnerIDs flexibleCommaStringList `json:"allowedOwnerIds"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
|
||||
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
|
||||
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
|
||||
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
|
||||
RobotStayMinMS int64 `json:"robotStayMinMs"`
|
||||
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
|
||||
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
|
||||
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
|
||||
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
|
||||
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
|
||||
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
|
||||
GiftIDs []string `json:"giftIds"`
|
||||
LuckyGiftIDs []string `json:"luckyGiftIds"`
|
||||
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
|
||||
LuckyComboMin int64 `json:"luckyComboMin"`
|
||||
LuckyComboMax int64 `json:"luckyComboMax"`
|
||||
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
|
||||
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
|
||||
MaxGiftSenders int64 `json:"maxGiftSenders"`
|
||||
AllowedOwnerIDs flexibleCommaStringList `json:"allowedOwnerIds"`
|
||||
CountryLimitEnabled bool `json:"countryLimitEnabled"`
|
||||
CountryRules []HumanRoomRobotCountryRule `json:"countryRules"`
|
||||
}
|
||||
|
||||
type flexibleJSONInt64StringSlice []string
|
||||
|
||||
@ -59,31 +59,38 @@ type AvailableRoomRobot struct {
|
||||
}
|
||||
|
||||
type HumanRoomRobotConfig struct {
|
||||
AppCode string `json:"appCode"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
|
||||
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
|
||||
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
|
||||
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
|
||||
RobotStayMinMS int64 `json:"robotStayMinMs"`
|
||||
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
|
||||
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
|
||||
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
|
||||
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
|
||||
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
|
||||
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
|
||||
GiftIDs []string `json:"giftIds"`
|
||||
LuckyGiftIDs []string `json:"luckyGiftIds"`
|
||||
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
|
||||
LuckyComboMin int64 `json:"luckyComboMin"`
|
||||
LuckyComboMax int64 `json:"luckyComboMax"`
|
||||
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
|
||||
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
|
||||
MaxGiftSenders int64 `json:"maxGiftSenders"`
|
||||
AllowedOwnerIDs []string `json:"allowedOwnerIds"`
|
||||
UpdatedByAdminID uint64 `json:"updatedByAdminId"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
AppCode string `json:"appCode"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
|
||||
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
|
||||
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
|
||||
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
|
||||
RobotStayMinMS int64 `json:"robotStayMinMs"`
|
||||
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
|
||||
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
|
||||
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
|
||||
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
|
||||
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
|
||||
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
|
||||
GiftIDs []string `json:"giftIds"`
|
||||
LuckyGiftIDs []string `json:"luckyGiftIds"`
|
||||
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
|
||||
LuckyComboMin int64 `json:"luckyComboMin"`
|
||||
LuckyComboMax int64 `json:"luckyComboMax"`
|
||||
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
|
||||
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
|
||||
MaxGiftSenders int64 `json:"maxGiftSenders"`
|
||||
AllowedOwnerIDs []string `json:"allowedOwnerIds"`
|
||||
CountryLimitEnabled bool `json:"countryLimitEnabled"`
|
||||
CountryRules []HumanRoomRobotCountryRule `json:"countryRules"`
|
||||
UpdatedByAdminID uint64 `json:"updatedByAdminId"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type HumanRoomRobotCountryRule struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
MaxRoomCount int32 `json:"maxRoomCount"`
|
||||
}
|
||||
|
||||
func (s *Service) ListRobotRooms(ctx context.Context, query robotRoomListQuery) ([]RobotRoom, int64, error) {
|
||||
@ -215,6 +222,8 @@ func (s *Service) UpdateHumanRoomRobotConfig(ctx context.Context, req updateHuma
|
||||
LuckyPauseMaxMS: req.LuckyPauseMaxMS,
|
||||
MaxGiftSenders: req.MaxGiftSenders,
|
||||
AllowedOwnerIDs: normalizeStringList([]string(req.AllowedOwnerIDs)),
|
||||
CountryLimitEnabled: req.CountryLimitEnabled,
|
||||
CountryRules: humanRoomRobotCountryRulesToClient(req.CountryRules),
|
||||
}
|
||||
saved, err := s.roomClient.UpdateHumanRoomRobotConfig(ctx, roomclient.UpdateHumanRoomRobotConfigRequest{
|
||||
Config: config,
|
||||
@ -286,6 +295,8 @@ func (s *Service) humanRoomRobotConfigFromClient(_ context.Context, config roomc
|
||||
LuckyPauseMaxMS: config.LuckyPauseMaxMS,
|
||||
MaxGiftSenders: config.MaxGiftSenders,
|
||||
AllowedOwnerIDs: append([]string(nil), config.AllowedOwnerIDs...),
|
||||
CountryLimitEnabled: config.CountryLimitEnabled,
|
||||
CountryRules: humanRoomRobotCountryRulesFromClient(config.CountryRules),
|
||||
UpdatedByAdminID: config.UpdatedByAdminID,
|
||||
CreatedAtMS: config.CreatedAtMS,
|
||||
UpdatedAtMS: config.UpdatedAtMS,
|
||||
@ -293,6 +304,28 @@ func (s *Service) humanRoomRobotConfigFromClient(_ context.Context, config roomc
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func humanRoomRobotCountryRulesToClient(values []HumanRoomRobotCountryRule) []roomclient.HumanRoomRobotCountryRule {
|
||||
out := make([]roomclient.HumanRoomRobotCountryRule, 0, len(values))
|
||||
for _, value := range values {
|
||||
out = append(out, roomclient.HumanRoomRobotCountryRule{
|
||||
CountryCode: strings.ToUpper(strings.TrimSpace(value.CountryCode)),
|
||||
MaxRoomCount: value.MaxRoomCount,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func humanRoomRobotCountryRulesFromClient(values []roomclient.HumanRoomRobotCountryRule) []HumanRoomRobotCountryRule {
|
||||
out := make([]HumanRoomRobotCountryRule, 0, len(values))
|
||||
for _, value := range values {
|
||||
out = append(out, HumanRoomRobotCountryRule{
|
||||
CountryCode: value.CountryCode,
|
||||
MaxRoomCount: value.MaxRoomCount,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) CreateRobotRoom(ctx context.Context, req createRobotRoomRequest, actor shared.Actor) (RobotRoom, error) {
|
||||
if s.roomClient == nil {
|
||||
return RobotRoom{}, fmt.Errorf("room service client is not configured")
|
||||
|
||||
@ -2,7 +2,10 @@ package wheel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -25,6 +28,7 @@ type Repository interface {
|
||||
|
||||
type WalletClient interface {
|
||||
CreditWheelReward(ctx context.Context, req *walletv1.CreditWheelRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditWheelRewardResponse, error)
|
||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@ -78,7 +82,7 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
|
||||
if err != nil {
|
||||
return domain.DrawResult{}, err
|
||||
}
|
||||
return s.creditCoinRewardFastPath(ctx, cmd, result), nil
|
||||
return s.fulfillWheelRewardFastPath(ctx, cmd, result), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpsertConfig(ctx context.Context, config domain.RuleConfig) (domain.RuleConfig, error) {
|
||||
@ -120,37 +124,180 @@ func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (d
|
||||
return s.repository.GetWheelDrawSummary(ctx, normalizeDrawQuery(query))
|
||||
}
|
||||
|
||||
func (s *Service) creditCoinRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
|
||||
if result.RewardType != domain.RewardTypeCoin || result.RewardCoins <= 0 || result.RewardStatus == domain.StatusGranted || len(result.DrawIDs) != 1 {
|
||||
func (s *Service) fulfillWheelRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
|
||||
if result.RewardStatus == domain.StatusGranted {
|
||||
return result
|
||||
}
|
||||
if s.wallet == nil {
|
||||
logx.Warn(ctx, "wheel_reward_wallet_not_configured", slog.String("draw_id", result.DrawID))
|
||||
return result
|
||||
}
|
||||
rewards := wheelResultRewards(result)
|
||||
if len(rewards) == 0 {
|
||||
return result
|
||||
}
|
||||
receipts := make([]string, 0, len(rewards))
|
||||
for index, reward := range rewards {
|
||||
receipt, coinBalance, err := s.fulfillWheelRewardItem(ctx, cmd, result, reward, index, len(rewards))
|
||||
if err != nil {
|
||||
// 抽奖结果已经在事务内确认,发放失败时不能回滚抽奖事实;保留 pending/outbox,补偿 worker 或重试可以用同一 command_id 幂等补发。
|
||||
logx.Error(ctx, "wheel_reward_fast_path_failed", err,
|
||||
slog.String("draw_id", result.DrawID),
|
||||
slog.String("command_id", result.CommandID),
|
||||
slog.String("reward_type", reward.RewardType),
|
||||
slog.String("selected_tier_id", reward.SelectedTierID),
|
||||
)
|
||||
return result
|
||||
}
|
||||
if receipt != "" {
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
if coinBalance > 0 {
|
||||
result.CoinBalanceAfter = coinBalance
|
||||
}
|
||||
}
|
||||
result.RewardStatus = domain.StatusGranted
|
||||
result.WalletTransactionID = strings.Join(receipts, ",")
|
||||
drawIDs := []string{result.DrawID}
|
||||
if result.DrawID == "" {
|
||||
drawIDs = append([]string(nil), result.DrawIDs...)
|
||||
}
|
||||
if err := s.repository.MarkWheelDrawsGranted(appcode.WithContext(context.WithoutCancel(ctx), appcode.FromContext(ctx)), appcode.FromContext(ctx), drawIDs, result.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil {
|
||||
logx.Error(ctx, "wheel_reward_fast_path_mark_granted_failed", err, slog.String("draw_id", result.DrawID), slog.String("wallet_transaction_id", result.WalletTransactionID))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) fulfillWheelRewardItem(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
|
||||
reward.RewardType = strings.TrimSpace(strings.ToLower(reward.RewardType))
|
||||
switch reward.RewardType {
|
||||
case domain.RewardTypeCoin:
|
||||
return s.creditWheelCoinReward(ctx, cmd, result, reward, index, total)
|
||||
case domain.RewardTypeGift, domain.RewardTypeProp, domain.RewardTypeDress:
|
||||
return s.grantWheelResourceReward(ctx, cmd, result, reward, index, total)
|
||||
default:
|
||||
return "", 0, fmt.Errorf("unsupported wheel reward type %q", reward.RewardType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) creditWheelCoinReward(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
|
||||
if reward.RewardCoins <= 0 {
|
||||
return "", 0, fmt.Errorf("wheel coin reward amount must be positive")
|
||||
}
|
||||
resp, err := s.wallet.CreditWheelReward(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.CreditWheelRewardRequest{
|
||||
CommandId: "wheel_reward:" + result.DrawID,
|
||||
CommandId: wheelRewardCommandID("wheel_reward", result.DrawID, index, total),
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
TargetUserId: cmd.UserID,
|
||||
Amount: result.RewardCoins,
|
||||
Amount: reward.RewardCoins,
|
||||
DrawId: result.DrawID,
|
||||
WheelId: result.WheelID,
|
||||
SelectedTierId: result.SelectedTierID,
|
||||
SelectedTierId: reward.SelectedTierID,
|
||||
Reason: "wheel_reward",
|
||||
VisibleRegionId: cmd.VisibleRegionID,
|
||||
})
|
||||
if err != nil {
|
||||
// 钱包不可用不能回滚已经确认的抽奖事实;pending 记录和 outbox 保留,后续补偿 worker 可以继续处理。
|
||||
logx.Error(ctx, "wheel_reward_fast_path_failed", err, slog.String("draw_id", result.DrawID), slog.String("command_id", result.CommandID))
|
||||
return result
|
||||
return "", 0, err
|
||||
}
|
||||
result.RewardStatus = domain.StatusGranted
|
||||
result.WalletTransactionID = resp.GetTransactionId()
|
||||
result.CoinBalanceAfter = resp.GetBalance().GetAvailableAmount()
|
||||
if err := s.repository.MarkWheelDrawsGranted(appcode.WithContext(context.WithoutCancel(ctx), appcode.FromContext(ctx)), appcode.FromContext(ctx), result.DrawIDs, result.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil {
|
||||
logx.Error(ctx, "wheel_reward_fast_path_mark_granted_failed", err, slog.String("draw_id", result.DrawID), slog.String("wallet_transaction_id", result.WalletTransactionID))
|
||||
return resp.GetTransactionId(), resp.GetBalance().GetAvailableAmount(), nil
|
||||
}
|
||||
|
||||
func (s *Service) grantWheelResourceReward(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
|
||||
resourceID := wheelRewardResourceID(reward)
|
||||
if resourceID <= 0 {
|
||||
// gift/prop/dress 的真实发放 owner 是 wallet 资源权益;缺 resource_id 时不能用展示 ID 猜测,必须让记录保持 pending 以便后台修配置后补偿。
|
||||
return "", 0, fmt.Errorf("wheel resource reward missing resource_id")
|
||||
}
|
||||
quantity := reward.RewardCount
|
||||
if quantity <= 0 {
|
||||
quantity = 1
|
||||
}
|
||||
resp, err := s.wallet.GrantResource(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.GrantResourceRequest{
|
||||
CommandId: wheelRewardCommandID("wheel_resource", result.DrawID, index, total),
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
TargetUserId: cmd.UserID,
|
||||
ResourceId: resourceID,
|
||||
Quantity: quantity,
|
||||
DurationMs: wheelRewardDurationMS(reward),
|
||||
Reason: "wheel_reward",
|
||||
OperatorUserId: cmd.UserID,
|
||||
GrantSource: "wheel_reward",
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return resp.GetGrant().GetGrantId(), 0, nil
|
||||
}
|
||||
|
||||
func wheelResultRewards(result domain.DrawResult) []domain.DrawReward {
|
||||
if len(result.Rewards) > 0 {
|
||||
return result.Rewards
|
||||
}
|
||||
if result.RewardType == "" {
|
||||
return nil
|
||||
}
|
||||
return []domain.DrawReward{{
|
||||
SelectedTierID: result.SelectedTierID,
|
||||
RewardType: result.RewardType,
|
||||
RewardID: result.RewardID,
|
||||
RewardCount: result.RewardCount,
|
||||
RewardCoins: result.RewardCoins,
|
||||
RTPValueCoins: result.RTPValueCoins,
|
||||
RewardStatus: result.RewardStatus,
|
||||
MetadataJSON: result.MetadataJSON,
|
||||
}}
|
||||
}
|
||||
|
||||
func wheelRewardCommandID(prefix, drawID string, index, total int) string {
|
||||
if total <= 1 {
|
||||
return prefix + ":" + drawID
|
||||
}
|
||||
return fmt.Sprintf("%s:%s:%d", prefix, drawID, index+1)
|
||||
}
|
||||
|
||||
func wheelRewardResourceID(reward domain.DrawReward) int64 {
|
||||
if value := wheelRewardMetadataInt64(reward.MetadataJSON, "resource_id", "resourceId"); value > 0 {
|
||||
return value
|
||||
}
|
||||
value, _ := strconv.ParseInt(strings.TrimSpace(reward.RewardID), 10, 64)
|
||||
return value
|
||||
}
|
||||
|
||||
func wheelRewardDurationMS(reward domain.DrawReward) int64 {
|
||||
return wheelRewardMetadataInt64(reward.MetadataJSON, "duration_ms", "durationMs", "validity_ms", "validityMs")
|
||||
}
|
||||
|
||||
func wheelRewardMetadataInt64(raw string, keys ...string) int64 {
|
||||
metadata := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value, ok := metadata[key]; ok {
|
||||
if parsed := anyToInt64(value); parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func anyToInt64(value any) int64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case int64:
|
||||
return typed
|
||||
case int:
|
||||
return int64(typed)
|
||||
case json.Number:
|
||||
parsed, _ := typed.Int64()
|
||||
return parsed
|
||||
case string:
|
||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {
|
||||
|
||||
148
services/activity-service/internal/service/wheel/service_test.go
Normal file
148
services/activity-service/internal/service/wheel/service_test.go
Normal file
@ -0,0 +1,148 @@
|
||||
package wheel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
domain "hyapp/services/activity-service/internal/domain/wheel"
|
||||
)
|
||||
|
||||
func TestDrawGrantsResourceReward(t *testing.T) {
|
||||
repository := &fakeWheelRepository{executeResult: domain.DrawResult{
|
||||
DrawID: "wheel_draw_1",
|
||||
DrawIDs: []string{"wheel_draw_1"},
|
||||
CommandID: "cmd-1",
|
||||
WheelID: "classic",
|
||||
SelectedTierID: "classic-1",
|
||||
RewardType: domain.RewardTypeGift,
|
||||
RewardID: "gift-1",
|
||||
RewardCount: 2,
|
||||
RewardStatus: domain.StatusPending,
|
||||
MetadataJSON: `{"resource_id":326}`,
|
||||
}}
|
||||
wallet := &fakeWheelWallet{}
|
||||
service := New(repository, WithWallet(wallet))
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(1700000000000).UTC() })
|
||||
|
||||
result, err := service.Draw(appcode.WithContext(context.Background(), "yumi"), domain.DrawCommand{
|
||||
CommandID: "cmd-1",
|
||||
WheelID: "classic",
|
||||
UserID: 1001,
|
||||
DeviceID: "device-1",
|
||||
CoinSpent: 10000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw returned error: %v", err)
|
||||
}
|
||||
if result.RewardStatus != domain.StatusGranted {
|
||||
t.Fatalf("reward status mismatch: %s", result.RewardStatus)
|
||||
}
|
||||
if len(wallet.resourceGrants) != 1 {
|
||||
t.Fatalf("resource grants mismatch: %+v", wallet.resourceGrants)
|
||||
}
|
||||
req := wallet.resourceGrants[0]
|
||||
if req.GetCommandId() != "wheel_resource:wheel_draw_1" || req.GetTargetUserId() != 1001 || req.GetResourceId() != 326 || req.GetQuantity() != 2 {
|
||||
t.Fatalf("grant request mismatch: %+v", req)
|
||||
}
|
||||
if req.GetReason() != "wheel_reward" || req.GetGrantSource() != "wheel_reward" || req.GetOperatorUserId() != 1001 {
|
||||
t.Fatalf("grant audit fields mismatch: %+v", req)
|
||||
}
|
||||
if len(repository.markGrantedDrawIDs) != 1 || repository.markGrantedDrawIDs[0] != "wheel_draw_1" {
|
||||
t.Fatalf("marked draw IDs mismatch: %+v", repository.markGrantedDrawIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawFulfillsBatchCoinAndResourceRewards(t *testing.T) {
|
||||
repository := &fakeWheelRepository{executeResult: domain.DrawResult{
|
||||
DrawID: "wheel_draw_batch",
|
||||
DrawIDs: []string{"wheel_draw_batch"},
|
||||
CommandID: "cmd-batch",
|
||||
WheelID: "advanced",
|
||||
RewardType: "batch",
|
||||
RewardStatus: domain.StatusPending,
|
||||
Rewards: []domain.DrawReward{
|
||||
{SelectedTierID: "advanced-1", RewardType: domain.RewardTypeCoin, RewardCoins: 100},
|
||||
{SelectedTierID: "advanced-2", RewardType: domain.RewardTypeProp, RewardID: "901", RewardCount: 1, MetadataJSON: `{"resource_id":901}`},
|
||||
},
|
||||
}}
|
||||
wallet := &fakeWheelWallet{coinBalanceAfter: 9900}
|
||||
service := New(repository, WithWallet(wallet))
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(1700000000000).UTC() })
|
||||
|
||||
result, err := service.Draw(appcode.WithContext(context.Background(), "yumi"), domain.DrawCommand{
|
||||
CommandID: "cmd-batch",
|
||||
WheelID: "advanced",
|
||||
UserID: 2002,
|
||||
DeviceID: "device-2",
|
||||
CoinSpent: 50000,
|
||||
DrawCount: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw returned error: %v", err)
|
||||
}
|
||||
if result.RewardStatus != domain.StatusGranted {
|
||||
t.Fatalf("reward status mismatch: %s", result.RewardStatus)
|
||||
}
|
||||
if len(wallet.coinCredits) != 1 || wallet.coinCredits[0].GetCommandId() != "wheel_reward:wheel_draw_batch:1" {
|
||||
t.Fatalf("coin credit mismatch: %+v", wallet.coinCredits)
|
||||
}
|
||||
if len(wallet.resourceGrants) != 1 || wallet.resourceGrants[0].GetCommandId() != "wheel_resource:wheel_draw_batch:2" {
|
||||
t.Fatalf("resource grant mismatch: %+v", wallet.resourceGrants)
|
||||
}
|
||||
if result.CoinBalanceAfter != 9900 {
|
||||
t.Fatalf("coin balance mismatch: %d", result.CoinBalanceAfter)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWheelRepository struct {
|
||||
executeResult domain.DrawResult
|
||||
markGrantedDrawIDs []string
|
||||
}
|
||||
|
||||
func (r *fakeWheelRepository) PublishWheelRuleConfig(context.Context, domain.RuleConfig, int64) (domain.RuleConfig, error) {
|
||||
return domain.RuleConfig{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeWheelRepository) GetWheelRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) {
|
||||
return domain.RuleConfig{}, false, nil
|
||||
}
|
||||
|
||||
func (r *fakeWheelRepository) ExecuteWheelDraw(context.Context, domain.DrawCommand, int64) (domain.DrawResult, error) {
|
||||
return r.executeResult, nil
|
||||
}
|
||||
|
||||
func (r *fakeWheelRepository) ListWheelDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (r *fakeWheelRepository) GetWheelDrawSummary(context.Context, domain.DrawQuery) (domain.DrawSummary, error) {
|
||||
return domain.DrawSummary{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeWheelRepository) MarkWheelDrawsGranted(_ context.Context, _ string, drawIDs []string, _ string, _ int64) error {
|
||||
r.markGrantedDrawIDs = append([]string(nil), drawIDs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeWheelWallet struct {
|
||||
coinCredits []*walletv1.CreditWheelRewardRequest
|
||||
resourceGrants []*walletv1.GrantResourceRequest
|
||||
coinBalanceAfter int64
|
||||
}
|
||||
|
||||
func (w *fakeWheelWallet) CreditWheelReward(_ context.Context, req *walletv1.CreditWheelRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditWheelRewardResponse, error) {
|
||||
w.coinCredits = append(w.coinCredits, req)
|
||||
return &walletv1.CreditWheelRewardResponse{
|
||||
TransactionId: "tx-" + req.GetCommandId(),
|
||||
Balance: &walletv1.AssetBalance{AssetType: "COIN", AvailableAmount: w.coinBalanceAfter},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *fakeWheelWallet) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
|
||||
w.resourceGrants = append(w.resourceGrants, req)
|
||||
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-" + req.GetCommandId()}}, nil
|
||||
}
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -264,7 +265,7 @@ func wheelPrizeFromTier(tier *activityv1.WheelPrizeTier) wheelPrizeData {
|
||||
MetadataJSON: tier.GetMetadataJson(),
|
||||
PreviewURL: previewURL,
|
||||
AnimationURL: animationURL,
|
||||
Value: wheelPrizeDisplayValue(tier.GetRewardCoins(), tier.GetRtpValueCoins(), tier.GetRewardCount()),
|
||||
Value: wheelPrizeDisplayValue(tier.GetRewardCoins(), tier.GetRtpValueCoins(), tier.GetRewardCount(), tier.GetMetadataJson()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -315,7 +316,7 @@ func wheelPrizeFromDraw(item *activityv1.WheelDrawResult) wheelPrizeData {
|
||||
MetadataJSON: item.GetMetadataJson(),
|
||||
PreviewURL: previewURL,
|
||||
AnimationURL: animationURL,
|
||||
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount()),
|
||||
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount(), item.GetMetadataJson()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -334,7 +335,7 @@ func wheelPrizeFromDrawReward(item *activityv1.WheelDrawReward) wheelPrizeData {
|
||||
MetadataJSON: item.GetMetadataJson(),
|
||||
PreviewURL: previewURL,
|
||||
AnimationURL: animationURL,
|
||||
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount()),
|
||||
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount(), item.GetMetadataJson()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -376,12 +377,15 @@ func wheelPrizeMergeKey(item wheelPrizeData) string {
|
||||
}, "|")
|
||||
}
|
||||
|
||||
func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardCount int64) int64 {
|
||||
func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardCount int64, metadataJSON string) int64 {
|
||||
// H5 只需要一个展示值,不能把后台 RTP 字段原样暴露出去;金币奖品展示金币数,礼物可展示内部估值。
|
||||
// 道具和装扮不计入 RTP 时内部估值会是 0,这时回退奖励数量,避免页面拿到 value=0 后看起来像奖品没有价值。
|
||||
// 道具和装扮不计入 RTP 时内部估值会是 0,所以优先读取 metadata 里资源原价,避免页面只能展示 reward_count=1。
|
||||
if rewardCoins > 0 {
|
||||
return rewardCoins
|
||||
}
|
||||
if displayValue := wheelMetadataDisplayValue(metadataJSON); displayValue > 0 {
|
||||
return displayValue
|
||||
}
|
||||
if internalValueCoins > 0 {
|
||||
return internalValueCoins
|
||||
}
|
||||
@ -391,6 +395,14 @@ func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardC
|
||||
return 1
|
||||
}
|
||||
|
||||
func wheelMetadataDisplayValue(metadataJSON string) int64 {
|
||||
var metadata map[string]any
|
||||
if err := json.NewDecoder(strings.NewReader(strings.TrimSpace(metadataJSON))).Decode(&metadata); err != nil && err != io.EOF {
|
||||
return 0
|
||||
}
|
||||
return firstMetadataInt64(metadata, "display_value", "displayValue", "coin_price", "coinPrice", "price", "value")
|
||||
}
|
||||
|
||||
func wheelAssetURLs(rewardType string, metadataJSON string) (string, string) {
|
||||
var metadata map[string]any
|
||||
if err := json.NewDecoder(strings.NewReader(strings.TrimSpace(metadataJSON))).Decode(&metadata); err != nil && err != io.EOF {
|
||||
@ -412,3 +424,24 @@ func firstMetadataString(metadata map[string]any, keys ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstMetadataInt64(metadata map[string]any, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
value, ok := metadata[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed > 0 {
|
||||
return int64(typed)
|
||||
}
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
if err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
package activityapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWheelPrizeDisplayValueUsesMetadataPrice(t *testing.T) {
|
||||
metadata := `{"display_value":1314,"coin_price":1314}`
|
||||
|
||||
if got := wheelPrizeDisplayValue(0, 0, 1, metadata); got != 1314 {
|
||||
t.Fatalf("prop display value should use metadata price, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWheelPrizeDisplayValueKeepsCoinAmountFirst(t *testing.T) {
|
||||
metadata := `{"display_value":1314,"coin_price":1314}`
|
||||
|
||||
if got := wheelPrizeDisplayValue(99, 0, 1, metadata); got != 99 {
|
||||
t.Fatalf("coin display value should use reward coins first, got %d", got)
|
||||
}
|
||||
}
|
||||
@ -26,26 +26,30 @@ type resourceData struct {
|
||||
}
|
||||
|
||||
type giftConfigData struct {
|
||||
GiftID string `json:"gift_id"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
Resource resourceData `json:"resource"`
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
PresentationJSON string `json:"presentation_json"`
|
||||
PriceVersion string `json:"price_version"`
|
||||
GiftTypeCode string `json:"gift_type_code"`
|
||||
CPRelationType string `json:"cp_relation_type,omitempty"`
|
||||
ChargeAssetType string `json:"charge_asset_type"`
|
||||
CoinPrice int64 `json:"coin_price"`
|
||||
GiftPointAmount int64 `json:"gift_point_amount"`
|
||||
HeatValue int64 `json:"heat_value"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
EffectiveToMS int64 `json:"effective_to_ms"`
|
||||
EffectTypes []string `json:"effect_types"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
GiftID string `json:"gift_id"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
Resource resourceData `json:"resource"`
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
PresentationJSON string `json:"presentation_json"`
|
||||
PriceVersion string `json:"price_version"`
|
||||
GiftTypeCode string `json:"gift_type_code"`
|
||||
CPRelationType string `json:"cp_relation_type,omitempty"`
|
||||
ChargeAssetType string `json:"charge_asset_type"`
|
||||
CoinPrice int64 `json:"coin_price"`
|
||||
GiftPointAmount int64 `json:"gift_point_amount"`
|
||||
HeatValue int64 `json:"heat_value"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
EffectiveToMS int64 `json:"effective_to_ms"`
|
||||
EffectTypes []string `json:"effect_types"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
Source string `json:"source,omitempty"`
|
||||
EntitlementID string `json:"entitlement_id,omitempty"`
|
||||
OwnedQuantity int64 `json:"owned_quantity,omitempty"`
|
||||
RemainingQuantity int64 `json:"remaining_quantity,omitempty"`
|
||||
}
|
||||
|
||||
type giftTypeConfigData struct {
|
||||
|
||||
@ -732,16 +732,67 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
|
||||
gifts = append(gifts, giftFromProto(gift))
|
||||
}
|
||||
gifts = filterGiftPanelGifts(gifts, giftTypes)
|
||||
bagGifts, err := h.roomGiftBagGifts(request, viewerUserID, gifts)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
panelGifts := append([]giftConfigData{}, gifts...)
|
||||
panelGifts = append(panelGifts, bagGifts...)
|
||||
recipientProfiles := h.roomDisplayProfileMap(request, roomGiftRecipientUserIDs(snapshot))
|
||||
httpkit.WriteOK(writer, request, roomGiftPanelData{
|
||||
CoinBalance: coinBalanceFromProto(balances),
|
||||
Recipients: roomGiftRecipients(snapshot, recipientProfiles),
|
||||
Tabs: roomGiftTabs(gifts, giftTypes),
|
||||
Gifts: gifts,
|
||||
Tabs: roomGiftTabs(panelGifts, giftTypes),
|
||||
Gifts: panelGifts,
|
||||
QuantityPresets: []int32{1, 9, 99, 999},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gifts []giftConfigData) ([]giftConfigData, error) {
|
||||
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserId: viewerUserID,
|
||||
ResourceType: "gift",
|
||||
ActiveOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
giftByResourceID := make(map[int64]giftConfigData, len(gifts))
|
||||
for _, gift := range gifts {
|
||||
if gift.ResourceID > 0 {
|
||||
giftByResourceID[gift.ResourceID] = gift
|
||||
}
|
||||
}
|
||||
bagGifts := make([]giftConfigData, 0, len(resp.GetResources()))
|
||||
for _, item := range resp.GetResources() {
|
||||
remaining := item.GetRemainingQuantity()
|
||||
if remaining <= 0 {
|
||||
remaining = item.GetQuantity()
|
||||
}
|
||||
if remaining <= 0 {
|
||||
continue
|
||||
}
|
||||
gift, ok := giftByResourceID[item.GetResourceId()]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Bag tab 只承载用户实际拥有的 gift 权益;仍复用 gift 配置的价格/动效,避免背包资源和房间送礼协议出现两套展示口径。
|
||||
gift.GiftTypeCode = "bag"
|
||||
gift.Source = "bag"
|
||||
gift.EntitlementID = item.GetEntitlementId()
|
||||
gift.OwnedQuantity = item.GetQuantity()
|
||||
gift.RemainingQuantity = remaining
|
||||
bagGifts = append(bagGifts, gift)
|
||||
}
|
||||
return bagGifts, nil
|
||||
}
|
||||
|
||||
// getRoomRocket 返回房间火箭物料配置和当前进度。
|
||||
func (h *Handler) getRoomRocket(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.roomQueryClient == nil {
|
||||
@ -1486,6 +1537,8 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
GiftID string `json:"gift_id"`
|
||||
GiftCount int32 `json:"gift_count"`
|
||||
PoolID string `json:"pool_id"`
|
||||
EntitlementID string `json:"entitlement_id"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
@ -1524,6 +1577,8 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
TargetIsHost: targetIsHost,
|
||||
TargetHostRegionId: targetHostRegionID,
|
||||
TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
|
||||
EntitlementId: strings.TrimSpace(body.EntitlementID),
|
||||
Source: strings.TrimSpace(body.Source),
|
||||
})
|
||||
httpkit.Write(writer, request, resp, err)
|
||||
}
|
||||
@ -2213,10 +2268,15 @@ func roomGiftRecipients(snapshot *roomv1.RoomSnapshot, profiles map[int64]roomDi
|
||||
|
||||
func roomGiftTabs(gifts []giftConfigData, giftTypes []giftTypeConfigData) []roomGiftTabData {
|
||||
tabs := []roomGiftTabData{{Key: "all", Order: 0}}
|
||||
hasBag := false
|
||||
if len(giftTypes) > 0 {
|
||||
hasGift := make(map[string]bool, len(gifts))
|
||||
for _, gift := range gifts {
|
||||
key := strings.TrimSpace(gift.GiftTypeCode)
|
||||
if key == "bag" || strings.TrimSpace(gift.Source) == "bag" {
|
||||
hasBag = true
|
||||
continue
|
||||
}
|
||||
if key != "" {
|
||||
hasGift[key] = true
|
||||
}
|
||||
@ -2237,6 +2297,9 @@ func roomGiftTabs(gifts []giftConfigData, giftTypes []giftTypeConfigData) []room
|
||||
Order: giftType.SortOrder,
|
||||
})
|
||||
}
|
||||
if hasBag {
|
||||
tabs = append(tabs, roomGiftTabData{Key: "bag", GiftTypeCode: "bag", Label: "Bag", Order: 5})
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
seen := map[string]bool{"all": true}
|
||||
|
||||
@ -132,6 +132,28 @@ func TestRoomGiftTabsUsesTypeCodeAsKeyAndTabKeyAsLabel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomGiftTabsIncludesBagForOwnedGifts(t *testing.T) {
|
||||
tabs := roomGiftTabs(
|
||||
[]giftConfigData{
|
||||
{GiftTypeCode: "normal"},
|
||||
{GiftTypeCode: "bag", Source: "bag", EntitlementID: "ent-1"},
|
||||
},
|
||||
[]giftTypeConfigData{
|
||||
{TypeCode: "normal", Name: "普通礼物", TabKey: "Gift", SortOrder: 10},
|
||||
},
|
||||
)
|
||||
|
||||
if len(tabs) != 3 {
|
||||
t.Fatalf("tab count mismatch: got %d tabs=%+v", len(tabs), tabs)
|
||||
}
|
||||
if tabs[1].Key != "normal" || tabs[1].Label != "Gift" {
|
||||
t.Fatalf("normal tab mismatch: %+v", tabs[1])
|
||||
}
|
||||
if tabs[2].Key != "bag" || tabs[2].GiftTypeCode != "bag" || tabs[2].Label != "Bag" || tabs[2].Order != 5 {
|
||||
t.Fatalf("bag tab mismatch: %+v", tabs[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoomDataIncludesExplicitIMGroupID(t *testing.T) {
|
||||
data := createRoomDataFromProto(&roomv1.CreateRoomResponse{
|
||||
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 1, ServerTimeMs: 1700000000000},
|
||||
|
||||
@ -305,6 +305,8 @@ CREATE TABLE IF NOT EXISTS room_human_robot_configs (
|
||||
max_gift_senders BIGINT NOT NULL DEFAULT 1 COMMENT '同一时间最多送礼机器人数量',
|
||||
country_pools_json JSON NOT NULL COMMENT '国家机器人池配置',
|
||||
allowed_owner_ids_json JSON NOT NULL COMMENT '限定进入的房主展示短号/内部用户 ID,空数组表示所有真人房',
|
||||
country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用国家进房上限配置',
|
||||
country_rules_json JSON NOT NULL COMMENT '每个国家最多同时进几个真人房的配置',
|
||||
updated_by_admin_id BIGINT UNSIGNED 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',
|
||||
|
||||
@ -416,6 +416,10 @@ type SendGift struct {
|
||||
PoolID string `json:"pool_id,omitempty"`
|
||||
// GiftCount 是礼物数量,必须为正数。
|
||||
GiftCount int32 `json:"gift_count"`
|
||||
// EntitlementID 是背包送礼要消耗的用户礼物权益 ID;普通金币送礼为空。
|
||||
EntitlementID string `json:"entitlement_id,omitempty"`
|
||||
// Source 区分 coin/bag;为空按普通金币链路兼容旧客户端。
|
||||
Source string `json:"source,omitempty"`
|
||||
// SenderRegionID 是送礼用户所属区域,由 gateway 从 user-service 注入。
|
||||
SenderRegionID int64 `json:"sender_region_id,omitempty"`
|
||||
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。
|
||||
|
||||
@ -75,6 +75,8 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
|
||||
GiftID: req.GetGiftId(),
|
||||
PoolID: strings.TrimSpace(req.GetPoolId()),
|
||||
GiftCount: req.GetGiftCount(),
|
||||
EntitlementID: strings.TrimSpace(req.GetEntitlementId()),
|
||||
Source: strings.TrimSpace(req.GetSource()),
|
||||
SenderCountryID: req.GetSenderCountryId(),
|
||||
SenderRegionID: req.GetSenderRegionId(),
|
||||
TargetIsHost: req.GetTargetIsHost(),
|
||||
@ -123,6 +125,10 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
|
||||
// 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid")
|
||||
}
|
||||
if strings.EqualFold(cmd.Source, "bag") && strings.TrimSpace(cmd.EntitlementID) == "" {
|
||||
// 背包送礼只改变扣费来源,不改变房间事件;因此必须在扣费前明确指定要扣的权益,不能让 wallet 猜测某个库存。
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "entitlement_id is required for bag gift")
|
||||
}
|
||||
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, cmd.RoomID())
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
@ -481,6 +487,8 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
|
||||
TargetIsHost: cmd.TargetIsHost,
|
||||
TargetHostRegionId: cmd.TargetHostRegionID,
|
||||
TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID,
|
||||
EntitlementId: cmd.EntitlementID,
|
||||
ChargeSource: cmd.Source,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@ -512,6 +520,8 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
|
||||
RegionId: roomMeta.VisibleRegionID,
|
||||
SenderRegionId: cmd.SenderRegionID,
|
||||
Targets: targets,
|
||||
EntitlementId: cmd.EntitlementID,
|
||||
ChargeSource: cmd.Source,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -98,6 +99,8 @@ func (s *Service) scanHumanRoomRobotRuntimes(ctx context.Context) {
|
||||
s.stopAllHumanRoomRobotRuntimes()
|
||||
return
|
||||
}
|
||||
countryLimits := humanRoomRobotCountryLimits(config)
|
||||
runtimeCounts := s.reconcileHumanRoomRobotRuntimeLimits(config, countryLimits)
|
||||
pools, err := s.loadHumanRoomRobotPools(ctx, config.AppCode)
|
||||
if err != nil {
|
||||
logx.Warn(ctx, "human_room_robot_pool_load_failed", slog.String("error", err.Error()))
|
||||
@ -109,6 +112,12 @@ func (s *Service) scanHumanRoomRobotRuntimes(ctx context.Context) {
|
||||
if countryCode == "" || len(pool.RobotUserIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
if config.CountryLimitEnabled {
|
||||
maxRooms := countryLimits[countryCode]
|
||||
if maxRooms <= 0 || runtimeCounts[countryCode] >= maxRooms {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 限定房主 ID 只影响本轮候选房 SQL,不额外读取 Room Cell 或 presence;为空时保持原来的全房间扫描语义。
|
||||
rooms, err := s.repository.ListHumanRobotCandidateRooms(ctx, config.AppCode, countryCode, config.CandidateRoomMaxOnline, config.RoomTargetMaxOnline, 50, config.AllowedOwnerIDs)
|
||||
if err != nil {
|
||||
@ -116,13 +125,18 @@ func (s *Service) scanHumanRoomRobotRuntimes(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
for _, room := range rooms {
|
||||
if config.CountryLimitEnabled && runtimeCounts[countryCode] >= countryLimits[countryCode] {
|
||||
break
|
||||
}
|
||||
if s.humanRoomRobotRuntimeConfigChanged(room.RoomID, config.UpdatedAtMS) {
|
||||
s.stopHumanRoomRobotRuntime(room.RoomID)
|
||||
}
|
||||
if s.humanRoomRobotRuntimeExists(room.RoomID) {
|
||||
continue
|
||||
}
|
||||
s.startHumanRoomRobotRuntime(ctx, config, countryCode, room.RoomID, pool.RobotUserIDs)
|
||||
if s.startHumanRoomRobotRuntime(ctx, config, countryCode, room.RoomID, pool.RobotUserIDs) {
|
||||
runtimeCounts[countryCode]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -147,9 +161,9 @@ func (s *Service) loadHumanRoomRobotPools(ctx context.Context, appCode string) (
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config HumanRoomRobotConfig, countryCode string, roomID string, pool []int64) {
|
||||
func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config HumanRoomRobotConfig, countryCode string, roomID string, pool []int64) bool {
|
||||
if parent.Err() != nil || roomID == "" || len(pool) == 0 {
|
||||
return
|
||||
return false
|
||||
}
|
||||
s.humanRobotRuntimeMu.Lock()
|
||||
if s.humanRobotRuntimes == nil {
|
||||
@ -157,7 +171,7 @@ func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config Huma
|
||||
}
|
||||
if _, exists := s.humanRobotRuntimes[roomID]; exists {
|
||||
s.humanRobotRuntimeMu.Unlock()
|
||||
return
|
||||
return false
|
||||
}
|
||||
s.humanRobotRuntimeMu.Unlock()
|
||||
|
||||
@ -166,20 +180,20 @@ func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config Huma
|
||||
selected, err := s.prepareHumanRoomRobots(roomCtx, config, roomID, pool, targetOnline)
|
||||
if err != nil {
|
||||
logx.Warn(roomCtx, "human_room_robot_prepare_failed", slog.String("room_id", roomID), slog.String("country_code", countryCode), slog.String("error", err.Error()))
|
||||
return
|
||||
return false
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
s.humanRobotRuntimeMu.Lock()
|
||||
if _, exists := s.humanRobotRuntimes[roomID]; exists {
|
||||
s.humanRobotRuntimeMu.Unlock()
|
||||
return
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithCancel(roomCtx)
|
||||
token := fmt.Sprintf("%s:%d", roomID, time.Now().UTC().UnixNano())
|
||||
s.humanRobotRuntimes[roomID] = robotRoomRuntime{cancel: cancel, token: token, configUpdatedAtMS: config.UpdatedAtMS}
|
||||
s.humanRobotRuntimes[roomID] = robotRoomRuntime{cancel: cancel, token: token, countryCode: countryCode, configUpdatedAtMS: config.UpdatedAtMS}
|
||||
s.humanRobotRuntimeMu.Unlock()
|
||||
|
||||
logx.Info(ctx, "human_room_robot_runtime_started",
|
||||
@ -192,6 +206,7 @@ func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config Huma
|
||||
defer s.clearHumanRoomRobotRuntime(roomID, token)
|
||||
s.runHumanRoomRobotRuntime(ctx, config, roomID, pool, selected, targetOnline)
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) prepareHumanRoomRobots(ctx context.Context, config HumanRoomRobotConfig, roomID string, pool []int64, targetOnline int) ([]int64, error) {
|
||||
@ -608,6 +623,40 @@ func (s *Service) firstHumanRoomFreeSeatNo(ctx context.Context, roomID string) i
|
||||
return seats[0]
|
||||
}
|
||||
|
||||
func (s *Service) reconcileHumanRoomRobotRuntimeLimits(config HumanRoomRobotConfig, countryLimits map[string]int) map[string]int {
|
||||
counts := make(map[string]int, len(countryLimits))
|
||||
cancels := make([]context.CancelFunc, 0)
|
||||
s.humanRobotRuntimeMu.Lock()
|
||||
for roomID, runtime := range s.humanRobotRuntimes {
|
||||
countryCode := normalizeRoomCountryCode(runtime.countryCode)
|
||||
// 配置版本变化后直接取消旧 runtime,保证停留、礼物、国家限额等行为不会继续沿用旧快照。
|
||||
if runtime.configUpdatedAtMS > 0 && config.UpdatedAtMS > 0 && runtime.configUpdatedAtMS != config.UpdatedAtMS {
|
||||
if runtime.cancel != nil {
|
||||
cancels = append(cancels, runtime.cancel)
|
||||
}
|
||||
delete(s.humanRobotRuntimes, roomID)
|
||||
continue
|
||||
}
|
||||
// 国家限额开启后,未配置国家和超过本国家房间上限的 runtime 都必须停掉,避免旧扫描结果继续占用真人房。
|
||||
if config.CountryLimitEnabled {
|
||||
limit := countryLimits[countryCode]
|
||||
if countryCode == "" || limit <= 0 || counts[countryCode] >= limit {
|
||||
if runtime.cancel != nil {
|
||||
cancels = append(cancels, runtime.cancel)
|
||||
}
|
||||
delete(s.humanRobotRuntimes, roomID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
counts[countryCode]++
|
||||
}
|
||||
s.humanRobotRuntimeMu.Unlock()
|
||||
for _, cancel := range cancels {
|
||||
cancel()
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func (s *Service) humanRoomRobotRuntimeExists(roomID string) bool {
|
||||
s.humanRobotRuntimeMu.Lock()
|
||||
defer s.humanRobotRuntimeMu.Unlock()
|
||||
@ -664,6 +713,10 @@ func normalizeHumanRoomRobotConfig(appCode string, input *roomv1.AdminHumanRoomR
|
||||
if input == nil {
|
||||
return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "human room robot config is required")
|
||||
}
|
||||
countryRules, err := normalizeHumanRoomRobotCountryRules(input.GetCountryRules())
|
||||
if err != nil {
|
||||
return HumanRoomRobotConfig{}, err
|
||||
}
|
||||
config := HumanRoomRobotConfig{
|
||||
AppCode: appcode.Normalize(appCode),
|
||||
Enabled: input.GetEnabled(),
|
||||
@ -687,6 +740,8 @@ func normalizeHumanRoomRobotConfig(appCode string, input *roomv1.AdminHumanRoomR
|
||||
LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(),
|
||||
MaxGiftSenders: input.GetMaxGiftSenders(),
|
||||
AllowedOwnerIDs: normalizeStringSet(input.GetAllowedOwnerIds()),
|
||||
CountryLimitEnabled: input.GetCountryLimitEnabled(),
|
||||
CountryRules: countryRules,
|
||||
UpdatedByAdminID: adminID,
|
||||
CreatedAtMS: input.GetCreatedAtMs(),
|
||||
UpdatedAtMS: nowMS,
|
||||
@ -719,6 +774,9 @@ func normalizeHumanRoomRobotConfig(appCode string, input *roomv1.AdminHumanRoomR
|
||||
if config.MaxGiftSenders <= 0 {
|
||||
return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "max_gift_senders is required")
|
||||
}
|
||||
if config.Enabled && config.CountryLimitEnabled && len(config.CountryRules) == 0 {
|
||||
return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "country_rules is required")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
@ -779,6 +837,45 @@ func defaultHumanRoomRobotConfig(appCode string, nowMS int64) HumanRoomRobotConf
|
||||
return withHumanRoomRobotDefaults(HumanRoomRobotConfig{AppCode: appcode.Normalize(appCode), CreatedAtMS: nowMS, UpdatedAtMS: nowMS})
|
||||
}
|
||||
|
||||
func normalizeHumanRoomRobotCountryRules(values []*roomv1.AdminHumanRoomRobotCountryRule) ([]HumanRoomRobotCountryRule, error) {
|
||||
seen := make(map[string]bool, len(values))
|
||||
rules := make([]HumanRoomRobotCountryRule, 0, len(values))
|
||||
for _, value := range values {
|
||||
countryCode := normalizeRoomCountryCode(value.GetCountryCode())
|
||||
if countryCode == "" {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "country_code is invalid")
|
||||
}
|
||||
if seen[countryCode] {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "country_code is duplicated")
|
||||
}
|
||||
if value.GetMaxRoomCount() <= 0 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "country max_room_count is invalid")
|
||||
}
|
||||
seen[countryCode] = true
|
||||
rules = append(rules, HumanRoomRobotCountryRule{CountryCode: countryCode, MaxRoomCount: value.GetMaxRoomCount()})
|
||||
}
|
||||
sort.Slice(rules, func(i, j int) bool {
|
||||
return rules[i].CountryCode < rules[j].CountryCode
|
||||
})
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func humanRoomRobotCountryLimits(config HumanRoomRobotConfig) map[string]int {
|
||||
limits := make(map[string]int, len(config.CountryRules))
|
||||
if !config.CountryLimitEnabled {
|
||||
return limits
|
||||
}
|
||||
for _, rule := range config.CountryRules {
|
||||
countryCode := normalizeRoomCountryCode(rule.CountryCode)
|
||||
if countryCode == "" || rule.MaxRoomCount <= 0 {
|
||||
continue
|
||||
}
|
||||
// 同国家重复规则在保存阶段已被拒绝;这里保留最后一次赋值,保证历史脏数据不会导致 panic。
|
||||
limits[countryCode] = int(rule.MaxRoomCount)
|
||||
}
|
||||
return limits
|
||||
}
|
||||
|
||||
func humanRoomRobotConfigToProto(config HumanRoomRobotConfig) *roomv1.AdminHumanRoomRobotConfig {
|
||||
config = withHumanRoomRobotDefaults(config)
|
||||
out := &roomv1.AdminHumanRoomRobotConfig{
|
||||
@ -804,10 +901,17 @@ func humanRoomRobotConfigToProto(config HumanRoomRobotConfig) *roomv1.AdminHuman
|
||||
LuckyPauseMaxMs: config.LuckyPauseMaxMS,
|
||||
MaxGiftSenders: config.MaxGiftSenders,
|
||||
AllowedOwnerIds: append([]string(nil), config.AllowedOwnerIDs...),
|
||||
CountryLimitEnabled: config.CountryLimitEnabled,
|
||||
UpdatedByAdminId: config.UpdatedByAdminID,
|
||||
CreatedAtMs: config.CreatedAtMS,
|
||||
UpdatedAtMs: config.UpdatedAtMS,
|
||||
}
|
||||
for _, rule := range config.CountryRules {
|
||||
out.CountryRules = append(out.CountryRules, &roomv1.AdminHumanRoomRobotCountryRule{
|
||||
CountryCode: rule.CountryCode,
|
||||
MaxRoomCount: rule.MaxRoomCount,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/xerr"
|
||||
)
|
||||
|
||||
@ -78,3 +79,29 @@ func TestRandomHumanRoomTargetOnlineLegacyFixedValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHumanRoomRobotCountryRules(t *testing.T) {
|
||||
rules, err := normalizeHumanRoomRobotCountryRules([]*roomv1.AdminHumanRoomRobotCountryRule{
|
||||
{CountryCode: "sa", MaxRoomCount: 2},
|
||||
{CountryCode: "AE", MaxRoomCount: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize country rules failed: %v", err)
|
||||
}
|
||||
if len(rules) != 2 || rules[0].CountryCode != "AE" || rules[1].CountryCode != "SA" || rules[1].MaxRoomCount != 2 {
|
||||
t.Fatalf("country rules mismatch: %+v", rules)
|
||||
}
|
||||
limits := humanRoomRobotCountryLimits(HumanRoomRobotConfig{CountryLimitEnabled: true, CountryRules: rules})
|
||||
if limits["SA"] != 2 || limits["AE"] != 1 || len(limits) != 2 {
|
||||
t.Fatalf("country limits mismatch: %+v", limits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHumanRoomRobotCountryRulesRejectsDuplicate(t *testing.T) {
|
||||
if _, err := normalizeHumanRoomRobotCountryRules([]*roomv1.AdminHumanRoomRobotCountryRule{
|
||||
{CountryCode: "SA", MaxRoomCount: 2},
|
||||
{CountryCode: "sa", MaxRoomCount: 3},
|
||||
}); err == nil {
|
||||
t.Fatalf("duplicated country rules must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,3 +132,75 @@ func TestSendGiftPassesHostPeriodScopeToWallet(t *testing.T) {
|
||||
t.Fatalf("room-service must pass host period scope to wallet: %+v", wallet.lastDebit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGiftPassesBagSourceToWallet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{
|
||||
BillingReceiptId: "receipt-bag-single",
|
||||
CoinSpent: 20,
|
||||
ChargeAmount: 20,
|
||||
ChargeAssetType: "BAG",
|
||||
HeatValue: 20,
|
||||
GiftTypeCode: "normal",
|
||||
},
|
||||
{
|
||||
BillingReceiptId: "receipt-bag-batch-1",
|
||||
CoinSpent: 10,
|
||||
ChargeAmount: 10,
|
||||
ChargeAssetType: "BAG",
|
||||
HeatValue: 10,
|
||||
GiftTypeCode: "normal",
|
||||
},
|
||||
{
|
||||
BillingReceiptId: "receipt-bag-batch-2",
|
||||
CoinSpent: 10,
|
||||
ChargeAmount: 10,
|
||||
ChargeAssetType: "BAG",
|
||||
HeatValue: 10,
|
||||
GiftTypeCode: "normal",
|
||||
},
|
||||
}}
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)}
|
||||
svc := newRocketTestService(t, repository, wallet, now)
|
||||
|
||||
roomID := "room-bag-source"
|
||||
senderID := int64(2101)
|
||||
targetOneID := int64(2102)
|
||||
targetTwoID := int64(2103)
|
||||
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
|
||||
joinRocketRoom(t, ctx, svc, roomID, targetOneID)
|
||||
joinRocketRoom(t, ctx, svc, roomID, targetTwoID)
|
||||
|
||||
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: rocketMeta(roomID, senderID, "bag-single"),
|
||||
TargetType: "user",
|
||||
TargetUserId: targetOneID,
|
||||
GiftId: "gift-bag-single",
|
||||
GiftCount: 2,
|
||||
EntitlementId: "ent-bag-single",
|
||||
Source: "bag",
|
||||
}); err != nil {
|
||||
t.Fatalf("send single bag gift failed: %v", err)
|
||||
}
|
||||
if wallet.lastDebit == nil || wallet.lastDebit.GetEntitlementId() != "ent-bag-single" || wallet.lastDebit.GetChargeSource() != "bag" {
|
||||
t.Fatalf("single bag gift must pass entitlement/source to wallet: %+v", wallet.lastDebit)
|
||||
}
|
||||
|
||||
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: rocketMeta(roomID, senderID, "bag-batch"),
|
||||
TargetType: "user",
|
||||
TargetUserId: targetOneID,
|
||||
TargetUserIds: []int64{targetOneID, targetTwoID},
|
||||
GiftId: "gift-bag-batch",
|
||||
GiftCount: 1,
|
||||
EntitlementId: "ent-bag-batch",
|
||||
Source: "bag",
|
||||
}); err != nil {
|
||||
t.Fatalf("send batch bag gift failed: %v", err)
|
||||
}
|
||||
if wallet.lastBatch == nil || wallet.lastBatch.GetEntitlementId() != "ent-bag-batch" || wallet.lastBatch.GetChargeSource() != "bag" {
|
||||
t.Fatalf("batch bag gift must pass entitlement/source to wallet: %+v", wallet.lastBatch)
|
||||
}
|
||||
}
|
||||
|
||||
@ -265,6 +265,11 @@ type HumanRoomRobotCountryPool struct {
|
||||
RobotUserIDs []int64
|
||||
}
|
||||
|
||||
type HumanRoomRobotCountryRule struct {
|
||||
CountryCode string
|
||||
MaxRoomCount int32
|
||||
}
|
||||
|
||||
type HumanRoomRobotConfig struct {
|
||||
AppCode string
|
||||
Enabled bool
|
||||
@ -289,6 +294,8 @@ type HumanRoomRobotConfig struct {
|
||||
MaxGiftSenders int64
|
||||
CountryPools []HumanRoomRobotCountryPool
|
||||
AllowedOwnerIDs []string
|
||||
CountryLimitEnabled bool
|
||||
CountryRules []HumanRoomRobotCountryRule
|
||||
UpdatedByAdminID uint64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
|
||||
@ -114,6 +114,8 @@ type robotRoomRuntime struct {
|
||||
cancel context.CancelFunc
|
||||
// token 区分同一房间的前后两次启动,旧 goroutine 退出时不能误删新 runtime。
|
||||
token string
|
||||
// countryCode 记录真人房间机器人 runtime 所属国家,用于后台国家限额变更后精确清理。
|
||||
countryCode string
|
||||
// configUpdatedAtMS 记录启动 runtime 时采用的配置版本;后台改配置后扫描器据此重启真人房间机器人 runtime。
|
||||
configUpdatedAtMS int64
|
||||
}
|
||||
|
||||
@ -358,6 +358,8 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
max_gift_senders BIGINT NOT NULL DEFAULT 1,
|
||||
country_pools_json JSON NOT NULL,
|
||||
allowed_owner_ids_json JSON NOT NULL,
|
||||
country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
country_rules_json JSON NOT NULL,
|
||||
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
@ -465,6 +467,8 @@ func (r *Repository) ensureHumanRobotConfigBehaviorSchema(ctx context.Context) e
|
||||
{name: "room_target_min_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_min_online INT NOT NULL DEFAULT 8 AFTER room_full_stop_online`},
|
||||
{name: "room_target_max_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_max_online INT NOT NULL DEFAULT 10 AFTER room_target_min_online`},
|
||||
{name: "allowed_owner_ids_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN allowed_owner_ids_json JSON NULL AFTER country_pools_json`},
|
||||
{name: "country_limit_enabled", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER allowed_owner_ids_json`},
|
||||
{name: "country_rules_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_rules_json JSON NULL AFTER country_limit_enabled`},
|
||||
}
|
||||
for _, column := range columns {
|
||||
exists, err := r.columnExists(ctx, "room_human_robot_configs", column.name)
|
||||
@ -1565,7 +1569,9 @@ func (r *Repository) GetHumanRoomRobotConfig(ctx context.Context) (roomservice.H
|
||||
normal_gift_interval_ms, normal_gift_interval_min_ms, normal_gift_interval_max_ms,
|
||||
gift_ids_json, lucky_gift_ids_json, super_lucky_gift_ids_json,
|
||||
lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, max_gift_senders,
|
||||
country_pools_json, COALESCE(allowed_owner_ids_json, JSON_ARRAY()), updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
country_pools_json, COALESCE(allowed_owner_ids_json, JSON_ARRAY()),
|
||||
country_limit_enabled, COALESCE(country_rules_json, JSON_ARRAY()),
|
||||
updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
FROM room_human_robot_configs
|
||||
WHERE app_code = ?`,
|
||||
appcode.FromContext(ctx),
|
||||
@ -1587,7 +1593,7 @@ func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config room
|
||||
config.AppCode = appCode
|
||||
config.CreatedAtMS = firstPositiveInt64(config.CreatedAtMS, nowMS)
|
||||
config.UpdatedAtMS = nowMS
|
||||
rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, err := humanRoomRobotConfigJSON(config)
|
||||
rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, rawCountryRules, err := humanRoomRobotConfigJSON(config)
|
||||
if err != nil {
|
||||
return roomservice.HumanRoomRobotConfig{}, err
|
||||
}
|
||||
@ -1598,8 +1604,9 @@ func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config room
|
||||
normal_gift_interval_ms, normal_gift_interval_min_ms, normal_gift_interval_max_ms,
|
||||
gift_ids_json, lucky_gift_ids_json, super_lucky_gift_ids_json,
|
||||
lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, max_gift_senders,
|
||||
country_pools_json, allowed_owner_ids_json, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
country_pools_json, allowed_owner_ids_json, country_limit_enabled, country_rules_json,
|
||||
updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
candidate_room_max_online = VALUES(candidate_room_max_online),
|
||||
@ -1623,6 +1630,8 @@ func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config room
|
||||
max_gift_senders = VALUES(max_gift_senders),
|
||||
country_pools_json = VALUES(country_pools_json),
|
||||
allowed_owner_ids_json = VALUES(allowed_owner_ids_json),
|
||||
country_limit_enabled = VALUES(country_limit_enabled),
|
||||
country_rules_json = VALUES(country_rules_json),
|
||||
updated_by_admin_id = VALUES(updated_by_admin_id),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
config.AppCode,
|
||||
@ -1648,6 +1657,8 @@ func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config room
|
||||
config.MaxGiftSenders,
|
||||
string(rawPools),
|
||||
string(rawAllowedOwners),
|
||||
boolToInt(config.CountryLimitEnabled),
|
||||
string(rawCountryRules),
|
||||
config.UpdatedByAdminID,
|
||||
config.CreatedAtMS,
|
||||
config.UpdatedAtMS,
|
||||
@ -1756,6 +1767,8 @@ func scanHumanRoomRobotConfig(row humanRoomRobotConfigScanner) (roomservice.Huma
|
||||
var rawSuperLucky string
|
||||
var rawPools string
|
||||
var rawAllowedOwners string
|
||||
var countryLimitEnabled int
|
||||
var rawCountryRules string
|
||||
if err := row.Scan(
|
||||
&config.AppCode,
|
||||
&enabled,
|
||||
@ -1780,6 +1793,8 @@ func scanHumanRoomRobotConfig(row humanRoomRobotConfigScanner) (roomservice.Huma
|
||||
&config.MaxGiftSenders,
|
||||
&rawPools,
|
||||
&rawAllowedOwners,
|
||||
&countryLimitEnabled,
|
||||
&rawCountryRules,
|
||||
&config.UpdatedByAdminID,
|
||||
&config.CreatedAtMS,
|
||||
&config.UpdatedAtMS,
|
||||
@ -1802,31 +1817,39 @@ func scanHumanRoomRobotConfig(row humanRoomRobotConfigScanner) (roomservice.Huma
|
||||
if err := json.Unmarshal([]byte(rawAllowedOwners), &config.AllowedOwnerIDs); err != nil {
|
||||
return roomservice.HumanRoomRobotConfig{}, err
|
||||
}
|
||||
config.CountryLimitEnabled = countryLimitEnabled == 1
|
||||
if err := json.Unmarshal([]byte(rawCountryRules), &config.CountryRules); err != nil {
|
||||
return roomservice.HumanRoomRobotConfig{}, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func humanRoomRobotConfigJSON(config roomservice.HumanRoomRobotConfig) ([]byte, []byte, []byte, []byte, []byte, error) {
|
||||
func humanRoomRobotConfigJSON(config roomservice.HumanRoomRobotConfig) ([]byte, []byte, []byte, []byte, []byte, []byte, error) {
|
||||
rawGifts, err := json.Marshal(config.GiftIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
rawLucky, err := json.Marshal(config.LuckyGiftIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
rawSuperLucky, err := json.Marshal(config.SuperLuckyGiftIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
rawPools, err := json.Marshal(config.CountryPools)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
rawAllowedOwners, err := json.Marshal(config.AllowedOwnerIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
return rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, nil
|
||||
rawCountryRules, err := json.Marshal(config.CountryRules)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
return rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, rawCountryRules, nil
|
||||
}
|
||||
|
||||
func splitHumanRobotAllowedOwnerIDs(values []string) ([]string, []int64) {
|
||||
|
||||
@ -476,6 +476,8 @@ CREATE TABLE IF NOT EXISTS user_cp_applications (
|
||||
KEY idx_user_cp_app_target_status (app_code, target_user_id, status, updated_at_ms),
|
||||
KEY idx_user_cp_app_requester_status (app_code, requester_user_id, status, updated_at_ms),
|
||||
KEY idx_user_cp_app_pair_status (app_code, requester_user_id, target_user_id, status, relation_type),
|
||||
KEY idx_user_cp_app_admin_created (app_code, created_at_ms, application_id),
|
||||
KEY idx_user_cp_app_admin_filter (app_code, status, relation_type, created_at_ms, application_id),
|
||||
KEY idx_user_cp_app_expire (app_code, status, expires_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 CP 关系申请表';
|
||||
|
||||
@ -501,7 +503,9 @@ CREATE TABLE IF NOT EXISTS user_cp_relationships (
|
||||
PRIMARY KEY (app_code, relationship_id),
|
||||
UNIQUE KEY uk_user_cp_rel_active_pair (app_code, user_a_id, user_b_id, status),
|
||||
KEY idx_user_cp_rel_user_a (app_code, user_a_id, status, relation_type, updated_at_ms),
|
||||
KEY idx_user_cp_rel_user_b (app_code, user_b_id, status, relation_type, updated_at_ms)
|
||||
KEY idx_user_cp_rel_user_b (app_code, user_b_id, status, relation_type, updated_at_ms),
|
||||
KEY idx_user_cp_rel_admin_formed (app_code, formed_at_ms, relationship_id),
|
||||
KEY idx_user_cp_rel_admin_filter (app_code, status, relation_type, formed_at_ms, relationship_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 CP 关系表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_cp_relationship_breakups (
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
SET @add_cp_app_admin_created := (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
'ALTER TABLE user_cp_applications ADD INDEX idx_user_cp_app_admin_created (app_code, created_at_ms, application_id)',
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_cp_applications'
|
||||
AND INDEX_NAME = 'idx_user_cp_app_admin_created'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @add_cp_app_admin_created;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @add_cp_app_admin_filter := (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
'ALTER TABLE user_cp_applications ADD INDEX idx_user_cp_app_admin_filter (app_code, status, relation_type, created_at_ms, application_id)',
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_cp_applications'
|
||||
AND INDEX_NAME = 'idx_user_cp_app_admin_filter'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @add_cp_app_admin_filter;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @add_cp_rel_admin_formed := (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
'ALTER TABLE user_cp_relationships ADD INDEX idx_user_cp_rel_admin_formed (app_code, formed_at_ms, relationship_id)',
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_cp_relationships'
|
||||
AND INDEX_NAME = 'idx_user_cp_rel_admin_formed'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @add_cp_rel_admin_formed;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @add_cp_rel_admin_filter := (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
'ALTER TABLE user_cp_relationships ADD INDEX idx_user_cp_rel_admin_filter (app_code, status, relation_type, formed_at_ms, relationship_id)',
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_cp_relationships'
|
||||
AND INDEX_NAME = 'idx_user_cp_rel_admin_filter'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @add_cp_rel_admin_filter;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -9,6 +9,8 @@ const (
|
||||
AssetCoinSellerCoin = "COIN_SELLER_COIN"
|
||||
// AssetRobotCoin 是机器人房间专用金币资产,只允许内部机器人送礼链路扣减,不进入真人充值消费口径。
|
||||
AssetRobotCoin = "ROBOT_COIN"
|
||||
// AssetBagGift 是送礼回执里的支付来源标识,不是钱包资产账户;Bag 礼物只扣用户资源权益库存。
|
||||
AssetBagGift = "BAG"
|
||||
// AssetGiftPoint 是历史礼物积分资产,只保留历史账本查询兼容;新送礼不会再给它入账。
|
||||
AssetGiftPoint = "GIFT_POINT"
|
||||
// AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。
|
||||
@ -150,6 +152,10 @@ type DebitGiftCommand struct {
|
||||
TargetAgencyOwnerUserID int64
|
||||
// RobotGift 表示本次扣费只服务机器人房间展示:扣 ROBOT_COIN,不给主播钻石,不进入真实礼物墙投影。
|
||||
RobotGift bool
|
||||
// EntitlementID 非空表示本次送礼来自用户背包礼物权益,账务只扣库存但仍按礼物价格计算房间贡献。
|
||||
EntitlementID string
|
||||
// ChargeSource 区分 coin/bag;为空按 coin 兼容旧链路。
|
||||
ChargeSource string
|
||||
}
|
||||
|
||||
// DebitGiftTargetCommand 是批量送礼中单个接收方的账务语义。
|
||||
@ -178,6 +184,8 @@ type BatchDebitGiftCommand struct {
|
||||
RegionID int64
|
||||
SenderRegionID int64
|
||||
Targets []DebitGiftTargetCommand
|
||||
EntitlementID string
|
||||
ChargeSource string
|
||||
}
|
||||
|
||||
// Receipt 是账务命令落账后的稳定回执。
|
||||
@ -204,6 +212,8 @@ type Receipt struct {
|
||||
HostPeriodDiamondAdded int64
|
||||
// HostPeriodCycleKey 是工资周期键,当前按 UTC 月生成,后续结算按此键定位周期账户。
|
||||
HostPeriodCycleKey string
|
||||
EntitlementID string
|
||||
ChargeSource string
|
||||
}
|
||||
|
||||
// BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。
|
||||
@ -1210,6 +1220,9 @@ func ValidSalaryAssetType(assetType string) bool {
|
||||
}
|
||||
|
||||
func NormalizeGiftChargeAssetType(assetType string) string {
|
||||
if strings.ToUpper(strings.TrimSpace(assetType)) == AssetBagGift {
|
||||
return AssetBagGift
|
||||
}
|
||||
return AssetCoin
|
||||
}
|
||||
|
||||
|
||||
@ -196,6 +196,25 @@ type ResourceShopItem struct {
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
type ResourceShopPurchaseOrder struct {
|
||||
AppCode string
|
||||
OrderID string
|
||||
CommandID string
|
||||
UserID int64
|
||||
ShopItemID int64
|
||||
ResourceID int64
|
||||
Resource Resource
|
||||
ResourceSnapshotJSON string
|
||||
DurationDays int32
|
||||
PriceCoin int64
|
||||
Status string
|
||||
WalletTransactionID string
|
||||
ResourceGrantID string
|
||||
EntitlementID string
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
type UserResourceEntitlement struct {
|
||||
AppCode string
|
||||
EntitlementID string
|
||||
@ -457,6 +476,16 @@ type ListResourceShopItemsQuery struct {
|
||||
ActiveOnly bool
|
||||
}
|
||||
|
||||
type ListResourceShopPurchaseOrdersQuery struct {
|
||||
AppCode string
|
||||
UserID int64
|
||||
ResourceType string
|
||||
Status string
|
||||
Keyword string
|
||||
Page int32
|
||||
PageSize int32
|
||||
}
|
||||
|
||||
type ResourceShopItemsCommand struct {
|
||||
AppCode string
|
||||
Items []ResourceShopItemInput
|
||||
|
||||
@ -269,6 +269,22 @@ func (s *Service) SetResourceShopItemStatus(ctx context.Context, command resourc
|
||||
return s.repository.SetResourceShopItemStatus(ctx, command)
|
||||
}
|
||||
|
||||
func (s *Service) ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, 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.ListResourceShopPurchaseOrders(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) {
|
||||
if strings.TrimSpace(command.CommandID) == "" || command.UserID <= 0 || command.ShopItemID <= 0 {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InvalidArgument, "resource shop purchase command is incomplete")
|
||||
|
||||
@ -106,6 +106,7 @@ type Repository interface {
|
||||
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)
|
||||
ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, int64, error)
|
||||
PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error)
|
||||
ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error)
|
||||
ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error)
|
||||
|
||||
@ -2874,6 +2874,86 @@ func TestGiftConfigMustSelectGiftResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDebitGiftFromBagConsumesEntitlementWithoutCoinDebit 验证背包送礼只扣礼物权益库存,但房间贡献仍按礼物原价结算。
|
||||
func TestDebitGiftFromBagConsumesEntitlementWithoutCoinDebit(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
ctx := context.Background()
|
||||
|
||||
giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||||
ResourceCode: "gift_bag_spark",
|
||||
ResourceType: resourcedomain.TypeGift,
|
||||
Name: "Bag Spark",
|
||||
Status: resourcedomain.StatusActive,
|
||||
Grantable: true,
|
||||
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||||
UsageScopes: []string{"gift"},
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create bag gift resource failed: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
|
||||
GiftID: "bag-spark",
|
||||
ResourceID: giftResource.ResourceID,
|
||||
Status: resourcedomain.StatusActive,
|
||||
Name: "Bag Spark",
|
||||
PriceVersion: "v1",
|
||||
CoinPrice: 5,
|
||||
OperatorUserID: 90001,
|
||||
}); err != nil {
|
||||
t.Fatalf("create bag gift config failed: %v", err)
|
||||
}
|
||||
grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||||
CommandID: "cmd-grant-bag-spark",
|
||||
TargetUserID: 61001,
|
||||
ResourceID: giftResource.ResourceID,
|
||||
Quantity: 3,
|
||||
Reason: "bag gift test",
|
||||
OperatorUserID: 90001,
|
||||
GrantSource: resourcedomain.GrantSourceAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GrantResource bag gift failed: %v", err)
|
||||
}
|
||||
if len(grant.Items) != 1 || grant.Items[0].EntitlementID == "" {
|
||||
t.Fatalf("bag gift grant must return entitlement: %+v", grant)
|
||||
}
|
||||
|
||||
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
|
||||
CommandID: "cmd-send-bag-spark",
|
||||
RoomID: "room-bag",
|
||||
SenderUserID: 61001,
|
||||
TargetUserID: 61002,
|
||||
GiftID: "bag-spark",
|
||||
GiftCount: 2,
|
||||
EntitlementID: grant.Items[0].EntitlementID,
|
||||
ChargeSource: "bag",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DebitGift from bag failed: %v", err)
|
||||
}
|
||||
if receipt.CoinSpent != 10 || receipt.ChargeAmount != 10 || receipt.HeatValue != 10 || receipt.ChargeAssetType != ledger.AssetBagGift {
|
||||
t.Fatalf("bag gift must keep original gift value in receipt: %+v", receipt)
|
||||
}
|
||||
if receipt.ChargeSource != "bag" || receipt.EntitlementID != grant.Items[0].EntitlementID || receipt.BalanceAfter != 0 {
|
||||
t.Fatalf("bag gift source/entitlement/balance mismatch: %+v", receipt)
|
||||
}
|
||||
_, quantity, remaining := repository.ActiveResourceEntitlement(61001, giftResource.ResourceID)
|
||||
if quantity != 3 || remaining != 1 {
|
||||
t.Fatalf("bag gift entitlement quantity mismatch: quantity=%d remaining=%d", quantity, remaining)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 0 {
|
||||
t.Fatalf("bag gift must not write COIN wallet entries, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftDebited"); got != 1 {
|
||||
t.Fatalf("bag gift must still publish WalletGiftDebited for normal room projections, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_outbox", "command_id = ? AND event_type = ?", "cmd-send-bag-spark", "UserResourceChanged"); got != 1 {
|
||||
t.Fatalf("bag gift must publish resource change event, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftConfigCoercesDiamondChargeToCoinAndEffectiveRange(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
@ -53,6 +53,8 @@ const (
|
||||
outboxStatusRetryable = "retryable"
|
||||
outboxStatusFailed = "failed"
|
||||
giftWallChargeAssetMixed = "MIXED"
|
||||
giftChargeSourceCoin = "coin"
|
||||
giftChargeSourceBag = "bag"
|
||||
)
|
||||
|
||||
// Repository 是 wallet-service 的 MySQL v2 账本入口。
|
||||
@ -119,6 +121,10 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureResourceShopPurchaseOrderIndexes(ctx, db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureHostSalarySettlementSchema(ctx, db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
@ -127,6 +133,27 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
|
||||
return &Repository{db: db}, nil
|
||||
}
|
||||
|
||||
func ensureResourceShopPurchaseOrderIndexes(ctx context.Context, db *sql.DB) error {
|
||||
// 后台售卖记录默认按购买时间倒序翻页;旧表只有用户维度索引,未筛选时容易退化成全表排序。
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE resource_shop_purchase_orders
|
||||
ADD INDEX idx_resource_shop_purchase_time (app_code, created_at_ms, order_id)`); err != nil && !isDuplicateKeyNameError(err) {
|
||||
return err
|
||||
}
|
||||
// 道具类型筛选会先通过 resources 过滤出 resource_id,再回到订单表按时间分页;补 resource/time 组合索引避免回表扫描扩大。
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE resource_shop_purchase_orders
|
||||
ADD INDEX idx_resource_shop_purchase_resource_time (app_code, resource_id, created_at_ms, order_id)`); err != nil && !isDuplicateKeyNameError(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDuplicateKeyNameError(err error) bool {
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1061
|
||||
}
|
||||
|
||||
func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error {
|
||||
// Host/Agency 工资记录需要区分自动和手动来源;旧库没有该列时启动期补齐,避免手动结算上线后插入失败。
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
@ -393,10 +420,16 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
if err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
chargeSource := normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID)
|
||||
if command.RobotGift && chargeSource == giftChargeSourceBag {
|
||||
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "robot gift cannot use bag entitlement")
|
||||
}
|
||||
chargeAssetType := price.ChargeAssetType
|
||||
if command.RobotGift {
|
||||
// 机器人礼物复用真实礼物价格和素材快照,但扣专用 ROBOT_COIN,避免污染真人 COIN 充值消费口径。
|
||||
chargeAssetType = ledger.AssetRobotCoin
|
||||
} else if chargeSource == giftChargeSourceBag {
|
||||
chargeAssetType = ledger.AssetBagGift
|
||||
}
|
||||
chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
|
||||
if err != nil {
|
||||
@ -433,7 +466,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
|
||||
// 机器人礼物扣的是展示专用 ROBOT_COIN;本地或新机器人账号第一次送礼时可能还没有
|
||||
// 该资产账户,必须先创建空账户,再由下方 robotCoinTopUp 按本次价格补足并立即扣减。
|
||||
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, chargeAssetType, command.RobotGift || chargeAmount == 0, nowMs)
|
||||
accountAssetType := chargeAssetType
|
||||
if chargeSource == giftChargeSourceBag {
|
||||
accountAssetType = ledger.AssetCoin
|
||||
}
|
||||
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, command.RobotGift || chargeAmount == 0 || chargeSource == giftChargeSourceBag, nowMs)
|
||||
if err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
@ -442,10 +479,14 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
// 机器人房间使用专用 ROBOT_COIN 做展示账务,按次补足后立刻扣减,不要求真实充值预算。
|
||||
robotCoinTopUp = chargeAmount - sender.AvailableAmount
|
||||
}
|
||||
if !command.RobotGift && sender.AvailableAmount < chargeAmount {
|
||||
if !command.RobotGift && chargeSource != giftChargeSourceBag && sender.AvailableAmount < chargeAmount {
|
||||
return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
||||
}
|
||||
senderAfter := sender.AvailableAmount + robotCoinTopUp - chargeAmount
|
||||
if chargeSource == giftChargeSourceBag {
|
||||
// Bag 礼物不扣 COIN;balance_after 只给客户端刷新余额使用,必须保持当前 COIN 余额。
|
||||
senderAfter = sender.AvailableAmount
|
||||
}
|
||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||
metadata := giftMetadata{
|
||||
AppCode: command.AppCode,
|
||||
@ -466,6 +507,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
ChargeAssetType: chargeAssetType,
|
||||
ChargeAmount: chargeAmount,
|
||||
CoinSpent: chargeAmount,
|
||||
ChargeSource: chargeSource,
|
||||
EntitlementID: strings.TrimSpace(command.EntitlementID),
|
||||
GiftPointAdded: 0,
|
||||
HeatValue: heatValue,
|
||||
BalanceAfter: senderAfter,
|
||||
@ -517,22 +560,30 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
sender.AvailableAmount = topUpAfter
|
||||
sender.Version++
|
||||
}
|
||||
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: command.SenderUserID,
|
||||
AssetType: chargeAssetType,
|
||||
AvailableDelta: -chargeAmount,
|
||||
FrozenDelta: 0,
|
||||
AvailableAfter: senderAfter,
|
||||
FrozenAfter: sender.FrozenAmount,
|
||||
CounterpartyUserID: command.TargetUserID,
|
||||
RoomID: command.RoomID,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
if chargeSource == giftChargeSourceBag {
|
||||
resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, command, giftConfig.ResourceID, int64(command.GiftCount), metadata, nowMs)
|
||||
if err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
events = append(events, resourceEvent)
|
||||
} else {
|
||||
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: command.SenderUserID,
|
||||
AssetType: chargeAssetType,
|
||||
AvailableDelta: -chargeAmount,
|
||||
FrozenDelta: 0,
|
||||
AvailableAfter: senderAfter,
|
||||
FrozenAfter: sender.FrozenAmount,
|
||||
CounterpartyUserID: command.TargetUserID,
|
||||
RoomID: command.RoomID,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
}
|
||||
if hostPeriodDiamondAdded > 0 {
|
||||
// 周期钻石与送礼扣费同事务提交;如果后续任一步失败,用户扣币和主播工资周期钻石会一起回滚。
|
||||
@ -546,7 +597,9 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
|
||||
if !command.RobotGift {
|
||||
// 机器人礼物只服务房间展示,不需要客户端余额私信、礼物墙投影或真实消费类下游事件。
|
||||
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
|
||||
if chargeSource != giftChargeSourceBag {
|
||||
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
|
||||
}
|
||||
events = append(events, giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
|
||||
}
|
||||
if hostPeriodDiamondAdded > 0 {
|
||||
@ -594,6 +647,11 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
if err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
chargeSource := normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID)
|
||||
chargeAssetType := price.ChargeAssetType
|
||||
if chargeSource == giftChargeSourceBag {
|
||||
chargeAssetType = ledger.AssetBagGift
|
||||
}
|
||||
chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
|
||||
if err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
@ -651,13 +709,36 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, nil
|
||||
}
|
||||
|
||||
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, totalChargeAmount == 0, nowMs)
|
||||
accountAssetType := chargeAssetType
|
||||
if chargeSource == giftChargeSourceBag {
|
||||
accountAssetType = ledger.AssetCoin
|
||||
}
|
||||
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, totalChargeAmount == 0 || chargeSource == giftChargeSourceBag, nowMs)
|
||||
if err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
if sender.AvailableAmount < totalChargeAmount {
|
||||
if chargeSource != giftChargeSourceBag && sender.AvailableAmount < totalChargeAmount {
|
||||
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
||||
}
|
||||
events := make([]walletOutboxEvent, 0, len(command.Targets)*3+1)
|
||||
if chargeSource == giftChargeSourceBag {
|
||||
resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, debitGiftCommandFromBatchCommand(command), giftConfig.ResourceID, int64(command.GiftCount)*int64(len(command.Targets)), giftMetadata{
|
||||
AppCode: command.AppCode,
|
||||
GiftID: command.GiftID,
|
||||
ResourceID: giftConfig.ResourceID,
|
||||
GiftCount: command.GiftCount,
|
||||
ChargeAssetType: chargeAssetType,
|
||||
ChargeSource: chargeSource,
|
||||
EntitlementID: strings.TrimSpace(command.EntitlementID),
|
||||
CoinSpent: totalChargeAmount,
|
||||
SenderUserID: command.SenderUserID,
|
||||
RoomID: command.RoomID,
|
||||
}, nowMs)
|
||||
if err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
events = append(events, resourceEvent)
|
||||
}
|
||||
|
||||
anyHostTarget := false
|
||||
for _, target := range command.Targets {
|
||||
@ -675,7 +756,6 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
}
|
||||
}
|
||||
|
||||
events := make([]walletOutboxEvent, 0, len(command.Targets)*3)
|
||||
targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets))
|
||||
for _, target := range command.Targets {
|
||||
hostPeriodDiamondAdded := int64(0)
|
||||
@ -695,6 +775,10 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
|
||||
transactionID := transactionID(command.AppCode, target.CommandID)
|
||||
senderAfter := sender.AvailableAmount - chargeAmount
|
||||
if chargeSource == giftChargeSourceBag {
|
||||
// Bag 批量送礼按总数量扣库存,COIN 余额不变;每个目标回执仍保留原礼物价格,供房间贡献和麦位热度复用。
|
||||
senderAfter = sender.AvailableAmount
|
||||
}
|
||||
metadata := giftMetadata{
|
||||
AppCode: command.AppCode,
|
||||
GiftID: command.GiftID,
|
||||
@ -711,9 +795,11 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
SortOrder: giftConfig.SortOrder,
|
||||
GiftCount: command.GiftCount,
|
||||
PriceVersion: price.PriceVersion,
|
||||
ChargeAssetType: price.ChargeAssetType,
|
||||
ChargeAssetType: chargeAssetType,
|
||||
ChargeAmount: chargeAmount,
|
||||
CoinSpent: chargeAmount,
|
||||
ChargeSource: chargeSource,
|
||||
EntitlementID: strings.TrimSpace(command.EntitlementID),
|
||||
GiftPointAdded: 0,
|
||||
HeatValue: heatValue,
|
||||
BalanceAfter: senderAfter,
|
||||
@ -739,26 +825,28 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, target.CommandID, bizTypeGiftDebit, debitRequestHash(debitGiftCommandFromBatchTarget(command, target)), fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
|
||||
sender.AvailableAmount = senderAfter
|
||||
sender.Version++
|
||||
if chargeSource != giftChargeSourceBag {
|
||||
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
|
||||
sender.AvailableAmount = senderAfter
|
||||
sender.Version++
|
||||
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: command.SenderUserID,
|
||||
AssetType: price.ChargeAssetType,
|
||||
AvailableDelta: -chargeAmount,
|
||||
FrozenDelta: 0,
|
||||
AvailableAfter: senderAfter,
|
||||
FrozenAfter: sender.FrozenAmount,
|
||||
CounterpartyUserID: target.TargetUserID,
|
||||
RoomID: command.RoomID,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: command.SenderUserID,
|
||||
AssetType: price.ChargeAssetType,
|
||||
AvailableDelta: -chargeAmount,
|
||||
FrozenDelta: 0,
|
||||
AvailableAfter: senderAfter,
|
||||
FrozenAfter: sender.FrozenAmount,
|
||||
CounterpartyUserID: target.TargetUserID,
|
||||
RoomID: command.RoomID,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
}
|
||||
if hostPeriodDiamondAdded > 0 {
|
||||
hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, target.CommandID, metadata, nowMs)
|
||||
@ -769,7 +857,7 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion
|
||||
}
|
||||
|
||||
events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs))
|
||||
events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
|
||||
if hostPeriodDiamondAdded > 0 {
|
||||
events = append(events, hostPeriodDiamondCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
|
||||
}
|
||||
@ -3223,6 +3311,10 @@ type giftMetadata struct {
|
||||
ChargeAssetType string `json:"charge_asset_type"`
|
||||
ChargeAmount int64 `json:"charge_amount"`
|
||||
CoinSpent int64 `json:"coin_spent"`
|
||||
// ChargeSource 记录本次送礼从哪里扣费;bag 会扣用户资源库存,但 CoinSpent 仍保留礼物价值用于房间贡献。
|
||||
ChargeSource string `json:"charge_source,omitempty"`
|
||||
// EntitlementID 只在背包送礼时存在,用于排查某次送礼具体消耗了哪条用户权益。
|
||||
EntitlementID string `json:"entitlement_id,omitempty"`
|
||||
// GiftPointAdded 是历史收礼积分字段,新送礼固定写 0,保留 JSON 字段只为旧事件解析兼容。
|
||||
GiftPointAdded int64 `json:"gift_point_added"`
|
||||
HeatValue int64 `json:"heat_value"`
|
||||
@ -3488,6 +3580,8 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger
|
||||
BalanceAfter: metadata.BalanceAfter,
|
||||
HostPeriodDiamondAdded: metadata.HostPeriodDiamondAdded,
|
||||
HostPeriodCycleKey: metadata.HostPeriodCycleKey,
|
||||
EntitlementID: strings.TrimSpace(metadata.EntitlementID),
|
||||
ChargeSource: normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID),
|
||||
}
|
||||
}
|
||||
|
||||
@ -4275,9 +4369,111 @@ func debitGiftCommandFromBatchTarget(command ledger.BatchDebitGiftCommand, targe
|
||||
TargetIsHost: target.TargetIsHost,
|
||||
TargetHostRegionID: target.TargetHostRegionID,
|
||||
TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID,
|
||||
EntitlementID: command.EntitlementID,
|
||||
ChargeSource: command.ChargeSource,
|
||||
}
|
||||
}
|
||||
|
||||
func debitGiftCommandFromBatchCommand(command ledger.BatchDebitGiftCommand) ledger.DebitGiftCommand {
|
||||
// 背包批量送礼的库存扣减只发生一次;这里构造共享命令快照用于统一校验 sender、gift 和 entitlement。
|
||||
return ledger.DebitGiftCommand{
|
||||
AppCode: command.AppCode,
|
||||
CommandID: command.CommandID,
|
||||
RoomID: command.RoomID,
|
||||
SenderUserID: command.SenderUserID,
|
||||
GiftID: command.GiftID,
|
||||
GiftCount: command.GiftCount,
|
||||
PriceVersion: command.PriceVersion,
|
||||
RegionID: command.RegionID,
|
||||
SenderRegionID: command.SenderRegionID,
|
||||
EntitlementID: command.EntitlementID,
|
||||
ChargeSource: command.ChargeSource,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGiftChargeSource(source string, entitlementID string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(source))
|
||||
if normalized == giftChargeSourceBag || strings.TrimSpace(entitlementID) != "" {
|
||||
return giftChargeSourceBag
|
||||
}
|
||||
return giftChargeSourceCoin
|
||||
}
|
||||
|
||||
func (r *Repository) consumeGiftEntitlementForSend(ctx context.Context, tx *sql.Tx, command ledger.DebitGiftCommand, giftResourceID int64, quantity int64, metadata giftMetadata, nowMs int64) (walletOutboxEvent, error) {
|
||||
entitlementID := strings.TrimSpace(command.EntitlementID)
|
||||
if entitlementID == "" {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "entitlement_id is required")
|
||||
}
|
||||
if command.SenderUserID <= 0 || giftResourceID <= 0 || quantity <= 0 {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag gift entitlement command is invalid")
|
||||
}
|
||||
row := tx.QueryRowContext(ctx, userResourceSelectSQL()+`
|
||||
WHERE e.app_code = ? AND e.entitlement_id = ?
|
||||
FOR UPDATE`,
|
||||
appcode.FromContext(ctx), entitlementID,
|
||||
)
|
||||
entitlement, err := scanUserResourceEntitlement(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.NotFound, "bag gift entitlement not found")
|
||||
}
|
||||
if err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
if entitlement.UserID != command.SenderUserID {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.PermissionDenied, "bag gift entitlement does not belong to sender")
|
||||
}
|
||||
if entitlement.ResourceID != giftResourceID {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag gift entitlement resource mismatch")
|
||||
}
|
||||
if entitlement.Status != resourcedomain.StatusActive || entitlement.Resource.Status != resourcedomain.StatusActive {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.Conflict, "bag gift entitlement is not active")
|
||||
}
|
||||
if resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) != resourcedomain.TypeGift {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag entitlement resource_type must be gift")
|
||||
}
|
||||
if entitlement.EffectiveAtMS > nowMs || (entitlement.ExpiresAtMS > 0 && entitlement.ExpiresAtMS <= nowMs) {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.Conflict, "bag gift entitlement is expired")
|
||||
}
|
||||
if entitlement.RemainingQuantity < quantity {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "bag gift inventory is insufficient")
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_resource_entitlements
|
||||
SET remaining_quantity = remaining_quantity - ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND entitlement_id = ? AND remaining_quantity >= ?`,
|
||||
quantity, nowMs, appcode.FromContext(ctx), entitlementID, quantity,
|
||||
)
|
||||
if err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
if affected != 1 {
|
||||
// 行锁后的二次条件正常不会失败;保留该分支用于防并发或手工修数后的 fail-close。
|
||||
return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "bag gift inventory is insufficient")
|
||||
}
|
||||
entitlement.RemainingQuantity -= quantity
|
||||
entitlement.UpdatedAtMS = nowMs
|
||||
return resourceOutboxEvent("UserResourceChanged", command.CommandID, command.SenderUserID, giftResourceID, map[string]any{
|
||||
"action": "consume_gift_send",
|
||||
"app_code": appcode.FromContext(ctx),
|
||||
"user_id": command.SenderUserID,
|
||||
"resource_id": giftResourceID,
|
||||
"resource_type": entitlement.Resource.ResourceType,
|
||||
"entitlement_id": entitlementID,
|
||||
"consumed_quantity": quantity,
|
||||
"remaining_quantity": entitlement.RemainingQuantity,
|
||||
"room_id": command.RoomID,
|
||||
"gift_id": command.GiftID,
|
||||
"charge_source": giftChargeSourceBag,
|
||||
"gift_value_coins": metadata.CoinSpent,
|
||||
"updated_at_ms": nowMs,
|
||||
"entitlement": entitlement,
|
||||
}, nowMs), nil
|
||||
}
|
||||
|
||||
func giftDebitBizType(command ledger.DebitGiftCommand) string {
|
||||
if command.RobotGift {
|
||||
return bizTypeRobotGiftDebit
|
||||
@ -4351,7 +4547,7 @@ func hostPeriodCycleKeyFromMS(ms int64) string {
|
||||
|
||||
func debitRequestHash(command ledger.DebitGiftCommand) string {
|
||||
// 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。
|
||||
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t",
|
||||
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t|%s|%s",
|
||||
appcode.Normalize(command.AppCode),
|
||||
command.RoomID,
|
||||
command.SenderUserID,
|
||||
@ -4365,6 +4561,8 @@ func debitRequestHash(command ledger.DebitGiftCommand) string {
|
||||
command.TargetHostRegionID,
|
||||
command.TargetAgencyOwnerUserID,
|
||||
command.RobotGift,
|
||||
strings.TrimSpace(command.EntitlementID),
|
||||
normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@ -1264,6 +1264,45 @@ func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedo
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
// ListResourceShopPurchaseOrders 返回后台售卖记录;订单事实取购买表,素材展示优先使用发放时的资源快照。
|
||||
func (r *Repository) ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, 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 = normalizeResourceShopPurchaseOrdersQuery(query)
|
||||
where, args := resourceShopPurchaseOrdersWhereSQL(query)
|
||||
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM resource_shop_purchase_orders po
|
||||
JOIN resources r ON r.app_code = po.app_code AND r.resource_id = po.resource_id
|
||||
`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, resourceShopPurchaseOrderSelectSQL()+where+`
|
||||
ORDER BY po.created_at_ms DESC, po.order_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.ResourceShopPurchaseOrder, 0, query.PageSize)
|
||||
for rows.Next() {
|
||||
item, err := scanResourceShopPurchaseOrder(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 {
|
||||
@ -2623,6 +2662,58 @@ func scanResourceShopItem(scanner scanTarget) (resourcedomain.ResourceShopItem,
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanResourceShopPurchaseOrder(scanner scanTarget) (resourcedomain.ResourceShopPurchaseOrder, error) {
|
||||
var item resourcedomain.ResourceShopPurchaseOrder
|
||||
var resource resourcedomain.Resource
|
||||
var scopesJSON string
|
||||
if err := scanner.Scan(
|
||||
&item.AppCode,
|
||||
&item.OrderID,
|
||||
&item.CommandID,
|
||||
&item.UserID,
|
||||
&item.ShopItemID,
|
||||
&item.ResourceID,
|
||||
&item.DurationDays,
|
||||
&item.PriceCoin,
|
||||
&item.Status,
|
||||
&item.WalletTransactionID,
|
||||
&item.ResourceGrantID,
|
||||
&item.EntitlementID,
|
||||
&item.ResourceSnapshotJSON,
|
||||
&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.ResourceShopPurchaseOrder{}, err
|
||||
}
|
||||
resource.UsageScopes = parseStringArray(scopesJSON)
|
||||
item.Resource = resourceFromSnapshotJSON(item.ResourceSnapshotJSON, resource)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanResourceGroup(scanner scanTarget) (resourcedomain.ResourceGroup, error) {
|
||||
var group resourcedomain.ResourceGroup
|
||||
err := scanner.Scan(
|
||||
@ -2896,6 +2987,20 @@ func resourceShopItemSelectSQL() string {
|
||||
`
|
||||
}
|
||||
|
||||
func resourceShopPurchaseOrderSelectSQL() string {
|
||||
return `
|
||||
SELECT po.app_code, po.order_id, po.command_id, po.user_id, po.shop_item_id, po.resource_id,
|
||||
po.duration_days, po.price_coin, po.status, po.wallet_transaction_id, po.resource_grant_id,
|
||||
po.entitlement_id, COALESCE(CAST(gi.resource_snapshot_json AS CHAR), ''), po.created_at_ms, po.updated_at_ms,
|
||||
` + resourceColumnsWithAlias("r") + `
|
||||
FROM resource_shop_purchase_orders po
|
||||
JOIN resources r ON r.app_code = po.app_code AND r.resource_id = po.resource_id
|
||||
LEFT JOIN resource_grant_items gi ON gi.app_code = po.app_code
|
||||
AND gi.grant_id = po.resource_grant_id
|
||||
AND gi.resource_id = po.resource_id
|
||||
`
|
||||
}
|
||||
|
||||
func userResourceSelectSQL() string {
|
||||
return `
|
||||
SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
|
||||
@ -3032,6 +3137,29 @@ func resourceShopItemsWhereSQL(query resourcedomain.ListResourceShopItemsQuery)
|
||||
return where, args
|
||||
}
|
||||
|
||||
func resourceShopPurchaseOrdersWhereSQL(query resourcedomain.ListResourceShopPurchaseOrdersQuery) (string, []any) {
|
||||
where := `WHERE po.app_code = ?`
|
||||
args := []any{query.AppCode}
|
||||
if query.UserID > 0 {
|
||||
where += ` AND po.user_id = ?`
|
||||
args = append(args, query.UserID)
|
||||
}
|
||||
if query.Status != "" {
|
||||
where += ` AND po.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 (po.order_id LIKE ? OR po.command_id LIKE ? OR r.resource_code LIKE ? OR r.name LIKE ?)`
|
||||
args = append(args, like, like, 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) {
|
||||
@ -3086,6 +3214,20 @@ func normalizeResourceShopItemsQuery(query resourcedomain.ListResourceShopItemsQ
|
||||
return query
|
||||
}
|
||||
|
||||
func normalizeResourceShopPurchaseOrdersQuery(query resourcedomain.ListResourceShopPurchaseOrdersQuery) resourcedomain.ListResourceShopPurchaseOrdersQuery {
|
||||
if query.UserID < 0 {
|
||||
query.UserID = 0
|
||||
}
|
||||
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
|
||||
@ -3717,6 +3859,27 @@ func normalizeJSONObject(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func resourceFromSnapshotJSON(snapshotJSON string, fallback resourcedomain.Resource) resourcedomain.Resource {
|
||||
snapshotJSON = strings.TrimSpace(snapshotJSON)
|
||||
if snapshotJSON == "" || snapshotJSON == "{}" {
|
||||
return fallback
|
||||
}
|
||||
var snapshot resourcedomain.Resource
|
||||
if err := json.Unmarshal([]byte(snapshotJSON), &snapshot); err != nil || snapshot.ResourceID <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if snapshot.AppCode == "" {
|
||||
snapshot.AppCode = fallback.AppCode
|
||||
}
|
||||
if snapshot.ResourceCode == "" {
|
||||
snapshot.ResourceCode = fallback.ResourceCode
|
||||
}
|
||||
if snapshot.ResourceType == "" {
|
||||
snapshot.ResourceType = fallback.ResourceType
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func cpRelationTypeFromPresentationJSON(value string) string {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(normalizeJSONObject(value)), &payload); err != nil {
|
||||
|
||||
@ -388,6 +388,26 @@ func (s *Server) SetResourceShopItemStatus(ctx context.Context, req *walletv1.Se
|
||||
return &walletv1.ResourceShopItemResponse{Item: resourceShopItemToProto(item)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListResourceShopPurchaseOrders(ctx context.Context, req *walletv1.ListResourceShopPurchaseOrdersRequest) (*walletv1.ListResourceShopPurchaseOrdersResponse, error) {
|
||||
items, total, err := s.svc.ListResourceShopPurchaseOrders(ctx, resourcedomain.ListResourceShopPurchaseOrdersQuery{
|
||||
AppCode: req.GetAppCode(),
|
||||
UserID: req.GetUserId(),
|
||||
ResourceType: req.GetResourceType(),
|
||||
Status: req.GetStatus(),
|
||||
Keyword: req.GetKeyword(),
|
||||
Page: req.GetPage(),
|
||||
PageSize: req.GetPageSize(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
resp := &walletv1.ListResourceShopPurchaseOrdersResponse{Orders: make([]*walletv1.ResourceShopPurchaseOrder, 0, len(items)), Total: total}
|
||||
for _, item := range items {
|
||||
resp.Orders = append(resp.Orders, resourceShopPurchaseOrderToProto(item))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) PurchaseResourceShopItem(ctx context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, error) {
|
||||
receipt, err := s.svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
@ -617,6 +637,26 @@ func resourceShopItemToProto(item resourcedomain.ResourceShopItem) *walletv1.Res
|
||||
}
|
||||
}
|
||||
|
||||
func resourceShopPurchaseOrderToProto(item resourcedomain.ResourceShopPurchaseOrder) *walletv1.ResourceShopPurchaseOrder {
|
||||
return &walletv1.ResourceShopPurchaseOrder{
|
||||
AppCode: appcode.Normalize(item.AppCode),
|
||||
OrderId: item.OrderID,
|
||||
CommandId: item.CommandID,
|
||||
UserId: item.UserID,
|
||||
ShopItemId: item.ShopItemID,
|
||||
ResourceId: item.ResourceID,
|
||||
Resource: resourceToProto(item.Resource),
|
||||
DurationDays: item.DurationDays,
|
||||
PriceCoin: item.PriceCoin,
|
||||
Status: item.Status,
|
||||
WalletTransactionId: item.WalletTransactionID,
|
||||
ResourceGrantId: item.ResourceGrantID,
|
||||
EntitlementId: item.EntitlementID,
|
||||
CreatedAtMs: item.CreatedAtMS,
|
||||
UpdatedAtMs: item.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func resourceShopPurchaseReceiptToProto(receipt resourcedomain.ResourceShopPurchaseReceipt) *walletv1.PurchaseResourceShopItemResponse {
|
||||
return &walletv1.PurchaseResourceShopItemResponse{
|
||||
OrderId: receipt.OrderID,
|
||||
|
||||
@ -41,6 +41,8 @@ func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest)
|
||||
// 工资政策区域来自 user-service host profile,不能用房间 visible_region_id 兜底。
|
||||
TargetHostRegionID: req.GetTargetHostRegionId(),
|
||||
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
|
||||
EntitlementID: req.GetEntitlementId(),
|
||||
ChargeSource: req.GetChargeSource(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
@ -73,6 +75,8 @@ func (s *Server) BatchDebitGift(ctx context.Context, req *walletv1.BatchDebitGif
|
||||
RegionID: req.GetRegionId(),
|
||||
SenderRegionID: req.GetSenderRegionId(),
|
||||
Targets: targets,
|
||||
EntitlementID: req.GetEntitlementId(),
|
||||
ChargeSource: req.GetChargeSource(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
@ -131,6 +135,8 @@ func debitGiftResponseFromReceipt(receipt ledger.Receipt) *walletv1.DebitGiftRes
|
||||
PriceVersion: receipt.PriceVersion,
|
||||
HostPeriodDiamondAdded: receipt.HostPeriodDiamondAdded,
|
||||
HostPeriodCycleKey: receipt.HostPeriodCycleKey,
|
||||
EntitlementId: receipt.EntitlementID,
|
||||
ChargeSource: receipt.ChargeSource,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
559
tests/smoke/bag_gift_real_http_flow_test.go
Normal file
559
tests/smoke/bag_gift_real_http_flow_test.go
Normal file
@ -0,0 +1,559 @@
|
||||
package smoke_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
bagGiftAppCode = "lalu"
|
||||
bagGiftJWTSecret = "dev-shared-secret"
|
||||
|
||||
bagGiftSenderID = int64(920001)
|
||||
bagGiftTargetID = int64(920002)
|
||||
|
||||
bagGiftID = "bag_smoke_gift"
|
||||
bagGiftResourceCode = "bag_smoke_gift_resource"
|
||||
bagGiftEntitlement = "bag-smoke-entitlement"
|
||||
bagGiftPrice = int64(10)
|
||||
bagGiftHeatValue = int64(10)
|
||||
bagGiftSendCount = int64(2)
|
||||
)
|
||||
|
||||
func TestBagGiftLocalHTTPFlow(t *testing.T) {
|
||||
if os.Getenv("BAG_GIFT_REAL_HTTP_FLOW_TEST") != "1" {
|
||||
t.Skip("set BAG_GIFT_REAL_HTTP_FLOW_TEST=1 to run the local bag gift HTTP flow")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, err := sql.Open("mysql", bagGiftEnvOr("BAG_GIFT_MYSQL_DSN", "hyapp:hyapp@tcp(127.0.0.1:23306)/?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true"))
|
||||
if err != nil {
|
||||
t.Fatalf("open mysql failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
t.Fatalf("mysql ping failed: %v", err)
|
||||
}
|
||||
|
||||
flow := &bagGiftHTTPFlow{
|
||||
db: db,
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
gateway: strings.TrimRight(bagGiftEnvOr("BAG_GIFT_GATEWAY", "http://127.0.0.1:13000"), "/"),
|
||||
startMS: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
regionID, err := flow.lookupUSRegion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup region failed: %v", err)
|
||||
}
|
||||
if err := flow.cleanup(ctx); err != nil {
|
||||
t.Fatalf("cleanup failed: %v", err)
|
||||
}
|
||||
if err := flow.seedUsers(ctx, regionID); err != nil {
|
||||
t.Fatalf("seed users failed: %v", err)
|
||||
}
|
||||
if err := flow.seedBagGift(ctx); err != nil {
|
||||
t.Fatalf("seed bag gift failed: %v", err)
|
||||
}
|
||||
if err := flow.assertMyGiftResourceVisible(ctx); err != nil {
|
||||
t.Fatalf("my gift resource assertion failed: %v", err)
|
||||
}
|
||||
roomID, err := flow.createRoomAndJoin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create room and join failed: %v", err)
|
||||
}
|
||||
if err := flow.assertBagPanelVisible(ctx, roomID); err != nil {
|
||||
t.Fatalf("bag gift panel assertion failed: %v", err)
|
||||
}
|
||||
|
||||
commandID := bagGiftCommandID("send")
|
||||
sendResp, err := flow.sendBagGift(ctx, roomID, commandID)
|
||||
if err != nil {
|
||||
t.Fatalf("send bag gift failed: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(sendResp.BillingReceiptID) == "" {
|
||||
t.Fatalf("send response must include billing_receipt_id: %+v", sendResp)
|
||||
}
|
||||
if sendResp.RoomHeat < bagGiftPrice*bagGiftSendCount {
|
||||
t.Fatalf("room_heat must include bag gift value, got=%d want>=%d resp=%+v", sendResp.RoomHeat, bagGiftPrice*bagGiftSendCount, sendResp)
|
||||
}
|
||||
if err := flow.assertWalletFacts(ctx, commandID, sendResp.BillingReceiptID); err != nil {
|
||||
t.Fatalf("wallet facts mismatch: %v", err)
|
||||
}
|
||||
if err := flow.assertRoomFacts(ctx, roomID, commandID); err != nil {
|
||||
t.Fatalf("room facts mismatch: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("bag gift HTTP smoke passed: gateway=%s room_id=%s command_id=%s receipt=%s", flow.gateway, roomID, commandID, sendResp.BillingReceiptID)
|
||||
}
|
||||
|
||||
type bagGiftHTTPFlow struct {
|
||||
db *sql.DB
|
||||
http *http.Client
|
||||
gateway string
|
||||
startMS int64
|
||||
}
|
||||
|
||||
type bagGiftEnvelope struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type bagGiftCreateRoomData struct {
|
||||
Room struct {
|
||||
RoomID string `json:"room_id"`
|
||||
} `json:"room"`
|
||||
}
|
||||
|
||||
type bagGiftSendData struct {
|
||||
BillingReceiptID string `json:"billing_receipt_id"`
|
||||
RoomHeat int64 `json:"room_heat"`
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) lookupUSRegion(ctx context.Context) (int64, error) {
|
||||
var regionID int64
|
||||
err := f.db.QueryRowContext(ctx, `
|
||||
SELECT r.region_id
|
||||
FROM hyapp_user.regions r
|
||||
JOIN hyapp_user.region_countries rc ON rc.app_code = r.app_code AND rc.region_id = r.region_id
|
||||
WHERE r.app_code = ? AND rc.country_code = 'US' AND r.status = 'active' AND rc.status = 'active'
|
||||
ORDER BY r.region_id
|
||||
LIMIT 1`, bagGiftAppCode).Scan(®ionID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("lookup US region: %w", err)
|
||||
}
|
||||
return regionID, nil
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) cleanup(ctx context.Context) error {
|
||||
// 这个 smoke 使用固定用户和固定资源,运行前先把本用例自己产生的房间、钱包流水和权益恢复到确定状态。
|
||||
// 业务行为仍然只通过 gateway HTTP 发起;这里的 SQL 只负责隔离测试数据,避免旧房间 owner 唯一约束和旧权益库存影响结果。
|
||||
queries := []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{`DELETE FROM hyapp_room.room_background_images WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
|
||||
{`DELETE FROM hyapp_room.room_snapshots WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
|
||||
{`DELETE FROM hyapp_room.room_command_log WHERE app_code = ? AND (command_id LIKE 'bag-gift-smoke:%' OR room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?)))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
|
||||
{`DELETE FROM hyapp_room.room_outbox WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
|
||||
{`DELETE FROM hyapp_room.room_user_gift_stats WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
|
||||
{`DELETE FROM hyapp_room.room_user_presence WHERE app_code = ? AND (user_id IN (?, ?) OR room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?)))`, []any{bagGiftAppCode, bagGiftSenderID, bagGiftTargetID, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
|
||||
{`DELETE FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?)`, []any{bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
|
||||
{`DELETE FROM hyapp_wallet.wallet_entries WHERE app_code = ? AND transaction_id IN (SELECT transaction_id FROM hyapp_wallet.wallet_transactions WHERE app_code = ? AND command_id LIKE 'bag-gift-smoke:%')`, []any{bagGiftAppCode, bagGiftAppCode}},
|
||||
{`DELETE FROM hyapp_wallet.wallet_outbox WHERE app_code = ? AND command_id LIKE 'bag-gift-smoke:%'`, []any{bagGiftAppCode}},
|
||||
{`DELETE FROM hyapp_wallet.wallet_transactions WHERE app_code = ? AND command_id LIKE 'bag-gift-smoke:%'`, []any{bagGiftAppCode}},
|
||||
}
|
||||
for _, query := range queries {
|
||||
if _, err := f.db.ExecContext(ctx, query.sql, query.args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) seedUsers(ctx context.Context, regionID int64) error {
|
||||
if _, err := f.db.ExecContext(ctx, `
|
||||
DELETE FROM hyapp_user.user_display_user_ids
|
||||
WHERE app_code = ? AND (user_id IN (?, ?) OR display_user_id IN ('920001', '920002'))`,
|
||||
bagGiftAppCode, bagGiftSenderID, bagGiftTargetID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
users := []struct {
|
||||
id int64
|
||||
name string
|
||||
avatar string
|
||||
}{
|
||||
{bagGiftSenderID, "Bag Smoke Sender", "https://cdn.example.com/bag-smoke-sender.png"},
|
||||
{bagGiftTargetID, "Bag Smoke Target", "https://cdn.example.com/bag-smoke-target.png"},
|
||||
}
|
||||
for _, user := range users {
|
||||
displayID := strconv.FormatInt(user.id, 10)
|
||||
if _, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO hyapp_user.users (
|
||||
app_code, user_id, default_display_user_id, current_display_user_id, current_display_user_id_kind,
|
||||
username, gender, country, region_id, avatar, profile_completed, profile_completed_at_ms,
|
||||
onboarding_status, status, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, 'default', ?, 'unknown', 'US', ?, ?, 1, ?, 'completed', 'active', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
current_display_user_id = VALUES(current_display_user_id),
|
||||
current_display_user_id_kind = VALUES(current_display_user_id_kind),
|
||||
username = VALUES(username),
|
||||
country = VALUES(country),
|
||||
region_id = VALUES(region_id),
|
||||
avatar = VALUES(avatar),
|
||||
profile_completed = VALUES(profile_completed),
|
||||
profile_completed_at_ms = VALUES(profile_completed_at_ms),
|
||||
onboarding_status = VALUES(onboarding_status),
|
||||
status = VALUES(status),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
bagGiftAppCode, user.id, displayID, displayID, user.name, regionID, user.avatar, nowMS, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO hyapp_user.user_display_user_ids (
|
||||
app_code, display_user_id, user_id, display_user_id_kind, status,
|
||||
assigned_at_ms, activated_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, 'default', 'active', ?, ?, ?, ?)`,
|
||||
bagGiftAppCode, displayID, user.id, nowMS, nowMS, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) seedBagGift(ctx context.Context) error {
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
if _, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO hyapp_wallet.resources (
|
||||
app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled,
|
||||
grant_strategy, wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount,
|
||||
usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order,
|
||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, 'gift', 'Bag Smoke Gift', 'active', TRUE, TRUE,
|
||||
'increase_quantity', '', 0, 'coin', ?, 0, JSON_ARRAY('gift_panel'),
|
||||
'https://cdn.example.com/bag-smoke-gift.png', 'https://cdn.example.com/bag-smoke-gift-preview.png',
|
||||
'https://cdn.example.com/bag-smoke-gift-animation.svga', JSON_OBJECT(), 0, 0, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
resource_type = 'gift',
|
||||
name = VALUES(name),
|
||||
status = 'active',
|
||||
grantable = TRUE,
|
||||
manager_grant_enabled = TRUE,
|
||||
grant_strategy = 'increase_quantity',
|
||||
price_type = 'coin',
|
||||
coin_price = VALUES(coin_price),
|
||||
usage_scope_json = VALUES(usage_scope_json),
|
||||
asset_url = VALUES(asset_url),
|
||||
preview_url = VALUES(preview_url),
|
||||
animation_url = VALUES(animation_url),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
bagGiftAppCode, bagGiftResourceCode, bagGiftPrice, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var resourceID int64
|
||||
if err := f.db.QueryRowContext(ctx, `
|
||||
SELECT resource_id FROM hyapp_wallet.resources WHERE app_code = ? AND resource_code = ?`,
|
||||
bagGiftAppCode, bagGiftResourceCode,
|
||||
).Scan(&resourceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO hyapp_wallet.gift_configs (
|
||||
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
|
||||
gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
|
||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, 'active', 'Bag Smoke Gift', 1, CAST(? AS JSON), 'normal', 0, 0, JSON_ARRAY(), 0, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
resource_id = VALUES(resource_id),
|
||||
status = 'active',
|
||||
name = VALUES(name),
|
||||
sort_order = VALUES(sort_order),
|
||||
presentation_json = VALUES(presentation_json),
|
||||
gift_type_code = 'normal',
|
||||
effective_from_ms = 0,
|
||||
effective_to_ms = 0,
|
||||
effect_types_json = JSON_ARRAY(),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
bagGiftAppCode, bagGiftID, resourceID, `{"preview_url":"https://cdn.example.com/bag-smoke-gift-preview.png","animation_url":"https://cdn.example.com/bag-smoke-gift-animation.svga"}`, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO hyapp_wallet.gift_config_regions (app_code, gift_id, region_id, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`,
|
||||
bagGiftAppCode, bagGiftID, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO hyapp_wallet.wallet_gift_prices (
|
||||
app_code, gift_id, price_version, status, charge_asset_type, coin_price,
|
||||
gift_point_amount, heat_value, effective_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, 'bag-smoke-v1', 'active', 'COIN', ?, 0, ?, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = 'active',
|
||||
charge_asset_type = 'COIN',
|
||||
coin_price = VALUES(coin_price),
|
||||
gift_point_amount = 0,
|
||||
heat_value = VALUES(heat_value),
|
||||
effective_at_ms = 0,
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
bagGiftAppCode, bagGiftID, bagGiftPrice, bagGiftHeatValue, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO hyapp_wallet.wallet_accounts (
|
||||
app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, 'COIN', 0, 0, 1, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE available_amount = 0, frozen_amount = 0, version = version + 1, updated_at_ms = VALUES(updated_at_ms)`,
|
||||
bagGiftAppCode, bagGiftSenderID, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `
|
||||
INSERT INTO hyapp_wallet.user_resource_entitlements (
|
||||
app_code, entitlement_id, user_id, resource_id, status, quantity, remaining_quantity,
|
||||
effective_at_ms, expires_at_ms, source_grant_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, 'active', 3, 3, 0, 0, 'bag-smoke-grant', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
user_id = VALUES(user_id),
|
||||
resource_id = VALUES(resource_id),
|
||||
status = 'active',
|
||||
quantity = 3,
|
||||
remaining_quantity = 3,
|
||||
effective_at_ms = 0,
|
||||
expires_at_ms = 0,
|
||||
source_grant_id = VALUES(source_grant_id),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
bagGiftAppCode, bagGiftEntitlement, bagGiftSenderID, resourceID, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) createRoomAndJoin(ctx context.Context) (string, error) {
|
||||
var createData bagGiftCreateRoomData
|
||||
if err := f.request(ctx, http.MethodPost, "/api/v1/rooms/create", bagGiftSenderID, map[string]any{
|
||||
"command_id": bagGiftCommandID("create-room"),
|
||||
"seat_count": 10,
|
||||
"mode": "voice",
|
||||
"room_name": "Bag Gift Smoke Room",
|
||||
"room_avatar": "https://cdn.example.com/bag-gift-smoke-room.png",
|
||||
"room_description": "bag gift smoke",
|
||||
}, &createData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(createData.Room.RoomID) == "" {
|
||||
return "", fmt.Errorf("create room response missing room.room_id")
|
||||
}
|
||||
for _, userID := range []int64{bagGiftSenderID, bagGiftTargetID} {
|
||||
if err := f.request(ctx, http.MethodPost, "/api/v1/rooms/join", userID, map[string]any{
|
||||
"room_id": createData.Room.RoomID,
|
||||
"command_id": bagGiftCommandID(fmt.Sprintf("join-%d", userID)),
|
||||
"role": "audience",
|
||||
}, nil); err != nil {
|
||||
return "", fmt.Errorf("join user %d: %w", userID, err)
|
||||
}
|
||||
}
|
||||
return createData.Room.RoomID, nil
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) assertMyGiftResourceVisible(ctx context.Context) error {
|
||||
var raw json.RawMessage
|
||||
if err := f.request(ctx, http.MethodGet, "/api/v1/users/me/resources?resource_type=gift", bagGiftSenderID, nil, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
body := string(raw)
|
||||
if !strings.Contains(body, bagGiftEntitlement) || !strings.Contains(body, bagGiftResourceCode) {
|
||||
return fmt.Errorf("my resources does not contain bag gift entitlement/resource data=%s", body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) assertBagPanelVisible(ctx context.Context, roomID string) error {
|
||||
var raw json.RawMessage
|
||||
if err := f.request(ctx, http.MethodGet, "/api/v1/rooms/"+url.PathEscape(roomID)+"/gift-panel", bagGiftSenderID, nil, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
body := string(raw)
|
||||
if !strings.Contains(body, bagGiftID) {
|
||||
return fmt.Errorf("gift panel does not contain gift_id=%s data=%s", bagGiftID, body)
|
||||
}
|
||||
if !strings.Contains(body, bagGiftEntitlement) || !strings.Contains(body, `"source":"bag"`) {
|
||||
return fmt.Errorf("gift panel does not contain bag entitlement/source data=%s", body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) sendBagGift(ctx context.Context, roomID string, commandID string) (bagGiftSendData, error) {
|
||||
var data bagGiftSendData
|
||||
err := f.request(ctx, http.MethodPost, "/api/v1/rooms/gift/send", bagGiftSenderID, map[string]any{
|
||||
"room_id": roomID,
|
||||
"command_id": commandID,
|
||||
"target_type": "user",
|
||||
"target_user_ids": []int64{bagGiftTargetID},
|
||||
"gift_id": bagGiftID,
|
||||
"gift_count": bagGiftSendCount,
|
||||
"source": "bag",
|
||||
"entitlement_id": bagGiftEntitlement,
|
||||
}, &data)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) assertWalletFacts(ctx context.Context, commandID string, billingReceiptID string) error {
|
||||
var remaining int64
|
||||
if err := f.db.QueryRowContext(ctx, `
|
||||
SELECT remaining_quantity
|
||||
FROM hyapp_wallet.user_resource_entitlements
|
||||
WHERE app_code = ? AND entitlement_id = ?`,
|
||||
bagGiftAppCode, bagGiftEntitlement,
|
||||
).Scan(&remaining); err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining != 1 {
|
||||
return fmt.Errorf("remaining_quantity=%d, want 1", remaining)
|
||||
}
|
||||
|
||||
var transactionID, bizType, status, chargeSource, chargeAssetType, gotBillingReceiptID string
|
||||
var coinSpent, heatValue int64
|
||||
if err := f.db.QueryRowContext(ctx, `
|
||||
SELECT transaction_id, biz_type, status,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_source')), ''),
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_asset_type')), ''),
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.billing_receipt_id')), ''),
|
||||
COALESCE(JSON_EXTRACT(metadata_json, '$.coin_spent'), 0),
|
||||
COALESCE(JSON_EXTRACT(metadata_json, '$.heat_value'), 0)
|
||||
FROM hyapp_wallet.wallet_transactions
|
||||
WHERE app_code = ? AND command_id = ?`,
|
||||
bagGiftAppCode, commandID,
|
||||
).Scan(&transactionID, &bizType, &status, &chargeSource, &chargeAssetType, &gotBillingReceiptID, &coinSpent, &heatValue); err != nil {
|
||||
return err
|
||||
}
|
||||
// Bag 送礼只是不扣 COIN 账户库存,交易快照里的 coin_spent/heat_value 仍表示礼物原价总值;
|
||||
// 房间贡献、麦位热度和主播积分继续复用这份价值口径,不能因为来源是背包就归零。
|
||||
if bizType != "gift_debit" || status != "succeeded" || chargeSource != "bag" || chargeAssetType != "BAG" || coinSpent != bagGiftPrice*bagGiftSendCount || heatValue != bagGiftPrice*bagGiftSendCount {
|
||||
return fmt.Errorf("wallet transaction mismatch biz_type=%s status=%s source=%s asset=%s coin_spent=%d heat=%d", bizType, status, chargeSource, chargeAssetType, coinSpent, heatValue)
|
||||
}
|
||||
if gotBillingReceiptID != billingReceiptID {
|
||||
return fmt.Errorf("billing receipt mismatch metadata=%s response=%s transaction_id=%s", gotBillingReceiptID, billingReceiptID, transactionID)
|
||||
}
|
||||
var entryCount int64
|
||||
if err := f.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM hyapp_wallet.wallet_entries WHERE app_code = ? AND transaction_id = ?`,
|
||||
bagGiftAppCode, transactionID,
|
||||
).Scan(&entryCount); err != nil {
|
||||
return err
|
||||
}
|
||||
if entryCount != 0 {
|
||||
return fmt.Errorf("bag send must not create coin wallet entries, got=%d", entryCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) assertRoomFacts(ctx context.Context, roomID string, commandID string) error {
|
||||
var commandCount int64
|
||||
if err := f.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM hyapp_room.room_command_log
|
||||
WHERE app_code = ? AND room_id = ? AND command_id = ? AND command_type = 'send_gift'`,
|
||||
bagGiftAppCode, roomID, commandID,
|
||||
).Scan(&commandCount); err != nil {
|
||||
return err
|
||||
}
|
||||
if commandCount != 1 {
|
||||
return fmt.Errorf("room_command_log count=%d, want 1", commandCount)
|
||||
}
|
||||
|
||||
var outboxCount int64
|
||||
if err := f.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM hyapp_room.room_outbox
|
||||
WHERE app_code = ? AND room_id = ? AND event_type = 'RoomGiftSent' AND created_at_ms >= ?`,
|
||||
bagGiftAppCode, roomID, f.startMS,
|
||||
).Scan(&outboxCount); err != nil {
|
||||
return err
|
||||
}
|
||||
if outboxCount == 0 {
|
||||
return fmt.Errorf("missing RoomGiftSent outbox event")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *bagGiftHTTPFlow) request(ctx context.Context, method string, path string, userID int64, body any, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, f.gateway+path, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+bagGiftTokenForUser(userID))
|
||||
req.Header.Set("X-App-Code", bagGiftAppCode)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := f.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
var envelope bagGiftEnvelope
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return fmt.Errorf("%s %s returned non-envelope status=%d body=%s", method, path, resp.StatusCode, string(raw))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != "OK" {
|
||||
return fmt.Errorf("%s %s failed status=%d code=%s message=%s body=%s", method, path, resp.StatusCode, envelope.Code, envelope.Message, string(raw))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if rawOut, ok := out.(*json.RawMessage); ok {
|
||||
*rawOut = append((*rawOut)[:0], envelope.Data...)
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
||||
return fmt.Errorf("decode data for %s %s failed: %w data=%s", method, path, err, string(envelope.Data))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bagGiftTokenForUser(userID int64) string {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": strconv.FormatInt(userID, 10),
|
||||
"app_code": bagGiftAppCode,
|
||||
"sid": "bag-gift-smoke-" + strconv.FormatInt(userID, 10),
|
||||
"profile_completed": true,
|
||||
"onboarding_status": "completed",
|
||||
"exp": time.Now().Add(2 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
signed, err := token.SignedString([]byte(bagGiftJWTSecret))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
func bagGiftCommandID(key string) string {
|
||||
return fmt.Sprintf("bag-gift-smoke:%s:%d", key, time.Now().UTC().UnixNano())
|
||||
}
|
||||
|
||||
func bagGiftEnvOr(key string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user