2026-07-22 18:36:49 +08:00

494 lines
23 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"
)
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, regionID int64, 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,
COALESCE(service_region_ids, JSON_ARRAY()), 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 regionsJSON []byte
var countriesJSON []byte
if err := rows.Scan(&item.AppCode, &item.SellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &regionsJSON, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return nil, err
}
if err := json.Unmarshal(regionsJSON, &item.ServiceRegionIDs); err != nil {
return nil, xerr.New(xerr.Internal, "point withdrawal seller region config is invalid")
}
if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil {
return nil, xerr.New(xerr.Internal, "point withdrawal seller country config is invalid")
}
item.ServiceRegionIDs = normalizePointWithdrawalRegionIDs(item.ServiceRegionIDs)
item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes)
if item.PointAmount <= 0 || item.SellerCoinAmount <= 0 {
return nil, xerr.New(xerr.Internal, "point withdrawal seller ratio is invalid")
}
// region=0 且 country 为空是 Admin/内部全量读取;用户入口会携带资料中的真实区域和国家。
if regionID > 0 || countryCode != "" {
allowed, scopeErr := serviceScopeAllowed(item.ServiceRegionIDs, item.ServiceCountryCodes, regionID, countryCode)
if scopeErr != nil {
return nil, scopeErr
}
if !allowed {
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))
allowed, err := serviceScopeAllowed(config.ServiceRegionIDs, config.ServiceCountryCodes, command.RegionID, countryCode)
if err != nil {
return ledger.PointToCoinSellerReceipt{}, err
}
if !allowed {
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.PermissionDenied, "coin seller does not serve user region or 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 regionsJSON []byte
var countriesJSON []byte
err := tx.QueryRowContext(ctx, `
SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount,
COALESCE(service_region_ids, JSON_ARRAY()), 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,
&regionsJSON, &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(regionsJSON, &item.ServiceRegionIDs); err != nil {
return item, xerr.New(xerr.Internal, "point withdrawal coin seller region config is invalid")
}
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.ServiceRegionIDs = normalizePointWithdrawalRegionIDs(item.ServiceRegionIDs)
item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes)
if len(item.ServiceRegionIDs) > 0 && len(item.ServiceCountryCodes) > 0 {
return item, xerr.New(xerr.Internal, "point withdrawal coin seller service scopes conflict")
}
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 normalizePointWithdrawalRegionIDs(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
// serviceScopeAllowed 保持三态边界:区域白名单、国家白名单、两个都为空的全量范围。
// 同时存在两种范围属于损坏配置,必须失败而不是猜测 OR/AND 语义后放宽资金入口。
func serviceScopeAllowed(regionIDs []int64, countryCodes []string, regionID int64, countryCode string) (bool, error) {
if len(regionIDs) > 0 && len(countryCodes) > 0 {
return false, xerr.New(xerr.Internal, "point withdrawal coin seller service scopes conflict")
}
if len(regionIDs) > 0 {
for _, configuredID := range regionIDs {
if configuredID == regionID {
return true, nil
}
}
return false, nil
}
if len(countryCodes) == 0 {
return true, nil
}
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" {
return false, nil
}
for _, configuredCode := range countryCodes {
if strings.EqualFold(configuredCode, countryCode) {
return true, nil
}
}
return false, nil
}