接入订单结账确认流程

This commit is contained in:
yml
2026-05-24 16:52:15 +08:00
parent 1693ee3167
commit 03e5d211dc
13 changed files with 818 additions and 124 deletions
+12 -12
View File
@@ -318,16 +318,16 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh
notification.Entry{ notification.Entry{
UserID: order.RenterID, UserID: order.RenterID,
Type: "timeout", Type: "timeout",
Title: "订单已逾期未归还", Title: "订单已逾期未结账",
Content: "订单已超过预计截止时间,请尽快提交归还说明,避免进入申诉处理。", Content: "订单已超过预计截止时间,请尽快发起结账,避免进入申诉处理。",
BizType: "order", BizType: "order",
BizID: &orderID, BizID: &orderID,
}, },
notification.Entry{ notification.Entry{
UserID: order.OwnerID, UserID: order.OwnerID,
Type: "timeout", Type: "timeout",
Title: "租客逾期未归还", Title: "租客逾期未结账",
Content: "租客未在预计截止后及时归还,你可以发起申诉或等待客服处理。", Content: "租客未在预计截止后及时发起结账,你可以发起申诉或等待客服处理。",
BizType: "order", BizType: "order",
BizID: &orderID, BizID: &orderID,
}, },
@@ -346,7 +346,7 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh
func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
var rows []model.RentalOrder var rows []model.RentalOrder
err := j.db.WithContext(ctx). err := j.db.WithContext(ctx).
Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_return_confirm", "pending_owner_return_confirm", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)). Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_checkout_confirm", "pending_owner_checkout", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)).
Order("id ASC"). Order("id ASC").
Limit(100). Limit(100).
Find(&rows).Error Find(&rows).Error
@@ -355,28 +355,28 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
} }
count := 0 count := 0
for _, row := range rows { for _, row := range rows {
if err := j.updateOrder(ctx, row.ID, "order.timeout.owner_return_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) { if err := j.updateOrder(ctx, row.ID, "order.timeout.owner_checkout_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) {
if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" { if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
return "", nil return "", nil
} }
before := snapshot(order) before := snapshot(order)
order.Status = "abnormal" order.Status = "abnormal"
order.HandoffStatus = "owner_return_confirm_timeout" order.HandoffStatus = "owner_checkout_confirm_timeout"
orderID := order.ID orderID := order.ID
if err := notification.Append(tx, if err := notification.Append(tx,
notification.Entry{ notification.Entry{
UserID: order.RenterID, UserID: order.RenterID,
Type: "timeout", Type: "timeout",
Title: "号主确认归还超时", Title: "号主确认结账超时",
Content: "号主未在规定时间内确认归还,订单已进入客服复核状态。", Content: "号主未在规定时间内确认结账,订单已进入客服复核状态。",
BizType: "order", BizType: "order",
BizID: &orderID, BizID: &orderID,
}, },
notification.Entry{ notification.Entry{
UserID: order.OwnerID, UserID: order.OwnerID,
Type: "timeout", Type: "timeout",
Title: "确认归还已超时", Title: "确认结账已超时",
Content: "你未在规定时间内确认归还,订单已进入客服复核状态。", Content: "你未在规定时间内确认结账,订单已进入客服复核状态。",
BizType: "order", BizType: "order",
BizID: &orderID, BizID: &orderID,
}, },
+34
View File
@@ -0,0 +1,34 @@
package model
import (
"time"
"gorm.io/datatypes"
)
type OrderCheckout struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID uint64 `gorm:"not null;index" json:"order_id"`
InitiatedBy uint64 `gorm:"not null" json:"initiated_by"`
Status string `gorm:"size:32;not null;default:'submitted'" json:"status"`
RentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"rent_amount"`
DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"`
ConsumableAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"consumable_amount"`
CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"`
OtherAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"other_amount"`
DepositDeductAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_deduct_amount"`
RenterRefundAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"renter_refund_amount"`
OwnerIncomeAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"owner_income_amount"`
Content string `json:"content"`
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
RenterRejectedAt *time.Time `json:"renter_rejected_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (OrderCheckout) TableName() string {
return "order_checkouts"
}
+41
View File
@@ -27,6 +27,7 @@ type OrderDTO struct {
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"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -43,6 +44,23 @@ type SubmitReturnRequest struct {
Content string `json:"content" binding:"required"` Content string `json:"content" binding:"required"`
} }
type SubmitCheckoutRequest struct {
Content string `json:"content" binding:"required"`
ConsumableAmount float64 `json:"consumable_amount"`
CoinConsumedM float64 `json:"coin_consumed_m"`
OtherAmount float64 `json:"other_amount"`
EvidenceURLS []string `json:"evidence_urls"`
}
type CounterCheckoutRequest struct {
ConsumableAmount float64 `json:"consumable_amount"`
CoinConsumedM float64 `json:"coin_consumed_m"`
OtherAmount float64 `json:"other_amount"`
DepositDeductAmount float64 `json:"deposit_deduct_amount"`
Reason string `json:"reason" binding:"required"`
EvidenceURLS []string `json:"evidence_urls"`
}
type AdminActionRequest struct { type AdminActionRequest struct {
Reason string `json:"reason" binding:"required"` Reason string `json:"reason" binding:"required"`
} }
@@ -63,3 +81,26 @@ type HandoffRecordDTO struct {
ConfirmedByOwnerAt *time.Time `json:"confirmed_by_owner_at"` ConfirmedByOwnerAt *time.Time `json:"confirmed_by_owner_at"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type CheckoutDTO struct {
ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"`
InitiatedBy uint64 `json:"initiated_by"`
Status string `json:"status"`
RentAmount float64 `json:"rent_amount"`
DepositAmount float64 `json:"deposit_amount"`
ConsumableAmount float64 `json:"consumable_amount"`
CoinConsumedM float64 `json:"coin_consumed_m"`
OtherAmount float64 `json:"other_amount"`
DepositDeductAmount float64 `json:"deposit_deduct_amount"`
RenterRefundAmount float64 `json:"renter_refund_amount"`
OwnerIncomeAmount float64 `json:"owner_income_amount"`
Content string `json:"content"`
EvidenceURLS []string `json:"evidence_urls"`
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
RenterRejectedAt *time.Time `json:"renter_rejected_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+88
View File
@@ -267,6 +267,86 @@ func (h *Handler) ConfirmReturn(c *gin.Context) {
response.OK(c, gin.H{"completed": true}) response.OK(c, gin.H{"completed": true})
} }
func (h *Handler) SubmitCheckout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req SubmitCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "结账说明不能为空")
return
}
record, err := h.service.SubmitCheckout(userID, id, req)
if err != nil {
writeOrderError(c, err)
return
}
response.Created(c, record)
}
func (h *Handler) ConfirmCheckout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.ConfirmCheckout(userID, id); err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"completed": true})
}
func (h *Handler) CounterCheckout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req CounterCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "结账修正原因不能为空")
return
}
checkout, err := h.service.CounterCheckout(userID, id, req)
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, checkout)
}
func (h *Handler) AcceptCheckout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.AcceptCheckout(userID, id); err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"completed": true})
}
func currentUserID(c *gin.Context) (uint64, bool) { func currentUserID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextUserID) value, ok := c.Get(middleware.ContextUserID)
if !ok { if !ok {
@@ -318,6 +398,14 @@ func writeOrderError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "order_cannot_return", "当前订单不能归还") response.Error(c, http.StatusConflict, "order_cannot_return", "当前订单不能归还")
case errors.Is(err, ErrOrderCannotComplete): case errors.Is(err, ErrOrderCannotComplete):
response.Error(c, http.StatusConflict, "order_cannot_complete", "当前订单不能完成") response.Error(c, http.StatusConflict, "order_cannot_complete", "当前订单不能完成")
case errors.Is(err, ErrCheckoutCannotSubmit):
response.Error(c, http.StatusConflict, "checkout_cannot_submit", "当前订单不能发起结账")
case errors.Is(err, ErrCheckoutCannotConfirm):
response.Error(c, http.StatusConflict, "checkout_cannot_confirm", "当前结账不能确认")
case errors.Is(err, ErrCheckoutCannotCounter):
response.Error(c, http.StatusConflict, "checkout_cannot_counter", "当前结账不能修改")
case errors.Is(err, ErrInvalidCheckoutAmount):
response.BadRequest(c, "结账金额不符合规则")
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):
+320 -78
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"math"
"strconv" "strconv"
"time" "time"
@@ -376,6 +377,10 @@ func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRec
} }
func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) { func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) {
return r.SubmitCheckout(userID, orderID, SubmitCheckoutRequest{Content: req.Content})
}
func (r *Repository) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
var recordID uint64 var recordID uint64
err := r.db.Transaction(func(tx *gorm.DB) error { err := r.db.Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder var order model.RentalOrder
@@ -386,28 +391,41 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
return ErrPermissionDenied return ErrPermissionDenied
} }
if (order.Status != "renting" && order.Status != "overdue") || (order.HandoffStatus != "received" && order.HandoffStatus != "return_overdue") { if (order.Status != "renting" && order.Status != "overdue") || (order.HandoffStatus != "received" && order.HandoffStatus != "return_overdue") {
return ErrOrderCannotReturn return ErrCheckoutCannotSubmit
}
hasOpen, err := hasOpenCheckout(tx, order.ID)
if err != nil {
return err
}
if hasOpen {
return ErrCheckoutCannotSubmit
} }
now := time.Now()
record := model.HandoffRecord{ record := model.HandoffRecord{
OrderID: order.ID, OrderID: order.ID,
FromUserID: order.RenterID, FromUserID: order.RenterID,
ToUserID: order.OwnerID, ToUserID: order.OwnerID,
Type: "renter_return", Type: "renter_checkout",
Content: req.Content, Content: req.Content,
} }
if err := tx.Create(&record).Error; err != nil { if err := tx.Create(&record).Error; err != nil {
return err return err
} }
order.Status = "pending_return_confirm" checkout, err := buildCheckout(order, order.RenterID, "submitted", req.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, 0, false)
order.HandoffStatus = "pending_owner_return_confirm" if err != nil {
order.RentEndAt = &now return err
}
if err := tx.Create(&checkout).Error; err != nil {
return err
}
order.Status = "pending_checkout_confirm"
order.HandoffStatus = "pending_owner_checkout"
order.SettlementStatus = "pending"
orderID := order.ID orderID := order.ID
if err := notification.Append(tx, notification.Entry{ if err := notification.Append(tx, notification.Entry{
UserID: order.OwnerID, UserID: order.OwnerID,
Type: "return", Type: "checkout",
Title: "租客已提交归还", Title: "租客已发起结账",
Content: "请检查账号状态,确认无误后完成订单。", Content: "请检查账号状态和消耗明细,确认无误后完成结算。",
BizType: "order", BizType: "order",
BizID: &orderID, BizID: &orderID,
}); err != nil { }); err != nil {
@@ -426,6 +444,10 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
} }
func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error { func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
return r.ConfirmCheckout(userID, orderID)
}
func (r *Repository) ConfirmCheckout(userID uint64, orderID uint64) error {
return r.db.Transaction(func(tx *gorm.DB) error { return r.db.Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
@@ -434,93 +456,120 @@ func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
if order.OwnerID != userID { if order.OwnerID != userID {
return ErrPermissionDenied return ErrPermissionDenied
} }
if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" { if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
return ErrOrderCannotComplete return ErrCheckoutCannotConfirm
}
var checkout model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND status = ?", order.ID, "submitted").
Order("id DESC").
First(&checkout).Error; err != nil {
return err
} }
now := time.Now() now := time.Now()
if err := tx.Model(&model.HandoffRecord{}). if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type = ?", order.ID, "renter_return"). Where("order_id = ? AND type = ?", order.ID, "renter_checkout").
Update("confirmed_by_owner_at", now).Error; err != nil { Update("confirmed_by_owner_at", now).Error; err != nil {
return err return err
} }
var listing model.RentalListing checkout.Status = "accepted"
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil { checkout.OwnerAdjustedAt = &now
return r.finalizeCheckout(tx, &order, &checkout, "号主已确认结账,订单完成。")
})
}
func (r *Repository) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
var checkoutID uint64
err := r.db.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 return err
} }
var account model.GameAccount if order.OwnerID != userID {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil { return ErrPermissionDenied
}
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
return ErrCheckoutCannotCounter
}
var checkout model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND status = ?", order.ID, "submitted").
Order("id DESC").
First(&checkout).Error; err != nil {
return err return err
} }
order.Status = "completed" next, err := buildCheckout(order, checkout.InitiatedBy, "countered", checkout.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, req.DepositDeductAmount, true)
order.HandoffStatus = "returned" if err != nil {
order.SettlementStatus = "settled" return err
order.SettledAt = &now }
order.OwnerSettledAt = &now now := time.Now()
checkout.Status = "countered"
checkout.ConsumableAmount = next.ConsumableAmount
checkout.CoinConsumedM = next.CoinConsumedM
checkout.OtherAmount = next.OtherAmount
checkout.DepositDeductAmount = next.DepositDeductAmount
checkout.RenterRefundAmount = next.RenterRefundAmount
checkout.OwnerIncomeAmount = next.OwnerIncomeAmount
checkout.OwnerAdjustmentReason = req.Reason
checkout.OwnerAdjustedAt = &now
checkout.EvidenceURLS = next.EvidenceURLS
order.Status = "pending_checkout_accept"
order.HandoffStatus = "pending_renter_checkout"
order.SettlementStatus = "pending"
orderID := order.ID orderID := order.ID
if err := wallet.AppendEntries(tx, if err := notification.Append(tx, notification.Entry{
wallet.Entry{ UserID: order.RenterID,
UserID: order.RenterID, Type: "checkout",
OrderID: &orderID, Title: "号主已修改结账金额",
Direction: "out", Content: "请核对号主修正的消耗和结算金额。同意后订单完成;不同意可发起争议。",
Amount: order.RentAmount + order.DepositAmount, BizType: "order",
BalanceType: "frozen", BizID: &orderID,
BizType: "order_settle", }); err != nil {
BizNo: order.OrderNo,
Remark: "订单完成释放模拟冻结金额",
},
wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
Amount: order.RentAmount,
BalanceType: "available",
BizType: "owner_income",
BizNo: order.OrderNo,
Remark: "订单完成模拟结算订单金额",
},
wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "in",
Amount: order.DepositAmount,
BalanceType: "available",
BizType: "deposit_release",
BizNo: order.OrderNo,
Remark: "订单完成模拟退回押金",
},
); err != nil {
return err return err
} }
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "settlement",
Title: "订单已完成",
Content: "号主已确认归还,模拟押金已退回。",
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "settlement",
Title: "订单已完成",
Content: "订单已完成,模拟订单金额已入账。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return err
}
listing.Status = "published"
listing.InTransaction = false
account.Status = "published"
if err := tx.Save(&order).Error; err != nil { if err := tx.Save(&order).Error; err != nil {
return err return err
} }
if err := tx.Save(&listing).Error; err != nil { if err := tx.Save(&checkout).Error; err != nil {
return err return err
} }
return tx.Save(&account).Error checkoutID = checkout.ID
return nil
})
if err != nil {
return nil, err
}
checkout, err := r.findCheckout(checkoutID)
if err != nil {
return nil, err
}
dto := toCheckoutDTO(*checkout)
return &dto, nil
}
func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error {
return r.db.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.RenterID != userID {
return ErrPermissionDenied
}
if order.Status != "pending_checkout_accept" || order.HandoffStatus != "pending_renter_checkout" {
return ErrCheckoutCannotConfirm
}
var checkout model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND status = ?", order.ID, "countered").
Order("id DESC").
First(&checkout).Error; err != nil {
return err
}
now := time.Now()
checkout.Status = "accepted"
checkout.RenterConfirmedAt = &now
return r.finalizeCheckout(tx, &order, &checkout, "租客已确认修正结账,订单完成。")
}) })
} }
@@ -562,6 +611,7 @@ func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) {
return nil, err return nil, err
} }
dto := row.toDTO() dto := row.toDTO()
dto.Checkout = r.latestCheckoutDTO(orderID)
return &dto, nil return &dto, nil
} }
@@ -752,9 +802,95 @@ func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, erro
return nil, err return nil, err
} }
dto := row.toDTO() dto := row.toDTO()
dto.Checkout = r.latestCheckoutDTO(orderID)
return &dto, nil return &dto, nil
} }
func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, renterContent string) error {
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
return err
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
return err
}
now := time.Now()
order.Status = "completed"
order.HandoffStatus = "returned"
order.SettlementStatus = "settled"
order.SettledAt = &now
order.OwnerSettledAt = &now
listing.Status = "published"
listing.InTransaction = false
account.Status = "published"
orderID := order.ID
if err := wallet.AppendEntries(tx,
wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "out",
Amount: order.RentAmount + order.DepositAmount,
BalanceType: "frozen",
BizType: "order_settle",
BizNo: order.OrderNo,
Remark: "订单结账释放冻结金额",
},
wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
Amount: checkout.OwnerIncomeAmount,
BalanceType: "available",
BizType: "owner_income",
BizNo: order.OrderNo,
Remark: "订单结账收入",
},
wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "in",
Amount: checkout.RenterRefundAmount,
BalanceType: "available",
BizType: "deposit_release",
BizNo: order.OrderNo,
Remark: "订单结账退回押金",
},
); err != nil {
return err
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "settlement",
Title: "订单已完成",
Content: renterContent,
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "settlement",
Title: "订单已完成",
Content: "订单已完成,结账金额已入账。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return err
}
if err := tx.Save(order).Error; err != nil {
return err
}
if err := tx.Save(checkout).Error; err != nil {
return err
}
if err := tx.Save(&listing).Error; err != nil {
return err
}
return tx.Save(&account).Error
}
func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) { func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) {
var order model.RentalOrder var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
@@ -797,6 +933,87 @@ func pendingPaymentTimeoutMinutes(tx *gorm.DB) int {
return value return value
} }
func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, content string, evidenceURLS []string, consumableAmount float64, coinConsumedM float64, otherAmount float64, explicitDeduct float64, useExplicitDeduct bool) (model.OrderCheckout, error) {
if consumableAmount < 0 || coinConsumedM < 0 || otherAmount < 0 || explicitDeduct < 0 {
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
}
deductAmount := consumableAmount + otherAmount
if useExplicitDeduct {
deductAmount = explicitDeduct
}
if deductAmount > order.DepositAmount {
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
}
renterRefund := order.DepositAmount - deductAmount
evidence, err := marshalStringList(evidenceURLS)
if err != nil {
return model.OrderCheckout{}, err
}
return model.OrderCheckout{
OrderID: order.ID,
InitiatedBy: initiatedBy,
Status: status,
RentAmount: order.RentAmount,
DepositAmount: order.DepositAmount,
ConsumableAmount: roundMoney(consumableAmount),
CoinConsumedM: roundMoney(coinConsumedM),
OtherAmount: roundMoney(otherAmount),
DepositDeductAmount: roundMoney(deductAmount),
RenterRefundAmount: roundMoney(renterRefund),
OwnerIncomeAmount: roundMoney(order.RentAmount + deductAmount),
Content: content,
EvidenceURLS: evidence,
}, nil
}
func marshalStringList(items []string) (datatypes.JSON, error) {
if items == nil {
items = []string{}
}
raw, err := json.Marshal(items)
return datatypes.JSON(raw), err
}
func decodeStringList(raw datatypes.JSON) []string {
if len(raw) == 0 {
return []string{}
}
var items []string
if err := json.Unmarshal(raw, &items); err != nil {
return []string{}
}
return items
}
func roundMoney(value float64) float64 {
return math.Round(value*100) / 100
}
func hasOpenCheckout(tx *gorm.DB, orderID uint64) (bool, error) {
var count int64
err := tx.Model(&model.OrderCheckout{}).
Where("order_id = ? AND status IN ?", orderID, []string{"submitted", "countered", "accepted", "disputed"}).
Count(&count).Error
return count > 0, err
}
func (r *Repository) latestCheckoutDTO(orderID uint64) *CheckoutDTO {
var checkout model.OrderCheckout
if err := r.db.Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil {
return nil
}
dto := toCheckoutDTO(checkout)
return &dto
}
func (r *Repository) findCheckout(id uint64) (*model.OrderCheckout, error) {
var checkout model.OrderCheckout
if err := r.db.First(&checkout, id).Error; err != nil {
return nil, err
}
return &checkout, nil
}
func (r *Repository) baseQuery() *gorm.DB { func (r *Repository) baseQuery() *gorm.DB {
return r.db.Table("rental_orders AS o"). return r.db.Table("rental_orders AS o").
Select("o.*, a.title, a.server_region, a.login_platform"). Select("o.*, a.title, a.server_region, a.login_platform").
@@ -861,6 +1078,31 @@ func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO {
} }
} }
func toCheckoutDTO(checkout model.OrderCheckout) CheckoutDTO {
return CheckoutDTO{
ID: checkout.ID,
OrderID: checkout.OrderID,
InitiatedBy: checkout.InitiatedBy,
Status: checkout.Status,
RentAmount: checkout.RentAmount,
DepositAmount: checkout.DepositAmount,
ConsumableAmount: checkout.ConsumableAmount,
CoinConsumedM: checkout.CoinConsumedM,
OtherAmount: checkout.OtherAmount,
DepositDeductAmount: checkout.DepositDeductAmount,
RenterRefundAmount: checkout.RenterRefundAmount,
OwnerIncomeAmount: checkout.OwnerIncomeAmount,
Content: checkout.Content,
EvidenceURLS: decodeStringList(checkout.EvidenceURLS),
OwnerAdjustmentReason: checkout.OwnerAdjustmentReason,
OwnerAdjustedAt: checkout.OwnerAdjustedAt,
RenterConfirmedAt: checkout.RenterConfirmedAt,
RenterRejectedAt: checkout.RenterRejectedAt,
CreatedAt: checkout.CreatedAt,
UpdatedAt: checkout.UpdatedAt,
}
}
func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) { func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) {
payload := map[string]any{ payload := map[string]any{
"account_id": account.ID, "account_id": account.ID,
+38
View File
@@ -14,6 +14,10 @@ var (
ErrOrderCannotReceive = errors.New("order cannot receive") ErrOrderCannotReceive = errors.New("order cannot receive")
ErrOrderCannotReturn = errors.New("order cannot return") ErrOrderCannotReturn = errors.New("order cannot return")
ErrOrderCannotComplete = errors.New("order cannot complete") ErrOrderCannotComplete = errors.New("order cannot complete")
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
ErrPermissionDenied = errors.New("permission denied") ErrPermissionDenied = errors.New("permission denied")
) )
@@ -88,6 +92,16 @@ func (s *Service) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRe
return s.repo.SubmitReturn(userID, orderID, req) return s.repo.SubmitReturn(userID, orderID, req)
} }
func (s *Service) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 || req.Content == "" {
return nil, ErrCheckoutCannotSubmit
}
return s.repo.SubmitCheckout(userID, orderID, req)
}
func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error { func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error {
if s.repo == nil { if s.repo == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
@@ -95,6 +109,30 @@ func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error {
return s.repo.ConfirmReturn(userID, orderID) return s.repo.ConfirmReturn(userID, orderID)
} }
func (s *Service) ConfirmCheckout(userID uint64, orderID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.ConfirmCheckout(userID, orderID)
}
func (s *Service) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 || req.Reason == "" {
return nil, ErrCheckoutCannotCounter
}
return s.repo.CounterCheckout(userID, orderID, req)
}
func (s *Service) AcceptCheckout(userID uint64, orderID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.AcceptCheckout(userID, orderID)
}
func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) { func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
+4
View File
@@ -173,6 +173,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive) orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive)
orderRoutes.POST("/:id/return", orderHandler.SubmitReturn) orderRoutes.POST("/:id/return", orderHandler.SubmitReturn)
orderRoutes.POST("/:id/confirm-return", orderHandler.ConfirmReturn) orderRoutes.POST("/:id/confirm-return", orderHandler.ConfirmReturn)
orderRoutes.POST("/:id/checkout", orderHandler.SubmitCheckout)
orderRoutes.POST("/:id/checkout/confirm", orderHandler.ConfirmCheckout)
orderRoutes.POST("/:id/checkout/counter", orderHandler.CounterCheckout)
orderRoutes.POST("/:id/checkout/accept", orderHandler.AcceptCheckout)
orderRoutes.POST("/:id/dispute", disputeHandler.Create) orderRoutes.POST("/:id/dispute", disputeHandler.Create)
} }
@@ -18,3 +18,28 @@ DEALLOCATE PREPARE add_listing_in_transaction_stmt;
INSERT INTO system_configs (`key`, `value`, description) INSERT INTO system_configs (`key`, `value`, description)
VALUES ('order.pending_payment_timeout_minutes', '15', '订单待支付超时取消分钟数') VALUES ('order.pending_payment_timeout_minutes', '15', '订单待支付超时取消分钟数')
ON DUPLICATE KEY UPDATE `key` = VALUES(`key`); ON DUPLICATE KEY UPDATE `key` = VALUES(`key`);
CREATE TABLE IF NOT EXISTS order_checkouts (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
initiated_by BIGINT UNSIGNED NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'submitted',
rent_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
deposit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
consumable_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
coin_consumed_m DECIMAL(12,2) NOT NULL DEFAULT 0.00,
other_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
deposit_deduct_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
renter_refund_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
owner_income_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
content TEXT NULL,
evidence_urls JSON NULL,
owner_adjustment_reason TEXT NULL,
owner_adjusted_at DATETIME NULL,
renter_confirmed_at DATETIME NULL,
renter_rejected_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_order_checkouts_order_id (order_id),
KEY idx_order_checkouts_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+61
View File
@@ -21,6 +21,30 @@ export interface Order {
status: string status: string
handoff_status: string handoff_status: string
settlement_status: string settlement_status: string
checkout?: Checkout
created_at: string
updated_at: string
}
export interface Checkout {
id: number
order_id: number
initiated_by: number
status: string
rent_amount: number
deposit_amount: number
consumable_amount: number
coin_consumed_m: number
other_amount: number
deposit_deduct_amount: number
renter_refund_amount: number
owner_income_amount: number
content: string
evidence_urls: string[]
owner_adjustment_reason: string
owner_adjusted_at?: string
renter_confirmed_at?: string
renter_rejected_at?: string
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -37,6 +61,23 @@ export interface HandoffRecord {
created_at: string created_at: string
} }
export interface SubmitCheckoutPayload {
content: string
consumable_amount: number
coin_consumed_m: number
other_amount: number
evidence_urls: string[]
}
export interface CounterCheckoutPayload {
consumable_amount: number
coin_consumed_m: number
other_amount: number
deposit_deduct_amount: number
reason: string
evidence_urls: string[]
}
interface ApiResponse<T> { interface ApiResponse<T> {
code: string code: string
message: string message: string
@@ -119,3 +160,23 @@ export async function confirmReturn(id: number) {
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/confirm-return`) const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/confirm-return`)
return data.data return data.data
} }
export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) {
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/checkout`, payload)
return data.data
}
export async function confirmCheckout(id: number) {
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/checkout/confirm`)
return data.data
}
export async function counterCheckout(id: number, payload: CounterCheckoutPayload) {
const { data } = await apiClient.post<ApiResponse<Checkout>>(`/orders/${id}/checkout/counter`, payload)
return data.data
}
export async function acceptCheckout(id: number) {
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/checkout/accept`)
return data.data
}
+11 -3
View File
@@ -19,7 +19,10 @@ const orderStatusMap: Record<string, string> = {
pending_handoff: '待交接', pending_handoff: '待交接',
renting: '使用中', renting: '使用中',
overdue: '已逾期', overdue: '已逾期',
pending_return_confirm: '待归还确认', pending_return_confirm: '待结账确认',
pending_checkout_confirm: '待号主确认结账',
pending_checkout_accept: '待租客确认修正',
checkout_disputing: '结账争议中',
completed: '已完成', completed: '已完成',
cancelled: '已取消', cancelled: '已取消',
closed: '已关闭', closed: '已关闭',
@@ -31,13 +34,17 @@ const handoffStatusMap: Record<string, string> = {
pending_owner: '待号主交接', pending_owner: '待号主交接',
pending_renter_confirm: '待租客确认', pending_renter_confirm: '待租客确认',
received: '已确认收号', received: '已确认收号',
pending_owner_return_confirm: '待号主确认归还', pending_owner_return_confirm: '待号主确认结账',
pending_owner_checkout: '待号主确认结账',
pending_renter_checkout: '待租客确认修正',
checkout_disputed: '结账争议中',
returned: '已归还', returned: '已归还',
cancelled: '已取消', cancelled: '已取消',
owner_timeout: '号主交接超时', owner_timeout: '号主交接超时',
renter_confirm_timeout: '租客确认超时', renter_confirm_timeout: '租客确认超时',
return_overdue: '归还逾期', return_overdue: '归还逾期',
owner_return_confirm_timeout: '号主确认归还超时', owner_return_confirm_timeout: '号主确认结账超时',
owner_checkout_confirm_timeout: '号主确认结账超时',
admin_closed: '客服关闭', admin_closed: '客服关闭',
admin_abnormal: '客服标记异常', admin_abnormal: '客服标记异常',
arbitrated: '已仲裁', arbitrated: '已仲裁',
@@ -51,6 +58,7 @@ const settlementStatusMap: Record<string, string> = {
refunded: '已退款', refunded: '已退款',
cancelled: '已取消', cancelled: '已取消',
closed: '已关闭', closed: '已关闭',
disputed: '争议中',
arbitrated: '已仲裁', arbitrated: '已仲裁',
} }
+172 -28
View File
@@ -6,14 +6,16 @@ import { useRoute, useRouter } from 'vue-router'
import { createDispute } from '@/api/disputes' import { createDispute } from '@/api/disputes'
import { uploadFile } from '@/api/files' import { uploadFile } from '@/api/files'
import { import {
acceptCheckout,
cancelOrder, cancelOrder,
confirmCheckout,
confirmReceive, confirmReceive,
confirmReturn, counterCheckout,
fetchHandoffRecords, fetchHandoffRecords,
fetchOrder, fetchOrder,
payOrder, payOrder,
submitCheckout,
submitHandoff, submitHandoff,
submitReturn,
type HandoffRecord, type HandoffRecord,
type Order, type Order,
} from '@/api/orders' } from '@/api/orders'
@@ -31,12 +33,30 @@ const handoffing = ref(false)
const confirming = ref(false) const confirming = ref(false)
const returning = ref(false) const returning = ref(false)
const completing = ref(false) const completing = ref(false)
const countering = ref(false)
const acceptingCheckout = ref(false)
const rejectingCheckout = ref(false)
const disputing = ref(false) const disputing = 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[]>([])
const handoffContent = ref('') const handoffContent = ref('')
const returnContent = ref('') const checkoutForm = ref({
content: '',
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
evidenceText: '',
})
const counterForm = ref({
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
deposit_deduct_amount: 0,
reason: '',
evidenceText: '',
})
const rejectReason = ref('')
const disputeType = ref('cannot_login') const disputeType = ref('cannot_login')
const disputeDescription = ref('') const disputeDescription = ref('')
const disputeEvidenceText = ref('') const disputeEvidenceText = ref('')
@@ -45,7 +65,10 @@ const isOwner = computed(() => order.value?.owner_id === session.userId)
const isRenter = computed(() => order.value?.renter_id === session.userId) const isRenter = computed(() => order.value?.renter_id === session.userId)
const canOpenDispute = computed(() => { const canOpenDispute = computed(() => {
if (!order.value || (!isOwner.value && !isRenter.value)) return false if (!order.value || (!isOwner.value && !isRenter.value)) return false
return !['completed', 'cancelled', 'closed', 'disputing'].includes(order.value.status) return !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing', 'abnormal'].includes(order.value.status)
})
const isCheckoutDisputeStage = computed(() => {
return !!order.value && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status)
}) })
onMounted(loadOrder) onMounted(loadOrder)
@@ -55,6 +78,7 @@ async function loadOrder() {
try { try {
order.value = await fetchOrder(String(route.params.id)) order.value = await fetchOrder(String(route.params.id))
handoffRecords.value = await fetchHandoffRecords(String(route.params.id)) handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
hydrateCounterForm()
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -117,45 +141,108 @@ async function handleConfirmReceive() {
} }
} }
async function handleSubmitReturn() { async function handleSubmitCheckout() {
if (!order.value) return if (!order.value) return
returning.value = true returning.value = true
try { try {
await submitReturn(order.value.id, returnContent.value) await submitCheckout(order.value.id, {
returnContent.value = '' content: checkoutForm.value.content,
ElMessage.success('归还申请已提交,等待号主确认') consumable_amount: checkoutForm.value.consumable_amount,
coin_consumed_m: checkoutForm.value.coin_consumed_m,
other_amount: checkoutForm.value.other_amount,
evidence_urls: linesToList(checkoutForm.value.evidenceText),
})
checkoutForm.value = {
content: '',
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
evidenceText: '',
}
ElMessage.success('结账已发起,等待号主确认')
await loadOrder() await loadOrder()
} catch (error) { } catch (error) {
ElMessage.error(readError(error, '提交归还失败')) ElMessage.error(readError(error, '发起结账失败'))
} finally { } finally {
returning.value = false returning.value = false
} }
} }
async function handleConfirmReturn() { async function handleConfirmCheckout() {
if (!order.value) return if (!order.value) return
completing.value = true completing.value = true
try { try {
await confirmReturn(order.value.id) await confirmCheckout(order.value.id)
ElMessage.success('归还已确认,订单完成') ElMessage.success('结账已确认,订单完成')
await loadOrder() await loadOrder()
} catch (error) { } catch (error) {
ElMessage.error(readError(error, '确认归还失败')) ElMessage.error(readError(error, '确认结账失败'))
} finally { } finally {
completing.value = false completing.value = false
} }
} }
async function handleCounterCheckout() {
if (!order.value) return
countering.value = true
try {
await counterCheckout(order.value.id, {
consumable_amount: counterForm.value.consumable_amount,
coin_consumed_m: counterForm.value.coin_consumed_m,
other_amount: counterForm.value.other_amount,
deposit_deduct_amount: counterForm.value.deposit_deduct_amount,
reason: counterForm.value.reason,
evidence_urls: linesToList(counterForm.value.evidenceText),
})
ElMessage.success('结账修正已提交,等待租客确认')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '修改结账失败'))
} finally {
countering.value = false
}
}
async function handleAcceptCheckout() {
if (!order.value) return
acceptingCheckout.value = true
try {
await acceptCheckout(order.value.id)
ElMessage.success('已确认修正结账,订单完成')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '确认修正失败'))
} finally {
acceptingCheckout.value = false
}
}
async function handleRejectCheckout() {
if (!order.value) return
rejectingCheckout.value = true
try {
await createDispute(order.value.id, {
type: 'checkout_dispute',
description: rejectReason.value,
evidence_urls: [],
})
rejectReason.value = ''
ElMessage.success('已拒绝修正结账,订单进入争议处理')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '拒绝修正失败'))
} finally {
rejectingCheckout.value = false
}
}
async function handleCreateDispute() { async function handleCreateDispute() {
if (!order.value) return if (!order.value) return
disputing.value = true disputing.value = true
try { try {
const evidence_urls = disputeEvidenceText.value const evidence_urls = linesToList(disputeEvidenceText.value)
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
await createDispute(order.value.id, { await createDispute(order.value.id, {
type: disputeType.value, type: isCheckoutDisputeStage.value ? 'checkout_dispute' : disputeType.value,
description: disputeDescription.value, description: disputeDescription.value,
evidence_urls, evidence_urls,
}) })
@@ -194,6 +281,22 @@ function readError(error: unknown, fallback: string) {
} }
return fallback return fallback
} }
function hydrateCounterForm() {
if (!order.value?.checkout) return
const checkout = order.value.checkout
counterForm.value.consumable_amount = checkout.consumable_amount
counterForm.value.coin_consumed_m = checkout.coin_consumed_m
counterForm.value.other_amount = checkout.other_amount
counterForm.value.deposit_deduct_amount = checkout.deposit_deduct_amount
}
function linesToList(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
}
</script> </script>
<template> <template>
@@ -261,20 +364,59 @@ function readError(error: unknown, fallback: string) {
</div> </div>
<div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel"> <div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel">
<h2>提交归还</h2> <h2>发起结账</h2>
<el-input v-model="returnContent" type="textarea" :rows="4" placeholder="填写归还说明、租后资产状态或注意事项" /> <el-input v-model="checkoutForm.content" type="textarea" :rows="4" placeholder="填写结账说明、租后资产状态或注意事项" />
<el-button class="panel-action" type="primary" :loading="returning" @click="handleSubmitReturn">提交归还</el-button> <div class="form-grid panel-action">
<el-input-number v-model="checkoutForm.consumable_amount" class="full-control" :min="0" :precision="2" controls-position="right" placeholder="消耗扣款" />
<el-input-number v-model="checkoutForm.coin_consumed_m" class="full-control" :min="0" :precision="2" controls-position="right" placeholder="消耗哈夫币 M" />
<el-input-number v-model="checkoutForm.other_amount" class="full-control" :min="0" :precision="2" controls-position="right" placeholder="其他扣款" />
</div>
<el-input
v-model="checkoutForm.evidenceText"
class="panel-action"
type="textarea"
:rows="3"
placeholder="结账证据链接,一行一个。可填写截图地址或备注链接"
/>
<el-button class="panel-action" type="primary" :loading="returning" @click="handleSubmitCheckout">发起结账</el-button>
</div> </div>
<div v-if="order && isOwner && order.status === 'pending_return_confirm'" class="order-panel"> <div v-if="order && order.checkout && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)" class="order-panel">
<h2>确认归还</h2> <h2>结账明细</h2>
<p>确认账号状态无误后订单会完成账号重新上架</p> <p>租金¥{{ order.checkout.rent_amount }}押金¥{{ order.checkout.deposit_amount }}</p>
<el-button type="primary" :loading="completing" @click="handleConfirmReturn">确认归还并完成订单</el-button> <p>扣除¥{{ order.checkout.deposit_deduct_amount }}退还租客¥{{ order.checkout.renter_refund_amount }}号主收入¥{{ order.checkout.owner_income_amount }}</p>
<p v-if="order.checkout.content">说明{{ order.checkout.content }}</p>
<p v-if="order.checkout.owner_adjustment_reason">修正原因{{ order.checkout.owner_adjustment_reason }}</p>
</div>
<div v-if="order && isOwner && order.status === 'pending_checkout_confirm'" class="order-panel">
<h2>确认结账</h2>
<p>确认账号状态和扣款金额无误后订单会完成账号重新上架</p>
<el-button type="primary" :loading="completing" @click="handleConfirmCheckout">确认结账并完成订单</el-button>
<h2 class="panel-action">修改结账</h2>
<div class="form-grid">
<el-input-number v-model="counterForm.consumable_amount" class="full-control" :min="0" :precision="2" controls-position="right" placeholder="消耗扣款" />
<el-input-number v-model="counterForm.coin_consumed_m" class="full-control" :min="0" :precision="2" controls-position="right" placeholder="消耗哈夫币 M" />
<el-input-number v-model="counterForm.other_amount" class="full-control" :min="0" :precision="2" controls-position="right" placeholder="其他扣款" />
<el-input-number v-model="counterForm.deposit_deduct_amount" class="full-control" :min="0" :max="order.deposit_amount" :precision="2" controls-position="right" placeholder="押金扣除" />
</div>
<el-input v-model="counterForm.reason" class="panel-action" type="textarea" :rows="3" placeholder="填写修改原因" />
<el-input v-model="counterForm.evidenceText" class="panel-action" type="textarea" :rows="3" placeholder="修正证据链接,一行一个" />
<el-button class="panel-action" type="warning" :loading="countering" @click="handleCounterCheckout">提交修正给租客确认</el-button>
</div>
<div v-if="order && isRenter && order.status === 'pending_checkout_accept'" class="order-panel">
<h2>确认修正结账</h2>
<p>同意后订单会完成结算不同意会进入争议由客服仲裁</p>
<el-button type="primary" :loading="acceptingCheckout" @click="handleAcceptCheckout">同意修正并完成订单</el-button>
<el-input v-model="rejectReason" class="panel-action" type="textarea" :rows="3" placeholder="不同意时填写原因,会进入争议处理" />
<el-button class="panel-action" type="danger" :loading="rejectingCheckout" @click="handleRejectCheckout">拒绝修正并发起争议</el-button>
</div> </div>
<div v-if="order && canOpenDispute" class="order-panel"> <div v-if="order && canOpenDispute" class="order-panel">
<h2>发起申诉</h2> <h2>{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h2>
<el-select v-model="disputeType" class="full-control" placeholder="选择申诉类型"> <el-select v-if="!isCheckoutDisputeStage" v-model="disputeType" class="full-control" placeholder="选择申诉类型">
<el-option label="无法登录" value="cannot_login" /> <el-option label="无法登录" value="cannot_login" />
<el-option label="虚假描述" value="false_description" /> <el-option label="虚假描述" value="false_description" />
<el-option label="账号被封" value="account_banned" /> <el-option label="账号被封" value="account_banned" />
@@ -300,7 +442,9 @@ function readError(error: unknown, fallback: string) {
<div class="panel-action upload-line"> <div class="panel-action upload-line">
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" :disabled="uploadingEvidence" @change="handleEvidenceUpload" /> <input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" :disabled="uploadingEvidence" @change="handleEvidenceUpload" />
</div> </div>
<el-button class="panel-action" type="warning" :loading="disputing" @click="handleCreateDispute">提交申诉</el-button> <el-button class="panel-action" type="warning" :loading="disputing" @click="handleCreateDispute">
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
</el-button>
</div> </div>
</section> </section>
</template> </template>
+4 -1
View File
@@ -40,7 +40,10 @@ async function loadOrders() {
<el-option label="待交接" value="pending_handoff" /> <el-option label="待交接" value="pending_handoff" />
<el-option label="使用中" value="renting" /> <el-option label="使用中" value="renting" />
<el-option label="逾期中" value="overdue" /> <el-option label="逾期中" value="overdue" />
<el-option label="待归还确认" value="pending_return_confirm" /> <el-option label="待结账确认" value="pending_return_confirm" />
<el-option label="待号主确认结账" value="pending_checkout_confirm" />
<el-option label="待租客确认修正" value="pending_checkout_accept" />
<el-option label="结账争议中" value="checkout_disputing" />
<el-option label="申诉中" value="disputing" /> <el-option label="申诉中" value="disputing" />
<el-option label="异常" value="abnormal" /> <el-option label="异常" value="abnormal" />
<el-option label="已完成" value="completed" /> <el-option label="已完成" value="completed" />
@@ -38,7 +38,7 @@ const statusTabs = [
{ key: "pending_payment", label: "待支付" }, { key: "pending_payment", label: "待支付" },
{ key: "pending_handoff", label: "待交接" }, { key: "pending_handoff", label: "待交接" },
{ key: "renting", label: "使用中" }, { key: "renting", label: "使用中" },
{ key: "pending_return_confirm", label: "待归还" }, { key: "pending_checkout_confirm", label: "待结账" },
{ key: "completed", label: "已完成" }, { key: "completed", label: "已完成" },
]; ];
@@ -55,6 +55,9 @@ function statusColor(status: string) {
renting: "#1477ff", renting: "#1477ff",
overdue: "#e91e63", overdue: "#e91e63",
pending_return_confirm: "#e91e63", pending_return_confirm: "#e91e63",
pending_checkout_confirm: "#e91e63",
pending_checkout_accept: "#9c27b0",
checkout_disputing: "#f44336",
completed: "#4caf50", completed: "#4caf50",
cancelled: "#999", cancelled: "#999",
disputing: "#f44336", disputing: "#f44336",
@@ -70,7 +73,10 @@ function statusLabel(status: string) {
pending_handoff: "待交接", pending_handoff: "待交接",
renting: "使用中", renting: "使用中",
overdue: "已逾期", overdue: "已逾期",
pending_return_confirm: "待归还", pending_return_confirm: "待结账",
pending_checkout_confirm: "待号主确认结账",
pending_checkout_accept: "待租客确认修正",
checkout_disputing: "结账争议中",
completed: "已完成", completed: "已完成",
cancelled: "已取消", cancelled: "已取消",
disputing: "申诉中", disputing: "申诉中",