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) }