306 lines
12 KiB
Go
306 lines
12 KiB
Go
package financeorder
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"hyapp-admin-server/internal/appctx"
|
||
"hyapp-admin-server/internal/model"
|
||
"hyapp-admin-server/internal/modules/shared"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
const (
|
||
coinSellerRechargeExchangeRateGroup = "finance-coin-seller-recharge"
|
||
coinSellerRechargeExchangeRateKey = "exchange-rate-policy"
|
||
maxExchangeRateTiers = 100
|
||
maxExchangeRateWhitelistEntries = 1000
|
||
)
|
||
|
||
type coinSellerRechargeExchangeRateTierRequest struct {
|
||
MinUSDAmount float64 `json:"minUsdAmount"`
|
||
MaxUSDAmount float64 `json:"maxUsdAmount"`
|
||
CoinsPerUSD int64 `json:"coinsPerUsd"`
|
||
}
|
||
|
||
type coinSellerRechargeExchangeRateWhitelistRequest struct {
|
||
UserID string `json:"userId"`
|
||
CoinsPerUSD int64 `json:"coinsPerUsd"`
|
||
}
|
||
|
||
type coinSellerRechargeExchangeRatePolicyRequest struct {
|
||
Tiers []coinSellerRechargeExchangeRateTierRequest `json:"tiers"`
|
||
Whitelist []coinSellerRechargeExchangeRateWhitelistRequest `json:"whitelist"`
|
||
}
|
||
|
||
type coinSellerRechargeExchangeRateTierDTO struct {
|
||
MinUSDAmount string `json:"minUsdAmount"`
|
||
MaxUSDAmount string `json:"maxUsdAmount"`
|
||
CoinsPerUSD int64 `json:"coinsPerUsd"`
|
||
}
|
||
|
||
type coinSellerRechargeExchangeRateWhitelistDTO struct {
|
||
UserID string `json:"userId"`
|
||
CoinsPerUSD int64 `json:"coinsPerUsd"`
|
||
}
|
||
|
||
type coinSellerRechargeExchangeRatePolicyDTO struct {
|
||
AppCode string `json:"appCode"`
|
||
Tiers []coinSellerRechargeExchangeRateTierDTO `json:"tiers"`
|
||
Whitelist []coinSellerRechargeExchangeRateWhitelistDTO `json:"whitelist"`
|
||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||
}
|
||
|
||
type coinSellerRechargeQuoteDTO struct {
|
||
AppCode string `json:"appCode"`
|
||
TargetUserID string `json:"targetUserId"`
|
||
TargetDisplayUserID string `json:"targetDisplayUserId"`
|
||
USDAmount string `json:"usdAmount"`
|
||
CoinAmount int64 `json:"coinAmount"`
|
||
CoinsPerUSD int64 `json:"coinsPerUsd"`
|
||
RateSource string `json:"rateSource"`
|
||
MatchedUserID string `json:"matchedUserId,omitempty"`
|
||
MinUSDAmount string `json:"minUsdAmount,omitempty"`
|
||
MaxUSDAmount string `json:"maxUsdAmount,omitempty"`
|
||
}
|
||
|
||
type coinSellerRechargeExchangeRatePolicy struct {
|
||
Tiers []coinSellerRechargeExchangeRateTier `json:"tiers"`
|
||
Whitelist []coinSellerRechargeExchangeRateWhitelist `json:"whitelist"`
|
||
}
|
||
|
||
type coinSellerRechargeExchangeRateTier struct {
|
||
MinUSDMinor int64 `json:"minUsdMinor"`
|
||
MaxUSDMinor int64 `json:"maxUsdMinor"`
|
||
CoinsPerUSD int64 `json:"coinsPerUsd"`
|
||
}
|
||
|
||
type coinSellerRechargeExchangeRateWhitelist struct {
|
||
UserID string `json:"userId"`
|
||
CoinsPerUSD int64 `json:"coinsPerUsd"`
|
||
}
|
||
|
||
func (s *Service) QuoteOrder(ctx context.Context, actor shared.Actor, req coinSellerRechargeQuoteRequest, requestID string) (*coinSellerRechargeQuoteDTO, error) {
|
||
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeCreate) {
|
||
return nil, errCoinSellerRechargeForbidden
|
||
}
|
||
appCode := appctx.Normalize(req.AppCode)
|
||
targetUserID := strings.TrimSpace(req.TargetUserID)
|
||
usdAmount, usdMinorAmount, err := normalizeUSD(req.USDAmount)
|
||
if err != nil || appCode == "" || targetUserID == "" {
|
||
return nil, errInvalidCoinSellerRechargeOrderInput
|
||
}
|
||
// 报价先解析真实币商,保证白名单同时支持运营输入的展示 ID 和账务内部 user_id,且不会给非币商生成可提交结果。
|
||
target, err := s.resolveCoinSellerTarget(ctx, requestID, appCode, targetUserID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
quote, err := s.quoteResolvedTarget(appCode, targetUserID, target, usdMinorAmount)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
quote.USDAmount = usdAmount
|
||
return "e, nil
|
||
}
|
||
|
||
func (s *Service) createOrderCoinAmount(input normalizedCreateOrderInput, target coinSellerTarget) (int64, error) {
|
||
if input.IsMakeup {
|
||
// 补单仍绑定并校验真实充值凭证、记录 USD 充值统计,但明确不读取汇率、不产生金币发放;GrantOrder 会走既有 0 金币完成分支。
|
||
return 0, nil
|
||
}
|
||
quote, err := s.quoteResolvedTarget(input.AppCode, input.TargetUserID, target, input.USDMinorAmount)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return quote.CoinAmount, nil
|
||
}
|
||
|
||
func (s *Service) GetExchangeRatePolicy(appCode string) (*coinSellerRechargeExchangeRatePolicyDTO, error) {
|
||
appCode = appctx.Normalize(appCode)
|
||
if appCode == "" {
|
||
return nil, errInvalidCoinSellerRechargeOrderInput
|
||
}
|
||
policy, updatedAtMS, err := s.loadExchangeRatePolicy(appCode)
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return exchangeRatePolicyDTO(appCode, coinSellerRechargeExchangeRatePolicy{}, 0), nil
|
||
}
|
||
return nil, err
|
||
}
|
||
return exchangeRatePolicyDTO(appCode, policy, updatedAtMS), nil
|
||
}
|
||
|
||
func (s *Service) ReplaceExchangeRatePolicy(actor shared.Actor, appCode string, req coinSellerRechargeExchangeRatePolicyRequest) (*coinSellerRechargeExchangeRatePolicyDTO, error) {
|
||
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerExchangeRate) {
|
||
return nil, errCoinSellerRechargeForbidden
|
||
}
|
||
appCode = appctx.Normalize(appCode)
|
||
policy, err := normalizeExchangeRatePolicyRequest(req)
|
||
if err != nil || appCode == "" {
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return nil, errInvalidCoinSellerRechargeOrderInput
|
||
}
|
||
value, err := json.Marshal(policy)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 配置写入 app-scoped 行,禁止一个 APP 的汇率覆盖其他 APP;订单创建会重新读取此行,不信任前端缓存的报价。
|
||
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{{
|
||
Group: coinSellerRechargeExchangeRateGroup,
|
||
Key: coinSellerRechargeExchangeRateKey,
|
||
Value: string(value),
|
||
Description: "币商充值 USD 金币兑换区间与用户白名单",
|
||
}}); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetExchangeRatePolicy(appCode)
|
||
}
|
||
|
||
func (s *Service) quoteResolvedTarget(appCode string, rawTarget string, target coinSellerTarget, usdMinorAmount int64) (coinSellerRechargeQuoteDTO, error) {
|
||
policy, _, err := s.loadExchangeRatePolicy(appCode)
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return coinSellerRechargeQuoteDTO{}, errors.New("当前 APP 尚未配置币商充值金币汇率")
|
||
}
|
||
return coinSellerRechargeQuoteDTO{}, err
|
||
}
|
||
quote, err := calculateCoinSellerRechargeQuote(policy, rawTarget, target, usdMinorAmount)
|
||
if err != nil {
|
||
return coinSellerRechargeQuoteDTO{}, err
|
||
}
|
||
quote.AppCode = appCode
|
||
quote.TargetUserID = formatInt64(target.UserID)
|
||
quote.TargetDisplayUserID = target.DisplayUserID
|
||
return quote, nil
|
||
}
|
||
|
||
func (s *Service) loadExchangeRatePolicy(appCode string) (coinSellerRechargeExchangeRatePolicy, int64, error) {
|
||
if s == nil || s.store == nil {
|
||
return coinSellerRechargeExchangeRatePolicy{}, 0, errors.New("admin store is not configured")
|
||
}
|
||
item, err := s.store.GetScopedAppConfig(appctx.Normalize(appCode), coinSellerRechargeExchangeRateGroup, coinSellerRechargeExchangeRateKey)
|
||
if err != nil {
|
||
return coinSellerRechargeExchangeRatePolicy{}, 0, err
|
||
}
|
||
var policy coinSellerRechargeExchangeRatePolicy
|
||
if err := json.Unmarshal([]byte(item.Value), &policy); err != nil {
|
||
return coinSellerRechargeExchangeRatePolicy{}, 0, errors.New("币商充值汇率配置损坏")
|
||
}
|
||
return policy, item.UpdatedAtMS, nil
|
||
}
|
||
|
||
func normalizeExchangeRatePolicyRequest(req coinSellerRechargeExchangeRatePolicyRequest) (coinSellerRechargeExchangeRatePolicy, error) {
|
||
if len(req.Tiers) == 0 {
|
||
return coinSellerRechargeExchangeRatePolicy{}, errors.New("至少配置一个 USD 金额区间")
|
||
}
|
||
if len(req.Tiers) > maxExchangeRateTiers || len(req.Whitelist) > maxExchangeRateWhitelistEntries {
|
||
return coinSellerRechargeExchangeRatePolicy{}, errors.New("币商充值汇率配置条目过多")
|
||
}
|
||
policy := coinSellerRechargeExchangeRatePolicy{
|
||
Tiers: make([]coinSellerRechargeExchangeRateTier, 0, len(req.Tiers)),
|
||
Whitelist: make([]coinSellerRechargeExchangeRateWhitelist, 0, len(req.Whitelist)),
|
||
}
|
||
for _, item := range req.Tiers {
|
||
_, minMinor, minErr := normalizeUSD(item.MinUSDAmount)
|
||
_, maxMinor, maxErr := normalizeUSD(item.MaxUSDAmount)
|
||
if minErr != nil || maxErr != nil || minMinor <= 0 || maxMinor < minMinor || item.CoinsPerUSD <= 0 {
|
||
return coinSellerRechargeExchangeRatePolicy{}, errors.New("USD 金额区间或金币汇率不正确")
|
||
}
|
||
policy.Tiers = append(policy.Tiers, coinSellerRechargeExchangeRateTier{MinUSDMinor: minMinor, MaxUSDMinor: maxMinor, CoinsPerUSD: item.CoinsPerUSD})
|
||
}
|
||
sort.Slice(policy.Tiers, func(i, j int) bool { return policy.Tiers[i].MinUSDMinor < policy.Tiers[j].MinUSDMinor })
|
||
for index := 1; index < len(policy.Tiers); index++ {
|
||
if policy.Tiers[index].MinUSDMinor <= policy.Tiers[index-1].MaxUSDMinor {
|
||
return coinSellerRechargeExchangeRatePolicy{}, errors.New("USD 金额区间不能重叠")
|
||
}
|
||
}
|
||
seen := make(map[string]struct{}, len(req.Whitelist))
|
||
for _, item := range req.Whitelist {
|
||
userID := strings.TrimSpace(item.UserID)
|
||
if userID == "" || item.CoinsPerUSD <= 0 {
|
||
return coinSellerRechargeExchangeRatePolicy{}, errors.New("白名单用户 ID 或金币汇率不正确")
|
||
}
|
||
if _, exists := seen[userID]; exists {
|
||
return coinSellerRechargeExchangeRatePolicy{}, fmt.Errorf("白名单用户 ID 重复: %s", userID)
|
||
}
|
||
seen[userID] = struct{}{}
|
||
policy.Whitelist = append(policy.Whitelist, coinSellerRechargeExchangeRateWhitelist{UserID: userID, CoinsPerUSD: item.CoinsPerUSD})
|
||
}
|
||
return policy, nil
|
||
}
|
||
|
||
func calculateCoinSellerRechargeQuote(policy coinSellerRechargeExchangeRatePolicy, rawTarget string, target coinSellerTarget, usdMinorAmount int64) (coinSellerRechargeQuoteDTO, error) {
|
||
if usdMinorAmount <= 0 {
|
||
return coinSellerRechargeQuoteDTO{}, errors.New("充值美金数量不正确")
|
||
}
|
||
targetIDs := map[string]struct{}{
|
||
strings.TrimSpace(rawTarget): {},
|
||
strings.TrimSpace(target.DisplayUserID): {},
|
||
formatInt64(target.UserID): {},
|
||
}
|
||
for _, item := range policy.Whitelist {
|
||
if _, ok := targetIDs[item.UserID]; ok {
|
||
return quoteFromRate(usdMinorAmount, item.CoinsPerUSD, "whitelist", item.UserID, 0, 0)
|
||
}
|
||
}
|
||
for _, item := range policy.Tiers {
|
||
if usdMinorAmount >= item.MinUSDMinor && usdMinorAmount <= item.MaxUSDMinor {
|
||
return quoteFromRate(usdMinorAmount, item.CoinsPerUSD, "tier", "", item.MinUSDMinor, item.MaxUSDMinor)
|
||
}
|
||
}
|
||
return coinSellerRechargeQuoteDTO{}, errors.New("USD 金额未命中当前 APP 的金币兑换区间")
|
||
}
|
||
|
||
func quoteFromRate(usdMinorAmount int64, coinsPerUSD int64, source string, matchedUserID string, minMinor int64, maxMinor int64) (coinSellerRechargeQuoteDTO, error) {
|
||
// USD 最多两位小数;金币必须为整数,因此按最接近整数四舍五入,创建订单时会用同一公式重新计算。
|
||
if coinsPerUSD <= 0 || usdMinorAmount > (math.MaxInt64-50)/coinsPerUSD {
|
||
return coinSellerRechargeQuoteDTO{}, errors.New("计算后的充值金币数量过大")
|
||
}
|
||
coinAmount := (usdMinorAmount*coinsPerUSD + 50) / 100
|
||
return coinSellerRechargeQuoteDTO{
|
||
USDAmount: formatUSDMinorAmount(usdMinorAmount),
|
||
CoinAmount: coinAmount,
|
||
CoinsPerUSD: coinsPerUSD,
|
||
RateSource: source,
|
||
MatchedUserID: matchedUserID,
|
||
MinUSDAmount: optionalUSDMinorAmount(minMinor),
|
||
MaxUSDAmount: optionalUSDMinorAmount(maxMinor),
|
||
}, nil
|
||
}
|
||
|
||
func exchangeRatePolicyDTO(appCode string, policy coinSellerRechargeExchangeRatePolicy, updatedAtMS int64) *coinSellerRechargeExchangeRatePolicyDTO {
|
||
dto := &coinSellerRechargeExchangeRatePolicyDTO{
|
||
AppCode: appCode,
|
||
Tiers: make([]coinSellerRechargeExchangeRateTierDTO, 0, len(policy.Tiers)),
|
||
Whitelist: make([]coinSellerRechargeExchangeRateWhitelistDTO, 0, len(policy.Whitelist)),
|
||
UpdatedAtMS: updatedAtMS,
|
||
}
|
||
for _, item := range policy.Tiers {
|
||
dto.Tiers = append(dto.Tiers, coinSellerRechargeExchangeRateTierDTO{MinUSDAmount: formatUSDMinorAmount(item.MinUSDMinor), MaxUSDAmount: formatUSDMinorAmount(item.MaxUSDMinor), CoinsPerUSD: item.CoinsPerUSD})
|
||
}
|
||
for _, item := range policy.Whitelist {
|
||
dto.Whitelist = append(dto.Whitelist, coinSellerRechargeExchangeRateWhitelistDTO{UserID: item.UserID, CoinsPerUSD: item.CoinsPerUSD})
|
||
}
|
||
return dto
|
||
}
|
||
|
||
func formatUSDMinorAmount(value int64) string {
|
||
return strconv.FormatInt(value/100, 10) + "." + fmt.Sprintf("%02d", value%100)
|
||
}
|
||
|
||
func optionalUSDMinorAmount(value int64) string {
|
||
if value <= 0 {
|
||
return ""
|
||
}
|
||
return formatUSDMinorAmount(value)
|
||
}
|