From 9ccc16e068b9a9122d4b6b5148f78ca82122c45f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 26 Jul 2026 09:54:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AE=A2=E6=9C=8D=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=E5=8F=91=E8=B5=B7=E7=94=B3=E8=AF=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/dispute.go | 2 + backend/internal/modules/dispute/cancel.go | 4 +- backend/internal/modules/dispute/dto.go | 9 + backend/internal/modules/dispute/handler.go | 23 +++ backend/internal/modules/dispute/mutation.go | 183 ++++++++++++++++++ backend/internal/modules/dispute/presenter.go | 2 + backend/internal/modules/dispute/query.go | 36 +++- .../modules/dispute/repository_test.go | 52 +++++ backend/internal/modules/dispute/service.go | 10 + backend/internal/modules/order/dto.go | 10 +- .../modules/order/platform_managed.go | 4 +- backend/internal/modules/order/queries.go | 17 +- .../order/repository_integration_test.go | 2 +- backend/internal/router/router.go | 1 + .../000036_admin_initiated_disputes.sql | 19 ++ .../admin/views/AdminDisputesView.vue | 12 +- .../admin/views/AdminOrderDetailView.vue | 140 +++++++++++++- .../src/features/disputes/api/disputes.ts | 18 ++ frontend/src/features/orders/api/orders.ts | 2 + .../orders/composables/useOrderActions.ts | 1 + 20 files changed, 522 insertions(+), 25 deletions(-) create mode 100644 backend/migrations/000036_admin_initiated_disputes.sql diff --git a/backend/internal/model/dispute.go b/backend/internal/model/dispute.go index 3dd7421..d235f37 100644 --- a/backend/internal/model/dispute.go +++ b/backend/internal/model/dispute.go @@ -10,6 +10,8 @@ type Dispute struct { ID uint64 `gorm:"primaryKey" json:"id"` OrderID uint64 `gorm:"not null;index" json:"order_id"` InitiatorID uint64 `gorm:"not null" json:"initiator_id"` + InitiatorType string `gorm:"size:16;not null;default:'user';index" json:"initiator_type"` + InitiatorAdminID *uint64 `gorm:"index" json:"initiator_admin_id"` TargetUserID uint64 `gorm:"not null" json:"target_user_id"` Type string `gorm:"size:32;not null" json:"type"` Status string `gorm:"size:32;not null;default:'open'" json:"status"` diff --git a/backend/internal/modules/dispute/cancel.go b/backend/internal/modules/dispute/cancel.go index 0db071d..44f78e1 100644 --- a/backend/internal/modules/dispute/cancel.go +++ b/backend/internal/modules/dispute/cancel.go @@ -14,7 +14,7 @@ import ( func (r *Repository) CancelByOrder(ctx context.Context, userID uint64, orderID uint64) (*DisputeDTO, error) { var row model.Dispute if err := r.db.WithContext(ctx). - Where("order_id = ? AND initiator_id = ? AND status IN ?", orderID, userID, []string{"open", "processing"}). + Where("order_id = ? AND initiator_type = ? AND initiator_id = ? AND status IN ?", orderID, disputeInitiatorUser, userID, []string{"open", "processing"}). Order("id DESC"). First(&row).Error; err != nil { return nil, err @@ -28,7 +28,7 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, id uint64) (*Dis if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil { return err } - if row.InitiatorID != userID { + if effectiveInitiatorType(row) != disputeInitiatorUser || row.InitiatorID != userID { return ErrPermissionDenied } if row.Status != "open" && row.Status != "processing" { diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go index eb19b56..d51a146 100644 --- a/backend/internal/modules/dispute/dto.go +++ b/backend/internal/modules/dispute/dto.go @@ -22,6 +22,8 @@ type DisputeDTO struct { OwnerPhone string `json:"owner_phone"` RenterPhone string `json:"renter_phone"` InitiatorID uint64 `json:"initiator_id"` + InitiatorType string `json:"initiator_type"` + InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` TargetUserID uint64 `json:"target_user_id"` Type string `json:"type"` Status string `json:"status"` @@ -46,6 +48,13 @@ type CreateRequest struct { EvidenceURLS []string `json:"evidence_urls"` } +type AdminCreateRequest struct { + Type string `json:"type" binding:"required"` + Description string `json:"description" binding:"required"` + TargetRole string `json:"target_role" binding:"required"` + EvidenceURLS []string `json:"evidence_urls"` +} + type ArbitrateRequest struct { Result string `json:"result" binding:"required"` Remark string `json:"remark" binding:"required"` diff --git a/backend/internal/modules/dispute/handler.go b/backend/internal/modules/dispute/handler.go index 7648521..dce7764 100644 --- a/backend/internal/modules/dispute/handler.go +++ b/backend/internal/modules/dispute/handler.go @@ -129,6 +129,29 @@ func (h *Handler) AdminList(c *gin.Context) { response.OK(c, result) } +func (h *Handler) AdminCreateByOrder(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + orderID, ok := parseID(c) + if !ok { + return + } + var req AdminCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "申诉信息不完整") + return + } + item, err := h.service.AdminCreateByOrder(c.Request.Context(), adminID, orderID, req, auditMeta(c)) + if err != nil { + writeDisputeError(c, err) + return + } + response.Created(c, item) +} + func (h *Handler) AdminArbitrate(c *gin.Context) { adminID, ok := currentAdminID(c) if !ok { diff --git a/backend/internal/modules/dispute/mutation.go b/backend/internal/modules/dispute/mutation.go index 5024349..03a910c 100644 --- a/backend/internal/modules/dispute/mutation.go +++ b/backend/internal/modules/dispute/mutation.go @@ -11,6 +11,14 @@ import ( "gorm.io/gorm/clause" ) +const ( + disputeInitiatorUser = "user" + disputeInitiatorAdmin = "admin" + + disputeTargetOwner = "owner" + disputeTargetRenter = "renter" +) + func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) { var createdID uint64 err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -46,6 +54,7 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, row := model.Dispute{ OrderID: order.ID, InitiatorID: userID, + InitiatorType: disputeInitiatorUser, TargetUserID: targetID, Type: disputeType(req.Type, isCheckoutDispute), Status: "open", @@ -154,9 +163,183 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, return r.FindForUser(ctx, userID, createdID) } +func (r *Repository) AdminCreateByOrder(ctx context.Context, adminID uint64, orderID uint64, req AdminCreateRequest, meta AuditMeta) (*DisputeDTO, error) { + var createdID uint64 + 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.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" { + return ErrInvalidDispute + } + var count int64 + if err := tx.Model(&model.Dispute{}). + Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return ErrDisputeExists + } + targetID, err := adminDisputeTargetUserID(order, req.TargetRole) + if err != nil { + return err + } + evidence, err := marshalEvidence(req.EvidenceURLS) + if err != nil { + return err + } + isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept" + adminIDCopy := adminID + row := model.Dispute{ + OrderID: order.ID, + InitiatorID: 0, + InitiatorType: disputeInitiatorAdmin, + InitiatorAdminID: &adminIDCopy, + TargetUserID: targetID, + Type: disputeType(req.Type, isCheckoutDispute), + Status: "open", + Description: req.Description, + EvidenceURLS: evidence, + PreviousOrderStatus: order.Status, + PreviousHandoffStatus: order.HandoffStatus, + PreviousSettlementStatus: order.SettlementStatus, + } + var checkout model.OrderCheckout + if isCheckoutDispute { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}). + Order("id DESC"). + First(&checkout).Error; err != nil { + return err + } + row.CheckoutID = &checkout.ID + row.PreviousCheckoutStatus = checkout.Status + } + if err := tx.Create(&row).Error; err != nil { + return err + } + if isCheckoutDispute { + now := time.Now() + order.Status = "checkout_disputing" + order.HandoffStatus = "checkout_disputed" + order.SettlementStatus = "disputed" + if err := tx.Model(&model.OrderCheckout{}). + Where("id = ?", checkout.ID). + Updates(map[string]any{"status": "disputed", "updated_at": now}).Error; err != nil { + return err + } + } else { + order.Status = "disputing" + } + recordType := "admin_dispute_opened" + recordContent := "客服发起订单申诉:" + req.Description + if isCheckoutDispute { + recordType = "admin_checkout_dispute_opened" + recordContent = "客服发起结账争议:" + req.Description + } + record := model.HandoffRecord{ + OrderID: order.ID, + FromUserID: adminID, + ToUserID: targetID, + Type: recordType, + Content: recordContent, + } + if err := tx.Create(&record).Error; err != nil { + return err + } + if err := tx.Save(&order).Error; err != nil { + return err + } + disputeID := row.ID + title := "客服已发起订单申诉" + content := "客服已将订单提交仲裁流程,请等待处理或补充沟通记录。" + if isCheckoutDispute { + title = "客服已发起结账争议" + content = "客服已将结账问题提交仲裁流程,请等待处理或补充结账证据。" + } + if err := appendAdminCreatedDisputeNotifications(tx, order, disputeID, title, content); err != nil { + return err + } + if err := appendAuditLog(tx, adminID, "dispute.admin_create", "dispute", row.ID, meta, map[string]any{ + "dispute_id": row.ID, + "order_id": order.ID, + "order_no": order.OrderNo, + "type": row.Type, + "target_role": req.TargetRole, + "target_user_id": targetID, + "description": req.Description, + "previous_order_status": row.PreviousOrderStatus, + "previous_handoff_status": row.PreviousHandoffStatus, + "previous_settlement_status": row.PreviousSettlementStatus, + "after_order_status": order.Status, + "after_handoff_status": order.HandoffStatus, + "after_settlement_status": order.SettlementStatus, + }); err != nil { + return err + } + createdID = row.ID + return nil + }) + if err != nil { + return nil, err + } + return r.FindAdminByID(ctx, createdID) +} + func disputeType(input string, isCheckoutDispute bool) string { if isCheckoutDispute { return "checkout_dispute" } return input } + +func effectiveInitiatorType(row model.Dispute) string { + if row.InitiatorType == disputeInitiatorAdmin { + return disputeInitiatorAdmin + } + return disputeInitiatorUser +} + +func adminDisputeTargetUserID(order model.RentalOrder, role string) (uint64, error) { + switch role { + case disputeTargetOwner: + return order.OwnerID, nil + case disputeTargetRenter: + return order.RenterID, nil + default: + return 0, ErrInvalidDispute + } +} + +func appendAdminCreatedDisputeNotifications(tx *gorm.DB, order model.RentalOrder, disputeID uint64, title string, content string) error { + if isPlatformManagedOrder(order) { + return notification.Append(tx, notification.Entry{ + UserID: order.RenterID, + Type: "dispute", + Title: title, + Content: content, + BizType: "dispute", + BizID: &disputeID, + }) + } + return notification.Append(tx, + notification.Entry{ + UserID: order.RenterID, + Type: "dispute", + Title: title, + Content: content, + BizType: "dispute", + BizID: &disputeID, + }, + notification.Entry{ + UserID: order.OwnerID, + Type: "dispute", + Title: title, + Content: content, + BizType: "dispute", + BizID: &disputeID, + }, + ) +} diff --git a/backend/internal/modules/dispute/presenter.go b/backend/internal/modules/dispute/presenter.go index 361d1a6..1a0c1ee 100644 --- a/backend/internal/modules/dispute/presenter.go +++ b/backend/internal/modules/dispute/presenter.go @@ -37,6 +37,8 @@ func (row disputeRow) toDTO() DisputeDTO { OwnerPhone: row.OwnerPhone, RenterPhone: row.RenterPhone, InitiatorID: row.InitiatorID, + InitiatorType: effectiveInitiatorType(row.Dispute), + InitiatorAdminID: row.InitiatorAdminID, TargetUserID: row.TargetUserID, Type: row.Type, Status: row.Status, diff --git a/backend/internal/modules/dispute/query.go b/backend/internal/modules/dispute/query.go index a26c35b..e82f654 100644 --- a/backend/internal/modules/dispute/query.go +++ b/backend/internal/modules/dispute/query.go @@ -5,22 +5,18 @@ import ( "strconv" "strings" - "hfb_sys/backend/internal/model" - "gorm.io/gorm" ) func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) { - db := r.db.WithContext(ctx) - conditions := db.Model(&model.Dispute{}).Where("initiator_id = ? OR target_user_id = ?", userID, userID) + conditions := r.userVisibleQuery(ctx, userID) var total int64 if err := conditions.Count(&total).Error; err != nil { return nil, err } offset := (page - 1) * pageSize var rows []disputeRow - err := r.baseQuery(ctx). - Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID). + err := applyUserVisibleFilter(r.baseQuery(ctx), userID). Order("d.id DESC"). Offset(offset).Limit(pageSize). Scan(&rows).Error @@ -32,8 +28,7 @@ func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageS func (r *Repository) FindForUser(ctx context.Context, userID uint64, id uint64) (*DisputeDTO, error) { var row disputeRow - if err := r.baseQuery(ctx). - Where("d.id = ? AND (d.initiator_id = ? OR d.target_user_id = ?)", id, userID, userID). + if err := applyUserVisibleFilter(r.baseQuery(ctx).Where("d.id = ?", id), userID). First(&row).Error; err != nil { return nil, err } @@ -68,6 +63,31 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Pagi return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil } +func (r *Repository) FindAdminByID(ctx context.Context, id uint64) (*DisputeDTO, error) { + var row disputeRow + if err := r.baseQuery(ctx).Where("d.id = ?", id).First(&row).Error; err != nil { + return nil, err + } + dto := row.toDTO() + return &dto, nil +} + +func (r *Repository) userVisibleQuery(ctx context.Context, userID uint64) *gorm.DB { + return applyUserVisibleFilter(r.adminFilterQuery(ctx), userID) +} + +func applyUserVisibleFilter(db *gorm.DB, userID uint64) *gorm.DB { + return db.Where( + `((d.initiator_type = ? AND d.initiator_id = ?) OR d.target_user_id = ? OR (d.initiator_type = ? AND (o.owner_id = ? OR o.renter_id = ?)))`, + disputeInitiatorUser, + userID, + userID, + disputeInitiatorAdmin, + userID, + userID, + ) +} + func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { return r.adminFilterQuery(ctx). Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status, diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index 8dd4cdb..49225a6 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -169,6 +169,58 @@ func TestRepositoryCancelRestoresCheckoutDisputeStatus(t *testing.T) { } } +func TestAdminCreateByOrderCreatesAdminInitiatedDispute(t *testing.T) { + db := setupDisputeTestDB(t) + repo := NewRepository(db) + adminID := uint64(88) + owner, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{ + Status: "renting", + HandoffStatus: "received", + SettlementStatus: "unsettled", + }) + + created, err := repo.AdminCreateByOrder(t.Context(), adminID, order.ID, AdminCreateRequest{ + Type: "cannot_login", + TargetRole: "owner", + Description: "客服核查后主动发起申诉", + }, AuditMeta{}) + if err != nil { + t.Fatalf("后台发起申诉失败: %v", err) + } + if created.InitiatorType != disputeInitiatorAdmin || created.InitiatorAdminID == nil || *created.InitiatorAdminID != adminID { + t.Fatalf("发起方 = %s/%v, want admin/%d", created.InitiatorType, created.InitiatorAdminID, adminID) + } + if created.InitiatorID != 0 || created.TargetUserID != owner.ID { + t.Fatalf("initiator/target = %d/%d, want 0/%d", created.InitiatorID, created.TargetUserID, owner.ID) + } + + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if saved.Status != "disputing" || saved.HandoffStatus != "received" || saved.SettlementStatus != "unsettled" { + t.Fatalf("订单状态 = %s/%s/%s, want disputing/received/unsettled", saved.Status, saved.HandoffStatus, saved.SettlementStatus) + } + + if _, err := repo.FindForUser(t.Context(), renter.ID, created.ID); err != nil { + t.Fatalf("租客应能看到客服发起的申诉: %v", err) + } + if _, err := repo.FindForUser(t.Context(), owner.ID, created.ID); err != nil { + t.Fatalf("号主应能看到客服发起的申诉: %v", err) + } + if _, err := repo.CancelByOrder(t.Context(), renter.ID, order.ID); err == nil { + t.Fatal("客服发起的申诉不应允许租客取消") + } + + var record model.HandoffRecord + if err := db.Where("order_id = ? AND type = ?", order.ID, "admin_dispute_opened").First(&record).Error; err != nil { + t.Fatalf("读取客服发起申诉记录失败: %v", err) + } + if record.FromUserID != adminID || record.ToUserID != owner.ID { + t.Fatalf("record from/to = %d/%d, want %d/%d", record.FromUserID, record.ToUserID, adminID, owner.ID) + } +} + func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) { db := setupDisputeTestDB(t) repo := NewRepository(db) diff --git a/backend/internal/modules/dispute/service.go b/backend/internal/modules/dispute/service.go index 4884432..d7c3e0b 100644 --- a/backend/internal/modules/dispute/service.go +++ b/backend/internal/modules/dispute/service.go @@ -32,6 +32,16 @@ func (s *Service) Create(ctx context.Context, userID uint64, orderID uint64, req return s.repo.Create(ctx, userID, orderID, req) } +func (s *Service) AdminCreateByOrder(ctx context.Context, adminID uint64, orderID uint64, req AdminCreateRequest, meta AuditMeta) (*DisputeDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if orderID == 0 || req.Type == "" || req.Description == "" || req.TargetRole == "" { + return nil, ErrInvalidDispute + } + return s.repo.AdminCreateByOrder(ctx, adminID, orderID, req, meta) +} + func (s *Service) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index b01e46a..fd56a88 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -74,10 +74,12 @@ type AdminActionDTO struct { } type ActiveDisputeDTO struct { - ID uint64 `json:"id"` - InitiatorID uint64 `json:"initiator_id"` - Type string `json:"type"` - Status string `json:"status"` + ID uint64 `json:"id"` + InitiatorID uint64 `json:"initiator_id"` + InitiatorType string `json:"initiator_type"` + InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` + Type string `json:"type"` + Status string `json:"status"` } type CreateRequest struct { diff --git a/backend/internal/modules/order/platform_managed.go b/backend/internal/modules/order/platform_managed.go index bfcd57b..c7bffbb 100644 --- a/backend/internal/modules/order/platform_managed.go +++ b/backend/internal/modules/order/platform_managed.go @@ -336,7 +336,9 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u beforeSettlementStatus := order.SettlementStatus row := model.Dispute{ OrderID: order.ID, - InitiatorID: adminID, + InitiatorID: 0, + InitiatorType: "admin", + InitiatorAdminID: &adminID, TargetUserID: order.RenterID, Type: "checkout_dispute", Status: "open", diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index dc2fa8e..5aa85ce 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -203,13 +203,22 @@ func (r *Repository) activeDisputeDTO(ctx context.Context, orderID uint64) *Acti return nil } return &ActiveDisputeDTO{ - ID: row.ID, - InitiatorID: row.InitiatorID, - Type: row.Type, - Status: row.Status, + ID: row.ID, + InitiatorID: row.InitiatorID, + InitiatorType: effectiveDisputeInitiatorType(row), + InitiatorAdminID: row.InitiatorAdminID, + Type: row.Type, + Status: row.Status, } } +func effectiveDisputeInitiatorType(row model.Dispute) string { + if row.InitiatorType == "admin" { + return "admin" + } + return "user" +} + func shouldAttachCheckout(status string) bool { switch status { case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted: diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 9792969..564eb0e 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -1068,7 +1068,7 @@ func TestAdminPlatformCheckoutDisputeCreatesArbitrationCase(t *testing.T) { if err := db.Where("order_id = ?", order.ID).First(&dispute).Error; err != nil { t.Fatalf("load dispute failed: %v", err) } - if dispute.InitiatorID != adminID || dispute.TargetUserID != renter.ID || dispute.Type != "checkout_dispute" { + if dispute.InitiatorID != 0 || dispute.InitiatorType != "admin" || dispute.InitiatorAdminID == nil || *dispute.InitiatorAdminID != adminID || dispute.TargetUserID != renter.ID || dispute.Type != "checkout_dispute" { t.Fatalf("dispute = %#v, want admin initiated checkout dispute to renter", dispute) } if dispute.CheckoutID == nil || *dispute.CheckoutID != checkout.ID || dispute.PreviousCheckoutStatus != checkoutStatusSubmitted { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index c1d5938..61b430d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -588,6 +588,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/orders/:id/deposit-hold", requirePerm("order:deposit_hold"), orderHandler.AdminHoldDeposit) adminRoutes.POST("/orders/:id/deposit-release", requirePerm("order:deposit_hold"), orderHandler.AdminReleaseDeposit) adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund) + adminRoutes.POST("/orders/:id/dispute", requirePerm("dispute:arbitrate"), disputeHandler.AdminCreateByOrder) // 管理员线下提号(独立于正常订单流程) adminRoutes.POST("/pickups", requirePerm("order:pickup"), pickupHandler.Create) diff --git a/backend/migrations/000036_admin_initiated_disputes.sql b/backend/migrations/000036_admin_initiated_disputes.sql new file mode 100644 index 0000000..0774bca --- /dev/null +++ b/backend/migrations/000036_admin_initiated_disputes.sql @@ -0,0 +1,19 @@ +-- +goose Up + +ALTER TABLE disputes + ADD COLUMN initiator_type VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '发起方类型: user用户/admin客服' AFTER initiator_id, + ADD COLUMN initiator_admin_id BIGINT UNSIGNED NULL COMMENT '客服发起时的管理员ID' AFTER initiator_type, + ADD KEY idx_disputes_initiator_type (initiator_type, initiator_id), + ADD KEY idx_disputes_initiator_admin (initiator_admin_id); + +UPDATE disputes +SET initiator_type = 'user' +WHERE initiator_type = ''; + +-- +goose Down + +ALTER TABLE disputes + DROP KEY idx_disputes_initiator_admin, + DROP KEY idx_disputes_initiator_type, + DROP COLUMN initiator_admin_id, + DROP COLUMN initiator_type; diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue index 7c02318..801f4e5 100644 --- a/frontend/src/features/admin/views/AdminDisputesView.vue +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -103,6 +103,13 @@ function userPhone(row: Dispute, userID: number) { return '-' } +function initiatorLabel(row: Dispute) { + if (row.initiator_type === 'admin') { + return row.initiator_admin_id ? `客服 ID ${row.initiator_admin_id}` : '客服' + } + return `${roleLabel(row, row.initiator_id)} ${userPhone(row, row.initiator_id)}` +} + function evidenceCount(row: Dispute) { return evidenceItems(row).length } @@ -388,10 +395,7 @@ async function handleArbitrate() { + +
+

+ {{ order.order_no }} · 商品编号 {{ listingCode }} · {{ order.title }} +

+
+ + +
+ + +
+ +
+ >( + `/admin/orders/${orderId}/dispute`, + payload + ) + return data.data +} + export async function cancelOrderDispute(orderId: number) { const { data } = await apiClient.post>(`/orders/${orderId}/dispute/cancel`) return data.data diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index bef6374..2dfa674 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -74,6 +74,8 @@ export interface AdminAction { export interface ActiveDispute { id: number initiator_id: number + initiator_type?: 'user' | 'admin' | string + initiator_admin_id?: number type: string status: string } diff --git a/frontend/src/features/orders/composables/useOrderActions.ts b/frontend/src/features/orders/composables/useOrderActions.ts index a311472..c1ed508 100644 --- a/frontend/src/features/orders/composables/useOrderActions.ts +++ b/frontend/src/features/orders/composables/useOrderActions.ts @@ -224,6 +224,7 @@ export function useOrderActions(options: UseOrderActionsOptions) { !!order.value && (isOwner.value || isRenter.value) && ['disputing', 'checkout_disputing'].includes(order.value.status) && + order.value.active_dispute?.initiator_type !== 'admin' && order.value.active_dispute?.initiator_id === session.userId ) })