From 7a59d3571cf08dcf62690ceb2a2c9b50a36baf6f Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 5 Jun 2026 12:09:37 +0800 Subject: [PATCH] Update gift ratios and production service changes --- .../037_gift_diamond_global_defaults.sql | 18 ++ server/admin/cmd/server/main.go | 2 +- .../admin/configs/config.tencent.example.yaml | 2 +- .../internal/integration/userclient/client.go | 1 + .../internal/modules/giftdiamond/service.go | 17 +- .../admin/internal/modules/hostorg/reader.go | 44 +++- .../admin/internal/modules/hostorg/request.go | 4 +- .../admin/internal/modules/hostorg/service.go | 26 ++- .../internal/modules/luckygift/handler.go | 2 +- server/admin/internal/modules/payment/dto.go | 96 +++++++-- .../admin/internal/modules/payment/handler.go | 66 +++++- .../mysql/initdb/001_activity_service.sql | 105 ++++------ .../internal/service/luckygift/config.go | 192 ------------------ .../internal/service/luckygift/config_test.go | 60 ------ .../storage/mysql/lucky_gift_repository.go | 62 ------ .../internal/storage/mysql/repository.go | 70 ++++--- .../internal/room/service/gift.go | 11 +- .../room/service/gift_leaderboard_test.go | 81 ++++++++ .../internal/room/service/pipeline.go | 2 +- .../room/service/room_gift_leaderboard.go | 9 +- .../internal/room/service/service.go | 6 +- .../statistics-service/internal/app/app.go | 32 ++- .../internal/app/local_flow_test.go | 2 +- .../internal/service/host/commands.go | 8 +- .../internal/service/host/service.go | 18 +- .../internal/service/host/service_test.go | 42 ++++ .../internal/storage/mysql/host/admin.go | 46 +++-- .../mysql/initdb/001_wallet_service.sql | 4 +- .../internal/service/wallet/service_test.go | 38 ++-- .../mysql/google_payment_repository.go | 17 +- .../internal/storage/mysql/repository.go | 34 +++- 31 files changed, 598 insertions(+), 519 deletions(-) create mode 100644 scripts/mysql/037_gift_diamond_global_defaults.sql create mode 100644 services/room-service/internal/room/service/gift_leaderboard_test.go diff --git a/scripts/mysql/037_gift_diamond_global_defaults.sql b/scripts/mysql/037_gift_diamond_global_defaults.sql new file mode 100644 index 00000000..4219de83 --- /dev/null +++ b/scripts/mysql/037_gift_diamond_global_defaults.sql @@ -0,0 +1,18 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_wallet; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +INSERT INTO gift_diamond_ratio_configs ( + app_code, region_id, gift_type_code, status, ratio_percent, + created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms +) VALUES + ('lalu', 0, 'normal', 'active', 100.00, 0, 0, @now_ms, @now_ms), + ('lalu', 0, 'lucky', 'active', 10.00, 0, 0, @now_ms, @now_ms), + ('lalu', 0, 'super_lucky', 'active', 1.00, 0, 0, @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + status = VALUES(status), + ratio_percent = VALUES(ratio_percent), + updated_by_admin_id = VALUES(updated_by_admin_id), + updated_at_ms = VALUES(updated_at_ms); diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 1d1f6aec..c7912c59 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -239,7 +239,7 @@ func main() { LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler), LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler), Menu: menumodule.New(store, auditHandler), - Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler), + Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, auditHandler), RBAC: rbacmodule.New(store, auditHandler), RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler), Report: reportmodule.New(userDB, roomClient), diff --git a/server/admin/configs/config.tencent.example.yaml b/server/admin/configs/config.tencent.example.yaml index 78edae8a..3276871b 100644 --- a/server/admin/configs/config.tencent.example.yaml +++ b/server/admin/configs/config.tencent.example.yaml @@ -36,7 +36,7 @@ bootstrap: password: "REPLACE_WITH_TEMP_BOOTSTRAP_PASSWORD" user_service: addr: "10.2.1.16:13005" - request_timeout: "3s" + request_timeout: "10s" wallet_service: addr: "10.2.1.15:13004" request_timeout: "3s" diff --git a/server/admin/internal/integration/userclient/client.go b/server/admin/internal/integration/userclient/client.go index 7a295a76..42c8d105 100644 --- a/server/admin/internal/integration/userclient/client.go +++ b/server/admin/internal/integration/userclient/client.go @@ -400,6 +400,7 @@ type HostProfile struct { Avatar string `json:"avatar"` RegionName string `json:"regionName"` CurrentAgencyName string `json:"currentAgencyName"` + Diamond int64 `json:"diamond"` } type CoinSellerProfile struct { diff --git a/server/admin/internal/modules/giftdiamond/service.go b/server/admin/internal/modules/giftdiamond/service.go index 6a21664c..a68000cf 100644 --- a/server/admin/internal/modules/giftdiamond/service.go +++ b/server/admin/internal/modules/giftdiamond/service.go @@ -91,7 +91,7 @@ func (s *Service) resolveRatio(ctx context.Context, appCode string, regionID int return item, err } } - return ratioDTO{GiftTypeCode: giftType, RatioPercent: "100.00", RegionID: regionID, EffectiveRegionID: 0, Status: statusActive}, nil + return ratioDTO{GiftTypeCode: giftType, RatioPercent: defaultRatioPercent(giftType), RegionID: regionID, EffectiveRegionID: 0, Status: statusActive}, nil } func (s *Service) getRatio(ctx context.Context, appCode string, regionID int64, giftType string) (ratioDTO, bool, error) { @@ -133,6 +133,17 @@ func formatPercentString(raw string) string { return fmt.Sprintf("%.2f", value) } +func defaultRatioPercent(giftType string) string { + switch strings.TrimSpace(giftType) { + case "lucky": + return "10.00" + case "super_lucky": + return "1.00" + default: + return "100.00" + } +} + func (s *Service) ensureSchema(ctx context.Context) error { if s == nil || s.db == nil { return errors.New("wallet db is not configured") @@ -159,8 +170,8 @@ func (s *Service) ensureSchema(ctx context.Context) error { created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, 0, 'normal', 'active', 100.00, 0, 0, 0, 0), - (?, 0, 'lucky', 'active', 100.00, 0, 0, 0, 0), - (?, 0, 'super_lucky', 'active', 100.00, 0, 0, 0, 0)`, + (?, 0, 'lucky', 'active', 10.00, 0, 0, 0, 0), + (?, 0, 'super_lucky', 'active', 1.00, 0, 0, 0, 0)`, "lalu", "lalu", "lalu") return err } diff --git a/server/admin/internal/modules/hostorg/reader.go b/server/admin/internal/modules/hostorg/reader.go index 23d7227a..46cccc47 100644 --- a/server/admin/internal/modules/hostorg/reader.go +++ b/server/admin/internal/modules/hostorg/reader.go @@ -382,6 +382,7 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user defer rows.Close() items := make([]*userclient.HostProfile, 0, query.PageSize) + userIDs := make([]int64, 0, query.PageSize) for rows.Next() { item := &userclient.HostProfile{} if err := rows.Scan( @@ -403,8 +404,19 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user return nil, 0, err } items = append(items, item) + userIDs = append(userIDs, item.UserID) } - return items, total, rows.Err() + if err := rows.Err(); err != nil { + return nil, 0, err + } + diamonds, err := r.hostPeriodDiamonds(ctx, appCode, userIDs) + if err != nil { + return nil, 0, err + } + for _, item := range items { + item.Diamond = diamonds[item.UserID] + } + return items, total, nil } // ListCoinSellers 聚合 user-service 身份事实和 wallet-service 币商余额,只做后台展示读模型。 @@ -676,6 +688,36 @@ func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs return result, rows.Err() } +func (r *Reader) hostPeriodDiamonds(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) { + result := make(map[int64]int64, len(userIDs)) + if r == nil || r.walletDB == nil || len(userIDs) == 0 { + return result, nil + } + placeholders := strings.TrimRight(strings.Repeat("?,", len(userIDs)), ",") + args := []any{appCode, time.Now().UTC().Format("2006-01")} + for _, userID := range userIDs { + args = append(args, userID) + } + rows, err := r.walletDB.QueryContext(ctx, fmt.Sprintf(` + SELECT user_id, total_diamonds + FROM host_period_diamond_accounts + WHERE app_code = ? AND cycle_key = ? AND user_id IN (%s) + `, placeholders), args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var userID int64 + var amount int64 + if err := rows.Scan(&userID, &amount); err != nil { + return nil, err + } + result[userID] = amount + } + return result, rows.Err() +} + func sqlPlaceholders(count int) string { if count <= 0 { return "" diff --git a/server/admin/internal/modules/hostorg/request.go b/server/admin/internal/modules/hostorg/request.go index 89c01e88..aa6b2ad9 100644 --- a/server/admin/internal/modules/hostorg/request.go +++ b/server/admin/internal/modules/hostorg/request.go @@ -21,7 +21,7 @@ type createBDLeaderRequest struct { type createBDRequest struct { CommandID string `json:"commandId" binding:"required"` TargetUserID int64 `json:"targetUserId" binding:"required"` - ParentLeaderUserID int64 `json:"parentLeaderUserId" binding:"required"` + ParentLeaderUserID int64 `json:"parentLeaderUserId"` Reason string `json:"reason"` } @@ -73,7 +73,7 @@ type coinSellerSalaryRateTierRequest struct { type createAgencyRequest struct { CommandID string `json:"commandId" binding:"required"` OwnerUserID int64 `json:"ownerUserId" binding:"required"` - ParentBDUserID int64 `json:"parentBdUserId" binding:"required"` + ParentBDUserID int64 `json:"parentBdUserId"` Name string `json:"name" binding:"required"` JoinEnabled *bool `json:"joinEnabled" binding:"required"` MaxHosts int32 `json:"maxHosts"` diff --git a/server/admin/internal/modules/hostorg/service.go b/server/admin/internal/modules/hostorg/service.go index e9bc87ad..adeea825 100644 --- a/server/admin/internal/modules/hostorg/service.go +++ b/server/admin/internal/modules/hostorg/service.go @@ -135,9 +135,16 @@ func (s *Service) CreateBD(ctx context.Context, actorID int64, requestID string, if err != nil { return nil, err } - parentLeaderUserID, err := s.resolveDisplayUserID(ctx, requestID, req.ParentLeaderUserID) - if err != nil { - return nil, err + parentLeaderUserID := int64(0) + if req.ParentLeaderUserID < 0 { + return nil, fmt.Errorf("parent_leader_user_id must not be negative") + } + if req.ParentLeaderUserID > 0 { + // 父级 Leader 是可选关系;传入短 ID 时才解析成内部 user_id,未传时保留 0 表示独立 BD。 + parentLeaderUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentLeaderUserID) + if err != nil { + return nil, err + } } return s.userClient.CreateBD(ctx, userclient.CreateBDRequest{ RequestID: requestID, @@ -281,9 +288,16 @@ func (s *Service) CreateAgency(ctx context.Context, actorID int64, requestID str if err != nil { return nil, err } - parentBDUserID, err := s.resolveDisplayUserID(ctx, requestID, req.ParentBDUserID) - if err != nil { - return nil, err + parentBDUserID := int64(0) + if req.ParentBDUserID < 0 { + return nil, fmt.Errorf("parent_bd_user_id must not be negative") + } + if req.ParentBDUserID > 0 { + // 父级 BD 是可选关系;传入短 ID 时才解析成内部 user_id,未传时保留 0 表示独立 Agency。 + parentBDUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentBDUserID) + if err != nil { + return nil, err + } } joinEnabled := false if req.JoinEnabled != nil { diff --git a/server/admin/internal/modules/luckygift/handler.go b/server/admin/internal/modules/luckygift/handler.go index c23ba0ea..32c53011 100644 --- a/server/admin/internal/modules/luckygift/handler.go +++ b/server/admin/internal/modules/luckygift/handler.go @@ -147,7 +147,7 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) { return } item := configFromProto(resp.GetConfig()) - shared.OperationLogWithResourceID(c, h.audit, "upsert-lucky-gift-config", "lucky_gift_rules", item.PoolID, "success", "") + shared.OperationLogWithResourceID(c, h.audit, "upsert-lucky-gift-config", "lucky_gift_rule_versions", item.PoolID, "success", "") response.OK(c, item) } diff --git a/server/admin/internal/modules/payment/dto.go b/server/admin/internal/modules/payment/dto.go index d6fd1387..feb29e78 100644 --- a/server/admin/internal/modules/payment/dto.go +++ b/server/admin/internal/modules/payment/dto.go @@ -9,24 +9,33 @@ import ( ) type rechargeBillDTO struct { - AppCode string `json:"appCode"` - TransactionID string `json:"transactionId"` - CommandID string `json:"commandId"` - RechargeType string `json:"rechargeType"` - Status string `json:"status"` - ExternalRef string `json:"externalRef"` - UserID int64 `json:"userId"` - SellerUserID int64 `json:"sellerUserId"` - SellerRegionID int64 `json:"sellerRegionId"` - TargetRegionID int64 `json:"targetRegionId"` - PolicyID int64 `json:"policyId"` - PolicyVersion string `json:"policyVersion"` - CurrencyCode string `json:"currencyCode"` - CoinAmount int64 `json:"coinAmount"` - USDMinorAmount int64 `json:"usdMinorAmount"` - ExchangeCoinAmount int64 `json:"exchangeCoinAmount"` - ExchangeUSDMinorAmount int64 `json:"exchangeUsdMinorAmount"` - CreatedAtMS int64 `json:"createdAtMs"` + AppCode string `json:"appCode"` + TransactionID string `json:"transactionId"` + CommandID string `json:"commandId"` + RechargeType string `json:"rechargeType"` + Status string `json:"status"` + ExternalRef string `json:"externalRef"` + UserID int64 `json:"userId"` + SellerUserID int64 `json:"sellerUserId"` + SellerRegionID int64 `json:"sellerRegionId"` + TargetRegionID int64 `json:"targetRegionId"` + PolicyID int64 `json:"policyId"` + PolicyVersion string `json:"policyVersion"` + CurrencyCode string `json:"currencyCode"` + CoinAmount int64 `json:"coinAmount"` + USDMinorAmount int64 `json:"usdMinorAmount"` + ExchangeCoinAmount int64 `json:"exchangeCoinAmount"` + ExchangeUSDMinorAmount int64 `json:"exchangeUsdMinorAmount"` + CreatedAtMS int64 `json:"createdAtMs"` + User rechargeBillUserDTO `json:"user"` + Seller rechargeBillUserDTO `json:"seller"` +} + +type rechargeBillUserDTO struct { + UserID string `json:"userId"` + DisplayUserID string `json:"displayUserId"` + Username string `json:"username"` + Avatar string `json:"avatar"` } type rechargeProductDTO struct { @@ -81,6 +90,57 @@ func rechargeBillFromProto(item *walletv1.RechargeBill) rechargeBillDTO { } } +// collectRechargeBillUserIDs 把付款用户和币商用户放进同一个去重列表;后续只查一次 users 表即可覆盖两列展示。 +func collectRechargeBillUserIDs(items []rechargeBillDTO) []int64 { + seen := make(map[int64]struct{}, len(items)*2) + userIDs := make([]int64, 0, len(items)*2) + for _, item := range items { + for _, userID := range []int64{item.UserID, item.SellerUserID} { + if userID <= 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + userIDs = append(userIDs, userID) + } + } + return userIDs +} + +// rechargeBillUserOrFallback 保证用户资料缺失时仍返回长 ID,列表和导出不会因为历史用户资料缺失而显示空白。 +func rechargeBillUserOrFallback(profile rechargeBillUserDTO, userID int64) rechargeBillUserDTO { + if userID <= 0 { + return rechargeBillUserDTO{} + } + if profile.UserID != "" { + return profile + } + return rechargeBillUserDTO{UserID: strconv.FormatInt(userID, 10)} +} + +// int64Args 将用户 ID 列表转换为 database/sql 可展开的参数切片,避免为 IN 条件拼接原始数值。 +func int64Args(values []int64) []any { + args := make([]any, 0, len(values)) + for _, value := range values { + args = append(args, value) + } + return args +} + +// placeholders 只生成固定数量的问号占位符;实际用户 ID 仍通过参数绑定传入,避免 SQL 注入。 +func placeholders(count int) string { + if count <= 0 { + return "" + } + items := make([]string, count) + for index := range items { + items[index] = "?" + } + return strings.Join(items, ",") +} + func rechargeProductFromProto(item *walletv1.RechargeProduct) rechargeProductDTO { if item == nil { return rechargeProductDTO{} diff --git a/server/admin/internal/modules/payment/handler.go b/server/admin/internal/modules/payment/handler.go index d7ce37e4..f09bbf92 100644 --- a/server/admin/internal/modules/payment/handler.go +++ b/server/admin/internal/modules/payment/handler.go @@ -1,6 +1,8 @@ package payment import ( + "context" + "database/sql" "errors" "math" "strconv" @@ -22,18 +24,21 @@ const usdtMicroUnit int64 = 1_000_000 type Handler struct { wallet walletclient.Client + userDB *sql.DB audit shared.OperationLogger } -func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler { - return &Handler{wallet: wallet, audit: audit} +// New 组装支付后台 handler;wallet 负责账单和商品事实,userDB 只补后台列表需要的用户展示资料。 +func New(wallet walletclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler { + return &Handler{wallet: wallet, userDB: userDB, audit: audit} } func (h *Handler) ListRechargeBills(c *gin.Context) { options := shared.ListOptions(c) + appCode := appctx.FromContext(c.Request.Context()) resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{ RequestId: middleware.CurrentRequestID(c), - AppCode: appctx.FromContext(c.Request.Context()), + AppCode: appCode, UserId: queryInt64(c, "user_id", "userId"), SellerUserId: queryInt64(c, "seller_user_id", "sellerUserId"), RegionId: queryInt64(c, "region_id", "regionId"), @@ -54,9 +59,64 @@ func (h *Handler) ListRechargeBills(c *gin.Context) { for _, item := range resp.GetBills() { items = append(items, rechargeBillFromProto(item)) } + // 账单事实仍由 wallet-service 返回;这里只按本页出现的付款用户和币商用户批量补展示资料,避免列表渲染时逐行查用户。 + if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil { + response.ServerError(c, "获取账单用户资料失败") + return + } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } +// fillRechargeBillUsers 将用户资料写回当前页账单 DTO;缺失资料会在 DTO 层保留长 ID,避免影响账单事实展示。 +func (h *Handler) fillRechargeBillUsers(ctx context.Context, appCode string, items []rechargeBillDTO) error { + userIDs := collectRechargeBillUserIDs(items) + if len(userIDs) == 0 { + return nil + } + profiles, err := h.loadRechargeBillUsers(ctx, appCode, userIDs) + if err != nil { + return err + } + for index := range items { + items[index].User = rechargeBillUserOrFallback(profiles[items[index].UserID], items[index].UserID) + items[index].Seller = rechargeBillUserOrFallback(profiles[items[index].SellerUserID], items[index].SellerUserID) + } + return nil +} + +// loadRechargeBillUsers 按 app_code 和用户 ID 批量读取展示资料,返回 map 便于用户列和币商列复用同一次查询结果。 +func (h *Handler) loadRechargeBillUsers(ctx context.Context, appCode string, userIDs []int64) (map[int64]rechargeBillUserDTO, error) { + if h == nil || h.userDB == nil { + return nil, errors.New("user mysql is not configured") + } + // 本查询只读取 users 表里的展示字段,按照 app_code 和 user_id 精确命中;区域和账单筛选仍以 wallet-service 返回为准。 + rows, err := h.userDB.QueryContext(ctx, ` + SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') + FROM users + WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`, + append([]any{appCode}, int64Args(userIDs)...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + profiles := make(map[int64]rechargeBillUserDTO, len(userIDs)) + for rows.Next() { + var profile rechargeBillUserDTO + var userID int64 + if err := rows.Scan(&userID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil { + return nil, err + } + profile.UserID = strconv.FormatInt(userID, 10) + profiles[userID] = profile + } + if err := rows.Err(); err != nil { + return nil, err + } + return profiles, nil +} + func (h *Handler) ListRechargeProducts(c *gin.Context) { options := shared.ListOptions(c) resp, err := h.wallet.ListAdminRechargeProducts(c.Request.Context(), &walletv1.ListAdminRechargeProductsRequest{ diff --git a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql index defd27fc..8192c27f 100644 --- a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql +++ b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql @@ -50,49 +50,6 @@ CREATE TABLE IF NOT EXISTS activity_outbox ( KEY idx_activity_outbox_retention (app_code, status, updated_at_ms, outbox_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动事件 outbox 表'; -CREATE TABLE IF NOT EXISTS lucky_gift_rules ( - app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', - gift_id VARCHAR(96) NOT NULL COMMENT '奖池 ID;当前列名沿用旧内部作用域名', - enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用', - rule_version BIGINT NOT NULL COMMENT '规则版本', - gift_price BIGINT NOT NULL COMMENT '单次抽奖消耗金币', - target_rtp_ppm INT NOT NULL COMMENT '基础 RTP 目标,ppm', - pool_rate_ppm INT NOT NULL COMMENT '基础奖池入池比例,ppm', - global_window_draws BIGINT NOT NULL COMMENT '全站 RTP 窗口抽数', - gift_window_draws BIGINT NOT NULL COMMENT '礼物 RTP 子窗口抽数', - novice_draw_limit BIGINT NOT NULL COMMENT '新手池截止抽数', - intermediate_draw_limit BIGINT NOT NULL COMMENT '中级池截止抽数', - high_multiplier BIGINT NOT NULL COMMENT '高倍判定倍率', - high_water_pool_multiple BIGINT NOT NULL COMMENT '高倍开启所需奖池水位倍数', - platform_pool_weight_ppm INT NOT NULL COMMENT '平台池基础返奖责任权重', - room_pool_weight_ppm INT NOT NULL COMMENT '房间池基础返奖责任权重', - gift_pool_weight_ppm INT NOT NULL COMMENT '礼物池基础返奖责任权重', - initial_platform_pool BIGINT NOT NULL COMMENT '平台池初始水位', - initial_gift_pool BIGINT NOT NULL COMMENT '礼物池初始水位', - initial_room_pool BIGINT NOT NULL COMMENT '房间池初始水位', - platform_reserve BIGINT NOT NULL COMMENT '平台池安全水位', - gift_reserve BIGINT NOT NULL COMMENT '礼物池安全水位', - room_reserve BIGINT NOT NULL COMMENT '房间池安全水位', - max_single_payout BIGINT NOT NULL COMMENT '单次最大奖励', - user_hourly_payout_cap BIGINT NOT NULL COMMENT '用户小时奖励上限', - user_daily_payout_cap BIGINT NOT NULL COMMENT '用户日奖励上限', - device_daily_payout_cap BIGINT NOT NULL COMMENT '设备日奖励上限', - room_hourly_payout_cap BIGINT NOT NULL COMMENT '房间小时奖励上限', - anchor_daily_payout_cap BIGINT NOT NULL COMMENT '主播关联日奖励上限', - room_atmosphere_rate_ppm INT NOT NULL COMMENT '房间气氛池入池比例', - room_atmosphere_initial BIGINT NOT NULL COMMENT '房间气氛池初始预算', - room_atmosphere_reserve BIGINT NOT NULL COMMENT '房间气氛池安全水位', - activity_budget BIGINT NOT NULL COMMENT '活动补贴预算', - activity_daily_limit BIGINT NOT NULL COMMENT '活动补贴日上限', - large_tier_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启高倍奖档', - tiers_json JSON NOT NULL COMMENT '倍率生成奖档快照;权重由 RTP 窗口运行时生成', - updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员', - created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', - updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, gift_id), - KEY idx_lucky_gift_rules_enabled (app_code, enabled, updated_at_ms) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物规则表'; - CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', @@ -996,29 +953,43 @@ CREATE TABLE IF NOT EXISTS message_fanout_jobs ( KEY idx_message_fanout_status (app_code, status, next_run_at_ms, updated_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息分发任务表'; --- 本地开发必须开箱即可验证幸运礼物链路;INSERT IGNORE 只补缺省奖池,不覆盖后台已发布规则。 +-- 本地开发必须开箱即可验证幸运礼物 v2 链路;INSERT IGNORE 只补缺省奖池,不覆盖后台已发布版本。 SET @lucky_seed_now_ms := 1779259000000; -SET @lucky_seed_tiers := JSON_ARRAY( - JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true), - JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true), - JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true), - JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true), - JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true), - JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true), - JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true), - JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true), - JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true) -); -INSERT IGNORE INTO lucky_gift_rules ( - app_code, gift_id, enabled, rule_version, gift_price, target_rtp_ppm, pool_rate_ppm, - global_window_draws, gift_window_draws, novice_draw_limit, intermediate_draw_limit, - high_multiplier, high_water_pool_multiple, platform_pool_weight_ppm, room_pool_weight_ppm, - gift_pool_weight_ppm, initial_platform_pool, initial_gift_pool, initial_room_pool, - platform_reserve, gift_reserve, room_reserve, max_single_payout, user_hourly_payout_cap, - user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap, - room_atmosphere_rate_ppm, room_atmosphere_initial, room_atmosphere_reserve, - activity_budget, activity_daily_limit, large_tier_enabled, tiers_json, - updated_by_admin_id, created_at_ms, updated_at_ms +INSERT IGNORE INTO lucky_gift_rule_versions ( + app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm, + settlement_window_wager, control_band_ppm, gift_price_reference, + novice_max_equivalent_draws, normal_max_equivalent_draws, + max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, device_daily_payout_cap, + room_hourly_payout_cap, anchor_daily_payout_cap, effective_from_ms, created_by_admin_id, created_at_ms ) VALUES - ('lalu', 'lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, @lucky_seed_tiers, 0, @lucky_seed_now_ms, @lucky_seed_now_ms), - ('lalu', 'super_lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, @lucky_seed_tiers, 0, @lucky_seed_now_ms, @lucky_seed_now_ms); + ('lalu', 'lucky', 1, true, 950000, 960000, 1000000, 10000, 100, 2000, 20000, 50000, 34200, 615600, 1026000, 684000, 12312000, @lucky_seed_now_ms, 0, @lucky_seed_now_ms), + ('lalu', 'super_lucky', 1, true, 950000, 960000, 1000000, 10000, 100, 2000, 20000, 50000, 34200, 615600, 1026000, 684000, 12312000, @lucky_seed_now_ms, 0, @lucky_seed_now_ms); + +INSERT IGNORE INTO lucky_gift_stage_tiers ( + app_code, pool_id, rule_version, stage, tier_id, multiplier_ppm, base_weight_ppm, + reward_source, high_water_only, broadcast_level, enabled +) VALUES + ('lalu', 'lucky', 1, 'novice', 'novice_none', 0, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'novice', 'novice_0_5x', 500000, 200000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'novice', 'novice_1x', 1000000, 550000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'novice', 'novice_2x', 2000000, 150000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'normal', 'normal_none', 0, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'normal', 'normal_0_5x', 500000, 200000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'normal', 'normal_1x', 1000000, 550000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'normal', 'normal_2x', 2000000, 150000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'advanced', 'advanced_none', 0, 270000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'advanced', 'advanced_1x', 1000000, 600000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'advanced', 'advanced_2x', 2000000, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'advanced', 'advanced_5x', 5000000, 30000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'novice', 'novice_none', 0, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'novice', 'novice_0_5x', 500000, 200000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'novice', 'novice_1x', 1000000, 550000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'novice', 'novice_2x', 2000000, 150000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'normal', 'normal_none', 0, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'normal', 'normal_0_5x', 500000, 200000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'normal', 'normal_1x', 1000000, 550000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'normal', 'normal_2x', 2000000, 150000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'advanced', 'advanced_none', 0, 270000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'advanced', 'advanced_1x', 1000000, 600000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'advanced', 'advanced_2x', 2000000, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'advanced', 'advanced_5x', 5000000, 30000, 'base_rtp', false, 'none', true); diff --git a/services/activity-service/internal/service/luckygift/config.go b/services/activity-service/internal/service/luckygift/config.go index e321c5ed..0955616a 100644 --- a/services/activity-service/internal/service/luckygift/config.go +++ b/services/activity-service/internal/service/luckygift/config.go @@ -2,7 +2,6 @@ package luckygift import ( "fmt" - "sort" "strings" "hyapp/pkg/xerr" @@ -14,51 +13,6 @@ const ( highTierMultiplierPPM int64 = 10_000_000 ) -// DefaultConfig 给后台指定奖池提供规则草稿;真正生效必须由后台显式 enabled。 -func DefaultConfig(appCode, poolID string) domain.Config { - const giftPrice int64 = 10_000 - poolID = normalizePoolID(poolID) - return domain.Config{ - AppCode: appCode, - GiftID: poolID, - PoolID: poolID, - Enabled: false, - GiftPrice: giftPrice, - TargetRTPPPM: 950_000, - PoolRatePPM: 950_000, - ControlBandPPM: 10_000, - GlobalWindowDraws: 100_000, - GiftWindowDraws: 100_000, - NoviceMaxEquivalentDraws: 2_000, - NormalMaxEquivalentDraws: 20_000, - HighMultiplier: 100, - HighWaterPoolMultiple: 2, - PlatformPoolWeightPPM: 200_000, - RoomPoolWeightPPM: 300_000, - GiftPoolWeightPPM: 500_000, - InitialPlatformPool: 9_500_000, - InitialGiftPool: 23_750_000, - InitialRoomPool: 5_000_000, - PlatformReserve: 1_000_000, - GiftReserve: 1_000_000, - RoomReserve: 100_000, - MaxSinglePayout: giftPrice * 500, - UserHourlyPayoutCap: 3_420_000, - UserDailyPayoutCap: 61_560_000, - DeviceDailyPayoutCap: 102_600_000, - RoomHourlyPayoutCap: 68_400_000, - AnchorDailyPayoutCap: 1_231_200_000, - RoomAtmosphereRatePPM: 10_000, - RoomAtmosphereInitial: 100_000, - RoomAtmosphereReserve: 10_000, - ActivityBudget: 0, - ActivityDailyLimit: 0, - LargeTierEnabled: true, - MultiplierPPMs: defaultMultiplierPPMs(), - Tiers: defaultTiers(giftPrice), - } -} - // DefaultRuleConfig 返回 v2 配置草稿。草稿默认 disabled,只有后台发布后才写入不可变版本表。 func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig { poolID = normalizePoolID(poolID) @@ -286,152 +240,6 @@ func validBroadcastLevel(level string) bool { level == domain.BroadcastGlobal } -// validateConfig 只校验会破坏线上抽奖不变量的字段;展示类默认值留给后台表单处理。 -func validateConfig(config domain.Config) error { - if normalizePoolID(config.PoolID) == "" { - return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required") - } - // 当前 DB 历史列名仍叫 gift_id,但业务语义已经是 pool_id;这里强制二者一致,避免后台提交真实礼物 ID 后把窗口和奖池切散。 - config.GiftID = normalizePoolID(config.PoolID) - if config.GiftPrice <= 0 || config.TargetRTPPPM <= 0 || config.TargetRTPPPM > ppmScale { - return xerr.New(xerr.InvalidArgument, "gift price or target RTP is invalid") - } - // 入池比例低于目标 RTP 时,即使长期抽数足够也无法靠奖池支付基础返奖,必须在发布前拒绝。 - if config.PoolRatePPM < config.TargetRTPPPM || config.PoolRatePPM > ppmScale { - return xerr.New(xerr.InvalidArgument, "pool rate must be between target RTP and 100%") - } - if config.GlobalWindowDraws <= 0 || config.GiftWindowDraws <= 0 { - return xerr.New(xerr.InvalidArgument, "RTP window draws must be positive") - } - if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws { - return xerr.New(xerr.InvalidArgument, "experience pool equivalent draw limits are invalid") - } - if config.HighMultiplier <= 0 || config.HighWaterPoolMultiple <= 0 { - return xerr.New(xerr.InvalidArgument, "high multiplier config is invalid") - } - if config.PlatformPoolWeightPPM < 0 || config.RoomPoolWeightPPM < 0 || config.GiftPoolWeightPPM < 0 || - config.PlatformPoolWeightPPM+config.RoomPoolWeightPPM+config.GiftPoolWeightPPM != ppmScale { - return xerr.New(xerr.InvalidArgument, "pool weights must sum to 1000000") - } - if config.MaxSinglePayout <= 0 || config.UserHourlyPayoutCap <= 0 || config.UserDailyPayoutCap <= 0 || - config.DeviceDailyPayoutCap <= 0 || config.RoomHourlyPayoutCap <= 0 || config.AnchorDailyPayoutCap <= 0 { - return xerr.New(xerr.InvalidArgument, "risk payout caps must be positive") - } - // tiers 是倍率列表归一化后的运行时候选集合;即使后台只提交 multiplier_ppms,发布前也必须先生成 tiers。 - if len(config.Tiers) == 0 { - return xerr.New(xerr.InvalidArgument, "reward tiers are required") - } - maxMultiplierPPM := int64(0) - for _, tier := range config.Tiers { - if tier.Pool == "" || tier.TierID == "" || tier.Weight < 0 || tier.RewardCoins < 0 || tier.MultiplierPPM < 0 { - return xerr.New(xerr.InvalidArgument, fmt.Sprintf("invalid reward tier: %s", tier.TierID)) - } - if tier.Enabled && tier.MultiplierPPM > maxMultiplierPPM { - maxMultiplierPPM = tier.MultiplierPPM - } - } - // 最大倍率低于目标 RTP 时,窗口最后几抽可能没有足够高的基础候选追平目标,只能提前拒绝这类配置。 - if maxMultiplierPPM < config.TargetRTPPPM { - return xerr.New(xerr.InvalidArgument, "max lucky gift multiplier must not be lower than target RTP") - } - return nil -} - -// normalizeTiers 把后台“可中倍率”转换成三档体验池候选;后台传入的权重不会成为线上概率。 -func normalizeTiers(tiers []domain.Tier, multipliers []int64, giftPrice, highMultiplier int64) ([]domain.Tier, []int64) { - multiplierPPMs := normalizeMultiplierPPMs(multipliers, tiers, giftPrice) - return buildMultiplierTiers(giftPrice, highMultiplier, multiplierPPMs), multiplierPPMs -} - -func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64) []int64 { - // 新后台直接提交 multiplier_ppms;旧调用方如果仍提交 tiers,则从 reward_coins 反推倍率以减少迁移断点。 - values := append([]int64(nil), input...) - if len(values) == 0 { - for _, tier := range tiers { - if tier.MultiplierPPM > 0 || tier.RewardCoins == 0 { - values = append(values, tier.MultiplierPPM) - continue - } - if giftPrice > 0 && tier.RewardCoins > 0 { - values = append(values, tier.RewardCoins*ppmScale/giftPrice) - } - } - } - // 没有任何输入时使用保守默认倍率,保证默认草稿可被后台查看和复制,但仍默认 disabled。 - if len(values) == 0 { - values = defaultMultiplierPPMs() - } - seen := map[int64]bool{} - out := make([]int64, 0, len(values)) - for _, value := range values { - // 负倍率没有业务含义;重复倍率会造成同一返奖点被重复计权,因此在入口处去重。 - if value < 0 || seen[value] { - continue - } - seen[value] = true - out = append(out, value) - } - // 全部被过滤时回退默认列表,避免构造出空 tiers 后在更深层事务里失败。 - if len(out) == 0 { - out = defaultMultiplierPPMs() - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -func buildMultiplierTiers(cost, highMultiplier int64, multipliers []int64) []domain.Tier { - out := make([]domain.Tier, 0, len(multipliers)*3) - for _, pool := range []string{domain.PoolNovice, domain.PoolIntermediate, domain.PoolAdvanced} { - for _, multiplierPPM := range multipliers { - out = append(out, domain.Tier{ - Pool: pool, - TierID: pool + "_" + multiplierTierID(multiplierPPM), - RewardCoins: cost * multiplierPPM / ppmScale, - MultiplierPPM: multiplierPPM, - // 旧 multiplier_ppms 入口没有显式概率;运行侧会退化为等权随机,正式 v2 发布应使用阶段奖档概率。 - Weight: 0, - HighWaterOnly: highMultiplier > 0 && multiplierPPM >= highMultiplier*ppmScale, - Enabled: true, - }) - } - } - return out -} - -func defaultMultiplierPPMs() []int64 { - return []int64{ - 0, - 200_000, - 500_000, - 1_000_000, - 2_000_000, - 5_000_000, - 10_000_000, - 20_000_000, - 50_000_000, - 100_000_000, - 500_000_000, - } -} - -func defaultTiers(cost int64) []domain.Tier { - return buildMultiplierTiers(cost, 100, defaultMultiplierPPMs()) -} - -func multiplierTierID(multiplierPPM int64) string { - if multiplierPPM == 0 { - return "none" - } - whole := multiplierPPM / ppmScale - fraction := multiplierPPM % ppmScale - if fraction == 0 { - return fmt.Sprintf("%dx", whole) - } - // 小数倍率用下划线表达,避免 tier_id 出现点号后和后台路径/筛选语法冲突。 - text := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%d_%06dx", whole, fraction), "0"), "_") - return text -} - func normalizePoolID(poolID string) string { poolID = strings.TrimSpace(poolID) if poolID == "" { diff --git a/services/activity-service/internal/service/luckygift/config_test.go b/services/activity-service/internal/service/luckygift/config_test.go index e5397065..5328d272 100644 --- a/services/activity-service/internal/service/luckygift/config_test.go +++ b/services/activity-service/internal/service/luckygift/config_test.go @@ -4,66 +4,6 @@ import ( "testing" ) -func TestDefaultConfigPassesValidation(t *testing.T) { - config := DefaultConfig("hyapp", "pool_95") - config.Enabled = true - - if err := validateConfig(config); err != nil { - t.Fatalf("default lucky gift config should be publishable: %v", err) - } -} - -func TestValidateConfigAcceptsIndependentPoolScope(t *testing.T) { - config := DefaultConfig("hyapp", "pool_98") - config.Enabled = true - - if err := validateConfig(config); err != nil { - t.Fatalf("expected pool scoped lucky gift config to pass: %v", err) - } -} - -func TestNormalizeTiersBuildsMultiplierDrivenTiers(t *testing.T) { - tiers, multipliers := normalizeTiers(nil, []int64{0, 500_000, 2_000_000}, 500, 100) - - if len(multipliers) != 3 || multipliers[1] != 500_000 || multipliers[2] != 2_000_000 { - t.Fatalf("expected normalized multiplier list, got %#v", multipliers) - } - if len(tiers) != 9 { - t.Fatalf("expected three pools times three multipliers, got %d", len(tiers)) - } - for _, tier := range tiers { - if !tier.Enabled || tier.Weight != 0 { - t.Fatalf("tier should be enabled with runtime-generated weight: %#v", tier) - } - if tier.MultiplierPPM == 500_000 && tier.RewardCoins != 250 { - t.Fatalf("expected 0.5x reward to follow reference cost, got %#v", tier) - } - } -} - -func TestNormalizeTiersDoesNotForceZeroMultiplier(t *testing.T) { - tiers, multipliers := normalizeTiers(nil, []int64{1_000_000, 2_000_000}, 500, 100) - - if len(multipliers) != 2 || multipliers[0] != 1_000_000 || multipliers[1] != 2_000_000 { - t.Fatalf("expected positive-only multiplier list to stay positive-only, got %#v", multipliers) - } - for _, tier := range tiers { - if tier.MultiplierPPM == 0 || tier.RewardCoins == 0 { - t.Fatalf("positive-only test config must not generate none tier: %#v", tier) - } - } -} - -func TestValidateConfigRejectsMultiplierListBelowTargetRTP(t *testing.T) { - config := DefaultConfig("hyapp", "pool_low") - config.MultiplierPPMs = []int64{0, 500_000} - config.Tiers, config.MultiplierPPMs = normalizeTiers(nil, config.MultiplierPPMs, config.GiftPrice, config.HighMultiplier) - - if err := validateConfig(config); err == nil { - t.Fatalf("expected max multiplier below RTP to be rejected") - } -} - func TestDefaultRuleConfigPassesValidation(t *testing.T) { config := DefaultRuleConfig("hyapp", "pool_v2") config.Enabled = true diff --git a/services/activity-service/internal/storage/mysql/lucky_gift_repository.go b/services/activity-service/internal/storage/mysql/lucky_gift_repository.go index 12962498..fc10e550 100644 --- a/services/activity-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/activity-service/internal/storage/mysql/lucky_gift_repository.go @@ -1956,72 +1956,10 @@ func luckyDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any return strings.Join(where, " AND "), args } -type luckyQueryer interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -} - type luckyScanner interface { Scan(...any) error } -func (r *Repository) getLuckyGiftConfig(ctx context.Context, q luckyQueryer, appCode string, giftID string, forUpdate bool) (domain.Config, bool, error) { - if giftID == "" { - return domain.Config{}, false, nil - } - lockSQL := "" - if forUpdate { - lockSQL = " FOR UPDATE" - } - row := q.QueryRowContext(ctx, ` - SELECT app_code, gift_id, enabled, rule_version, gift_price, target_rtp_ppm, pool_rate_ppm, - global_window_draws, gift_window_draws, novice_draw_limit, intermediate_draw_limit, - high_multiplier, high_water_pool_multiple, platform_pool_weight_ppm, room_pool_weight_ppm, - gift_pool_weight_ppm, initial_platform_pool, initial_gift_pool, initial_room_pool, - platform_reserve, gift_reserve, room_reserve, max_single_payout, user_hourly_payout_cap, - user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap, - room_atmosphere_rate_ppm, room_atmosphere_initial, room_atmosphere_reserve, - activity_budget, activity_daily_limit, large_tier_enabled, - COALESCE(CAST(tiers_json AS CHAR), '[]'), updated_by_admin_id, created_at_ms, updated_at_ms - FROM lucky_gift_rules - WHERE app_code = ? AND gift_id = ?`+lockSQL, - appCode, giftID, - ) - config, err := scanLuckyConfig(row) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return domain.Config{}, false, nil - } - return domain.Config{}, false, err - } - return config, true, nil -} - -func scanLuckyConfig(scanner luckyScanner) (domain.Config, error) { - var config domain.Config - var tiersJSON string - var noviceDrawLimit int64 - var intermediateDrawLimit int64 - if err := scanner.Scan(&config.AppCode, &config.GiftID, &config.Enabled, &config.RuleVersion, &config.GiftPrice, &config.TargetRTPPPM, &config.PoolRatePPM, - &config.GlobalWindowDraws, &config.GiftWindowDraws, &noviceDrawLimit, &intermediateDrawLimit, - &config.HighMultiplier, &config.HighWaterPoolMultiple, &config.PlatformPoolWeightPPM, &config.RoomPoolWeightPPM, - &config.GiftPoolWeightPPM, &config.InitialPlatformPool, &config.InitialGiftPool, &config.InitialRoomPool, - &config.PlatformReserve, &config.GiftReserve, &config.RoomReserve, &config.MaxSinglePayout, &config.UserHourlyPayoutCap, - &config.UserDailyPayoutCap, &config.DeviceDailyPayoutCap, &config.RoomHourlyPayoutCap, &config.AnchorDailyPayoutCap, - &config.RoomAtmosphereRatePPM, &config.RoomAtmosphereInitial, &config.RoomAtmosphereReserve, - &config.ActivityBudget, &config.ActivityDailyLimit, &config.LargeTierEnabled, - &tiersJSON, &config.UpdatedByAdminID, &config.CreatedAtMS, &config.UpdatedAtMS); err != nil { - return domain.Config{}, err - } - if err := json.Unmarshal([]byte(tiersJSON), &config.Tiers); err != nil { - return domain.Config{}, err - } - config.PoolID = config.GiftID - config.NoviceMaxEquivalentDraws = noviceDrawLimit - config.NormalMaxEquivalentDraws = intermediateDrawLimit - config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice) - return config, nil -} - func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Config, error) { poolID := luckyPoolID(ruleConfig.PoolID) referencePrice := ruleConfig.GiftPriceReference diff --git a/services/activity-service/internal/storage/mysql/repository.go b/services/activity-service/internal/storage/mysql/repository.go index 2d9c43a2..2bebaebc 100644 --- a/services/activity-service/internal/storage/mysql/repository.go +++ b/services/activity-service/internal/storage/mysql/repository.go @@ -414,35 +414,51 @@ func (r *Repository) ensureLuckyGiftRuleVersionRiskCapColumns(ctx context.Contex } func (r *Repository) ensureDefaultLuckyGiftRules(ctx context.Context) error { - // 本地和 Docker 开发库通常保留 MySQL volume;这里补齐缺省 lucky/super_lucky 契约,但不覆盖后台已发布规则。 + // 本地和 Docker 开发库通常保留 MySQL volume;这里补齐 v2 lucky/super_lucky 契约,但不覆盖后台已发布版本。 const seedNowMS int64 = 1779259000000 - const seedTiersJSON = `[ - {"pool":"novice","tier_id":"novice_none","reward_coins":0,"multiplier_ppm":0,"weight":0,"high_water_only":false,"enabled":true}, - {"pool":"novice","tier_id":"novice_1x","reward_coins":100,"multiplier_ppm":1000000,"weight":0,"high_water_only":false,"enabled":true}, - {"pool":"novice","tier_id":"novice_2x","reward_coins":200,"multiplier_ppm":2000000,"weight":0,"high_water_only":false,"enabled":true}, - {"pool":"intermediate","tier_id":"intermediate_none","reward_coins":0,"multiplier_ppm":0,"weight":0,"high_water_only":false,"enabled":true}, - {"pool":"intermediate","tier_id":"intermediate_1x","reward_coins":100,"multiplier_ppm":1000000,"weight":0,"high_water_only":false,"enabled":true}, - {"pool":"intermediate","tier_id":"intermediate_2x","reward_coins":200,"multiplier_ppm":2000000,"weight":0,"high_water_only":false,"enabled":true}, - {"pool":"advanced","tier_id":"advanced_none","reward_coins":0,"multiplier_ppm":0,"weight":0,"high_water_only":false,"enabled":true}, - {"pool":"advanced","tier_id":"advanced_1x","reward_coins":100,"multiplier_ppm":1000000,"weight":0,"high_water_only":false,"enabled":true}, - {"pool":"advanced","tier_id":"advanced_2x","reward_coins":200,"multiplier_ppm":2000000,"weight":0,"high_water_only":false,"enabled":true} - ]` - _, err := r.db.ExecContext(ctx, ` - INSERT IGNORE INTO lucky_gift_rules ( - app_code, gift_id, enabled, rule_version, gift_price, target_rtp_ppm, pool_rate_ppm, - global_window_draws, gift_window_draws, novice_draw_limit, intermediate_draw_limit, - high_multiplier, high_water_pool_multiple, platform_pool_weight_ppm, room_pool_weight_ppm, - gift_pool_weight_ppm, initial_platform_pool, initial_gift_pool, initial_room_pool, - platform_reserve, gift_reserve, room_reserve, max_single_payout, user_hourly_payout_cap, - user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap, - room_atmosphere_rate_ppm, room_atmosphere_initial, room_atmosphere_reserve, - activity_budget, activity_daily_limit, large_tier_enabled, tiers_json, - updated_by_admin_id, created_at_ms, updated_at_ms + if _, err := r.db.ExecContext(ctx, ` + INSERT IGNORE INTO lucky_gift_rule_versions ( + app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm, + settlement_window_wager, control_band_ppm, gift_price_reference, + novice_max_equivalent_draws, normal_max_equivalent_draws, + max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, device_daily_payout_cap, + room_hourly_payout_cap, anchor_daily_payout_cap, effective_from_ms, created_by_admin_id, created_at_ms ) VALUES - ('lalu', 'lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, ?, 0, ?, ?), - ('lalu', 'super_lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, ?, 0, ?, ?)`, - seedTiersJSON, seedNowMS, seedNowMS, seedTiersJSON, seedNowMS, seedNowMS, - ) + ('lalu', 'lucky', 1, true, 950000, 960000, 1000000, 10000, 100, 2000, 20000, 50000, 34200, 615600, 1026000, 684000, 12312000, ?, 0, ?), + ('lalu', 'super_lucky', 1, true, 950000, 960000, 1000000, 10000, 100, 2000, 20000, 50000, 34200, 615600, 1026000, 684000, 12312000, ?, 0, ?)`, + seedNowMS, seedNowMS, seedNowMS, seedNowMS, + ); err != nil { + return err + } + _, err := r.db.ExecContext(ctx, ` + INSERT IGNORE INTO lucky_gift_stage_tiers ( + app_code, pool_id, rule_version, stage, tier_id, multiplier_ppm, base_weight_ppm, + reward_source, high_water_only, broadcast_level, enabled + ) VALUES + ('lalu', 'lucky', 1, 'novice', 'novice_none', 0, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'novice', 'novice_0_5x', 500000, 200000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'novice', 'novice_1x', 1000000, 550000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'novice', 'novice_2x', 2000000, 150000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'normal', 'normal_none', 0, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'normal', 'normal_0_5x', 500000, 200000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'normal', 'normal_1x', 1000000, 550000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'normal', 'normal_2x', 2000000, 150000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'advanced', 'advanced_none', 0, 270000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'advanced', 'advanced_1x', 1000000, 600000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'advanced', 'advanced_2x', 2000000, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'lucky', 1, 'advanced', 'advanced_5x', 5000000, 30000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'novice', 'novice_none', 0, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'novice', 'novice_0_5x', 500000, 200000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'novice', 'novice_1x', 1000000, 550000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'novice', 'novice_2x', 2000000, 150000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'normal', 'normal_none', 0, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'normal', 'normal_0_5x', 500000, 200000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'normal', 'normal_1x', 1000000, 550000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'normal', 'normal_2x', 2000000, 150000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'advanced', 'advanced_none', 0, 270000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'advanced', 'advanced_1x', 1000000, 600000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'advanced', 'advanced_2x', 2000000, 100000, 'base_rtp', false, 'none', true), + ('lalu', 'super_lucky', 1, 'advanced', 'advanced_5x', 5000000, 30000, 'base_rtp', false, 'none', true)`) return err } diff --git a/services/room-service/internal/room/service/gift.go b/services/room-service/internal/room/service/gift.go index 8530a439..51551591 100644 --- a/services/room-service/internal/room/service/gift.go +++ b/services/room-service/internal/room/service/gift.go @@ -231,7 +231,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{ AppCode: appcode.FromContext(ctx), RoomID: current.RoomID, - CoinSpent: roomGiftLeaderboardCoinSpent(billing), + CoinSpent: roomGiftLeaderboardValue(billing), OccurredAtMS: now.UnixMilli(), }, roomUserGiftStats: []RoomUserGiftStatIncrement{ @@ -387,14 +387,15 @@ func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.Reques return results, nil } -func roomGiftLeaderboardCoinSpent(billing *walletv1.DebitGiftResponse) int64 { +func roomGiftLeaderboardValue(billing *walletv1.DebitGiftResponse) int64 { if billing == nil { return 0 } - if billing.GetCoinSpent() > 0 { - return billing.GetCoinSpent() + // 房间贡献榜跟房间热度、房间内贡献人保持同一口径,使用 wallet 已按全局默认比例折算后的 heat_value。 + if billing.GetHeatValue() > 0 { + return billing.GetHeatValue() } - return billing.GetChargeAmount() + return 0 } func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool { diff --git a/services/room-service/internal/room/service/gift_leaderboard_test.go b/services/room-service/internal/room/service/gift_leaderboard_test.go new file mode 100644 index 00000000..c038b67a --- /dev/null +++ b/services/room-service/internal/room/service/gift_leaderboard_test.go @@ -0,0 +1,81 @@ +package service_test + +import ( + "context" + "testing" + "time" + + roomv1 "hyapp.local/api/proto/room/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/services/room-service/internal/integration" + roomservice "hyapp/services/room-service/internal/room/service" + "hyapp/services/room-service/internal/router" + "hyapp/services/room-service/internal/testutil/mysqltest" +) + +type recordingRoomGiftLeaderboard struct { + increments []roomservice.RoomGiftLeaderboardIncrement +} + +func (s *recordingRoomGiftLeaderboard) IncrementRoomGift(_ context.Context, input roomservice.RoomGiftLeaderboardIncrement) error { + s.increments = append(s.increments, input) + return nil +} + +func (s *recordingRoomGiftLeaderboard) ListRoomGiftLeaderboard(context.Context, roomservice.RoomGiftLeaderboardQuery) (roomservice.RoomGiftLeaderboardPage, error) { + return roomservice.RoomGiftLeaderboardPage{}, nil +} + +func TestSendGiftWritesRoomGiftLeaderboardWithHeatValue(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{{ + BillingReceiptId: "receipt-gift-leaderboard", + CoinSpent: 100, + ChargeAmount: 100, + GiftPointAdded: 100, + HeatValue: 10, + GiftTypeCode: "lucky", + }}} + leaderboard := &recordingRoomGiftLeaderboard{} + now := &fixedRoomTreasureClock{now: time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)} + svc := roomservice.New(roomservice.Config{ + NodeID: "node-gift-leaderboard-test", + LeaseTTL: 10 * time.Second, + RankLimit: 20, + SnapshotEveryN: 1, + Clock: now, + RoomGiftLeaderboard: leaderboard, + }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) + + roomID := "room-gift-leaderboard" + senderID := int64(18001) + targetID := int64(18002) + createTreasureRoom(t, ctx, svc, roomID, senderID, 9001) + joinTreasureRoom(t, ctx, svc, roomID, targetID) + + if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ + Meta: &roomv1.RequestMeta{ + RequestId: "req-gift-leaderboard", + CommandId: "cmd-gift-leaderboard", + ActorUserId: senderID, + RoomId: roomID, + AppCode: appcode.Default, + SentAtMs: now.Now().UnixMilli(), + }, + TargetType: "user", + TargetUserId: targetID, + GiftId: "gift-leaderboard", + GiftCount: 1, + }); err != nil { + t.Fatalf("send gift failed: %v", err) + } + + if len(leaderboard.increments) != 1 { + t.Fatalf("expected one leaderboard increment, got %+v", leaderboard.increments) + } + if leaderboard.increments[0].CoinSpent != 10 { + t.Fatalf("leaderboard must use heat value after global ratio, got %+v", leaderboard.increments[0]) + } +} diff --git a/services/room-service/internal/room/service/pipeline.go b/services/room-service/internal/room/service/pipeline.go index 1ddf6b45..f2b882bc 100644 --- a/services/room-service/internal/room/service/pipeline.go +++ b/services/room-service/internal/room/service/pipeline.go @@ -159,7 +159,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl s.projectRoomListBestEffort(ctx, result.snapshot) // 当前房间恢复读模型同样最终一致;权威校验仍以 Room Cell 快照为准。 s.projectRoomPresenceBestEffort(ctx, result.snapshot) - // 跨房间金币榜是 Redis 轻量读模型,不能影响已提交的房间状态和账务事实。 + // 跨房间贡献榜是 Redis 轻量读模型,不能影响已提交的房间状态和账务事实。 s.recordRoomGiftLeaderboardBestEffort(ctx, result.roomGiftLeaderboard) projectionMS = elapsedMS(projectionStartedAt) } diff --git a/services/room-service/internal/room/service/room_gift_leaderboard.go b/services/room-service/internal/room/service/room_gift_leaderboard.go index cb638f42..81de3d84 100644 --- a/services/room-service/internal/room/service/room_gift_leaderboard.go +++ b/services/room-service/internal/room/service/room_gift_leaderboard.go @@ -24,15 +24,16 @@ const ( maxRoomGiftLeaderboardPageSize = 100 ) -// RoomGiftLeaderboardStore 是房间金币榜的轻量读模型边界;生产实现使用 Redis zset。 +// RoomGiftLeaderboardStore 是跨房间贡献榜的轻量读模型边界;生产实现使用 Redis zset。 type RoomGiftLeaderboardStore interface { IncrementRoomGift(ctx context.Context, input RoomGiftLeaderboardIncrement) error ListRoomGiftLeaderboard(ctx context.Context, query RoomGiftLeaderboardQuery) (RoomGiftLeaderboardPage, error) } type RoomGiftLeaderboardIncrement struct { - AppCode string - RoomID string + AppCode string + RoomID string + // CoinSpent 沿用 proto 字段名,运行值实际是 wallet 折算后的房间贡献值。 CoinSpent int64 OccurredAtMS int64 } @@ -60,7 +61,7 @@ type RoomGiftLeaderboardEntry struct { CoinSpent int64 } -// RedisRoomGiftLeaderboardStore 只在 zset 中保存 room_id -> 周期金币消耗分。 +// RedisRoomGiftLeaderboardStore 只在 zset 中保存 room_id -> 周期房间贡献分。 type RedisRoomGiftLeaderboardStore struct { client *redis.Client } diff --git a/services/room-service/internal/room/service/service.go b/services/room-service/internal/room/service/service.go index 30ddab97..19436743 100644 --- a/services/room-service/internal/room/service/service.go +++ b/services/room-service/internal/room/service/service.go @@ -34,7 +34,7 @@ type Config struct { RTCUserRemover integration.RTCUserRemover // RoomTreasureOpenScheduler 把倒计时开箱唤醒交给外部延迟消息,避免只靠已加载 Cell 扫描。 RoomTreasureOpenScheduler integration.RoomTreasureOpenScheduler - // RoomGiftLeaderboard 是跨房间金币榜读模型,SendGift 提交后 best-effort 写入。 + // RoomGiftLeaderboard 是跨房间贡献榜读模型,SendGift 提交后 best-effort 写入。 RoomGiftLeaderboard RoomGiftLeaderboardStore // LuckyGiftSendLocker 串行化同一用户的幸运礼物扣费、抽奖和同步返奖链路。 LuckyGiftSendLocker LuckyGiftSendLocker @@ -74,7 +74,7 @@ type Service struct { outboxPublisher integration.OutboxPublisher // roomTreasureOpenScheduler 在 countdown outbox 投递成功后安排 open_at_ms 唤醒。 roomTreasureOpenScheduler integration.RoomTreasureOpenScheduler - // roomGiftLeaderboard 只保存房间维度金币消耗 zset,不承载 Room Cell 核心状态。 + // roomGiftLeaderboard 只保存房间维度贡献值 zset,不承载 Room Cell 核心状态。 roomGiftLeaderboard RoomGiftLeaderboardStore // luckyGiftSendLocker 只保护同一用户幸运礼物扣费到同步返奖的小事务链路。 luckyGiftSendLocker LuckyGiftSendLocker @@ -134,7 +134,7 @@ type mutationResult struct { closeInfo *RoomCloseInfo // walletDebitMS 记录 SendGift 同步扣费耗时;非钱包命令保持 0。 walletDebitMS int64 - // roomGiftLeaderboard 是 SendGift 成功后写入跨房间金币榜的轻量增量。 + // roomGiftLeaderboard 是 SendGift 成功后写入跨房间贡献榜的轻量增量。 roomGiftLeaderboard *RoomGiftLeaderboardIncrement // roomUserGiftStats 是当前房间用户送礼价值统计,和命令日志同事务提交。 roomUserGiftStats []RoomUserGiftStatIncrement diff --git a/services/statistics-service/internal/app/app.go b/services/statistics-service/internal/app/app.go index 8c6f5f21..1e907d9e 100644 --- a/services/statistics-service/internal/app/app.go +++ b/services/statistics-service/internal/app/app.go @@ -227,7 +227,8 @@ func rechargeEvent(body []byte) (mysqlstorage.RechargeEvent, bool, error) { return mysqlstorage.RechargeEvent{}, false, nil } payload := mysqlstorage.DecodeJSON(message.PayloadJSON) - usdMinor := firstNonZeroInt64(mysqlstorage.Int64(payload, "recharge_usd_minor"), mysqlstorage.Int64(payload, "amount_micro")) + rechargeType := firstNonEmpty(mysqlstorage.String(payload, "recharge_type"), mysqlstorage.String(payload, "channel"), mysqlstorage.String(payload, "provider"), "coin_seller_transfer") + usdMinor := rechargeUSDMinorFromPayload(payload, rechargeType) regionID := firstNonZeroInt64(mysqlstorage.Int64(payload, "target_region_id"), mysqlstorage.Int64(payload, "region_id")) return mysqlstorage.RechargeEvent{ AppCode: message.AppCode, @@ -237,11 +238,38 @@ func rechargeEvent(body []byte) (mysqlstorage.RechargeEvent, bool, error) { USDMinor: usdMinor, RechargeSequence: mysqlstorage.Int64(payload, "recharge_sequence"), TargetRegionID: regionID, - RechargeType: firstNonEmpty(mysqlstorage.String(payload, "recharge_type"), mysqlstorage.String(payload, "channel"), mysqlstorage.String(payload, "provider"), "coin_seller_transfer"), + RechargeType: rechargeType, OccurredAtMS: message.OccurredAtMS, }, true, nil } +func rechargeUSDMinorFromPayload(payload map[string]any, rechargeType string) int64 { + if value := mysqlstorage.Int64(payload, "recharge_usd_minor"); value > 0 { + return value + } + // 旧 Google outbox 只有 amount_micro;这里按微单位折算成美分,避免 0.99 被统计成 9900。 + if isGoogleRechargeType(rechargeType) { + return amountMicroToUSDMinor(mysqlstorage.Int64(payload, "amount_micro")) + } + return 0 +} + +func isGoogleRechargeType(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "google", "google_play": + return true + default: + return false + } +} + +func amountMicroToUSDMinor(amountMicro int64) int64 { + if amountMicro <= 0 { + return 0 + } + return amountMicro / 10_000 +} + func luckyGiftRewardEvent(body []byte) (mysqlstorage.LuckyGiftRewardEvent, bool, error) { message, err := walletmq.DecodeWalletOutboxMessage(body) if err != nil { diff --git a/services/statistics-service/internal/app/local_flow_test.go b/services/statistics-service/internal/app/local_flow_test.go index b0d61c42..20d9d05d 100644 --- a/services/statistics-service/internal/app/local_flow_test.go +++ b/services/statistics-service/internal/app/local_flow_test.go @@ -129,7 +129,7 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) { if err != nil { t.Fatalf("query overview failed: %v", err) } - if overview.GoogleRechargeUSDMinor != 1_500_000 || overview.RechargeUSDMinor != 1_500_000 || overview.NewUserRechargeUSDMinor != 1_500_000 { + if overview.GoogleRechargeUSDMinor != 150 || overview.RechargeUSDMinor != 150 || overview.NewUserRechargeUSDMinor != 150 { t.Fatalf("google recharge metrics mismatch: %+v", overview) } if overview.LuckyGiftTurnover != 1_000 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 700 || overview.LuckyGiftPayers != 1 { diff --git a/services/user-service/internal/service/host/commands.go b/services/user-service/internal/service/host/commands.go index 29488d2a..376077f7 100644 --- a/services/user-service/internal/service/host/commands.go +++ b/services/user-service/internal/service/host/commands.go @@ -167,7 +167,7 @@ type CreateBDLeaderCommand struct { NowMs int64 } -// CreateBDInput 是后台创建普通 BD 的外部输入。 +// CreateBDInput 是后台创建普通 BD 的外部输入;ParentLeaderUserID 为 0 时表示独立 BD。 type CreateBDInput struct { CommandID string AdminUserID int64 @@ -177,7 +177,7 @@ type CreateBDInput struct { RequestID string } -// CreateBDCommand 是后台创建普通 BD 事务需要的完整命令。 +// CreateBDCommand 是后台创建普通 BD 事务需要的完整命令;ParentLeaderUserID 为 0 时不校验父级 Leader。 type CreateBDCommand struct { CommandID string AdminUserID int64 @@ -253,7 +253,7 @@ type SetCoinSellerStatusCommand struct { NowMs int64 } -// CreateAgencyInput 是后台直接创建 Agency 的外部输入。 +// CreateAgencyInput 是后台直接创建 Agency 的外部输入;ParentBDUserID 为 0 时表示独立 Agency。 type CreateAgencyInput struct { CommandID string AdminUserID int64 @@ -266,7 +266,7 @@ type CreateAgencyInput struct { RequestID string } -// CreateAgencyCommand 是后台直接创建 Agency 事务需要的完整命令。 +// CreateAgencyCommand 是后台直接创建 Agency 事务需要的完整命令;ParentBDUserID 为 0 时不校验父级 BD。 type CreateAgencyCommand struct { CommandID string AdminUserID int64 diff --git a/services/user-service/internal/service/host/service.go b/services/user-service/internal/service/host/service.go index db7cccb7..25419e16 100644 --- a/services/user-service/internal/service/host/service.go +++ b/services/user-service/internal/service/host/service.go @@ -343,13 +343,16 @@ func (s *Service) CreateBDLeader(ctx context.Context, command CreateBDLeaderInpu }) } -// CreateBD 由后台创建普通 BD,并绑定一个有效 BD Leader。 +// CreateBD 由后台创建普通 BD;传入父级 Leader 时绑定到该 Leader,未传时创建独立 BD。 func (s *Service) CreateBD(ctx context.Context, command CreateBDInput) (hostdomain.BDProfile, error) { if err := s.requireWriteDependencies(); err != nil { return hostdomain.BDProfile{}, err } - if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.TargetUserID <= 0 || command.ParentLeaderUserID <= 0 { - return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id, target_user_id and parent_leader_user_id are required") + if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.TargetUserID <= 0 { + return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and target_user_id are required") + } + if command.ParentLeaderUserID < 0 { + return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "parent_leader_user_id must not be negative") } nowMs := s.now().UnixMilli() @@ -438,14 +441,17 @@ func (s *Service) SetCoinSellerStatus(ctx context.Context, command SetCoinSeller }) } -// CreateAgency 由后台直接创建 Agency,并原子创建 owner 的 host_profile 和 owner membership。 +// CreateAgency 由后台直接创建 Agency;父级 BD 可选,并原子创建 owner 的 host_profile 和 owner membership。 func (s *Service) CreateAgency(ctx context.Context, command CreateAgencyInput) (hostdomain.CreateAgencyResult, error) { if err := s.requireWriteDependencies(); err != nil { return hostdomain.CreateAgencyResult{}, err } name := strings.TrimSpace(command.Name) - if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.OwnerUserID <= 0 || command.ParentBDUserID <= 0 || name == "" { - return hostdomain.CreateAgencyResult{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id, owner_user_id, parent_bd_user_id and name are required") + if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.OwnerUserID <= 0 || name == "" { + return hostdomain.CreateAgencyResult{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id, owner_user_id and name are required") + } + if command.ParentBDUserID < 0 { + return hostdomain.CreateAgencyResult{}, xerr.New(xerr.InvalidArgument, "parent_bd_user_id must not be negative") } if command.MaxHosts < 0 { return hostdomain.CreateAgencyResult{}, xerr.New(xerr.InvalidArgument, "max_hosts must not be negative") diff --git a/services/user-service/internal/service/host/service_test.go b/services/user-service/internal/service/host/service_test.go index 8ab15cdc..a99484c4 100644 --- a/services/user-service/internal/service/host/service_test.go +++ b/services/user-service/internal/service/host/service_test.go @@ -272,6 +272,7 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) { repository.PutRegion(userdomain.Region{RegionID: 20, RegionCode: "R20", Name: "Region 20"}) seedActiveUser(t, repository, 801, 10) seedActiveUser(t, repository, 802, 20) + seedActiveUser(t, repository, 804, 20) svc := newHostService(repository, 4000, 5000) leader, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{ @@ -323,6 +324,20 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) { t.Fatalf("bd mismatch: %+v", bd) } + independentBD, err := svc.CreateBD(ctx, hostservice.CreateBDInput{ + CommandID: "admin-create-bd-804-independent", + AdminUserID: 1, + TargetUserID: 804, + Reason: "seed independent bd", + RequestID: "req-bd-independent-1", + }) + if err != nil { + t.Fatalf("CreateBD without parent leader failed: %v", err) + } + if independentBD.Role != hostdomain.BDRoleBD || independentBD.ParentLeaderUserID != 0 || independentBD.RegionID != 20 { + t.Fatalf("independent bd mismatch: %+v", independentBD) + } + disabled, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{ CommandID: "admin-disable-bd-802", AdminUserID: 1, @@ -466,6 +481,33 @@ func TestAdminCreateAgencyControlsAppSearch(t *testing.T) { } } +func TestAdminCreateAgencyWithoutParentBD(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedActiveUser(t, repository, 904, 31) + svc := newHostService(repository, 5600, 6600) + + created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{ + CommandID: "admin-create-agency-904-independent", + AdminUserID: 1, + OwnerUserID: 904, + Name: "Independent Agency", + JoinEnabled: true, + MaxHosts: 20, + Reason: "seed independent agency", + RequestID: "req-agency-independent-1", + }) + if err != nil { + t.Fatalf("CreateAgency without parent bd failed: %v", err) + } + if created.Agency.OwnerUserID != 904 || created.Agency.ParentBDUserID != 0 || created.Agency.RegionID != 31 || !created.Agency.JoinEnabled { + t.Fatalf("independent agency mismatch: %+v", created.Agency) + } + if created.HostProfile.CurrentAgencyID != created.Agency.AgencyID || created.Membership.MembershipType != hostdomain.MembershipTypeOwner { + t.Fatalf("independent agency owner facts mismatch: host=%+v membership=%+v", created.HostProfile, created.Membership) + } +} + func TestAdminCreateAgencyRejectsExistingHost(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) diff --git a/services/user-service/internal/storage/mysql/host/admin.go b/services/user-service/internal/storage/mysql/host/admin.go index 2235ec2e..54813f18 100644 --- a/services/user-service/internal/storage/mysql/host/admin.go +++ b/services/user-service/internal/storage/mysql/host/admin.go @@ -67,7 +67,7 @@ func (r *Repository) CreateBDLeader(ctx context.Context, command hostservice.Cre return profile, nil } -// CreateBD 创建普通 BD,并把它固定到一个有效 BD 负责人名下。 +// CreateBD 创建普通 BD;父级 Leader 可选,填入时必须是同区域有效 Leader。 func (r *Repository) CreateBD(ctx context.Context, command hostservice.CreateBDCommand) (hostdomain.BDProfile, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -84,19 +84,22 @@ func (r *Repository) CreateBD(ctx context.Context, command hostservice.CreateBDC return queryBDProfile(ctx, tx, "WHERE user_id = ?", existing.ResultID) } - leader, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.ParentLeaderUserID) - if err != nil { - return hostdomain.BDProfile{}, err - } - if leader.Status != hostdomain.BDStatusActive || leader.Role != hostdomain.BDRoleLeader { - return hostdomain.BDProfile{}, xerr.New(xerr.PermissionDenied, "parent leader is not active") - } regionID, err := r.userRegion(ctx, tx, command.TargetUserID, "FOR UPDATE") if err != nil { return hostdomain.BDProfile{}, err } - if regionID != leader.RegionID { - return hostdomain.BDProfile{}, xerr.New(xerr.PermissionDenied, "target region does not match leader region") + if command.ParentLeaderUserID > 0 { + // 独立 BD 不需要父级行锁;只有传入 Leader 时才校验 Leader 身份、启用状态和区域归属。 + leader, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.ParentLeaderUserID) + if err != nil { + return hostdomain.BDProfile{}, err + } + if leader.Status != hostdomain.BDStatusActive || leader.Role != hostdomain.BDRoleLeader { + return hostdomain.BDProfile{}, xerr.New(xerr.PermissionDenied, "parent leader is not active") + } + if regionID != leader.RegionID { + return hostdomain.BDProfile{}, xerr.New(xerr.PermissionDenied, "target region does not match leader region") + } } if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, command.TargetUserID, true); err != nil { return hostdomain.BDProfile{}, err @@ -256,7 +259,7 @@ func (r *Repository) SetCoinSellerStatus(ctx context.Context, command hostservic return after, nil } -// CreateAgency 后台直建 Agency,并原子创建 owner 的 Host 身份和拥有者成员关系。 +// CreateAgency 后台直建 Agency;父级 BD 可选,填入时必须是同区域有效 BD。 func (r *Repository) CreateAgency(ctx context.Context, command hostservice.CreateAgencyCommand) (hostdomain.CreateAgencyResult, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -273,19 +276,22 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat return createAgencyResultByAgencyID(ctx, tx, existing.ResultID) } - parentBD, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.ParentBDUserID) - if err != nil { - return hostdomain.CreateAgencyResult{}, err - } - if parentBD.Status != hostdomain.BDStatusActive { - return hostdomain.CreateAgencyResult{}, xerr.New(xerr.PermissionDenied, "parent bd is not active") - } regionID, err := r.userRegion(ctx, tx, command.OwnerUserID, "FOR UPDATE") if err != nil { return hostdomain.CreateAgencyResult{}, err } - if regionID != parentBD.RegionID { - return hostdomain.CreateAgencyResult{}, xerr.New(xerr.PermissionDenied, "owner region does not match parent bd region") + if command.ParentBDUserID > 0 { + // 独立 Agency 不需要父级行锁;只有传入 BD 时才校验 BD 启用状态和同区域归属。 + parentBD, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.ParentBDUserID) + if err != nil { + return hostdomain.CreateAgencyResult{}, err + } + if parentBD.Status != hostdomain.BDStatusActive { + return hostdomain.CreateAgencyResult{}, xerr.New(xerr.PermissionDenied, "parent bd is not active") + } + if regionID != parentBD.RegionID { + return hostdomain.CreateAgencyResult{}, xerr.New(xerr.PermissionDenied, "owner region does not match parent bd region") + } } if _, ok, err := queryActiveAgencyByOwner(ctx, tx, command.OwnerUserID, true); err != nil { return hostdomain.CreateAgencyResult{}, err diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 5d3b6053..f6151479 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -1190,5 +1190,5 @@ INSERT IGNORE INTO gift_diamond_ratio_configs ( created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES ('lalu', 0, 'normal', 'active', 100.00, 0, 0, 0, 0), - ('lalu', 0, 'lucky', 'active', 100.00, 0, 0, 0, 0), - ('lalu', 0, 'super_lucky', 'active', 100.00, 0, 0, 0, 0); + ('lalu', 0, 'lucky', 'active', 10.00, 0, 0, 0, 0), + ('lalu', 0, 'super_lucky', 'active', 1.00, 0, 0, 0, 0); diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index f312ba9a..6f2813cf 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -216,7 +216,7 @@ func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) { } } -func TestDebitGiftCreditsHostPeriodDiamondsBySenderRegionAndGiftType(t *testing.T) { +func TestDebitGiftCreditsHostPeriodDiamondsByGlobalDefaultAndGiftType(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeNormal, "30.00") @@ -228,16 +228,17 @@ func TestDebitGiftCreditsHostPeriodDiamondsBySenderRegionAndGiftType(t *testing. giftType string commandID string senderID int64 + coinPrice int64 wantDiamond int64 }{ - {giftID: "normal-ratio-gift", giftType: resourcedomain.GiftTypeNormal, commandID: "cmd-normal-ratio", senderID: 11001, wantDiamond: 30}, - {giftID: "lucky-ratio-gift", giftType: resourcedomain.GiftTypeLucky, commandID: "cmd-lucky-ratio", senderID: 11002, wantDiamond: 40}, - {giftID: "super-lucky-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, commandID: "cmd-super-lucky-ratio", senderID: 11003, wantDiamond: 50}, + {giftID: "normal-ratio-gift", giftType: resourcedomain.GiftTypeNormal, commandID: "cmd-normal-ratio", senderID: 11001, coinPrice: 100, wantDiamond: 100}, + {giftID: "lucky-ratio-gift", giftType: resourcedomain.GiftTypeLucky, commandID: "cmd-lucky-ratio", senderID: 11002, coinPrice: 100, wantDiamond: 10}, + {giftID: "super-lucky-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, commandID: "cmd-super-lucky-ratio", senderID: 11003, coinPrice: 99, wantDiamond: 0}, } for _, tc := range cases { repository.SetBalance(tc.senderID, 100) - repository.SetGiftPrice(tc.giftID, "v1", 100, 100, 100) + repository.SetGiftPrice(tc.giftID, "v1", tc.coinPrice, 100, tc.coinPrice) repository.SetGiftType(tc.giftID, tc.giftType) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: tc.commandID, @@ -258,12 +259,12 @@ func TestDebitGiftCreditsHostPeriodDiamondsBySenderRegionAndGiftType(t *testing. t.Fatalf("%s ratio diamond mismatch: got %+v want %d", tc.giftType, receipt, tc.wantDiamond) } } - if got := repository.HostPeriodGiftDiamondTotal(12001); got != 120 { + if got := repository.HostPeriodGiftDiamondTotal(12001); got != 110 { t.Fatalf("host period account total mismatch, got %d", got) } } -func TestDebitGiftAppliesRoomRegionGiftDiamondRatioToHeatValueByGiftType(t *testing.T) { +func TestDebitGiftAppliesGlobalDefaultGiftDiamondRatioToHeatValueByGiftType(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeNormal, "30.00") @@ -275,16 +276,17 @@ func TestDebitGiftAppliesRoomRegionGiftDiamondRatioToHeatValueByGiftType(t *test giftType string command string senderID int64 + heatUnit int64 wantHeat int64 }{ - {giftID: "normal-room-ratio-gift", giftType: resourcedomain.GiftTypeNormal, command: "cmd-normal-room-ratio", senderID: 13001, wantHeat: 30}, - {giftID: "lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeLucky, command: "cmd-lucky-room-ratio", senderID: 13002, wantHeat: 40}, - {giftID: "super-lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, command: "cmd-super-lucky-room-ratio", senderID: 13003, wantHeat: 50}, + {giftID: "normal-room-ratio-gift", giftType: resourcedomain.GiftTypeNormal, command: "cmd-normal-room-ratio", senderID: 13001, heatUnit: 100, wantHeat: 100}, + {giftID: "lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeLucky, command: "cmd-lucky-room-ratio", senderID: 13002, heatUnit: 100, wantHeat: 10}, + {giftID: "super-lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, command: "cmd-super-lucky-room-ratio", senderID: 13003, heatUnit: 99, wantHeat: 0}, } for _, tc := range cases { repository.SetBalance(tc.senderID, 100) - repository.SetGiftPrice(tc.giftID, "v1", 100, 100, 100) + repository.SetGiftPrice(tc.giftID, "v1", tc.heatUnit, 100, tc.heatUnit) repository.SetGiftType(tc.giftID, tc.giftType) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: tc.command, @@ -300,13 +302,13 @@ func TestDebitGiftAppliesRoomRegionGiftDiamondRatioToHeatValueByGiftType(t *test if err != nil { t.Fatalf("DebitGift %s failed: %v", tc.giftType, err) } - if receipt.HeatValue != tc.wantHeat || receipt.GiftPointAdded != 100 || receipt.CoinSpent != 100 { + if receipt.HeatValue != tc.wantHeat || receipt.GiftPointAdded != 100 || receipt.CoinSpent != tc.heatUnit { t.Fatalf("%s room contribution ratio mismatch: %+v want heat %d", tc.giftType, receipt, tc.wantHeat) } } } -func TestBatchDebitGiftAppliesRoomRegionGiftDiamondRatioToEachTargetHeatValue(t *testing.T) { +func TestBatchDebitGiftAppliesGlobalDefaultGiftDiamondRatioToEachTargetHeatValue(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(15001, 1000) repository.SetGiftPrice("batch-room-ratio-gift", "v1", 100, 100, 100) @@ -331,11 +333,11 @@ func TestBatchDebitGiftAppliesRoomRegionGiftDiamondRatioToEachTargetHeatValue(t if err != nil { t.Fatalf("BatchDebitGift failed: %v", err) } - if receipt.Aggregate.HeatValue != 50 || len(receipt.Targets) != 2 { + if receipt.Aggregate.HeatValue != 20 || len(receipt.Targets) != 2 { t.Fatalf("batch aggregate room contribution ratio mismatch: %+v", receipt) } for _, target := range receipt.Targets { - if target.Receipt.HeatValue != 25 || target.Receipt.GiftPointAdded != 100 { + if target.Receipt.HeatValue != 10 || target.Receipt.GiftPointAdded != 100 { t.Fatalf("target room contribution ratio mismatch: %+v", target) } } @@ -3619,6 +3621,12 @@ func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) { if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 { t.Fatalf("google payment should write one wallet entry, got %d", got) } + if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ? AND exchange_usd_minor_amount = ?", receipt.TransactionID, int64(1500), int64(150), int64(150)); got != 1 { + t.Fatalf("google payment should write recharge USD minor from micro amount, got %d", got) + } + if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ? AND total_coin_amount = ? AND total_usd_minor_amount = ?", int64(53001), int64(1500), int64(150)); got != 1 { + t.Fatalf("google payment should aggregate recharge USD minor from micro amount, got %d", got) + } } func TestApplyGameCoinChangeDebitCreditAndIdempotency(t *testing.T) { diff --git a/services/wallet-service/internal/storage/mysql/google_payment_repository.go b/services/wallet-service/internal/storage/mysql/google_payment_repository.go index 3bbffa87..e86b7243 100644 --- a/services/wallet-service/internal/storage/mysql/google_payment_repository.go +++ b/services/wallet-service/internal/storage/mysql/google_payment_repository.go @@ -54,7 +54,9 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go transactionID := transactionID(command.AppCode, command.CommandID) paymentOrderID := googlePaymentOrderID(command.AppCode, tokenHash) balanceAfter := account.AvailableAmount + product.CoinAmount - rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, command.AppCode, command.UserID, transactionID, product.CoinAmount, product.AmountMicro, nowMs) + // Google Play 返回和后台商品都保存微单位价格;充值事实表使用美元最小单位,必须在写账前收敛成美分。 + rechargeUSDMinor := googleAmountMicroToUSDMinor(product.AmountMicro) + rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, command.AppCode, command.UserID, transactionID, product.CoinAmount, rechargeUSDMinor, nowMs) if err != nil { return ledger.GooglePaymentReceipt{}, err } @@ -79,6 +81,7 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go CoinAmount: product.CoinAmount, CurrencyCode: product.CurrencyCode, AmountMicro: product.AmountMicro, + RechargeUSDMinor: rechargeUSDMinor, BalanceAfter: balanceAfter, RechargeSequence: rechargeSequence, CreatedAtMS: nowMs, @@ -109,11 +112,11 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go TargetAssetType: ledger.AssetCoin, TargetBalanceAfter: balanceAfter, RechargeSequence: rechargeSequence, - RechargeUSDMinor: product.AmountMicro, + RechargeUSDMinor: rechargeUSDMinor, RechargeCurrencyCode: product.CurrencyCode, RechargePolicyVersion: product.PolicyVersion, RechargePolicyCoinAmount: product.CoinAmount, - RechargePolicyUSDMinorAmount: product.AmountMicro, + RechargePolicyUSDMinorAmount: rechargeUSDMinor, }, nowMs); err != nil { return ledger.GooglePaymentReceipt{}, err } @@ -306,11 +309,19 @@ type googlePaymentMetadata struct { CoinAmount int64 `json:"coin_amount"` CurrencyCode string `json:"currency_code"` AmountMicro int64 `json:"amount_micro"` + RechargeUSDMinor int64 `json:"recharge_usd_minor"` BalanceAfter int64 `json:"balance_after"` RechargeSequence int64 `json:"recharge_sequence"` CreatedAtMS int64 `json:"created_at_ms"` } +func googleAmountMicroToUSDMinor(amountMicro int64) int64 { + if amountMicro <= 0 { + return 0 + } + return amountMicro / 10_000 +} + func googlePaymentCreditedEvent(transactionID string, commandID string, metadata googlePaymentMetadata, nowMs int64) walletOutboxEvent { return walletOutboxEvent{ EventID: eventID(transactionID, "WalletGooglePaymentCredited", metadata.UserID, ledger.AssetCoin), diff --git a/services/wallet-service/internal/storage/mysql/repository.go b/services/wallet-service/internal/storage/mysql/repository.go index 6e87b2c8..60f69e6d 100644 --- a/services/wallet-service/internal/storage/mysql/repository.go +++ b/services/wallet-service/internal/storage/mysql/repository.go @@ -128,8 +128,8 @@ func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error { created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES ('lalu', 0, 'normal', 'active', 100.00, 0, 0, 0, 0), - ('lalu', 0, 'lucky', 'active', 100.00, 0, 0, 0, 0), - ('lalu', 0, 'super_lucky', 'active', 100.00, 0, 0, 0, 0)`) + ('lalu', 0, 'lucky', 'active', 10.00, 0, 0, 0, 0), + ('lalu', 0, 'super_lucky', 'active', 1.00, 0, 0, 0, 0)`) return err } @@ -226,11 +226,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm if err != nil { return ledger.Receipt{}, err } - roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.RegionID, giftConfig.GiftTypeCode) + roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) if err != nil { return ledger.Receipt{}, err } - // 房间贡献由房间 visible_region_id 命中后台礼物钻石比例;room-service 只消费结算后的 heat_value,不再自己查钱包配置。 + // 房间贡献统一按全局默认礼物比例折算;room-service 只消费结算后的 heat_value,不再自己查钱包配置。 heatValue, err := giftDiamondAmount(baseHeatValue, roomContributionRatio.PPM) if err != nil { return ledger.Receipt{}, err @@ -240,7 +240,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm giftDiamondRatioPercent := "0.00" giftDiamondRatioRegionID := int64(0) if command.TargetIsHost { - ratio, ratioRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.SenderRegionID, giftConfig.GiftTypeCode) + ratio, ratioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) if err != nil { return ledger.Receipt{}, err } @@ -414,11 +414,11 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb if err != nil { return ledger.BatchGiftReceipt{}, err } - roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.RegionID, giftConfig.GiftTypeCode) + roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) if err != nil { return ledger.BatchGiftReceipt{}, err } - // 批量多目标仍然是每个目标各送一份同款礼物;每份房间贡献先按房间区域比例折算,再由聚合回执累加。 + // 批量多目标仍然是每个目标各送一份同款礼物;每份房间贡献先按全局默认礼物比例折算,再由聚合回执累加。 heatValue, err := giftDiamondAmount(baseHeatValue, roomContributionRatio.PPM) if err != nil { return ledger.BatchGiftReceipt{}, err @@ -489,7 +489,7 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb hostRatio := giftDiamondRatioSnapshot{Percent: "0.00", PPM: 0} hostRatioRegionID := int64(0) if anyHostTarget { - hostRatio, hostRatioRegionID, err = r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.SenderRegionID, giftConfig.GiftTypeCode) + hostRatio, hostRatioRegionID, err = r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) if err != nil { return ledger.BatchGiftReceipt{}, err } @@ -1754,7 +1754,12 @@ func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, ap return ratio, regionID, nil } } - return giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000}, 0, nil + return defaultGiftDiamondRatio(giftTypeCode), 0, nil +} + +func (r *Repository) resolveGlobalGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { + // 送礼运行链路只认后台“全局默认”行,避免房间区域或送礼人区域把贡献值和主播钻石算成不同口径。 + return r.resolveGiftDiamondRatio(ctx, tx, appCode, 0, giftTypeCode) } func (r *Repository) getGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) { @@ -1794,6 +1799,17 @@ func giftDiamondAmount(chargeAmount int64, ratioPPM int64) (int64, error) { return product / 1_000_000, nil } +func defaultGiftDiamondRatio(giftTypeCode string) giftDiamondRatioSnapshot { + switch strings.TrimSpace(giftTypeCode) { + case "lucky": + return giftDiamondRatioSnapshot{Percent: "10.00", PPM: 100_000} + case "super_lucky": + return giftDiamondRatioSnapshot{Percent: "1.00", PPM: 10_000} + default: + return giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000} + } +} + func (r *Repository) resolveRechargePolicy(ctx context.Context, tx *sql.Tx, regionID int64, nowMs int64) (rechargePolicy, error) { row := tx.QueryRowContext(ctx, `SELECT policy_id, region_id, policy_version, currency_code, coin_amount, usd_minor_amount, effective_from_ms