实现结账流程:替换旧归还逻辑为完整结账-修正-争议流程

- 新增 order_checkouts 表,支持结账明细(消耗/押金扣除/退回/号主入账)
- 租客发起结账 → 号主确认 → 已完成(正常路径)
- 号主修改结账 → 租客确认修正 → 已完成(修正路径)
- 结账阶段任一方发起争议 → 后台仲裁 → 已完成/已关闭/异常(争议路径)
- 争议模块适配结账争议类型,仲裁结果新增 mark_abnormal
- 超时任务适配新的 pending_checkout_confirm 状态
- 前端结账明细面板、发起结账/确认/修正/拒绝表单全部实现
- 移除旧的 SubmitReturn/ConfirmReturn 接口
This commit is contained in:
yml2213
2026-05-24 02:17:36 +08:00
parent 25e5599e0a
commit 3631e70321
29 changed files with 991 additions and 197 deletions
+12 -12
View File
@@ -253,16 +253,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,
}, },
@@ -281,7 +281,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
@@ -290,28 +290,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"
}
@@ -58,7 +58,7 @@ func (r *Repository) Summary() (*DashboardDTO, error) {
if err := r.db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil { if err := r.db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil {
return nil, err return nil, err
} }
if err := r.db.Model(&model.RentalOrder{}).Where("status = ?", "pending_return_confirm").Count(&pending.PendingReturnConfirms).Error; err != nil { if err := r.db.Model(&model.RentalOrder{}).Where("status IN ?", []string{"pending_checkout_confirm", "pending_checkout_accept"}).Count(&pending.PendingReturnConfirms).Error; err != nil {
return nil, err return nil, err
} }
recentOrders, err := r.recentOrders() recentOrders, err := r.recentOrders()
+44 -4
View File
@@ -35,6 +35,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" { if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
return ErrInvalidDispute return ErrInvalidDispute
} }
isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept"
var count int64 var count int64
if err := tx.Model(&model.Dispute{}). if err := tx.Model(&model.Dispute{}).
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}). Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
@@ -57,7 +58,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
OrderID: order.ID, OrderID: order.ID,
InitiatorID: userID, InitiatorID: userID,
TargetUserID: targetID, TargetUserID: targetID,
Type: req.Type, Type: disputeType(req.Type, isCheckoutDispute),
Status: "open", Status: "open",
Description: req.Description, Description: req.Description,
EvidenceURLS: evidence, EvidenceURLS: evidence,
@@ -65,17 +66,42 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
if err := tx.Create(&row).Error; err != nil { if err := tx.Create(&row).Error; err != nil {
return err return err
} }
order.Status = "disputing" if isCheckoutDispute {
now := time.Now()
order.Status = "checkout_disputing"
order.HandoffStatus = "checkout_disputed"
order.SettlementStatus = "disputed"
updates := map[string]any{
"status": "disputed",
"updated_at": now,
}
if userID == order.RenterID {
updates["renter_rejected_at"] = now
}
if err := tx.Model(&model.OrderCheckout{}).
Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}).
Updates(updates).Error; err != nil {
return err
}
} else {
order.Status = "disputing"
}
if err := tx.Save(&order).Error; err != nil { if err := tx.Save(&order).Error; err != nil {
return err return err
} }
disputeID := row.ID disputeID := row.ID
title := "订单进入申诉"
content := "对方已发起申诉,请等待客服仲裁或补充沟通记录。"
if isCheckoutDispute {
title = "订单进入结账争议"
content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。"
}
if err := notification.Append(tx, if err := notification.Append(tx,
notification.Entry{ notification.Entry{
UserID: targetID, UserID: targetID,
Type: "dispute", Type: "dispute",
Title: "订单进入申诉", Title: title,
Content: "对方已发起申诉,请等待客服仲裁或补充沟通记录。", Content: content,
BizType: "dispute", BizType: "dispute",
BizID: &disputeID, BizID: &disputeID,
}, },
@@ -177,6 +203,9 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest,
if req.Result == "order_close" { if req.Result == "order_close" {
listing.Status = "offline" listing.Status = "offline"
account.Status = "offline" account.Status = "offline"
} else if req.Result == "mark_abnormal" {
listing.Status = "abnormal"
account.Status = "abnormal"
} else { } else {
listing.Status = "published" listing.Status = "published"
account.Status = "published" account.Status = "published"
@@ -335,6 +364,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (
addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客") addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客")
case "order_close": case "order_close":
// Only release frozen funds. No available-balance settlement happens in development mode. // Only release frozen funds. No available-balance settlement happens in development mode.
case "mark_abnormal":
// 标记异常只释放冻结账务,后续由客服继续线下复核。
default: default:
return settlement, ErrInvalidDispute return settlement, ErrInvalidDispute
} }
@@ -395,11 +426,20 @@ func arbitrateOrderStatus(result string) string {
switch result { switch result {
case "full_refund", "partial_refund", "order_close": case "full_refund", "partial_refund", "order_close":
return "closed" return "closed"
case "mark_abnormal":
return "abnormal"
default: default:
return "completed" return "completed"
} }
} }
func disputeType(input string, isCheckoutDispute bool) string {
if isCheckoutDispute {
return "checkout_dispute"
}
return input
}
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error { func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error {
raw, err := json.Marshal(detail) raw, err := json.Marshal(detail)
if err != nil { if err != nil {
+39 -2
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"`
} }
@@ -39,8 +40,21 @@ type SubmitHandoffRequest struct {
Content string `json:"content" binding:"required"` Content string `json:"content" binding:"required"`
} }
type SubmitReturnRequest struct { type SubmitCheckoutRequest struct {
Content string `json:"content" binding:"required"` 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 {
@@ -63,3 +77,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"`
}
+54 -8
View File
@@ -210,7 +210,7 @@ func (h *Handler) HandoffRecords(c *gin.Context) {
response.OK(c, gin.H{"items": items}) response.OK(c, gin.H{"items": items})
} }
func (h *Handler) SubmitReturn(c *gin.Context) { func (h *Handler) SubmitCheckout(c *gin.Context) {
userID, ok := currentUserID(c) userID, ok := currentUserID(c)
if !ok { if !ok {
response.Unauthorized(c, "缺少用户上下文") response.Unauthorized(c, "缺少用户上下文")
@@ -220,12 +220,12 @@ func (h *Handler) SubmitReturn(c *gin.Context) {
if !ok { if !ok {
return return
} }
var req SubmitReturnRequest var req SubmitCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "归还说明不能为空") response.BadRequest(c, "结账说明不能为空")
return return
} }
record, err := h.service.SubmitReturn(userID, id, req) record, err := h.service.SubmitCheckout(userID, id, req)
if err != nil { if err != nil {
writeOrderError(c, err) writeOrderError(c, err)
return return
@@ -233,7 +233,7 @@ func (h *Handler) SubmitReturn(c *gin.Context) {
response.Created(c, record) response.Created(c, record)
} }
func (h *Handler) ConfirmReturn(c *gin.Context) { func (h *Handler) ConfirmCheckout(c *gin.Context) {
userID, ok := currentUserID(c) userID, ok := currentUserID(c)
if !ok { if !ok {
response.Unauthorized(c, "缺少用户上下文") response.Unauthorized(c, "缺少用户上下文")
@@ -243,7 +243,47 @@ func (h *Handler) ConfirmReturn(c *gin.Context) {
if !ok { if !ok {
return return
} }
if err := h.service.ConfirmReturn(userID, id); err != nil { 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) writeOrderError(c, err)
return return
} }
@@ -293,10 +333,16 @@ func writeOrderError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接") response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接")
case errors.Is(err, ErrOrderCannotReceive): case errors.Is(err, ErrOrderCannotReceive):
response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号") response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号")
case errors.Is(err, ErrOrderCannotReturn):
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):
+313 -77
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"math"
"strconv" "strconv"
"time" "time"
@@ -296,7 +297,7 @@ func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRec
return items, nil return items, nil
} }
func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) { 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
@@ -307,28 +308,43 @@ 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
}
hasOpenCheckout, err := hasOpenCheckout(tx, order.ID)
if err != nil {
return err
}
if hasOpenCheckout {
return ErrCheckoutCannotSubmit
} }
now := time.Now() 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 {
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"
order.RentEndAt = &now order.RentEndAt = &now
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 {
@@ -346,7 +362,7 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
return r.findHandoffRecord(recordID) return r.findHandoffRecord(recordID)
} }
func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error { 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 {
@@ -355,92 +371,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"
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, "租客已确认修正结账,订单完成。")
}) })
} }
@@ -482,6 +526,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
} }
@@ -655,9 +700,175 @@ 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"
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 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) 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 {
@@ -751,6 +962,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,
+26 -6
View File
@@ -10,8 +10,11 @@ var (
ErrOrderCannotCancel = errors.New("order cannot cancel") ErrOrderCannotCancel = errors.New("order cannot cancel")
ErrOrderCannotHandoff = errors.New("order cannot handoff") ErrOrderCannotHandoff = errors.New("order cannot handoff")
ErrOrderCannotReceive = errors.New("order cannot receive") ErrOrderCannotReceive = errors.New("order cannot receive")
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")
) )
@@ -66,21 +69,38 @@ func (s *Service) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRecord
return s.repo.HandoffRecords(userID, orderID) return s.repo.HandoffRecords(userID, orderID)
} }
func (s *Service) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) { func (s *Service) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
if orderID == 0 || req.Content == "" { if orderID == 0 || req.Content == "" {
return nil, ErrOrderCannotReturn return nil, ErrCheckoutCannotSubmit
} }
return s.repo.SubmitReturn(userID, orderID, req) return s.repo.SubmitCheckout(userID, orderID, req)
} }
func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error { func (s *Service) ConfirmCheckout(userID uint64, orderID uint64) error {
if s.repo == nil { if s.repo == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
} }
return s.repo.ConfirmReturn(userID, orderID) 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) {
@@ -30,8 +30,8 @@ type defaultConfig struct {
var defaultConfigs = []defaultConfig{ var defaultConfigs = []defaultConfig{
{Key: "handoff.owner_submit_timeout_minutes", Value: "30", Description: "号主待交接超时分钟数"}, {Key: "handoff.owner_submit_timeout_minutes", Value: "30", Description: "号主待交接超时分钟数"},
{Key: "handoff.renter_confirm_timeout_minutes", Value: "30", Description: "租客待确认收号超时分钟数"}, {Key: "handoff.renter_confirm_timeout_minutes", Value: "30", Description: "租客待确认收号超时分钟数"},
{Key: "handoff.owner_return_confirm_timeout_minutes", Value: "120", Description: "号主待确认归还超时分钟数"}, {Key: "handoff.owner_return_confirm_timeout_minutes", Value: "120", Description: "号主待确认结账超时分钟数"},
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后归还宽限分钟数"}, {Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"},
{Key: "deposit.min_amount", Value: "50", Description: "发布租号最低押金"}, {Key: "deposit.min_amount", Value: "50", Description: "发布租号最低押金"},
{Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"}, {Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"},
{Key: "risk.sms_limit_per_phone_hour", Value: "5", Description: "单手机号每小时短信验证码次数"}, {Key: "risk.sms_limit_per_phone_hour", Value: "5", Description: "单手机号每小时短信验证码次数"},
+4 -2
View File
@@ -170,8 +170,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff) orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords) orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords)
orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive) orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive)
orderRoutes.POST("/:id/return", orderHandler.SubmitReturn) orderRoutes.POST("/:id/checkout", orderHandler.SubmitCheckout)
orderRoutes.POST("/:id/confirm-return", orderHandler.ConfirmReturn) 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)
} }
+25
View File
@@ -109,6 +109,31 @@ CREATE TABLE handoff_records (
KEY idx_handoff_records_order_id (order_id) KEY idx_handoff_records_order_id (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE 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;
CREATE TABLE wallet_accounts ( CREATE TABLE wallet_accounts (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL, user_id BIGINT UNSIGNED NOT NULL,
@@ -0,0 +1,24 @@
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;
+5 -3
View File
@@ -7,7 +7,7 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
- 认证:短信验证码、手机号登录、Token 刷新、退出登录。 - 认证:短信验证码、手机号登录、Token 刷新、退出登录。
- 实名:发起认证、查询状态、服务回调。 - 实名:发起认证、查询状态、服务回调。
- 发布:创建、修改、提交审核、下架、列表、详情。 - 发布:创建、修改、提交审核、下架、列表、详情。
- 订单:创建、取消、确认收号、提交归还、确认归还、发起申诉。 - 订单:创建、取消、确认收号、发起结账、确认结账、修改结账、确认/拒绝修正、发起申诉。
- 交接:提交交接说明、查看交接记录。 - 交接:提交交接说明、查看交接记录。
- 钱包:余额、流水、提现预留。 - 钱包:余额、流水、提现预留。
- 通知:站内信列表、标记已读。 - 通知:站内信列表、标记已读。
@@ -37,8 +37,10 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
- `POST /api/orders/{id}/handoff` - `POST /api/orders/{id}/handoff`
- `GET /api/orders/{id}/handoff-records` - `GET /api/orders/{id}/handoff-records`
- `POST /api/orders/{id}/confirm-receive` - `POST /api/orders/{id}/confirm-receive`
- `POST /api/orders/{id}/return` - `POST /api/orders/{id}/checkout`
- `POST /api/orders/{id}/confirm-return` - `POST /api/orders/{id}/checkout/confirm`
- `POST /api/orders/{id}/checkout/counter`
- `POST /api/orders/{id}/checkout/accept`
- `POST /api/orders/{id}/dispute` - `POST /api/orders/{id}/dispute`
- `GET /api/disputes` - `GET /api/disputes`
- `GET /api/disputes/{id}` - `GET /api/disputes/{id}`
+18 -13
View File
@@ -49,24 +49,27 @@
- 确认收号时会重新计算租赁开始时间和结束时间。 - 确认收号时会重新计算租赁开始时间和结束时间。
- 交接记录保存在 `handoff_records`,订单双方都可以查看。 - 交接记录保存在 `handoff_records`,订单双方都可以查看。
## 开发态归还与完成 ## 开发态结账与完成
- 租赁中订单可以由租客提交归还 - 租赁中订单可以由租客发起结账,填写使用结束说明、消耗金额、哈夫币消耗量、其他扣款和证据链接
- 逾期中订单仍允许租客提交归还,但后台和号主可以根据逾期情况发起申诉或客服处理。 - 逾期中订单仍允许租客发起结账,但后台和号主可以根据逾期情况发起申诉或客服处理。
- 租客提交归还后,订单状态变为 `pending_return_confirm`,交接状态变为 `pending_owner_return_confirm` - 租客发起结账后,订单状态变为 `pending_checkout_confirm`,交接状态变为 `pending_owner_checkout`
- 只有号主可以确认归还 - 只有号主可以确认结账或修改结账
- 号主确认归还后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态标记为 `settled` - 号主直接确认结账后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态标记为 `settled`
- 号主修改结账后,订单状态变为 `pending_checkout_accept`,交接状态变为 `pending_renter_checkout`,必须等待租客确认修正。
- 待号主确认结账或待租客确认修正时,任一方都可以发起结账争议。
- 结账争议后订单状态变为 `checkout_disputing`,交接状态变为 `checkout_disputed`,由客服仲裁。
- 订单完成后,账号和发布状态恢复为 `published` - 订单完成后,账号和发布状态恢复为 `published`
- 当前会生成开发态模拟钱包流水,不代表真实支付或提现。 - 当前会生成开发态模拟钱包流水,不代表真实支付或提现。
## 开发态超时任务 ## 开发态超时任务
- 后端启动后会运行订单超时扫描任务,默认每 1 分钟执行一次。 - 后端启动后会运行订单超时扫描任务,默认每 1 分钟执行一次。
- 任务从 `system_configs` 读取超时阈值,包括号主待交接、租客确认收号、租客归还宽限和号主确认归还 - 任务从 `system_configs` 读取超时阈值,包括号主待交接、租客确认收号、租客结账宽限和号主确认结账
- 号主待交接超时:订单保持 `pending_handoff`,交接状态变为 `owner_timeout`,租客仍可取消订单或发起申诉。 - 号主待交接超时:订单保持 `pending_handoff`,交接状态变为 `owner_timeout`,租客仍可取消订单或发起申诉。
- 租客确认收号超时:订单状态变为 `abnormal`,交接状态变为 `renter_confirm_timeout`,进入客服介入。 - 租客确认收号超时:订单状态变为 `abnormal`,交接状态变为 `renter_confirm_timeout`,进入客服介入。
- 租客逾期未归还:订单状态变为 `overdue`,交接状态变为 `return_overdue`,租客仍可提交归还,号主可发起申诉。 - 租客逾期未结账:订单状态变为 `overdue`,交接状态变为 `return_overdue`,租客仍可发起结账,号主可发起申诉。
- 号主确认归还超时:订单状态变为 `abnormal`,交接状态变为 `owner_return_confirm_timeout`,进入客服复核。 - 号主确认结账超时:订单状态变为 `abnormal`,交接状态变为 `owner_checkout_confirm_timeout`,进入客服复核。
- 每个超时动作只推进一次状态,避免重复通知。 - 每个超时动作只推进一次状态,避免重复通知。
- 超时动作会给相关用户写入站内信,并以 `system` 身份写入 `audit_logs` - 超时动作会给相关用户写入站内信,并以 `system` 身份写入 `audit_logs`
@@ -97,8 +100,10 @@
- 租客取消订单后通知号主和租客。 - 租客取消订单后通知号主和租客。
- 号主提交交接说明后通知租客。 - 号主提交交接说明后通知租客。
- 租客确认收号后通知号主。 - 租客确认收号后通知号主。
- 租客提交归还后通知号主。 - 租客发起结账后通知号主。
- 号主确认归还后通知号主和租客。 - 号主确认结账后通知号主和租客。
- 号主修改结账后通知租客。
- 租客拒绝修正结账后通知双方并进入争议。
- 发起申诉后通知对方和发起人。 - 发起申诉后通知对方和发起人。
- 仲裁完成后通知号主和租客。 - 仲裁完成后通知号主和租客。
- 站内信支持列表查询和标记已读。 - 站内信支持列表查询和标记已读。
@@ -107,7 +112,7 @@
- 订单双方都可以在非终态订单发起申诉。 - 订单双方都可以在非终态订单发起申诉。
- 同一个订单同一时间只允许存在一个 `open``processing` 申诉。 - 同一个订单同一时间只允许存在一个 `open``processing` 申诉。
- 发起申诉后订单状态变为 `disputing`,账号和发布保持锁定。 - 发起普通申诉后订单状态变为 `disputing`发起结账争议后订单状态变为 `checkout_disputing`账号和发布保持锁定。
- 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。 - 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。
- 仲裁会释放开发态模拟冻结金额,并根据结果生成钱包流水。 - 仲裁会释放开发态模拟冻结金额,并根据结果生成钱包流水。
- 全额退款:释放冻结金额后,将租金加押金作为可用余额退给租客,订单置为 `closed` - 全额退款:释放冻结金额后,将租金加押金作为可用余额退给租客,订单置为 `closed`
@@ -139,7 +144,7 @@
- 后台仪表盘接口为 `/api/admin/dashboard`,前端页面为 `/admin/dashboard` - 后台仪表盘接口为 `/api/admin/dashboard`,前端页面为 `/admin/dashboard`
- 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。 - 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。
- 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待归还确认订单。 - 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待结账确认订单。
- 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。 - 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。
## 开发态用户管理 ## 开发态用户管理
+28 -21
View File
@@ -113,7 +113,7 @@
- 租客选择商品和租期后创建订单。 - 租客选择商品和租期后创建订单。
- 系统锁定商品库存,避免同一账号被重复出租。 - 系统锁定商品库存,避免同一账号被重复出租。
- 订单流转覆盖待确认、待交接、租赁中、待归还确认、已完成、已取消、申诉中。 - 订单流转覆盖待确认、待交接、租赁中、待号主确认结账、待租客确认修正、结账争议中、已完成、已取消、申诉中。
钱包账务: 钱包账务:
@@ -142,10 +142,10 @@
2. 订单确认后,商品进入锁定状态,订单进入待交接。 2. 订单确认后,商品进入锁定状态,订单进入待交接。
3. 号主在订单内提交交接说明,例如登录方式、注意事项、联系说明或外部交接备注。 3. 号主在订单内提交交接说明,例如登录方式、注意事项、联系说明或外部交接备注。
4. 租客确认收到账号后,订单进入租赁中,并记录租赁开始时间和预计结束时间。 4. 租客确认收到账号后,订单进入租赁中,并记录租赁开始时间和预计结束时间。
5. 租期即将结束时,系统通过站内信和短信提醒租客归还 5. 租期即将结束时,系统通过站内信和短信提醒租客完成使用并发起结账
6. 租客提交归还申请,上传必要的租后截图或说明 6. 租客发起结账,填写使用结束说明、消耗金额、哈夫币消耗量和必要证据
7. 号主确认账号状态无误后,订单进入结算 7. 号主确认账号状态无误后直接完成结算,或修改结账金额后交由租客确认
8. 任一方对交接、使用、归还或资产变化有异议,订单进入申诉仲裁。 8. 租客同意修正后订单完成;拒绝修正或任一方对交接、使用、结账、资产变化有异议,订单进入申诉仲裁。
回收边界: 回收边界:
@@ -158,8 +158,8 @@
- 号主待交接超时:订单进入待交接后,默认 30 分钟未提交交接说明,租客可取消订单或发起申诉。 - 号主待交接超时:订单进入待交接后,默认 30 分钟未提交交接说明,租客可取消订单或发起申诉。
- 租客确认收号超时:号主提交交接说明后,默认 30 分钟未确认收号,订单进入客服介入,不自动开始租期。 - 租客确认收号超时:号主提交交接说明后,默认 30 分钟未确认收号,订单进入客服介入,不自动开始租期。
- 租客归还超时:超过租期结束时间仍未提交归还,系统提醒后进入逾期中,号主可发起申诉。 - 租客结账超时:超过租期结束时间仍未发起结账,系统提醒后进入逾期中,号主可发起申诉。
- 号主确认归还超时:租客提交归还后,默认 2 小时未确认,订单进入自动确认候选或客服复核。 - 号主确认结账超时:租客发起结账后,默认 2 小时未确认,订单进入客服复核。
- 所有超时阈值必须进入 `system_configs` 管理,后台可调整。 - 所有超时阈值必须进入 `system_configs` 管理,后台可调整。
订单状态机: 订单状态机:
@@ -173,12 +173,16 @@
| 待交接 | 申诉中 | 租客/号主 | 交接争议或安全验证无法完成 | | 待交接 | 申诉中 | 租客/号主 | 交接争议或安全验证无法完成 |
| 待收号确认 | 租赁中 | 租客 | 租客确认收到账号并可正常登录 | | 待收号确认 | 租赁中 | 租客 | 租客确认收到账号并可正常登录 |
| 待收号确认 | 申诉中 | 租客/系统 | 租客确认超时、无法登录或描述不符 | | 待收号确认 | 申诉中 | 租客/系统 | 租客确认超时、无法登录或描述不符 |
| 租赁中 | 逾期中 | 系统 | 超过租期结束时间仍未提交归还 | | 租赁中 | 逾期中 | 系统 | 超过租期结束时间仍未发起结账 |
| 租赁中 | 待归还确认 | 租客 | 租客提交归还和租后凭证 | | 租赁中 | 待号主确认结账 | 租客 | 租客发起结账和租后凭证 |
| 逾期中 | 待归还确认 | 租客 | 租客补交归还申请 | | 逾期中 | 待号主确认结账 | 租客 | 租客补交结账申请 |
| 逾期中 | 申诉中 | 号主/系统 | 逾期未归还或疑似资产损失 | | 逾期中 | 申诉中 | 号主/系统 | 逾期未归还或疑似资产损失 |
| 待归还确认 | 已完成 | 号主/系统 | 号主确认归还,或超时后客服/系统确认 | | 待号主确认结账 | 已完成 | 号主 | 号主确认结账 |
| 待归还确认 | 申诉中 | 号主/租客 | 归还争议、资产损失或封号异常 | | 待号主确认结账 | 待租客确认修正 | 号主 | 号主修改结账金额 |
| 待租客确认修正 | 已完成 | 租客 | 租客同意修正结账 |
| 待号主确认结账 | 结账争议中 | 任一方 | 发起结账争议 |
| 待租客确认修正 | 结账争议中 | 任一方 | 发起结账争议 |
| 结账争议中 | 已完成/已关闭/异常 | 客服 | 后台仲裁 |
| 申诉中 | 已完成 | 客服 | 仲裁为正常完成或部分扣款后完成 | | 申诉中 | 已完成 | 客服 | 仲裁为正常完成或部分扣款后完成 |
| 申诉中 | 已取消 | 客服 | 仲裁为订单取消和退款 | | 申诉中 | 已取消 | 客服 | 仲裁为订单取消和退款 |
| 申诉中 | 已关闭 | 客服 | 仲裁为异常关闭、冻结用户或冻结商品 | | 申诉中 | 已关闭 | 客服 | 仲裁为异常关闭、冻结用户或冻结商品 |
@@ -216,7 +220,7 @@
结算规则: 结算规则:
- 正常完成订单后,租金按平台抽成规则分账给号主。 - 正常完成订单后,租金按平台抽成规则分账给号主。
- 押金在号主确认归还后释放给租客。 - 押金在双方结账确认或客服仲裁后释放给租客。
- 如发生资产损失、无法登录、封号或超时归还,可由客服仲裁后扣除部分或全部押金。 - 如发生资产损失、无法登录、封号或超时归还,可由客服仲裁后扣除部分或全部押金。
- 如号主虚假描述、超时未交接或账号无法使用,可裁定全额或部分退款。 - 如号主虚假描述、超时未交接或账号无法使用,可裁定全额或部分退款。
- 仲裁导致的扣款、退款、赔付必须生成资金流水和审计日志。 - 仲裁导致的扣款、退款、赔付必须生成资金流水和审计日志。
@@ -281,8 +285,9 @@
- 号主提交交接说明。 - 号主提交交接说明。
- 租客确认收号。 - 租客确认收号。
- 租期即将结束。 - 租期即将结束。
- 租客提交归还 - 租客发起结账
- 号主确认归还 - 号主确认或修改结账
- 租客确认或拒绝修正结账。
- 订单完成结算。 - 订单完成结算。
- 发起申诉。 - 发起申诉。
- 仲裁结果。 - 仲裁结果。
@@ -592,8 +597,10 @@
- `GET /api/orders/{id}`:订单详情。 - `GET /api/orders/{id}`:订单详情。
- `POST /api/orders/{id}/cancel`:取消订单。 - `POST /api/orders/{id}/cancel`:取消订单。
- `POST /api/orders/{id}/confirm-receive`:租客确认收号。 - `POST /api/orders/{id}/confirm-receive`:租客确认收号。
- `POST /api/orders/{id}/return`:租客提交归还 - `POST /api/orders/{id}/checkout`:租客发起结账
- `POST /api/orders/{id}/confirm-return`:号主确认归还 - `POST /api/orders/{id}/checkout/confirm`:号主确认结账
- `POST /api/orders/{id}/checkout/counter`:号主修改结账。
- `POST /api/orders/{id}/checkout/accept`:租客同意修正结账。
- `POST /api/orders/{id}/dispute`:发起申诉。 - `POST /api/orders/{id}/dispute`:发起申诉。
交接: 交接:
@@ -835,8 +842,8 @@ hfb_sys/
- 实现订单账号资产快照。 - 实现订单账号资产快照。
- 实现号主提交交接说明。 - 实现号主提交交接说明。
- 实现租客确认收号。 - 实现租客确认收号。
- 实现租客提交归还、号主确认归还 - 实现租客发起结账、号主确认或修改结账、租客确认或拒绝修正结账
- 实现交接、确认收号、归还、确认归还的超时处理。 - 实现交接、确认收号、结账逾期、确认结账超时处理。
- 实现钱包账户、押金冻结、租金流水、结算流水。 - 实现钱包账户、押金冻结、租金流水、结算流水。
- 预留支付和提现接口。 - 预留支付和提现接口。
@@ -887,9 +894,9 @@ hfb_sys/
- 商品草稿、待审核、已上架、已下架、审核拒绝状态流转正确。 - 商品草稿、待审核、已上架、已下架、审核拒绝状态流转正确。
- 审核未通过商品不能下单。 - 审核未通过商品不能下单。
- 同一账号并发下单只能成功一单。 - 同一账号并发下单只能成功一单。
- 订单待交接、租赁中、待归还确认、已完成、申诉中状态流转正确。 - 订单待交接、租赁中、待号主确认结账、待租客确认修正、结账争议中、已完成、申诉中状态流转正确。
- 非法订单状态转换会被拒绝。 - 非法订单状态转换会被拒绝。
- 交接、收号、归还、确认归还超时会按配置触发取消、申诉、逾期或客服复核。 - 交接、收号、结账逾期、确认结账超时会按配置触发取消、申诉、逾期或客服复核。
- 押金冻结、释放、扣款、退款流水一致。 - 押金冻结、释放、扣款、退款流水一致。
- 每笔 `wallet_ledger` 正确记录 `balance_after` - 每笔 `wallet_ledger` 正确记录 `balance_after`
- 租金、平台抽成、号主结算流水一致。 - 租金、平台抽成、号主结算流水一致。
+55 -4
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
@@ -105,12 +146,22 @@ export async function confirmReceive(id: number) {
return data.data return data.data
} }
export async function submitReturn(id: number, content: string) { export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) {
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/return`, { content }) const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/checkout`, payload)
return data.data return data.data
} }
export async function confirmReturn(id: number) { export async function confirmCheckout(id: number) {
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/confirm-return`) 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 return data.data
} }
+1
View File
@@ -619,6 +619,7 @@ h1 {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 16px; column-gap: 16px;
row-gap: 14px;
} }
.order-panel { .order-panel {
+11 -3
View File
@@ -18,7 +18,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: '已关闭',
@@ -30,13 +33,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: '已仲裁',
@@ -50,6 +57,7 @@ const settlementStatusMap: Record<string, string> = {
refunded: '已退款', refunded: '已退款',
cancelled: '已取消', cancelled: '已取消',
closed: '已关闭', closed: '已关闭',
disputed: '争议中',
arbitrated: '已仲裁', arbitrated: '已仲裁',
} }
+259 -29
View File
@@ -6,13 +6,15 @@ 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,
submitCheckout,
submitHandoff, submitHandoff,
submitReturn,
type HandoffRecord, type HandoffRecord,
type Order, type Order,
} from '@/api/orders' } from '@/api/orders'
@@ -29,12 +31,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('')
@@ -43,7 +63,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)
@@ -53,6 +76,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
} }
@@ -101,50 +125,114 @@ 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,
}) })
disputeDescription.value = '' disputeDescription.value = ''
disputeEvidenceText.value = '' disputeEvidenceText.value = ''
disputeType.value = 'cannot_login'
ElMessage.success('申诉已提交,订单进入仲裁处理') ElMessage.success('申诉已提交,订单进入仲裁处理')
await loadOrder() await loadOrder()
} catch (error) { } catch (error) {
@@ -171,6 +259,23 @@ async function handleEvidenceUpload(event: Event) {
} }
} }
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
counterForm.value.evidenceText = (checkout.evidence_urls || []).join('\n')
}
function linesToList(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
}
function readError(error: unknown, fallback: string) { function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) { if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response const response = (error as { response?: { data?: { message?: string } } }).response
@@ -240,21 +345,144 @@ function readError(error: unknown, fallback: string) {
<el-button type="primary" :loading="confirming" @click="handleConfirmReceive">确认已收到账号</el-button> <el-button type="primary" :loading="confirming" @click="handleConfirmReceive">确认已收到账号</el-button>
</div> </div>
<div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel"> <div v-if="order?.checkout" class="order-panel">
<h2>提交归还</h2> <h2>结账明细</h2>
<el-input v-model="returnContent" type="textarea" :rows="4" placeholder="填写归还说明、租后资产状态或注意事项" /> <div class="detail-grid">
<el-button class="panel-action" type="primary" :loading="returning" @click="handleSubmitReturn">提交归还</el-button> <div class="metric-card">
<span>押金扣除</span>
<strong>¥{{ order.checkout.deposit_deduct_amount }}</strong>
</div>
<div class="metric-card">
<span>租客退回</span>
<strong>¥{{ order.checkout.renter_refund_amount }}</strong>
</div>
<div class="metric-card">
<span>号主入账</span>
<strong>¥{{ order.checkout.owner_income_amount }}</strong>
</div>
<div class="metric-card">
<span>消耗</span>
<strong>{{ order.checkout.coin_consumed_m }}M</strong>
</div>
</div>
<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>
<div v-if="order && isOwner && order.status === 'pending_return_confirm'" class="order-panel"> <div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel">
<h2>确认归还</h2> <h2>发起结账</h2>
<p>确认账号状态无误后订单会完成账号重新上架</p> <el-input v-model="checkoutForm.content" type="textarea" :rows="4" placeholder="填写使用结束说明、账号状态和需要号主核对的内容" />
<el-button type="primary" :loading="completing" @click="handleConfirmReturn">确认归还并完成订单</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 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" />
@@ -280,7 +508,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>
+1 -1
View File
@@ -24,7 +24,7 @@ async function loadOrders() {
<div class="page-header"> <div class="page-header">
<p class="eyebrow">Orders</p> <p class="eyebrow">Orders</p>
<h1>我的订单</h1> <h1>我的订单</h1>
<p>跟踪待交接使用中归还申诉中和已完成订单</p> <p>跟踪待交接使用中结账申诉中和已完成订单</p>
</div> </div>
<el-table v-loading="loading" class="table-panel" :data="orders"> <el-table v-loading="loading" class="table-panel" :data="orders">
@@ -96,8 +96,8 @@ function actionType(action: string) {
<el-option label="订单标记异常" value="order.mark_abnormal" /> <el-option label="订单标记异常" value="order.mark_abnormal" />
<el-option label="号主交接超时" value="order.timeout.owner_submit" /> <el-option label="号主交接超时" value="order.timeout.owner_submit" />
<el-option label="租客确认超时" value="order.timeout.renter_confirm" /> <el-option label="租客确认超时" value="order.timeout.renter_confirm" />
<el-option label="租客归还逾期" value="order.timeout.return_overdue" /> <el-option label="租客结账逾期" value="order.timeout.return_overdue" />
<el-option label="号主确认归还超时" value="order.timeout.owner_return_confirm" /> <el-option label="号主确认结账超时" value="order.timeout.owner_checkout_confirm" />
<el-option label="申诉仲裁" value="dispute.arbitrate" /> <el-option label="申诉仲裁" value="dispute.arbitrate" />
<el-option label="更新系统配置" value="system_config.update" /> <el-option label="更新系统配置" value="system_config.update" />
<el-option label="创建系统配置" value="system_config.create" /> <el-option label="创建系统配置" value="system_config.create" />
@@ -86,7 +86,7 @@ function money(value?: number) {
<strong>{{ dashboard.pending.pending_handoffs }}</strong> <strong>{{ dashboard.pending.pending_handoffs }}</strong>
</div> </div>
<div class="pending-row"> <div class="pending-row">
<span>归还确认</span> <span>结账确认</span>
<strong>{{ dashboard.pending.pending_return_confirms }}</strong> <strong>{{ dashboard.pending.pending_return_confirms }}</strong>
</div> </div>
</div> </div>
@@ -99,7 +99,7 @@ function readError(error: unknown, fallback: string) {
<div class="page-header"> <div class="page-header">
<p class="eyebrow">Arbitration</p> <p class="eyebrow">Arbitration</p>
<h1>仲裁中心</h1> <h1>仲裁中心</h1>
<p>处理无法登录资产损失哈夫币争议和超时归还</p> <p>处理无法登录资产损失哈夫币争议和结账争议</p>
</div> </div>
<el-table v-loading="loading" class="table-panel" :data="disputes"> <el-table v-loading="loading" class="table-panel" :data="disputes">
@@ -134,6 +134,7 @@ function readError(error: unknown, fallback: string) {
<el-option label="释放押金" value="release_deposit" /> <el-option label="释放押金" value="release_deposit" />
<el-option label="赔付号主" value="compensate_owner" /> <el-option label="赔付号主" value="compensate_owner" />
<el-option label="关闭订单" value="order_close" /> <el-option label="关闭订单" value="order_close" />
<el-option label="标记异常" value="mark_abnormal" />
</el-select> </el-select>
<el-input-number <el-input-number
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)" v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
@@ -121,6 +121,20 @@ function readError(error: unknown, fallback: string) {
</div> </div>
</div> </div>
<div v-if="order?.checkout" class="order-panel dashboard-panel">
<h2>结账信息</h2>
<div class="detail-grid checkout-grid">
<p>结账状态{{ order.checkout.status }}</p>
<p>租金收入¥{{ order.checkout.rent_amount }}</p>
<p>押金¥{{ order.checkout.deposit_amount }}</p>
<p>消耗扣款¥{{ order.checkout.deposit_deduct_amount }}</p>
<p>租客退回¥{{ order.checkout.renter_refund_amount }}</p>
<p>号主入账¥{{ order.checkout.owner_income_amount }}</p>
</div>
<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" class="order-panel dashboard-panel code-panel"> <div v-if="order" class="order-panel dashboard-panel code-panel">
<h2>账号快照</h2> <h2>账号快照</h2>
<pre>{{ snapshotText }}</pre> <pre>{{ snapshotText }}</pre>
+4 -1
View File
@@ -39,7 +39,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" />
@@ -36,7 +36,7 @@ const statusTabs = [
{ key: "all", label: "全部" }, { key: "all", 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: "已完成" },
]; ];
@@ -52,6 +52,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",
@@ -66,7 +69,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: "申诉中",
+1 -1
View File
@@ -88,7 +88,7 @@ function listingPrice(item: Listing) {
<section class="pc-listings page"> <section class="pc-listings page">
<div class="anti-fraud-strip compact"> <div class="anti-fraud-strip compact">
<span <span
>防骗提示只在平台内沟通下单和确认归还谨防冒充客服与低价私单</span >防骗提示只在平台内沟通下单和确认结账谨防冒充客服与低价私单</span
> >
</div> </div>
@@ -3,7 +3,7 @@
<div class="page-header"> <div class="page-header">
<p class="eyebrow">Handoffs</p> <p class="eyebrow">Handoffs</p>
<h1>交接管理</h1> <h1>交接管理</h1>
<p>处理待交接归还确认和超时订单</p> <p>处理待交接结账确认和超时订单</p>
</div> </div>
</section> </section>
</template> </template>
+2
View File
@@ -70,6 +70,8 @@ init_database() {
log "数据库结构已存在,跳过迁移" log "数据库结构已存在,跳过迁移"
fi fi
log "应用增量迁移..."
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000002_order_checkouts.sql"
} }
load_env_file() { load_env_file() {