911 lines
32 KiB
Go
911 lines
32 KiB
Go
package financeorder
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"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
|
||
legacyWriters map[string]LegacyCoinSellerRechargeWriter
|
||
}
|
||
|
||
type Option func(*Service)
|
||
|
||
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
|
||
}
|
||
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,
|
||
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
|
||
}
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func (input normalizedCreateOrderInput) receiptVerificationInput() normalizedReceiptVerificationInput {
|
||
return normalizedReceiptVerificationInput{
|
||
AppCode: input.AppCode,
|
||
USDMinorAmount: input.USDMinorAmount,
|
||
ProviderCode: input.ProviderCode,
|
||
Chain: input.Chain,
|
||
ExternalOrderNo: input.ExternalOrderNo,
|
||
}
|
||
}
|
||
|
||
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 充值必须选择链")
|
||
}
|
||
}
|
||
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 providerCode == "usdt" {
|
||
if chain == "" {
|
||
return normalizedReceiptVerificationInput{}, errors.New("USDT 充值必须选择链")
|
||
}
|
||
if usdMinorAmount <= 0 {
|
||
return normalizedReceiptVerificationInput{}, errors.New("USDT 校验必须填写 USD 金额")
|
||
}
|
||
}
|
||
return normalizedReceiptVerificationInput{
|
||
AppCode: appCode,
|
||
USDMinorAmount: usdMinorAmount,
|
||
ProviderCode: providerCode,
|
||
Chain: chain,
|
||
ExternalOrderNo: externalOrderNo,
|
||
}, 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,
|
||
})
|
||
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 金额只作为 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 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")
|
||
}
|