2026-07-09 18:15:57 +08:00

993 lines
35 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"
"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-serviceAslan/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")
}