diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 31bd476..75dda49 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -29,6 +29,12 @@ type RentalOrder struct { RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"` RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"` RefundedAt *time.Time `json:"refunded_at"` + DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"` + DepositHoldAmountCent int64 `gorm:"not null;default:0" json:"deposit_hold_amount_cent"` + DepositHoldReason string `gorm:"size:255;not null;default:''" json:"deposit_hold_reason"` + DepositHeldBy *uint64 `json:"deposit_held_by"` + DepositHeldAt *time.Time `json:"deposit_held_at"` + DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"` OwnerSettledAt *time.Time `json:"owner_settled_at"` SettledAt *time.Time `json:"settled_at"` CreatedAt time.Time `json:"created_at"` @@ -38,3 +44,45 @@ type RentalOrder struct { func (RentalOrder) TableName() string { return "rental_orders" } + +// 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算, +// 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。 +const ( + DepositHoldStatusNone = "none" // 未暂扣 + DepositHoldStatusHeld = "held" // 已暂扣(押金退款被拦截/挂起) + DepositHoldStatusReleased = "released" // 已归还 +) + +// IsDepositHeld 判断订单当前是否处于押金暂扣状态。 +func (o *RentalOrder) IsDepositHeld() bool { + return o.DepositHoldStatus == DepositHoldStatusHeld +} + +// ApplyDepositHold 在任一结算退款路径中拦截押金退款。 +// totalRefundCent 是本次本应原路退还给租客的总金额(含租金退款和押金退款), +// depositPortionCent 是其中属于押金退款的部分。 +// 若订单已暂扣,则把押金部分(不超过本次退款总额)从退款中扣除, +// 并把尚未记录过的部分累加到暂扣金额(累计不超过实付押金),返回扣除后实际发起原路退款的金额; +// 未暂扣时原样返回 totalRefundCent。 +// 注意:本函数只做“少退、挂起”,绝不多退,因此在任何路径下都不会造成租客损失或平台多付。 +func (o *RentalOrder) ApplyDepositHold(totalRefundCent, depositPortionCent int64) int64 { + if !o.IsDepositHeld() { + return totalRefundCent + } + withheld := depositPortionCent + if withheld > totalRefundCent { + withheld = totalRefundCent + } + if withheld <= 0 { + return totalRefundCent + } + remainingDepositHoldCent := o.DepositAmountCent - o.DepositHoldAmountCent + if remainingDepositHoldCent > 0 { + newHold := withheld + if newHold > remainingDepositHoldCent { + newHold = remainingDepositHoldCent + } + o.DepositHoldAmountCent += newHold + } + return totalRefundCent - withheld +} diff --git a/backend/internal/model/order_test.go b/backend/internal/model/order_test.go new file mode 100644 index 0000000..ad69d2e --- /dev/null +++ b/backend/internal/model/order_test.go @@ -0,0 +1,49 @@ +package model + +import "testing" + +func TestRentalOrderApplyDepositHold(t *testing.T) { + order := RentalOrder{ + DepositAmountCent: 1000, + DepositHoldStatus: DepositHoldStatusHeld, + } + + actualRefundCent := order.ApplyDepositHold(1500, 1000) + if actualRefundCent != 500 { + t.Fatalf("actual refund = %d, want 500", actualRefundCent) + } + if order.DepositHoldAmountCent != 1000 { + t.Fatalf("hold amount = %d, want 1000", order.DepositHoldAmountCent) + } +} + +func TestRentalOrderApplyDepositHoldKeepsWithholdingAfterAmountRecorded(t *testing.T) { + order := RentalOrder{ + DepositAmountCent: 1000, + DepositHoldStatus: DepositHoldStatusHeld, + DepositHoldAmountCent: 1000, + } + + actualRefundCent := order.ApplyDepositHold(1500, 1000) + if actualRefundCent != 500 { + t.Fatalf("actual refund = %d, want 500", actualRefundCent) + } + if order.DepositHoldAmountCent != 1000 { + t.Fatalf("hold amount = %d, want 1000", order.DepositHoldAmountCent) + } +} + +func TestRentalOrderApplyDepositHoldSkipsWhenNotHeld(t *testing.T) { + order := RentalOrder{ + DepositAmountCent: 1000, + DepositHoldStatus: DepositHoldStatusNone, + } + + actualRefundCent := order.ApplyDepositHold(1500, 1000) + if actualRefundCent != 1500 { + t.Fatalf("actual refund = %d, want 1500", actualRefundCent) + } + if order.DepositHoldAmountCent != 0 { + t.Fatalf("hold amount = %d, want 0", order.DepositHoldAmountCent) + } +} diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index e730b5c..cd459e3 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -79,8 +79,10 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil { return err } - if settlement.RenterRefundAmountCent > 0 { - action, err := r.prepareRefund(&order, settlement.RenterRefundAmountCent, "arbitration_refund", "仲裁退款原路退还") + // 押金暂扣:若订单被暂扣,则把本次退款中的押金部分挂起不退,仅退还剩余部分。 + refundAmountCent := order.ApplyDepositHold(settlement.RenterRefundAmountCent, settlement.RenterDepositRefundCent) + if refundAmountCent > 0 { + action, err := r.prepareRefund(&order, refundAmountCent, "arbitration_refund", "仲裁退款原路退还") if err != nil { return err } @@ -169,6 +171,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r type arbitrationSettlement struct { Entries []wallet.Entry RenterRefundAmountCent int64 + RenterDepositRefundCent int64 OwnerIncomeAmountCent int64 DepositDeductAmountCent int64 } @@ -223,15 +226,19 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r switch req.Result { case "full_refund": addRenterRefund(totalCent, "仲裁全额退款") + settlement.RenterDepositRefundCent = depositAmountCent case "partial_refund": if req.AmountCent <= 0 || req.AmountCent > totalCent { return settlement, ErrInvalidDispute } addRenterRefund(req.AmountCent, "仲裁部分退款") + // 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。 + settlement.RenterDepositRefundCent = money.MinCent(req.AmountCent, depositAmountCent) addOwnerIncome(money.MinCent(totalCent-req.AmountCent, ownerRentAmountCent+depositAmountCent), "仲裁剩余金额结算给号主") case "release_deposit": addOwnerIncome(ownerRentAmountCent, "仲裁确认订单金额结算给号主") addRenterRefund(depositAmountCent, "仲裁释放押金给租客") + settlement.RenterDepositRefundCent = depositAmountCent case "deduct_deposit", "compensate_owner": deductAmountCent := req.AmountCent if deductAmountCent <= 0 { @@ -243,6 +250,7 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r settlement.DepositDeductAmountCent = deductAmountCent addOwnerIncome(ownerRentAmountCent+deductAmountCent, "仲裁订单金额及押金赔付结算给号主") addRenterRefund(depositAmountCent-deductAmountCent, "仲裁退回剩余押金给租客") + settlement.RenterDepositRefundCent = depositAmountCent - deductAmountCent case "order_close": // Only release frozen funds. No available-balance settlement happens in development mode. case "mark_abnormal": diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go index 44a1cf2..8ad2289 100644 --- a/backend/internal/modules/order/admin_actions.go +++ b/backend/internal/modules/order/admin_actions.go @@ -38,7 +38,9 @@ func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uin archiveAssets(listing, account) if beforeOrderStatus != orderStatusPendingPayment { totalCent := order.RentAmountCent + order.DepositAmountCent - action, err := r.prepareRefund(order, totalCent, refundBizAdminClose, "客服关闭订单原路退款") + // 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。 + refundAmountCent := order.ApplyDepositHold(totalCent, order.DepositAmountCent) + action, err := r.prepareRefund(order, refundAmountCent, refundBizAdminClose, "客服关闭订单原路退款") if err != nil { return err } @@ -307,26 +309,50 @@ func resetTargetForOrder(order model.RentalOrder) (adminResetTarget, bool) { // AdminRefund 触发后台人工退款,退款由 payment 模块走渠道原路退回。 func (r *Repository) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) { - var order model.RentalOrder db := r.db.WithContext(ctx) - if err := db.First(&order, orderID).Error; err != nil { - return nil, err - } - if order.RefundStatus == refundStatusRefunded { - return r.buildRefundStatusDTO(&order), nil - } - if r.refundStarter == nil { - return nil, ErrDependencyUnavailable - } - totalCent := order.RentAmountCent + order.DepositAmountCent - if totalCent <= 0 { - return nil, ErrInvalidCheckoutAmount - } - status, err := r.refundStarter.StartRefund(ctx, orderID, totalCent, refundBizAdmin, "后台人工退款") + var refund *refundAction + err := db.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 == refundStatusRefunded { + return nil + } + totalCent := order.RentAmountCent + order.DepositAmountCent + if totalCent <= 0 { + return ErrInvalidCheckoutAmount + } + // 押金暂扣:人工退款也必须尊重暂扣状态,避免绕过押金挂起直接全额退款。 + refundAmountCent := order.ApplyDepositHold(totalCent, order.DepositAmountCent) + if refundAmountCent <= 0 { + order.RefundStatus = "none" + order.RefundAmountCent = 0 + return tx.Save(&order).Error + } + if r.refundStarter == nil { + return ErrDependencyUnavailable + } + action, err := r.prepareRefund(&order, refundAmountCent, refundBizAdmin, "后台人工退款") + if err != nil { + return err + } + refund = action + return tx.Save(&order).Error + }) if err != nil { return nil, err } + status := "" + if refund != nil { + var err error + status, err = r.refundStarter.StartRefund(ctx, refund.OrderID, refund.RefundAmountCent, refund.BizType, refund.Remark) + if err != nil { + return nil, err + } + } // 重新读取订单,拿到 payment 模块更新后的退款字段。 + var order model.RentalOrder if err := db.First(&order, orderID).Error; err != nil { return nil, err } @@ -371,7 +397,15 @@ func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) err if order.RefundAmountCent <= 0 { return errors.New("退款金额无效") } - action, err := r.prepareRefund(&order, order.RefundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)") + // 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。 + refundAmountCent := order.ApplyDepositHold(order.RefundAmountCent, order.DepositAmountCent) + if refundAmountCent <= 0 { + // 全部押金被暂扣且无其他可退金额,直接标记为已处理,等待后续手动退押金。 + order.RefundStatus = "none" + order.RefundAmountCent = 0 + return tx.Save(&order).Error + } + action, err := r.prepareRefund(&order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)") if err != nil { return err } @@ -469,7 +503,9 @@ func (r *Repository) AdminSeal(ctx context.Context, adminID uint64, orderID uint sealAssets(listing, account) if beforeOrderStatus != orderStatusPendingPayment { totalCent := order.RentAmountCent + order.DepositAmountCent - action, err := r.prepareRefund(order, totalCent, refundBizAdminSeal, "客服封存订单原路退款") + // 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。 + refundAmountCent := order.ApplyDepositHold(totalCent, order.DepositAmountCent) + action, err := r.prepareRefund(order, refundAmountCent, refundBizAdminSeal, "客服封存订单原路退款") if err != nil { return err } diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index c665714..cd53602 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -77,10 +77,12 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement func (r *Repository) prepareCheckoutRefund(order *model.RentalOrder, settlement checkoutSettlement) (*refundAction, error) { renterRefundTotalCent := settlement.RentRefundCent + settlement.DepositRefundCent - if renterRefundTotalCent <= 0 { + // 押金暂扣:若订单已暂扣,拦截本次结账中应退还租客的押金部分挂起不退,其余(租金退款)照常原路退。 + actualRefundCent := order.ApplyDepositHold(renterRefundTotalCent, settlement.DepositRefundCent) + if actualRefundCent <= 0 { return nil, nil } - return r.prepareRefund(order, renterRefundTotalCent, refundBizCheckout, "结账退款原路退还") + return r.prepareRefund(order, actualRefundCent, refundBizCheckout, "结账退款原路退还") } func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutSettlement) { diff --git a/backend/internal/modules/order/constants.go b/backend/internal/modules/order/constants.go index fb74197..eb0dd8f 100644 --- a/backend/internal/modules/order/constants.go +++ b/backend/internal/modules/order/constants.go @@ -1,5 +1,7 @@ package order +import "hfb_sys/backend/internal/model" + const ( orderStatusPendingPayment = "pending_payment" orderStatusPendingHandoff = "pending_handoff" @@ -66,4 +68,9 @@ const ( refundBizAdminSeal = "admin_seal_refund" refundBizAdmin = "admin_refund" refundBizCheckout = "checkout_refund" + refundBizDeposit = "deposit_refund" + + depositHoldStatusNone = model.DepositHoldStatusNone + depositHoldStatusHeld = model.DepositHoldStatusHeld + depositHoldStatusReleased = model.DepositHoldStatusReleased ) diff --git a/backend/internal/modules/order/deposit_hold.go b/backend/internal/modules/order/deposit_hold.go new file mode 100644 index 0000000..0cb9588 --- /dev/null +++ b/backend/internal/modules/order/deposit_hold.go @@ -0,0 +1,110 @@ +package order + +import ( + "context" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// AdminHoldDeposit 客服对进行中订单的押金进行暂扣。 +// 暂扣本身不移动任何资金:租客押金全程在支付渠道,系统只记录“暂扣中”标记; +// 待订单结算/取消/关闭/仲裁时,本应原路退还租客的押金部分会被拦截挂起, +// 后续由客服通过 AdminReleaseDeposit 手动原路归还。 +func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) 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 !canHoldDeposit(order) { + return ErrDepositCannotHold + } + beforeStatus := order.DepositHoldStatus + now := time.Now() + order.DepositHoldStatus = depositHoldStatusHeld + order.DepositHoldReason = req.Reason + order.DepositHeldBy = &adminID + order.DepositHeldAt = &now + order.DepositHoldReleasedAt = nil + if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{ + "order_id": order.ID, + "order_no": order.OrderNo, + "renter_id": order.RenterID, + "reason": req.Reason, + "before_hold_status": beforeStatus, + "after_hold_status": order.DepositHoldStatus, + "deposit_amount_cent": order.DepositAmountCent, + }); err != nil { + return err + } + return tx.Save(&order).Error + }) +} + +// AdminReleaseDeposit 客服手动归还此前暂扣的押金,通过支付渠道原路退回。 +// 仅支持归还,不支持扣款(系统内无平台资金池承接扣款)。 +func (r *Repository) AdminReleaseDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) 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.DepositHoldStatus != depositHoldStatusHeld { + return ErrDepositNotHeld + } + holdAmountCent := order.DepositHoldAmountCent + if holdAmountCent <= 0 { + return ErrDepositHoldAmountEmpty + } + now := time.Now() + order.DepositHoldStatus = depositHoldStatusReleased + order.DepositHoldReleasedAt = &now + action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回") + if err != nil { + return err + } + refund = action + if err := appendAuditLog(tx, adminID, "order.deposit_release", "order", order.ID, meta, map[string]any{ + "order_id": order.ID, + "order_no": order.OrderNo, + "renter_id": order.RenterID, + "reason": req.Reason, + "deposit_hold_amount_cent": holdAmountCent, + }); err != nil { + return err + } + return tx.Save(&order).Error + }) + if err != nil { + return err + } + r.startRefundBestEffort(ctx, refund) + return nil +} + +// canHoldDeposit 判断订单当前是否允许暂扣押金: +// 仅进行中(非终态、押金退款尚未发生)且有实付押金、且未处于暂扣态的订单。 +func canHoldDeposit(order model.RentalOrder) bool { + if order.DepositHoldStatus != depositHoldStatusNone { + return false + } + if order.DepositAmountCent <= 0 { + return false + } + switch order.Status { + case orderStatusPendingHandoff, + orderStatusRenting, + orderStatusOverdue, + orderStatusPendingCheckoutConfirm, + orderStatusPendingCheckoutAccept, + orderStatusCheckoutDisputing: + return true + default: + return false + } +} diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 3b706f2..6f1fc7f 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -38,6 +38,11 @@ type OrderDTO struct { SettlementStatus string `json:"settlement_status"` RefundStatus string `json:"refund_status,omitempty"` RefundAmountCent int64 `json:"refund_amount_cent"` + DepositHoldStatus string `json:"deposit_hold_status,omitempty"` + DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"` + DepositHoldReason string `json:"deposit_hold_reason,omitempty"` + DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"` + DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"` 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 a457f4b..b031a6c 100644 --- a/backend/internal/modules/order/handler_admin.go +++ b/backend/internal/modules/order/handler_admin.go @@ -74,6 +74,14 @@ func (h *Handler) AdminResetHandoff(c *gin.Context) { h.adminAction(c, h.service.AdminResetHandoff, gin.H{"reset": true}) } +func (h *Handler) AdminHoldDeposit(c *gin.Context) { + h.adminAction(c, h.service.AdminHoldDeposit, gin.H{"held": true}) +} + +func (h *Handler) AdminReleaseDeposit(c *gin.Context) { + h.adminAction(c, h.service.AdminReleaseDeposit, gin.H{"released": true}) +} + func (h *Handler) AdminRefund(c *gin.Context) { orderID, ok := parseID(c) if !ok { diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index a9aec55..add69c8 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -47,6 +47,12 @@ func writeOrderError(c *gin.Context, err error) { response.BadRequest(c, "结账金额不符合规则") case errors.Is(err, ErrPermissionDenied): response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单") + case errors.Is(err, ErrDepositCannotHold): + response.Error(c, http.StatusConflict, "deposit_cannot_hold", "当前订单不可暂扣押金") + case errors.Is(err, ErrDepositNotHeld): + response.Error(c, http.StatusConflict, "deposit_not_held", "该订单押金未处于暂扣状态") + case errors.Is(err, ErrDepositHoldAmountEmpty): + response.Error(c, http.StatusConflict, "deposit_hold_amount_empty", "暂扣押金尚未形成可归还金额") case IsNotFound(err): response.Error(c, http.StatusNotFound, "not_found", "订单不存在") default: diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 411e1b5..21eab22 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -58,6 +58,11 @@ func (row orderRow) toAdminDTO() OrderDTO { SettlementStatus: row.SettlementStatus, RefundStatus: row.RefundStatus, RefundAmountCent: row.RefundAmountCent, + DepositHoldStatus: row.DepositHoldStatus, + DepositHoldAmountCent: row.DepositHoldAmountCent, + DepositHoldReason: row.DepositHoldReason, + DepositHeldAt: row.DepositHeldAt, + DepositHoldReleasedAt: row.DepositHoldReleasedAt, AdminActions: adminActionsForOrder(row.RentalOrder), CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, @@ -67,6 +72,12 @@ func (row orderRow) toAdminDTO() OrderDTO { func (row orderRow) toDTOForUser(userID uint64) OrderDTO { dto := row.toAdminDTO() dto.AdminActions = nil + // 押金暂扣属于客服内部处置,不向租客/号主暴露。 + dto.DepositHoldStatus = "" + dto.DepositHoldAmountCent = 0 + dto.DepositHoldReason = "" + dto.DepositHeldAt = nil + dto.DepositHoldReleasedAt = nil applyOrderPriceView(&dto, row.RentalOrder, userID) return dto } diff --git a/backend/internal/modules/order/presenter_test.go b/backend/internal/modules/order/presenter_test.go new file mode 100644 index 0000000..7313d6a --- /dev/null +++ b/backend/internal/modules/order/presenter_test.go @@ -0,0 +1,41 @@ +package order + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/model" +) + +func TestOrderDTOForUserHidesDepositHoldFields(t *testing.T) { + now := time.Now() + row := orderRow{ + RentalOrder: model.RentalOrder{ + ID: 1, + OwnerID: 10, + RenterID: 20, + DepositHoldStatus: model.DepositHoldStatusHeld, + DepositHoldAmountCent: 1000, + DepositHoldReason: "风控复核", + DepositHeldAt: &now, + DepositHoldReleasedAt: &now, + }, + } + + dto := row.toDTOForUser(20) + if dto.DepositHoldStatus != "" { + t.Fatalf("deposit hold status = %q, want empty", dto.DepositHoldStatus) + } + if dto.DepositHoldAmountCent != 0 { + t.Fatalf("deposit hold amount = %d, want 0", dto.DepositHoldAmountCent) + } + if dto.DepositHoldReason != "" { + t.Fatalf("deposit hold reason = %q, want empty", dto.DepositHoldReason) + } + if dto.DepositHeldAt != nil { + t.Fatalf("deposit held at = %v, want nil", dto.DepositHeldAt) + } + if dto.DepositHoldReleasedAt != nil { + t.Fatalf("deposit hold released at = %v, want nil", dto.DepositHoldReleasedAt) + } +} diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index e089b9d..bc4020a 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -24,6 +24,9 @@ var ( ErrCheckoutCannotCounter = errors.New("checkout cannot counter") ErrInvalidCheckoutAmount = errors.New("invalid checkout amount") ErrPermissionDenied = errors.New("permission denied") + ErrDepositCannotHold = errors.New("deposit cannot hold") + ErrDepositNotHeld = errors.New("deposit not held") + ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty") ) const internalOrderHours = 24 @@ -216,6 +219,26 @@ func (s *Service) AdminResetHandoff(ctx context.Context, adminID uint64, orderID return s.repo.AdminResetHandoff(ctx, adminID, orderID, req, meta) } +func (s *Service) AdminHoldDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if orderID == 0 || req.Reason == "" { + return ErrDepositCannotHold + } + return s.repo.AdminHoldDeposit(ctx, adminID, orderID, req, meta) +} + +func (s *Service) AdminReleaseDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if orderID == 0 || req.Reason == "" { + return ErrDepositCannotHold + } + return s.repo.AdminReleaseDeposit(ctx, adminID, orderID, req, meta) +} + func (s *Service) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b10334d..28f296f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -543,6 +543,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { 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.POST("/orders/:id/deposit-hold", requirePerm("order:deposit_hold"), orderHandler.AdminHoldDeposit) + adminRoutes.POST("/orders/:id/deposit-release", requirePerm("order:deposit_hold"), orderHandler.AdminReleaseDeposit) adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund) // 管理员线下提号(独立于正常订单流程) diff --git a/backend/migrations/000022_order_deposit_hold.sql b/backend/migrations/000022_order_deposit_hold.sql new file mode 100644 index 0000000..6cca3df --- /dev/null +++ b/backend/migrations/000022_order_deposit_hold.sql @@ -0,0 +1,38 @@ +-- +goose Up + +-- 押金暂扣:客服可对进行中订单的押金退款进行暂扣,订单照常结算/关闭/仲裁, +-- 但本应原路退给租客的押金部分不自动退,挂起为“已暂扣”,后续由客服手动归还。 +ALTER TABLE rental_orders + ADD COLUMN deposit_hold_status VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '押金暂扣状态: none无/held已暂扣/released已归还' AFTER refunded_at, + ADD COLUMN deposit_hold_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '实际暂扣的押金金额(分),结算时确定' AFTER deposit_hold_status, + ADD COLUMN deposit_hold_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '暂扣原因' AFTER deposit_hold_amount_cent, + ADD COLUMN deposit_held_by BIGINT UNSIGNED NULL COMMENT '暂扣操作管理员' AFTER deposit_hold_reason, + ADD COLUMN deposit_held_at DATETIME NULL COMMENT '暂扣时间' AFTER deposit_held_by, + ADD COLUMN deposit_hold_released_at DATETIME NULL COMMENT '押金归还时间' AFTER deposit_held_at; + +CREATE INDEX idx_deposit_hold_status ON rental_orders (deposit_hold_status); + +INSERT INTO permissions (code, name, resource, action) +VALUES ('order:deposit_hold', '押金暂扣与归还', 'order', 'deposit_hold'); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code IN ('cs', 'ops') AND p.code = 'order:deposit_hold'; + +-- +goose Down + +DROP INDEX idx_deposit_hold_status ON rental_orders; + +ALTER TABLE rental_orders + DROP COLUMN deposit_hold_status, + DROP COLUMN deposit_hold_amount_cent, + DROP COLUMN deposit_hold_reason, + DROP COLUMN deposit_held_by, + DROP COLUMN deposit_held_at, + DROP COLUMN deposit_hold_released_at; + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'order:deposit_hold'; + +DELETE FROM permissions WHERE code = 'order:deposit_hold'; diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index c0b4a93..f508516 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -6,9 +6,11 @@ import { useRoute } from 'vue-router' import { adminCloseOrder, + adminHoldDeposit, adminMarkOrderAbnormal, adminRefundOrder, adminRefundStatus, + adminReleaseDeposit, adminResetHandoff, adminSealOrder, fetchAdminHandoffRecords, @@ -41,7 +43,15 @@ const submitting = ref(false) const order = ref(null) const handoffRecords = ref([]) const paymentRecords = ref([]) -const actionType = ref<'close' | 'seal' | 'abnormal' | 'reset' | ''>('') +type OrderActionType = + | 'close' + | 'seal' + | 'abnormal' + | 'reset' + | 'deposit_hold' + | 'deposit_release' + | '' +const actionType = ref('') const reason = ref('') const refundStatus = ref(null) @@ -88,16 +98,50 @@ const resetActionLabel = computed(() => { return resetAction.value?.label || '重置' }) const canResetHandoff = computed(() => resetAction.value?.enabled === true) +// 押金暂扣:仅进行中、有实付押金、且未暂扣过的订单可暂扣。 +const depositHoldStatus = computed(() => order.value?.deposit_hold_status || 'none') +const depositHoldAmountCent = computed(() => Number(order.value?.deposit_hold_amount_cent || 0)) +const isDepositHeld = computed(() => depositHoldStatus.value === 'held') +const canHoldDeposit = computed( + () => + canOperate.value && + depositHoldStatus.value === 'none' && + Number(order.value?.deposit_amount_cent || 0) > 0 +) +const canReleaseDeposit = computed(() => isDepositHeld.value && depositHoldAmountCent.value > 0) +const depositHoldStatusLabel = computed(() => { + switch (depositHoldStatus.value) { + case 'held': + return '押金已暂扣' + case 'released': + return '押金已归还' + default: + return '' + } +}) const actionTitle = computed(() => { if (actionType.value === 'close') return '客服关闭订单' if (actionType.value === 'seal') return '封存订单' if (actionType.value === 'reset') return `${resetActionLabel.value}(恢复到对应待办)` + if (actionType.value === 'deposit_hold') return '暂扣押金' + if (actionType.value === 'deposit_release') return '归还暂扣押金' return '标记订单异常' }) -const closeActionTip = '关闭订单并归档商品/账号;已支付订单会按租金+实付押金全量原路退款。' +const actionConfirmButtonType = computed(() => + actionType.value === 'deposit_release' ? 'primary' : 'danger' +) +const closeActionTip = + '关闭订单并归档商品/账号;已支付订单会原路退款,押金已暂扣时押金部分会继续挂起。' const sealActionTip = - '封存订单:终止订单并全量原路退款,自动解散关联群聊,关联商品永久封存,号主无法再次编辑或上架。适用于号主失联、无法联系的场景。' -const refundActionTip = '仅发起后台人工全量原路退款,不关闭订单或调整商品/账号状态。' + '封存订单:终止订单并原路退款,自动解散关联群聊,关联商品永久封存;押金已暂扣时押金部分会继续挂起。' +const depositHoldActionTip = '暂扣后订单继续流转;后续本应退给租客的押金会挂起,需客服手动归还。' +const depositReleaseActionTip = computed(() => + depositHoldAmountCent.value > 0 + ? `将已暂扣的 ${moneyCent(depositHoldAmountCent.value)} 押金原路归还租客。` + : '暂扣金额为 0,需订单结算/关闭/仲裁产生暂扣金额后才能归还。' +) +const refundActionTip = + '仅发起后台人工原路退款,不关闭订单或调整商品/账号状态;押金已暂扣时仅退非暂扣部分。' const refundButtonDisabled = computed(() => refundStatus.value?.refund_status === 'refunded') const orderTotalCent = computed( () => Number(order.value?.rent_amount_cent || 0) + Number(order.value?.deposit_amount_cent || 0) @@ -146,9 +190,7 @@ const ownerLossPriceCent = computed(() => { const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== null) const fundSplitRows = computed(() => { if (!order.value) return [] - const rows: FundSplitRow[] = [ - { label: '租客租金', amountCent: order.value.rent_amount_cent }, - ] + const rows: FundSplitRow[] = [{ label: '租客租金', amountCent: order.value.rent_amount_cent }] if (hasOwnerRentBreakdown.value) { rows.push( { label: '号主纯币价格', amountCent: ownerCoinBasePriceCent.value }, @@ -205,7 +247,7 @@ async function loadRefundStatus() { } } -function openAction(type: 'close' | 'seal' | 'abnormal' | 'reset') { +function openAction(type: Exclude) { actionType.value = type reason.value = '' } @@ -214,7 +256,7 @@ async function confirmCloseAction() { if (!order.value || !canOperate.value) return try { await ElMessageBox.confirm( - '客服关闭会终止订单,归档关联商品和账号;若订单已支付,将按租金加实付押金发起全量原路退款。确认继续?', + '客服关闭会终止订单,归档关联商品和账号;若订单已支付,将发起原路退款。若押金已暂扣,押金部分会继续挂起。确认继续?', '确认客服关闭', { confirmButtonText: '继续关闭', @@ -232,7 +274,7 @@ async function confirmSealAction() { if (!order.value || !canOperate.value) return try { await ElMessageBox.confirm( - '封存会终止订单并全量原路退款,自动解散关联群聊,关联商品将被永久封存,号主无法再次编辑或上架。适用于号主失联场景。确认继续?', + '封存会终止订单并发起原路退款,自动解散关联群聊,关联商品将被永久封存。若押金已暂扣,押金部分会继续挂起。确认继续?', '确认封存订单', { confirmButtonText: '继续封存', @@ -246,6 +288,46 @@ async function confirmSealAction() { openAction('seal') } +async function confirmHoldDeposit() { + if (!order.value || !canHoldDeposit.value) return + try { + await ElMessageBox.confirm( + '暂扣后订单继续流转;后续结算、关闭或仲裁中本应退给租客的押金会被挂起,需客服手动归还。确认继续?', + '确认暂扣押金', + { + confirmButtonText: '继续暂扣', + cancelButtonText: '取消', + type: 'warning', + } + ) + } catch { + return + } + openAction('deposit_hold') +} + +async function confirmReleaseDeposit() { + if (!order.value || !isDepositHeld.value) return + if (!canReleaseDeposit.value) { + ElMessage.warning('暂扣押金尚未形成可归还金额') + return + } + try { + await ElMessageBox.confirm( + `将已暂扣的 ${moneyCent(depositHoldAmountCent.value)} 押金原路归还租客。确认继续?`, + '确认归还押金', + { + confirmButtonText: '继续归还', + cancelButtonText: '取消', + type: 'warning', + } + ) + } catch { + return + } + openAction('deposit_release') +} + async function submitAction() { if (!order.value || !actionType.value) return submitting.value = true @@ -259,6 +341,12 @@ async function submitAction() { } else if (actionType.value === 'reset') { await adminResetHandoff(order.value.id, reason.value) ElMessage.success(`${resetActionLabel.value}成功`) + } else if (actionType.value === 'deposit_hold') { + await adminHoldDeposit(order.value.id, reason.value) + ElMessage.success('押金已暂扣') + } else if (actionType.value === 'deposit_release') { + await adminReleaseDeposit(order.value.id, reason.value) + ElMessage.success('暂扣押金已发起归还') } else { await adminMarkOrderAbnormal(order.value.id, reason.value) ElMessage.success('订单已标记异常') @@ -306,7 +394,9 @@ async function confirmRefund() { ) const message = hasRefundInProgress ? '当前订单已有退款处理中,继续人工退款可能导致重复全量退款。确认仍要发起?' - : '人工退款只会发起全量原路退款,不会关闭订单或调整商品状态。请确认该订单确实需要补发全额退款。' + : isDepositHeld.value + ? '人工退款不会关闭订单或调整商品状态;当前押金已暂扣,本次仅退非暂扣部分。确认继续?' + : '人工退款只会发起原路退款,不会关闭订单或调整商品状态。请确认该订单确实需要补发退款。' try { await ElMessageBox.confirm(message, '确认人工退款', { confirmButtonText: '确认退款', @@ -346,6 +436,8 @@ function paymentBizTypeLabel(type: string) { admin_refund: '人工退款', cancel_refund: '取消退款', admin_close_refund: '客服关闭退款', + admin_seal_refund: '封存退款', + deposit_refund: '暂扣押金归还', } return map[type] || type } @@ -499,6 +591,23 @@ function paymentPaidAt(record: AdminPayment) { > + + + 暂扣押金 + + + + + + 归还押金 + + + 支付流水
{{ paymentRecords.length }} 条
+
+
押金暂扣
+
{{ depositHoldStatusLabel || '未暂扣' }}
+
+
+
已暂扣金额
+
{{ moneyCent(depositHoldAmountCent) }}
+
+
+
暂扣原因
+
{{ order.deposit_hold_reason }}
+
+
+
暂扣时间
+
{{ formatDateTime(order.deposit_held_at) }}
+
+
+
归还时间
+
{{ formatDateTime(order.deposit_hold_released_at) }}
+
@@ -816,10 +945,15 @@ function paymentPaidAt(record: AdminPayment) {
{{ item.value }}
-
+
回收比例 - 1:{{ formatRatioNumber(snapshotRatio.recycleRatio) }} + 1:{{ formatRatioNumber(snapshotRatio.recycleRatio) }}
售卖比例 @@ -860,7 +994,9 @@ function paymentPaidAt(record: AdminPayment) {
diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 8325d55..30865d9 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -36,6 +36,11 @@ export interface Order { settlement_status: SettlementStatus refund_status?: string refund_amount_cent?: number + deposit_hold_status?: string + deposit_hold_amount_cent?: number + deposit_hold_reason?: string + deposit_held_at?: string + deposit_hold_released_at?: string active_dispute?: ActiveDispute checkout?: Checkout admin_actions?: AdminActions @@ -387,6 +392,22 @@ export async function adminRejectRefund(id: number, action: 'restore' | 'close') return data.data } +export async function adminHoldDeposit(id: number, reason: string) { + const { data } = await apiClient.post>( + `/admin/orders/${id}/deposit-hold`, + { reason } + ) + return data.data +} + +export async function adminReleaseDeposit(id: number, reason: string) { + const { data } = await apiClient.post>( + `/admin/orders/${id}/deposit-release`, + { reason } + ) + return data.data +} + export async function fetchPendingRefundOrders() { const { data } = await apiClient.get>( '/admin/orders/refund-pending'