新增客服一键交接功能

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
+1
View File
@@ -578,6 +578,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal)
adminRoutes.POST("/orders/:id/reset-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminResetHandoff)
adminRoutes.POST("/orders/:id/platform-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformHandoff)
adminRoutes.POST("/orders/:id/force-handoff", requirePerm("order:force_handoff"), orderHandler.AdminForceHandoff)
adminRoutes.POST("/orders/:id/platform-checkout/confirm", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutConfirm)
adminRoutes.POST("/orders/:id/platform-checkout/counter", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutCounter)
adminRoutes.POST("/orders/:id/platform-checkout/dispute", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutDispute)
@@ -0,0 +1,23 @@
-- +goose Up
INSERT INTO permissions (code, name, resource, action)
VALUES ('order:force_handoff', '客服一键交接', 'order', 'force_handoff')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
resource = VALUES(resource),
action = VALUES(action);
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.code = 'order:force_handoff'
WHERE r.code IN ('cs', 'ops');
-- +goose Down
DELETE rp
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE p.code = 'order:force_handoff';
DELETE FROM permissions WHERE code = 'order:force_handoff';
@@ -6,6 +6,7 @@ import { useRoute } from 'vue-router'
import {
adminCloseOrder,
adminForceHandoff,
adminHoldDeposit,
adminMarkOfflineSettlement,
adminMarkOrderAbnormal,
@@ -66,6 +67,9 @@ const refundStatus = ref<RefundStatus | null>(null)
const platformHandoffVisible = ref(false)
const platformHandoffContent = ref('')
const platformHandoffReason = ref('')
const forceHandoffVisible = ref(false)
const forceHandoffContent = ref('')
const forceHandoffReason = ref('')
const platformCheckoutCounterVisible = ref(false)
const platformCheckoutCounterForm = ref({
consumableAmountYuan: 0,
@@ -126,6 +130,7 @@ const canOperate = computed(
)
const resetAction = computed(() => order.value?.admin_actions?.reset_handoff)
const platformHandoffAction = computed(() => order.value?.admin_actions?.platform_handoff)
const forceHandoffAction = computed(() => order.value?.admin_actions?.force_handoff)
const platformCheckoutConfirmAction = computed(
() => order.value?.admin_actions?.platform_checkout_confirm
)
@@ -143,6 +148,12 @@ const resetActionLabel = computed(() => {
})
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
const canPlatformHandoff = computed(() => platformHandoffAction.value?.enabled === true)
const canForceHandoff = computed(() => forceHandoffAction.value?.enabled === true)
const forceHandoffNeedsContent = computed(
() =>
order.value?.handoff_status === 'pending_owner' ||
order.value?.handoff_status === 'owner_timeout'
)
const canPlatformCheckoutConfirm = computed(
() => platformCheckoutConfirmAction.value?.enabled === true
)
@@ -498,6 +509,39 @@ async function submitPlatformHandoff() {
}
}
function openForceHandoff() {
forceHandoffContent.value = ''
forceHandoffReason.value = ''
forceHandoffVisible.value = true
}
async function submitForceHandoff() {
if (!order.value) return
if (!forceHandoffReason.value.trim()) {
ElMessage.warning('请填写客服操作原因')
return
}
if (forceHandoffNeedsContent.value && !forceHandoffContent.value.trim()) {
ElMessage.warning('当前尚无交接说明,请填写交接内容')
return
}
platformSubmitting.value = true
try {
await adminForceHandoff(
order.value.id,
forceHandoffContent.value.trim(),
forceHandoffReason.value.trim()
)
ElMessage.success('客服已确认交接,订单进入使用中')
forceHandoffVisible.value = false
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '客服一键交接失败'))
} finally {
platformSubmitting.value = false
}
}
function openPlatformCheckoutCounter() {
const checkout = order.value?.checkout
platformCheckoutCounterForm.value = {
@@ -880,6 +924,9 @@ function paymentPaidAt(record: AdminPayment) {
>
{{ platformHandoffAction?.label || '客服代交接' }}
</el-button>
<el-button v-if="canForceHandoff" type="warning" @click="openForceHandoff">
{{ forceHandoffAction?.label || '客服一键交接' }}
</el-button>
<el-button
v-if="canPlatformCheckoutConfirm"
type="primary"
@@ -1433,6 +1480,39 @@ function paymentPaidAt(record: AdminPayment) {
</template>
</el-dialog>
<el-dialog v-model="forceHandoffVisible" title="客服一键交接" width="560px" destroy-on-close>
<div v-if="order" class="dialog-body">
<p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
</p>
<el-alert
title="确认后将跳过号主交接与租客确认步骤,订单会立即进入使用中并开始计算租期。"
type="warning"
:closable="false"
show-icon
/>
<el-input
v-if="forceHandoffNeedsContent"
v-model="forceHandoffContent"
type="textarea"
:rows="5"
placeholder="填写可供租客查看的交接说明"
/>
<el-input
v-model="forceHandoffReason"
type="textarea"
:rows="3"
placeholder="填写客服确认双方已完成交接的原因"
/>
</div>
<template #footer>
<el-button @click="forceHandoffVisible = false">取消</el-button>
<el-button type="warning" :loading="platformSubmitting" @click="submitForceHandoff">
确认一键交接
</el-button>
</template>
</el-dialog>
<el-dialog v-model="adminDisputeVisible" title="发起订单申诉" width="620px" destroy-on-close>
<div v-if="order" class="dialog-body">
<p>
@@ -78,6 +78,7 @@ export interface Order {
export interface AdminActions {
reset_handoff?: AdminAction
platform_handoff?: AdminAction
force_handoff?: AdminAction
platform_checkout_confirm?: AdminAction
platform_checkout_counter?: AdminAction
platform_checkout_dispute?: AdminAction
@@ -418,6 +419,14 @@ export async function adminPlatformHandoff(id: number, content: string, reason:
return data.data
}
export async function adminForceHandoff(id: number, content: string, reason: string) {
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(
`/admin/orders/${id}/force-handoff`,
{ content, reason }
)
return data.data
}
export async function adminPlatformCheckoutConfirm(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<{ confirmed: boolean }>>(
`/admin/orders/${id}/platform-checkout/confirm`,
@@ -242,6 +242,7 @@ export function formatHandoffRecordType(type: string) {
const typeMap: Record<string, string> = {
owner_handoff: '卖家交接',
platform_handoff: '客服代交接',
admin_force_handoff: '客服一键交接',
renter_checkout: '买家结账',
owner_counter_checkout: '卖家反驳结账',
platform_checkout_counter: '客服修改结账',