Files
hfb_sys/backend/internal/modules/order/admin_actions.go
T
yml2213andClaude Opus 4.8 be5d322c10 feat: 客服封存订单功能(号主失联场景)
新增客服「封存订单」动作,用于号主失联、无法联系的场景:单事务内
完成关闭订单、全量原路退款、永久封存关联商品、解散发布群、双方通知
与审计日志。

- 新增 listing 终态 sealed(封存),区别于可恢复的 offline
- listingLockedForOwnerMutation 锁定 sealed,号主无法再编辑/提审/上架
- 解散群聊按 listing_id 查发布群(listing_group)置 archived,
  阻断所有成员发言并留系统消息
- 新增 order.AdminSeal 及 /admin/orders/:id/seal 路由
- 前端:卖家页 PC/移动端 sealed 视为终态禁用操作,客服后台新增
  「封存订单」按钮与二次确认

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:52:20 +08:00

540 lines
18 KiB
Go

package order
import (
"context"
"errors"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *Repository) AdminClose(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 {
assets, err := r.lockOrderAssets(tx, orderID)
if err != nil {
return err
}
order := assets.Order
listing := assets.Listing
account := assets.Account
if isTerminalStatus(order.Status) {
return ErrOrderCannotComplete
}
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
beforeSettlementStatus := order.SettlementStatus
beforeListingStatus := listing.Status
beforeAccountStatus := account.Status
now := time.Now()
order.Status = orderStatusClosed
order.HandoffStatus = handoffStatusAdminClosed
order.SettlementStatus = settlementStatusClosed
order.SettledAt = &now
archiveAssets(listing, account)
if beforeOrderStatus != orderStatusPendingPayment {
totalCent := order.RentAmountCent + order.DepositAmountCent
action, err := r.prepareRefund(order, totalCent, refundBizAdminClose, "客服关闭订单原路退款")
if err != nil {
return err
}
refund = action
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "order_admin",
Title: "订单已由客服关闭",
Content: "客服已关闭订单,退款将原路退回您的支付账户。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "order_admin",
Title: "订单已由客服关闭",
Content: "客服已关闭订单,关联商品已下架。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.admin_close", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"listing_id": order.ListingID,
"account_id": order.AccountID,
"reason": req.Reason,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
"before_settlement_status": beforeSettlementStatus,
"after_settlement_status": order.SettlementStatus,
"before_listing_status": beforeListingStatus,
"after_listing_status": listing.Status,
"before_account_status": beforeAccountStatus,
"after_account_status": account.Status,
}); err != nil {
return err
}
if err := tx.Save(order).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
return tx.Save(account).Error
})
if err != nil {
return err
}
r.startRefundBestEffort(ctx, refund)
return nil
}
func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
assets, err := r.lockOrderAssets(tx, orderID)
if err != nil {
return err
}
order := assets.Order
listing := assets.Listing
account := assets.Account
if isTerminalStatus(order.Status) {
return ErrOrderCannotComplete
}
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
beforeListingStatus := listing.Status
beforeAccountStatus := account.Status
order.Status = orderStatusAbnormal
order.HandoffStatus = handoffStatusAdminAbnormal
markAssetsAbnormal(listing, account)
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "order_admin",
Title: "订单已被标记异常",
Content: "客服已将订单标记为异常,请等待进一步处理。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "order_admin",
Title: "订单已被标记异常",
Content: "客服已将订单标记为异常,关联商品暂不可出租。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.mark_abnormal", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"listing_id": order.ListingID,
"account_id": order.AccountID,
"reason": req.Reason,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
"before_listing_status": beforeListingStatus,
"after_listing_status": listing.Status,
"before_account_status": beforeAccountStatus,
"after_account_status": account.Status,
}); err != nil {
return err
}
if err := tx.Save(order).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
return tx.Save(account).Error
})
}
type adminResetTarget struct {
OrderStatus string
HandoffStatus string
Label string
OwnerTitle string
OwnerContent string
RenterTitle string
RenterContent string
AuditAction string
RefreshStageTime bool
RecalculateEnd bool
}
// AdminResetHandoff 将超时卡住的订单恢复到对应待办阶段,并刷新阶段计时,
// 让客服可以给责任方再次处理机会,避免订单卡死在异常状态。
func (r *Repository) AdminResetHandoff(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
}
target, ok := resetTargetForOrder(order)
if !ok {
return ErrOrderCannotResetHandoff
}
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
now := time.Now()
order.Status = target.OrderStatus
order.HandoffStatus = target.HandoffStatus
if target.RefreshStageTime {
order.HandoffStartedAt = &now
}
if target.RecalculateEnd {
durationHours := estimateOrderDurationHours(order.AccountSnapshot)
if durationHours > 0 {
order.EstimatedDurationHours = durationHours
}
if order.RentedAt == nil {
order.RentedAt = &now
}
if order.RentedAt != nil {
estimatedEndAt := order.RentedAt.Add(time.Duration(order.EstimatedDurationHours) * time.Hour)
if !estimatedEndAt.After(now) {
order.RentedAt = &now
}
}
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.OwnerID,
Type: "order_admin",
Title: target.OwnerTitle,
Content: target.OwnerContent + "原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
notification.Entry{
UserID: order.RenterID,
Type: "order_admin",
Title: target.RenterTitle,
Content: target.RenterContent + "原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, target.AuditAction, "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"reason": req.Reason,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
}); err != nil {
return err
}
return tx.Save(&order).Error
})
}
func resetTargetForOrder(order model.RentalOrder) (adminResetTarget, bool) {
if order.Status == orderStatusPendingHandoff && order.HandoffStatus == handoffStatusOwnerTimeout {
return adminResetTarget{
OrderStatus: orderStatusPendingHandoff,
HandoffStatus: handoffStatusPendingOwner,
Label: "重置交接",
OwnerTitle: "请重新提交交接说明",
OwnerContent: "客服已重置交接状态,请尽快提交交接说明,避免再次超时。",
RenterTitle: "订单交接已重置",
RenterContent: "客服已重置交接状态,正在等待号主重新提交交接说明。",
AuditAction: "order.reset_handoff",
RefreshStageTime: true,
}, true
}
if order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout {
return adminResetTarget{
OrderStatus: orderStatusPendingHandoff,
HandoffStatus: handoffStatusPendingRenterConfirm,
Label: "重置确认收号",
OwnerTitle: "订单确认收号已重置",
OwnerContent: "客服已重置确认收号状态,正在等待租客重新确认收号。",
RenterTitle: "请重新确认收号",
RenterContent: "客服已重置确认收号状态,请尽快检查账号并确认收号,避免再次超时。",
AuditAction: "order.reset_renter_confirm",
RefreshStageTime: true,
}, true
}
if order.Status == orderStatusAbnormal &&
(order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout ||
order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout) {
return adminResetTarget{
OrderStatus: orderStatusPendingCheckoutConfirm,
HandoffStatus: handoffStatusPendingOwnerCheckout,
Label: "重置结账确认",
OwnerTitle: "请重新确认结账",
OwnerContent: "客服已重置结账确认状态,请尽快核对账号和结账金额,避免再次超时。",
RenterTitle: "结账确认已重置",
RenterContent: "客服已重置结账确认状态,正在等待号主重新确认结账。",
AuditAction: "order.reset_checkout_confirm",
RefreshStageTime: true,
}, true
}
if order.Status == orderStatusOverdue && order.HandoffStatus == handoffStatusReturnOverdue {
return adminResetTarget{
OrderStatus: orderStatusRenting,
HandoffStatus: handoffStatusReceived,
Label: "重置归还逾期",
OwnerTitle: "订单归还逾期已重置",
OwnerContent: "客服已重置归还逾期状态,订单恢复为使用中。",
RenterTitle: "请及时发起结账",
RenterContent: "客服已重置归还逾期状态,请尽快发起结账。",
AuditAction: "order.reset_return_overdue",
RecalculateEnd: true,
}, true
}
return adminResetTarget{}, false
}
// 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, "后台人工退款")
if err != nil {
return nil, err
}
// 重新读取订单,拿到 payment 模块更新后的退款字段。
if err := db.First(&order, orderID).Error; err != nil {
return nil, err
}
dto := r.buildRefundStatusDTO(&order)
if status != "" {
dto.RefundStatus = status
}
return dto, nil
}
// AdminRefundStatus 查询订单退款状态。
func (r *Repository) AdminRefundStatus(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) {
var order model.RentalOrder
if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil {
return nil, err
}
return r.buildRefundStatusDTO(&order), nil
}
func (r *Repository) buildRefundStatusDTO(order *model.RentalOrder) *RefundStatusDTO {
return &RefundStatusDTO{
OrderID: order.ID,
OrderNo: order.OrderNo,
RefundStatus: order.RefundStatus,
RefundAmountCent: order.RefundAmountCent,
RefundedAt: order.RefundedAt,
TotalAmountCent: order.RentAmountCent + order.DepositAmountCent,
}
}
// 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 客服驳回退款申请。
// action: "restore" 恢复订单至待交接继续流转;"close" 关闭订单并归档商品。
func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, action string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
assets, err := r.lockOrderAssets(tx, orderID)
if err != nil {
return err
}
order := assets.Order
listing := assets.Listing
account := assets.Account
if order.RefundStatus != refundStatusPendingReview {
return errors.New("订单不处于待审核退款状态")
}
order.RefundStatus = "none"
order.RefundAmountCent = 0
switch action {
case "restore":
if order.Status != orderStatusCancelled {
return errors.New("仅已取消的订单可以恢复")
}
order.Status = orderStatusPendingHandoff
if order.HandoffStatus == handoffStatusCancelled || order.HandoffStatus == handoffStatusRenterConfirmTimeout {
order.HandoffStatus = handoffStatusPendingOwner
}
order.SettlementStatus = settlementStatusUnsettled
order.SettledAt = nil
reserveListingForOrder(listing)
markAssetsRented(listing, account)
case "close":
now := time.Now()
order.Status = orderStatusClosed
order.HandoffStatus = handoffStatusAdminClosed
order.SettlementStatus = settlementStatusClosed
order.SettledAt = &now
archiveAssets(listing, account)
default:
return errors.New("无效的驳回操作,可选 restore 或 close")
}
if err := tx.Save(order).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
return tx.Save(account).Error
})
}
// AdminSeal 客服封存订单:关闭订单并全额原路退款,将关联商品/账号置为终态 sealed
// (号主不可再编辑、提审、上架),同时在同一事务内解散关联群聊。
// 适用于号主失联、无法继续交接,需要终止并永久封存该商品的场景。
func (r *Repository) AdminSeal(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 {
assets, err := r.lockOrderAssets(tx, orderID)
if err != nil {
return err
}
order := assets.Order
listing := assets.Listing
account := assets.Account
if isTerminalStatus(order.Status) {
return ErrOrderCannotComplete
}
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
beforeSettlementStatus := order.SettlementStatus
beforeListingStatus := listing.Status
beforeAccountStatus := account.Status
now := time.Now()
order.Status = orderStatusClosed
order.HandoffStatus = handoffStatusAdminClosed
order.SettlementStatus = settlementStatusClosed
order.SettledAt = &now
sealAssets(listing, account)
if beforeOrderStatus != orderStatusPendingPayment {
totalCent := order.RentAmountCent + order.DepositAmountCent
action, err := r.prepareRefund(order, totalCent, refundBizAdminSeal, "客服封存订单原路退款")
if err != nil {
return err
}
refund = action
}
if r.chatArchiver != nil {
if err := r.chatArchiver.ArchiveListingConversation(tx, listing.ID, req.Reason); err != nil {
return err
}
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "order_admin",
Title: "订单已由客服封存",
Content: "客服已封存订单,退款将原路退回您的支付账户。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "order_admin",
Title: "订单已由客服封存",
Content: "客服已封存订单,关联商品已永久封存,不可再次编辑或上架。原因:" + req.Reason,
BizType: "order",
BizID: &order.ID,
},
); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.admin_seal", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"listing_id": order.ListingID,
"account_id": order.AccountID,
"reason": req.Reason,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
"before_settlement_status": beforeSettlementStatus,
"after_settlement_status": order.SettlementStatus,
"before_listing_status": beforeListingStatus,
"after_listing_status": listing.Status,
"before_account_status": beforeAccountStatus,
"after_account_status": account.Status,
}); err != nil {
return err
}
if err := tx.Save(order).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
return tx.Save(account).Error
})
if err != nil {
return err
}
r.startRefundBestEffort(ctx, refund)
return nil
}
func isTerminalStatus(status string) bool {
return status == orderStatusCompleted || status == orderStatusCancelled || status == orderStatusClosed
}