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

- 租客在待交接状态取消订单不再自动退款,改为 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
}
@@ -40,6 +40,7 @@ const (
checkoutStatusDisputed = "disputed"
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)
@@ -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<string, string> = {
pending: '待退款',
refunding: '退款中',
refunded: '已退款',
failed: '退款失败',
}
return map[status] || status || '未退款'
}
function paymentStatusLabel(status: string) {
const map: Record<string, string> = {
created: '已创建',
@@ -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
@@ -0,0 +1,281 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Close, Refresh, View } from '@element-plus/icons-vue'
import {
adminApproveRefund,
adminRejectRefund,
fetchPendingRefundOrders,
type Order,
} from '@/features/orders/api/orders'
import { formatDateTime } from '@/shared/utils/time'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { orderStatusLabel, handoffStatusLabel } from '@/shared/utils/statusLabels'
import { readError } from '@/shared/utils/error'
import { adminPath } from '@/shared/utils/adminPath'
const router = useRouter()
const loading = ref(false)
const submitting = ref(false)
const orders = ref<Order[]>([])
async function loadOrders() {
loading.value = true
try {
orders.value = await fetchPendingRefundOrders()
} catch (error) {
ElMessage.error(readError(error, '加载退款审核列表失败'))
} finally {
loading.value = false
}
}
async function handleApprove(row: Order) {
try {
await ElMessageBox.confirm(
`确认通过「${row.order_no}」的退款申请?通过后将原路退回 ${formatCentWithSymbol(row.refund_amount_cent || 0)}`,
'退款审核通过确认',
{ confirmButtonText: '通过退款', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
submitting.value = true
try {
await adminApproveRefund(row.id)
ElMessage.success(`退款审核通过,${row.order_no} 已发起退款`)
await loadOrders()
} catch (error) {
ElMessage.error(readError(error, '退款审核失败'))
} finally {
submitting.value = false
}
}
async function handleReject(row: Order) {
try {
await ElMessageBox.confirm(
`确认驳回「${row.order_no}」的退款申请?驳回后不再退款。`,
'退款审核驳回确认',
{ confirmButtonText: '驳回退款', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
submitting.value = true
try {
await adminRejectRefund(row.id)
ElMessage.success(`退款审核已驳回,${row.order_no} 不再退款`)
await loadOrders()
} catch (error) {
ElMessage.error(readError(error, '驳回退款失败'))
} finally {
submitting.value = false
}
}
function openDetail(order: Order) {
router.push(adminPath(`orders/${order.id}`))
}
onMounted(loadOrders)
</script>
<template>
<div class="refund-review-page">
<div class="page-toolbar">
<h2>退款审核</h2>
<el-button :icon="Refresh" :loading="loading" @click="loadOrders">刷新</el-button>
</div>
<div v-loading="loading" class="review-list">
<div v-if="!loading && orders.length === 0" class="empty-state">
<el-empty description="暂无待审核退款" />
</div>
<div class="review-cards">
<div
v-for="order in orders"
:key="order.id"
class="review-card"
>
<div class="card-header">
<div class="order-info">
<span class="order-no" @click="openDetail(order)">{{ order.order_no }}</span>
<el-tag size="small" type="warning">待客服审核退款</el-tag>
</div>
<div class="order-time">{{ formatDateTime(order.updated_at) }}</div>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">商品</span>
<span class="info-value">{{ order.title || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">订单状态</span>
<span class="info-value">{{ orderStatusLabel(order.status) }}</span>
</div>
<div class="info-row">
<span class="info-label">交接状态</span>
<span class="info-value">{{ handoffStatusLabel(order.handoff_status) }}</span>
</div>
<div class="info-row">
<span class="info-label">号主</span>
<span class="info-value">{{ order.owner_phone || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">租客</span>
<span class="info-value">{{ order.renter_phone || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">订单总额</span>
<span class="info-value"
>{{ formatCentWithSymbol((order.rent_amount_cent || 0) + order.deposit_amount_cent) }}租金{{
formatCentWithSymbol(order.rent_amount_cent || 0)
}}
/ 押金{{ formatCentWithSymbol(order.deposit_amount_cent) }}</span
>
</div>
<div class="info-row">
<span class="info-label">退款金额</span>
<span class="info-value refund-amount">{{ formatCentWithSymbol(order.refund_amount_cent || 0) }}</span>
</div>
</div>
<div class="card-actions">
<el-button
:icon="View"
size="small"
@click="openDetail(order)"
>
查看详情
</el-button>
<el-button
:icon="Check"
type="primary"
size="small"
:loading="submitting"
@click="handleApprove(order)"
>
通过退款
</el-button>
<el-button
:icon="Close"
type="danger"
size="small"
:loading="submitting"
@click="handleReject(order)"
>
驳回退款
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.refund-review-page {
max-width: 900px;
margin: 0 auto;
}
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-toolbar h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.empty-state {
padding: 60px 0;
}
.review-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.review-card {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
padding: 16px;
background: var(--el-bg-color);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.order-info {
display: flex;
align-items: center;
gap: 8px;
}
.order-no {
font-weight: 600;
color: var(--el-color-primary);
cursor: pointer;
}
.order-no:hover {
text-decoration: underline;
}
.order-time {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.card-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 24px;
margin-bottom: 12px;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.info-label {
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.info-value {
color: var(--el-text-color-primary);
}
.refund-amount {
font-weight: 600;
color: var(--el-color-danger);
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>
@@ -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<ApiResponse<RefundStatus>>(`/admin/orders/${id}/refund`)
return data.data
}
export async function adminApproveRefund(id: number) {
const { data } = await apiClient.post<ApiResponse<{ approved: boolean }>>(
`/admin/orders/${id}/refund/approve`
)
return data.data
}
export async function adminRejectRefund(id: number) {
const { data } = await apiClient.post<ApiResponse<{ rejected: boolean }>>(
`/admin/orders/${id}/refund/reject`
)
return data.data
}
export async function fetchPendingRefundOrders() {
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>(
'/admin/orders/refund-pending'
)
return data.data.items
}
+6
View File
@@ -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: '商品审核',
+6
View File
@@ -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',
+22 -9
View File
@@ -40,29 +40,29 @@ const orderStatusMap: Record<OrderStatus, string> = {
pending_checkout_accept: '待租客确认修正',
checkout_disputing: '结账争议中',
completed: '已完成',
cancelled: '已取消',
closed: '已关闭',
cancelled: '租客已取消',
closed: '客服已关闭',
disputing: '申诉中',
abnormal: '异常',
abnormal: '客服介入',
}
const handoffStatusMap: Record<HandoffStatus, string> = {
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<SettlementStatus, string> = {
frozen: '冻结中',
settled: '已结算',
refunded: '已退款',
cancelled: '已取消',
closed: '已关闭',
cancelled: '结算已取消',
closed: '结算已关闭',
disputed: '争议中',
arbitrated: '已仲裁',
}
const refundStatusMap: Record<string, string> = {
none: '未退款',
pending_review: '待客服审核退款',
pending: '待退款',
refunding: '退款中',
refunded: '已退款',
failed: '退款失败',
}
const realnameStatusMap: Partial<Record<RealnameStatusValue, string>> = {
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)
}