接入订单结账确认流程
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user