优化申诉仲裁流程和证据展示
This commit is contained in:
@@ -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 {
|
||||
ID uint64 `json:"id"`
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
ListingNo string `json:"listing_no"`
|
||||
Title string `json:"title"`
|
||||
InitiatorID uint64 `json:"initiator_id"`
|
||||
TargetUserID uint64 `json:"target_user_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
|
||||
ArbitrationResult string `json:"arbitration_result"`
|
||||
ArbitrationRemark string `json:"arbitration_remark"`
|
||||
HandledBy *uint64 `json:"handled_by"`
|
||||
HandledAt *time.Time `json:"handled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `json:"id"`
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
OrderStatus string `json:"order_status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
ListingNo string `json:"listing_no"`
|
||||
Title string `json:"title"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
OwnerPhone string `json:"owner_phone"`
|
||||
RenterPhone string `json:"renter_phone"`
|
||||
InitiatorID uint64 `json:"initiator_id"`
|
||||
TargetUserID uint64 `json:"target_user_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
|
||||
PreviousOrderStatus string `json:"previous_order_status"`
|
||||
PreviousHandoffStatus string `json:"previous_handoff_status"`
|
||||
PreviousSettlementStatus string `json:"previous_settlement_status"`
|
||||
CheckoutID *uint64 `json:"checkout_id"`
|
||||
PreviousCheckoutStatus string `json:"previous_checkout_status"`
|
||||
ArbitrationResult string `json:"arbitration_result"`
|
||||
ArbitrationRemark string `json:"arbitration_remark"`
|
||||
HandledBy *uint64 `json:"handled_by"`
|
||||
HandledAt *time.Time `json:"handled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
|
||||
@@ -75,6 +75,42 @@ func (h *Handler) Detail(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) Cancel(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.Cancel(c.Request.Context(), userID, id)
|
||||
if err != nil {
|
||||
writeDisputeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) CancelByOrder(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
orderID, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.CancelByOrder(c.Request.Context(), userID, orderID)
|
||||
if err != nil {
|
||||
writeDisputeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminList(c *gin.Context) {
|
||||
page, pageSize := parsePagination(c)
|
||||
result, err := h.service.ListAdmin(c.Request.Context(), page, pageSize)
|
||||
@@ -168,6 +204,8 @@ func writeDisputeError(c *gin.Context, err error) {
|
||||
response.Error(c, http.StatusConflict, "dispute_exists", "该订单已有处理中申诉")
|
||||
case errors.Is(err, ErrDisputeCannotHandle):
|
||||
response.Error(c, http.StatusConflict, "dispute_cannot_handle", "当前申诉不能仲裁")
|
||||
case errors.Is(err, ErrDisputeCannotCancel):
|
||||
response.Error(c, http.StatusConflict, "dispute_cannot_cancel", "当前申诉不能取消")
|
||||
case errors.Is(err, ErrPermissionDenied):
|
||||
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该申诉")
|
||||
case IsNotFound(err):
|
||||
|
||||
@@ -44,13 +44,27 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64,
|
||||
return err
|
||||
}
|
||||
row := model.Dispute{
|
||||
OrderID: order.ID,
|
||||
InitiatorID: userID,
|
||||
TargetUserID: targetID,
|
||||
Type: disputeType(req.Type, isCheckoutDispute),
|
||||
Status: "open",
|
||||
Description: req.Description,
|
||||
EvidenceURLS: evidence,
|
||||
OrderID: order.ID,
|
||||
InitiatorID: userID,
|
||||
TargetUserID: targetID,
|
||||
Type: disputeType(req.Type, isCheckoutDispute),
|
||||
Status: "open",
|
||||
Description: req.Description,
|
||||
EvidenceURLS: evidence,
|
||||
PreviousOrderStatus: order.Status,
|
||||
PreviousHandoffStatus: order.HandoffStatus,
|
||||
PreviousSettlementStatus: order.SettlementStatus,
|
||||
}
|
||||
var checkout model.OrderCheckout
|
||||
if isCheckoutDispute {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}).
|
||||
Order("id DESC").
|
||||
First(&checkout).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
row.CheckoutID = &checkout.ID
|
||||
row.PreviousCheckoutStatus = checkout.Status
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
@@ -68,13 +82,29 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64,
|
||||
updates["renter_rejected_at"] = now
|
||||
}
|
||||
if err := tx.Model(&model.OrderCheckout{}).
|
||||
Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}).
|
||||
Where("id = ?", checkout.ID).
|
||||
Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
order.Status = "disputing"
|
||||
}
|
||||
recordType := "dispute_opened"
|
||||
recordContent := "用户发起订单申诉:" + req.Description
|
||||
if isCheckoutDispute {
|
||||
recordType = "checkout_dispute_opened"
|
||||
recordContent = "用户发起结账争议:" + req.Description
|
||||
}
|
||||
record := model.HandoffRecord{
|
||||
OrderID: order.ID,
|
||||
FromUserID: userID,
|
||||
ToUserID: targetID,
|
||||
Type: recordType,
|
||||
Content: recordContent,
|
||||
}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,30 +10,49 @@ import (
|
||||
|
||||
type disputeRow struct {
|
||||
model.Dispute
|
||||
OrderNo string
|
||||
ListingNo string
|
||||
Title string
|
||||
OrderNo string
|
||||
OrderStatus string
|
||||
HandoffStatus string
|
||||
SettlementStatus string
|
||||
ListingNo string
|
||||
Title string
|
||||
OwnerID uint64
|
||||
RenterID uint64
|
||||
OwnerPhone string
|
||||
RenterPhone string
|
||||
}
|
||||
|
||||
func (row disputeRow) toDTO() DisputeDTO {
|
||||
return DisputeDTO{
|
||||
ID: row.ID,
|
||||
OrderID: row.OrderID,
|
||||
OrderNo: row.OrderNo,
|
||||
ListingNo: row.ListingNo,
|
||||
Title: row.Title,
|
||||
InitiatorID: row.InitiatorID,
|
||||
TargetUserID: row.TargetUserID,
|
||||
Type: row.Type,
|
||||
Status: row.Status,
|
||||
Description: row.Description,
|
||||
EvidenceURLS: row.EvidenceURLS,
|
||||
ArbitrationResult: row.ArbitrationResult,
|
||||
ArbitrationRemark: row.ArbitrationRemark,
|
||||
HandledBy: row.HandledBy,
|
||||
HandledAt: row.HandledAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ID: row.ID,
|
||||
OrderID: row.OrderID,
|
||||
OrderNo: row.OrderNo,
|
||||
OrderStatus: row.OrderStatus,
|
||||
HandoffStatus: row.HandoffStatus,
|
||||
SettlementStatus: row.SettlementStatus,
|
||||
ListingNo: row.ListingNo,
|
||||
Title: row.Title,
|
||||
OwnerID: row.OwnerID,
|
||||
RenterID: row.RenterID,
|
||||
OwnerPhone: row.OwnerPhone,
|
||||
RenterPhone: row.RenterPhone,
|
||||
InitiatorID: row.InitiatorID,
|
||||
TargetUserID: row.TargetUserID,
|
||||
Type: row.Type,
|
||||
Status: row.Status,
|
||||
Description: row.Description,
|
||||
EvidenceURLS: row.EvidenceURLS,
|
||||
PreviousOrderStatus: row.PreviousOrderStatus,
|
||||
PreviousHandoffStatus: row.PreviousHandoffStatus,
|
||||
PreviousSettlementStatus: row.PreviousSettlementStatus,
|
||||
CheckoutID: row.CheckoutID,
|
||||
PreviousCheckoutStatus: row.PreviousCheckoutStatus,
|
||||
ArbitrationResult: row.ArbitrationResult,
|
||||
ArbitrationRemark: row.ArbitrationRemark,
|
||||
HandledBy: row.HandledBy,
|
||||
HandledAt: row.HandledAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,8 +55,12 @@ func (r *Repository) ListAdmin(ctx context.Context, page, pageSize int) (*Pagina
|
||||
|
||||
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||
return r.db.WithContext(ctx).Table("disputes AS d").
|
||||
Select("d.*, o.order_no, l.listing_no, a.title").
|
||||
Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status,
|
||||
o.owner_id, o.renter_id, owner.phone AS owner_phone, renter.phone AS renter_phone,
|
||||
l.listing_no, a.title`).
|
||||
Joins("JOIN rental_orders AS o ON o.id = d.order_id").
|
||||
Joins("JOIN rental_listings AS l ON l.id = o.listing_id").
|
||||
Joins("JOIN game_accounts AS a ON a.id = o.account_id")
|
||||
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
|
||||
Joins("JOIN users AS owner ON owner.id = o.owner_id").
|
||||
Joins("JOIN users AS renter ON renter.id = o.renter_id")
|
||||
}
|
||||
|
||||
@@ -3,10 +3,187 @@ package dispute
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func setupDisputeTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("无法创建测试数据库: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.GameAccount{},
|
||||
&model.RentalListing{},
|
||||
&model.RentalOrder{},
|
||||
&model.OrderCheckout{},
|
||||
&model.HandoffRecord{},
|
||||
&model.Dispute{},
|
||||
&model.Notification{},
|
||||
); err != nil {
|
||||
t.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func createDisputeOrderFixture(t *testing.T, db *gorm.DB, order model.RentalOrder) (model.User, model.User, model.RentalOrder) {
|
||||
t.Helper()
|
||||
owner := model.User{Phone: "13800000001"}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatalf("创建号主失败: %v", err)
|
||||
}
|
||||
renter := model.User{Phone: "13800000002"}
|
||||
if err := db.Create(&renter).Error; err != nil {
|
||||
t.Fatalf("创建租客失败: %v", err)
|
||||
}
|
||||
account := model.GameAccount{
|
||||
OwnerID: owner.ID,
|
||||
Title: "测试账号",
|
||||
ServerRegion: "国服",
|
||||
LoginPlatform: "安卓",
|
||||
}
|
||||
if err := db.Create(&account).Error; err != nil {
|
||||
t.Fatalf("创建账号失败: %v", err)
|
||||
}
|
||||
listing := model.RentalListing{
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
PriceCent: 1000,
|
||||
Status: "rented",
|
||||
ReviewStatus: "approved",
|
||||
InTransaction: true,
|
||||
}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatalf("创建商品失败: %v", err)
|
||||
}
|
||||
order.OrderNo = "RO202606130001"
|
||||
order.ListingID = listing.ID
|
||||
order.AccountID = account.ID
|
||||
order.OwnerID = owner.ID
|
||||
order.RenterID = renter.ID
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("创建订单失败: %v", err)
|
||||
}
|
||||
return owner, renter, order
|
||||
}
|
||||
|
||||
func TestRepositoryCancelRestoresNormalDisputeOrderStatus(t *testing.T) {
|
||||
db := setupDisputeTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
_, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{
|
||||
Status: "renting",
|
||||
HandoffStatus: "received",
|
||||
SettlementStatus: "unsettled",
|
||||
})
|
||||
|
||||
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
|
||||
Type: "cannot_login",
|
||||
Description: "无法登录账号",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建申诉失败: %v", err)
|
||||
}
|
||||
if created.Status != "open" {
|
||||
t.Fatalf("申诉状态 = %s, want open", created.Status)
|
||||
}
|
||||
|
||||
cancelled, err := repo.CancelByOrder(t.Context(), renter.ID, order.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("取消申诉失败: %v", err)
|
||||
}
|
||||
if cancelled.Status != "cancelled" {
|
||||
t.Fatalf("取消后申诉状态 = %s, want cancelled", cancelled.Status)
|
||||
}
|
||||
|
||||
var restored model.RentalOrder
|
||||
if err := db.First(&restored, order.ID).Error; err != nil {
|
||||
t.Fatalf("读取订单失败: %v", err)
|
||||
}
|
||||
if restored.Status != "renting" || restored.HandoffStatus != "received" || restored.SettlementStatus != "unsettled" {
|
||||
t.Fatalf("订单状态未恢复: status=%s handoff=%s settlement=%s", restored.Status, restored.HandoffStatus, restored.SettlementStatus)
|
||||
}
|
||||
var records []model.HandoffRecord
|
||||
if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&records).Error; err != nil {
|
||||
t.Fatalf("读取交接记录失败: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("交接记录数量 = %d, want 2", len(records))
|
||||
}
|
||||
if records[0].Type != "dispute_opened" || records[1].Type != "dispute_cancelled" {
|
||||
t.Fatalf("交接记录类型 = %s/%s, want dispute_opened/dispute_cancelled", records[0].Type, records[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryCancelRestoresCheckoutDisputeStatus(t *testing.T) {
|
||||
db := setupDisputeTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
_, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{
|
||||
Status: "pending_checkout_accept",
|
||||
HandoffStatus: "pending_renter_checkout",
|
||||
SettlementStatus: "pending",
|
||||
})
|
||||
checkout := model.OrderCheckout{
|
||||
OrderID: order.ID,
|
||||
InitiatedBy: renter.ID,
|
||||
Status: "countered",
|
||||
OwnerAdjustmentReason: "补扣押金",
|
||||
OwnerAdjustedAt: &now,
|
||||
}
|
||||
if err := db.Create(&checkout).Error; err != nil {
|
||||
t.Fatalf("创建结账记录失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
|
||||
Type: "checkout_amount",
|
||||
Description: "不同意结账修正",
|
||||
}); err != nil {
|
||||
t.Fatalf("创建结账争议失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.CancelByOrder(t.Context(), renter.ID, order.ID); err != nil {
|
||||
t.Fatalf("取消结账争议失败: %v", err)
|
||||
}
|
||||
|
||||
var restored model.RentalOrder
|
||||
if err := db.First(&restored, order.ID).Error; err != nil {
|
||||
t.Fatalf("读取订单失败: %v", err)
|
||||
}
|
||||
if restored.Status != "pending_checkout_accept" || restored.HandoffStatus != "pending_renter_checkout" || restored.SettlementStatus != "pending" {
|
||||
t.Fatalf("订单状态未恢复: status=%s handoff=%s settlement=%s", restored.Status, restored.HandoffStatus, restored.SettlementStatus)
|
||||
}
|
||||
|
||||
var restoredCheckout model.OrderCheckout
|
||||
if err := db.First(&restoredCheckout, checkout.ID).Error; err != nil {
|
||||
t.Fatalf("读取结账记录失败: %v", err)
|
||||
}
|
||||
if restoredCheckout.Status != "countered" {
|
||||
t.Fatalf("结账状态 = %s, want countered", restoredCheckout.Status)
|
||||
}
|
||||
if restoredCheckout.RenterRejectedAt != nil {
|
||||
t.Fatalf("取消争议后 renter_rejected_at 应被清空")
|
||||
}
|
||||
var records []model.HandoffRecord
|
||||
if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&records).Error; err != nil {
|
||||
t.Fatalf("读取交接记录失败: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("交接记录数量 = %d, want 2", len(records))
|
||||
}
|
||||
if records[0].Type != "checkout_dispute_opened" || records[1].Type != "checkout_dispute_cancelled" {
|
||||
t.Fatalf("交接记录类型 = %s/%s, want checkout_dispute_opened/checkout_dispute_cancelled", records[0].Type, records[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) {
|
||||
order := model.RentalOrder{
|
||||
ID: 11,
|
||||
|
||||
@@ -11,6 +11,7 @@ var (
|
||||
ErrInvalidDispute = errors.New("invalid dispute")
|
||||
ErrDisputeExists = errors.New("dispute already exists")
|
||||
ErrDisputeCannotHandle = errors.New("dispute cannot handle")
|
||||
ErrDisputeCannotCancel = errors.New("dispute cannot cancel")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -45,6 +46,26 @@ func (s *Service) FindForUser(ctx context.Context, userID uint64, id uint64) (*D
|
||||
return s.repo.FindForUser(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) Cancel(ctx context.Context, userID uint64, id uint64) (*DisputeDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if id == 0 {
|
||||
return nil, ErrInvalidDispute
|
||||
}
|
||||
return s.repo.Cancel(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) CancelByOrder(ctx context.Context, userID uint64, orderID uint64) (*DisputeDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if orderID == 0 {
|
||||
return nil, ErrInvalidDispute
|
||||
}
|
||||
return s.repo.CancelByOrder(ctx, userID, orderID)
|
||||
}
|
||||
|
||||
func (s *Service) ListAdmin(ctx context.Context, page, pageSize int) (*PaginatedResult, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -9,36 +9,44 @@ import (
|
||||
)
|
||||
|
||||
type OrderDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
ListingNo string `json:"listing_no"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
RenterPhone string `json:"renter_phone,omitempty"`
|
||||
Title string `json:"title"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RentedAt *time.Time `json:"rented_at"`
|
||||
EstimatedDurationHours int `json:"estimated_duration_hours"`
|
||||
PriceRole string `json:"price_role,omitempty"`
|
||||
DisplayAmountCent int64 `json:"display_amount_cent"`
|
||||
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
||||
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
||||
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `json:"id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
ListingNo string `json:"listing_no"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
RenterPhone string `json:"renter_phone,omitempty"`
|
||||
Title string `json:"title"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RentedAt *time.Time `json:"rented_at"`
|
||||
EstimatedDurationHours int `json:"estimated_duration_hours"`
|
||||
PriceRole string `json:"price_role,omitempty"`
|
||||
DisplayAmountCent int64 `json:"display_amount_cent"`
|
||||
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
||||
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
||||
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
|
||||
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ActiveDisputeDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
InitiatorID uint64 `json:"initiator_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
|
||||
@@ -75,6 +75,7 @@ func (r *Repository) FindAdmin(ctx context.Context, orderID uint64) (*OrderDTO,
|
||||
}
|
||||
dto := row.toAdminDTO()
|
||||
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db))
|
||||
dto.ActiveDispute = r.activeDisputeDTO(ctx, orderID)
|
||||
dto.Checkout = r.latestCheckoutAdminDTO(ctx, orderID)
|
||||
return &dto, nil
|
||||
}
|
||||
@@ -89,6 +90,7 @@ func (r *Repository) FindForUser(ctx context.Context, userID uint64, orderID uin
|
||||
}
|
||||
dto := row.toDTOForUser(userID)
|
||||
applyPaymentDeadline(&dto, row.RentalOrder, pendingPaymentTimeoutMinutes(db))
|
||||
dto.ActiveDispute = r.activeDisputeDTO(ctx, orderID)
|
||||
dto.Checkout = r.latestCheckoutDTOForUser(ctx, orderID, userID, row.RentalOrder)
|
||||
return &dto, nil
|
||||
}
|
||||
@@ -123,6 +125,22 @@ func (r *Repository) latestCheckoutAdminDTO(ctx context.Context, orderID uint64)
|
||||
return &dto
|
||||
}
|
||||
|
||||
func (r *Repository) activeDisputeDTO(ctx context.Context, orderID uint64) *ActiveDisputeDTO {
|
||||
var row model.Dispute
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("order_id = ? AND status IN ?", orderID, []string{"open", "processing"}).
|
||||
Order("id DESC").
|
||||
First(&row).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &ActiveDisputeDTO{
|
||||
ID: row.ID,
|
||||
InitiatorID: row.InitiatorID,
|
||||
Type: row.Type,
|
||||
Status: row.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func shouldAttachCheckout(status string) bool {
|
||||
switch status {
|
||||
case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted:
|
||||
|
||||
Reference in New Issue
Block a user