269 lines
13 KiB
Go
269 lines
13 KiB
Go
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
|
||
}
|