优化订单支付

This commit is contained in:
yml
2026-06-08 23:56:25 +08:00
parent 870ea72996
commit a2a0489158
20 changed files with 884 additions and 5 deletions
+48 -1
View File
@@ -1,6 +1,10 @@
package payment
import "time"
import (
"time"
"gorm.io/datatypes"
)
type StartPaymentRequest struct {
PayWay string `json:"pay_way"`
@@ -52,3 +56,46 @@ type NotifyResult struct {
OK bool
Message string
}
type AdminPaymentQuery struct {
UserID uint64
OrderID uint64
OrderNo string
BizType string
Status string
Provider string
Page int
PageSize int
}
type PaginatedResult struct {
Items interface{} `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type AdminPaymentDTO struct {
ID uint64 `json:"id"`
PaymentNo string `json:"payment_no"`
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
UserID uint64 `json:"user_id"`
UserPhone string `json:"user_phone"`
Provider string `json:"provider"`
MerchantID string `json:"merchant_id"`
ThirdOrderID string `json:"third_order_id"`
ProviderOrderID string `json:"provider_order_id"`
PayWay string `json:"pay_way"`
AmountCent int64 `json:"amount_cent"`
BizType string `json:"biz_type"`
Status string `json:"status"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
RawRequest datatypes.JSON `json:"raw_request"`
RawResponse datatypes.JSON `json:"raw_response"`
PaidAt *time.Time `json:"paid_at,omitempty"`
NotifiedAt *time.Time `json:"notified_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -113,6 +113,19 @@ func (h *Handler) QueryRefundStatus(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) AdminList(c *gin.Context) {
query, ok := parseAdminPaymentQuery(c)
if !ok {
return
}
result, err := h.service.AdminList(query)
if err != nil {
writePaymentError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) LeshuaNotify(c *gin.Context) {
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
@@ -196,6 +209,35 @@ func parseID(c *gin.Context) (uint64, bool) {
return id, true
}
func parseAdminPaymentQuery(c *gin.Context) (AdminPaymentQuery, bool) {
var query AdminPaymentQuery
if raw := c.Query("user_id"); raw != "" {
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil || value == 0 {
response.BadRequest(c, "用户ID不正确")
return query, false
}
query.UserID = value
}
if raw := c.Query("order_id"); raw != "" {
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil || value == 0 {
response.BadRequest(c, "订单ID不正确")
return query, false
}
query.OrderID = value
}
query.OrderNo = c.Query("order_no")
query.BizType = c.Query("biz_type")
query.Status = c.Query("status")
query.Provider = c.Query("provider")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query.Page = page
query.PageSize = pageSize
return query, true
}
func writePaymentError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrDependencyUnavailable):
+182 -1
View File
@@ -180,6 +180,8 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques
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(context.Background(), channelCreatePaymentRequest{
ThirdOrderID: payment.ThirdOrderID,
AmountCent: payment.AmountCent,
@@ -193,10 +195,14 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques
})
if err != nil {
_ = r.markPaymentFailed(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(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.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
@@ -216,6 +222,8 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques
return nil, err
}
r.recordConfigUsage(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
}
@@ -254,6 +262,8 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen
_ = r.markPaymentFailed(payment.ID, nil, "payment channel unavailable")
return nil, ErrPaymentUnavailable
}
log.Printf("[payment] wallet recharge start user_id=%d payment_id=%d provider=%s amount_cent=%d third_order_id=%s",
userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID)
resp, err := runtimeConfig.Channel.CreatePayment(context.Background(), channelCreatePaymentRequest{
ThirdOrderID: payment.ThirdOrderID,
AmountCent: payment.AmountCent,
@@ -267,10 +277,14 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen
})
if err != nil {
_ = r.markPaymentFailed(payment.ID, nil, err.Error())
log.Printf("[payment] wallet recharge request failed user_id=%d payment_id=%d provider=%s amount_cent=%d err=%v",
userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err)
return nil, err
}
if !resp.OK {
_ = r.markPaymentFailed(payment.ID, resp.Raw, resp.ErrorMessage)
log.Printf("[payment] wallet recharge rejected user_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s",
userID, 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.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
@@ -290,6 +304,8 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen
return nil, err
}
r.recordConfigUsage(runtimeConfig, latest)
log.Printf("[payment] wallet recharge result user_id=%d payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s",
userID, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID)
dto := toDTO(*latest)
return &dto, nil
}
@@ -466,6 +482,8 @@ func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType
if err := r.db.Create(&refundOrder).Error; err != nil {
return nil, err
}
log.Printf("[payment] refund start order_id=%d order_no=%s payment_id=%d biz_type=%s provider=%s amount_cent=%d merchant_refund_id=%s origin_third_order_id=%s origin_provider_order_id=%s",
orderID, originalPayment.OrderNo, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, merchantRefundID, originalPayment.ThirdOrderID, refundOriginProviderOrderID(originalPayment))
r.recordConfigUsage(runtimeConfig, &refundOrder)
if err := r.markOrderRefunding(orderID, refundAmountCent); err != nil {
log.Printf("[payment] mark order refunding failed order_id=%d err=%v", orderID, err)
@@ -477,7 +495,7 @@ func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType
}
resp, err := runtimeConfig.Channel.CreateRefund(context.Background(), channelCreateRefundRequest{
ThirdOrderID: originalPayment.ThirdOrderID,
ProviderOrderID: originalPayment.ProviderOrderID,
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
MerchantRefundID: merchantRefundID,
RefundAmountCent: refundAmountCent,
NotifyURL: runtimeConfig.NotifyURL,
@@ -486,10 +504,14 @@ func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType
})
if err != nil {
_ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()})
log.Printf("[payment] refund request failed order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d err=%v",
orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, err)
return nil, err
}
if !resp.OK {
_ = r.markRefundFailed(refundOrder.ID, orderID, refundAmountCent, resp.Raw)
log.Printf("[payment] refund rejected order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d code=%s message=%s",
orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage)
return nil, ErrPaymentUnavailable
}
@@ -522,6 +544,8 @@ func (r *Repository) StartRefund(orderID uint64, refundAmountCent int64, bizType
}
refundOrder.Status = refundStatus
refundOrder.ProviderOrderID = resp.ProviderRefundID
log.Printf("[payment] refund result order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d status=%s provider_refund_id=%s",
orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, refundStatus, resp.ProviderRefundID)
dto := toRefundDTO(refundOrder)
return &dto, nil
@@ -581,6 +605,51 @@ func (r *Repository) QueryRefundStatus(orderID uint64) (*RefundDTO, error) {
return &dto, nil
}
func (r *Repository) AdminList(query AdminPaymentQuery) (*PaginatedResult, error) {
db := r.db.Table("payment_orders AS p").
Select("p.*, COALESCE(u.phone, '') AS user_phone").
Joins("LEFT JOIN users AS u ON u.id = p.user_id")
countDB := r.db.Model(&model.PaymentOrder{})
if query.UserID > 0 {
db = db.Where("p.user_id = ?", query.UserID)
countDB = countDB.Where("user_id = ?", query.UserID)
}
if query.OrderID > 0 {
db = db.Where("p.order_id = ?", query.OrderID)
countDB = countDB.Where("order_id = ?", query.OrderID)
}
if query.OrderNo != "" {
db = db.Where("p.order_no = ?", query.OrderNo)
countDB = countDB.Where("order_no = ?", query.OrderNo)
}
if query.BizType != "" {
db = db.Where("p.biz_type = ?", query.BizType)
countDB = countDB.Where("biz_type = ?", query.BizType)
}
if query.Status != "" {
db = db.Where("p.status = ?", query.Status)
countDB = countDB.Where("status = ?", query.Status)
}
if query.Provider != "" {
db = db.Where("p.provider = ?", query.Provider)
countDB = countDB.Where("provider = ?", query.Provider)
}
var total int64
if err := countDB.Count(&total).Error; err != nil {
return nil, err
}
offset := (query.Page - 1) * query.PageSize
var rows []adminPaymentRow
if err := db.Order("p.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]AdminPaymentDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil
}
// HandleRefundNotify 处理渠道退款通知。
func (r *Repository) HandleRefundNotify(provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) {
payment, err := r.findRefundPaymentForNotify(params)
@@ -1004,6 +1073,55 @@ func toDTO(payment model.PaymentOrder) PaymentDTO {
}
}
type adminPaymentRow struct {
model.PaymentOrder
UserPhone string
}
func (row adminPaymentRow) toDTO() AdminPaymentDTO {
errorCode, errorMessage := paymentErrorSummary(row.Status, row.RawResponse)
return AdminPaymentDTO{
ID: row.ID,
PaymentNo: row.PaymentNo,
OrderID: row.OrderID,
OrderNo: row.OrderNo,
UserID: row.UserID,
UserPhone: row.UserPhone,
Provider: row.Provider,
MerchantID: row.MerchantID,
ThirdOrderID: row.ThirdOrderID,
ProviderOrderID: row.ProviderOrderID,
PayWay: row.PayWay,
AmountCent: row.AmountCent,
BizType: row.BizType,
Status: row.Status,
ErrorCode: errorCode,
ErrorMessage: errorMessage,
RawRequest: row.RawRequest,
RawResponse: row.RawResponse,
PaidAt: row.PaidAt,
NotifiedAt: row.NotifiedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func paymentErrorSummary(status string, raw datatypes.JSON) (string, string) {
if status != "failed" {
return "", ""
}
if len(raw) == 0 {
return "", ""
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return "", ""
}
code := firstStringValue(payload, "code", "resp_code", "result_code", "error_code", "status")
message := firstStringValue(payload, "msg", "message", "error", "error_message", "result_msg", "result_desc")
return code, message
}
func moneyCent(value float64) int64 {
return int64(math.Round(value * 100))
}
@@ -1014,6 +1132,69 @@ func parseCent(value string) int64 {
return amount
}
func refundOriginProviderOrderID(payment model.PaymentOrder) string {
if payment.Provider != "lakala" {
return payment.ProviderOrderID
}
if tradeID := lakalaOriginTradeID(payment.RawResponse); tradeID != "" {
return tradeID
}
return payment.ProviderOrderID
}
func lakalaOriginTradeID(raw datatypes.JSON) string {
if len(raw) == 0 {
return ""
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return ""
}
if tradeID := firstStringValue(payload, "trade_no", "origin_trade_no"); tradeID != "" {
return tradeID
}
value, ok := payload["order_trade_info_list"]
if !ok {
return ""
}
switch typed := value.(type) {
case string:
var items []map[string]any
if err := json.Unmarshal([]byte(typed), &items); err != nil {
return ""
}
for _, item := range items {
if tradeID := firstStringValue(item, "trade_no", "origin_trade_no"); tradeID != "" {
return tradeID
}
}
case []any:
for _, item := range typed {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
if tradeID := firstStringValue(itemMap, "trade_no", "origin_trade_no"); tradeID != "" {
return tradeID
}
}
}
return ""
}
func firstStringValue(values map[string]any, keys ...string) string {
for _, key := range keys {
value, ok := values[key]
if !ok {
continue
}
if text, ok := value.(string); ok && text != "" {
return text
}
}
return ""
}
func parseChannelTime(value string) *time.Time {
if value == "" {
return nil
@@ -128,3 +128,32 @@ func TestParseChannelTimeUsesShanghaiWhenLocalIsUTC(t *testing.T) {
t.Fatalf("parseChannelTime() = %s, want 20260607202700 in Asia/Shanghai", got)
}
}
func TestRefundOriginProviderOrderIDUsesLakalaTradeNo(t *testing.T) {
payment := model.PaymentOrder{
Provider: "lakala",
ProviderOrderID: "26060811012001101011735013210",
RawResponse: datatypes.JSON([]byte(`{
"pay_order_no": "26060811012001101011735013210",
"order_trade_info_list": "[{\"trade_no\":\"20260608110113230266224452004512\",\"pay_order_no\":\"26060811012001101011735013210\"}]"
}`)),
}
got := refundOriginProviderOrderID(payment)
if got != "20260608110113230266224452004512" {
t.Fatalf("refundOriginProviderOrderID() = %q, want trade_no", got)
}
}
func TestRefundOriginProviderOrderIDFallsBackToProviderOrderID(t *testing.T) {
payment := model.PaymentOrder{
Provider: "lakala",
ProviderOrderID: "26060811012001101011735013210",
RawResponse: datatypes.JSON([]byte(`{"pay_order_no":"26060811012001101011735013210"}`)),
}
got := refundOriginProviderOrderID(payment)
if got != payment.ProviderOrderID {
t.Fatalf("refundOriginProviderOrderID() = %q, want fallback %q", got, payment.ProviderOrderID)
}
}
@@ -106,3 +106,19 @@ func (s *Service) QueryRefundStatus(orderID uint64) (*RefundDTO, error) {
}
return s.repo.QueryRefundStatus(orderID)
}
func (s *Service) AdminList(query AdminPaymentQuery) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repo.AdminList(query)
}