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" ) const pointWithdrawalMinimumPoints int64 = 1_000_000 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"` 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"` 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() }() requestHash := stableHash(fmt.Sprintf("point_transfer_coin_seller|%s|%d|%d|%d|%s|%s", command.AppCode, command.SourceUserID, command.SellerUserID, command.PointAmount, strings.ToUpper(strings.TrimSpace(command.SourceCountryCode)), strings.TrimSpace(command.Reason))) 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) } 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") } numerator, err := checkedMul(command.PointAmount, config.SellerCoinAmount) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } sellerCoinAmount := numerator / config.PointAmount if sellerCoinAmount <= 0 { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point amount is below exchange precision") } nowMS := time.Now().UnixMilli() source, err := r.lockAccount(ctx, tx, command.SourceUserID, ledger.AssetPoint, false, nowMS) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } if source.AvailableAmount < command.PointAmount { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient POINT balance") } seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMS) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } sourceAfter := source.AvailableAmount - command.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: ledger.AssetPoint, SellerAssetType: ledger.AssetCoinSellerCoin, PointAmount: command.PointAmount, SellerCoinAmount: sellerCoinAmount, RatioPointAmount: config.PointAmount, RatioSellerCoinAmount: config.SellerCoinAmount, 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, -command.PointAmount, 0, nowMS); err != nil { return ledger.PointToCoinSellerReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{TransactionID: transactionID, UserID: command.SourceUserID, AssetType: ledger.AssetPoint, AvailableDelta: -command.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, ledger.AssetPoint, -command.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 } // GetPointWithdrawalConfig 只读取已发布钱包政策;不存在时显式 found=false,gateway 不回退到猜测参数。 func (r *Repository) GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (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, &sql.TxOptions{ReadOnly: true}) if err != nil { return ledger.PointWithdrawalRuntimeConfig{}, err } defer func() { _ = tx.Rollback() }() 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, FeeBPS: policy.WithdrawFeeBPS, MinimumPoints: pointWithdrawalMinimumPoints, 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, } } 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 }