diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 35dd349..71089de 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -63,20 +63,20 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery) (*Financ db := r.db.WithContext(ctx) var payment paymentSummaryRow if err := db.Table("payment_orders AS po"). - Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ('order_pay') AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, + Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN po.amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent, - COALESCE(SUM(CASE WHEN po.biz_type IN ('order_pay') AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count, + COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS full_refund_count, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent < COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS partial_refund_count, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`, - refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). + payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). Joins(`LEFT JOIN ( SELECT order_id, MAX(amount_cent) AS amount_cent FROM payment_orders - WHERE biz_type = 'order_pay' AND status = 'paid' + WHERE biz_type IN ? AND status = 'paid' GROUP BY order_id - ) AS orig ON orig.order_id = po.order_id`). + ) AS orig ON orig.order_id = po.order_id`, payBizTypes()). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Scan(&payment).Error; err != nil { return nil, err @@ -168,20 +168,20 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi payments := make([]dailyPaymentRow, 0) if err := db.Table("payment_orders AS po"). Select(`DATE(po.created_at) AS date, - COALESCE(SUM(CASE WHEN po.biz_type IN ('order_pay') AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, + COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN po.amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent, - COALESCE(SUM(CASE WHEN po.biz_type IN ('order_pay') AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count, + COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS full_refund_count, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent < COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS partial_refund_count, COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`, - refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). + payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). Joins(`LEFT JOIN ( SELECT order_id, MAX(amount_cent) AS amount_cent FROM payment_orders - WHERE biz_type = 'order_pay' AND status = 'paid' + WHERE biz_type IN ? AND status = 'paid' GROUP BY order_id - ) AS orig ON orig.order_id = po.order_id`). + ) AS orig ON orig.order_id = po.order_id`, payBizTypes()). Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate). Group("DATE(po.created_at)"). Scan(&payments).Error; err != nil { diff --git a/backend/internal/modules/adminfinance/helper.go b/backend/internal/modules/adminfinance/helper.go index 8ac9bc8..881dbec 100644 --- a/backend/internal/modules/adminfinance/helper.go +++ b/backend/internal/modules/adminfinance/helper.go @@ -20,6 +20,10 @@ const ( financeStatusOfflineSettlementPending = "offline_settlement_pending" ) +func payBizTypes() []string { + return []string{"order_pay", "mohong_pay"} +} + func refundBizTypes() []string { return []string{ "cancel_refund", @@ -29,6 +33,7 @@ func refundBizTypes() []string { "deposit_refund", "rent_refund", "arbitration_refund", + "mohong_refund", } } diff --git a/backend/internal/modules/adminfinance/helper_test.go b/backend/internal/modules/adminfinance/helper_test.go index e736f56..3fcedea 100644 --- a/backend/internal/modules/adminfinance/helper_test.go +++ b/backend/internal/modules/adminfinance/helper_test.go @@ -23,3 +23,25 @@ func TestDailyDateKey(t *testing.T) { }) } } + +func TestPayBizTypesIncludeMohongPay(t *testing.T) { + values := map[string]bool{} + for _, item := range payBizTypes() { + values[item] = true + } + for _, want := range []string{"order_pay", "mohong_pay"} { + if !values[want] { + t.Fatalf("payBizTypes missing %s", want) + } + } +} + +func TestRefundBizTypesIncludeMohongRefund(t *testing.T) { + values := map[string]bool{} + for _, item := range refundBizTypes() { + values[item] = true + } + if !values["mohong_refund"] { + t.Fatal("refundBizTypes missing mohong_refund") + } +} diff --git a/backend/internal/modules/payment/dto.go b/backend/internal/modules/payment/dto.go index 8f91db4..a061a6f 100644 --- a/backend/internal/modules/payment/dto.go +++ b/backend/internal/modules/payment/dto.go @@ -11,6 +11,11 @@ type StartPaymentRequest struct { JSPayFlag string `json:"jspay_flag"` } +type StartMohongRefundRequest struct { + AmountCent int64 `json:"amount_cent"` + Remark string `json:"remark"` +} + type PaymentDTO struct { ID uint64 `json:"id"` PaymentNo string `json:"payment_no"` diff --git a/backend/internal/modules/payment/handler.go b/backend/internal/modules/payment/handler.go index 29035a0..735dc27 100644 --- a/backend/internal/modules/payment/handler.go +++ b/backend/internal/modules/payment/handler.go @@ -147,6 +147,24 @@ func (h *Handler) QueryMohong(c *gin.Context) { response.OK(c, item) } +func (h *Handler) AdminRefundMohong(c *gin.Context) { + orderID, ok := parseID(c) + if !ok { + return + } + var req StartMohongRefundRequest + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + response.BadRequest(c, "请求参数不正确") + return + } + item, err := h.service.StartMohongRefund(c.Request.Context(), orderID, req.AmountCent, req.Remark) + if err != nil { + writePaymentError(c, err) + return + } + response.OK(c, item) +} + func (h *Handler) QueryRefundStatus(c *gin.Context) { orderID, ok := parseID(c) if !ok { diff --git a/backend/internal/modules/payment/refund.go b/backend/internal/modules/payment/refund.go index 0432e90..5c76870 100644 --- a/backend/internal/modules/payment/refund.go +++ b/backend/internal/modules/payment/refund.go @@ -264,13 +264,13 @@ func (r *Repository) prepareRefundOrder(ctx context.Context, originalPayment mod } func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.PaymentOrder, queryTerminal bool) (*model.PaymentOrder, error) { if payment.Status == "refunded" && !queryTerminal { - if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil { + if err := r.updateRefundedOrderStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil { return nil, err } return payment, nil } if payment.Status == "failed" && !queryTerminal { - if err := r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent); err != nil { + if err := r.markRefundFailedOrderStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil { return nil, err } return payment, nil @@ -290,7 +290,7 @@ func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.Payme if runtimeConfig.Channel == nil { return nil, ErrPaymentUnavailable } - originalPayment, err := r.findOriginalPayment(ctx, payment.OrderID) + originalPayment, err := r.findOriginalPaymentForRefund(ctx, payment) if err != nil { return nil, err } @@ -362,11 +362,11 @@ func (r *Repository) applyRefundChannelStatus(ctx context.Context, payment *mode } switch status { case "refunded": - return r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType) + return r.updateRefundedOrderStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType) case "failed": - return r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent) + return r.markRefundFailedOrderStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType) default: - return r.markOrderRefunding(ctx, payment.OrderID, payment.AmountCent) + return r.markRefundingOrderStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType) } } func isRefundBizType(bizType string) bool { @@ -385,6 +385,35 @@ func refundQueryRequest(payment model.PaymentOrder, originalPayment model.Paymen ProviderRefundID: payment.ProviderOrderID, } } + +func (r *Repository) findOriginalPaymentForRefund(ctx context.Context, payment *model.PaymentOrder) (model.PaymentOrder, error) { + if payment != nil && isMohongRefundBizType(payment.BizType) { + return r.findOriginalMohongPayment(ctx, payment.OrderID) + } + return r.findOriginalPayment(ctx, payment.OrderID) +} + +func (r *Repository) updateRefundedOrderStatus(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string) error { + if isMohongRefundBizType(bizType) { + return r.updateMohongOrderRefundStatus(ctx, orderID) + } + return r.updateOrderRefundStatus(ctx, orderID, refundAmountCent, bizType) +} + +func (r *Repository) markRefundingOrderStatus(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string) error { + if isMohongRefundBizType(bizType) { + return nil + } + return r.markOrderRefunding(ctx, orderID, refundAmountCent) +} + +func (r *Repository) markRefundFailedOrderStatus(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string) error { + if isMohongRefundBizType(bizType) { + return nil + } + return r.markOrderRefundFailed(ctx, orderID, refundAmountCent) +} + func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order model.RentalOrder diff --git a/backend/internal/modules/payment/refund_mohong.go b/backend/internal/modules/payment/refund_mohong.go new file mode 100644 index 0000000..e1d686a --- /dev/null +++ b/backend/internal/modules/payment/refund_mohong.go @@ -0,0 +1,230 @@ +package payment + +import ( + "context" + + "hfb_sys/backend/internal/model" + + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const bizTypeMohongRefund = "mohong_refund" + +func (r *Repository) StartMohongRefund(ctx context.Context, orderID uint64, refundAmountCent int64, remark string) (*RefundDTO, error) { + originalPayment, err := r.findOriginalMohongPayment(ctx, orderID) + if err != nil { + return nil, err + } + runtimeConfig, err := r.runtimeConfigForPayment(ctx, &originalPayment) + if err != nil { + return nil, ErrPaymentUnavailable + } + + refundOrder, existing, err := r.prepareMohongRefundOrder(ctx, originalPayment, *runtimeConfig, refundAmountCent) + if err != nil { + return nil, err + } + if existing { + latest, syncErr := r.syncRefundPayment(ctx, refundOrder, refundOrder.Status != "refunded") + if syncErr != nil { + r.log().Warn("已有撞车退款单同步失败", paymentLogFields(ctx, appendFields( + paymentOrderFields(refundOrder), + []zap.Field{zap.Error(syncErr)}, + )..., + )...) + dto := toRefundDTO(*refundOrder) + return &dto, nil + } + dto := toRefundDTO(*latest) + return &dto, nil + } + + if runtimeConfig.isMockMode() { + raw := map[string]string{"mock": "true"} + if remark != "" { + raw["remark"] = remark + } + if err := r.applyRefundChannelStatus(ctx, refundOrder, refundChannelStatusUpdate{ + Status: "refunded", + ProviderRefundID: "MOCKREF" + refundOrder.ThirdOrderID, + Raw: raw, + Source: channelSourceMock, + }); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(ctx, refundOrder.ID) + if err != nil { + return nil, err + } + dto := toRefundDTO(*latest) + return &dto, nil + } + + if runtimeConfig.Channel == nil { + if err := r.markMohongRefundFailed(ctx, refundOrder.ID, map[string]string{"error": "payment channel unavailable"}, nil); err != nil { + r.log().Warn("撞车退款失败状态保存失败", paymentLogFields(ctx, appendFields( + paymentOrderFields(refundOrder), + []zap.Field{zap.Error(err)}, + )..., + )...) + } + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{ + ThirdOrderID: originalPayment.ThirdOrderID, + ProviderOrderID: refundOriginProviderOrderID(originalPayment), + MerchantRefundID: refundOrder.ThirdOrderID, + RefundAmountCent: refundOrder.AmountCent, + NotifyURL: runtimeConfig.NotifyURL, + Attach: originalPayment.OrderNo, + Remark: remark, + }) + if err != nil { + var rawRequest map[string]string + if resp != nil { + rawRequest = resp.RawRequest + } + if markErr := r.markMohongRefundFailed(ctx, refundOrder.ID, map[string]string{"error": err.Error()}, rawRequest); markErr != nil { + r.log().Warn("撞车退款失败状态保存失败", paymentLogFields(ctx, appendFields( + paymentOrderFields(refundOrder), + []zap.Field{zap.Error(markErr)}, + )..., + )...) + } + return nil, err + } + if !resp.OK { + if markErr := r.markMohongRefundFailed(ctx, refundOrder.ID, resp.Raw, resp.RawRequest); markErr != nil { + r.log().Warn("撞车退款拒绝状态保存失败", paymentLogFields(ctx, appendFields( + paymentOrderFields(refundOrder), + []zap.Field{zap.Error(markErr)}, + )..., + )...) + } + return nil, ErrPaymentUnavailable + } + + if err := r.applyRefundChannelStatus(ctx, refundOrder, refundChannelStatusUpdate{ + Status: resp.Status, + ProviderRefundID: resp.ProviderRefundID, + RawRequest: resp.RawRequest, + Raw: resp.Raw, + Source: channelSourceCreate, + }); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(ctx, refundOrder.ID) + if err != nil { + return nil, err + } + dto := toRefundDTO(*latest) + return &dto, nil +} + +func (r *Repository) findOriginalMohongPayment(ctx context.Context, orderID uint64) (model.PaymentOrder, error) { + var originalPayment model.PaymentOrder + if err := r.db.WithContext(ctx). + Where("order_id = ? AND status = 'paid' AND biz_type = ?", orderID, bizTypeMohongPay). + Order("id DESC"). + First(&originalPayment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return originalPayment, ErrPaymentNotFound + } + return originalPayment, err + } + return originalPayment, nil +} + +func (r *Repository) prepareMohongRefundOrder(ctx context.Context, originalPayment model.PaymentOrder, runtimeConfig runtimePaymentConfig, refundAmountCent int64) (*model.PaymentOrder, bool, error) { + var paymentID uint64 + existing := false + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var order model.MohongOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, originalPayment.OrderID).Error; err != nil { + return err + } + if order.Status != model.MohongOrderStatusCancelled { + return ErrRefundCannotStart + } + amountCent := refundAmountCent + if amountCent == 0 { + amountCent = order.AmountCent + } + if amountCent <= 0 || amountCent > originalPayment.AmountCent { + return ErrRefundCannotStart + } + + var existingRefund model.PaymentOrder + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id = ? AND biz_type = ?", originalPayment.OrderID, bizTypeMohongRefund). + Order("id DESC"). + First(&existingRefund).Error + if err == nil { + paymentID = existingRefund.ID + existing = true + return nil + } + if err != gorm.ErrRecordNotFound { + return err + } + + paymentNo, err := newPaymentNo() + if err != nil { + return err + } + merchantRefundID := "REF" + paymentNo[3:] + refundOrder := model.PaymentOrder{ + PaymentNo: paymentNo, + OrderID: originalPayment.OrderID, + OrderNo: originalPayment.OrderNo, + UserID: originalPayment.UserID, + PaymentConfigID: runtimeConfig.ID, + Provider: runtimeConfig.Provider, + MerchantID: runtimeConfig.MerchantID, + ThirdOrderID: merchantRefundID, + ProviderOrderID: "", + PayWay: originalPayment.PayWay, + JSPayFlag: originalPayment.JSPayFlag, + AmountCent: amountCent, + BizType: bizTypeMohongRefund, + Status: "refunding", + } + if err := tx.Create(&refundOrder).Error; err != nil { + return err + } + paymentID = refundOrder.ID + return nil + }) + if err != nil { + return nil, false, err + } + payment, err := r.findPaymentByID(ctx, paymentID) + if err != nil { + return nil, false, err + } + return payment, existing, nil +} + +func (r *Repository) updateMohongOrderRefundStatus(ctx context.Context, orderID uint64) error { + return r.db.WithContext(ctx).Model(&model.MohongOrder{}).Where("id = ?", orderID).Update("status", model.MohongOrderStatusRefunded).Error +} + +func (r *Repository) markMohongRefundFailed(ctx context.Context, paymentID uint64, raw map[string]string, rawRequest map[string]string) error { + if raw == nil { + raw = map[string]string{"error": "refund failed"} + } + updates := map[string]any{ + "status": "failed", + "raw_response": jsonMap(raw), + } + if rawRequest != nil { + updates["raw_request"] = jsonMap(rawRequest) + } + return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(updates).Error +} + +func isMohongRefundBizType(bizType string) bool { + return bizType == bizTypeMohongRefund +} diff --git a/backend/internal/modules/payment/repository_integration_test.go b/backend/internal/modules/payment/repository_integration_test.go index 8c5e5ce..8d803b8 100644 --- a/backend/internal/modules/payment/repository_integration_test.go +++ b/backend/internal/modules/payment/repository_integration_test.go @@ -3,6 +3,7 @@ package payment import ( "context" "encoding/json" + "errors" "testing" "time" @@ -514,6 +515,103 @@ func TestStartRefundDoesNotCreateNewOrderWhenFailedRefundExists(t *testing.T) { } } +func TestStartMohongRefundDefaultsFullAmountForCancelledPaidOrder(t *testing.T) { + db := setupPaymentTestDB(t) + if err := db.AutoMigrate(&model.MohongOrder{}); err != nil { + t.Fatalf("migrate mohong order failed: %v", err) + } + repo := NewRepository(db, nil, nil) + order := model.MohongOrder{ + OrderNo: "MH202607310001", + UserID: 42, + ProductID: 8, + Quantity: 1, + UnitPriceCent: 13300, + AmountCent: 13300, + Status: model.MohongOrderStatusCancelled, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create mohong order failed: %v", err) + } + original := model.PaymentOrder{ + PaymentNo: "PAYMOHONG0001", + OrderID: order.ID, + OrderNo: order.OrderNo, + UserID: order.UserID, + Provider: "mock", + ThirdOrderID: "PAYMOHONG0001", + ProviderOrderID: "MOCKPAYMOHONG0001", + AmountCent: order.AmountCent, + BizType: bizTypeMohongPay, + Status: "paid", + } + if err := db.Create(&original).Error; err != nil { + t.Fatalf("create mohong payment failed: %v", err) + } + + dto, err := repo.StartMohongRefund(t.Context(), order.ID, 0, "后台撞车订单人工退款") + if err != nil { + t.Fatalf("StartMohongRefund() error = %v", err) + } + if dto.AmountCent != order.AmountCent { + t.Fatalf("refund amount = %d, want %d", dto.AmountCent, order.AmountCent) + } + if dto.BizType != bizTypeMohongRefund { + t.Fatalf("refund biz_type = %q, want %q", dto.BizType, bizTypeMohongRefund) + } + if dto.Status != "refunded" { + t.Fatalf("refund status = %q, want refunded", dto.Status) + } + + var latest model.MohongOrder + if err := db.First(&latest, order.ID).Error; err != nil { + t.Fatalf("find mohong order failed: %v", err) + } + if latest.Status != model.MohongOrderStatusRefunded { + t.Fatalf("mohong order status = %q, want refunded", latest.Status) + } +} + +func TestStartMohongRefundRequiresCancelledOrder(t *testing.T) { + db := setupPaymentTestDB(t) + if err := db.AutoMigrate(&model.MohongOrder{}); err != nil { + t.Fatalf("migrate mohong order failed: %v", err) + } + repo := NewRepository(db, nil, nil) + order := model.MohongOrder{ + OrderNo: "MH202607310002", + UserID: 42, + ProductID: 8, + Quantity: 1, + UnitPriceCent: 13300, + AmountCent: 13300, + Status: model.MohongOrderStatusPaid, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create mohong order failed: %v", err) + } + original := model.PaymentOrder{ + PaymentNo: "PAYMOHONG0002", + OrderID: order.ID, + OrderNo: order.OrderNo, + UserID: order.UserID, + Provider: "mock", + ThirdOrderID: "PAYMOHONG0002", + ProviderOrderID: "MOCKPAYMOHONG0002", + AmountCent: order.AmountCent, + BizType: bizTypeMohongPay, + Status: "paid", + } + if err := db.Create(&original).Error; err != nil { + t.Fatalf("create mohong payment failed: %v", err) + } + + _, err := repo.StartMohongRefund(t.Context(), order.ID, 0, "后台撞车订单人工退款") + if !errors.Is(err, ErrRefundCannotStart) { + t.Fatalf("StartMohongRefund() error = %v, want ErrRefundCannotStart", err) + } +} + func TestConfirmPaidUpdatesOrderAndPaymentInOnePath(t *testing.T) { db := setupPaymentTestDB(t) orderRepo := ordermodule.NewRepository(db) diff --git a/backend/internal/modules/payment/service.go b/backend/internal/modules/payment/service.go index 9906a01..ee4a50a 100644 --- a/backend/internal/modules/payment/service.go +++ b/backend/internal/modules/payment/service.go @@ -86,6 +86,16 @@ func (s *Service) StartRefund(ctx context.Context, orderID uint64, refundAmountC return s.repo.StartRefund(ctx, orderID, refundAmountCent, bizType, remark) } +func (s *Service) StartMohongRefund(ctx context.Context, orderID uint64, refundAmountCent int64, remark string) (*RefundDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if orderID == 0 || refundAmountCent < 0 { + return nil, ErrRefundCannotStart + } + return s.repo.StartMohongRefund(ctx, orderID, refundAmountCent, remark) +} + func (s *Service) QueryRefundStatus(ctx context.Context, orderID uint64) (*RefundDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 186d8cc..9824ae6 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -630,6 +630,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST(prefix+"/orders/:id/receive", requirePerm("mohong:order_manage"), mohongHandler.AdminReceiveOrder) adminRoutes.POST(prefix+"/orders/:id/complete", requirePerm("mohong:order_manage"), mohongHandler.AdminCompleteOrder) adminRoutes.POST(prefix+"/orders/:id/cancel", requirePerm("mohong:order_manage"), mohongHandler.AdminCancelOrder) + adminRoutes.POST(prefix+"/orders/:id/refund", requirePerm("mohong:order_manage"), paymentHandler.AdminRefundMohong) adminRoutes.GET(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminGetConfig) adminRoutes.PUT(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminUpdateConfig) } diff --git a/frontend/src/features/admin/views/AdminMohongOrdersView.vue b/frontend/src/features/admin/views/AdminMohongOrdersView.vue index 522b132..5f33122 100644 --- a/frontend/src/features/admin/views/AdminMohongOrdersView.vue +++ b/frontend/src/features/admin/views/AdminMohongOrdersView.vue @@ -1,16 +1,17 @@