244 lines
8.8 KiB
Go
244 lines
8.8 KiB
Go
package payment
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"hfb_sys/backend/internal/model"
|
|
"hfb_sys/backend/internal/timeutil"
|
|
"log"
|
|
"time"
|
|
)
|
|
|
|
// Start 发起订单支付,并按请求支付方式选择对应默认渠道配置。
|
|
func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) {
|
|
req.PayWay = normalizePayWay(req.PayWay)
|
|
defaultConfig, err := r.defaultRuntimeConfig(ctx, req.PayWay)
|
|
if err != nil {
|
|
return nil, ErrPaymentUnavailable
|
|
}
|
|
payment, orderRow, err := r.preparePayment(ctx, userID, orderID, req, *defaultConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment)
|
|
if err != nil {
|
|
return nil, ErrPaymentUnavailable
|
|
}
|
|
if payment.Status == "paid" {
|
|
r.recordConfigUsage(ctx, runtimeConfig, payment)
|
|
dto := toDTO(*payment)
|
|
return &dto, nil
|
|
}
|
|
if runtimeConfig.isMockMode() {
|
|
if err := r.confirmPaid(ctx, payment, "2", time.Now(), map[string]string{
|
|
"mock": "true",
|
|
"third_order_id": payment.ThirdOrderID,
|
|
"leshua_order_id": payment.ProviderOrderID,
|
|
"status": "2",
|
|
}, channelSourceMock); err != nil {
|
|
return nil, err
|
|
}
|
|
latest, err := r.findPaymentByID(ctx, payment.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.recordConfigUsage(ctx, runtimeConfig, latest)
|
|
dto := toDTO(*latest)
|
|
return &dto, nil
|
|
}
|
|
if payment.Status == "paying" && (payment.TDCode != "" || payment.JSPayURL != "" || payment.JSPayInfo != "") {
|
|
r.recordConfigUsage(ctx, runtimeConfig, payment)
|
|
dto := toDTO(*payment)
|
|
return &dto, nil
|
|
}
|
|
if runtimeConfig.Channel == nil {
|
|
_ = r.markPaymentFailed(ctx, payment.ID, nil, "payment channel unavailable")
|
|
return nil, ErrPaymentUnavailable
|
|
}
|
|
|
|
log.Printf("[payment] payment start order_id=%d order_no=%s payment_id=%d provider=%s amount_cent=%d third_order_id=%s",
|
|
orderID, orderRow.OrderNo, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID)
|
|
resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{
|
|
ThirdOrderID: payment.ThirdOrderID,
|
|
AmountCent: payment.AmountCent,
|
|
PayWay: payment.PayWay,
|
|
JSPayFlag: payment.JSPayFlag,
|
|
NotifyURL: runtimeConfig.NotifyURL,
|
|
JumpURL: runtimeConfig.JumpURL,
|
|
ClientIP: clientIP,
|
|
Body: "租号订单 " + orderRow.OrderNo,
|
|
Attach: orderRow.OrderNo,
|
|
})
|
|
if err != nil {
|
|
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
|
log.Printf("[payment] payment request failed order_id=%d payment_id=%d provider=%s amount_cent=%d err=%v",
|
|
orderID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err)
|
|
return nil, err
|
|
}
|
|
if !resp.OK {
|
|
_ = r.markPaymentFailed(ctx, payment.ID, resp.Raw, resp.ErrorMessage)
|
|
log.Printf("[payment] payment rejected order_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s",
|
|
orderID, payment.ID, runtimeConfig.Provider, payment.AmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage)
|
|
return nil, ErrPaymentUnavailable
|
|
}
|
|
if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
|
|
"status": "paying",
|
|
"provider_order_id": resp.ProviderOrderID,
|
|
"pay_way": firstNonEmpty(resp.PayWay, payment.PayWay),
|
|
"td_code": resp.TDCode,
|
|
"jspay_url": resp.JSPayURL,
|
|
"jspay_info": resp.JSPayInfo,
|
|
"raw_request": jsonMap(resp.RawRequest),
|
|
"raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)),
|
|
}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
latest, err := r.findPaymentByID(ctx, payment.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.recordConfigUsage(ctx, runtimeConfig, latest)
|
|
log.Printf("[payment] payment result order_id=%d order_no=%s payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s",
|
|
orderID, orderRow.OrderNo, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID)
|
|
dto := toDTO(*latest)
|
|
return &dto, nil
|
|
}
|
|
|
|
// preparePayment 创建或复用订单支付单,并写入本次命中的支付配置 ID。
|
|
func (r *Repository) preparePayment(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, *model.RentalOrder, error) {
|
|
var paymentID uint64
|
|
var orderRow model.RentalOrder
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var row model.RentalOrder
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("id = ? AND renter_id = ?", orderID, userID).
|
|
First(&row).Error; err != nil {
|
|
return err
|
|
}
|
|
if row.Status != "pending_payment" {
|
|
return ErrPaymentCannotStart
|
|
}
|
|
amountCent := row.RentAmountCent + row.DepositAmountCent
|
|
if amountCent <= 0 {
|
|
return ErrPaymentCannotStart
|
|
}
|
|
var existing model.PaymentOrder
|
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("order_id = ? AND biz_type = ?", row.ID, "order_pay").
|
|
Order("id DESC").
|
|
First(&existing).Error
|
|
if err == nil {
|
|
if canReuseOrderPayment(existing, runtimeConfig) {
|
|
existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF")
|
|
existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2")
|
|
existing.AmountCent = amountCent
|
|
existing.PaymentConfigID = firstNonZero(existing.PaymentConfigID, runtimeConfig.ID)
|
|
existing.Provider = firstNonEmpty(existing.Provider, runtimeConfig.Provider)
|
|
existing.MerchantID = firstNonEmpty(existing.MerchantID, runtimeConfig.MerchantID)
|
|
if existing.Provider == "mock" && existing.ProviderOrderID == "" {
|
|
existing.ProviderOrderID = "MOCK" + existing.ThirdOrderID
|
|
}
|
|
if err := tx.Save(&existing).Error; err != nil {
|
|
return err
|
|
}
|
|
paymentID = existing.ID
|
|
orderRow = row
|
|
return nil
|
|
}
|
|
} else if err != gorm.ErrRecordNotFound {
|
|
return err
|
|
}
|
|
payment, err := newOrderPayment(row, amountCent, req, runtimeConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(&payment).Error; err != nil {
|
|
return err
|
|
}
|
|
paymentID = payment.ID
|
|
orderRow = row
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
payment, err := r.findPaymentByID(ctx, paymentID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return payment, &orderRow, nil
|
|
}
|
|
|
|
// canReuseOrderPayment 判断旧支付单是否还能复用,支付方式或配置不同则必须重新下单。
|
|
func canReuseOrderPayment(payment model.PaymentOrder, runtimeConfig runtimePaymentConfig) bool {
|
|
if payment.Status == "paid" {
|
|
return true
|
|
}
|
|
if payment.Status != "created" && payment.Status != "paying" {
|
|
return false
|
|
}
|
|
if payment.Provider != "" && runtimeConfig.Provider != "" && payment.Provider != runtimeConfig.Provider {
|
|
return false
|
|
}
|
|
if payment.MerchantID != "" && runtimeConfig.MerchantID != "" && payment.MerchantID != runtimeConfig.MerchantID {
|
|
return false
|
|
}
|
|
if payment.PaymentConfigID != 0 && runtimeConfig.ID != 0 && payment.PaymentConfigID != runtimeConfig.ID {
|
|
return false
|
|
}
|
|
if payment.PayWay != "" && runtimeConfig.PayWay != "" && normalizePayWay(payment.PayWay) != normalizePayWay(runtimeConfig.PayWay) {
|
|
return false
|
|
}
|
|
if payment.Status == "paying" && paymentCashierExpired(payment) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// paymentCashierExpired 判断渠道收银台是否已过期,过期后不再复用支付单。
|
|
func paymentCashierExpired(payment model.PaymentOrder) bool {
|
|
if len(payment.RawRequest) == 0 {
|
|
return false
|
|
}
|
|
var raw map[string]string
|
|
if err := json.Unmarshal(payment.RawRequest, &raw); err != nil {
|
|
return false
|
|
}
|
|
deadline := parseChannelTime(raw["order_efficient_time"])
|
|
if deadline == nil {
|
|
return false
|
|
}
|
|
return !timeutil.ShanghaiNow().Before(*deadline)
|
|
}
|
|
|
|
// newOrderPayment 创建新的订单支付单,并固化支付方式和支付配置 ID。
|
|
func newOrderPayment(row model.RentalOrder, amountCent int64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) {
|
|
paymentNo, err := newPaymentNo()
|
|
if err != nil {
|
|
return model.PaymentOrder{}, err
|
|
}
|
|
payment := model.PaymentOrder{
|
|
PaymentNo: paymentNo,
|
|
OrderID: row.ID,
|
|
OrderNo: row.OrderNo,
|
|
UserID: row.RenterID,
|
|
PaymentConfigID: runtimeConfig.ID,
|
|
Provider: runtimeConfig.Provider,
|
|
MerchantID: runtimeConfig.MerchantID,
|
|
ThirdOrderID: paymentNo,
|
|
ProviderOrderID: "",
|
|
PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"),
|
|
JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"),
|
|
AmountCent: amountCent,
|
|
BizType: "order_pay",
|
|
Status: "created",
|
|
}
|
|
if runtimeConfig.isMockMode() {
|
|
payment.ProviderOrderID = "MOCK" + paymentNo
|
|
payment.TDCode = "mock://payment/pay/" + paymentNo
|
|
}
|
|
return payment, nil
|
|
}
|