package financeorder import ( "context" "encoding/json" "errors" "fmt" "log/slog" "math" "strconv" "strings" "time" "unicode/utf8" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/repository" walletv1 "hyapp.local/api/proto/wallet/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) const ( permissionCoinSellerRechargeView = "finance-order:coin-seller-recharge:view" permissionCoinSellerRechargeCreate = "finance-order:coin-seller-recharge:create" permissionCoinSellerRechargeVerify = "finance-order:coin-seller-recharge:verify" permissionCoinSellerRechargeGrant = "finance-order:coin-seller-recharge:grant" coinSellerRechargeAssetType = "COIN_SELLER_COIN" coinSellerRechargeStockType = "usdt_purchase" financeOrderInternalUserIDMinDigits = 15 ) var ( errInvalidCoinSellerRechargeOrderInput = errors.New("币商充值订单参数不正确") errCoinSellerRechargeForbidden = errors.New("没有操作权限") ) type Service struct { store *repository.Store wallet walletclient.Client user userclient.Client now func() time.Time statsRecorder financeCoinSellerRechargeStatsRecorder legacyWriters map[string]LegacyCoinSellerRechargeWriter } type Option func(*Service) type financeCoinSellerRechargeStatsRecorder interface { RecordFinanceCoinSellerRechargeOrder(ctx context.Context, order model.CoinSellerRechargeOrder) error } func WithFinanceCoinSellerRechargeStatsRecorder(recorder financeCoinSellerRechargeStatsRecorder) Option { return func(service *Service) { service.statsRecorder = recorder } } func WithLegacyCoinSellerRechargeWriters(writers ...LegacyCoinSellerRechargeWriter) Option { return func(service *Service) { if service.legacyWriters == nil { service.legacyWriters = map[string]LegacyCoinSellerRechargeWriter{} } for _, writer := range writers { if writer == nil { continue } appCode := appctx.Normalize(writer.AppCode()) if appCode != "" { service.legacyWriters[appCode] = writer } } } } func NewService(store *repository.Store, wallet walletclient.Client, user userclient.Client, options ...Option) *Service { service := &Service{ store: store, wallet: wallet, user: user, now: func() time.Time { return time.Now().UTC() }, legacyWriters: map[string]LegacyCoinSellerRechargeWriter{}, } for _, option := range options { if option != nil { option(service) } } return service } func (s *Service) CreateOrder(ctx context.Context, actor shared.Actor, req createCoinSellerRechargeOrderRequest, requestID string) (*coinSellerRechargeOrderDTO, error) { if s == nil || s.store == nil { return nil, errors.New("admin store is not configured") } if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeCreate) { return nil, errCoinSellerRechargeForbidden } input, err := normalizeCreateOrderRequest(req) if err != nil { return nil, err } // 前端“校验通过”只能改善操作体验,不能作为资金事实;创建时必须复用同一套服务端校验,避免篡改请求直接落库。 receipt, err := s.verifyReceiptInput(ctx, input.receiptVerificationInput(), strings.TrimSpace(requestID)) if err != nil { return nil, err } if !receipt.Verified { return nil, errors.New(firstNonEmpty(receipt.FailureReason, "充值凭证未支付成功")) } if err := verifyReceiptMatchesInput(input, receipt); err != nil { return nil, err } // 钱包入账对象必须在订单占用前确认身份;Lalu 查 user-service,Aslan/Yumi 走 legacy writer,避免跨账套写错目标。 target, err := s.resolveCoinSellerTarget(ctx, requestID, input.AppCode, input.TargetUserID) if err != nil { return nil, err } nowMS := s.now().UnixMilli() verifiedAtMS := receipt.VerifiedAtMS if verifiedAtMS <= 0 { verifiedAtMS = nowMS } order := &model.CoinSellerRechargeOrder{ AppCode: input.AppCode, TargetUserID: target.UserID, TargetDisplayUserID: firstNonEmpty(target.DisplayUserID, formatInt64(target.UserID)), TargetCountryID: target.CountryID, TargetRegionID: target.RegionID, USDAmount: input.USDAmount, USDMinorAmount: input.USDMinorAmount, CoinAmount: input.CoinAmount, ProviderCode: input.ProviderCode, Chain: input.Chain, ExternalOrderNo: input.ExternalOrderNo, ProviderOrderID: receipt.ProviderOrderID, ProviderStatus: receipt.Status, ProviderCountryCode: input.ProviderCountryCode, ProviderCurrencyCode: firstNonEmpty(receipt.CurrencyCode, input.ProviderCurrencyCode), ProviderAmountMinor: receipt.ProviderAmountMinor, ProviderPayType: input.ProviderPayType, ReceiveAddress: receipt.ReceiveAddress, OrderDateMS: input.OrderDateMS, // 新建订单已经通过外部凭证校验,因此进入待发放状态;历史 pending 订单仍可走列表行校验按钮重试。 Status: model.CoinSellerRechargeOrderStatusVerified, VerifyStatus: model.CoinSellerRechargeVerifyStatusVerified, GrantStatus: model.CoinSellerRechargeGrantStatusPending, Remark: input.Remark, ProviderPayloadJSON: receipt.RawJSON, OperatorUserID: actor.UserID, OperatorName: actor.Username, VerifiedByUserID: &actor.UserID, VerifiedByName: actor.Username, VerifiedAtMS: &verifiedAtMS, CreatedAtMS: nowMS, UpdatedAtMS: nowMS, } // admin_coin_seller_recharge_orders 是所有 APP 的订单/币商绑定台账;即使 Aslan/Yumi 最终写 legacy 钱包,也必须先在这里占用外部订单号。 if err := s.store.CreateCoinSellerRechargeOrder(order); err != nil { if isDuplicateKeyError(err) { return nil, errors.New("订单号已绑定,不能重复创建") } return nil, err } item, err := s.store.GetCoinSellerRechargeOrder(order.ID) if err != nil { return nil, err } s.recordVerifiedCoinSellerRechargeOrder(ctx, *item) dto := coinSellerRechargeOrderDTOFromModel(*item) return &dto, nil } func (s *Service) VerifyReceipt(ctx context.Context, actor shared.Actor, req verifyCoinSellerRechargeReceiptRequest, requestID string) (*coinSellerRechargeReceiptVerificationDTO, error) { if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeVerify) { return nil, errCoinSellerRechargeForbidden } // 该接口只返回三方/链上凭证快照,不创建订单、不占用外部订单号;真正绑定发生在 CreateOrder 的事务路径。 input, err := normalizeReceiptVerificationRequest(req) if err != nil { return nil, err } receipt, err := s.verifyReceiptInput(ctx, input, strings.TrimSpace(requestID)) if err != nil { return nil, err } return &receipt, nil } func (s *Service) ListOrders(options repository.CoinSellerRechargeOrderListOptions) ([]coinSellerRechargeOrderDTO, int64, error) { if s == nil || s.store == nil { return nil, 0, errors.New("admin store is not configured") } if appCode := strings.TrimSpace(options.AppCode); appCode != "" && appCode != "all" { options.AppCode = appctx.Normalize(appCode) } else { options.AppCode = "" } items, total, err := s.store.ListCoinSellerRechargeOrders(options) if err != nil { return nil, 0, err } return coinSellerRechargeOrderDTOsFromModel(items), total, nil } func (s *Service) GetOrder(id uint) (*coinSellerRechargeOrderDTO, error) { if s == nil || s.store == nil { return nil, errors.New("admin store is not configured") } item, err := s.store.GetCoinSellerRechargeOrder(id) if err != nil { return nil, err } dto := coinSellerRechargeOrderDTOFromModel(*item) return &dto, nil } func (s *Service) VerifyOrder(ctx context.Context, actor shared.Actor, id uint, requestID string) (*coinSellerRechargeOrderDTO, error) { if s == nil || s.store == nil { return nil, errors.New("admin store is not configured") } if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeVerify) { return nil, errCoinSellerRechargeForbidden } if s.wallet == nil { return nil, errors.New("finance order wallet verifier is not configured") } order, err := s.store.GetCoinSellerRechargeOrder(id) if err != nil { return nil, err } if order.GrantStatus == model.CoinSellerRechargeGrantStatusGranted { return nil, repository.ErrCoinSellerRechargeOrderGranted } resp, err := s.wallet.VerifyCoinSellerRechargeReceipt(appctx.WithContext(ctx, order.AppCode), &walletv1.VerifyCoinSellerRechargeReceiptRequest{ RequestId: strings.TrimSpace(requestID), AppCode: order.AppCode, ProviderCode: receiptProviderCode(*order), ExternalOrderNo: order.ExternalOrderNo, Chain: receiptChain(*order), UsdMinorAmount: order.USDMinorAmount, OrderDateMs: order.OrderDateMS, ProviderCountryCode: order.ProviderCountryCode, ProviderCurrencyCode: order.ProviderCurrencyCode, ProviderAmountMinor: order.ProviderAmountMinor, ProviderPayType: order.ProviderPayType, }) if err != nil { return nil, err } if resp == nil { return nil, errors.New("充值凭证校验返回为空") } failureReason := strings.TrimSpace(resp.GetFailureReason()) if resp.GetVerified() { if matchErr := verifyReceiptMatchesOrder(*order, resp); matchErr != nil { failureReason = matchErr.Error() } } verifyInput := coinSellerRechargeVerifyInput(actor, resp, failureReason, s.now().UnixMilli()) var updated *model.CoinSellerRechargeOrder if resp.GetVerified() && failureReason == "" { updated, err = s.store.MarkCoinSellerRechargeOrderVerified(id, verifyInput) } else { if failureReason == "" { failureReason = "充值凭证未通过校验" verifyInput.FailureReason = failureReason } updated, err = s.store.MarkCoinSellerRechargeOrderVerifyFailed(id, verifyInput) } if err != nil { return nil, err } if updated != nil && updated.VerifyStatus == model.CoinSellerRechargeVerifyStatusVerified { s.recordVerifiedCoinSellerRechargeOrder(ctx, *updated) } dto := coinSellerRechargeOrderDTOFromModel(*updated) return &dto, nil } func (s *Service) GrantOrder(ctx context.Context, actor shared.Actor, id uint, requestID string) (*coinSellerRechargeOrderDTO, error) { if s == nil || s.store == nil { return nil, errors.New("admin store is not configured") } if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeGrant) { return nil, errCoinSellerRechargeForbidden } nowMS := s.now().UnixMilli() claimed, err := s.store.ClaimCoinSellerRechargeOrderGrant(id, actor.UserID, actor.Username, nowMS) if err != nil { return nil, err } if claimed.GrantStatus == model.CoinSellerRechargeGrantStatusGranted { dto := coinSellerRechargeOrderDTOFromModel(*claimed) return &dto, nil } execution, err := s.executeGrant(ctx, actor, *claimed, requestID) if err != nil { failed, markErr := s.store.MarkCoinSellerRechargeOrderGrantFailed(id, repository.CoinSellerRechargeOrderGrantInput{ GrantedByUserID: actor.UserID, GrantedByName: actor.Username, GrantedAtMS: s.now().UnixMilli(), FailureReason: err.Error(), }) if markErr != nil { return nil, markErr } dto := coinSellerRechargeOrderDTOFromModel(*failed) return &dto, err } updated, err := s.store.MarkCoinSellerRechargeOrderGranted(id, repository.CoinSellerRechargeOrderGrantInput{ GrantedByUserID: actor.UserID, GrantedByName: actor.Username, GrantedAtMS: s.now().UnixMilli(), WalletCommandID: execution.CommandID, WalletTransactionID: execution.TransactionID, WalletAssetType: execution.AssetType, WalletAmountDelta: execution.AmountDelta, WalletBalanceAfter: execution.BalanceAfter, }) if err != nil { return nil, err } dto := coinSellerRechargeOrderDTOFromModel(*updated) return &dto, nil } func (s *Service) recordVerifiedCoinSellerRechargeOrder(ctx context.Context, order model.CoinSellerRechargeOrder) { if s == nil || s.statsRecorder == nil || order.ID == 0 || order.VerifyStatus != model.CoinSellerRechargeVerifyStatusVerified { return } // 统计投影是幂等的派生读模型;创建/校验主链路不能因为 statistics-service 短暂不可用而回滚已校验财务工单。 if err := s.statsRecorder.RecordFinanceCoinSellerRechargeOrder(ctx, order); err != nil { slog.WarnContext(ctx, "finance_coin_seller_recharge_stats_record_failed", "order_id", order.ID, "app_code", order.AppCode, "error", err) } } type normalizedCreateOrderInput struct { AppCode string TargetUserID string USDAmount string USDMinorAmount int64 CoinAmount int64 ProviderCode string Chain string ExternalOrderNo string ProviderCountryCode string ProviderCurrencyCode string ProviderAmountMinor int64 ProviderPayType string OrderDateMS int64 Remark string } type normalizedReceiptVerificationInput struct { AppCode string USDMinorAmount int64 ProviderCode string Chain string ExternalOrderNo string ProviderCountryCode string ProviderCurrencyCode string ProviderAmountMinor int64 ProviderPayType string OrderDateMS int64 } func (input normalizedCreateOrderInput) receiptVerificationInput() normalizedReceiptVerificationInput { return normalizedReceiptVerificationInput{ AppCode: input.AppCode, USDMinorAmount: input.USDMinorAmount, ProviderCode: input.ProviderCode, Chain: input.Chain, ExternalOrderNo: input.ExternalOrderNo, ProviderCountryCode: input.ProviderCountryCode, ProviderCurrencyCode: input.ProviderCurrencyCode, ProviderAmountMinor: input.ProviderAmountMinor, ProviderPayType: input.ProviderPayType, OrderDateMS: input.OrderDateMS, } } func normalizeCreateOrderRequest(req createCoinSellerRechargeOrderRequest) (normalizedCreateOrderInput, error) { appCode := appctx.Normalize(req.AppCode) targetUserID := strings.TrimSpace(req.TargetUserID) providerCode, chain, err := normalizeRechargeProvider(req.ProviderCode, req.Chain) if err != nil { return normalizedCreateOrderInput{}, err } usdAmount, usdMinorAmount, err := normalizeUSD(req.USDAmount) if err != nil { return normalizedCreateOrderInput{}, err } externalOrderNo := strings.TrimSpace(req.ExternalOrderNo) if appCode == "" || targetUserID == "" || externalOrderNo == "" || req.CoinAmount < 0 { return normalizedCreateOrderInput{}, errInvalidCoinSellerRechargeOrderInput } if len(externalOrderNo) > 128 { return normalizedCreateOrderInput{}, errors.New("订单号过长") } if req.ProviderAmountMinor < 0 { return normalizedCreateOrderInput{}, errors.New("三方实付金额不正确") } providerCountryCode := strings.ToUpper(strings.TrimSpace(req.ProviderCountryCode)) providerCurrencyCode := strings.ToUpper(strings.TrimSpace(req.ProviderCurrencyCode)) providerPayType := strings.TrimSpace(req.ProviderPayType) remark := strings.TrimSpace(req.Remark) if utf8.RuneCountInString(remark) > 512 { return normalizedCreateOrderInput{}, errors.New("备注不能超过 512 字") } orderDateMS, err := normalizeOrderDateMS(req.OrderDate, req.OrderDateMS) if err != nil { return normalizedCreateOrderInput{}, err } switch providerCode { case "usdt": if chain == "" { return normalizedCreateOrderInput{}, errors.New("USDT 充值必须选择链") } case "mifapay", "v5pay": // 三方实付币种和金额是人工订单绑定的校验条件;缺失时会退化成只按订单号查,容易把错误 APP/币种的流水绑定进币商台账。 if providerCurrencyCode == "" { return normalizedCreateOrderInput{}, errors.New("三方币种不能为空") } if req.ProviderAmountMinor <= 0 { return normalizedCreateOrderInput{}, errors.New("三方实付金额不正确") } } return normalizedCreateOrderInput{ AppCode: appCode, TargetUserID: targetUserID, USDAmount: usdAmount, USDMinorAmount: usdMinorAmount, CoinAmount: req.CoinAmount, ProviderCode: providerCode, Chain: chain, ExternalOrderNo: externalOrderNo, ProviderCountryCode: providerCountryCode, ProviderCurrencyCode: providerCurrencyCode, ProviderAmountMinor: req.ProviderAmountMinor, ProviderPayType: providerPayType, OrderDateMS: orderDateMS, Remark: remark, }, nil } func normalizeReceiptVerificationRequest(req verifyCoinSellerRechargeReceiptRequest) (normalizedReceiptVerificationInput, error) { appCode := appctx.Normalize(req.AppCode) providerCode, chain, err := normalizeRechargeProvider(req.ProviderCode, req.Chain) if err != nil { return normalizedReceiptVerificationInput{}, err } externalOrderNo := strings.TrimSpace(req.ExternalOrderNo) if appCode == "" || externalOrderNo == "" { return normalizedReceiptVerificationInput{}, errInvalidCoinSellerRechargeOrderInput } if len(externalOrderNo) > 128 { return normalizedReceiptVerificationInput{}, errors.New("订单号过长") } usdMinorAmount := req.USDMinorAmount if usdMinorAmount <= 0 && req.USDAmount > 0 { _, cents, err := normalizeUSD(req.USDAmount) if err != nil { return normalizedReceiptVerificationInput{}, err } usdMinorAmount = cents } if usdMinorAmount < 0 { return normalizedReceiptVerificationInput{}, errors.New("充值美金数量不正确") } if req.ProviderAmountMinor < 0 { return normalizedReceiptVerificationInput{}, errors.New("三方实付金额不正确") } providerCountryCode := strings.ToUpper(strings.TrimSpace(req.ProviderCountryCode)) providerCurrencyCode := strings.ToUpper(strings.TrimSpace(req.ProviderCurrencyCode)) providerPayType := strings.TrimSpace(req.ProviderPayType) orderDateMS, err := normalizeOrderDateMS(req.OrderDate, req.OrderDateMS) if err != nil { return normalizedReceiptVerificationInput{}, err } if providerCode == "usdt" { if chain == "" { return normalizedReceiptVerificationInput{}, errors.New("USDT 充值必须选择链") } if usdMinorAmount <= 0 { return normalizedReceiptVerificationInput{}, errors.New("USDT 校验必须填写 USD 金额") } } else if requiresProviderCurrencyAmount(providerCode) { // 校验按钮是创建订单前的资金事实预检;MiFaPay/V5Pay 必须带运营填写的币种和实付金额,才能和 provider 回包做强比对。 if providerCurrencyCode == "" { return normalizedReceiptVerificationInput{}, errors.New("三方币种不能为空") } if req.ProviderAmountMinor <= 0 { return normalizedReceiptVerificationInput{}, errors.New("三方实付金额不正确") } } return normalizedReceiptVerificationInput{ AppCode: appCode, USDMinorAmount: usdMinorAmount, ProviderCode: providerCode, Chain: chain, ExternalOrderNo: externalOrderNo, ProviderCountryCode: providerCountryCode, ProviderCurrencyCode: providerCurrencyCode, ProviderAmountMinor: req.ProviderAmountMinor, ProviderPayType: providerPayType, OrderDateMS: orderDateMS, }, nil } func (s *Service) verifyReceiptInput(ctx context.Context, input normalizedReceiptVerificationInput, requestID string) (coinSellerRechargeReceiptVerificationDTO, error) { if s == nil || s.wallet == nil { return coinSellerRechargeReceiptVerificationDTO{}, errors.New("finance order wallet verifier is not configured") } // admin-server 不直连三方支付和链节点;wallet-service 统一持有 provider 配置、收款地址和查单适配器。 resp, err := s.wallet.VerifyCoinSellerRechargeReceipt(appctx.WithContext(ctx, input.AppCode), &walletv1.VerifyCoinSellerRechargeReceiptRequest{ RequestId: strings.TrimSpace(requestID), AppCode: input.AppCode, ProviderCode: receiptVerificationProviderCode(input), ExternalOrderNo: input.ExternalOrderNo, Chain: receiptVerificationChain(input), UsdMinorAmount: input.USDMinorAmount, OrderDateMs: input.OrderDateMS, ProviderCountryCode: input.ProviderCountryCode, ProviderCurrencyCode: input.ProviderCurrencyCode, ProviderAmountMinor: input.ProviderAmountMinor, ProviderPayType: input.ProviderPayType, }) if err != nil { return coinSellerRechargeReceiptVerificationDTO{}, err } if resp == nil { return coinSellerRechargeReceiptVerificationDTO{}, errors.New("充值凭证校验返回为空") } return receiptVerificationDTOFromProto(resp), nil } func (s *Service) resolveCoinSellerTarget(ctx context.Context, requestID string, appCode string, rawTarget string) (coinSellerTarget, error) { if writer := s.legacyWriters[appCode]; writer != nil { return writer.ResolveCoinSeller(ctx, rawTarget) } if requiresLegacyWriter(appCode) { return coinSellerTarget{}, errors.New("legacy coin seller recharge writer is not configured") } if s.user == nil { return coinSellerTarget{}, errors.New("finance order user resolver is not configured") } ctx = appctx.WithContext(ctx, appCode) user, err := s.resolveWalletUser(ctx, strings.TrimSpace(requestID), rawTarget) if err != nil { return coinSellerTarget{}, err } if user.CountryID <= 0 || user.RegionID <= 0 { return coinSellerTarget{}, errors.New("币商国家或区域不完整") } summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{ RequestID: strings.TrimSpace(requestID), Caller: "admin-server", UserID: user.UserID, }) if err != nil { return coinSellerTarget{}, err } if summary == nil || !summary.IsCoinSeller { return coinSellerTarget{}, errors.New("目标用户不是启用中的币商") } return coinSellerTarget{ UserID: user.UserID, DisplayUserID: firstNonEmpty(user.PrettyDisplayUserID, user.DisplayUserID, user.DefaultDisplayUserID, formatInt64(user.UserID)), CountryID: user.CountryID, RegionID: user.RegionID, }, nil } func (s *Service) resolveWalletUser(ctx context.Context, requestID string, rawTarget string) (*userclient.User, error) { keyword := strings.TrimSpace(rawTarget) if keyword == "" { return nil, errors.New("目标用户ID不正确") } numericID, numericOK := parsePositiveInt64(keyword) userIDChecked := false if numericOK && len(strings.TrimLeft(keyword, "0")) >= financeOrderInternalUserIDMinDigits { userIDChecked = true if user, found, err := s.getWalletUserByID(ctx, requestID, numericID); err != nil || found { return user, err } } if identity, err := s.user.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{ RequestID: requestID, Caller: "admin-server", DisplayUserID: keyword, }); err != nil { if !isGRPCNotFound(err) { return nil, err } } else if identity != nil && identity.UserID > 0 { user, found, err := s.getWalletUserByID(ctx, requestID, identity.UserID) if err != nil { return nil, err } if found { return user, nil } } if numericOK && !userIDChecked { if user, found, err := s.getWalletUserByID(ctx, requestID, numericID); err != nil || found { return user, err } } return nil, errors.New("目标用户不存在") } func (s *Service) getWalletUserByID(ctx context.Context, requestID string, userID int64) (*userclient.User, bool, error) { user, err := s.user.GetUser(ctx, userclient.GetUserRequest{ RequestID: requestID, Caller: "admin-server", UserID: userID, }) if err != nil { if isGRPCNotFound(err) { return nil, false, nil } return nil, false, err } if user == nil || user.UserID <= 0 { return nil, false, nil } return user, true, nil } type grantExecutionResult struct { CommandID string TransactionID string AssetType string AmountDelta int64 BalanceAfter int64 } func (s *Service) executeGrant(ctx context.Context, actor shared.Actor, order model.CoinSellerRechargeOrder, requestID string) (grantExecutionResult, error) { commandID := coinSellerRechargeWalletCommandID(order.ID) if order.CoinAmount == 0 { // 金币数为 0 是补单:订单仍然绑定外部充值凭证并计入充值台账,但不能写 Lalu 钱包或 legacy 进货流水。 return grantExecutionResult{ CommandID: commandID, AssetType: coinSellerRechargeAssetType, AmountDelta: 0, BalanceAfter: 0, }, nil } if writer := s.legacyWriters[order.AppCode]; writer != nil { result, err := writer.GrantCoinSellerRecharge(ctx, legacyRechargeGrantInput{ OrderID: order.ID, UserID: order.TargetUserID, ExternalOrderNo: order.ExternalOrderNo, ProviderCode: order.ProviderCode, Chain: order.Chain, USDAmount: order.USDAmount, CoinAmount: order.CoinAmount, OperatorUserID: actor.UserID, GrantedAt: s.now(), }) if err != nil { return grantExecutionResult{}, err } return grantExecutionResult{ CommandID: commandID, TransactionID: result.TransactionID, AssetType: coinSellerRechargeAssetType, AmountDelta: order.CoinAmount, BalanceAfter: result.BalanceAfter, }, nil } if requiresLegacyWriter(order.AppCode) { return grantExecutionResult{}, errors.New("legacy coin seller recharge writer is not configured") } if s.wallet == nil { return grantExecutionResult{}, errors.New("finance order wallet executor is not configured") } ctx = appctx.WithContext(ctx, order.AppCode) resp, err := s.wallet.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{ CommandId: commandID, SellerUserId: order.TargetUserID, StockType: coinSellerRechargeStockType, CoinAmount: order.CoinAmount, PaidCurrencyCode: "USDT", PaidAmountMicro: order.USDMinorAmount * 10_000, PaymentRef: coinSellerRechargePaymentRef(order), EvidenceRef: coinSellerRechargeEvidenceRef(order.ID), OperatorUserId: int64(actor.UserID), Reason: coinSellerRechargeReason(order), AppCode: order.AppCode, SellerCountryId: order.TargetCountryID, SellerRegionId: order.TargetRegionID, }) if err != nil { return grantExecutionResult{}, err } return grantExecutionResult{ CommandID: commandID, TransactionID: resp.GetTransactionId(), AssetType: coinSellerRechargeAssetType, AmountDelta: resp.GetCoinAmount(), BalanceAfter: resp.GetBalanceAfter(), }, nil } func coinSellerRechargeVerifyInput(actor shared.Actor, resp *walletv1.VerifyCoinSellerRechargeReceiptResponse, failureReason string, nowMS int64) repository.CoinSellerRechargeOrderVerifyInput { verifiedAtMS := resp.GetVerifiedAtMs() if verifiedAtMS <= 0 { verifiedAtMS = nowMS } return repository.CoinSellerRechargeOrderVerifyInput{ VerifiedByUserID: actor.UserID, VerifiedByName: actor.Username, VerifiedAtMS: verifiedAtMS, ProviderOrderID: resp.GetProviderOrderId(), Status: resp.GetStatus(), CurrencyCode: resp.GetCurrencyCode(), ProviderAmountMinor: resp.GetProviderAmountMinor(), ProviderPayloadJSON: receiptPayloadJSON(resp), ReceiveAddress: resp.GetReceiveAddress(), FailureReason: failureReason, } } func verifyReceiptMatchesOrder(order model.CoinSellerRechargeOrder, resp *walletv1.VerifyCoinSellerRechargeReceiptResponse) error { if !strings.EqualFold(resp.GetExternalOrderNo(), order.ExternalOrderNo) { return errors.New("校验返回订单号不匹配") } // USD/USDT 是后台账面金额,必须精确匹配;本地币种三方金额只在旧订单保存过期望值时才参与强校验。 currency := strings.ToUpper(strings.TrimSpace(firstNonEmpty(resp.GetCurrencyCode(), order.ProviderCurrencyCode))) if order.ProviderCurrencyCode != "" && currency != "" && !strings.EqualFold(currency, order.ProviderCurrencyCode) { return errors.New("校验返回币种不匹配") } amount := resp.GetProviderAmountMinor() if amount <= 0 { return errors.New("校验返回金额为空") } if currency == "USD" || currency == "USDT" { if amount != order.USDMinorAmount { return errors.New("校验返回金额与订单美元金额不匹配") } return nil } if order.ProviderAmountMinor <= 0 { return nil } if amount != order.ProviderAmountMinor { return errors.New("校验返回金额与三方实付金额不匹配") } return nil } func verifyReceiptMatchesInput(input normalizedCreateOrderInput, receipt coinSellerRechargeReceiptVerificationDTO) error { if !strings.EqualFold(receipt.ExternalOrderNo, input.ExternalOrderNo) { return errors.New("校验返回订单号不匹配") } // USD/USDT 继续按后台美元金额核验;MiFaPay/V5Pay 的本地币种实付金额由表单强制填写,创建前再次和 provider 回包比对。 currency := strings.ToUpper(strings.TrimSpace(firstNonEmpty(receipt.CurrencyCode, input.ProviderCurrencyCode))) if input.ProviderCurrencyCode != "" && currency != "" && !strings.EqualFold(currency, input.ProviderCurrencyCode) { return errors.New("校验返回币种不匹配") } if receipt.ProviderAmountMinor <= 0 { return errors.New("校验返回金额为空") } if currency == "USD" || currency == "USDT" { if receipt.ProviderAmountMinor != input.USDMinorAmount { return errors.New("校验返回金额与订单美元金额不匹配") } return nil } if input.ProviderAmountMinor > 0 && receipt.ProviderAmountMinor != input.ProviderAmountMinor { return errors.New("校验返回金额与三方实付金额不匹配") } return nil } func receiptPayloadJSON(resp *walletv1.VerifyCoinSellerRechargeReceiptResponse) string { if raw := strings.TrimSpace(resp.GetRawJson()); raw != "" { return raw } body, _ := json.Marshal(map[string]any{ "verified": resp.GetVerified(), "provider_code": resp.GetProviderCode(), "external_order_no": resp.GetExternalOrderNo(), "provider_order_id": resp.GetProviderOrderId(), "status": resp.GetStatus(), "currency_code": resp.GetCurrencyCode(), "provider_amount_minor": resp.GetProviderAmountMinor(), "chain": resp.GetChain(), "receive_address": resp.GetReceiveAddress(), "failure_reason": resp.GetFailureReason(), }) return string(body) } func receiptVerificationDTOFromProto(resp *walletv1.VerifyCoinSellerRechargeReceiptResponse) coinSellerRechargeReceiptVerificationDTO { if resp == nil { return coinSellerRechargeReceiptVerificationDTO{} } return coinSellerRechargeReceiptVerificationDTO{ Verified: resp.GetVerified(), ProviderCode: resp.GetProviderCode(), ExternalOrderNo: resp.GetExternalOrderNo(), ProviderOrderID: resp.GetProviderOrderId(), Status: resp.GetStatus(), CurrencyCode: strings.ToUpper(strings.TrimSpace(resp.GetCurrencyCode())), ProviderAmountMinor: resp.GetProviderAmountMinor(), Chain: resp.GetChain(), ReceiveAddress: resp.GetReceiveAddress(), VerifiedAtMS: resp.GetVerifiedAtMs(), FailureReason: resp.GetFailureReason(), RawJSON: receiptPayloadJSON(resp), } } func normalizeRechargeProvider(providerCode string, chain string) (string, string, error) { provider := strings.ToLower(strings.TrimSpace(providerCode)) provider = strings.ReplaceAll(provider, "-", "_") normalizedChain := normalizeRechargeChain(chain) if strings.TrimSpace(chain) != "" && normalizedChain == "" { return "", "", errors.New("USDT 链不正确") } switch provider { case "mifapy", "mifapay": return "mifapay", "", nil case "v5pay": return "v5pay", "", nil case "usdt": return "usdt", normalizedChain, nil case "usdt_trc20", "trc20", "tron": return "usdt", "TRON", nil case "usdt_bep20", "bep20", "bsc", "bnb": return "usdt", "BSC", nil default: return "", "", errors.New("充值方式不正确") } } func normalizeRechargeChain(chain string) string { chain = strings.ToLower(strings.TrimSpace(chain)) chain = strings.ReplaceAll(chain, "-", "_") switch chain { case "", "none": return "" case "tron", "trx", "trc20", "usdt_trc20": return "TRON" case "bsc", "bnb", "bep20", "usdt_bep20": return "BSC" default: return "" } } func receiptProviderCode(order model.CoinSellerRechargeOrder) string { if order.ProviderCode != "usdt" { return order.ProviderCode } switch strings.ToUpper(strings.TrimSpace(order.Chain)) { case "TRON": return "usdt_trc20" case "BSC": return "usdt_bep20" default: return "usdt" } } func receiptVerificationProviderCode(input normalizedReceiptVerificationInput) string { return receiptProviderCode(model.CoinSellerRechargeOrder{ProviderCode: input.ProviderCode, Chain: input.Chain}) } func requiresProviderCurrencyAmount(providerCode string) bool { switch strings.ToLower(strings.TrimSpace(providerCode)) { case "mifapay", "v5pay": return true default: return false } } func receiptChain(order model.CoinSellerRechargeOrder) string { switch strings.ToUpper(strings.TrimSpace(order.Chain)) { case "TRON": return "trc20" case "BSC": return "bep20" default: return "" } } func receiptVerificationChain(input normalizedReceiptVerificationInput) string { return receiptChain(model.CoinSellerRechargeOrder{ProviderCode: input.ProviderCode, Chain: input.Chain}) } func normalizeUSD(amount float64) (string, int64, error) { if math.IsNaN(amount) || math.IsInf(amount, 0) || amount <= 0 { return "", 0, errors.New("充值美金数量不正确") } cents := int64(math.Round(amount * 100)) if cents <= 0 { return "", 0, errors.New("充值美金数量不正确") } return fmt.Sprintf("%d.%02d", cents/100, cents%100), cents, nil } func normalizeOrderDateMS(orderDate string, orderDateMS int64) (int64, error) { if orderDateMS > 0 { return orderDateMS, nil } text := strings.TrimSpace(orderDate) if text == "" { return 0, nil } for _, layout := range []string{"2006-01-02", "2006/01/02", time.RFC3339} { if parsed, err := time.ParseInLocation(layout, text, time.UTC); err == nil { return parsed.UTC().UnixMilli(), nil } } return 0, errors.New("订单日期不正确") } func requiresLegacyWriter(appCode string) bool { switch appctx.Normalize(appCode) { case "aslan", "yumi": return true default: return false } } func coinSellerRechargeWalletCommandID(orderID uint) string { return fmt.Sprintf("admin-coin-seller-recharge:%d", orderID) } func coinSellerRechargeEvidenceRef(orderID uint) string { return fmt.Sprintf("admin-coin-seller-recharge:%d", orderID) } func coinSellerRechargePaymentRef(order model.CoinSellerRechargeOrder) string { if order.ProviderCode == "usdt" { return "usdt:" + strings.ToLower(strings.TrimSpace(order.Chain)) + ":" + order.ExternalOrderNo } return order.ProviderCode + ":" + order.ExternalOrderNo } func coinSellerRechargeReason(order model.CoinSellerRechargeOrder) string { return fmt.Sprintf("coin seller recharge order %d %s", order.ID, coinSellerRechargePaymentRef(order)) } func parsePositiveInt64(value string) (int64, bool) { parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) return parsed, err == nil && parsed > 0 } func formatInt64(value int64) string { return strconv.FormatInt(value, 10) } func firstNonEmpty(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" } func hasPermission(permissions []string, code string) bool { for _, permission := range permissions { if permission == code { return true } } return false } func isGRPCNotFound(err error) bool { return status.Code(err) == codes.NotFound } func isDuplicateKeyError(err error) bool { text := strings.ToLower(strings.TrimSpace(err.Error())) return strings.Contains(text, "duplicate") || strings.Contains(text, "unique constraint") }