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>
This commit is contained in:
yml2213
2026-06-29 22:52:20 +08:00
co-authored by Claude Opus 4.8
parent 8a11c2517e
commit be5d322c10
16 changed files with 217 additions and 4 deletions
@@ -440,6 +440,100 @@ func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, acti
})
}
// 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
}