package mysql import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) type pointToCoinSellerMetadata struct { AppCode string `json:"app_code"` SourceUserID int64 `json:"source_user_id"` SellerUserID int64 `json:"seller_user_id"` SourceCountryCode string `json:"source_country_code"` RegionID int64 `json:"region_id"` SourceAssetType string `json:"source_asset_type"` SellerAssetType string `json:"seller_asset_type"` PointAmount int64 `json:"point_amount"` SellerCoinAmount int64 `json:"seller_coin_amount"` RatioPointAmount int64 `json:"ratio_point_amount"` RatioSellerCoinAmount int64 `json:"ratio_seller_coin_amount"` GrossUSDMinor int64 `json:"gross_usd_minor,omitempty"` FeeUSDMinor int64 `json:"fee_usd_minor,omitempty"` NetUSDMinor int64 `json:"net_usd_minor,omitempty"` WithdrawFeeBPS int32 `json:"withdraw_fee_bps,omitempty"` PolicyID uint64 `json:"policy_id,omitempty"` PolicyVersion uint64 `json:"policy_version,omitempty"` SourcePointBalanceAfter int64 `json:"source_point_balance_after"` SellerBalanceAfter int64 `json:"seller_balance_after"` Reason string `json:"reason"` CreatedAtMS int64 `json:"created_at_ms"` } // ListPointWithdrawalCoinSellers 返回当前 App 的白名单;国家过滤在 wallet 内执行,gateway 不能绕过服务范围。 func (r *Repository) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, appCode) where := "app_code = ?" args := []any{appcode.FromContext(ctx)} if !includeDisabled { where += " AND status = 'active'" } rows, err := r.db.QueryContext(ctx, ` SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, service_country_codes, status, created_at_ms, updated_at_ms FROM point_withdrawal_coin_seller_configs WHERE `+where+` ORDER BY sort_order ASC, seller_user_id ASC`, args...) if err != nil { return nil, err } defer rows.Close() countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) items := make([]ledger.PointWithdrawalCoinSellerConfig, 0) for rows.Next() { var item ledger.PointWithdrawalCoinSellerConfig var countriesJSON []byte if err := rows.Scan(&item.AppCode, &item.SellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { return nil, err } if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil { return nil, xerr.New(xerr.Internal, "point withdrawal seller country config is invalid") } item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes) if item.PointAmount <= 0 || item.SellerCoinAmount <= 0 { return nil, xerr.New(xerr.Internal, "point withdrawal seller ratio is invalid") } if countryCode != "" && !countryAllowed(item.ServiceCountryCodes, countryCode) { continue } items = append(items, item) } return items, rows.Err() } // TransferPointToCoinSeller 在一个事务中锁定白名单配置和双方账户;任何比例更新只影响新 command_id。 func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledger.PointToCoinSellerCommand) (ledger.PointToCoinSellerReceipt, error) { if r == nil || r.db == nil { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } defer func() { _ = tx.Rollback() }() sourceAssetType := strings.ToUpper(strings.TrimSpace(command.SourceAssetType)) if sourceAssetType == "" { sourceAssetType = ledger.AssetPoint } if sourceAssetType != ledger.AssetPoint && sourceAssetType != ledger.AssetPointDiamond { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point source asset type is invalid") } // region/country/reason 都是 Gateway 根据当前资料派生的权威上下文,不属于用户稳定意图。 // 同一 command_id 仍严格绑定源用户、目标币商、资产和用户提交金额,防止篡改真实兑换目标。 requestHash := stableHash(fmt.Sprintf("point_transfer_coin_seller|%s|%d|%d|%s|%d|%d", command.AppCode, command.SourceUserID, command.SellerUserID, sourceAssetType, command.PointAmount, command.GrossUSDMinor)) if txRow, exists, lookupErr := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypePointTransferToCoinSeller, xerr.IdempotencyConflict); lookupErr != nil || exists { if lookupErr != nil || !exists { return ledger.PointToCoinSellerReceipt{}, lookupErr } return r.receiptForPointToCoinSellerTransaction(ctx, tx, txRow.TransactionID) } nowMS := time.Now().UTC().UnixMilli() pointAmount := command.PointAmount ratioPointAmount := int64(0) ratioSellerCoinAmount := int64(0) sellerCoinAmount := int64(0) grossUSDMinor := int64(0) feeUSDMinor := int64(0) netUSDMinor := int64(0) withdrawFeeBPS := int32(0) policyID := uint64(0) policyVersion := uint64(0) if sourceAssetType == ledger.AssetPointDiamond { policy, err := r.resolvePointDiamondHostPolicy(ctx, tx, command.RegionID, nowMS) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } if command.GrossUSDMinor < policy.MinimumWithdrawUSDMinor { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "withdrawal amount is below configured minimum") } pointAmount, feeUSDMinor, netUSDMinor, sellerCoinAmount, err = calculatePointDiamondCoinSellerAmounts( command.GrossUSDMinor, policy.PointDiamondsPerUSD, policy.CoinsPerUSD, policy.WithdrawFeeBPS, ) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } ratioPointAmount, ratioSellerCoinAmount = policy.PointDiamondsPerUSD, policy.CoinsPerUSD grossUSDMinor, withdrawFeeBPS = command.GrossUSDMinor, policy.WithdrawFeeBPS policyID, policyVersion = policy.PolicyID, policy.PolicyVersion if pointAmount <= 0 || sellerCoinAmount <= 0 { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "withdrawal amount is below exchange precision") } } else { policy, found, err := r.resolveActiveWalletPolicy(ctx, tx, command.AppCode, command.RegionID, nowMS) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } if !found { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.PermissionDenied, "point wallet policy is not configured") } if pointAmount < policy.MinimumPoints { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point withdrawal amount is below configured minimum") } } config, err := r.lockPointWithdrawalCoinSellerConfig(ctx, tx, command.SellerUserID) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } countryCode := strings.ToUpper(strings.TrimSpace(command.SourceCountryCode)) if !countryAllowed(config.ServiceCountryCodes, countryCode) { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.PermissionDenied, "coin seller does not serve user country") } if sourceAssetType == ledger.AssetPoint { numerator, err := checkedMul(pointAmount, config.SellerCoinAmount) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } sellerCoinAmount = numerator / config.PointAmount ratioPointAmount, ratioSellerCoinAmount = config.PointAmount, config.SellerCoinAmount if sellerCoinAmount <= 0 { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point amount is below exchange precision") } } // 送礼、积分兑币和币商提现可能沿相反方向触达相同资产账户;双方账户必须先按全局 // asset/user 顺序一次锁齐,再消费次数限制,避免 source->limit->seller 与其他账变反向等待。 accountStates, err := r.lockAccountsOrdered(ctx, tx, []walletAccountLockRequest{ {UserID: command.SourceUserID, AssetType: sourceAssetType, CreateIfMissing: false}, {UserID: command.SellerUserID, AssetType: ledger.AssetCoinSellerCoin, CreateIfMissing: true}, }, nowMS) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } source := accountStates[walletAccountLockKey(command.SourceUserID, sourceAssetType)].account seller := accountStates[walletAccountLockKey(command.SellerUserID, ledger.AssetCoinSellerCoin)].account if source.AvailableAmount < pointAmount { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient point balance") } policyType := "" if sourceAssetType == ledger.AssetPointDiamond { // 永久积分继续引用最后一份 POINT_DIAMOND 快照,后续切换工资政策不影响旧余额的转出限制。 policyType = ledger.HostPolicyTypePointDiamond } if err := r.consumeHostPolicyWithdrawalLimitForPolicyType(ctx, tx, command.SourceUserID, command.RegionID, pointWithdrawalChannelCoinSeller, command.CommandID, "", nowMS, sourceAssetType, policyType, requireHostPolicy); err != nil { return ledger.PointToCoinSellerReceipt{}, err } sourceAfter := source.AvailableAmount - pointAmount sellerAfter, err := checkedAdd(seller.AvailableAmount, sellerCoinAmount) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } transactionID := transactionID(command.AppCode, command.CommandID) metadata := pointToCoinSellerMetadata{ AppCode: command.AppCode, SourceUserID: command.SourceUserID, SellerUserID: command.SellerUserID, SourceCountryCode: countryCode, SourceAssetType: sourceAssetType, SellerAssetType: ledger.AssetCoinSellerCoin, RegionID: command.RegionID, PointAmount: pointAmount, SellerCoinAmount: sellerCoinAmount, RatioPointAmount: ratioPointAmount, RatioSellerCoinAmount: ratioSellerCoinAmount, GrossUSDMinor: grossUSDMinor, FeeUSDMinor: feeUSDMinor, NetUSDMinor: netUSDMinor, WithdrawFeeBPS: withdrawFeeBPS, PolicyID: policyID, PolicyVersion: policyVersion, SourcePointBalanceAfter: sourceAfter, SellerBalanceAfter: sellerAfter, Reason: command.Reason, CreatedAtMS: nowMS, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypePointTransferToCoinSeller, requestHash, fmt.Sprintf("point_coin_seller:%d:%d", command.SourceUserID, command.SellerUserID), metadata, nowMS); err != nil { return ledger.PointToCoinSellerReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, source, -pointAmount, 0, nowMS); err != nil { return ledger.PointToCoinSellerReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{TransactionID: transactionID, UserID: command.SourceUserID, AssetType: sourceAssetType, AvailableDelta: -pointAmount, AvailableAfter: sourceAfter, FrozenAfter: source.FrozenAmount, CounterpartyUserID: command.SellerUserID, CreatedAtMS: nowMS}); err != nil { return ledger.PointToCoinSellerReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, seller, sellerCoinAmount, 0, nowMS); err != nil { return ledger.PointToCoinSellerReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{TransactionID: transactionID, UserID: command.SellerUserID, AssetType: ledger.AssetCoinSellerCoin, AvailableDelta: sellerCoinAmount, AvailableAfter: sellerAfter, FrozenAfter: seller.FrozenAmount, CounterpartyUserID: command.SourceUserID, CreatedAtMS: nowMS}); err != nil { return ledger.PointToCoinSellerReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, sourceAssetType, -pointAmount, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMS, bizTypePointTransferToCoinSeller), balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, sellerCoinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMS, bizTypePointTransferToCoinSeller), }); err != nil { return ledger.PointToCoinSellerReceipt{}, err } if err := tx.Commit(); err != nil { return ledger.PointToCoinSellerReceipt{}, err } return receiptFromPointToCoinSellerMetadata(transactionID, metadata), nil } func calculatePointDiamondCoinSellerAmounts(grossUSDMinor int64, pointsPerUSD int64, coinsPerUSD int64, feeBPS int32) (pointAmount int64, feeUSDMinor int64, netUSDMinor int64, sellerCoinAmount int64, err error) { if grossUSDMinor <= 0 || pointsPerUSD <= 0 || coinsPerUSD <= 0 || feeBPS < 0 || feeBPS > 10_000 { err = xerr.New(xerr.InvalidArgument, "point diamond withdrawal policy is invalid") return } pointProduct, calculateErr := checkedMul(grossUSDMinor, pointsPerUSD) if calculateErr != nil { err = calculateErr return } feeProduct, calculateErr := checkedMul(grossUSDMinor, int64(feeBPS)) if calculateErr != nil { err = calculateErr return } pointAmount = pointProduct / 100 feeUSDMinor = feeProduct / 10_000 netUSDMinor = grossUSDMinor - feeUSDMinor coinProduct, calculateErr := checkedMul(netUSDMinor, coinsPerUSD) if calculateErr != nil { err = calculateErr return } sellerCoinAmount = coinProduct / 100 if pointAmount <= 0 || netUSDMinor <= 0 || sellerCoinAmount <= 0 { err = xerr.New(xerr.InvalidArgument, "withdrawal amount is below exchange precision") } return } // GetPointWithdrawalConfig 只暴露原 wallet 政策中 POINT 提现和兑换实际消费的字段。 // 礼物收益不会读取该政策;缺少配置时显式 found=false,不回退编译期常量。 func (r *Repository) GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (ledger.PointWithdrawalRuntimeConfig, error) { return r.GetPointWithdrawalConfigForAsset(ctx, appCode, regionID, nowMS, ledger.AssetPoint) } func (r *Repository) GetPointWithdrawalConfigForAsset(ctx context.Context, appCode string, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) { return r.getPointWithdrawalConfig(ctx, appCode, 0, regionID, nowMS, assetType) } func (r *Repository) GetPointWithdrawalConfigForUser(ctx context.Context, appCode string, userID int64, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) { if userID <= 0 { return ledger.PointWithdrawalRuntimeConfig{}, xerr.New(xerr.InvalidArgument, "point withdrawal config user is invalid") } return r.getPointWithdrawalConfig(ctx, appCode, userID, regionID, nowMS, assetType) } func (r *Repository) getPointWithdrawalConfig(ctx context.Context, appCode string, userID int64, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) { if r == nil || r.db == nil { return ledger.PointWithdrawalRuntimeConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, appCode) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return ledger.PointWithdrawalRuntimeConfig{}, err } defer func() { _ = tx.Rollback() }() assetType = strings.ToUpper(strings.TrimSpace(assetType)) if assetType == ledger.AssetPointDiamond { policy, err := r.resolvePointDiamondHostPolicy(ctx, tx, regionID, nowMS) if err != nil { if xerr.CodeOf(err) == xerr.PermissionDenied { return ledger.PointWithdrawalRuntimeConfig{Found: false}, nil } return ledger.PointWithdrawalRuntimeConfig{}, err } minimumPoints, calculateErr := checkedMul(policy.MinimumWithdrawUSDMinor, policy.PointDiamondsPerUSD) if calculateErr != nil { return ledger.PointWithdrawalRuntimeConfig{}, calculateErr } config := ledger.PointWithdrawalRuntimeConfig{ Found: true, PointsPerUSD: policy.PointDiamondsPerUSD, CoinsPerUSD: policy.CoinsPerUSD, FeeBPS: int64(policy.WithdrawFeeBPS), MinimumPoints: minimumPoints / 100, PolicyInstanceCode: fmt.Sprintf("host:%d:%d", policy.PolicyID, policy.PolicyVersion), PolicyType: policy.PolicyType, MinimumWithdrawUSDMinor: policy.MinimumWithdrawUSDMinor, AgencyPointShareBPS: policy.AgencyPointShareBPS, PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion, } if userID > 0 { now := time.UnixMilli(nowMS).UTC() coinSellerLimit, limitErr := hostPolicyWithdrawalLimitFromPolicy(policy, pointWithdrawalChannelCoinSeller, now) if limitErr != nil { return ledger.PointWithdrawalRuntimeConfig{}, limitErr } platformLimit, limitErr := hostPolicyWithdrawalLimitFromPolicy(policy, pointWithdrawalChannelPlatform, now) if limitErr != nil { return ledger.PointWithdrawalRuntimeConfig{}, limitErr } config.CoinSellerAvailability, limitErr = r.pointWithdrawalActionAvailability(ctx, tx, userID, assetType, pointWithdrawalChannelCoinSeller, coinSellerLimit, now) if limitErr != nil { return ledger.PointWithdrawalRuntimeConfig{}, limitErr } config.PlatformAvailability, limitErr = r.pointWithdrawalActionAvailability(ctx, tx, userID, assetType, pointWithdrawalChannelPlatform, platformLimit, now) if limitErr != nil { return ledger.PointWithdrawalRuntimeConfig{}, limitErr } config.AvailabilityEvaluated = true } return config, nil } policy, found, err := r.resolveActiveWalletPolicy(ctx, tx, appcode.FromContext(ctx), regionID, nowMS) if err != nil || !found { return ledger.PointWithdrawalRuntimeConfig{Found: false}, err } return ledger.PointWithdrawalRuntimeConfig{ Found: true, PointsPerUSD: policy.PointsPerUSD, CoinsPerUSD: ledger.SalaryExchangeCoinPerUSD, FeeBPS: policy.WithdrawFeeBPS, MinimumPoints: policy.MinimumPoints, PolicyInstanceCode: policy.InstanceCode, }, nil } func (r *Repository) lockPointWithdrawalCoinSellerConfig(ctx context.Context, tx *sql.Tx, sellerUserID int64) (ledger.PointWithdrawalCoinSellerConfig, error) { var item ledger.PointWithdrawalCoinSellerConfig var countriesJSON []byte err := tx.QueryRowContext(ctx, ` SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, service_country_codes, status, created_at_ms, updated_at_ms FROM point_withdrawal_coin_seller_configs WHERE app_code = ? AND seller_user_id = ? AND status = 'active' FOR UPDATE`, appcode.FromContext(ctx), sellerUserID).Scan( &item.AppCode, &item.SellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS, ) if errors.Is(err, sql.ErrNoRows) { return item, xerr.New(xerr.NotFound, "point withdrawal coin seller is not available") } if err != nil { return item, err } if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil || item.PointAmount <= 0 || item.SellerCoinAmount <= 0 { return item, xerr.New(xerr.Internal, "point withdrawal coin seller config is invalid") } item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes) return item, nil } func (r *Repository) receiptForPointToCoinSellerTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.PointToCoinSellerReceipt, error) { var metadataJSON string if err := tx.QueryRowContext(ctx, `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') FROM wallet_transactions WHERE app_code = ? AND transaction_id = ?`, appcode.FromContext(ctx), transactionID).Scan(&metadataJSON); err != nil { return ledger.PointToCoinSellerReceipt{}, err } var metadata pointToCoinSellerMetadata if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { return ledger.PointToCoinSellerReceipt{}, err } return receiptFromPointToCoinSellerMetadata(transactionID, metadata), nil } func receiptFromPointToCoinSellerMetadata(transactionID string, metadata pointToCoinSellerMetadata) ledger.PointToCoinSellerReceipt { return ledger.PointToCoinSellerReceipt{ TransactionID: transactionID, SourceUserID: metadata.SourceUserID, SellerUserID: metadata.SellerUserID, SourcePointBalanceAfter: metadata.SourcePointBalanceAfter, SellerBalanceAfter: metadata.SellerBalanceAfter, PointAmount: metadata.PointAmount, SellerCoinAmount: metadata.SellerCoinAmount, RatioPointAmount: metadata.RatioPointAmount, RatioSellerCoinAmount: metadata.RatioSellerCoinAmount, CreatedAtMS: metadata.CreatedAtMS, SourceAssetType: metadata.SourceAssetType, GrossUSDMinor: metadata.GrossUSDMinor, FeeUSDMinor: metadata.FeeUSDMinor, NetUSDMinor: metadata.NetUSDMinor, WithdrawFeeBPS: metadata.WithdrawFeeBPS, PolicyID: metadata.PolicyID, PolicyVersion: metadata.PolicyVersion, } } func normalizeCountryCodes(values []string) []string { seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { value = strings.ToUpper(strings.TrimSpace(value)) if value == "" { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} out = append(out, value) } return out } func countryAllowed(configured []string, countryCode string) bool { if len(configured) == 0 { return true } countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) if countryCode == "" { return false } for _, configuredCode := range configured { if strings.EqualFold(configuredCode, countryCode) { return true } } return false }