494 lines
23 KiB
Go
494 lines
23 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"
|
||
)
|
||
|
||
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, ®ionsJSON, &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,
|
||
®ionsJSON, &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
|
||
}
|