diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go index 1174b97..ef23723 100644 --- a/backend/internal/jobs/ordertimeout/job.go +++ b/backend/internal/jobs/ordertimeout/job.go @@ -318,16 +318,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, }, @@ -346,7 +346,7 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { 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 @@ -355,28 +355,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/order/dto.go b/backend/internal/modules/order/dto.go index d4c9f0a..486fd2f 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"` } @@ -43,6 +44,23 @@ 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 { Reason string `json:"reason" binding:"required"` } @@ -63,3 +81,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 d7189dc..f64d2fb 100644 --- a/backend/internal/modules/order/handler.go +++ b/backend/internal/modules/order/handler.go @@ -267,6 +267,86 @@ func (h *Handler) ConfirmReturn(c *gin.Context) { response.OK(c, gin.H{"completed": true}) } +func (h *Handler) SubmitCheckout(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req SubmitCheckoutRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "结账说明不能为空") + return + } + record, err := h.service.SubmitCheckout(userID, id, req) + if err != nil { + writeOrderError(c, err) + return + } + response.Created(c, record) +} + +func (h *Handler) ConfirmCheckout(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + if err := h.service.ConfirmCheckout(userID, id); err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"completed": true}) +} + +func (h *Handler) CounterCheckout(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req CounterCheckoutRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "结账修正原因不能为空") + return + } + checkout, err := h.service.CounterCheckout(userID, id, req) + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, checkout) +} + +func (h *Handler) AcceptCheckout(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + if err := h.service.AcceptCheckout(userID, id); err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"completed": true}) +} + func currentUserID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextUserID) if !ok { @@ -318,6 +398,14 @@ func writeOrderError(c *gin.Context, err error) { 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 91357ff..f8ec860 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" @@ -376,6 +377,10 @@ func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRec } func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) { + return r.SubmitCheckout(userID, orderID, SubmitCheckoutRequest{Content: req.Content}) +} + +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 @@ -386,28 +391,41 @@ 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 + } + hasOpen, err := hasOpenCheckout(tx, order.ID) + if err != nil { + return err + } + if hasOpen { + 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" - order.RentEndAt = &now + 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" 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 { @@ -426,6 +444,10 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur } func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error { + return r.ConfirmCheckout(userID, orderID) +} + +func (r *Repository) ConfirmCheckout(userID uint64, orderID uint64) error { return r.db.Transaction(func(tx *gorm.DB) error { var order model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { @@ -434,93 +456,120 @@ func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error { if order.OwnerID != userID { 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" - listing.InTransaction = false - 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, "租客已确认修正结账,订单完成。") }) } @@ -562,6 +611,7 @@ func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) { return nil, err } dto := row.toDTO() + dto.Checkout = r.latestCheckoutDTO(orderID) return &dto, nil } @@ -752,9 +802,95 @@ 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" + listing.InTransaction = false + account.Status = "published" + orderID := order.ID + if err := wallet.AppendEntries(tx, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "out", + Amount: order.RentAmount + order.DepositAmount, + BalanceType: "frozen", + BizType: "order_settle", + BizNo: order.OrderNo, + Remark: "订单结账释放冻结金额", + }, + wallet.Entry{ + UserID: order.OwnerID, + OrderID: &orderID, + Direction: "in", + Amount: checkout.OwnerIncomeAmount, + BalanceType: "available", + BizType: "owner_income", + BizNo: order.OrderNo, + Remark: "订单结账收入", + }, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "in", + Amount: checkout.RenterRefundAmount, + BalanceType: "available", + BizType: "deposit_release", + BizNo: order.OrderNo, + Remark: "订单结账退回押金", + }, + ); err != nil { + return err + } + if err := notification.Append(tx, + notification.Entry{ + UserID: order.RenterID, + Type: "settlement", + Title: "订单已完成", + Content: renterContent, + BizType: "order", + BizID: &orderID, + }, + notification.Entry{ + UserID: order.OwnerID, + Type: "settlement", + Title: "订单已完成", + Content: "订单已完成,结账金额已入账。", + BizType: "order", + BizID: &orderID, + }, + ); err != nil { + return err + } + if err := tx.Save(order).Error; err != nil { + return err + } + if err := tx.Save(checkout).Error; err != nil { + return err + } + if err := tx.Save(&listing).Error; err != nil { + return err + } + return tx.Save(&account).Error +} + func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) { var order model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { @@ -797,6 +933,87 @@ func pendingPaymentTimeoutMinutes(tx *gorm.DB) int { return value } +func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, content string, evidenceURLS []string, consumableAmount float64, coinConsumedM float64, otherAmount float64, explicitDeduct float64, useExplicitDeduct bool) (model.OrderCheckout, error) { + if consumableAmount < 0 || coinConsumedM < 0 || otherAmount < 0 || explicitDeduct < 0 { + return model.OrderCheckout{}, ErrInvalidCheckoutAmount + } + deductAmount := consumableAmount + otherAmount + if useExplicitDeduct { + deductAmount = explicitDeduct + } + if deductAmount > order.DepositAmount { + return model.OrderCheckout{}, ErrInvalidCheckoutAmount + } + renterRefund := order.DepositAmount - deductAmount + evidence, err := marshalStringList(evidenceURLS) + if err != nil { + return model.OrderCheckout{}, err + } + return model.OrderCheckout{ + OrderID: order.ID, + InitiatedBy: initiatedBy, + Status: status, + RentAmount: order.RentAmount, + DepositAmount: order.DepositAmount, + ConsumableAmount: roundMoney(consumableAmount), + CoinConsumedM: roundMoney(coinConsumedM), + OtherAmount: roundMoney(otherAmount), + DepositDeductAmount: roundMoney(deductAmount), + RenterRefundAmount: roundMoney(renterRefund), + OwnerIncomeAmount: roundMoney(order.RentAmount + deductAmount), + Content: content, + EvidenceURLS: evidence, + }, nil +} + +func marshalStringList(items []string) (datatypes.JSON, error) { + if items == nil { + items = []string{} + } + raw, err := json.Marshal(items) + return datatypes.JSON(raw), err +} + +func decodeStringList(raw datatypes.JSON) []string { + if len(raw) == 0 { + return []string{} + } + var items []string + if err := json.Unmarshal(raw, &items); err != nil { + return []string{} + } + return items +} + +func roundMoney(value float64) float64 { + return math.Round(value*100) / 100 +} + +func hasOpenCheckout(tx *gorm.DB, orderID uint64) (bool, error) { + var count int64 + err := tx.Model(&model.OrderCheckout{}). + Where("order_id = ? AND status IN ?", orderID, []string{"submitted", "countered", "accepted", "disputed"}). + Count(&count).Error + return count > 0, err +} + +func (r *Repository) latestCheckoutDTO(orderID uint64) *CheckoutDTO { + var checkout model.OrderCheckout + if err := r.db.Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil { + return nil + } + dto := toCheckoutDTO(checkout) + return &dto +} + +func (r *Repository) findCheckout(id uint64) (*model.OrderCheckout, error) { + var checkout model.OrderCheckout + if err := r.db.First(&checkout, id).Error; err != nil { + return nil, err + } + return &checkout, nil +} + func (r *Repository) baseQuery() *gorm.DB { return r.db.Table("rental_orders AS o"). Select("o.*, a.title, a.server_region, a.login_platform"). @@ -861,6 +1078,31 @@ func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO { } } +func toCheckoutDTO(checkout model.OrderCheckout) CheckoutDTO { + return CheckoutDTO{ + ID: checkout.ID, + OrderID: checkout.OrderID, + InitiatedBy: checkout.InitiatedBy, + Status: checkout.Status, + RentAmount: checkout.RentAmount, + DepositAmount: checkout.DepositAmount, + ConsumableAmount: checkout.ConsumableAmount, + CoinConsumedM: checkout.CoinConsumedM, + OtherAmount: checkout.OtherAmount, + DepositDeductAmount: checkout.DepositDeductAmount, + RenterRefundAmount: checkout.RenterRefundAmount, + OwnerIncomeAmount: checkout.OwnerIncomeAmount, + Content: checkout.Content, + EvidenceURLS: decodeStringList(checkout.EvidenceURLS), + OwnerAdjustmentReason: checkout.OwnerAdjustmentReason, + OwnerAdjustedAt: checkout.OwnerAdjustedAt, + RenterConfirmedAt: checkout.RenterConfirmedAt, + RenterRejectedAt: checkout.RenterRejectedAt, + CreatedAt: checkout.CreatedAt, + UpdatedAt: checkout.UpdatedAt, + } +} + func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) { 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 9555b15..e4fc5ca 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -14,6 +14,10 @@ var ( 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") ) @@ -88,6 +92,16 @@ func (s *Service) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRe return s.repo.SubmitReturn(userID, orderID, req) } +func (s *Service) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if orderID == 0 || req.Content == "" { + return nil, ErrCheckoutCannotSubmit + } + return s.repo.SubmitCheckout(userID, orderID, req) +} + func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error { if s.repo == nil { return ErrDependencyUnavailable @@ -95,6 +109,30 @@ func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error { return s.repo.ConfirmReturn(userID, orderID) } +func (s *Service) ConfirmCheckout(userID uint64, orderID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.ConfirmCheckout(userID, orderID) +} + +func (s *Service) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if orderID == 0 || req.Reason == "" { + return nil, ErrCheckoutCannotCounter + } + return s.repo.CounterCheckout(userID, orderID, req) +} + +func (s *Service) AcceptCheckout(userID uint64, orderID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.AcceptCheckout(userID, orderID) +} + func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 17ddf78..74456ab 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -173,6 +173,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive) orderRoutes.POST("/:id/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/000002_order_payment_stepwise.sql b/backend/migrations/000002_order_payment_stepwise.sql index 96eeb4d..faff9f3 100644 --- a/backend/migrations/000002_order_payment_stepwise.sql +++ b/backend/migrations/000002_order_payment_stepwise.sql @@ -18,3 +18,28 @@ DEALLOCATE PREPARE add_listing_in_transaction_stmt; INSERT INTO system_configs (`key`, `value`, description) VALUES ('order.pending_payment_timeout_minutes', '15', '订单待支付超时取消分钟数') ON DUPLICATE KEY UPDATE `key` = VALUES(`key`); + +CREATE TABLE IF NOT EXISTS order_checkouts ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + initiated_by BIGINT UNSIGNED NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'submitted', + rent_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, + deposit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, + consumable_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, + coin_consumed_m DECIMAL(12,2) NOT NULL DEFAULT 0.00, + other_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, + deposit_deduct_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, + renter_refund_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, + owner_income_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, + content TEXT NULL, + evidence_urls JSON NULL, + owner_adjustment_reason TEXT NULL, + owner_adjusted_at DATETIME NULL, + renter_confirmed_at DATETIME NULL, + renter_rejected_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_order_checkouts_order_id (order_id), + KEY idx_order_checkouts_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index e5dba1e..d16d06d 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 @@ -119,3 +160,23 @@ export async function confirmReturn(id: number) { const { data } = await apiClient.post>(`/orders/${id}/confirm-return`) return data.data } + +export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) { + const { data } = await apiClient.post>(`/orders/${id}/checkout`, payload) + return data.data +} + +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/utils/statusLabels.ts b/frontend/src/utils/statusLabels.ts index f6594e4..2bb1bf1 100644 --- a/frontend/src/utils/statusLabels.ts +++ b/frontend/src/utils/statusLabels.ts @@ -19,7 +19,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: '已关闭', @@ -31,13 +34,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: '已仲裁', @@ -51,6 +58,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 e3ac2ca..443a597 100644 --- a/frontend/src/views/account/OrderDetailView.vue +++ b/frontend/src/views/account/OrderDetailView.vue @@ -6,14 +6,16 @@ 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, payOrder, + submitCheckout, submitHandoff, - submitReturn, type HandoffRecord, type Order, } from '@/api/orders' @@ -31,12 +33,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('') @@ -45,7 +65,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) @@ -55,6 +78,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 } @@ -117,45 +141,108 @@ 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, }) @@ -194,6 +281,22 @@ function readError(error: unknown, fallback: string) { } return fallback } + +function hydrateCounterForm() { + if (!order.value?.checkout) return + const checkout = order.value.checkout + counterForm.value.consumable_amount = checkout.consumable_amount + counterForm.value.coin_consumed_m = checkout.coin_consumed_m + counterForm.value.other_amount = checkout.other_amount + counterForm.value.deposit_deduct_amount = checkout.deposit_deduct_amount +} + +function linesToList(value: string) { + return value + .split('\n') + .map((item) => item.trim()) + .filter(Boolean) +} diff --git a/frontend/src/views/admin/AdminOrdersView.vue b/frontend/src/views/admin/AdminOrdersView.vue index 2f1246f..7cebf45 100644 --- a/frontend/src/views/admin/AdminOrdersView.vue +++ b/frontend/src/views/admin/AdminOrdersView.vue @@ -40,7 +40,10 @@ async function loadOrders() { - + + + + diff --git a/frontend/src/views/mobile/MobileOrdersView.vue b/frontend/src/views/mobile/MobileOrdersView.vue index 1224096..78ff895 100644 --- a/frontend/src/views/mobile/MobileOrdersView.vue +++ b/frontend/src/views/mobile/MobileOrdersView.vue @@ -38,7 +38,7 @@ const statusTabs = [ { key: "pending_payment", label: "待支付" }, { key: "pending_handoff", label: "待交接" }, { key: "renting", label: "使用中" }, - { key: "pending_return_confirm", label: "待归还" }, + { key: "pending_checkout_confirm", label: "待结账" }, { key: "completed", label: "已完成" }, ]; @@ -55,6 +55,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", @@ -70,7 +73,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: "申诉中",