2026-07-12 17:38:48 +08:00

269 lines
13 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"
"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=falsegateway 不回退到猜测参数。
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
}