2026-07-22 13:55:55 +08:00

382 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}