支持客服主动发起申诉
This commit is contained in:
@@ -10,6 +10,8 @@ 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"`
|
||||||
|
InitiatorType string `gorm:"size:16;not null;default:'user';index" json:"initiator_type"`
|
||||||
|
InitiatorAdminID *uint64 `gorm:"index" json:"initiator_admin_id"`
|
||||||
TargetUserID uint64 `gorm:"not null" json:"target_user_id"`
|
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"`
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
func (r *Repository) CancelByOrder(ctx context.Context, userID uint64, orderID uint64) (*DisputeDTO, error) {
|
func (r *Repository) CancelByOrder(ctx context.Context, userID uint64, orderID uint64) (*DisputeDTO, error) {
|
||||||
var row model.Dispute
|
var row model.Dispute
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Where("order_id = ? AND initiator_id = ? AND status IN ?", orderID, userID, []string{"open", "processing"}).
|
Where("order_id = ? AND initiator_type = ? AND initiator_id = ? AND status IN ?", orderID, disputeInitiatorUser, userID, []string{"open", "processing"}).
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
First(&row).Error; err != nil {
|
First(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -28,7 +28,7 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, id uint64) (*Dis
|
|||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if row.InitiatorID != userID {
|
if effectiveInitiatorType(row) != disputeInitiatorUser || row.InitiatorID != userID {
|
||||||
return ErrPermissionDenied
|
return ErrPermissionDenied
|
||||||
}
|
}
|
||||||
if row.Status != "open" && row.Status != "processing" {
|
if row.Status != "open" && row.Status != "processing" {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type DisputeDTO struct {
|
|||||||
OwnerPhone string `json:"owner_phone"`
|
OwnerPhone string `json:"owner_phone"`
|
||||||
RenterPhone string `json:"renter_phone"`
|
RenterPhone string `json:"renter_phone"`
|
||||||
InitiatorID uint64 `json:"initiator_id"`
|
InitiatorID uint64 `json:"initiator_id"`
|
||||||
|
InitiatorType string `json:"initiator_type"`
|
||||||
|
InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"`
|
||||||
TargetUserID uint64 `json:"target_user_id"`
|
TargetUserID uint64 `json:"target_user_id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -46,6 +48,13 @@ type CreateRequest struct {
|
|||||||
EvidenceURLS []string `json:"evidence_urls"`
|
EvidenceURLS []string `json:"evidence_urls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminCreateRequest struct {
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
Description string `json:"description" binding:"required"`
|
||||||
|
TargetRole string `json:"target_role" binding:"required"`
|
||||||
|
EvidenceURLS []string `json:"evidence_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
type ArbitrateRequest struct {
|
type ArbitrateRequest struct {
|
||||||
Result string `json:"result" binding:"required"`
|
Result string `json:"result" binding:"required"`
|
||||||
Remark string `json:"remark" binding:"required"`
|
Remark string `json:"remark" binding:"required"`
|
||||||
|
|||||||
@@ -129,6 +129,29 @@ func (h *Handler) AdminList(c *gin.Context) {
|
|||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminCreateByOrder(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderID, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req AdminCreateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "申诉信息不完整")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.AdminCreateByOrder(c.Request.Context(), adminID, orderID, req, auditMeta(c))
|
||||||
|
if err != nil {
|
||||||
|
writeDisputeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Created(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminArbitrate(c *gin.Context) {
|
func (h *Handler) AdminArbitrate(c *gin.Context) {
|
||||||
adminID, ok := currentAdminID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
disputeInitiatorUser = "user"
|
||||||
|
disputeInitiatorAdmin = "admin"
|
||||||
|
|
||||||
|
disputeTargetOwner = "owner"
|
||||||
|
disputeTargetRenter = "renter"
|
||||||
|
)
|
||||||
|
|
||||||
func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) {
|
func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64, req CreateRequest) (*DisputeDTO, error) {
|
||||||
var createdID uint64
|
var createdID uint64
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -46,6 +54,7 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64,
|
|||||||
row := model.Dispute{
|
row := model.Dispute{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
InitiatorID: userID,
|
InitiatorID: userID,
|
||||||
|
InitiatorType: disputeInitiatorUser,
|
||||||
TargetUserID: targetID,
|
TargetUserID: targetID,
|
||||||
Type: disputeType(req.Type, isCheckoutDispute),
|
Type: disputeType(req.Type, isCheckoutDispute),
|
||||||
Status: "open",
|
Status: "open",
|
||||||
@@ -154,9 +163,183 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64,
|
|||||||
return r.FindForUser(ctx, userID, createdID)
|
return r.FindForUser(ctx, userID, createdID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AdminCreateByOrder(ctx context.Context, adminID uint64, orderID uint64, req AdminCreateRequest, meta AuditMeta) (*DisputeDTO, error) {
|
||||||
|
var createdID uint64
|
||||||
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order model.RentalOrder
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
|
||||||
|
return ErrInvalidDispute
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&model.Dispute{}).
|
||||||
|
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return ErrDisputeExists
|
||||||
|
}
|
||||||
|
targetID, err := adminDisputeTargetUserID(order, req.TargetRole)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
evidence, err := marshalEvidence(req.EvidenceURLS)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept"
|
||||||
|
adminIDCopy := adminID
|
||||||
|
row := model.Dispute{
|
||||||
|
OrderID: order.ID,
|
||||||
|
InitiatorID: 0,
|
||||||
|
InitiatorType: disputeInitiatorAdmin,
|
||||||
|
InitiatorAdminID: &adminIDCopy,
|
||||||
|
TargetUserID: targetID,
|
||||||
|
Type: disputeType(req.Type, isCheckoutDispute),
|
||||||
|
Status: "open",
|
||||||
|
Description: req.Description,
|
||||||
|
EvidenceURLS: evidence,
|
||||||
|
PreviousOrderStatus: order.Status,
|
||||||
|
PreviousHandoffStatus: order.HandoffStatus,
|
||||||
|
PreviousSettlementStatus: order.SettlementStatus,
|
||||||
|
}
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if isCheckoutDispute {
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}).
|
||||||
|
Order("id DESC").
|
||||||
|
First(&checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row.CheckoutID = &checkout.ID
|
||||||
|
row.PreviousCheckoutStatus = checkout.Status
|
||||||
|
}
|
||||||
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isCheckoutDispute {
|
||||||
|
now := time.Now()
|
||||||
|
order.Status = "checkout_disputing"
|
||||||
|
order.HandoffStatus = "checkout_disputed"
|
||||||
|
order.SettlementStatus = "disputed"
|
||||||
|
if err := tx.Model(&model.OrderCheckout{}).
|
||||||
|
Where("id = ?", checkout.ID).
|
||||||
|
Updates(map[string]any{"status": "disputed", "updated_at": now}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
order.Status = "disputing"
|
||||||
|
}
|
||||||
|
recordType := "admin_dispute_opened"
|
||||||
|
recordContent := "客服发起订单申诉:" + req.Description
|
||||||
|
if isCheckoutDispute {
|
||||||
|
recordType = "admin_checkout_dispute_opened"
|
||||||
|
recordContent = "客服发起结账争议:" + req.Description
|
||||||
|
}
|
||||||
|
record := model.HandoffRecord{
|
||||||
|
OrderID: order.ID,
|
||||||
|
FromUserID: adminID,
|
||||||
|
ToUserID: targetID,
|
||||||
|
Type: recordType,
|
||||||
|
Content: recordContent,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
disputeID := row.ID
|
||||||
|
title := "客服已发起订单申诉"
|
||||||
|
content := "客服已将订单提交仲裁流程,请等待处理或补充沟通记录。"
|
||||||
|
if isCheckoutDispute {
|
||||||
|
title = "客服已发起结账争议"
|
||||||
|
content = "客服已将结账问题提交仲裁流程,请等待处理或补充结账证据。"
|
||||||
|
}
|
||||||
|
if err := appendAdminCreatedDisputeNotifications(tx, order, disputeID, title, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := appendAuditLog(tx, adminID, "dispute.admin_create", "dispute", row.ID, meta, map[string]any{
|
||||||
|
"dispute_id": row.ID,
|
||||||
|
"order_id": order.ID,
|
||||||
|
"order_no": order.OrderNo,
|
||||||
|
"type": row.Type,
|
||||||
|
"target_role": req.TargetRole,
|
||||||
|
"target_user_id": targetID,
|
||||||
|
"description": req.Description,
|
||||||
|
"previous_order_status": row.PreviousOrderStatus,
|
||||||
|
"previous_handoff_status": row.PreviousHandoffStatus,
|
||||||
|
"previous_settlement_status": row.PreviousSettlementStatus,
|
||||||
|
"after_order_status": order.Status,
|
||||||
|
"after_handoff_status": order.HandoffStatus,
|
||||||
|
"after_settlement_status": order.SettlementStatus,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
createdID = row.ID
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.FindAdminByID(ctx, createdID)
|
||||||
|
}
|
||||||
|
|
||||||
func disputeType(input string, isCheckoutDispute bool) string {
|
func disputeType(input string, isCheckoutDispute bool) string {
|
||||||
if isCheckoutDispute {
|
if isCheckoutDispute {
|
||||||
return "checkout_dispute"
|
return "checkout_dispute"
|
||||||
}
|
}
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func effectiveInitiatorType(row model.Dispute) string {
|
||||||
|
if row.InitiatorType == disputeInitiatorAdmin {
|
||||||
|
return disputeInitiatorAdmin
|
||||||
|
}
|
||||||
|
return disputeInitiatorUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminDisputeTargetUserID(order model.RentalOrder, role string) (uint64, error) {
|
||||||
|
switch role {
|
||||||
|
case disputeTargetOwner:
|
||||||
|
return order.OwnerID, nil
|
||||||
|
case disputeTargetRenter:
|
||||||
|
return order.RenterID, nil
|
||||||
|
default:
|
||||||
|
return 0, ErrInvalidDispute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendAdminCreatedDisputeNotifications(tx *gorm.DB, order model.RentalOrder, disputeID uint64, title string, content string) error {
|
||||||
|
if isPlatformManagedOrder(order) {
|
||||||
|
return notification.Append(tx, notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "dispute",
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
BizType: "dispute",
|
||||||
|
BizID: &disputeID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return notification.Append(tx,
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "dispute",
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
BizType: "dispute",
|
||||||
|
BizID: &disputeID,
|
||||||
|
},
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.OwnerID,
|
||||||
|
Type: "dispute",
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
BizType: "dispute",
|
||||||
|
BizID: &disputeID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ func (row disputeRow) toDTO() DisputeDTO {
|
|||||||
OwnerPhone: row.OwnerPhone,
|
OwnerPhone: row.OwnerPhone,
|
||||||
RenterPhone: row.RenterPhone,
|
RenterPhone: row.RenterPhone,
|
||||||
InitiatorID: row.InitiatorID,
|
InitiatorID: row.InitiatorID,
|
||||||
|
InitiatorType: effectiveInitiatorType(row.Dispute),
|
||||||
|
InitiatorAdminID: row.InitiatorAdminID,
|
||||||
TargetUserID: row.TargetUserID,
|
TargetUserID: row.TargetUserID,
|
||||||
Type: row.Type,
|
Type: row.Type,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
|
|||||||
@@ -5,22 +5,18 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
db := r.db.WithContext(ctx)
|
conditions := r.userVisibleQuery(ctx, userID)
|
||||||
conditions := db.Model(&model.Dispute{}).Where("initiator_id = ? OR target_user_id = ?", userID, userID)
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := conditions.Count(&total).Error; err != nil {
|
if err := conditions.Count(&total).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
var rows []disputeRow
|
var rows []disputeRow
|
||||||
err := r.baseQuery(ctx).
|
err := applyUserVisibleFilter(r.baseQuery(ctx), userID).
|
||||||
Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID).
|
|
||||||
Order("d.id DESC").
|
Order("d.id DESC").
|
||||||
Offset(offset).Limit(pageSize).
|
Offset(offset).Limit(pageSize).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
@@ -32,8 +28,7 @@ func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageS
|
|||||||
|
|
||||||
func (r *Repository) FindForUser(ctx context.Context, userID uint64, id uint64) (*DisputeDTO, error) {
|
func (r *Repository) FindForUser(ctx context.Context, userID uint64, id uint64) (*DisputeDTO, error) {
|
||||||
var row disputeRow
|
var row disputeRow
|
||||||
if err := r.baseQuery(ctx).
|
if err := applyUserVisibleFilter(r.baseQuery(ctx).Where("d.id = ?", id), userID).
|
||||||
Where("d.id = ? AND (d.initiator_id = ? OR d.target_user_id = ?)", id, userID, userID).
|
|
||||||
First(&row).Error; err != nil {
|
First(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -68,6 +63,31 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Pagi
|
|||||||
return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil
|
return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) FindAdminByID(ctx context.Context, id uint64) (*DisputeDTO, error) {
|
||||||
|
var row disputeRow
|
||||||
|
if err := r.baseQuery(ctx).Where("d.id = ?", id).First(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto := row.toDTO()
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) userVisibleQuery(ctx context.Context, userID uint64) *gorm.DB {
|
||||||
|
return applyUserVisibleFilter(r.adminFilterQuery(ctx), userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUserVisibleFilter(db *gorm.DB, userID uint64) *gorm.DB {
|
||||||
|
return db.Where(
|
||||||
|
`((d.initiator_type = ? AND d.initiator_id = ?) OR d.target_user_id = ? OR (d.initiator_type = ? AND (o.owner_id = ? OR o.renter_id = ?)))`,
|
||||||
|
disputeInitiatorUser,
|
||||||
|
userID,
|
||||||
|
userID,
|
||||||
|
disputeInitiatorAdmin,
|
||||||
|
userID,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||||
return r.adminFilterQuery(ctx).
|
return r.adminFilterQuery(ctx).
|
||||||
Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status,
|
Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status,
|
||||||
|
|||||||
@@ -169,6 +169,58 @@ func TestRepositoryCancelRestoresCheckoutDisputeStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminCreateByOrderCreatesAdminInitiatedDispute(t *testing.T) {
|
||||||
|
db := setupDisputeTestDB(t)
|
||||||
|
repo := NewRepository(db)
|
||||||
|
adminID := uint64(88)
|
||||||
|
owner, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{
|
||||||
|
Status: "renting",
|
||||||
|
HandoffStatus: "received",
|
||||||
|
SettlementStatus: "unsettled",
|
||||||
|
})
|
||||||
|
|
||||||
|
created, err := repo.AdminCreateByOrder(t.Context(), adminID, order.ID, AdminCreateRequest{
|
||||||
|
Type: "cannot_login",
|
||||||
|
TargetRole: "owner",
|
||||||
|
Description: "客服核查后主动发起申诉",
|
||||||
|
}, AuditMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台发起申诉失败: %v", err)
|
||||||
|
}
|
||||||
|
if created.InitiatorType != disputeInitiatorAdmin || created.InitiatorAdminID == nil || *created.InitiatorAdminID != adminID {
|
||||||
|
t.Fatalf("发起方 = %s/%v, want admin/%d", created.InitiatorType, created.InitiatorAdminID, adminID)
|
||||||
|
}
|
||||||
|
if created.InitiatorID != 0 || created.TargetUserID != owner.ID {
|
||||||
|
t.Fatalf("initiator/target = %d/%d, want 0/%d", created.InitiatorID, created.TargetUserID, owner.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved model.RentalOrder
|
||||||
|
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取订单失败: %v", err)
|
||||||
|
}
|
||||||
|
if saved.Status != "disputing" || saved.HandoffStatus != "received" || saved.SettlementStatus != "unsettled" {
|
||||||
|
t.Fatalf("订单状态 = %s/%s/%s, want disputing/received/unsettled", saved.Status, saved.HandoffStatus, saved.SettlementStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.FindForUser(t.Context(), renter.ID, created.ID); err != nil {
|
||||||
|
t.Fatalf("租客应能看到客服发起的申诉: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.FindForUser(t.Context(), owner.ID, created.ID); err != nil {
|
||||||
|
t.Fatalf("号主应能看到客服发起的申诉: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.CancelByOrder(t.Context(), renter.ID, order.ID); err == nil {
|
||||||
|
t.Fatal("客服发起的申诉不应允许租客取消")
|
||||||
|
}
|
||||||
|
|
||||||
|
var record model.HandoffRecord
|
||||||
|
if err := db.Where("order_id = ? AND type = ?", order.ID, "admin_dispute_opened").First(&record).Error; err != nil {
|
||||||
|
t.Fatalf("读取客服发起申诉记录失败: %v", err)
|
||||||
|
}
|
||||||
|
if record.FromUserID != adminID || record.ToUserID != owner.ID {
|
||||||
|
t.Fatalf("record from/to = %d/%d, want %d/%d", record.FromUserID, record.ToUserID, adminID, owner.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
|
func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
|
||||||
db := setupDisputeTestDB(t)
|
db := setupDisputeTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ func (s *Service) Create(ctx context.Context, userID uint64, orderID uint64, req
|
|||||||
return s.repo.Create(ctx, userID, orderID, req)
|
return s.repo.Create(ctx, userID, orderID, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminCreateByOrder(ctx context.Context, adminID uint64, orderID uint64, req AdminCreateRequest, meta AuditMeta) (*DisputeDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 || req.Type == "" || req.Description == "" || req.TargetRole == "" {
|
||||||
|
return nil, ErrInvalidDispute
|
||||||
|
}
|
||||||
|
return s.repo.AdminCreateByOrder(ctx, adminID, orderID, req, meta)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
func (s *Service) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ type AdminActionDTO struct {
|
|||||||
type ActiveDisputeDTO struct {
|
type ActiveDisputeDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
InitiatorID uint64 `json:"initiator_id"`
|
InitiatorID uint64 `json:"initiator_id"`
|
||||||
|
InitiatorType string `json:"initiator_type"`
|
||||||
|
InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,7 +336,9 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u
|
|||||||
beforeSettlementStatus := order.SettlementStatus
|
beforeSettlementStatus := order.SettlementStatus
|
||||||
row := model.Dispute{
|
row := model.Dispute{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
InitiatorID: adminID,
|
InitiatorID: 0,
|
||||||
|
InitiatorType: "admin",
|
||||||
|
InitiatorAdminID: &adminID,
|
||||||
TargetUserID: order.RenterID,
|
TargetUserID: order.RenterID,
|
||||||
Type: "checkout_dispute",
|
Type: "checkout_dispute",
|
||||||
Status: "open",
|
Status: "open",
|
||||||
|
|||||||
@@ -205,11 +205,20 @@ func (r *Repository) activeDisputeDTO(ctx context.Context, orderID uint64) *Acti
|
|||||||
return &ActiveDisputeDTO{
|
return &ActiveDisputeDTO{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
InitiatorID: row.InitiatorID,
|
InitiatorID: row.InitiatorID,
|
||||||
|
InitiatorType: effectiveDisputeInitiatorType(row),
|
||||||
|
InitiatorAdminID: row.InitiatorAdminID,
|
||||||
Type: row.Type,
|
Type: row.Type,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func effectiveDisputeInitiatorType(row model.Dispute) string {
|
||||||
|
if row.InitiatorType == "admin" {
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
|
||||||
func shouldAttachCheckout(status string) bool {
|
func shouldAttachCheckout(status string) bool {
|
||||||
switch status {
|
switch status {
|
||||||
case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted:
|
case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted:
|
||||||
|
|||||||
@@ -1068,7 +1068,7 @@ func TestAdminPlatformCheckoutDisputeCreatesArbitrationCase(t *testing.T) {
|
|||||||
if err := db.Where("order_id = ?", order.ID).First(&dispute).Error; err != nil {
|
if err := db.Where("order_id = ?", order.ID).First(&dispute).Error; err != nil {
|
||||||
t.Fatalf("load dispute failed: %v", err)
|
t.Fatalf("load dispute failed: %v", err)
|
||||||
}
|
}
|
||||||
if dispute.InitiatorID != adminID || dispute.TargetUserID != renter.ID || dispute.Type != "checkout_dispute" {
|
if dispute.InitiatorID != 0 || dispute.InitiatorType != "admin" || dispute.InitiatorAdminID == nil || *dispute.InitiatorAdminID != adminID || dispute.TargetUserID != renter.ID || dispute.Type != "checkout_dispute" {
|
||||||
t.Fatalf("dispute = %#v, want admin initiated checkout dispute to renter", dispute)
|
t.Fatalf("dispute = %#v, want admin initiated checkout dispute to renter", dispute)
|
||||||
}
|
}
|
||||||
if dispute.CheckoutID == nil || *dispute.CheckoutID != checkout.ID || dispute.PreviousCheckoutStatus != checkoutStatusSubmitted {
|
if dispute.CheckoutID == nil || *dispute.CheckoutID != checkout.ID || dispute.PreviousCheckoutStatus != checkoutStatusSubmitted {
|
||||||
|
|||||||
@@ -588,6 +588,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.POST("/orders/:id/deposit-hold", requirePerm("order:deposit_hold"), orderHandler.AdminHoldDeposit)
|
adminRoutes.POST("/orders/:id/deposit-hold", requirePerm("order:deposit_hold"), orderHandler.AdminHoldDeposit)
|
||||||
adminRoutes.POST("/orders/:id/deposit-release", requirePerm("order:deposit_hold"), orderHandler.AdminReleaseDeposit)
|
adminRoutes.POST("/orders/:id/deposit-release", requirePerm("order:deposit_hold"), orderHandler.AdminReleaseDeposit)
|
||||||
adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund)
|
adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund)
|
||||||
|
adminRoutes.POST("/orders/:id/dispute", requirePerm("dispute:arbitrate"), disputeHandler.AdminCreateByOrder)
|
||||||
|
|
||||||
// 管理员线下提号(独立于正常订单流程)
|
// 管理员线下提号(独立于正常订单流程)
|
||||||
adminRoutes.POST("/pickups", requirePerm("order:pickup"), pickupHandler.Create)
|
adminRoutes.POST("/pickups", requirePerm("order:pickup"), pickupHandler.Create)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE disputes
|
||||||
|
ADD COLUMN initiator_type VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '发起方类型: user用户/admin客服' AFTER initiator_id,
|
||||||
|
ADD COLUMN initiator_admin_id BIGINT UNSIGNED NULL COMMENT '客服发起时的管理员ID' AFTER initiator_type,
|
||||||
|
ADD KEY idx_disputes_initiator_type (initiator_type, initiator_id),
|
||||||
|
ADD KEY idx_disputes_initiator_admin (initiator_admin_id);
|
||||||
|
|
||||||
|
UPDATE disputes
|
||||||
|
SET initiator_type = 'user'
|
||||||
|
WHERE initiator_type = '';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE disputes
|
||||||
|
DROP KEY idx_disputes_initiator_admin,
|
||||||
|
DROP KEY idx_disputes_initiator_type,
|
||||||
|
DROP COLUMN initiator_admin_id,
|
||||||
|
DROP COLUMN initiator_type;
|
||||||
@@ -103,6 +103,13 @@ function userPhone(row: Dispute, userID: number) {
|
|||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initiatorLabel(row: Dispute) {
|
||||||
|
if (row.initiator_type === 'admin') {
|
||||||
|
return row.initiator_admin_id ? `客服 ID ${row.initiator_admin_id}` : '客服'
|
||||||
|
}
|
||||||
|
return `${roleLabel(row, row.initiator_id)} ${userPhone(row, row.initiator_id)}`
|
||||||
|
}
|
||||||
|
|
||||||
function evidenceCount(row: Dispute) {
|
function evidenceCount(row: Dispute) {
|
||||||
return evidenceItems(row).length
|
return evidenceItems(row).length
|
||||||
}
|
}
|
||||||
@@ -388,10 +395,7 @@ async function handleArbitrate() {
|
|||||||
<el-table-column label="双方信息" min-width="240">
|
<el-table-column label="双方信息" min-width="240">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="party-cell">
|
<div class="party-cell">
|
||||||
<span
|
<span>申诉方:{{ initiatorLabel(row) }}</span>
|
||||||
>申诉方:{{ roleLabel(row, row.initiator_id) }}
|
|
||||||
{{ userPhone(row, row.initiator_id) }}</span
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
>被申诉方:{{ roleLabel(row, row.target_user_id) }}
|
>被申诉方:{{ roleLabel(row, row.target_user_id) }}
|
||||||
{{ userPhone(row, row.target_user_id) }}</span
|
{{ userPhone(row, row.target_user_id) }}</span
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type Order,
|
type Order,
|
||||||
type RefundStatus,
|
type RefundStatus,
|
||||||
} from '@/features/orders'
|
} from '@/features/orders'
|
||||||
|
import { adminCreateOrderDispute } from '@/features/disputes'
|
||||||
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
|
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
|
||||||
import {
|
import {
|
||||||
getSnapshotHafCoinM,
|
getSnapshotHafCoinM,
|
||||||
@@ -76,6 +77,14 @@ const platformCheckoutCounterForm = ref({
|
|||||||
const offlineSettlementVisible = ref(false)
|
const offlineSettlementVisible = ref(false)
|
||||||
const offlineSettlementRemark = ref('')
|
const offlineSettlementRemark = ref('')
|
||||||
const platformSubmitting = ref(false)
|
const platformSubmitting = ref(false)
|
||||||
|
const adminDisputeVisible = ref(false)
|
||||||
|
const adminDisputeSubmitting = ref(false)
|
||||||
|
const adminDisputeForm = ref({
|
||||||
|
type: 'cannot_login',
|
||||||
|
targetRole: 'renter',
|
||||||
|
description: '',
|
||||||
|
evidenceText: '',
|
||||||
|
})
|
||||||
|
|
||||||
type FundSplitRow = {
|
type FundSplitRow = {
|
||||||
label: string
|
label: string
|
||||||
@@ -144,6 +153,7 @@ const canPlatformCheckoutDispute = computed(
|
|||||||
() => platformCheckoutDisputeAction.value?.enabled === true
|
() => platformCheckoutDisputeAction.value?.enabled === true
|
||||||
)
|
)
|
||||||
const canOfflineSettlement = computed(() => offlineSettlementAction.value?.enabled === true)
|
const canOfflineSettlement = computed(() => offlineSettlementAction.value?.enabled === true)
|
||||||
|
const canAdminCreateDispute = computed(() => canOperate.value && !order.value?.active_dispute)
|
||||||
const isPlatformManaged = computed(
|
const isPlatformManaged = computed(
|
||||||
() =>
|
() =>
|
||||||
order.value?.handoff_mode === 'platform' ||
|
order.value?.handoff_mode === 'platform' ||
|
||||||
@@ -199,6 +209,16 @@ const depositReleaseActionTip = computed(() =>
|
|||||||
const refundActionTip =
|
const refundActionTip =
|
||||||
'仅发起后台人工原路退款,不关闭订单或调整商品/账号状态;押金已暂扣时仅退非暂扣部分。'
|
'仅发起后台人工原路退款,不关闭订单或调整商品/账号状态;押金已暂扣时仅退非暂扣部分。'
|
||||||
const refundButtonDisabled = computed(() => refundStatus.value?.refund_status === 'refunded')
|
const refundButtonDisabled = computed(() => refundStatus.value?.refund_status === 'refunded')
|
||||||
|
const disputeTypeOptions = [
|
||||||
|
{ label: '无法登录', value: 'cannot_login' },
|
||||||
|
{ label: '描述不符', value: 'false_description' },
|
||||||
|
{ label: '账号封禁', value: 'account_banned' },
|
||||||
|
{ label: '资产损失', value: 'asset_loss' },
|
||||||
|
{ label: '哈夫币争议', value: 'haf_coin_dispute' },
|
||||||
|
{ label: '交接超时', value: 'handoff_timeout' },
|
||||||
|
{ label: '归还超时', value: 'return_timeout' },
|
||||||
|
{ label: '结账金额争议', value: 'checkout_amount' },
|
||||||
|
]
|
||||||
const orderTotalCent = computed(
|
const orderTotalCent = computed(
|
||||||
() => Number(order.value?.rent_amount_cent || 0) + Number(order.value?.deposit_amount_cent || 0)
|
() => Number(order.value?.rent_amount_cent || 0) + Number(order.value?.deposit_amount_cent || 0)
|
||||||
)
|
)
|
||||||
@@ -493,6 +513,43 @@ async function submitPlatformCheckoutCounter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAdminDispute() {
|
||||||
|
adminDisputeForm.value = {
|
||||||
|
type: ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value?.status || '')
|
||||||
|
? 'checkout_amount'
|
||||||
|
: 'cannot_login',
|
||||||
|
targetRole: 'renter',
|
||||||
|
description: '',
|
||||||
|
evidenceText: '',
|
||||||
|
}
|
||||||
|
adminDisputeVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAdminDispute() {
|
||||||
|
if (!order.value) return
|
||||||
|
const form = adminDisputeForm.value
|
||||||
|
if (!form.description.trim()) {
|
||||||
|
ElMessage.warning('请填写申诉说明')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminDisputeSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminCreateOrderDispute(order.value.id, {
|
||||||
|
type: form.type,
|
||||||
|
target_role: form.targetRole,
|
||||||
|
description: form.description.trim(),
|
||||||
|
evidence_urls: linesToList(form.evidenceText),
|
||||||
|
})
|
||||||
|
ElMessage.success('申诉已发起,订单进入仲裁流程')
|
||||||
|
adminDisputeVisible.value = false
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '发起申诉失败'))
|
||||||
|
} finally {
|
||||||
|
adminDisputeSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openOfflineSettlement() {
|
function openOfflineSettlement() {
|
||||||
offlineSettlementRemark.value = ''
|
offlineSettlementRemark.value = ''
|
||||||
offlineSettlementVisible.value = true
|
offlineSettlementVisible.value = true
|
||||||
@@ -706,10 +763,28 @@ function disputeTypeLabel(type: string) {
|
|||||||
handoff: '交接申诉',
|
handoff: '交接申诉',
|
||||||
checkout: '结账争议',
|
checkout: '结账争议',
|
||||||
order: '订单争议',
|
order: '订单争议',
|
||||||
|
cannot_login: '无法登录',
|
||||||
|
false_description: '描述不符',
|
||||||
|
account_banned: '账号封禁',
|
||||||
|
asset_loss: '资产损失',
|
||||||
|
haf_coin_dispute: '哈夫币争议',
|
||||||
|
handoff_timeout: '交接超时',
|
||||||
|
return_timeout: '归还超时',
|
||||||
|
checkout_amount: '结账金额争议',
|
||||||
|
checkout_dispute: '结账争议',
|
||||||
}
|
}
|
||||||
return map[type] || type || '-'
|
return map[type] || type || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disputeInitiatorLabel() {
|
||||||
|
const active = order.value?.active_dispute
|
||||||
|
if (!active) return '-'
|
||||||
|
if (active.initiator_type === 'admin') {
|
||||||
|
return active.initiator_admin_id ? `客服 ID ${active.initiator_admin_id}` : '客服'
|
||||||
|
}
|
||||||
|
return `用户 ID ${active.initiator_id}`
|
||||||
|
}
|
||||||
|
|
||||||
function userDisplay(phone: string | undefined, id: number) {
|
function userDisplay(phone: string | undefined, id: number) {
|
||||||
return phone ? `${phone} / ID ${id}` : `ID ${id}`
|
return phone ? `${phone} / ID ${id}` : `ID ${id}`
|
||||||
}
|
}
|
||||||
@@ -796,6 +871,14 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
<el-button v-if="canOfflineSettlement" type="success" @click="openOfflineSettlement">
|
<el-button v-if="canOfflineSettlement" type="success" @click="openOfflineSettlement">
|
||||||
{{ offlineSettlementAction?.label || '确认线下结算' }}
|
{{ offlineSettlementAction?.label || '确认线下结算' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canAdminCreateDispute"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
@click="openAdminDispute"
|
||||||
|
>
|
||||||
|
发起申诉
|
||||||
|
</el-button>
|
||||||
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
|
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
|
||||||
>标记异常</el-button
|
>标记异常</el-button
|
||||||
>
|
>
|
||||||
@@ -1135,7 +1218,7 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>发起人</dt>
|
<dt>发起人</dt>
|
||||||
<dd>ID {{ order.active_dispute.initiator_id }}</dd>
|
<dd>{{ disputeInitiatorLabel() }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>当前状态</dt>
|
<dt>当前状态</dt>
|
||||||
@@ -1297,6 +1380,61 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="adminDisputeVisible"
|
||||||
|
title="发起订单申诉"
|
||||||
|
width="620px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-if="order" class="dialog-body">
|
||||||
|
<p>
|
||||||
|
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
|
||||||
|
</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>申诉类型</span>
|
||||||
|
<el-select v-model="adminDisputeForm.type" placeholder="选择申诉类型">
|
||||||
|
<el-option
|
||||||
|
v-for="item in disputeTypeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>被申诉方</span>
|
||||||
|
<el-select v-model="adminDisputeForm.targetRole" placeholder="选择被申诉方">
|
||||||
|
<el-option label="租客" value="renter" />
|
||||||
|
<el-option label="号主" value="owner" />
|
||||||
|
</el-select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="adminDisputeForm.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="填写申诉说明,会通知订单双方并进入仲裁中心"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="adminDisputeForm.evidenceText"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="证据链接,一行一个,可留空"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="adminDisputeVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="adminDisputeSubmitting"
|
||||||
|
@click="submitAdminDispute"
|
||||||
|
>
|
||||||
|
发起申诉
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="platformCheckoutCounterVisible"
|
v-model="platformCheckoutCounterVisible"
|
||||||
title="客服修改结账方案"
|
title="客服修改结账方案"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface Dispute {
|
|||||||
owner_phone: string
|
owner_phone: string
|
||||||
renter_phone: string
|
renter_phone: string
|
||||||
initiator_id: number
|
initiator_id: number
|
||||||
|
initiator_type?: 'user' | 'admin' | string
|
||||||
|
initiator_admin_id?: number
|
||||||
target_user_id: number
|
target_user_id: number
|
||||||
type: string
|
type: string
|
||||||
status: DisputeStatus
|
status: DisputeStatus
|
||||||
@@ -49,6 +51,22 @@ export async function createDispute(
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function adminCreateOrderDispute(
|
||||||
|
orderId: number,
|
||||||
|
payload: {
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
target_role: 'owner' | 'renter' | string
|
||||||
|
evidence_urls?: string[]
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Dispute>>(
|
||||||
|
`/admin/orders/${orderId}/dispute`,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function cancelOrderDispute(orderId: number) {
|
export async function cancelOrderDispute(orderId: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute/cancel`)
|
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute/cancel`)
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface AdminAction {
|
|||||||
export interface ActiveDispute {
|
export interface ActiveDispute {
|
||||||
id: number
|
id: number
|
||||||
initiator_id: number
|
initiator_id: number
|
||||||
|
initiator_type?: 'user' | 'admin' | string
|
||||||
|
initiator_admin_id?: number
|
||||||
type: string
|
type: string
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export function useOrderActions(options: UseOrderActionsOptions) {
|
|||||||
!!order.value &&
|
!!order.value &&
|
||||||
(isOwner.value || isRenter.value) &&
|
(isOwner.value || isRenter.value) &&
|
||||||
['disputing', 'checkout_disputing'].includes(order.value.status) &&
|
['disputing', 'checkout_disputing'].includes(order.value.status) &&
|
||||||
|
order.value.active_dispute?.initiator_type !== 'admin' &&
|
||||||
order.value.active_dispute?.initiator_id === session.userId
|
order.value.active_dispute?.initiator_id === session.userId
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user