package mysql import ( "context" "database/sql" "errors" "math/big" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) type giftPrice struct { GiftID string PriceVersion string ChargeAssetType string CoinPrice int64 // GiftPointAmount 是 wallet_gift_prices 的历史列;送礼结算只按 CoinPrice 和比例计算,不再读取它做收益。 GiftPointAmount int64 HeatValue int64 } type rechargePolicy struct { PolicyID int64 RegionID int64 PolicyVersion string CurrencyCode string CoinAmount int64 USDMinorAmount int64 EffectiveFromMs int64 } type giftDiamondRatioSnapshot struct { Percent string PPM int64 } func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID string, priceVersion string, nowMs int64) (giftPrice, error) { var row *sql.Row if strings.TrimSpace(priceVersion) != "" { row = tx.QueryRowContext(ctx, `SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value FROM wallet_gift_prices WHERE app_code = ? AND gift_id = ? AND price_version = ? AND status = 'active' AND effective_at_ms <= ?`, appcode.FromContext(ctx), giftID, priceVersion, nowMs, ) } else { row = tx.QueryRowContext(ctx, `SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value FROM wallet_gift_prices WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ? ORDER BY effective_at_ms DESC, price_version DESC LIMIT 1`, appcode.FromContext(ctx), giftID, nowMs, ) } var price giftPrice if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil { if errors.Is(err, sql.ErrNoRows) { return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active") } return giftPrice{}, err } if price.CoinPrice < 0 || price.HeatValue < 0 { return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid") } price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType) return price, nil } func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { giftTypeCode = strings.TrimSpace(giftTypeCode) if giftTypeCode == "" { giftTypeCode = "normal" } regions := []int64{regionID} if regionID != 0 { regions = append(regions, 0) } for _, regionID := range regions { ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, giftTypeCode) if err != nil { return giftDiamondRatioSnapshot{}, 0, err } if exists { return ratio, regionID, nil } } // 礼物类型倍率属于后台运行政策。区域和全局都未配置时必须在扣币前失败, // 不能用 normal/lucky/super_lucky 编译期常量悄悄制造 Host 收益。 return giftDiamondRatioSnapshot{}, 0, xerr.New(xerr.PermissionDenied, "gift diamond ratio is not configured") } 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) { var percent string var ppm int64 err := tx.QueryRowContext(ctx, ` SELECT CAST(ratio_percent AS CHAR), CAST(ROUND(ratio_percent * 10000) AS SIGNED) FROM gift_diamond_ratio_configs WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active' LIMIT 1`, appcode.Normalize(appCode), regionID, giftTypeCode, ).Scan(&percent, &ppm) if errors.Is(err, sql.ErrNoRows) { return giftDiamondRatioSnapshot{}, false, nil } if err != nil { return giftDiamondRatioSnapshot{}, false, err } if ppm < 0 || ppm > 1_000_000 { return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift diamond ratio is invalid") } percent = strings.TrimSpace(percent) if percent == "" { return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift diamond ratio is invalid") } return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil } func (r *Repository) resolveGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { giftTypeCode = strings.TrimSpace(giftTypeCode) if giftTypeCode == "" { giftTypeCode = "normal" } regions := []int64{regionID} if regionID != 0 { regions = append(regions, 0) } for _, regionID := range regions { ratio, exists, err := r.getGiftReturnCoinRatio(ctx, tx, appCode, regionID, giftTypeCode) if err != nil { return giftDiamondRatioSnapshot{}, 0, err } if exists { return ratio, regionID, nil } } // 返金币与主播钻石倍率共用后台配置行,但业务含义独立。区域和全局均无记录时 // 必须在账户锁和扣币前失败,不能再按 gift_type 推导 30/10/1。 return giftDiamondRatioSnapshot{}, 0, xerr.New(xerr.PermissionDenied, "gift return coin ratio is not configured") } func (r *Repository) getGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) { var percent string var ppm int64 err := tx.QueryRowContext(ctx, ` SELECT CAST(coin_return_ratio_percent AS CHAR), CAST(ROUND(coin_return_ratio_percent * 10000) AS SIGNED) FROM gift_diamond_ratio_configs WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active' LIMIT 1`, appcode.Normalize(appCode), regionID, giftTypeCode, ).Scan(&percent, &ppm) if errors.Is(err, sql.ErrNoRows) { return giftDiamondRatioSnapshot{}, false, nil } if err != nil { return giftDiamondRatioSnapshot{}, false, err } if ppm < 0 || ppm > 1_000_000 { return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift return coin ratio is invalid") } percent = strings.TrimSpace(percent) if percent == "" { return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift return coin ratio is invalid") } return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil } func giftDiamondAmount(chargeAmount int64, ratioPPM int64) (int64, error) { if chargeAmount < 0 || ratioPPM < 0 { return 0, xerr.New(xerr.InvalidArgument, "gift diamond amount is invalid") } if chargeAmount == 0 || ratioPPM == 0 { // 0 金额或 0% 比例都是合法业务结果;这里只跳过乘法溢出工具的正数乘数约束,不放宽负数和数量校验。 return 0, nil } product, err := checkedMul(chargeAmount, ratioPPM) if err != nil { return 0, err } return product / 1_000_000, nil } // giftDiamondAmountWithPolicyCoefficient 在一次定点运算中完成 // 金币 * 礼物类型倍率 * Host 政策系数,最后统一向下取整。 // 不能先算礼物倍率再乘政策系数,否则两次整数截断会让小额礼物少记周期钻石。 func giftDiamondAmountWithPolicyCoefficient(chargeAmount int64, giftTypeRatioPPM int64, policyCoefficientPPM int64) (int64, error) { if chargeAmount < 0 || giftTypeRatioPPM < 0 || policyCoefficientPPM <= 0 { return 0, xerr.New(xerr.InvalidArgument, "host period diamond amount is invalid") } if chargeAmount == 0 || giftTypeRatioPPM == 0 { return 0, nil } // 系数允许 6 位小数,两个 ppm 分母合并为 10^12。big.Int 只用于防止合法大额 // 在中间乘法溢出;最终值仍必须落入账本的 BIGINT 边界。 amount := new(big.Int).SetInt64(chargeAmount) amount.Mul(amount, big.NewInt(giftTypeRatioPPM)) amount.Mul(amount, big.NewInt(policyCoefficientPPM)) amount.Quo(amount, big.NewInt(1_000_000_000_000)) if !amount.IsInt64() { return 0, xerr.New(xerr.InvalidArgument, "host period diamond amount overflow") } return amount.Int64(), nil } func amountByBPS(amount int64, bps int32) (int64, error) { if amount < 0 || bps < 0 || bps > 10_000 { return 0, xerr.New(xerr.InvalidArgument, "income share bps is invalid") } if amount == 0 || bps == 0 { return 0, nil } value := new(big.Int).Mul(big.NewInt(amount), big.NewInt(int64(bps))) value.Quo(value, big.NewInt(10_000)) if !value.IsInt64() { return 0, xerr.New(xerr.InvalidArgument, "income share amount overflow") } return value.Int64(), nil } type hostPeriodDiamondCoefficientSnapshot struct { PolicyID uint64 PolicyVersion uint64 PolicyType string Value string PPM int64 PointDiamondsPerUSD int64 CoinsPerUSD int64 MinimumWithdrawUSDMinor int64 WithdrawFeeBPS int32 AgencyPointShareBPS int32 } // resolveHostPolicyDiamondCoefficient 只读取主播区域在当前周期绑定的已发布 Host 政策。 // 当前月缺少显式绑定时仅继承紧邻上月;完全没有政策时返回 found=false,调用方不得用代码默认系数累计周期钻石。 func (r *Repository) resolveHostPolicyDiamondCoefficient(ctx context.Context, tx *sql.Tx, regionID int64, cycleKey string) (hostPeriodDiamondCoefficientSnapshot, bool, error) { query := func() (hostPeriodDiamondCoefficientSnapshot, bool, error) { var snapshot hostPeriodDiamondCoefficientSnapshot err := tx.QueryRowContext(ctx, ` SELECT p.policy_id, p.policy_version, p.policy_type, CAST(p.gift_coin_to_diamond_ratio AS CHAR), CAST(ROUND(p.gift_coin_to_diamond_ratio * 1000000) AS SIGNED), p.point_diamonds_per_usd, p.coins_per_usd, p.minimum_withdraw_usd_minor, p.withdraw_fee_bps, p.agency_point_share_bps FROM host_salary_policy_cycle_bindings binding JOIN host_agency_salary_policies p ON p.app_code = binding.app_code AND p.policy_id = binding.policy_id AND p.policy_version = binding.policy_version WHERE binding.app_code = ? AND binding.region_id = ? AND binding.cycle_key = ? AND p.status = 'active' LIMIT 1`, appcode.FromContext(ctx), regionID, cycleKey).Scan( &snapshot.PolicyID, &snapshot.PolicyVersion, &snapshot.PolicyType, &snapshot.Value, &snapshot.PPM, &snapshot.PointDiamondsPerUSD, &snapshot.CoinsPerUSD, &snapshot.MinimumWithdrawUSDMinor, &snapshot.WithdrawFeeBPS, &snapshot.AgencyPointShareBPS, ) if errors.Is(err, sql.ErrNoRows) { return hostPeriodDiamondCoefficientSnapshot{}, false, nil } if err != nil { return hostPeriodDiamondCoefficientSnapshot{}, false, err } if snapshot.PPM <= 0 { return hostPeriodDiamondCoefficientSnapshot{}, false, xerr.New(xerr.InvalidArgument, "host policy diamond coefficient is invalid") } snapshot.PolicyType = strings.ToUpper(strings.TrimSpace(snapshot.PolicyType)) if snapshot.PolicyType != ledger.HostPolicyTypeSalaryDiamond && snapshot.PolicyType != ledger.HostPolicyTypePointDiamond { return hostPeriodDiamondCoefficientSnapshot{}, false, xerr.New(xerr.InvalidArgument, "host policy type is invalid") } if snapshot.AgencyPointShareBPS < 0 || snapshot.AgencyPointShareBPS > 10_000 { return hostPeriodDiamondCoefficientSnapshot{}, false, xerr.New(xerr.InvalidArgument, "agency point share bps is invalid") } snapshot.Value = strings.TrimSpace(snapshot.Value) return snapshot, true, nil } snapshot, found, err := query() if err != nil || found { return snapshot, found, err } previousCycleKey, err := previousHostSalaryCycleKey(cycleKey) if err != nil { return hostPeriodDiamondCoefficientSnapshot{}, false, nil } if err := r.inheritHostSalaryPolicyBinding(ctx, tx, regionID, previousCycleKey, cycleKey, "", ""); err != nil { return hostPeriodDiamondCoefficientSnapshot{}, false, err } return query() } // resolveHostPeriodDiamondCoefficient 只返回后台发布并绑定的 Host 政策。缺失政策时不产生任何主播钻石收益, // 从而保证运行口径完全来自政策快照,而不是产品名或代码默认值。 func (r *Repository) resolveHostPeriodDiamondCoefficient(ctx context.Context, tx *sql.Tx, regionID int64, cycleKey string) (hostPeriodDiamondCoefficientSnapshot, bool, error) { return r.resolveHostPolicyDiamondCoefficient(ctx, tx, regionID, cycleKey) } 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 FROM wallet_recharge_policies WHERE app_code = ? AND region_id = ? AND status = 'active' AND effective_from_ms <= ? AND (effective_to_ms IS NULL OR effective_to_ms > ?) ORDER BY effective_from_ms DESC, policy_id DESC LIMIT 1`, appcode.FromContext(ctx), regionID, nowMs, nowMs, ) var policy rechargePolicy if err := row.Scan(&policy.PolicyID, &policy.RegionID, &policy.PolicyVersion, &policy.CurrencyCode, &policy.CoinAmount, &policy.USDMinorAmount, &policy.EffectiveFromMs); err != nil { if errors.Is(err, sql.ErrNoRows) { return rechargePolicy{}, xerr.New(xerr.NotFound, "recharge policy not found") } return rechargePolicy{}, err } if policy.CoinAmount <= 0 || policy.USDMinorAmount <= 0 || strings.TrimSpace(policy.CurrencyCode) == "" { return rechargePolicy{}, xerr.New(xerr.Internal, "recharge policy is invalid") } return policy, nil } func calculateRechargeUSDMinor(coinAmount int64, policy rechargePolicy) (int64, error) { numerator, err := checkedMul(coinAmount, policy.USDMinorAmount) if err != nil { return 0, err } return numerator / policy.CoinAmount, nil } func (r *Repository) resolveCoinSellerSalaryExchangeRateTier(ctx context.Context, tx *sql.Tx, regionID int64, salaryUSDMinor int64) (ledger.CoinSellerSalaryExchangeRateTier, error) { row := tx.QueryRowContext(ctx, `SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms FROM coin_seller_salary_exchange_rate_tiers WHERE app_code = ? AND region_id = ? AND status = 'active' AND min_usd_minor <= ? AND max_usd_minor >= ? ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC LIMIT 1 FOR UPDATE`, appcode.FromContext(ctx), regionID, salaryUSDMinor, salaryUSDMinor, ) var tier ledger.CoinSellerSalaryExchangeRateTier if err := row.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.NotFound, "exchange rate not configured") } return ledger.CoinSellerSalaryExchangeRateTier{}, err } if tier.MinUSDMinor <= 0 || tier.MaxUSDMinor < tier.MinUSDMinor || tier.CoinPerUSD <= 0 || tier.CoinPerUSD%100 != 0 { return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.Internal, "coin seller salary exchange rate is invalid") } return tier, nil } func calculateSalaryCoinAmount(salaryUSDMinor int64, coinPerUSD int64) (int64, error) { numerator, err := checkedMul(salaryUSDMinor, coinPerUSD) if err != nil { return 0, err } if numerator%100 != 0 { return 0, xerr.New(xerr.InvalidArgument, "salary amount does not match exchange rate") } return numerator / 100, nil }