新增客服一键交接功能

This commit is contained in:
yml2213
2026-08-15 17:27:35 +08:00
parent d9f552577a
commit 4c1fe7439d
12 changed files with 437 additions and 1 deletions
+7
View File
@@ -80,6 +80,7 @@ type OrderDTO struct {
type AdminActionsDTO struct {
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
ForceHandoff *AdminActionDTO `json:"force_handoff,omitempty"`
PlatformCheckoutConfirm *AdminActionDTO `json:"platform_checkout_confirm,omitempty"`
PlatformCheckoutCounter *AdminActionDTO `json:"platform_checkout_counter,omitempty"`
PlatformCheckoutDispute *AdminActionDTO `json:"platform_checkout_dispute,omitempty"`
@@ -138,6 +139,12 @@ type PlatformHandoffRequest struct {
Reason string `json:"reason" binding:"required"`
}
// ForceHandoffRequest 客服确认普通号主订单已完成线下交接。
type ForceHandoffRequest struct {
Content string `json:"content"`
Reason string `json:"reason" binding:"required"`
}
type OfflineSettlementRequest struct {
Remark string `json:"remark"`
}
@@ -0,0 +1,125 @@
package order
import (
"context"
"strings"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func canAdminForceHandoff(order model.RentalOrder) bool {
if isPlatformHandoffOrder(order) || isPlatformSettlementOrder(order) {
return false
}
if order.Status == orderStatusPendingHandoff {
return order.HandoffStatus == handoffStatusPendingOwner ||
order.HandoffStatus == handoffStatusOwnerTimeout ||
order.HandoffStatus == handoffStatusPendingRenterConfirm
}
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout
}
func forceHandoffNeedsContent(order model.RentalOrder) bool {
return order.HandoffStatus == handoffStatusPendingOwner || order.HandoffStatus == handoffStatusOwnerTimeout
}
// AdminForceHandoff 由客服确认普通号主订单已完成线下交接,并立即开始租期。
func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, 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 !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" {
return ErrOrderCannotForceHandoff
}
if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview {
return ErrOrderCannotForceHandoff
}
var activeDisputeCount int64
if err := tx.Model(&model.Dispute{}).
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
Count(&activeDisputeCount).Error; err != nil {
return err
}
if activeDisputeCount > 0 {
return ErrOrderCannotForceHandoff
}
needsContent := forceHandoffNeedsContent(order)
content := strings.TrimSpace(req.Content)
if needsContent && content == "" {
return ErrOrderCannotForceHandoff
}
if !needsContent {
var handoffCount int64
if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type = ?", order.ID, "owner_handoff").
Count(&handoffCount).Error; err != nil {
return err
}
if handoffCount == 0 {
return ErrOrderCannotForceHandoff
}
content = "客服已确认双方完成交接,订单已进入使用中。"
}
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
now := time.Now()
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: adminID,
ToUserID: order.RenterID,
Type: "admin_force_handoff",
Content: content,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
order.Status = orderStatusRenting
order.HandoffStatus = handoffStatusReceived
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
order.RentedAt = &now
orderID := order.ID
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "order_admin",
Title: "客服已确认交接",
Content: "客服已确认双方完成交接,订单已进入使用中,租期已开始计算。",
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "order_admin",
Title: "客服已确认交接",
Content: "客服已确认双方完成交接,订单已进入使用中,租期已开始计算。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.force_handoff", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"reason": strings.TrimSpace(req.Reason),
"content_length": len(content),
"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
})
}
@@ -99,6 +99,28 @@ func (h *Handler) AdminPlatformHandoff(c *gin.Context) {
response.Created(c, record)
}
func (h *Handler) AdminForceHandoff(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req ForceHandoffRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "操作原因不能为空")
return
}
if err := h.service.AdminForceHandoff(c.Request.Context(), adminID, id, req, auditMeta(c)); err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"received": true})
}
func (h *Handler) AdminPlatformCheckoutConfirm(c *gin.Context) {
h.adminAction(c, h.service.AdminPlatformCheckoutConfirm, gin.H{"confirmed": true})
}
@@ -30,6 +30,8 @@ func writeOrderError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "order_cannot_cancel", "当前订单不能取消")
case errors.Is(err, ErrOrderCannotHandoff):
response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接")
case errors.Is(err, ErrOrderCannotForceHandoff):
response.Error(c, http.StatusConflict, "order_cannot_force_handoff", "当前订单不能客服一键交接")
case errors.Is(err, ErrOrderCannotResetHandoff):
response.Error(c, http.StatusConflict, "order_cannot_reset_handoff", "仅超时卡住的订单可重置到对应待办阶段")
case errors.Is(err, ErrOrderCannotReceive):
+7 -1
View File
@@ -23,6 +23,12 @@ func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO {
Label: "客服代交接",
}
}
if canAdminForceHandoff(order) {
actions.ForceHandoff = &AdminActionDTO{
Enabled: true,
Label: "客服一键交接",
}
}
if canAdminPlatformCheckoutConfirm(order) {
actions.PlatformCheckoutConfirm = &AdminActionDTO{
Enabled: true,
@@ -43,7 +49,7 @@ func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO {
Label: "确认线下结算",
}
}
if actions.ResetHandoff == nil && actions.PlatformHandoff == nil &&
if actions.ResetHandoff == nil && actions.PlatformHandoff == nil && actions.ForceHandoff == nil &&
actions.PlatformCheckoutConfirm == nil && actions.PlatformCheckoutCounter == nil &&
actions.PlatformCheckoutDispute == nil && actions.PlatformOfflineSettlement == nil {
return nil
@@ -1011,6 +1011,155 @@ func TestAdminResetRenterConfirmTimeoutRefreshesStageTime(t *testing.T) {
}
}
func TestAdminForceHandoffStartsNormalOwnerOrder(t *testing.T) {
db := setupOrderTestDB(t)
if err := db.AutoMigrate(&model.Dispute{}); err != nil {
t.Fatalf("migrate disputes failed: %v", err)
}
repo := NewRepository(db)
owner := model.User{Phone: "13800003006"}
renter := model.User{Phone: "13900003006"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("create owner failed: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("create renter failed: %v", err)
}
order := model.RentalOrder{
OrderNo: "ORD-FORCE-HANDOFF-001",
ListingID: 1,
AccountID: 1,
OwnerID: owner.ID,
RenterID: renter.ID,
Status: orderStatusPendingHandoff,
HandoffStatus: handoffStatusPendingOwner,
HandoffMode: handoffModeOwner,
AccountSnapshot: datatypes.JSON([]byte(`{
"haf_coin_amount": 100000000,
"daily_loss_m": 50
}`)),
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
err := repo.AdminForceHandoff(t.Context(), 99, order.ID, ForceHandoffRequest{
Content: "账号已通过群聊完成交接",
Reason: "已核实双方均可正常使用",
}, AuditMeta{})
if err != nil {
t.Fatalf("AdminForceHandoff() error = %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("load order failed: %v", err)
}
if saved.Status != orderStatusRenting || saved.HandoffStatus != handoffStatusReceived {
t.Fatalf("status = %s/%s, want renting/received", saved.Status, saved.HandoffStatus)
}
if saved.RentedAt == nil || saved.EstimatedDurationHours != 48 {
t.Fatalf("rented at/duration = %#v/%d, want set/48", saved.RentedAt, saved.EstimatedDurationHours)
}
var record model.HandoffRecord
if err := db.Where("order_id = ? AND type = ?", order.ID, "admin_force_handoff").First(&record).Error; err != nil {
t.Fatalf("find force handoff record failed: %v", err)
}
if record.Content != "账号已通过群聊完成交接" {
t.Fatalf("record content = %q", record.Content)
}
var notificationCount int64
if err := db.Model(&model.Notification{}).Where("biz_type = ? AND biz_id = ?", "order", order.ID).Count(&notificationCount).Error; err != nil {
t.Fatalf("count notifications failed: %v", err)
}
if notificationCount != 2 {
t.Fatalf("notification count = %d, want 2", notificationCount)
}
}
func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrder(t *testing.T) {
db := setupOrderTestDB(t)
if err := db.AutoMigrate(&model.Dispute{}); err != nil {
t.Fatalf("migrate disputes failed: %v", err)
}
repo := NewRepository(db)
owner := model.User{Phone: "13800003007"}
renter := model.User{Phone: "13900003007"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("create owner failed: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("create renter failed: %v", err)
}
order := model.RentalOrder{
OrderNo: "ORD-FORCE-HANDOFF-002",
ListingID: 2,
AccountID: 2,
OwnerID: owner.ID,
RenterID: renter.ID,
Status: orderStatusAbnormal,
HandoffStatus: handoffStatusRenterConfirmTimeout,
HandoffMode: handoffModeOwner,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
ownerRecord := model.HandoffRecord{
OrderID: order.ID,
FromUserID: owner.ID,
ToUserID: renter.ID,
Type: "owner_handoff",
Content: "号主已提交交接说明",
}
if err := db.Create(&ownerRecord).Error; err != nil {
t.Fatalf("create owner handoff record failed: %v", err)
}
err := repo.AdminForceHandoff(t.Context(), 99, order.ID, ForceHandoffRequest{
Reason: "已联系双方确认完成交接",
}, AuditMeta{})
if err != nil {
t.Fatalf("AdminForceHandoff() error = %v", err)
}
if err := db.First(&order, order.ID).Error; err != nil {
t.Fatalf("load order failed: %v", err)
}
if order.Status != orderStatusRenting || order.HandoffStatus != handoffStatusReceived {
t.Fatalf("status = %s/%s, want renting/received", order.Status, order.HandoffStatus)
}
var savedOwnerRecord model.HandoffRecord
if err := db.First(&savedOwnerRecord, ownerRecord.ID).Error; err != nil {
t.Fatalf("load owner handoff record failed: %v", err)
}
if savedOwnerRecord.ConfirmedByRenterAt != nil {
t.Fatal("owner handoff record must not be marked as renter confirmed")
}
platformOrder := model.RentalOrder{
OrderNo: "ORD-FORCE-HANDOFF-003",
ListingID: 3,
AccountID: 3,
OwnerID: owner.ID,
RenterID: renter.ID,
Status: orderStatusPendingHandoff,
HandoffStatus: handoffStatusPendingOwner,
HandoffMode: handoffModePlatform,
}
if err := db.Create(&platformOrder).Error; err != nil {
t.Fatalf("create platform order failed: %v", err)
}
if err := repo.AdminForceHandoff(t.Context(), 99, platformOrder.ID, ForceHandoffRequest{
Content: "不应允许",
Reason: "测试",
}, AuditMeta{}); err != ErrOrderCannotForceHandoff {
t.Fatalf("platform order error = %v, want %v", err, ErrOrderCannotForceHandoff)
}
}
func TestSubmitCheckoutRefreshesStageTime(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db)
+11
View File
@@ -15,6 +15,7 @@ var (
ErrChannelPaymentRequired = errors.New("channel payment required")
ErrOrderCannotCancel = errors.New("order cannot cancel")
ErrOrderCannotHandoff = errors.New("order cannot handoff")
ErrOrderCannotForceHandoff = errors.New("order cannot force handoff")
ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff")
ErrOrderCannotReceive = errors.New("order cannot receive")
ErrOrderCannotReturn = errors.New("order cannot return")
@@ -233,6 +234,16 @@ func (s *Service) AdminPlatformHandoff(ctx context.Context, adminID uint64, orde
return s.repo.AdminPlatformHandoff(ctx, adminID, orderID, req, meta)
}
func (s *Service) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, meta AuditMeta) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if orderID == 0 || req.Reason == "" {
return ErrOrderCannotForceHandoff
}
return s.repo.AdminForceHandoff(ctx, adminID, orderID, req, meta)
}
func (s *Service) AdminPlatformCheckoutConfirm(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
if s.repo == nil {
return ErrDependencyUnavailable