低空智控平台 后端go
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

430 lines
18 KiB

package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"laic-backend/cache"
"laic-backend/client"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type PaymentService struct {
agpay *client.AGPayClient
notifyURL string
}
type trafficCreditAudit struct {
UserID int64
OrderID int64
LedgerID int64
AmountBytes int64
BalanceBefore int64
BalanceAfter int64
}
var DefaultPaymentService = &PaymentService{}
func InitAGPay(conf common.AGPay) error {
if conf.BaseURL == "" && conf.NotifyURL == "" {
return nil
}
instance, err := client.NewAGPayClient(conf.BaseURL)
if err != nil {
return err
}
if conf.NotifyURL == "" {
return errors.New("AGPay notify URL is required")
}
DefaultPaymentService.agpay = instance
DefaultPaymentService.notifyURL = conf.NotifyURL
return nil
}
func (s *PaymentService) CreateWechatPayment(userID int64, req *vo.PaymentCreateReq) (*vo.PaymentCreateVO, *common.BusiError) {
if s.agpay == nil {
return nil, common.ErrPaymentDisabled
}
transaction, created, busiErr := s.getOrCreateTransaction(userID, req.BusinessType, req.BusinessOrderID)
if busiErr != nil {
return nil, busiErr
}
if transaction.Status == "paid" {
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "创建支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d status=paid", transaction.ID, req.BusinessType, req.BusinessOrderID), "failed", "user_api")
return nil, common.ErrPaymentAlreadyPaid
}
if !created {
if transaction.Status == "cancelled" || transaction.Status == "closed" || transaction.Status == "failed" || transaction.ProviderPayload == "" {
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "复用支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d status=%s", transaction.ID, transaction.BusinessType, transaction.BusinessOrderID, transaction.Status), "failed", "user_api")
return nil, common.ErrPaymentOrderConflict
}
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "复用支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d status=%s", transaction.ID, transaction.BusinessType, transaction.BusinessOrderID, transaction.Status), "success", "user_api")
return paymentCreateVO(transaction, transaction.ProviderPayload), nil
}
payload, err := s.agpay.CreateWechatQRCode(client.AGPayCreateRequest{
TotalFeeFen: transaction.TotalFeeFen, MerchantOrder: transaction.MerchantOrderNo, NotifyURL: s.notifyURL,
})
if err != nil {
logger.WARN("创建 AGPay 微信支付请求失败,交易保留待确认", transaction.ID, err)
return nil, common.ErrPaymentOrderConflict
}
payloadText := string(payload)
if err := common.DB.Model(&model.PaymentTransaction{}).Where("id = ? AND status = ?", transaction.ID, "processing").Updates(map[string]any{
"provider_payload": payloadText, "requested_at": time.Now(), "updated_at": time.Now(),
}).Error; err != nil {
logger.ERROR("保存 AGPay 支付响应失败", err)
return nil, common.ErrInternal
}
transaction.ProviderPayload = payloadText
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "创建支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d feeFen=%d", transaction.ID, transaction.BusinessType, transaction.BusinessOrderID, transaction.TotalFeeFen), "success", "user_api")
return paymentCreateVO(transaction, payloadText), nil
}
func (s *PaymentService) GetTransaction(userID, transactionID int64) (*vo.PaymentTransactionVO, *common.BusiError) {
var transaction model.PaymentTransaction
if err := common.DB.Where("id = ? AND user_id = ?", transactionID, userID).First(&transaction).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrPaymentTransactionNotFound
}
return nil, common.ErrInternal
}
return paymentTransactionVO(&transaction), nil
}
func (s *PaymentService) markPaymentRequestFailed(transactionID int64, businessType string, businessOrderID int64) {
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.PaymentTransaction{}).Where("id = ? AND status = ?", transactionID, "processing").Updates(map[string]any{"status": "failed", "updated_at": time.Now()}).Error; err != nil {
return err
}
switch businessType {
case "traffic_order":
return tx.Model(&model.TrafficOrder{}).Where("id = ? AND pay_status = ?", businessOrderID, "processing").Updates(map[string]any{"pay_status": "unpaid", "payment_provider": "", "updated_at": time.Now()}).Error
case "sim_recharge_order":
return tx.Model(&model.SimRechargeOrder{}).Where("id = ? AND payment_status = ?", businessOrderID, "processing").Updates(map[string]any{"payment_status": "unpaid", "updated_at": time.Now()}).Error
default:
return common.ErrParam
}
}); err != nil {
logger.ERROR("回滚支付请求状态失败", err)
}
}
func (s *PaymentService) getOrCreateTransaction(userID int64, businessType string, businessOrderID int64) (*model.PaymentTransaction, bool, *common.BusiError) {
var transaction model.PaymentTransaction
var created bool
var busiErr *common.BusiError
err := common.DB.Transaction(func(tx *gorm.DB) error {
amount, status, err := paymentBusinessOrder(tx, userID, businessType, businessOrderID)
if err != nil {
return err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND business_type = ? AND business_order_id = ? AND provider = ?", userID, businessType, businessOrderID, "agpay").
First(&transaction).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if amount <= 0 {
busiErr = common.ErrParam
return busiErr
}
if status == "paid" {
busiErr = common.ErrPaymentAlreadyPaid
return busiErr
}
if status != "unpaid" && status != "processing" {
busiErr = common.ErrPaymentOrderConflict
return busiErr
}
id, err := tool.NextID()
if err != nil {
return err
}
now := time.Now()
transaction = model.PaymentTransaction{
ID: id, UserID: userID, BusinessType: businessType, BusinessOrderID: businessOrderID,
Provider: "agpay", MerchantOrderNo: "agpay-" + strconv.FormatInt(id, 10),
TotalFeeFen: amount, Status: "processing", CreatedAt: now, UpdatedAt: now,
}
if err := tx.Create(&transaction).Error; err != nil {
return err
}
created = true
return updatePaymentBusinessStatus(tx, businessType, businessOrderID, "processing")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, common.ErrOrderNotFound
}
if busiErr != nil {
return nil, false, busiErr
}
logger.ERROR("创建支付交易失败", err)
return nil, false, common.ErrInternal
}
return &transaction, created, nil
}
func paymentBusinessOrder(tx *gorm.DB, userID int64, businessType string, businessOrderID int64) (int64, string, error) {
switch businessType {
case "traffic_order":
var order model.TrafficOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", businessOrderID, userID).First(&order).Error; err != nil {
return 0, "", err
}
return order.TotalFeeFen, order.PayStatus, nil
case "sim_recharge_order":
var order model.SimRechargeOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", businessOrderID, userID).First(&order).Error; err != nil {
return 0, "", err
}
return order.TotalFeeFen, order.PaymentStatus, nil
default:
return 0, "", common.ErrParam
}
}
func updatePaymentBusinessStatus(tx *gorm.DB, businessType string, businessOrderID int64, status string) error {
var result *gorm.DB
switch businessType {
case "traffic_order":
result = tx.Model(&model.TrafficOrder{}).Where("id = ? AND pay_status = ?", businessOrderID, "unpaid").Updates(map[string]any{"pay_status": status, "payment_provider": "agpay", "updated_at": time.Now()})
case "sim_recharge_order":
result = tx.Model(&model.SimRechargeOrder{}).Where("id = ? AND payment_status = ?", businessOrderID, "unpaid").Updates(map[string]any{"payment_status": status, "updated_at": time.Now()})
default:
return common.ErrParam
}
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return common.ErrPaymentOrderConflict
}
return nil
}
func paymentCreateVO(transaction *model.PaymentTransaction, payload string) *vo.PaymentCreateVO {
return &vo.PaymentCreateVO{
TransactionID: transaction.ID, BusinessType: transaction.BusinessType, BusinessOrderID: transaction.BusinessOrderID,
Provider: transaction.Provider, MerchantOrderNo: transaction.MerchantOrderNo, TotalFeeFen: transaction.TotalFeeFen,
Status: transaction.Status, ProviderPayload: payload, CallbackEnabled: false,
}
}
func paymentTransactionVO(transaction *model.PaymentTransaction) *vo.PaymentTransactionVO {
return &vo.PaymentTransactionVO{
ID: transaction.ID, BusinessType: transaction.BusinessType, BusinessOrderID: transaction.BusinessOrderID,
Provider: transaction.Provider, MerchantOrderNo: transaction.MerchantOrderNo, TotalFeeFen: transaction.TotalFeeFen,
Status: transaction.Status, CreatedAt: transaction.CreatedAt, PaidAt: transaction.PaidAt,
}
}
func (s *PaymentService) ConfirmAGPayCallback(tradeNo string, payload []byte) *common.BusiError {
if tradeNo == "" || len(tradeNo) > 128 {
return common.ErrParam
}
now := time.Now()
sum := sha256.Sum256(payload)
payloadHash := hex.EncodeToString(sum[:])
creditedUserID := int64(0)
var trafficCredit *trafficCreditAudit
var simOrderToSubmit *model.SimRechargeOrder
callbackAudit := ""
var callbackTransaction model.PaymentTransaction
var busiErr *common.BusiError
err := common.DB.Transaction(func(tx *gorm.DB) error {
var transaction model.PaymentTransaction
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ? AND merchant_order_no = ?", "agpay", tradeNo).First(&transaction).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
busiErr = common.ErrPaymentTransactionNotFound
return busiErr
}
return err
}
var event model.PaymentCallbackEvent
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ? AND event_key = ?", "agpay", tradeNo).First(&event).Error
if err == nil {
callbackTransaction = transaction
if event.PayloadHash != payloadHash {
callbackAudit = "conflict"
busiErr = common.ErrPaymentOrderConflict
return busiErr
}
callbackAudit = "duplicate"
callbackTransaction = transaction
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
id, err := tool.NextID()
if err != nil {
return err
}
event = model.PaymentCallbackEvent{ID: id, Provider: "agpay", EventKey: tradeNo, PayloadHash: payloadHash, Verified: true, CreatedAt: now}
if err := tx.Create(&event).Error; err != nil {
return err
}
callbackTransaction = transaction
if transaction.Status == "paid" {
callbackAudit = "duplicate"
return tx.Model(&event).Updates(map[string]any{"processed_at": now, "result_code": "duplicate"}).Error
}
if transaction.Status == "cancelled" || transaction.Status == "closed" {
callbackAudit = transaction.Status
return tx.Model(&event).Updates(map[string]any{"processed_at": now, "result_code": transaction.Status}).Error
}
if transaction.Status != "processing" {
callbackAudit = "conflict"
busiErr = common.ErrPaymentOrderConflict
return busiErr
}
result := tx.Model(&transaction).Where("id = ? AND status = ?", transaction.ID, "processing").Updates(map[string]any{
"status": "paid", "paid_at": now, "updated_at": now,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
callbackAudit = "conflict"
busiErr = common.ErrPaymentOrderConflict
return busiErr
}
switch transaction.BusinessType {
case "traffic_order":
trafficCredit, err = creditPaidTrafficOrder(tx, transaction.BusinessOrderID, transaction.UserID, now)
if err != nil {
return err
}
creditedUserID = trafficCredit.UserID
case "sim_recharge_order":
var order model.SimRechargeOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", transaction.BusinessOrderID, transaction.UserID).First(&order).Error; err != nil {
return err
}
result := tx.Model(&model.SimRechargeOrder{}).Where("id = ? AND user_id = ? AND payment_status = ? AND fulfillment_status = ?", order.ID, transaction.UserID, "processing", "pending").Updates(map[string]any{
"payment_status": "paid", "paid_at": now, "fulfillment_status": "submitting", "attempt_count": 1,
"fulfillment_started_at": now, "fulfillment_message": "续费提交处理中;如状态未更新,请联系管理员。",
"fulfillment_lease_token": nil, "fulfillment_lease_until": nil, "next_attempt_at": nil,
"last_error_code": "", "last_error_message": "", "updated_at": now,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
callbackAudit = "conflict"
busiErr = common.ErrPaymentOrderConflict
return busiErr
}
order.PaymentStatus = "paid"
order.FulfillmentStatus = "submitting"
order.AttemptCount = 1
order.PaidAt = &now
order.FulfillmentStartedAt = &now
order.FulfillmentMessage = "续费提交处理中;如状态未更新,请联系管理员。"
simOrderToSubmit = &order
default:
callbackAudit = "conflict"
busiErr = common.ErrPaymentOrderConflict
return busiErr
}
callbackAudit = "paid"
return tx.Model(&event).Updates(map[string]any{"processed_at": now, "result_code": "paid"}).Error
})
if err != nil {
if callbackAudit == "conflict" && callbackTransaction.ID != 0 {
DefaultOperationLogService.RecordEvent(callbackTransaction.UserID, "充值与履约", "AGPay回调冲突", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d", callbackTransaction.ID, callbackTransaction.BusinessType, callbackTransaction.BusinessOrderID), "failed", "agpay_callback")
}
if busiErr != nil {
return busiErr
}
logger.ERROR("确认 AGPay 支付回调失败", err)
return common.ErrInternal
}
if callbackAudit != "" {
action := "确认AGPay支付回调"
if callbackAudit == "duplicate" {
action = "忽略重复AGPay回调"
} else if callbackAudit == "cancelled" || callbackAudit == "closed" {
action = "忽略迟到AGPay回调"
}
DefaultOperationLogService.RecordEvent(callbackTransaction.UserID, "充值与履约", action, fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d result=%s", callbackTransaction.ID, callbackTransaction.BusinessType, callbackTransaction.BusinessOrderID, callbackAudit), "success", "agpay_callback")
}
if trafficCredit != nil {
DefaultOperationLogService.RecordEvent(trafficCredit.UserID, "充值与履约", "流量充值入账", fmt.Sprintf("orderId=%d ledgerId=%d amountBytes=%d balanceBefore=%d balanceAfter=%d", trafficCredit.OrderID, trafficCredit.LedgerID, trafficCredit.AmountBytes, trafficCredit.BalanceBefore, trafficCredit.BalanceAfter), "success", "agpay_callback")
}
if creditedUserID != 0 {
if err := common.Delete(cache.TrafficKeyOf(creditedUserID)); err != nil {
logger.WARN("删除支付入账余额缓存失败", err)
}
}
if simOrderToSubmit != nil {
DefaultOperationLogService.RecordEvent(simOrderToSubmit.UserID, "充值与履约", "启动SIM续费履约", fmt.Sprintf("orderId=%d attempt=1", simOrderToSubmit.ID), "success", "agpay_callback")
DefaultSimRechargeService.SubmitPaidOrderOnce(simOrderToSubmit)
}
return nil
}
func creditPaidTrafficOrder(tx *gorm.DB, orderID, expectedUserID int64, now time.Time) (*trafficCreditAudit, error) {
var order model.TrafficOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", orderID, expectedUserID).First(&order).Error; err != nil {
return nil, err
}
if order.PayStatus == "paid" && order.CreditStatus == "credited" {
return nil, nil
}
if order.PayStatus != "processing" || order.CreditStatus != "pending" || order.AmountBytes <= 0 {
return nil, common.ErrPaymentOrderConflict
}
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id", "traffic_balance").First(&user, order.UserID).Error; err != nil {
return nil, err
}
ledgerID := mustID()
if ledgerID == 0 {
return nil, errors.New("generate traffic credit ledger ID")
}
after := user.TrafficBalance + order.AmountBytes
if err := tx.Model(&user).UpdateColumn("traffic_balance", after).Error; err != nil {
return nil, err
}
idempotencyKey := "payment:" + strconv.FormatInt(order.ID, 10)
if err := tx.Create(&model.TrafficLedger{
ID: ledgerID, AccountType: "user", AccountID: order.UserID, Direction: "credit", AmountBytes: order.AmountBytes,
BalanceBefore: user.TrafficBalance, BalanceAfter: after, SourceType: "traffic_order", SourceID: strconv.FormatInt(order.ID, 10),
IdempotencyKey: idempotencyKey, CreatedAt: now,
}).Error; err != nil {
return nil, err
}
result := tx.Model(&model.TrafficOrder{}).Where("id = ? AND user_id = ? AND pay_status = ? AND credit_status = ?", order.ID, expectedUserID, "processing", "pending").Updates(map[string]any{
"pay_status": "paid", "payment_provider": "agpay",
"credit_status": "credited", "credit_ledger_id": ledgerID, "payment_idempotency_key": idempotencyKey,
"paid_at": now, "updated_at": now,
})
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected != 1 {
return nil, common.ErrPaymentOrderConflict
}
return &trafficCreditAudit{UserID: order.UserID, OrderID: order.ID, LedgerID: ledgerID, AmountBytes: order.AmountBytes, BalanceBefore: user.TrafficBalance, BalanceAfter: after}, nil
}