From c92cd03b8c40da0e8184d449b8fdcfecc271d569 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 28 Jun 2026 21:14:55 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=80=E6=AC=BE=E5=AE=A1=E6=A0=B8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=9A=E5=BE=85=E4=BA=A4=E6=8E=A5=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=87=AA=E5=8A=A8=E9=80=80=E6=AC=BE=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=AE=A2=E6=9C=8D=E5=AE=A1=E6=A0=B8=E5=90=8E?= =?UTF-8?q?=E5=8E=9F=E8=B7=AF=E9=80=80=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 租客在待交接状态取消订单不再自动退款,改为 refund_status=pending_review - 新增退款审核页面 /admin/orders/refund-review,客服可逐个审核 - 新增 POST /refund/approve 通过退款、POST /refund/reject 驳回退款 - 新增 GET /orders/refund-pending 查询待审核退款订单列表 - 优化状态标签表述:已取消→租客已取消、已关闭→客服已关闭、异常→客服介入 - 新增 refund_status: pending_review 及其前端标签「待客服审核退款」 - AdminOrderDetailView 退款状态改用统一 refundStatusLabel --- .../internal/modules/order/admin_actions.go | 45 +++ backend/internal/modules/order/constants.go | 5 +- backend/internal/modules/order/dto.go | 2 + .../internal/modules/order/handler_admin.go | 33 ++ backend/internal/modules/order/lifecycle.go | 14 +- backend/internal/modules/order/presenter.go | 2 + .../internal/modules/order/refund_review.go | 28 ++ backend/internal/modules/order/service.go | 27 ++ backend/internal/router/router.go | 3 + .../admin/views/AdminOrderDetailView.vue | 11 +- .../features/admin/views/AdminOrdersView.vue | 10 +- .../admin/views/AdminRefundReviewView.vue | 281 ++++++++++++++++++ frontend/src/features/orders/api/orders.ts | 23 ++ frontend/src/layouts/AdminLayout.vue | 6 + frontend/src/router/adminRoutes.ts | 6 + frontend/src/shared/utils/statusLabels.ts | 31 +- 16 files changed, 492 insertions(+), 35 deletions(-) create mode 100644 backend/internal/modules/order/refund_review.go create mode 100644 frontend/src/features/admin/views/AdminRefundReviewView.vue diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go index 533202f..cc763d7 100644 --- a/backend/internal/modules/order/admin_actions.go +++ b/backend/internal/modules/order/admin_actions.go @@ -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 } diff --git a/backend/internal/modules/order/constants.go b/backend/internal/modules/order/constants.go index 1c2f94a..2877a78 100644 --- a/backend/internal/modules/order/constants.go +++ b/backend/internal/modules/order/constants.go @@ -39,8 +39,9 @@ const ( checkoutStatusAccepted = "accepted" checkoutStatusDisputed = "disputed" - refundStatusPending = "pending" - refundStatusRefunded = "refunded" + refundStatusPending = "pending" + refundStatusPendingReview = "pending_review" + refundStatusRefunded = "refunded" listingReviewStatusApproved = "approved" diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 27ae838..3b706f2 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -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"` diff --git a/backend/internal/modules/order/handler_admin.go b/backend/internal/modules/order/handler_admin.go index 681901f..bf9b787 100644 --- a/backend/internal/modules/order/handler_admin.go +++ b/backend/internal/modules/order/handler_admin.go @@ -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 { diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 60ade5b..ff0816e 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -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 } diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index c3eef67..411e1b5 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -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, diff --git a/backend/internal/modules/order/refund_review.go b/backend/internal/modules/order/refund_review.go new file mode 100644 index 0000000..62709f8 --- /dev/null +++ b/backend/internal/modules/order/refund_review.go @@ -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 +} diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index af673d1..a521dc5 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -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 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 7823ce7..113f885 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index b31f277..1202e4f 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -28,6 +28,7 @@ import { disputeStatusLabel, handoffStatusLabel, orderStatusLabel, + refundStatusLabel, settlementStatusLabel, } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' @@ -293,16 +294,6 @@ async function confirmRefund() { await handleRefund() } -function refundStatusLabel(status: string) { - const map: Record = { - pending: '待退款', - refunding: '退款中', - refunded: '已退款', - failed: '退款失败', - } - return map[status] || status || '未退款' -} - function paymentStatusLabel(status: string) { const map: Record = { created: '已创建', diff --git a/frontend/src/features/admin/views/AdminOrdersView.vue b/frontend/src/features/admin/views/AdminOrdersView.vue index f94d655..3a893f2 100644 --- a/frontend/src/features/admin/views/AdminOrdersView.vue +++ b/frontend/src/features/admin/views/AdminOrdersView.vue @@ -35,10 +35,10 @@ const orderStatusOptions = [ { label: '待租客确认修正', value: 'pending_checkout_accept' }, { label: '结账争议中', value: 'checkout_disputing' }, { label: '申诉中', value: 'disputing' }, - { label: '异常', value: 'abnormal' }, + { label: '客服介入', value: 'abnormal' }, { label: '已完成', value: 'completed' }, - { label: '已关闭', value: 'closed' }, - { label: '已取消', value: 'cancelled' }, + { label: '客服已关闭', value: 'closed' }, + { label: '租客已取消', value: 'cancelled' }, ] as const const handoffStatusOptions = [ @@ -60,8 +60,8 @@ const settlementStatusOptions = [ { label: '冻结中', value: 'frozen' }, { label: '已结算', value: 'settled' }, { label: '已退款', value: 'refunded' }, - { label: '已取消', value: 'cancelled' }, - { label: '已关闭', value: 'closed' }, + { label: '结算已取消', value: 'cancelled' }, + { label: '结算已关闭', value: 'closed' }, { label: '争议中', value: 'disputed' }, { label: '已仲裁', value: 'arbitrated' }, ] as const diff --git a/frontend/src/features/admin/views/AdminRefundReviewView.vue b/frontend/src/features/admin/views/AdminRefundReviewView.vue new file mode 100644 index 0000000..5132cb1 --- /dev/null +++ b/frontend/src/features/admin/views/AdminRefundReviewView.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index f4fb15a..0e29545 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -34,6 +34,8 @@ export interface Order { status: OrderStatus handoff_status: HandoffStatus settlement_status: SettlementStatus + refund_status?: string + refund_amount_cent?: number active_dispute?: ActiveDispute checkout?: Checkout admin_actions?: AdminActions @@ -364,3 +366,24 @@ export async function adminRefundOrder(id: number) { const { data } = await apiClient.post>(`/admin/orders/${id}/refund`) return data.data } + +export async function adminApproveRefund(id: number) { + const { data } = await apiClient.post>( + `/admin/orders/${id}/refund/approve` + ) + return data.data +} + +export async function adminRejectRefund(id: number) { + const { data } = await apiClient.post>( + `/admin/orders/${id}/refund/reject` + ) + return data.data +} + +export async function fetchPendingRefundOrders() { + const { data } = await apiClient.get>( + '/admin/orders/refund-pending' + ) + return data.data.items +} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 1fc7f0d..6665cd7 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -83,6 +83,12 @@ const allNavGroups: NavGroup[] = [ children: [ { label: '用户管理', to: adminPath('users'), icon: User, permission: 'user:view' }, { label: '订单管理', to: adminPath('orders'), icon: Tickets, permission: 'order:view' }, + { + label: '退款审核', + to: adminPath('orders/refund-review'), + icon: Money, + permission: 'order:close', + }, { label: '商品管理', to: adminPath('listings'), icon: Shop, permission: 'listing:view' }, { label: '商品审核', diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 8968af8..e401d66 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -30,6 +30,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminOrdersView.vue'), meta: adminMeta, }, + { + path: adminPath('orders/refund-review'), + name: 'admin-refund-review', + component: () => import('@/features/admin/views/AdminRefundReviewView.vue'), + meta: adminMeta, + }, { path: adminPath('orders/:id'), name: 'admin-order-detail', diff --git a/frontend/src/shared/utils/statusLabels.ts b/frontend/src/shared/utils/statusLabels.ts index 23f8474..69baf64 100644 --- a/frontend/src/shared/utils/statusLabels.ts +++ b/frontend/src/shared/utils/statusLabels.ts @@ -40,29 +40,29 @@ const orderStatusMap: Record = { pending_checkout_accept: '待租客确认修正', checkout_disputing: '结账争议中', completed: '已完成', - cancelled: '已取消', - closed: '已关闭', + cancelled: '租客已取消', + closed: '客服已关闭', disputing: '申诉中', - abnormal: '异常', + abnormal: '客服介入', } const handoffStatusMap: Record = { pending_owner: '待号主交接', pending_renter_confirm: '待租客确认', - received: '已确认收号', + received: '租客已收号', pending_owner_return_confirm: '待号主确认结账', pending_owner_checkout: '待号主确认结账', pending_renter_checkout: '待租客确认修正', checkout_disputed: '结账争议中', returned: '已归还', - cancelled: '已取消', + cancelled: '租客已取消', owner_timeout: '号主交接超时', renter_confirm_timeout: '租客确认超时', return_overdue: '归还逾期', owner_return_confirm_timeout: '号主确认结账超时', owner_checkout_confirm_timeout: '号主确认结账超时', - admin_closed: '客服关闭', - admin_abnormal: '客服标记异常', + admin_closed: '客服已关闭', + admin_abnormal: '客服介入', arbitrated: '已仲裁', } @@ -72,12 +72,21 @@ const settlementStatusMap: Record = { frozen: '冻结中', settled: '已结算', refunded: '已退款', - cancelled: '已取消', - closed: '已关闭', + cancelled: '结算已取消', + closed: '结算已关闭', disputed: '争议中', arbitrated: '已仲裁', } +const refundStatusMap: Record = { + none: '未退款', + pending_review: '待客服审核退款', + pending: '待退款', + refunding: '退款中', + refunded: '已退款', + failed: '退款失败', +} + const realnameStatusMap: Partial> = { unverified: '未认证', pending: '认证中', @@ -148,6 +157,10 @@ export function settlementStatusLabel(status: string) { return readLabel(settlementStatusMap, status) } +export function refundStatusLabel(status: string) { + return readLabel(refundStatusMap, status) +} + export function realnameStatusLabel(status: string) { return readLabel(realnameStatusMap, status) }