优化申诉仲裁流程和证据展示
This commit is contained in:
@@ -281,7 +281,7 @@ func openE2EDB(t *testing.T) *gorm.DB {
|
|||||||
t.Fatalf("连接测试库失败: %v", err)
|
t.Fatalf("连接测试库失败: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
applyInitMigration(t, sqlDB)
|
applyMigrations(t, sqlDB)
|
||||||
|
|
||||||
gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB}), &gorm.Config{
|
gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB}), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
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()
|
t.Helper()
|
||||||
_, currentFile, _, ok := runtime.Caller(0)
|
_, currentFile, _, ok := runtime.Caller(0)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("无法定位当前测试文件")
|
t.Fatal("无法定位当前测试文件")
|
||||||
}
|
}
|
||||||
migrationPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations", "000001_init.sql")
|
migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations")
|
||||||
raw, err := os.ReadFile(migrationPath)
|
for _, name := range []string{"000001_init.sql", "000002_dispute_cancel_snapshot.sql"} {
|
||||||
if err != nil {
|
migrationPath := filepath.Join(migrationDir, name)
|
||||||
t.Fatalf("读取初始化迁移失败: %v", err)
|
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) {
|
func seedUsers(t *testing.T, db *gorm.DB) (model.User, model.User, uint64) {
|
||||||
|
|||||||
@@ -7,20 +7,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Dispute struct {
|
type Dispute struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||||
InitiatorID uint64 `gorm:"not null" json:"initiator_id"`
|
InitiatorID uint64 `gorm:"not null" json:"initiator_id"`
|
||||||
TargetUserID uint64 `gorm:"not null" json:"target_user_id"`
|
TargetUserID uint64 `gorm:"not null" json:"target_user_id"`
|
||||||
Type string `gorm:"size:32;not null" json:"type"`
|
Type string `gorm:"size:32;not null" json:"type"`
|
||||||
Status string `gorm:"size:32;not null;default:'open'" json:"status"`
|
Status string `gorm:"size:32;not null;default:'open'" json:"status"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
EvidenceURLS datatypes.JSON `gorm:"column:evidence_urls" json:"evidence_urls"`
|
EvidenceURLS datatypes.JSON `gorm:"column:evidence_urls" json:"evidence_urls"`
|
||||||
ArbitrationResult string `gorm:"size:32;not null;default:''" json:"arbitration_result"`
|
PreviousOrderStatus string `gorm:"size:32;not null;default:''" json:"previous_order_status"`
|
||||||
ArbitrationRemark string `json:"arbitration_remark"`
|
PreviousHandoffStatus string `gorm:"size:32;not null;default:''" json:"previous_handoff_status"`
|
||||||
HandledBy *uint64 `json:"handled_by"`
|
PreviousSettlementStatus string `gorm:"size:32;not null;default:''" json:"previous_settlement_status"`
|
||||||
HandledAt *time.Time `json:"handled_at"`
|
CheckoutID *uint64 `json:"checkout_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
PreviousCheckoutStatus string `gorm:"size:32;not null;default:''" json:"previous_checkout_status"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 {
|
func (Dispute) TableName() string {
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,23 +9,35 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DisputeDTO struct {
|
type DisputeDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
OrderID uint64 `json:"order_id"`
|
OrderID uint64 `json:"order_id"`
|
||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
ListingNo string `json:"listing_no"`
|
OrderStatus string `json:"order_status"`
|
||||||
Title string `json:"title"`
|
HandoffStatus string `json:"handoff_status"`
|
||||||
InitiatorID uint64 `json:"initiator_id"`
|
SettlementStatus string `json:"settlement_status"`
|
||||||
TargetUserID uint64 `json:"target_user_id"`
|
ListingNo string `json:"listing_no"`
|
||||||
Type string `json:"type"`
|
Title string `json:"title"`
|
||||||
Status string `json:"status"`
|
OwnerID uint64 `json:"owner_id"`
|
||||||
Description string `json:"description"`
|
RenterID uint64 `json:"renter_id"`
|
||||||
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
|
OwnerPhone string `json:"owner_phone"`
|
||||||
ArbitrationResult string `json:"arbitration_result"`
|
RenterPhone string `json:"renter_phone"`
|
||||||
ArbitrationRemark string `json:"arbitration_remark"`
|
InitiatorID uint64 `json:"initiator_id"`
|
||||||
HandledBy *uint64 `json:"handled_by"`
|
TargetUserID uint64 `json:"target_user_id"`
|
||||||
HandledAt *time.Time `json:"handled_at"`
|
Type string `json:"type"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Status string `json:"status"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 {
|
type CreateRequest struct {
|
||||||
|
|||||||
@@ -75,6 +75,42 @@ func (h *Handler) Detail(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func (h *Handler) AdminList(c *gin.Context) {
|
||||||
page, pageSize := parsePagination(c)
|
page, pageSize := parsePagination(c)
|
||||||
result, err := h.service.ListAdmin(c.Request.Context(), page, pageSize)
|
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", "该订单已有处理中申诉")
|
response.Error(c, http.StatusConflict, "dispute_exists", "该订单已有处理中申诉")
|
||||||
case errors.Is(err, ErrDisputeCannotHandle):
|
case errors.Is(err, ErrDisputeCannotHandle):
|
||||||
response.Error(c, http.StatusConflict, "dispute_cannot_handle", "当前申诉不能仲裁")
|
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):
|
case errors.Is(err, ErrPermissionDenied):
|
||||||
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该申诉")
|
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该申诉")
|
||||||
case IsNotFound(err):
|
case IsNotFound(err):
|
||||||
|
|||||||
@@ -44,13 +44,27 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
row := model.Dispute{
|
row := model.Dispute{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
InitiatorID: userID,
|
InitiatorID: userID,
|
||||||
TargetUserID: targetID,
|
TargetUserID: targetID,
|
||||||
Type: disputeType(req.Type, isCheckoutDispute),
|
Type: disputeType(req.Type, isCheckoutDispute),
|
||||||
Status: "open",
|
Status: "open",
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
EvidenceURLS: evidence,
|
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 {
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -68,13 +82,29 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64,
|
|||||||
updates["renter_rejected_at"] = now
|
updates["renter_rejected_at"] = now
|
||||||
}
|
}
|
||||||
if err := tx.Model(&model.OrderCheckout{}).
|
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 {
|
Updates(updates).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
order.Status = "disputing"
|
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 {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,30 +10,49 @@ import (
|
|||||||
|
|
||||||
type disputeRow struct {
|
type disputeRow struct {
|
||||||
model.Dispute
|
model.Dispute
|
||||||
OrderNo string
|
OrderNo string
|
||||||
ListingNo string
|
OrderStatus string
|
||||||
Title string
|
HandoffStatus string
|
||||||
|
SettlementStatus string
|
||||||
|
ListingNo string
|
||||||
|
Title string
|
||||||
|
OwnerID uint64
|
||||||
|
RenterID uint64
|
||||||
|
OwnerPhone string
|
||||||
|
RenterPhone string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (row disputeRow) toDTO() DisputeDTO {
|
func (row disputeRow) toDTO() DisputeDTO {
|
||||||
return DisputeDTO{
|
return DisputeDTO{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
OrderID: row.OrderID,
|
OrderID: row.OrderID,
|
||||||
OrderNo: row.OrderNo,
|
OrderNo: row.OrderNo,
|
||||||
ListingNo: row.ListingNo,
|
OrderStatus: row.OrderStatus,
|
||||||
Title: row.Title,
|
HandoffStatus: row.HandoffStatus,
|
||||||
InitiatorID: row.InitiatorID,
|
SettlementStatus: row.SettlementStatus,
|
||||||
TargetUserID: row.TargetUserID,
|
ListingNo: row.ListingNo,
|
||||||
Type: row.Type,
|
Title: row.Title,
|
||||||
Status: row.Status,
|
OwnerID: row.OwnerID,
|
||||||
Description: row.Description,
|
RenterID: row.RenterID,
|
||||||
EvidenceURLS: row.EvidenceURLS,
|
OwnerPhone: row.OwnerPhone,
|
||||||
ArbitrationResult: row.ArbitrationResult,
|
RenterPhone: row.RenterPhone,
|
||||||
ArbitrationRemark: row.ArbitrationRemark,
|
InitiatorID: row.InitiatorID,
|
||||||
HandledBy: row.HandledBy,
|
TargetUserID: row.TargetUserID,
|
||||||
HandledAt: row.HandledAt,
|
Type: row.Type,
|
||||||
CreatedAt: row.CreatedAt,
|
Status: row.Status,
|
||||||
UpdatedAt: row.UpdatedAt,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,12 @@ func (r *Repository) ListAdmin(ctx context.Context, page, pageSize int) (*Pagina
|
|||||||
|
|
||||||
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||||
return r.db.WithContext(ctx).Table("disputes AS d").
|
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_orders AS o ON o.id = d.order_id").
|
||||||
Joins("JOIN rental_listings AS l ON l.id = o.listing_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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,187 @@ package dispute
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"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) {
|
func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) {
|
||||||
order := model.RentalOrder{
|
order := model.RentalOrder{
|
||||||
ID: 11,
|
ID: 11,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ var (
|
|||||||
ErrInvalidDispute = errors.New("invalid dispute")
|
ErrInvalidDispute = errors.New("invalid dispute")
|
||||||
ErrDisputeExists = errors.New("dispute already exists")
|
ErrDisputeExists = errors.New("dispute already exists")
|
||||||
ErrDisputeCannotHandle = errors.New("dispute cannot handle")
|
ErrDisputeCannotHandle = errors.New("dispute cannot handle")
|
||||||
|
ErrDisputeCannotCancel = errors.New("dispute cannot cancel")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
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)
|
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) {
|
func (s *Service) ListAdmin(ctx context.Context, page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -9,36 +9,44 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OrderDTO struct {
|
type OrderDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
ListingID uint64 `json:"listing_id"`
|
ListingID uint64 `json:"listing_id"`
|
||||||
ListingNo string `json:"listing_no"`
|
ListingNo string `json:"listing_no"`
|
||||||
AccountID uint64 `json:"account_id"`
|
AccountID uint64 `json:"account_id"`
|
||||||
OwnerID uint64 `json:"owner_id"`
|
OwnerID uint64 `json:"owner_id"`
|
||||||
RenterID uint64 `json:"renter_id"`
|
RenterID uint64 `json:"renter_id"`
|
||||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||||
RenterPhone string `json:"renter_phone,omitempty"`
|
RenterPhone string `json:"renter_phone,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
ServerRegion string `json:"server_region"`
|
ServerRegion string `json:"server_region"`
|
||||||
LoginPlatform string `json:"login_platform"`
|
LoginPlatform string `json:"login_platform"`
|
||||||
RentedAt *time.Time `json:"rented_at"`
|
RentedAt *time.Time `json:"rented_at"`
|
||||||
EstimatedDurationHours int `json:"estimated_duration_hours"`
|
EstimatedDurationHours int `json:"estimated_duration_hours"`
|
||||||
PriceRole string `json:"price_role,omitempty"`
|
PriceRole string `json:"price_role,omitempty"`
|
||||||
DisplayAmountCent int64 `json:"display_amount_cent"`
|
DisplayAmountCent int64 `json:"display_amount_cent"`
|
||||||
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
||||||
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
||||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||||
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
||||||
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
||||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
HandoffStatus string `json:"handoff_status"`
|
HandoffStatus string `json:"handoff_status"`
|
||||||
SettlementStatus string `json:"settlement_status"`
|
SettlementStatus string `json:"settlement_status"`
|
||||||
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
|
||||||
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 {
|
type CreateRequest struct {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func (r *Repository) FindAdmin(ctx context.Context, orderID uint64) (*OrderDTO,
|
|||||||
}
|
}
|
||||||
dto := row.toAdminDTO()
|
dto := row.toAdminDTO()
|
||||||
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db))
|
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db))
|
||||||
|
dto.ActiveDispute = r.activeDisputeDTO(ctx, orderID)
|
||||||
dto.Checkout = r.latestCheckoutAdminDTO(ctx, orderID)
|
dto.Checkout = r.latestCheckoutAdminDTO(ctx, orderID)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@@ -89,6 +90,7 @@ func (r *Repository) FindForUser(ctx context.Context, userID uint64, orderID uin
|
|||||||
}
|
}
|
||||||
dto := row.toDTOForUser(userID)
|
dto := row.toDTOForUser(userID)
|
||||||
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db))
|
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db))
|
||||||
|
dto.ActiveDispute = r.activeDisputeDTO(ctx, orderID)
|
||||||
dto.Checkout = r.latestCheckoutDTOForUser(ctx, orderID, userID, row.RentalOrder)
|
dto.Checkout = r.latestCheckoutDTOForUser(ctx, orderID, userID, row.RentalOrder)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,22 @@ func (r *Repository) latestCheckoutAdminDTO(ctx context.Context, orderID uint64)
|
|||||||
return &dto
|
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 {
|
func shouldAttachCheckout(status string) bool {
|
||||||
switch status {
|
switch status {
|
||||||
case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted:
|
case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted:
|
||||||
|
|||||||
@@ -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/counter", orderHandler.CounterCheckout)
|
||||||
orderRoutes.POST("/:id/checkout/accept", orderHandler.AcceptCheckout)
|
orderRoutes.POST("/:id/checkout/accept", orderHandler.AcceptCheckout)
|
||||||
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
|
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
|
||||||
|
orderRoutes.POST("/:id/dispute/cancel", disputeHandler.CancelByOrder)
|
||||||
orderRoutes.GET("/:id/refund-status", paymentHandler.QueryRefundStatus)
|
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("", disputeHandler.List)
|
||||||
disputeRoutes.GET("/:id", disputeHandler.Detail)
|
disputeRoutes.GET("/:id", disputeHandler.Detail)
|
||||||
|
disputeRoutes.POST("/:id/cancel", disputeHandler.Cancel)
|
||||||
}
|
}
|
||||||
|
|
||||||
walletRoutes := api.Group("/wallet", requireAuth)
|
walletRoutes := api.Group("/wallet", requireAuth)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -5,7 +5,13 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
|
|
||||||
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/features/disputes'
|
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/features/disputes'
|
||||||
import { fetchAdminFileBlob } from '@/shared/api/files'
|
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 { formatDateTime } from '@/shared/utils/time'
|
||||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||||
import { yuanToCent } from '@/shared/utils/money'
|
import { yuanToCent } from '@/shared/utils/money'
|
||||||
@@ -22,13 +28,87 @@ const amount = ref<number | undefined>()
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const currentPageSize = ref(20)
|
const currentPageSize = ref(20)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const typeFilter = ref('')
|
||||||
const isPartialRefund = computed(() => result.value === 'partial_refund')
|
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(() => {
|
const canSubmitArbitration = computed(() => {
|
||||||
if (submitting.value || !activeDispute.value) return false
|
if (submitting.value || !activeDispute.value) return false
|
||||||
if (!result.value || !remark.value.trim()) return false
|
if (!result.value || !remark.value.trim()) return false
|
||||||
if (isPartialRefund.value && (!amount.value || amount.value <= 0)) return false
|
if (isPartialRefund.value && (!amount.value || amount.value <= 0)) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
function canArbitrate(row: Dispute) {
|
||||||
|
return ['open', 'processing'].includes(row.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disputeTypeLabel(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, 'success' | 'warning' | 'info' | 'danger'> = {
|
||||||
|
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)
|
onMounted(loadDisputes)
|
||||||
|
|
||||||
@@ -48,12 +128,18 @@ async function handlePageChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openArbitration(row: Dispute) {
|
function openArbitration(row: Dispute) {
|
||||||
|
if (!canArbitrate(row)) return
|
||||||
activeDispute.value = row
|
activeDispute.value = row
|
||||||
result.value = row.arbitration_result || 'release_deposit'
|
result.value = row.arbitration_result || 'release_deposit'
|
||||||
remark.value = row.arbitration_remark || ''
|
remark.value = row.arbitration_remark || ''
|
||||||
amount.value = undefined
|
amount.value = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
statusFilter.value = ''
|
||||||
|
typeFilter.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function evidenceItems(row: Dispute | null) {
|
function evidenceItems(row: Dispute | null) {
|
||||||
const raw = row?.evidence_urls
|
const raw = row?.evidence_urls
|
||||||
if (!raw) return []
|
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<string, string> = {
|
||||||
|
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) {
|
async function openEvidence(url: string) {
|
||||||
const key = extractObjectKey(url)
|
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')
|
window.open(url, '_blank')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
await copyEvidence(url)
|
||||||
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, '证据文件打开失败'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleArbitrate() {
|
async function handleArbitrate() {
|
||||||
@@ -119,32 +282,104 @@ async function handleArbitrate() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page">
|
<section class="page">
|
||||||
<div class="page-header">
|
<div class="page-header-row">
|
||||||
<p class="eyebrow">Arbitration</p>
|
<div class="page-header">
|
||||||
<h1>仲裁中心</h1>
|
<p class="eyebrow">申诉仲裁</p>
|
||||||
<p>处理无法登录、资产损失、哈夫币争议和结账争议。</p>
|
<h1>仲裁中心</h1>
|
||||||
|
<p>集中处理订单申诉、结账争议、证据查看和客服裁决。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="disputes">
|
<div class="dispute-summary">
|
||||||
|
<div class="summary-item">
|
||||||
|
<span>当前页待处理</span>
|
||||||
|
<strong>{{ pendingCount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span>当前页已处理</span>
|
||||||
|
<strong>{{ resolvedCount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span>当前页已取消</span>
|
||||||
|
<strong>{{ cancelledCount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span>当前页合计</span>
|
||||||
|
<strong>{{ disputes.length }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-panel">
|
||||||
|
<el-select v-model="statusFilter" clearable placeholder="全部状态" class="filter-control">
|
||||||
|
<el-option label="待处理" value="open" />
|
||||||
|
<el-option label="处理中" value="processing" />
|
||||||
|
<el-option label="已处理" value="resolved" />
|
||||||
|
<el-option label="已关闭" value="closed" />
|
||||||
|
<el-option label="已取消" value="cancelled" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="typeFilter" clearable placeholder="全部类型" class="filter-control">
|
||||||
|
<el-option label="无法登录" value="cannot_login" />
|
||||||
|
<el-option label="描述不符" value="false_description" />
|
||||||
|
<el-option label="账号封禁" value="account_banned" />
|
||||||
|
<el-option label="资产损失" value="asset_loss" />
|
||||||
|
<el-option label="哈夫币争议" value="haf_coin_dispute" />
|
||||||
|
<el-option label="交接超时" value="handoff_timeout" />
|
||||||
|
<el-option label="归还超时" value="return_timeout" />
|
||||||
|
<el-option label="结账争议" value="checkout_dispute" />
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" class="table-panel dispute-table" :data="filteredDisputes">
|
||||||
<el-table-column label="商品编号" width="140">
|
<el-table-column label="商品编号" width="140">
|
||||||
<template #default="{ row }">{{ formatListingNo(row.listing_no) }}</template>
|
<template #default="{ row }">{{ formatListingNo(row.listing_no) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
||||||
<el-table-column prop="title" label="账号" min-width="160" />
|
<el-table-column label="账号信息" min-width="190" show-overflow-tooltip>
|
||||||
<el-table-column prop="type" label="类型" width="150" />
|
<template #default="{ row }">
|
||||||
<el-table-column label="状态" width="110">
|
<div class="main-cell">
|
||||||
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
|
<strong>{{ row.title }}</strong>
|
||||||
|
<span>{{ orderStatusLabel(row.order_status) }} · {{ handoffStatusLabel(row.handoff_status) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="申诉类型" width="140">
|
||||||
|
<template #default="{ row }">{{ disputeTypeLabel(row.type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="申诉状态" width="115">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="disputeStatusTone(row.status)" effect="light">
|
||||||
|
{{ disputeStatusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="双方信息" min-width="240">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="party-cell">
|
||||||
|
<span>申诉方:{{ roleLabel(row, row.initiator_id) }} {{ userPhone(row, row.initiator_id) }}</span>
|
||||||
|
<span>被申诉方:{{ roleLabel(row, row.target_user_id) }} {{ userPhone(row, row.target_user_id) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="申诉说明" min-width="240" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.description || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="证据" width="90">
|
||||||
|
<template #default="{ row }">{{ evidenceCount(row) }} 份</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="创建时间" min-width="180">
|
<el-table-column label="创建时间" min-width="180">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column label="结算状态" width="115">
|
||||||
prop="arbitration_result"
|
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
|
||||||
label="仲裁结果"
|
</el-table-column>
|
||||||
width="150"
|
<el-table-column label="仲裁结果" width="135" show-overflow-tooltip>
|
||||||
show-overflow-tooltip
|
<template #default="{ row }">{{ arbitrationResultLabel(row.arbitration_result) }}</template>
|
||||||
/>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="160">
|
<el-table-column label="处理时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ row.handled_at ? formatDateTime(row.handled_at) : '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="170" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -154,7 +389,7 @@ async function handleArbitrate() {
|
|||||||
>
|
>
|
||||||
<el-button
|
<el-button
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="row.status === 'resolved'"
|
:disabled="!canArbitrate(row)"
|
||||||
@click="openArbitration(row)"
|
@click="openArbitration(row)"
|
||||||
>仲裁</el-button
|
>仲裁</el-button
|
||||||
>
|
>
|
||||||
@@ -182,6 +417,11 @@ async function handleArbitrate() {
|
|||||||
<strong>{{ activeDispute.order_no }}</strong> · 商品编号
|
<strong>{{ activeDispute.order_no }}</strong> · 商品编号
|
||||||
{{ formatListingNo(activeDispute.listing_no) }} · {{ activeDispute.title }}
|
{{ formatListingNo(activeDispute.listing_no) }} · {{ activeDispute.title }}
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ disputeTypeLabel(activeDispute.type) }} ·
|
||||||
|
{{ disputeStatusLabel(activeDispute.status) }} ·
|
||||||
|
{{ orderStatusLabel(activeDispute.order_status) }}
|
||||||
|
</p>
|
||||||
<p>{{ activeDispute.description }}</p>
|
<p>{{ activeDispute.description }}</p>
|
||||||
<el-select v-model="result" class="full-control" placeholder="选择裁决结果">
|
<el-select v-model="result" class="full-control" placeholder="选择裁决结果">
|
||||||
<el-option label="全额退款" value="full_refund" />
|
<el-option label="全额退款" value="full_refund" />
|
||||||
@@ -230,17 +470,81 @@ async function handleArbitrate() {
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="!!evidenceDispute"
|
:model-value="!!evidenceDispute"
|
||||||
title="申诉证据"
|
title="申诉证据"
|
||||||
width="640px"
|
width="760px"
|
||||||
@update:model-value="evidenceDispute = null"
|
@update:model-value="evidenceDispute = null"
|
||||||
>
|
>
|
||||||
<div v-if="evidenceDispute" class="dialog-body">
|
<div v-if="evidenceDispute" class="evidence-dialog">
|
||||||
<p>
|
<div class="evidence-header">
|
||||||
<strong>{{ evidenceDispute.order_no }}</strong> · 商品编号
|
<div>
|
||||||
{{ formatListingNo(evidenceDispute.listing_no) }} · {{ evidenceDispute.title }}
|
<strong>{{ evidenceDispute.order_no }}</strong>
|
||||||
</p>
|
<span>商品编号 {{ formatListingNo(evidenceDispute.listing_no) }}</span>
|
||||||
<div v-for="item in evidenceItems(evidenceDispute)" :key="item" class="evidence-row">
|
</div>
|
||||||
<span>{{ item }}</span>
|
<el-tag :type="disputeStatusTone(evidenceDispute.status)" effect="light">
|
||||||
<el-button size="small" @click="openEvidence(item)">打开</el-button>
|
{{ disputeStatusLabel(evidenceDispute.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evidence-meta-grid">
|
||||||
|
<div>
|
||||||
|
<span>账号</span>
|
||||||
|
<strong>{{ evidenceDispute.title }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>类型</span>
|
||||||
|
<strong>{{ disputeTypeLabel(evidenceDispute.type) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>证据数量</span>
|
||||||
|
<strong>{{ evidenceCount(evidenceDispute) }} 份</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>创建时间</span>
|
||||||
|
<strong>{{ formatDateTime(evidenceDispute.created_at) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evidence-description">
|
||||||
|
<span>申诉说明</span>
|
||||||
|
<p>{{ evidenceDispute.description || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty
|
||||||
|
v-if="evidenceItems(evidenceDispute).length === 0"
|
||||||
|
description="暂无证据文件"
|
||||||
|
/>
|
||||||
|
<div v-else class="evidence-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in evidenceItems(evidenceDispute)"
|
||||||
|
:key="`${item}-${index}`"
|
||||||
|
class="evidence-card"
|
||||||
|
>
|
||||||
|
<div class="evidence-preview" :class="'evidence-preview-' + evidenceKind(item)">
|
||||||
|
<AuthImage
|
||||||
|
v-if="evidenceKind(item) === 'image'"
|
||||||
|
:source="evidenceSource(item)"
|
||||||
|
admin
|
||||||
|
:alt="evidenceName(item, index)"
|
||||||
|
image-class="evidence-image"
|
||||||
|
fallback-class="evidence-image-fallback"
|
||||||
|
/>
|
||||||
|
<div v-else class="evidence-file-icon">
|
||||||
|
{{ evidenceKindLabel(item) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="evidence-info">
|
||||||
|
<div class="evidence-title-row">
|
||||||
|
<strong>{{ evidenceName(item, index) }}</strong>
|
||||||
|
<el-tag size="small" effect="plain">{{ evidenceKindLabel(item) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<p>{{ evidenceDetail(item) }}</p>
|
||||||
|
<div class="evidence-actions">
|
||||||
|
<el-button size="small" type="primary" plain @click="openEvidence(item)">
|
||||||
|
打开
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="copyEvidence(item)">复制</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -249,3 +553,253 @@ async function handleArbitrate() {
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dispute-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-cell,
|
||||||
|
.party-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-cell strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-cell span,
|
||||||
|
.party-cell span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispute-table :deep(.el-table__cell) {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-dialog {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-header div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-header strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-header span,
|
||||||
|
.evidence-meta-grid span,
|
||||||
|
.evidence-description span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-meta-grid > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-meta-grid strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-description {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-description p {
|
||||||
|
margin: 0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 420px;
|
||||||
|
padding-right: 4px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 132px minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 96px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-file-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4f46e5;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-card :deep(.evidence-image) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 96px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-card :deep(.evidence-image-fallback) {
|
||||||
|
min-height: 96px;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-info {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-title-row strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-info p {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.dispute-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-meta-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -7,14 +7,26 @@ export interface Dispute {
|
|||||||
id: number
|
id: number
|
||||||
order_id: number
|
order_id: number
|
||||||
order_no: string
|
order_no: string
|
||||||
|
order_status: string
|
||||||
|
handoff_status: string
|
||||||
|
settlement_status: string
|
||||||
listing_no: string
|
listing_no: string
|
||||||
title: string
|
title: string
|
||||||
|
owner_id: number
|
||||||
|
renter_id: number
|
||||||
|
owner_phone: string
|
||||||
|
renter_phone: string
|
||||||
initiator_id: number
|
initiator_id: number
|
||||||
target_user_id: number
|
target_user_id: number
|
||||||
type: string
|
type: string
|
||||||
status: DisputeStatus
|
status: DisputeStatus
|
||||||
description: string
|
description: string
|
||||||
evidence_urls?: string[]
|
evidence_urls?: string[]
|
||||||
|
previous_order_status?: string
|
||||||
|
previous_handoff_status?: string
|
||||||
|
previous_settlement_status?: string
|
||||||
|
checkout_id?: number
|
||||||
|
previous_checkout_status?: string
|
||||||
arbitration_result: string
|
arbitration_result: string
|
||||||
arbitration_remark: string
|
arbitration_remark: string
|
||||||
handled_by?: number
|
handled_by?: number
|
||||||
@@ -31,6 +43,16 @@ export async function createDispute(
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelOrderDispute(orderId: number) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute/cancel`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelDispute(id: number) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/disputes/${id}/cancel`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchDisputes(page = 1, pageSize = 20) {
|
export async function fetchDisputes(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/disputes', {
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/disputes', {
|
||||||
params: { page, page_size: pageSize },
|
params: { page, page_size: pageSize },
|
||||||
|
|||||||
@@ -33,12 +33,20 @@ export interface Order {
|
|||||||
status: OrderStatus
|
status: OrderStatus
|
||||||
handoff_status: HandoffStatus
|
handoff_status: HandoffStatus
|
||||||
settlement_status: SettlementStatus
|
settlement_status: SettlementStatus
|
||||||
|
active_dispute?: ActiveDispute
|
||||||
checkout?: Checkout
|
checkout?: Checkout
|
||||||
payment_deadline_at?: string
|
payment_deadline_at?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActiveDispute {
|
||||||
|
id: number
|
||||||
|
initiator_id: number
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Checkout {
|
export interface Checkout {
|
||||||
id: number
|
id: number
|
||||||
order_id: number
|
order_id: number
|
||||||
|
|||||||
@@ -237,6 +237,10 @@ export function formatHandoffRecordType(type: string) {
|
|||||||
renter_confirm_checkout: '买家确认结账',
|
renter_confirm_checkout: '买家确认结账',
|
||||||
owner_accept_checkout: '卖家接受结账',
|
owner_accept_checkout: '卖家接受结账',
|
||||||
admin_arbitration: '客服仲裁',
|
admin_arbitration: '客服仲裁',
|
||||||
|
dispute_opened: '发起申诉',
|
||||||
|
checkout_dispute_opened: '发起结账争议',
|
||||||
|
dispute_cancelled: '取消申诉',
|
||||||
|
checkout_dispute_cancelled: '取消结账争议',
|
||||||
}
|
}
|
||||||
return typeMap[type] || type
|
return typeMap[type] || type
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { showToast, showDialog } from 'vant'
|
import { showToast, showDialog } from 'vant'
|
||||||
|
|
||||||
import { fetchOrderChat } from '@/features/chats/api/chats'
|
import { fetchOrderChat } from '@/features/chats/api/chats'
|
||||||
import { createDispute } from '@/features/disputes/api/disputes'
|
import { cancelOrderDispute, createDispute } from '@/features/disputes/api/disputes'
|
||||||
import { uploadFile } from '@/shared/api/files'
|
import { uploadFile } from '@/shared/api/files'
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +57,7 @@ const countering = ref(false)
|
|||||||
const acceptingCheckout = ref(false)
|
const acceptingCheckout = ref(false)
|
||||||
const rejectingCheckout = ref(false)
|
const rejectingCheckout = ref(false)
|
||||||
const disputing = ref(false)
|
const disputing = ref(false)
|
||||||
|
const cancellingDispute = ref(false)
|
||||||
const uploadingEvidence = ref(false)
|
const uploadingEvidence = ref(false)
|
||||||
const openingChat = ref(false)
|
const openingChat = ref(false)
|
||||||
const order = ref<Order | null>(null)
|
const order = ref<Order | null>(null)
|
||||||
@@ -127,6 +128,14 @@ const canOpenDispute = computed(() => {
|
|||||||
'abnormal',
|
'abnormal',
|
||||||
].includes(order.value.status)
|
].includes(order.value.status)
|
||||||
})
|
})
|
||||||
|
const canCancelDispute = computed(() => {
|
||||||
|
return (
|
||||||
|
!!order.value &&
|
||||||
|
(isOwner.value || isRenter.value) &&
|
||||||
|
['disputing', 'checkout_disputing'].includes(order.value.status) &&
|
||||||
|
order.value.active_dispute?.initiator_id === session.userId
|
||||||
|
)
|
||||||
|
})
|
||||||
const isCheckoutDisputeStage = computed(() => {
|
const isCheckoutDisputeStage = computed(() => {
|
||||||
return (
|
return (
|
||||||
!!order.value &&
|
!!order.value &&
|
||||||
@@ -384,6 +393,26 @@ async function handleCreateDispute() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancelDispute() {
|
||||||
|
if (!order.value) return
|
||||||
|
showDialog({
|
||||||
|
title: order.value.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉',
|
||||||
|
message: '取消后订单将恢复到发起申诉前的流程,确定继续吗?',
|
||||||
|
showCancelButton: true,
|
||||||
|
}).then(async () => {
|
||||||
|
cancellingDispute.value = true
|
||||||
|
try {
|
||||||
|
await cancelOrderDispute(order.value!.id)
|
||||||
|
showToast({ message: '申诉已取消,订单已恢复', icon: 'passed' })
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
showToast({ message: readError(error, '取消申诉失败'), icon: 'cross' })
|
||||||
|
} finally {
|
||||||
|
cancellingDispute.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function handleEvidenceUpload(event: Event) {
|
async function handleEvidenceUpload(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
@@ -731,6 +760,20 @@ async function copyListingCode() {
|
|||||||
</van-button>
|
</van-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="canCancelDispute" class="card-section action-card">
|
||||||
|
<van-button
|
||||||
|
type="warning"
|
||||||
|
block
|
||||||
|
plain
|
||||||
|
round
|
||||||
|
:loading="cancellingDispute"
|
||||||
|
loading-text="取消中..."
|
||||||
|
@click="handleCancelDispute"
|
||||||
|
>
|
||||||
|
{{ order.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉' }}
|
||||||
|
</van-button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Snapshot Account Details Accordion -->
|
<!-- Snapshot Account Details Accordion -->
|
||||||
<section class="card-section">
|
<section class="card-section">
|
||||||
<van-collapse v-model="activeNames">
|
<van-collapse v-model="activeNames">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
import { fetchOrderChat } from '@/features/chats/api/chats'
|
import { fetchOrderChat } from '@/features/chats/api/chats'
|
||||||
import { createDispute } from '@/features/disputes'
|
import { cancelOrderDispute, createDispute } from '@/features/disputes'
|
||||||
import { uploadFile } from '@/shared/api/files'
|
import { uploadFile } from '@/shared/api/files'
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
import { formatCent } from '@/shared/utils/money'
|
import { formatCent } from '@/shared/utils/money'
|
||||||
@@ -56,6 +56,7 @@ const countering = ref(false)
|
|||||||
const acceptingCheckout = ref(false)
|
const acceptingCheckout = ref(false)
|
||||||
const rejectingCheckout = ref(false)
|
const rejectingCheckout = ref(false)
|
||||||
const disputing = ref(false)
|
const disputing = ref(false)
|
||||||
|
const cancellingDispute = ref(false)
|
||||||
const uploadingEvidence = ref(false)
|
const uploadingEvidence = ref(false)
|
||||||
const order = ref<Order | null>(null)
|
const order = ref<Order | null>(null)
|
||||||
const handoffRecords = ref<HandoffRecord[]>([])
|
const handoffRecords = ref<HandoffRecord[]>([])
|
||||||
@@ -116,6 +117,14 @@ const canOpenDispute = computed(() => {
|
|||||||
'abnormal',
|
'abnormal',
|
||||||
].includes(order.value.status)
|
].includes(order.value.status)
|
||||||
})
|
})
|
||||||
|
const canCancelDispute = computed(() => {
|
||||||
|
return (
|
||||||
|
!!order.value &&
|
||||||
|
(isOwner.value || isRenter.value) &&
|
||||||
|
['disputing', 'checkout_disputing'].includes(order.value.status) &&
|
||||||
|
order.value.active_dispute?.initiator_id === session.userId
|
||||||
|
)
|
||||||
|
})
|
||||||
const isCheckoutDisputeStage = computed(() => {
|
const isCheckoutDisputeStage = computed(() => {
|
||||||
return (
|
return (
|
||||||
!!order.value &&
|
!!order.value &&
|
||||||
@@ -438,6 +447,20 @@ async function handleCreateDispute() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancelDispute() {
|
||||||
|
if (!order.value) return
|
||||||
|
cancellingDispute.value = true
|
||||||
|
try {
|
||||||
|
await cancelOrderDispute(order.value.id)
|
||||||
|
ElMessage.success('申诉已取消,订单已恢复')
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '取消申诉失败'))
|
||||||
|
} finally {
|
||||||
|
cancellingDispute.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleEvidenceUpload(event: Event) {
|
async function handleEvidenceUpload(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
@@ -870,6 +893,22 @@ async function copyListingCode() {
|
|||||||
</el-button>
|
</el-button>
|
||||||
<p class="sidebar-hint">点击后滚动到申诉表单填写详细信息</p>
|
<p class="sidebar-hint">点击后滚动到申诉表单填写详细信息</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canCancelDispute" class="sidebar-card dispute-card">
|
||||||
|
<h3 class="sidebar-title">
|
||||||
|
{{ order.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉' }}
|
||||||
|
</h3>
|
||||||
|
<el-button
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
size="large"
|
||||||
|
:loading="cancellingDispute"
|
||||||
|
@click="handleCancelDispute"
|
||||||
|
>
|
||||||
|
{{ order.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉' }}
|
||||||
|
</el-button>
|
||||||
|
<p class="sidebar-hint">取消后订单将恢复到发起申诉前的流程</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export type UserStatus = (typeof userStatuses)[number]
|
|||||||
export const riskStatuses = ['normal', 'watch', 'restricted', 'blocked'] as const
|
export const riskStatuses = ['normal', 'watch', 'restricted', 'blocked'] as const
|
||||||
export type RiskStatus = (typeof riskStatuses)[number]
|
export type RiskStatus = (typeof riskStatuses)[number]
|
||||||
|
|
||||||
export const disputeStatuses = ['open', 'processing', 'resolved', 'closed'] as const
|
export const disputeStatuses = ['open', 'processing', 'resolved', 'closed', 'cancelled'] as const
|
||||||
export type DisputeStatus = (typeof disputeStatuses)[number]
|
export type DisputeStatus = (typeof disputeStatuses)[number]
|
||||||
|
|
||||||
export const walletStatuses = ['active', 'frozen', 'disabled'] as const
|
export const walletStatuses = ['active', 'frozen', 'disabled'] as const
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const disputeStatusMap: Record<DisputeStatus, string> = {
|
|||||||
processing: '处理中',
|
processing: '处理中',
|
||||||
resolved: '已处理',
|
resolved: '已处理',
|
||||||
closed: '已关闭',
|
closed: '已关闭',
|
||||||
|
cancelled: '已取消',
|
||||||
}
|
}
|
||||||
|
|
||||||
const walletStatusMap: Record<WalletStatus, string> = {
|
const walletStatusMap: Record<WalletStatus, string> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user