Update gift ratios and production service changes

This commit is contained in:
zhx 2026-06-05 12:09:37 +08:00
parent 90ea9d706d
commit 7a59d3571c
31 changed files with 598 additions and 519 deletions

View File

@ -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);

View File

@ -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),

View File

@ -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"

View File

@ -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 {

View File

@ -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
}

View File

@ -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 ""

View File

@ -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"`

View File

@ -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 {

View File

@ -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)
}

View File

@ -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{}

View File

@ -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 组装支付后台 handlerwallet 负责账单和商品事实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{

View File

@ -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);

View File

@ -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 == "" {

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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])
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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);

View File

@ -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) {

View File

@ -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),

View File

@ -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