diff --git a/backend/internal/e2e/rental_flow_test.go b/backend/internal/e2e/rental_flow_test.go index e940041..f1a5f9d 100644 --- a/backend/internal/e2e/rental_flow_test.go +++ b/backend/internal/e2e/rental_flow_test.go @@ -281,7 +281,7 @@ func openE2EDB(t *testing.T) *gorm.DB { t.Fatalf("连接测试库失败: %v", err) } t.Cleanup(func() { _ = sqlDB.Close() }) - applyInitMigration(t, sqlDB) + applyMigrations(t, sqlDB) gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB}), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), @@ -306,20 +306,36 @@ func assertSafeTestDBName(t *testing.T, dbName string) { } } -func applyInitMigration(t *testing.T, db *sql.DB) { +func applyMigrations(t *testing.T, db *sql.DB) { t.Helper() _, currentFile, _, ok := runtime.Caller(0) if !ok { t.Fatal("无法定位当前测试文件") } - migrationPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations", "000001_init.sql") - raw, err := os.ReadFile(migrationPath) - if err != nil { - t.Fatalf("读取初始化迁移失败: %v", err) + migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations") + for _, name := range []string{"000001_init.sql", "000002_dispute_cancel_snapshot.sql"} { + migrationPath := filepath.Join(migrationDir, name) + raw, err := os.ReadFile(migrationPath) + if err != nil { + t.Fatalf("读取迁移 %s 失败: %v", name, err) + } + sqlText := gooseUpSQL(string(raw)) + if _, err := db.Exec(sqlText); err != nil { + t.Fatalf("执行迁移 %s 失败: %v", name, err) + } } - if _, err := db.Exec(string(raw)); err != nil { - t.Fatalf("执行初始化迁移失败: %v", err) +} + +func gooseUpSQL(raw string) string { + upIndex := strings.Index(raw, "-- +goose Up") + if upIndex >= 0 { + raw = raw[upIndex+len("-- +goose Up"):] } + downIndex := strings.Index(raw, "-- +goose Down") + if downIndex >= 0 { + raw = raw[:downIndex] + } + return raw } func seedUsers(t *testing.T, db *gorm.DB) (model.User, model.User, uint64) { diff --git a/backend/internal/model/dispute.go b/backend/internal/model/dispute.go index 44dc1e4..3dd7421 100644 --- a/backend/internal/model/dispute.go +++ b/backend/internal/model/dispute.go @@ -7,20 +7,25 @@ import ( ) 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"` - 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"` - Description string `json:"description"` - EvidenceURLS datatypes.JSON `gorm:"column:evidence_urls" json:"evidence_urls"` - ArbitrationResult string `gorm:"size:32;not null;default:''" json:"arbitration_result"` - ArbitrationRemark string `json:"arbitration_remark"` - HandledBy *uint64 `json:"handled_by"` - HandledAt *time.Time `json:"handled_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + OrderID uint64 `gorm:"not null;index" json:"order_id"` + InitiatorID uint64 `gorm:"not null" json:"initiator_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"` + Description string `json:"description"` + EvidenceURLS datatypes.JSON `gorm:"column:evidence_urls" json:"evidence_urls"` + PreviousOrderStatus string `gorm:"size:32;not null;default:''" json:"previous_order_status"` + PreviousHandoffStatus string `gorm:"size:32;not null;default:''" json:"previous_handoff_status"` + PreviousSettlementStatus string `gorm:"size:32;not null;default:''" json:"previous_settlement_status"` + CheckoutID *uint64 `json:"checkout_id"` + PreviousCheckoutStatus string `gorm:"size:32;not null;default:''" json:"previous_checkout_status"` + ArbitrationResult string `gorm:"size:32;not null;default:''" json:"arbitration_result"` + ArbitrationRemark string `json:"arbitration_remark"` + HandledBy *uint64 `json:"handled_by"` + HandledAt *time.Time `json:"handled_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (Dispute) TableName() string { diff --git a/backend/internal/modules/dispute/cancel.go b/backend/internal/modules/dispute/cancel.go new file mode 100644 index 0000000..0db071d --- /dev/null +++ b/backend/internal/modules/dispute/cancel.go @@ -0,0 +1,185 @@ +package dispute + +import ( + "context" + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/notification" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +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"}). + Order("id DESC"). + First(&row).Error; err != nil { + return nil, err + } + return r.Cancel(ctx, userID, row.ID) +} + +func (r *Repository) Cancel(ctx context.Context, userID uint64, id uint64) (*DisputeDTO, error) { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var row model.Dispute + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil { + return err + } + if row.InitiatorID != userID { + return ErrPermissionDenied + } + if row.Status != "open" && row.Status != "processing" { + return ErrDisputeCannotCancel + } + + var order model.RentalOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, row.OrderID).Error; err != nil { + return err + } + if err := restoreOrderAfterDisputeCancel(tx, &row, &order); err != nil { + return err + } + + now := time.Now() + row.Status = "cancelled" + row.ArbitrationRemark = "用户自行取消申诉" + row.HandledAt = &now + if err := tx.Save(&row).Error; err != nil { + return err + } + if err := tx.Save(&order).Error; err != nil { + return err + } + recordType := "dispute_cancelled" + recordContent := "用户自行取消申诉,订单已恢复到申诉前流程。" + if row.Type == "checkout_dispute" { + recordType = "checkout_dispute_cancelled" + recordContent = "用户自行取消结账争议,订单已恢复到申诉前流程。" + } + record := model.HandoffRecord{ + OrderID: order.ID, + FromUserID: row.InitiatorID, + ToUserID: row.TargetUserID, + Type: recordType, + Content: recordContent, + } + if err := tx.Create(&record).Error; err != nil { + return err + } + + disputeID := row.ID + if err := notification.Append(tx, + notification.Entry{ + UserID: row.TargetUserID, + Type: "dispute", + Title: "申诉已取消", + Content: "对方已自行取消申诉,订单已恢复到申诉前流程。", + BizType: "dispute", + BizID: &disputeID, + }, + notification.Entry{ + UserID: row.InitiatorID, + Type: "dispute", + Title: "申诉已取消", + Content: "你已取消申诉,订单已恢复到申诉前流程。", + BizType: "dispute", + BizID: &disputeID, + }, + ); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + 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 restoreOrderAfterDisputeCancel(tx *gorm.DB, row *model.Dispute, order *model.RentalOrder) error { + if row.Type == "checkout_dispute" { + return restoreCheckoutDispute(tx, row, order) + } + if order.Status != "disputing" { + return ErrDisputeCannotCancel + } + restoreOrderStatusFromSnapshot(row, order) + if order.Status == "" || order.Status == "disputing" { + order.Status = inferOrderStatusForDisputeCancel(*order) + } + if order.Status == "" { + return ErrDisputeCannotCancel + } + return nil +} + +func restoreCheckoutDispute(tx *gorm.DB, row *model.Dispute, order *model.RentalOrder) error { + if order.Status != "checkout_disputing" { + return ErrDisputeCannotCancel + } + var checkout model.OrderCheckout + query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_id = ? AND status = ?", order.ID, "disputed") + if row.CheckoutID != nil && *row.CheckoutID > 0 { + query = query.Where("id = ?", *row.CheckoutID) + } + if err := query.Order("id DESC").First(&checkout).Error; err != nil { + return err + } + restoreOrderStatusFromSnapshot(row, order) + if order.Status == "" || order.Status == "checkout_disputing" { + order.Status = "pending_checkout_confirm" + order.HandoffStatus = "pending_owner_checkout" + if checkout.OwnerAdjustedAt != nil || checkout.OwnerAdjustmentReason != "" { + order.Status = "pending_checkout_accept" + order.HandoffStatus = "pending_renter_checkout" + } + order.SettlementStatus = "pending" + } + checkout.Status = row.PreviousCheckoutStatus + if checkout.Status == "" { + checkout.Status = "submitted" + if order.Status == "pending_checkout_accept" { + checkout.Status = "countered" + } + } + checkout.RenterRejectedAt = nil + if err := tx.Save(&checkout).Error; err != nil { + return err + } + return nil +} + +func restoreOrderStatusFromSnapshot(row *model.Dispute, order *model.RentalOrder) { + if row.PreviousOrderStatus != "" { + order.Status = row.PreviousOrderStatus + } + if row.PreviousHandoffStatus != "" { + order.HandoffStatus = row.PreviousHandoffStatus + } + if row.PreviousSettlementStatus != "" { + order.SettlementStatus = row.PreviousSettlementStatus + } +} + +func inferOrderStatusForDisputeCancel(order model.RentalOrder) string { + switch order.HandoffStatus { + case "none": + return "pending_payment" + case "pending_owner", "pending_renter_confirm": + return "pending_handoff" + case "received": + return "renting" + case "return_overdue": + return "overdue" + default: + return "" + } +} diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go index 5af13b4..f578fac 100644 --- a/backend/internal/modules/dispute/dto.go +++ b/backend/internal/modules/dispute/dto.go @@ -9,23 +9,35 @@ import ( ) type DisputeDTO struct { - ID uint64 `json:"id"` - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - ListingNo string `json:"listing_no"` - Title string `json:"title"` - InitiatorID uint64 `json:"initiator_id"` - TargetUserID uint64 `json:"target_user_id"` - Type string `json:"type"` - Status string `json:"status"` - Description string `json:"description"` - EvidenceURLS datatypes.JSON `json:"evidence_urls"` - ArbitrationResult string `json:"arbitration_result"` - ArbitrationRemark string `json:"arbitration_remark"` - HandledBy *uint64 `json:"handled_by"` - HandledAt *time.Time `json:"handled_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + OrderStatus string `json:"order_status"` + HandoffStatus string `json:"handoff_status"` + SettlementStatus string `json:"settlement_status"` + ListingNo string `json:"listing_no"` + Title string `json:"title"` + OwnerID uint64 `json:"owner_id"` + RenterID uint64 `json:"renter_id"` + OwnerPhone string `json:"owner_phone"` + RenterPhone string `json:"renter_phone"` + InitiatorID uint64 `json:"initiator_id"` + TargetUserID uint64 `json:"target_user_id"` + Type string `json:"type"` + Status string `json:"status"` + Description string `json:"description"` + EvidenceURLS datatypes.JSON `json:"evidence_urls"` + PreviousOrderStatus string `json:"previous_order_status"` + PreviousHandoffStatus string `json:"previous_handoff_status"` + PreviousSettlementStatus string `json:"previous_settlement_status"` + CheckoutID *uint64 `json:"checkout_id"` + PreviousCheckoutStatus string `json:"previous_checkout_status"` + ArbitrationResult string `json:"arbitration_result"` + ArbitrationRemark string `json:"arbitration_remark"` + HandledBy *uint64 `json:"handled_by"` + HandledAt *time.Time `json:"handled_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateRequest struct { diff --git a/backend/internal/modules/dispute/handler.go b/backend/internal/modules/dispute/handler.go index 47f8e6f..fb3a313 100644 --- a/backend/internal/modules/dispute/handler.go +++ b/backend/internal/modules/dispute/handler.go @@ -75,6 +75,42 @@ func (h *Handler) Detail(c *gin.Context) { response.OK(c, item) } +func (h *Handler) Cancel(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.Cancel(c.Request.Context(), userID, id) + if err != nil { + writeDisputeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) CancelByOrder(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + orderID, ok := parseID(c) + if !ok { + return + } + item, err := h.service.CancelByOrder(c.Request.Context(), userID, orderID) + if err != nil { + writeDisputeError(c, err) + return + } + response.OK(c, item) +} + func (h *Handler) AdminList(c *gin.Context) { page, pageSize := parsePagination(c) result, err := h.service.ListAdmin(c.Request.Context(), page, pageSize) @@ -168,6 +204,8 @@ func writeDisputeError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "dispute_exists", "该订单已有处理中申诉") case errors.Is(err, ErrDisputeCannotHandle): response.Error(c, http.StatusConflict, "dispute_cannot_handle", "当前申诉不能仲裁") + case errors.Is(err, ErrDisputeCannotCancel): + response.Error(c, http.StatusConflict, "dispute_cannot_cancel", "当前申诉不能取消") case errors.Is(err, ErrPermissionDenied): response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该申诉") case IsNotFound(err): diff --git a/backend/internal/modules/dispute/mutation.go b/backend/internal/modules/dispute/mutation.go index ebe42cc..553fe32 100644 --- a/backend/internal/modules/dispute/mutation.go +++ b/backend/internal/modules/dispute/mutation.go @@ -44,13 +44,27 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, return err } row := model.Dispute{ - OrderID: order.ID, - InitiatorID: userID, - TargetUserID: targetID, - Type: disputeType(req.Type, isCheckoutDispute), - Status: "open", - Description: req.Description, - EvidenceURLS: evidence, + OrderID: order.ID, + InitiatorID: userID, + 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 @@ -68,13 +82,29 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, updates["renter_rejected_at"] = now } if err := tx.Model(&model.OrderCheckout{}). - Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}). + Where("id = ?", checkout.ID). Updates(updates).Error; err != nil { return err } } else { order.Status = "disputing" } + recordType := "dispute_opened" + recordContent := "用户发起订单申诉:" + req.Description + if isCheckoutDispute { + recordType = "checkout_dispute_opened" + recordContent = "用户发起结账争议:" + req.Description + } + record := model.HandoffRecord{ + OrderID: order.ID, + FromUserID: userID, + 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 } diff --git a/backend/internal/modules/dispute/presenter.go b/backend/internal/modules/dispute/presenter.go index d52a8f4..361d1a6 100644 --- a/backend/internal/modules/dispute/presenter.go +++ b/backend/internal/modules/dispute/presenter.go @@ -10,30 +10,49 @@ import ( type disputeRow struct { model.Dispute - OrderNo string - ListingNo string - Title string + OrderNo string + OrderStatus string + HandoffStatus string + SettlementStatus string + ListingNo string + Title string + OwnerID uint64 + RenterID uint64 + OwnerPhone string + RenterPhone string } func (row disputeRow) toDTO() DisputeDTO { return DisputeDTO{ - ID: row.ID, - OrderID: row.OrderID, - OrderNo: row.OrderNo, - ListingNo: row.ListingNo, - Title: row.Title, - InitiatorID: row.InitiatorID, - TargetUserID: row.TargetUserID, - Type: row.Type, - Status: row.Status, - Description: row.Description, - EvidenceURLS: row.EvidenceURLS, - ArbitrationResult: row.ArbitrationResult, - ArbitrationRemark: row.ArbitrationRemark, - HandledBy: row.HandledBy, - HandledAt: row.HandledAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + ID: row.ID, + OrderID: row.OrderID, + OrderNo: row.OrderNo, + OrderStatus: row.OrderStatus, + HandoffStatus: row.HandoffStatus, + SettlementStatus: row.SettlementStatus, + ListingNo: row.ListingNo, + Title: row.Title, + OwnerID: row.OwnerID, + RenterID: row.RenterID, + OwnerPhone: row.OwnerPhone, + RenterPhone: row.RenterPhone, + InitiatorID: row.InitiatorID, + TargetUserID: row.TargetUserID, + Type: row.Type, + Status: row.Status, + Description: row.Description, + EvidenceURLS: row.EvidenceURLS, + PreviousOrderStatus: row.PreviousOrderStatus, + PreviousHandoffStatus: row.PreviousHandoffStatus, + PreviousSettlementStatus: row.PreviousSettlementStatus, + CheckoutID: row.CheckoutID, + PreviousCheckoutStatus: row.PreviousCheckoutStatus, + ArbitrationResult: row.ArbitrationResult, + ArbitrationRemark: row.ArbitrationRemark, + HandledBy: row.HandledBy, + HandledAt: row.HandledAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } } diff --git a/backend/internal/modules/dispute/query.go b/backend/internal/modules/dispute/query.go index 7b0fc6d..bcf5784 100644 --- a/backend/internal/modules/dispute/query.go +++ b/backend/internal/modules/dispute/query.go @@ -55,8 +55,12 @@ func (r *Repository) ListAdmin(ctx context.Context, page, pageSize int) (*Pagina func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { return r.db.WithContext(ctx).Table("disputes AS d"). - Select("d.*, o.order_no, l.listing_no, a.title"). + Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status, + o.owner_id, o.renter_id, owner.phone AS owner_phone, renter.phone AS renter_phone, + l.listing_no, a.title`). Joins("JOIN rental_orders AS o ON o.id = d.order_id"). Joins("JOIN rental_listings AS l ON l.id = o.listing_id"). - Joins("JOIN game_accounts AS a ON a.id = o.account_id") + Joins("JOIN game_accounts AS a ON a.id = o.account_id"). + Joins("JOIN users AS owner ON owner.id = o.owner_id"). + Joins("JOIN users AS renter ON renter.id = o.renter_id") } diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index 583d470..122d078 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -3,10 +3,187 @@ package dispute import ( "strings" "testing" + "time" "hfb_sys/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" ) +func setupDisputeTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("无法创建测试数据库: %v", err) + } + if err := db.AutoMigrate( + &model.User{}, + &model.GameAccount{}, + &model.RentalListing{}, + &model.RentalOrder{}, + &model.OrderCheckout{}, + &model.HandoffRecord{}, + &model.Dispute{}, + &model.Notification{}, + ); err != nil { + t.Fatalf("数据库迁移失败: %v", err) + } + return db +} + +func createDisputeOrderFixture(t *testing.T, db *gorm.DB, order model.RentalOrder) (model.User, model.User, model.RentalOrder) { + t.Helper() + owner := model.User{Phone: "13800000001"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建号主失败: %v", err) + } + renter := model.User{Phone: "13800000002"} + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("创建租客失败: %v", err) + } + account := model.GameAccount{ + OwnerID: owner.ID, + Title: "测试账号", + ServerRegion: "国服", + LoginPlatform: "安卓", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("创建账号失败: %v", err) + } + listing := model.RentalListing{ + AccountID: account.ID, + OwnerID: owner.ID, + PriceCent: 1000, + Status: "rented", + ReviewStatus: "approved", + InTransaction: true, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("创建商品失败: %v", err) + } + order.OrderNo = "RO202606130001" + order.ListingID = listing.ID + order.AccountID = account.ID + order.OwnerID = owner.ID + order.RenterID = renter.ID + if err := db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + return owner, renter, order +} + +func TestRepositoryCancelRestoresNormalDisputeOrderStatus(t *testing.T) { + db := setupDisputeTestDB(t) + repo := NewRepository(db) + _, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{ + Status: "renting", + HandoffStatus: "received", + SettlementStatus: "unsettled", + }) + + created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{ + Type: "cannot_login", + Description: "无法登录账号", + }) + if err != nil { + t.Fatalf("创建申诉失败: %v", err) + } + if created.Status != "open" { + t.Fatalf("申诉状态 = %s, want open", created.Status) + } + + cancelled, err := repo.CancelByOrder(t.Context(), renter.ID, order.ID) + if err != nil { + t.Fatalf("取消申诉失败: %v", err) + } + if cancelled.Status != "cancelled" { + t.Fatalf("取消后申诉状态 = %s, want cancelled", cancelled.Status) + } + + var restored model.RentalOrder + if err := db.First(&restored, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if restored.Status != "renting" || restored.HandoffStatus != "received" || restored.SettlementStatus != "unsettled" { + t.Fatalf("订单状态未恢复: status=%s handoff=%s settlement=%s", restored.Status, restored.HandoffStatus, restored.SettlementStatus) + } + var records []model.HandoffRecord + if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&records).Error; err != nil { + t.Fatalf("读取交接记录失败: %v", err) + } + if len(records) != 2 { + t.Fatalf("交接记录数量 = %d, want 2", len(records)) + } + if records[0].Type != "dispute_opened" || records[1].Type != "dispute_cancelled" { + t.Fatalf("交接记录类型 = %s/%s, want dispute_opened/dispute_cancelled", records[0].Type, records[1].Type) + } +} + +func TestRepositoryCancelRestoresCheckoutDisputeStatus(t *testing.T) { + db := setupDisputeTestDB(t) + repo := NewRepository(db) + now := time.Now() + _, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{ + Status: "pending_checkout_accept", + HandoffStatus: "pending_renter_checkout", + SettlementStatus: "pending", + }) + checkout := model.OrderCheckout{ + OrderID: order.ID, + InitiatedBy: renter.ID, + Status: "countered", + OwnerAdjustmentReason: "补扣押金", + OwnerAdjustedAt: &now, + } + if err := db.Create(&checkout).Error; err != nil { + t.Fatalf("创建结账记录失败: %v", err) + } + + if _, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{ + Type: "checkout_amount", + Description: "不同意结账修正", + }); err != nil { + t.Fatalf("创建结账争议失败: %v", err) + } + + if _, err := repo.CancelByOrder(t.Context(), renter.ID, order.ID); err != nil { + t.Fatalf("取消结账争议失败: %v", err) + } + + var restored model.RentalOrder + if err := db.First(&restored, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if restored.Status != "pending_checkout_accept" || restored.HandoffStatus != "pending_renter_checkout" || restored.SettlementStatus != "pending" { + t.Fatalf("订单状态未恢复: status=%s handoff=%s settlement=%s", restored.Status, restored.HandoffStatus, restored.SettlementStatus) + } + + var restoredCheckout model.OrderCheckout + if err := db.First(&restoredCheckout, checkout.ID).Error; err != nil { + t.Fatalf("读取结账记录失败: %v", err) + } + if restoredCheckout.Status != "countered" { + t.Fatalf("结账状态 = %s, want countered", restoredCheckout.Status) + } + if restoredCheckout.RenterRejectedAt != nil { + t.Fatalf("取消争议后 renter_rejected_at 应被清空") + } + var records []model.HandoffRecord + if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&records).Error; err != nil { + t.Fatalf("读取交接记录失败: %v", err) + } + if len(records) != 2 { + t.Fatalf("交接记录数量 = %d, want 2", len(records)) + } + if records[0].Type != "checkout_dispute_opened" || records[1].Type != "checkout_dispute_cancelled" { + t.Fatalf("交接记录类型 = %s/%s, want checkout_dispute_opened/checkout_dispute_cancelled", records[0].Type, records[1].Type) + } +} + func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) { order := model.RentalOrder{ ID: 11, diff --git a/backend/internal/modules/dispute/service.go b/backend/internal/modules/dispute/service.go index 52cf30c..cc621b6 100644 --- a/backend/internal/modules/dispute/service.go +++ b/backend/internal/modules/dispute/service.go @@ -11,6 +11,7 @@ var ( ErrInvalidDispute = errors.New("invalid dispute") ErrDisputeExists = errors.New("dispute already exists") ErrDisputeCannotHandle = errors.New("dispute cannot handle") + ErrDisputeCannotCancel = errors.New("dispute cannot cancel") ) type Service struct { @@ -45,6 +46,26 @@ func (s *Service) FindForUser(ctx context.Context, userID uint64, id uint64) (*D return s.repo.FindForUser(ctx, userID, id) } +func (s *Service) Cancel(ctx context.Context, userID uint64, id uint64) (*DisputeDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if id == 0 { + return nil, ErrInvalidDispute + } + return s.repo.Cancel(ctx, userID, id) +} + +func (s *Service) CancelByOrder(ctx context.Context, userID uint64, orderID uint64) (*DisputeDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if orderID == 0 { + return nil, ErrInvalidDispute + } + return s.repo.CancelByOrder(ctx, userID, orderID) +} + func (s *Service) ListAdmin(ctx context.Context, 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 1a90418..6bfd1ed 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -9,36 +9,44 @@ import ( ) type OrderDTO struct { - ID uint64 `json:"id"` - OrderNo string `json:"order_no"` - ListingID uint64 `json:"listing_id"` - ListingNo string `json:"listing_no"` - AccountID uint64 `json:"account_id"` - OwnerID uint64 `json:"owner_id"` - RenterID uint64 `json:"renter_id"` - OwnerPhone string `json:"owner_phone,omitempty"` - RenterPhone string `json:"renter_phone,omitempty"` - Title string `json:"title"` - ServerRegion string `json:"server_region"` - LoginPlatform string `json:"login_platform"` - RentedAt *time.Time `json:"rented_at"` - EstimatedDurationHours int `json:"estimated_duration_hours"` - PriceRole string `json:"price_role,omitempty"` - DisplayAmountCent int64 `json:"display_amount_cent"` - RentAmountCent *int64 `json:"rent_amount_cent,omitempty"` - OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"` - DepositAmountCent int64 `json:"deposit_amount_cent"` - DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"` - DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"` - PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` - AccountSnapshot datatypes.JSON `json:"account_snapshot"` - Status string `json:"status"` - HandoffStatus string `json:"handoff_status"` - SettlementStatus string `json:"settlement_status"` - Checkout *CheckoutDTO `json:"checkout,omitempty"` - PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + OrderNo string `json:"order_no"` + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + RenterID uint64 `json:"renter_id"` + OwnerPhone string `json:"owner_phone,omitempty"` + RenterPhone string `json:"renter_phone,omitempty"` + Title string `json:"title"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RentedAt *time.Time `json:"rented_at"` + EstimatedDurationHours int `json:"estimated_duration_hours"` + PriceRole string `json:"price_role,omitempty"` + DisplayAmountCent int64 `json:"display_amount_cent"` + RentAmountCent *int64 `json:"rent_amount_cent,omitempty"` + OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"` + DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"` + PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` + AccountSnapshot datatypes.JSON `json:"account_snapshot"` + Status string `json:"status"` + HandoffStatus string `json:"handoff_status"` + SettlementStatus string `json:"settlement_status"` + ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"` + Checkout *CheckoutDTO `json:"checkout,omitempty"` + PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ActiveDisputeDTO struct { + ID uint64 `json:"id"` + InitiatorID uint64 `json:"initiator_id"` + Type string `json:"type"` + Status string `json:"status"` } type CreateRequest struct { diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index 7aa3f3d..cd082d7 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -75,6 +75,7 @@ func (r *Repository) FindAdmin(ctx context.Context, orderID uint64) (*OrderDTO, } dto := row.toAdminDTO() applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db)) + dto.ActiveDispute = r.activeDisputeDTO(ctx, orderID) dto.Checkout = r.latestCheckoutAdminDTO(ctx, orderID) return &dto, nil } @@ -89,6 +90,7 @@ func (r *Repository) FindForUser(ctx context.Context, userID uint64, orderID uin } dto := row.toDTOForUser(userID) applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db)) + dto.ActiveDispute = r.activeDisputeDTO(ctx, orderID) dto.Checkout = r.latestCheckoutDTOForUser(ctx, orderID, userID, row.RentalOrder) return &dto, nil } @@ -123,6 +125,22 @@ func (r *Repository) latestCheckoutAdminDTO(ctx context.Context, orderID uint64) return &dto } +func (r *Repository) activeDisputeDTO(ctx context.Context, orderID uint64) *ActiveDisputeDTO { + var row model.Dispute + if err := r.db.WithContext(ctx). + Where("order_id = ? AND status IN ?", orderID, []string{"open", "processing"}). + Order("id DESC"). + First(&row).Error; err != nil { + return nil + } + return &ActiveDisputeDTO{ + ID: row.ID, + InitiatorID: row.InitiatorID, + Type: row.Type, + Status: row.Status, + } +} + func shouldAttachCheckout(status string) bool { switch status { case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted: diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 24287d7..ae45e67 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -348,6 +348,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { orderRoutes.POST("/:id/checkout/counter", orderHandler.CounterCheckout) orderRoutes.POST("/:id/checkout/accept", orderHandler.AcceptCheckout) orderRoutes.POST("/:id/dispute", disputeHandler.Create) + orderRoutes.POST("/:id/dispute/cancel", disputeHandler.CancelByOrder) orderRoutes.GET("/:id/refund-status", paymentHandler.QueryRefundStatus) } @@ -355,6 +356,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { { disputeRoutes.GET("", disputeHandler.List) disputeRoutes.GET("/:id", disputeHandler.Detail) + disputeRoutes.POST("/:id/cancel", disputeHandler.Cancel) } walletRoutes := api.Group("/wallet", requireAuth) diff --git a/backend/migrations/000002_dispute_cancel_snapshot.sql b/backend/migrations/000002_dispute_cancel_snapshot.sql new file mode 100644 index 0000000..89d7240 --- /dev/null +++ b/backend/migrations/000002_dispute_cancel_snapshot.sql @@ -0,0 +1,111 @@ +-- +goose Up + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND COLUMN_NAME = 'previous_order_status') = 0, + 'ALTER TABLE disputes ADD COLUMN previous_order_status VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''申诉前订单状态''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND COLUMN_NAME = 'previous_handoff_status') = 0, + 'ALTER TABLE disputes ADD COLUMN previous_handoff_status VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''申诉前交接状态''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND COLUMN_NAME = 'previous_settlement_status') = 0, + 'ALTER TABLE disputes ADD COLUMN previous_settlement_status VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''申诉前结算状态''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND COLUMN_NAME = 'checkout_id') = 0, + 'ALTER TABLE disputes ADD COLUMN checkout_id BIGINT UNSIGNED NULL COMMENT ''关联结账记录ID''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND COLUMN_NAME = 'previous_checkout_status') = 0, + 'ALTER TABLE disputes ADD COLUMN previous_checkout_status VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''申诉前结账记录状态''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND INDEX_NAME = 'idx_disputes_checkout_id') = 0, + 'ALTER TABLE disputes ADD KEY idx_disputes_checkout_id (checkout_id)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- +goose Down + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND INDEX_NAME = 'idx_disputes_checkout_id') > 0, + 'ALTER TABLE disputes DROP INDEX idx_disputes_checkout_id', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND COLUMN_NAME = 'previous_checkout_status') > 0, + 'ALTER TABLE disputes DROP COLUMN previous_checkout_status', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND COLUMN_NAME = 'checkout_id') > 0, + 'ALTER TABLE disputes DROP COLUMN checkout_id', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND COLUMN_NAME = 'previous_settlement_status') > 0, + 'ALTER TABLE disputes DROP COLUMN previous_settlement_status', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND COLUMN_NAME = 'previous_handoff_status') > 0, + 'ALTER TABLE disputes DROP COLUMN previous_handoff_status', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'disputes' AND COLUMN_NAME = 'previous_order_status') > 0, + 'ALTER TABLE disputes DROP COLUMN previous_order_status', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue index 0f8d3a3..3debc7a 100644 --- a/frontend/src/features/admin/views/AdminDisputesView.vue +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -5,7 +5,13 @@ import { computed, onMounted, ref } from 'vue' import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/features/disputes' import { fetchAdminFileBlob } from '@/shared/api/files' -import { disputeStatusLabel } from '@/shared/utils/statusLabels' +import AuthImage from '@/shared/components/business/AuthImage.vue' +import { + disputeStatusLabel, + handoffStatusLabel, + orderStatusLabel, + settlementStatusLabel, +} from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' import { formatListingNo } from '@/shared/utils/listingDisplay' import { yuanToCent } from '@/shared/utils/money' @@ -22,13 +28,87 @@ const amount = ref() const currentPage = ref(1) const currentPageSize = ref(20) const total = ref(0) +const statusFilter = ref('') +const typeFilter = ref('') const isPartialRefund = computed(() => result.value === 'partial_refund') +const filteredDisputes = computed(() => { + return disputes.value.filter(item => { + if (statusFilter.value && item.status !== statusFilter.value) return false + if (typeFilter.value && item.type !== typeFilter.value) return false + return true + }) +}) +const pendingCount = computed( + () => disputes.value.filter(item => ['open', 'processing'].includes(item.status)).length +) +const resolvedCount = computed(() => disputes.value.filter(item => item.status === 'resolved').length) +const cancelledCount = computed( + () => disputes.value.filter(item => item.status === 'cancelled').length +) const canSubmitArbitration = computed(() => { if (submitting.value || !activeDispute.value) return false if (!result.value || !remark.value.trim()) return false if (isPartialRefund.value && (!amount.value || amount.value <= 0)) return false return true }) +function canArbitrate(row: Dispute) { + return ['open', 'processing'].includes(row.status) +} + +function disputeTypeLabel(type: string) { + const map: Record = { + cannot_login: '无法登录', + false_description: '描述不符', + account_banned: '账号封禁', + asset_loss: '资产损失', + haf_coin_dispute: '哈夫币争议', + handoff_timeout: '交接超时', + return_timeout: '归还超时', + checkout_amount: '结账金额争议', + checkout_dispute: '结账争议', + } + return map[type] || type || '-' +} + +function arbitrationResultLabel(value: string) { + const map: Record = { + full_refund: '全额退款', + partial_refund: '部分退款', + deduct_deposit: '扣押金', + release_deposit: '释放押金', + compensate_owner: '赔付号主', + order_close: '关闭订单', + mark_abnormal: '标记异常', + } + return map[value] || value || '-' +} + +function disputeStatusTone(status: string) { + const map: Record = { + open: 'warning', + processing: 'warning', + resolved: 'success', + closed: 'info', + cancelled: 'info', + } + return map[status] || 'info' +} + +function roleLabel(row: Dispute, userID: number) { + if (userID === row.owner_id) return '号主' + if (userID === row.renter_id) return '租客' + return '用户' +} + +function userPhone(row: Dispute, userID: number) { + if (userID === row.owner_id) return row.owner_phone || '-' + if (userID === row.renter_id) return row.renter_phone || '-' + return '-' +} + +function evidenceCount(row: Dispute) { + return evidenceItems(row).length +} onMounted(loadDisputes) @@ -48,12 +128,18 @@ async function handlePageChange() { } function openArbitration(row: Dispute) { + if (!canArbitrate(row)) return activeDispute.value = row result.value = row.arbitration_result || 'release_deposit' remark.value = row.arbitration_remark || '' amount.value = undefined } +function resetFilters() { + statusFilter.value = '' + typeFilter.value = '' +} + function evidenceItems(row: Dispute | null) { const raw = row?.evidence_urls if (!raw) return [] @@ -70,20 +156,97 @@ function extractObjectKey(url: string) { } } +function isObjectKeyLike(value: string) { + const trimmed = value.trim() + if (!trimmed || trimmed.includes('\n')) return false + if (/^(https?:|blob:|data:|mailto:|tel:)/i.test(trimmed) || trimmed.startsWith('/')) return false + return /^[a-z0-9][a-z0-9/_-]*\/[^?#]+\.[a-z0-9]{2,8}$/i.test(trimmed) +} + +function evidenceObjectKey(value: string) { + return extractObjectKey(value) || (isObjectKeyLike(value) ? value.trim() : '') +} + +function evidenceSource(value: string) { + const key = evidenceObjectKey(value) + if (key && isObjectKeyLike(value)) { + return `/api/admin/files/object?key=${encodeURIComponent(key)}` + } + return value +} + +function evidenceName(value: string, index: number) { + const key = evidenceObjectKey(value) + const source = key || value + try { + const parsed = new URL(source, window.location.origin) + const part = decodeURIComponent(parsed.pathname.split('/').filter(Boolean).pop() || '') + if (part) return part + } catch { + // 非 URL 文本证据直接走下面的普通解析。 + } + const part = decodeURIComponent(source.split('/').filter(Boolean).pop() || '') + return part || `证据 ${index + 1}` +} + +function evidenceDetail(value: string) { + const key = evidenceObjectKey(value) + if (key) return decodeURIComponent(key) + if (value.startsWith('/api/files/object') || value.startsWith('/api/admin/files/object')) return value + try { + const parsed = new URL(value, window.location.origin) + return parsed.href + } catch { + return value + } +} + +function evidenceKind(value: string) { + const name = evidenceName(value, 0).toLowerCase() + if (/\.(png|jpe?g|webp|gif|bmp|avif)$/.test(name)) return 'image' + if (name.endsWith('.pdf')) return 'pdf' + if (value.startsWith('http') || value.startsWith('/api/')) return 'link' + return 'text' +} + +function evidenceKindLabel(value: string) { + const map: Record = { + image: '图片', + pdf: 'PDF', + link: '链接', + text: '文本', + } + return map[evidenceKind(value)] || '证据' +} + +async function copyEvidence(value: string) { + try { + await navigator.clipboard.writeText(evidenceDetail(value)) + ElMessage.success('证据信息已复制') + } catch { + ElMessage.error('复制失败') + } +} + async function openEvidence(url: string) { const key = extractObjectKey(url) - if (!key) { + const objectKey = key || (isObjectKeyLike(url) ? url.trim() : '') + if (objectKey) { + try { + const blob = await fetchAdminFileBlob(objectKey) + const objectURL = URL.createObjectURL(blob) + window.open(objectURL, '_blank') + window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000) + } catch (error) { + ElMessage.error(readError(error, '证据文件打开失败')) + } + return + } + if (/^https?:\/\//i.test(url) || url.startsWith('/api/')) { window.open(url, '_blank') return } - try { - const blob = await fetchAdminFileBlob(key) - const objectURL = URL.createObjectURL(blob) - window.open(objectURL, '_blank') - window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000) - } catch (error) { - ElMessage.error(readError(error, '证据文件打开失败')) - } + await copyEvidence(url) } async function handleArbitrate() { @@ -119,32 +282,104 @@ async function handleArbitrate() {