Update gift ratios and production service changes
This commit is contained in:
parent
90ea9d706d
commit
7a59d3571c
18
scripts/mysql/037_gift_diamond_global_defaults.sql
Normal file
18
scripts/mysql/037_gift_diamond_global_defaults.sql
Normal 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);
|
||||
@ -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),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user