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 }