2026-07-12 00:47:20 +08:00

306 lines
12 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 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 &quote, 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)
}