添加订单押金暂扣与归还

This commit is contained in:
yml2213
2026-07-05 19:35:57 +08:00
parent 9ef0843eb0
commit 090a90a268
17 changed files with 587 additions and 36 deletions
+48
View File
@@ -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
}
+49
View File
@@ -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)
}
}
@@ -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":
+54 -18
View File
@@ -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
}
@@ -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) {
@@ -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
)
@@ -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
}
}
+5
View File
@@ -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"`
@@ -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 {
@@ -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:
@@ -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
}
@@ -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)
}
}
+23
View File
@@ -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
+2
View File
@@ -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)
// 管理员线下提号(独立于正常订单流程)
@@ -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';
@@ -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<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const paymentRecords = ref<AdminPayment[]>([])
const actionType = ref<'close' | 'seal' | 'abnormal' | 'reset' | ''>('')
type OrderActionType =
| 'close'
| 'seal'
| 'abnormal'
| 'reset'
| 'deposit_hold'
| 'deposit_release'
| ''
const actionType = ref<OrderActionType>('')
const reason = ref('')
const refundStatus = ref<RefundStatus | null>(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<OrderActionType, ''>) {
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) {
>
</span>
</el-tooltip>
<el-tooltip v-if="canHoldDeposit" :content="depositHoldActionTip" placement="top">
<span>
<el-button type="warning" plain @click="confirmHoldDeposit"> 暂扣押金 </el-button>
</span>
</el-tooltip>
<el-tooltip v-if="isDepositHeld" :content="depositReleaseActionTip" placement="top">
<span>
<el-button
type="success"
plain
:disabled="!canReleaseDeposit"
@click="confirmReleaseDeposit"
>
归还押金
</el-button>
</span>
</el-tooltip>
<el-tooltip :content="refundActionTip" placement="top">
<span>
<el-button
@@ -670,6 +779,26 @@ function paymentPaidAt(record: AdminPayment) {
<dt>支付流水</dt>
<dd>{{ paymentRecords.length }} </dd>
</div>
<div>
<dt>押金暂扣</dt>
<dd>{{ depositHoldStatusLabel || '未暂扣' }}</dd>
</div>
<div v-if="depositHoldStatus !== 'none'">
<dt>已暂扣金额</dt>
<dd>{{ moneyCent(depositHoldAmountCent) }}</dd>
</div>
<div v-if="order.deposit_hold_reason" class="wide">
<dt>暂扣原因</dt>
<dd>{{ order.deposit_hold_reason }}</dd>
</div>
<div v-if="order.deposit_held_at">
<dt>暂扣时间</dt>
<dd>{{ formatDateTime(order.deposit_held_at) }}</dd>
</div>
<div v-if="order.deposit_hold_released_at">
<dt>归还时间</dt>
<dd>{{ formatDateTime(order.deposit_hold_released_at) }}</dd>
</div>
</dl>
</section>
@@ -816,10 +945,15 @@ function paymentPaidAt(record: AdminPayment) {
<dd>{{ item.value }}</dd>
</div>
</dl>
<div v-if="snapshotRatio && (snapshotRatio.recycleRatio > 0 || snapshotRatio.sellRatio > 0)" class="ratio-row">
<div
v-if="snapshotRatio && (snapshotRatio.recycleRatio > 0 || snapshotRatio.sellRatio > 0)"
class="ratio-row"
>
<div v-if="snapshotRatio.recycleRatio > 0" class="ratio-item">
<span class="ratio-label">回收比例</span>
<strong class="ratio-value">1:{{ formatRatioNumber(snapshotRatio.recycleRatio) }}</strong>
<strong class="ratio-value"
>1:{{ formatRatioNumber(snapshotRatio.recycleRatio) }}</strong
>
</div>
<div v-if="snapshotRatio.sellRatio > 0" class="ratio-item">
<span class="ratio-label">售卖比例</span>
@@ -860,7 +994,9 @@ function paymentPaidAt(record: AdminPayment) {
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
<el-button :type="actionConfirmButtonType" :loading="submitting" @click="submitAction">
确认操作
</el-button>
</template>
</el-dialog>
</section>
@@ -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<ApiResponse<{ held: boolean }>>(
`/admin/orders/${id}/deposit-hold`,
{ reason }
)
return data.data
}
export async function adminReleaseDeposit(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<{ released: boolean }>>(
`/admin/orders/${id}/deposit-release`,
{ reason }
)
return data.data
}
export async function fetchPendingRefundOrders() {
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>(
'/admin/orders/refund-pending'