diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go index 644f4b9..5f11be6 100644 --- a/backend/internal/jobs/ordertimeout/job.go +++ b/backend/internal/jobs/ordertimeout/job.go @@ -253,16 +253,16 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh notification.Entry{ UserID: order.RenterID, Type: "timeout", - Title: "订单已逾期未归还", - Content: "订单已超过预计截止时间,请尽快提交归还说明,避免进入申诉处理。", + Title: "订单已逾期未结账", + Content: "订单已超过预计截止时间,请尽快发起结账,避免进入申诉处理。", BizType: "order", BizID: &orderID, }, notification.Entry{ UserID: order.OwnerID, Type: "timeout", - Title: "租客逾期未归还", - Content: "租客未在预计截止后及时归还,你可以发起申诉或等待客服处理。", + Title: "租客逾期未结账", + Content: "租客未在预计截止后及时发起结账,你可以发起申诉或等待客服处理。", BizType: "order", 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) { var rows []model.RentalOrder 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"). Limit(100). Find(&rows).Error @@ -290,28 +290,28 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time } count := 0 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 order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" { + 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_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" { return "", nil } before := snapshot(order) order.Status = "abnormal" - order.HandoffStatus = "owner_return_confirm_timeout" + order.HandoffStatus = "owner_checkout_confirm_timeout" orderID := order.ID if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, Type: "timeout", - Title: "号主确认归还超时", - Content: "号主未在规定时间内确认归还,订单已进入客服复核状态。", + Title: "号主确认结账超时", + Content: "号主未在规定时间内确认结账,订单已进入客服复核状态。", BizType: "order", BizID: &orderID, }, notification.Entry{ UserID: order.OwnerID, Type: "timeout", - Title: "确认归还已超时", - Content: "你未在规定时间内确认归还,订单已进入客服复核状态。", + Title: "确认结账已超时", + Content: "你未在规定时间内确认结账,订单已进入客服复核状态。", BizType: "order", BizID: &orderID, }, diff --git a/backend/internal/model/order_checkout.go b/backend/internal/model/order_checkout.go new file mode 100644 index 0000000..df6e448 --- /dev/null +++ b/backend/internal/model/order_checkout.go @@ -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" +} diff --git a/backend/internal/modules/admindashboard/repository.go b/backend/internal/modules/admindashboard/repository.go index 4d81219..8a596b0 100644 --- a/backend/internal/modules/admindashboard/repository.go +++ b/backend/internal/modules/admindashboard/repository.go @@ -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 { 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 } recentOrders, err := r.recentOrders() diff --git a/backend/internal/modules/dispute/repository.go b/backend/internal/modules/dispute/repository.go index 1933929..a4df211 100644 --- a/backend/internal/modules/dispute/repository.go +++ b/backend/internal/modules/dispute/repository.go @@ -35,6 +35,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (* if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" { return ErrInvalidDispute } + isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept" var count int64 if err := tx.Model(&model.Dispute{}). 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, InitiatorID: userID, TargetUserID: targetID, - Type: req.Type, + Type: disputeType(req.Type, isCheckoutDispute), Status: "open", Description: req.Description, EvidenceURLS: evidence, @@ -65,17 +66,42 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (* if err := tx.Create(&row).Error; err != nil { 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 { return err } disputeID := row.ID + title := "订单进入申诉" + content := "对方已发起申诉,请等待客服仲裁或补充沟通记录。" + if isCheckoutDispute { + title = "订单进入结账争议" + content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。" + } if err := notification.Append(tx, notification.Entry{ UserID: targetID, Type: "dispute", - Title: "订单进入申诉", - Content: "对方已发起申诉,请等待客服仲裁或补充沟通记录。", + Title: title, + Content: content, BizType: "dispute", BizID: &disputeID, }, @@ -177,6 +203,9 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, if req.Result == "order_close" { listing.Status = "offline" account.Status = "offline" + } else if req.Result == "mark_abnormal" { + listing.Status = "abnormal" + account.Status = "abnormal" } else { listing.Status = "published" account.Status = "published" @@ -335,6 +364,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) ( addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客") case "order_close": // Only release frozen funds. No available-balance settlement happens in development mode. + case "mark_abnormal": + // 标记异常只释放冻结账务,后续由客服继续线下复核。 default: return settlement, ErrInvalidDispute } @@ -395,11 +426,20 @@ func arbitrateOrderStatus(result string) string { switch result { case "full_refund", "partial_refund", "order_close": return "closed" + case "mark_abnormal": + return "abnormal" default: 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 { raw, err := json.Marshal(detail) if err != nil { diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index d4c9f0a..71738ba 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -27,6 +27,7 @@ type OrderDTO struct { Status string `json:"status"` HandoffStatus string `json:"handoff_status"` SettlementStatus string `json:"settlement_status"` + Checkout *CheckoutDTO `json:"checkout,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -39,8 +40,21 @@ type SubmitHandoffRequest struct { Content string `json:"content" binding:"required"` } -type SubmitReturnRequest struct { - 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 { @@ -63,3 +77,26 @@ type HandoffRecordDTO struct { ConfirmedByOwnerAt *time.Time `json:"confirmed_by_owner_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"` +} diff --git a/backend/internal/modules/order/handler.go b/backend/internal/modules/order/handler.go index ec27d34..70671c7 100644 --- a/backend/internal/modules/order/handler.go +++ b/backend/internal/modules/order/handler.go @@ -210,7 +210,7 @@ func (h *Handler) HandoffRecords(c *gin.Context) { 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) if !ok { response.Unauthorized(c, "缺少用户上下文") @@ -220,12 +220,12 @@ func (h *Handler) SubmitReturn(c *gin.Context) { if !ok { return } - var req SubmitReturnRequest + var req SubmitCheckoutRequest if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "归还说明不能为空") + response.BadRequest(c, "结账说明不能为空") return } - record, err := h.service.SubmitReturn(userID, id, req) + record, err := h.service.SubmitCheckout(userID, id, req) if err != nil { writeOrderError(c, err) return @@ -233,7 +233,7 @@ func (h *Handler) SubmitReturn(c *gin.Context) { response.Created(c, record) } -func (h *Handler) ConfirmReturn(c *gin.Context) { +func (h *Handler) ConfirmCheckout(c *gin.Context) { userID, ok := currentUserID(c) if !ok { response.Unauthorized(c, "缺少用户上下文") @@ -243,7 +243,47 @@ func (h *Handler) ConfirmReturn(c *gin.Context) { if !ok { 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) return } @@ -293,10 +333,16 @@ func writeOrderError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接") case errors.Is(err, ErrOrderCannotReceive): 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): 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): response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单") case IsNotFound(err): diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 024cfae..5d94d1d 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "math" "strconv" "time" @@ -296,7 +297,7 @@ func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRec 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 err := r.db.Transaction(func(tx *gorm.DB) error { var order model.RentalOrder @@ -307,28 +308,43 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur return ErrPermissionDenied } 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() record := model.HandoffRecord{ OrderID: order.ID, FromUserID: order.RenterID, ToUserID: order.OwnerID, - Type: "renter_return", + Type: "renter_checkout", Content: req.Content, } if err := tx.Create(&record).Error; err != nil { return err } - order.Status = "pending_return_confirm" - order.HandoffStatus = "pending_owner_return_confirm" + checkout, err := buildCheckout(order, order.RenterID, "submitted", req.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, 0, false) + 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 orderID := order.ID if err := notification.Append(tx, notification.Entry{ UserID: order.OwnerID, - Type: "return", - Title: "租客已提交归还", - Content: "请检查账号状态,确认无误后完成订单。", + Type: "checkout", + Title: "租客已发起结账", + Content: "请检查账号状态和消耗明细,确认无误后完成结算。", BizType: "order", BizID: &orderID, }); err != nil { @@ -346,7 +362,7 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur 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 { var order model.RentalOrder 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 { return ErrPermissionDenied } - if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" { - return ErrOrderCannotComplete + if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" { + 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() 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 { return err } - var listing model.RentalListing - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil { + checkout.Status = "accepted" + 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 } - var account model.GameAccount - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil { + if order.OwnerID != userID { + 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 } - order.Status = "completed" - order.HandoffStatus = "returned" - order.SettlementStatus = "settled" - order.SettledAt = &now - order.OwnerSettledAt = &now + next, err := buildCheckout(order, checkout.InitiatedBy, "countered", checkout.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, req.DepositDeductAmount, true) + if err != nil { + return err + } + 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 - 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: 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 { + if err := notification.Append(tx, notification.Entry{ + UserID: order.RenterID, + Type: "checkout", + Title: "号主已修改结账金额", + Content: "请核对号主修正的消耗和结算金额。同意后订单完成;不同意可发起争议。", + BizType: "order", + BizID: &orderID, + }); err != nil { 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 { return err } - if err := tx.Save(&listing).Error; err != nil { + if err := tx.Save(&checkout).Error; err != nil { 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 } dto := row.toDTO() + dto.Checkout = r.latestCheckoutDTO(orderID) return &dto, nil } @@ -655,9 +700,175 @@ func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, erro return nil, err } dto := row.toDTO() + dto.Checkout = r.latestCheckoutDTO(orderID) 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) { var order model.RentalOrder 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) { payload := map[string]any{ "account_id": account.ID, diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index 5f27be1..c142887 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -10,8 +10,11 @@ var ( ErrOrderCannotCancel = errors.New("order cannot cancel") ErrOrderCannotHandoff = errors.New("order cannot handoff") ErrOrderCannotReceive = errors.New("order cannot receive") - ErrOrderCannotReturn = errors.New("order cannot return") 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") ) @@ -66,21 +69,38 @@ func (s *Service) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRecord 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 { return nil, ErrDependencyUnavailable } 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 { 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) { diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 040fa49..7030311 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -30,8 +30,8 @@ type defaultConfig struct { var defaultConfigs = []defaultConfig{ {Key: "handoff.owner_submit_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: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后归还宽限分钟数"}, + {Key: "handoff.owner_return_confirm_timeout_minutes", Value: "120", Description: "号主待确认结账超时分钟数"}, + {Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"}, {Key: "deposit.min_amount", Value: "50", Description: "发布租号最低押金"}, {Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"}, {Key: "risk.sms_limit_per_phone_hour", Value: "5", Description: "单手机号每小时短信验证码次数"}, diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index cdb5f9c..cab98ff 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -170,8 +170,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff) orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords) orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive) - orderRoutes.POST("/:id/return", orderHandler.SubmitReturn) - 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) } diff --git a/backend/migrations/000001_init.sql b/backend/migrations/000001_init.sql index a00c851..6a540e5 100644 --- a/backend/migrations/000001_init.sql +++ b/backend/migrations/000001_init.sql @@ -109,6 +109,31 @@ CREATE TABLE handoff_records ( KEY idx_handoff_records_order_id (order_id) ) 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 ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, diff --git a/backend/migrations/000002_order_checkouts.sql b/backend/migrations/000002_order_checkouts.sql new file mode 100644 index 0000000..39142e2 --- /dev/null +++ b/backend/migrations/000002_order_checkouts.sql @@ -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; diff --git a/docs/api.md b/docs/api.md index 4733e25..5c7df87 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7,7 +7,7 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - 认证:短信验证码、手机号登录、Token 刷新、退出登录。 - 实名:发起认证、查询状态、服务回调。 - 发布:创建、修改、提交审核、下架、列表、详情。 -- 订单:创建、取消、确认收号、提交归还、确认归还、发起申诉。 +- 订单:创建、取消、确认收号、发起结账、确认结账、修改结账、确认/拒绝修正、发起申诉。 - 交接:提交交接说明、查看交接记录。 - 钱包:余额、流水、提现预留。 - 通知:站内信列表、标记已读。 @@ -37,8 +37,10 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `POST /api/orders/{id}/handoff` - `GET /api/orders/{id}/handoff-records` - `POST /api/orders/{id}/confirm-receive` -- `POST /api/orders/{id}/return` -- `POST /api/orders/{id}/confirm-return` +- `POST /api/orders/{id}/checkout` +- `POST /api/orders/{id}/checkout/confirm` +- `POST /api/orders/{id}/checkout/counter` +- `POST /api/orders/{id}/checkout/accept` - `POST /api/orders/{id}/dispute` - `GET /api/disputes` - `GET /api/disputes/{id}` diff --git a/docs/business-rules.md b/docs/business-rules.md index 4890953..1309d1c 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -49,24 +49,27 @@ - 确认收号时会重新计算租赁开始时间和结束时间。 - 交接记录保存在 `handoff_records`,订单双方都可以查看。 -## 开发态归还与完成 +## 开发态结账与完成 -- 租赁中订单可以由租客提交归还。 -- 逾期中订单仍允许租客提交归还,但后台和号主可以根据逾期情况发起申诉或客服处理。 -- 租客提交归还后,订单状态变为 `pending_return_confirm`,交接状态变为 `pending_owner_return_confirm`。 -- 只有号主可以确认归还。 -- 号主确认归还后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态先标记为 `settled`。 +- 租赁中订单可以由租客发起结账,填写使用结束说明、消耗金额、哈夫币消耗量、其他扣款和证据链接。 +- 逾期中订单仍允许租客发起结账,但后台和号主可以根据逾期情况发起申诉或客服处理。 +- 租客发起结账后,订单状态变为 `pending_checkout_confirm`,交接状态变为 `pending_owner_checkout`。 +- 只有号主可以确认结账或修改结账。 +- 号主直接确认结账后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态标记为 `settled`。 +- 号主修改结账后,订单状态变为 `pending_checkout_accept`,交接状态变为 `pending_renter_checkout`,必须等待租客确认修正。 +- 待号主确认结账或待租客确认修正时,任一方都可以发起结账争议。 +- 结账争议后订单状态变为 `checkout_disputing`,交接状态变为 `checkout_disputed`,由客服仲裁。 - 订单完成后,账号和发布状态恢复为 `published`。 - 当前会生成开发态模拟钱包流水,不代表真实支付或提现。 ## 开发态超时任务 - 后端启动后会运行订单超时扫描任务,默认每 1 分钟执行一次。 -- 任务从 `system_configs` 读取超时阈值,包括号主待交接、租客确认收号、租客归还宽限和号主确认归还。 +- 任务从 `system_configs` 读取超时阈值,包括号主待交接、租客确认收号、租客结账宽限和号主确认结账。 - 号主待交接超时:订单保持 `pending_handoff`,交接状态变为 `owner_timeout`,租客仍可取消订单或发起申诉。 - 租客确认收号超时:订单状态变为 `abnormal`,交接状态变为 `renter_confirm_timeout`,进入客服介入。 -- 租客逾期未归还:订单状态变为 `overdue`,交接状态变为 `return_overdue`,租客仍可提交归还,号主可发起申诉。 -- 号主确认归还超时:订单状态变为 `abnormal`,交接状态变为 `owner_return_confirm_timeout`,进入客服复核。 +- 租客逾期未结账:订单状态变为 `overdue`,交接状态变为 `return_overdue`,租客仍可发起结账,号主可发起申诉。 +- 号主确认结账超时:订单状态变为 `abnormal`,交接状态变为 `owner_checkout_confirm_timeout`,进入客服复核。 - 每个超时动作只推进一次状态,避免重复通知。 - 超时动作会给相关用户写入站内信,并以 `system` 身份写入 `audit_logs`。 @@ -97,8 +100,10 @@ - 租客取消订单后通知号主和租客。 - 号主提交交接说明后通知租客。 - 租客确认收号后通知号主。 -- 租客提交归还后通知号主。 -- 号主确认归还后通知号主和租客。 +- 租客发起结账后通知号主。 +- 号主确认结账后通知号主和租客。 +- 号主修改结账后通知租客。 +- 租客拒绝修正结账后通知双方并进入争议。 - 发起申诉后通知对方和发起人。 - 仲裁完成后通知号主和租客。 - 站内信支持列表查询和标记已读。 @@ -107,7 +112,7 @@ - 订单双方都可以在非终态订单发起申诉。 - 同一个订单同一时间只允许存在一个 `open` 或 `processing` 申诉。 -- 发起申诉后订单状态变为 `disputing`,账号和发布保持锁定。 +- 发起普通申诉后订单状态变为 `disputing`,发起结账争议后订单状态变为 `checkout_disputing`,账号和发布保持锁定。 - 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。 - 仲裁会释放开发态模拟冻结金额,并根据结果生成钱包流水。 - 全额退款:释放冻结金额后,将租金加押金作为可用余额退给租客,订单置为 `closed`。 @@ -139,7 +144,7 @@ - 后台仪表盘接口为 `/api/admin/dashboard`,前端页面为 `/admin/dashboard`。 - 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。 -- 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待归还确认订单。 +- 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待结账确认订单。 - 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。 ## 开发态用户管理 diff --git a/docs/project-plan.md b/docs/project-plan.md index b5e827d..e1fa927 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -113,7 +113,7 @@ - 租客选择商品和租期后创建订单。 - 系统锁定商品库存,避免同一账号被重复出租。 -- 订单流转覆盖待确认、待交接、租赁中、待归还确认、已完成、已取消、申诉中。 +- 订单流转覆盖待确认、待交接、租赁中、待号主确认结账、待租客确认修正、结账争议中、已完成、已取消、申诉中。 钱包账务: @@ -142,10 +142,10 @@ 2. 订单确认后,商品进入锁定状态,订单进入待交接。 3. 号主在订单内提交交接说明,例如登录方式、注意事项、联系说明或外部交接备注。 4. 租客确认收到账号后,订单进入租赁中,并记录租赁开始时间和预计结束时间。 -5. 租期即将结束时,系统通过站内信和短信提醒租客归还。 -6. 租客提交归还申请,上传必要的租后截图或说明。 -7. 号主确认账号状态无误后,订单进入结算。 -8. 如任一方对交接、使用、归还或资产变化有异议,订单进入申诉仲裁。 +5. 租期即将结束时,系统通过站内信和短信提醒租客完成使用并发起结账。 +6. 租客发起结账,填写使用结束说明、消耗金额、哈夫币消耗量和必要证据。 +7. 号主确认账号状态无误后直接完成结算,或修改结账金额后交由租客确认。 +8. 租客同意修正后订单完成;拒绝修正或任一方对交接、使用、结账、资产变化有异议时,订单进入申诉仲裁。 回收边界: @@ -158,8 +158,8 @@ - 号主待交接超时:订单进入待交接后,默认 30 分钟未提交交接说明,租客可取消订单或发起申诉。 - 租客确认收号超时:号主提交交接说明后,默认 30 分钟未确认收号,订单进入客服介入,不自动开始租期。 -- 租客归还超时:超过租期结束时间仍未提交归还,系统提醒后进入逾期中,号主可发起申诉。 -- 号主确认归还超时:租客提交归还后,默认 2 小时未确认,订单进入自动确认候选或客服复核。 +- 租客结账超时:超过租期结束时间仍未发起结账,系统提醒后进入逾期中,号主可发起申诉。 +- 号主确认结账超时:租客发起结账后,默认 2 小时未确认,订单进入客服复核。 - 所有超时阈值必须进入 `system_configs` 管理,后台可调整。 订单状态机: @@ -173,12 +173,16 @@ | 待交接 | 申诉中 | 租客/号主 | 交接争议或安全验证无法完成 | | 待收号确认 | 租赁中 | 租客 | 租客确认收到账号并可正常登录 | | 待收号确认 | 申诉中 | 租客/系统 | 租客确认超时、无法登录或描述不符 | -| 租赁中 | 逾期中 | 系统 | 超过租期结束时间仍未提交归还 | -| 租赁中 | 待归还确认 | 租客 | 租客提交归还和租后凭证 | -| 逾期中 | 待归还确认 | 租客 | 租客补交归还申请 | +| 租赁中 | 逾期中 | 系统 | 超过租期结束时间仍未发起结账 | +| 租赁中 | 待号主确认结账 | 租客 | 租客发起结账和租后凭证 | +| 逾期中 | 待号主确认结账 | 租客 | 租客补交结账申请 | | 逾期中 | 申诉中 | 号主/系统 | 逾期未归还或疑似资产损失 | -| 待归还确认 | 已完成 | 号主/系统 | 号主确认归还,或超时后客服/系统确认 | -| 待归还确认 | 申诉中 | 号主/租客 | 归还争议、资产损失或封号异常 | +| 待号主确认结账 | 已完成 | 号主 | 号主确认结账 | +| 待号主确认结账 | 待租客确认修正 | 号主 | 号主修改结账金额 | +| 待租客确认修正 | 已完成 | 租客 | 租客同意修正结账 | +| 待号主确认结账 | 结账争议中 | 任一方 | 发起结账争议 | +| 待租客确认修正 | 结账争议中 | 任一方 | 发起结账争议 | +| 结账争议中 | 已完成/已关闭/异常 | 客服 | 后台仲裁 | | 申诉中 | 已完成 | 客服 | 仲裁为正常完成或部分扣款后完成 | | 申诉中 | 已取消 | 客服 | 仲裁为订单取消和退款 | | 申诉中 | 已关闭 | 客服 | 仲裁为异常关闭、冻结用户或冻结商品 | @@ -216,7 +220,7 @@ 结算规则: - 正常完成订单后,租金按平台抽成规则分账给号主。 -- 押金在号主确认归还后释放给租客。 +- 押金在双方结账确认或客服仲裁后释放给租客。 - 如发生资产损失、无法登录、封号或超时归还,可由客服仲裁后扣除部分或全部押金。 - 如号主虚假描述、超时未交接或账号无法使用,可裁定全额或部分退款。 - 仲裁导致的扣款、退款、赔付必须生成资金流水和审计日志。 @@ -281,8 +285,9 @@ - 号主提交交接说明。 - 租客确认收号。 - 租期即将结束。 -- 租客提交归还。 -- 号主确认归还。 +- 租客发起结账。 +- 号主确认或修改结账。 +- 租客确认或拒绝修正结账。 - 订单完成结算。 - 发起申诉。 - 仲裁结果。 @@ -592,8 +597,10 @@ - `GET /api/orders/{id}`:订单详情。 - `POST /api/orders/{id}/cancel`:取消订单。 - `POST /api/orders/{id}/confirm-receive`:租客确认收号。 -- `POST /api/orders/{id}/return`:租客提交归还。 -- `POST /api/orders/{id}/confirm-return`:号主确认归还。 +- `POST /api/orders/{id}/checkout`:租客发起结账。 +- `POST /api/orders/{id}/checkout/confirm`:号主确认结账。 +- `POST /api/orders/{id}/checkout/counter`:号主修改结账。 +- `POST /api/orders/{id}/checkout/accept`:租客同意修正结账。 - `POST /api/orders/{id}/dispute`:发起申诉。 交接: @@ -835,8 +842,8 @@ hfb_sys/ - 实现订单账号资产快照。 - 实现号主提交交接说明。 - 实现租客确认收号。 -- 实现租客提交归还、号主确认归还。 -- 实现交接、确认收号、归还、确认归还的超时处理。 +- 实现租客发起结账、号主确认或修改结账、租客确认或拒绝修正结账。 +- 实现交接、确认收号、结账逾期、确认结账超时处理。 - 实现钱包账户、押金冻结、租金流水、结算流水。 - 预留支付和提现接口。 @@ -887,9 +894,9 @@ hfb_sys/ - 商品草稿、待审核、已上架、已下架、审核拒绝状态流转正确。 - 审核未通过商品不能下单。 - 同一账号并发下单只能成功一单。 -- 订单待交接、租赁中、待归还确认、已完成、申诉中状态流转正确。 +- 订单待交接、租赁中、待号主确认结账、待租客确认修正、结账争议中、已完成、申诉中状态流转正确。 - 非法订单状态转换会被拒绝。 -- 交接、收号、归还、确认归还超时会按配置触发取消、申诉、逾期或客服复核。 +- 交接、收号、结账逾期、确认结账超时会按配置触发取消、申诉、逾期或客服复核。 - 押金冻结、释放、扣款、退款流水一致。 - 每笔 `wallet_ledger` 正确记录 `balance_after`。 - 租金、平台抽成、号主结算流水一致。 diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index b15b8ff..520745e 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -21,6 +21,30 @@ export interface Order { status: string handoff_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 updated_at: string } @@ -37,6 +61,23 @@ export interface HandoffRecord { 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 { code: string message: string @@ -105,12 +146,22 @@ export async function confirmReceive(id: number) { return data.data } -export async function submitReturn(id: number, content: string) { - const { data } = await apiClient.post>(`/orders/${id}/return`, { content }) +export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) { + const { data } = await apiClient.post>(`/orders/${id}/checkout`, payload) return data.data } -export async function confirmReturn(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/confirm-return`) +export async function confirmCheckout(id: number) { + const { data } = await apiClient.post>(`/orders/${id}/checkout/confirm`) + return data.data +} + +export async function counterCheckout(id: number, payload: CounterCheckoutPayload) { + const { data } = await apiClient.post>(`/orders/${id}/checkout/counter`, payload) + return data.data +} + +export async function acceptCheckout(id: number) { + const { data } = await apiClient.post>(`/orders/${id}/checkout/accept`) return data.data } diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 7598f5b..56f43df 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -619,6 +619,7 @@ h1 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px; + row-gap: 14px; } .order-panel { diff --git a/frontend/src/utils/statusLabels.ts b/frontend/src/utils/statusLabels.ts index 099a43e..080a7d7 100644 --- a/frontend/src/utils/statusLabels.ts +++ b/frontend/src/utils/statusLabels.ts @@ -18,7 +18,10 @@ const orderStatusMap: Record = { pending_handoff: '待交接', renting: '使用中', overdue: '已逾期', - pending_return_confirm: '待归还确认', + pending_return_confirm: '待结账确认', + pending_checkout_confirm: '待号主确认结账', + pending_checkout_accept: '待租客确认修正', + checkout_disputing: '结账争议中', completed: '已完成', cancelled: '已取消', closed: '已关闭', @@ -30,13 +33,17 @@ const handoffStatusMap: Record = { pending_owner: '待号主交接', pending_renter_confirm: '待租客确认', received: '已确认收号', - pending_owner_return_confirm: '待号主确认归还', + pending_owner_return_confirm: '待号主确认结账', + pending_owner_checkout: '待号主确认结账', + pending_renter_checkout: '待租客确认修正', + checkout_disputed: '结账争议中', returned: '已归还', cancelled: '已取消', owner_timeout: '号主交接超时', renter_confirm_timeout: '租客确认超时', return_overdue: '归还逾期', - owner_return_confirm_timeout: '号主确认归还超时', + owner_return_confirm_timeout: '号主确认结账超时', + owner_checkout_confirm_timeout: '号主确认结账超时', admin_closed: '客服关闭', admin_abnormal: '客服标记异常', arbitrated: '已仲裁', @@ -50,6 +57,7 @@ const settlementStatusMap: Record = { refunded: '已退款', cancelled: '已取消', closed: '已关闭', + disputed: '争议中', arbitrated: '已仲裁', } diff --git a/frontend/src/views/account/OrderDetailView.vue b/frontend/src/views/account/OrderDetailView.vue index a2f7aa1..517009e 100644 --- a/frontend/src/views/account/OrderDetailView.vue +++ b/frontend/src/views/account/OrderDetailView.vue @@ -6,13 +6,15 @@ import { useRoute, useRouter } from 'vue-router' import { createDispute } from '@/api/disputes' import { uploadFile } from '@/api/files' import { + acceptCheckout, cancelOrder, + confirmCheckout, confirmReceive, - confirmReturn, + counterCheckout, fetchHandoffRecords, fetchOrder, + submitCheckout, submitHandoff, - submitReturn, type HandoffRecord, type Order, } from '@/api/orders' @@ -29,12 +31,30 @@ const handoffing = ref(false) const confirming = ref(false) const returning = ref(false) const completing = ref(false) +const countering = ref(false) +const acceptingCheckout = ref(false) +const rejectingCheckout = ref(false) const disputing = ref(false) const uploadingEvidence = ref(false) const order = ref(null) const handoffRecords = 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 disputeDescription = 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 canOpenDispute = computed(() => { 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) @@ -53,6 +76,7 @@ async function loadOrder() { try { order.value = await fetchOrder(String(route.params.id)) handoffRecords.value = await fetchHandoffRecords(String(route.params.id)) + hydrateCounterForm() } finally { loading.value = false } @@ -101,50 +125,114 @@ async function handleConfirmReceive() { } } -async function handleSubmitReturn() { +async function handleSubmitCheckout() { if (!order.value) return returning.value = true try { - await submitReturn(order.value.id, returnContent.value) - returnContent.value = '' - ElMessage.success('归还申请已提交,等待号主确认') + await submitCheckout(order.value.id, { + content: checkoutForm.value.content, + 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() } catch (error) { - ElMessage.error(readError(error, '提交归还失败')) + ElMessage.error(readError(error, '发起结账失败')) } finally { returning.value = false } } -async function handleConfirmReturn() { +async function handleConfirmCheckout() { if (!order.value) return completing.value = true try { - await confirmReturn(order.value.id) - ElMessage.success('归还已确认,订单完成') + await confirmCheckout(order.value.id) + ElMessage.success('结账已确认,订单完成') await loadOrder() } catch (error) { - ElMessage.error(readError(error, '确认归还失败')) + ElMessage.error(readError(error, '确认结账失败')) } finally { 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() { if (!order.value) return disputing.value = true try { - const evidence_urls = disputeEvidenceText.value - .split('\n') - .map((item) => item.trim()) - .filter(Boolean) + const evidence_urls = linesToList(disputeEvidenceText.value) await createDispute(order.value.id, { - type: disputeType.value, + type: isCheckoutDisputeStage.value ? 'checkout_dispute' : disputeType.value, description: disputeDescription.value, evidence_urls, }) disputeDescription.value = '' disputeEvidenceText.value = '' + disputeType.value = 'cannot_login' ElMessage.success('申诉已提交,订单进入仲裁处理') await loadOrder() } 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) { if (typeof error === 'object' && error && 'response' in error) { const response = (error as { response?: { data?: { message?: string } } }).response @@ -240,21 +345,144 @@ function readError(error: unknown, fallback: string) { 确认已收到账号 -
-

提交归还

- - 提交归还 +
+

结账明细

+
+
+ 押金扣除 + ¥{{ order.checkout.deposit_deduct_amount }} +
+
+ 租客退回 + ¥{{ order.checkout.renter_refund_amount }} +
+
+ 号主入账 + ¥{{ order.checkout.owner_income_amount }} +
+
+ 消耗 + {{ order.checkout.coin_consumed_m }}M +
+
+

租客说明:{{ order.checkout.content }}

+

号主修正:{{ order.checkout.owner_adjustment_reason }}

-
-

确认归还

-

确认账号状态无误后,订单会完成,账号重新上架。

- 确认归还并完成订单 +
+

发起结账

+ +
+ + + +
+ + 发起结账 +
+ +
+

确认结账

+

确认账号状态和扣款金额无误后,订单会完成,账号重新上架。

+ 确认结账并完成订单 + +

修改结账

+
+ + + + +
+ + + 提交修正给租客确认 +
+ +
+

确认修正结账

+

同意后订单会完成结算;不同意会进入结账争议,由客服仲裁。

+ 同意修正并完成订单 + + 拒绝修正并发起争议
-

发起申诉

- +

{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}

+ @@ -280,7 +508,9 @@ function readError(error: unknown, fallback: string) {
- 提交申诉 + + {{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }} +
diff --git a/frontend/src/views/account/OrdersView.vue b/frontend/src/views/account/OrdersView.vue index 5278081..993f0ac 100644 --- a/frontend/src/views/account/OrdersView.vue +++ b/frontend/src/views/account/OrdersView.vue @@ -24,7 +24,7 @@ async function loadOrders() { diff --git a/frontend/src/views/admin/AdminAuditLogsView.vue b/frontend/src/views/admin/AdminAuditLogsView.vue index 3af6f29..05be890 100644 --- a/frontend/src/views/admin/AdminAuditLogsView.vue +++ b/frontend/src/views/admin/AdminAuditLogsView.vue @@ -96,8 +96,8 @@ function actionType(action: string) { - - + + diff --git a/frontend/src/views/admin/AdminDashboardView.vue b/frontend/src/views/admin/AdminDashboardView.vue index d4fe9f3..fb90fcd 100644 --- a/frontend/src/views/admin/AdminDashboardView.vue +++ b/frontend/src/views/admin/AdminDashboardView.vue @@ -86,7 +86,7 @@ function money(value?: number) { {{ dashboard.pending.pending_handoffs }}
- 待归还确认 + 待结账确认 {{ dashboard.pending.pending_return_confirms }}
diff --git a/frontend/src/views/admin/AdminDisputesView.vue b/frontend/src/views/admin/AdminDisputesView.vue index 2825794..327de96 100644 --- a/frontend/src/views/admin/AdminDisputesView.vue +++ b/frontend/src/views/admin/AdminDisputesView.vue @@ -99,7 +99,7 @@ function readError(error: unknown, fallback: string) { @@ -134,6 +134,7 @@ function readError(error: unknown, fallback: string) { + +
+

结账信息

+
+

结账状态:{{ order.checkout.status }}

+

租金收入:¥{{ order.checkout.rent_amount }}

+

押金:¥{{ order.checkout.deposit_amount }}

+

消耗扣款:¥{{ order.checkout.deposit_deduct_amount }}

+

租客退回:¥{{ order.checkout.renter_refund_amount }}

+

号主入账:¥{{ order.checkout.owner_income_amount }}

+
+

租客说明:{{ order.checkout.content }}

+

号主修正:{{ order.checkout.owner_adjustment_reason }}

+
+

账号快照

{{ snapshotText }}
diff --git a/frontend/src/views/admin/AdminOrdersView.vue b/frontend/src/views/admin/AdminOrdersView.vue index 8d50c23..29a97f5 100644 --- a/frontend/src/views/admin/AdminOrdersView.vue +++ b/frontend/src/views/admin/AdminOrdersView.vue @@ -39,7 +39,10 @@ async function loadOrders() { - + + + + diff --git a/frontend/src/views/mobile/MobileOrdersView.vue b/frontend/src/views/mobile/MobileOrdersView.vue index e45d583..c3dcdb1 100644 --- a/frontend/src/views/mobile/MobileOrdersView.vue +++ b/frontend/src/views/mobile/MobileOrdersView.vue @@ -36,7 +36,7 @@ const statusTabs = [ { key: "all", label: "全部" }, { key: "pending_handoff", label: "待交接" }, { key: "renting", label: "使用中" }, - { key: "pending_return_confirm", label: "待归还" }, + { key: "pending_checkout_confirm", label: "待结账" }, { key: "completed", label: "已完成" }, ]; @@ -52,6 +52,9 @@ function statusColor(status: string) { renting: "#1477ff", overdue: "#e91e63", pending_return_confirm: "#e91e63", + pending_checkout_confirm: "#e91e63", + pending_checkout_accept: "#9c27b0", + checkout_disputing: "#f44336", completed: "#4caf50", cancelled: "#999", disputing: "#f44336", @@ -66,7 +69,10 @@ function statusLabel(status: string) { pending_handoff: "待交接", renting: "使用中", overdue: "已逾期", - pending_return_confirm: "待归还", + pending_return_confirm: "待结账", + pending_checkout_confirm: "待号主确认结账", + pending_checkout_accept: "待租客确认修正", + checkout_disputing: "结账争议中", completed: "已完成", cancelled: "已取消", disputing: "申诉中", diff --git a/frontend/src/views/public/ListingsView.vue b/frontend/src/views/public/ListingsView.vue index e2f36d0..2dd976c 100644 --- a/frontend/src/views/public/ListingsView.vue +++ b/frontend/src/views/public/ListingsView.vue @@ -88,7 +88,7 @@ function listingPrice(item: Listing) {
防骗提示:只在平台内沟通、下单和确认归还,谨防冒充客服与低价私单。防骗提示:只在平台内沟通、下单和确认结账,谨防冒充客服与低价私单。
diff --git a/frontend/src/views/seller/SellerHandoffsView.vue b/frontend/src/views/seller/SellerHandoffsView.vue index 69ea9fc..e863e35 100644 --- a/frontend/src/views/seller/SellerHandoffsView.vue +++ b/frontend/src/views/seller/SellerHandoffsView.vue @@ -3,7 +3,7 @@
diff --git a/scripts/dev.sh b/scripts/dev.sh index 28a76cc..3ccd8ad 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -70,6 +70,8 @@ init_database() { log "数据库结构已存在,跳过迁移" fi + log "应用增量迁移..." + docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000002_order_checkouts.sql" } load_env_file() {