382 lines
15 KiB
Go
382 lines
15 KiB
Go
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
|
||
}
|