退款审核功能:待交接取消不再自动退款,改为客服审核后原路退回

- 租客在待交接状态取消订单不再自动退款,改为 refund_status=pending_review
- 新增退款审核页面 /admin/orders/refund-review,客服可逐个审核
- 新增 POST /refund/approve 通过退款、POST /refund/reject 驳回退款
- 新增 GET /orders/refund-pending 查询待审核退款订单列表
- 优化状态标签表述:已取消→租客已取消、已关闭→客服已关闭、异常→客服介入
- 新增 refund_status: pending_review 及其前端标签「待客服审核退款」
- AdminOrderDetailView 退款状态改用统一 refundStatusLabel
This commit is contained in:
yml2213
2026-06-28 21:14:55 +08:00
parent c6c6a92645
commit c92cd03b8c
16 changed files with 492 additions and 35 deletions
@@ -2,6 +2,7 @@ package order
import (
"context"
"errors"
"time"
"hfb_sys/backend/internal/model"
@@ -356,6 +357,50 @@ func (r *Repository) buildRefundStatusDTO(order *model.RentalOrder) *RefundStatu
}
}
// AdminApproveRefund 客服审核通过退款申请,触发渠道原路退款。
func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) error {
var refund *refundAction
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if order.RefundStatus != refundStatusPendingReview {
return errors.New("订单不处于待审核退款状态")
}
if order.RefundAmountCent <= 0 {
return errors.New("退款金额无效")
}
action, err := r.prepareRefund(&order, order.RefundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
if err != nil {
return err
}
refund = action
return tx.Save(&order).Error
})
if err != nil {
return err
}
r.startRefundBestEffort(ctx, refund)
return nil
}
// AdminRejectRefund 客服驳回退款申请,清除退款状态和金额。
func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if order.RefundStatus != refundStatusPendingReview {
return errors.New("订单不处于待审核退款状态")
}
order.RefundStatus = "none"
order.RefundAmountCent = 0
return tx.Save(&order).Error
})
}
func isTerminalStatus(status string) bool {
return status == orderStatusCompleted || status == orderStatusCancelled || status == orderStatusClosed
}
+3 -2
View File
@@ -39,8 +39,9 @@ const (
checkoutStatusAccepted = "accepted"
checkoutStatusDisputed = "disputed"
refundStatusPending = "pending"
refundStatusRefunded = "refunded"
refundStatusPending = "pending"
refundStatusPendingReview = "pending_review"
refundStatusRefunded = "refunded"
listingReviewStatusApproved = "approved"
+2
View File
@@ -36,6 +36,8 @@ type OrderDTO struct {
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
SettlementStatus string `json:"settlement_status"`
RefundStatus string `json:"refund_status,omitempty"`
RefundAmountCent int64 `json:"refund_amount_cent"`
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
Checkout *CheckoutDTO `json:"checkout,omitempty"`
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
@@ -96,6 +96,39 @@ func (h *Handler) AdminRefundStatus(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) AdminApproveRefund(c *gin.Context) {
orderID, ok := parseID(c)
if !ok {
return
}
if err := h.service.AdminApproveRefund(c.Request.Context(), orderID); err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"approved": true})
}
func (h *Handler) AdminRejectRefund(c *gin.Context) {
orderID, ok := parseID(c)
if !ok {
return
}
if err := h.service.AdminRejectRefund(c.Request.Context(), orderID); err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"rejected": true})
}
func (h *Handler) ListPendingRefund(c *gin.Context) {
items, err := h.service.ListPendingRefund(c.Request.Context())
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) {
adminID, ok := currentAdminID(c)
if !ok {
+5 -9
View File
@@ -227,7 +227,6 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
}
func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) error {
var refund *refundAction
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -251,18 +250,16 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
orderID := order.ID
if beforeStatus == orderStatusPendingHandoff {
totalCent := order.RentAmountCent + order.DepositAmountCent
action, err := r.prepareRefund(&order, totalCent, refundBizCancel, "取消订单原路退款")
if err != nil {
return err
}
refund = action
order.RefundStatus = refundStatusPendingReview
order.RefundAmountCent = totalCent
order.RefundedAt = nil
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.OwnerID,
Type: "order",
Title: "订单已取消",
Content: "租客已取消订单,账号已重新释放。",
Content: "租客已取消订单,退款待客服审核。",
BizType: "order",
BizID: &orderID,
},
@@ -270,7 +267,7 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
UserID: order.RenterID,
Type: "order",
Title: "订单取消成功",
Content: "订单已取消,退款将原路退回您的支付账户。",
Content: "订单已取消,退款将由客服审核后原路退回。",
BizType: "order",
BizID: &orderID,
},
@@ -293,7 +290,6 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
if err != nil {
return err
}
r.startRefundBestEffort(ctx, refund)
return nil
}
@@ -56,6 +56,8 @@ func (row orderRow) toAdminDTO() OrderDTO {
Status: row.Status,
HandoffStatus: row.HandoffStatus,
SettlementStatus: row.SettlementStatus,
RefundStatus: row.RefundStatus,
RefundAmountCent: row.RefundAmountCent,
AdminActions: adminActionsForOrder(row.RentalOrder),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
@@ -0,0 +1,28 @@
package order
import (
"context"
)
// ListPendingRefund 查询所有待客服审核退款的订单。
func (r *Repository) ListPendingRefund(ctx context.Context) ([]OrderDTO, error) {
var rows []orderRow
err := r.adminQuery(ctx).
Where("o.refund_status = ?", refundStatusPendingReview).
Order("o.updated_at ASC, o.id ASC").
Limit(200).
Scan(&rows).Error
if err != nil {
return nil, err
}
db := r.db.WithContext(ctx)
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db)
items := make([]OrderDTO, 0, len(rows))
for _, row := range rows {
dto := row.toAdminDTO()
applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes)
items = append(items, dto)
}
return items, nil
}
+27
View File
@@ -226,6 +226,33 @@ func (s *Service) AdminRefundStatus(ctx context.Context, orderID uint64) (*Refun
return s.repo.AdminRefundStatus(ctx, orderID)
}
func (s *Service) AdminApproveRefund(ctx context.Context, orderID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if orderID == 0 {
return ErrOrderCannotComplete
}
return s.repo.AdminApproveRefund(ctx, orderID)
}
func (s *Service) AdminRejectRefund(ctx context.Context, orderID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if orderID == 0 {
return ErrOrderCannotComplete
}
return s.repo.AdminRejectRefund(ctx, orderID)
}
func (s *Service) ListPendingRefund(ctx context.Context) ([]OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListPendingRefund(ctx)
}
func (s *Service) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
+3
View File
@@ -522,6 +522,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/orders/:id/reset-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminResetHandoff)
adminRoutes.POST("/orders/:id/refund", requirePerm("order:close"), orderHandler.AdminRefund)
adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus)
adminRoutes.POST("/orders/:id/refund/approve", requirePerm("order:close"), orderHandler.AdminApproveRefund)
adminRoutes.POST("/orders/:id/refund/reject", requirePerm("order:close"), orderHandler.AdminRejectRefund)
adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund)
adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin)
adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview)
adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)