package service import ( "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "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" { 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 } if isUsableProviderPayload(transaction.ProviderPayload) { 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 } // Older versions stored raw PNG bytes as UTF-8. The stale payload is // replaced below by requesting a fresh QR code for this transaction. } 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 := encodeProviderPayload(payload) if !isUsableProviderPayload(payloadText) { logger.WARN("AGPay 返回了无法展示的支付二维码", transaction.ID) return nil, common.ErrPaymentOrderConflict } 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 } // encodeProviderPayload converts AGPay's binary QR image into a JSON-safe data // URI. JSON responses remain unchanged for compatibility with other gateways. func encodeProviderPayload(payload []byte) string { if len(payload) == 0 { return "" } if json.Valid(payload) { return string(payload) } contentType := http.DetectContentType(payload) return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(payload) } func isUsableProviderPayload(payload string) bool { if payload == "" || strings.ContainsRune(payload, '\ufffd') { return false } if json.Valid([]byte(payload)) { return true } if !strings.HasPrefix(payload, "data:image/") { return false } comma := strings.IndexByte(payload, ',') if comma < 0 || !strings.HasSuffix(payload[:comma], ";base64") { return false } decoded, err := base64.StdEncoding.DecodeString(payload[comma+1:]) return err == nil && len(decoded) > 0 && strings.HasPrefix(http.DetectContentType(decoded), "image/") } 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 } existingResult := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = ? AND business_type = ? AND business_order_id = ? AND provider = ?", userID, businessType, businessOrderID, "agpay"). Limit(1).Find(&transaction) if existingResult.Error != nil { return existingResult.Error } if existingResult.RowsAffected > 0 { return nil } 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 eventResult := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ? AND event_key = ?", "agpay", tradeNo).Limit(1).Find(&event) if eventResult.Error != nil { return eventResult.Error } if eventResult.RowsAffected > 0 { callbackTransaction = transaction if event.PayloadHash != payloadHash { callbackAudit = "conflict" busiErr = common.ErrPaymentOrderConflict return busiErr } callbackAudit = "duplicate" callbackTransaction = transaction return nil } 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 }