diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go index 644f4b9..1174b97 100644 --- a/backend/internal/jobs/ordertimeout/job.go +++ b/backend/internal/jobs/ordertimeout/job.go @@ -22,6 +22,7 @@ type Job struct { } type thresholds struct { + PendingPaymentTimeoutMinutes int OwnerSubmitTimeoutMinutes int RenterConfirmTimeoutMinutes int ReturnOverdueGraceMinutes int @@ -66,6 +67,7 @@ func (j *Job) run(ctx context.Context) { } now := time.Now() handlers := []func(context.Context, time.Time, thresholds) (int, error){ + j.handlePendingPaymentTimeout, j.handleOwnerSubmitTimeout, j.handleRenterConfirmTimeout, j.handleReturnOverdue, @@ -87,6 +89,7 @@ func (j *Job) run(ctx context.Context) { func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) { cfg := thresholds{ + PendingPaymentTimeoutMinutes: 15, OwnerSubmitTimeoutMinutes: 30, RenterConfirmTimeoutMinutes: 30, ReturnOverdueGraceMinutes: 10, @@ -97,6 +100,7 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) { Where("`key` IN ?", []string{ "handoff.owner_submit_timeout_minutes", "handoff.renter_confirm_timeout_minutes", + "order.pending_payment_timeout_minutes", "order.return_overdue_grace_minutes", "handoff.owner_return_confirm_timeout_minutes", }). @@ -114,6 +118,8 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) { cfg.OwnerSubmitTimeoutMinutes = value case "handoff.renter_confirm_timeout_minutes": cfg.RenterConfirmTimeoutMinutes = value + case "order.pending_payment_timeout_minutes": + cfg.PendingPaymentTimeoutMinutes = value case "order.return_overdue_grace_minutes": cfg.ReturnOverdueGraceMinutes = value case "handoff.owner_return_confirm_timeout_minutes": @@ -123,6 +129,65 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) { return cfg, nil } +func (j *Job) handlePendingPaymentTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { + var rows []model.RentalOrder + err := j.db.WithContext(ctx). + Where("status = ? AND created_at <= ?", "pending_payment", now.Add(-time.Duration(cfg.PendingPaymentTimeoutMinutes)*time.Minute)). + Order("id ASC"). + Limit(100). + Find(&rows).Error + if err != nil { + return 0, err + } + count := 0 + for _, row := range rows { + if err := j.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var order model.RentalOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, row.ID).Error; err != nil { + return err + } + if order.Status != "pending_payment" { + return nil + } + before := snapshot(&order) + var listing model.RentalListing + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil { + return err + } + order.Status = "cancelled" + order.HandoffStatus = "cancelled" + listing.InTransaction = false + orderID := order.ID + if err := notification.Append(tx, notification.Entry{ + UserID: order.RenterID, + Type: "timeout", + Title: "订单支付超时", + Content: "订单未在规定时间内完成支付,已自动取消。", + BizType: "order", + BizID: &orderID, + }); err != nil { + return err + } + if err := tx.Save(&order).Error; err != nil { + return err + } + if err := tx.Save(&listing).Error; err != nil { + return err + } + return appendAuditLog(tx, "order.timeout.pending_payment", order.ID, map[string]any{ + "order_id": order.ID, + "order_no": order.OrderNo, + "before": before, + "after": snapshot(&order), + }) + }); err != nil { + return count, err + } + count++ + } + return count, nil +} + func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { var rows []model.RentalOrder err := j.db.WithContext(ctx). diff --git a/backend/internal/model/listing.go b/backend/internal/model/listing.go index c137c31..8ebaabe 100644 --- a/backend/internal/model/listing.go +++ b/backend/internal/model/listing.go @@ -36,6 +36,7 @@ type RentalListing struct { PriceDaily float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_daily"` PriceWeekly float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_weekly"` DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"` + InTransaction bool `gorm:"not null;default:false" json:"in_transaction"` Status string `gorm:"size:32;not null;default:'draft'" json:"status"` ReviewStatus string `gorm:"size:32;not null;default:'none'" json:"review_status"` ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"` diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 1592a13..027afe0 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -361,7 +361,7 @@ func (r *Repository) Offline(ownerID uint64, listingID uint64) error { func (r *Repository) ListPublic() ([]ListingDTO, error) { var rows []listingRow err := r.baseQuery(). - Where("l.status = ? AND l.review_status = ?", "published", "approved"). + Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). Order("l.published_at DESC, l.id DESC"). Limit(100). Scan(&rows).Error @@ -384,7 +384,7 @@ func (r *Repository) ListMine(ownerID uint64) ([]ListingDTO, error) { } func (r *Repository) FindPublic(id uint64) (*ListingDTO, error) { - dto, err := r.findDTO("l.id = ? AND l.status = ? AND l.review_status = ?", id, "published", "approved") + dto, err := r.findDTO("l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) if err != nil { return nil, err } @@ -400,7 +400,7 @@ func (r *Repository) FindPublicScreenshotKey(id uint64, index int) (string, erro if index < 0 { return "", gorm.ErrRecordNotFound } - dto, err := r.findDTO("l.id = ? AND l.status = ? AND l.review_status = ?", id, "published", "approved") + dto, err := r.findDTO("l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) if err != nil { return "", err } diff --git a/backend/internal/modules/order/handler.go b/backend/internal/modules/order/handler.go index ec27d34..d7189dc 100644 --- a/backend/internal/modules/order/handler.go +++ b/backend/internal/modules/order/handler.go @@ -152,6 +152,23 @@ func (h *Handler) Cancel(c *gin.Context) { response.OK(c, gin.H{"cancelled": true}) } +func (h *Handler) Pay(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + if err := h.service.Pay(userID, id); err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"paid": true}) +} + func (h *Handler) SubmitHandoff(c *gin.Context) { userID, ok := currentUserID(c) if !ok { @@ -287,6 +304,10 @@ func writeOrderError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "listing_unavailable", "该账号暂不可租") case errors.Is(err, ErrCannotRentOwnListing): response.BadRequest(c, "不能租用自己发布的账号") + case errors.Is(err, ErrInsufficientBalance): + response.Error(c, http.StatusConflict, "insufficient_balance", "钱包余额不足,请先充值") + case errors.Is(err, ErrOrderCannotPay): + response.Error(c, http.StatusConflict, "order_cannot_pay", "当前订单不能支付") case errors.Is(err, ErrOrderCannotCancel): response.Error(c, http.StatusConflict, "order_cannot_cancel", "当前订单不能取消") case errors.Is(err, ErrOrderCannotHandoff): diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 024cfae..91357ff 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -21,6 +21,8 @@ type Repository struct { db *gorm.DB } +const defaultPendingPaymentTimeoutMinutes = 15 + func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } @@ -32,7 +34,7 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, req.ListingID).Error; err != nil { return err } - if listing.Status != "published" || listing.ReviewStatus != "approved" { + if listing.Status != "published" || listing.ReviewStatus != "approved" || listing.InTransaction { return ErrListingUnavailable } if listing.OwnerID == renterID { @@ -51,72 +53,42 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro if err != nil { return err } - now := time.Now() rentHours := internalOrderHours - rentEnd := now.Add(time.Duration(rentHours) * time.Hour) order := model.RentalOrder{ OrderNo: orderNo, ListingID: listing.ID, AccountID: listing.AccountID, OwnerID: listing.OwnerID, RenterID: renterID, - RentStartAt: &now, - RentEndAt: &rentEnd, RentHours: rentHours, RentAmount: listing.PriceHourly * float64(rentHours), DepositAmount: listing.DepositAmount, PlatformFee: 0, AccountSnapshot: snapshot, - Status: "pending_handoff", - HandoffStatus: "pending_owner", + Status: "pending_payment", + HandoffStatus: "none", SettlementStatus: "unsettled", } if err := tx.Create(&order).Error; err != nil { return err } orderID := order.ID - if err := wallet.AppendEntries(tx, - wallet.Entry{ - UserID: renterID, - OrderID: &orderID, - Direction: "in", - Amount: order.RentAmount + order.DepositAmount, - BalanceType: "frozen", - BizType: "order_lock", - BizNo: order.OrderNo, - Remark: "开发态模拟冻结订单金额和押金", - }, - ); err != nil { - return err - } if err := notification.Append(tx, - notification.Entry{ - UserID: order.OwnerID, - Type: "order", - Title: "收到新的租号订单", - Content: "租客已下单,请尽快提交交接说明。", - BizType: "order", - BizID: &orderID, - }, notification.Entry{ UserID: order.RenterID, Type: "order", Title: "订单已创建", - Content: "账号已锁定,等待号主提交交接说明。", + Content: "订单已创建,请在有效时间内完成支付。", BizType: "order", BizID: &orderID, }, ); err != nil { return err } - listing.Status = "rented" - account.Status = "rented" + listing.InTransaction = true if err := tx.Save(&listing).Error; err != nil { return err } - if err := tx.Save(&account).Error; err != nil { - return err - } createdID = order.ID return nil }) @@ -126,6 +98,98 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro return r.FindForUser(renterID, createdID) } +func (r *Repository) Pay(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"}). + Where("id = ? AND renter_id = ?", orderID, userID). + First(&order).Error; err != nil { + return err + } + if order.Status != "pending_payment" { + return ErrOrderCannotPay + } + timeoutMinutes := pendingPaymentTimeoutMinutes(tx) + if order.CreatedAt.Before(time.Now().Add(-time.Duration(timeoutMinutes) * time.Minute)) { + return ErrOrderCannotPay + } + + var listing model.RentalListing + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil { + return err + } + if listing.Status != "published" || listing.ReviewStatus != "approved" || !listing.InTransaction { + return ErrListingUnavailable + } + var account model.GameAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil { + return err + } + + total := order.RentAmount + order.DepositAmount + if err := wallet.AppendEntries(tx, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &order.ID, + Direction: "out", + Amount: total, + BalanceType: "available", + BizType: "order_pay", + BizNo: order.OrderNo, + Remark: "订单支付扣减可用余额", + }, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &order.ID, + Direction: "in", + Amount: total, + BalanceType: "frozen", + BizType: "order_lock", + BizNo: order.OrderNo, + Remark: "订单支付冻结租金和押金", + }, + ); err != nil { + if errors.Is(err, wallet.ErrInsufficientBalance) { + return ErrInsufficientBalance + } + return err + } + + order.Status = "pending_handoff" + order.HandoffStatus = "pending_owner" + listing.Status = "rented" + account.Status = "rented" + orderID := order.ID + if err := notification.Append(tx, + notification.Entry{ + UserID: order.OwnerID, + Type: "order", + Title: "收到新的租号订单", + Content: "租客已完成支付,请尽快提交交接说明。", + BizType: "order", + BizID: &orderID, + }, + notification.Entry{ + UserID: order.RenterID, + Type: "order", + Title: "订单支付成功", + Content: "支付金额已冻结,等待号主提交交接说明。", + BizType: "order", + BizID: &orderID, + }, + ); err != nil { + return err + } + if err := tx.Save(&order).Error; err != nil { + return err + } + if err := tx.Save(&listing).Error; err != nil { + return err + } + return tx.Save(&account).Error + }) +} + func (r *Repository) Cancel(userID uint64, orderID uint64) error { return r.db.Transaction(func(tx *gorm.DB) error { var order model.RentalOrder @@ -134,7 +198,7 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error { First(&order).Error; err != nil { return err } - if order.Status != "pending_handoff" { + if order.Status != "pending_payment" && order.Status != "pending_handoff" { return ErrOrderCannotCancel } var listing model.RentalListing @@ -146,29 +210,43 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error { return err } + beforeStatus := order.Status order.Status = "cancelled" order.HandoffStatus = "cancelled" 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_cancel", - BizNo: order.OrderNo, - Remark: "取消订单释放模拟冻结金额", - }, - ); err != nil { - return err + if beforeStatus == "pending_handoff" { + total := order.RentAmount + order.DepositAmount + if err := wallet.AppendEntries(tx, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "out", + Amount: total, + BalanceType: "frozen", + BizType: "order_cancel", + BizNo: order.OrderNo, + Remark: "取消订单释放冻结金额", + }, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "in", + Amount: total, + BalanceType: "available", + BizType: "order_cancel_refund", + BizNo: order.OrderNo, + Remark: "取消订单退回可用余额", + }, + ); err != nil { + return err + } } if err := notification.Append(tx, notification.Entry{ UserID: order.OwnerID, Type: "order", Title: "订单已取消", - Content: "租客已取消待交接订单,账号已重新释放。", + Content: "租客已取消订单,账号已重新释放。", BizType: "order", BizID: &orderID, }, @@ -176,7 +254,7 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error { UserID: order.RenterID, Type: "order", Title: "订单取消成功", - Content: "待交接订单已取消,模拟冻结金额已释放。", + Content: "订单已取消,相关金额已释放。", BizType: "order", BizID: &orderID, }, @@ -184,6 +262,7 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error { return err } listing.Status = "published" + listing.InTransaction = false account.Status = "published" if err := tx.Save(&order).Error; err != nil { return err @@ -433,6 +512,7 @@ func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error { return err } listing.Status = "published" + listing.InTransaction = false account.Status = "published" if err := tx.Save(&order).Error; err != nil { return err @@ -521,18 +601,34 @@ func (r *Repository) AdminClose(adminID uint64, orderID uint64, req AdminActionR order.SettlementStatus = "closed" order.SettledAt = &now listing.Status = "offline" + listing.InTransaction = false account.Status = "offline" - if err := wallet.AppendEntries(tx, wallet.Entry{ - UserID: order.RenterID, - OrderID: &order.ID, - Direction: "out", - Amount: order.RentAmount + order.DepositAmount, - BalanceType: "frozen", - BizType: "admin_order_close", - BizNo: order.OrderNo, - Remark: "后台关闭订单释放模拟冻结金额", - }); err != nil { - return err + if beforeOrderStatus != "pending_payment" { + total := order.RentAmount + order.DepositAmount + if err := wallet.AppendEntries(tx, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &order.ID, + Direction: "out", + Amount: total, + BalanceType: "frozen", + BizType: "admin_order_close", + BizNo: order.OrderNo, + Remark: "后台关闭订单释放冻结金额", + }, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &order.ID, + Direction: "in", + Amount: total, + BalanceType: "available", + BizType: "admin_order_close_refund", + BizNo: order.OrderNo, + Remark: "后台关闭订单退回可用余额", + }, + ); err != nil { + return err + } } if err := notification.Append(tx, notification.Entry{ @@ -599,6 +695,7 @@ func (r *Repository) AdminMarkAbnormal(adminID uint64, orderID uint64, req Admin order.Status = "abnormal" order.HandoffStatus = "admin_abnormal" listing.Status = "abnormal" + listing.InTransaction = false account.Status = "abnormal" if err := notification.Append(tx, notification.Entry{ @@ -687,6 +784,19 @@ func (r *Repository) findHandoffRecord(id uint64) (*HandoffRecordDTO, error) { return &dto, nil } +func pendingPaymentTimeoutMinutes(tx *gorm.DB) int { + var row model.SystemConfig + err := tx.Where("`key` = ?", "order.pending_payment_timeout_minutes").First(&row).Error + if err != nil { + return defaultPendingPaymentTimeoutMinutes + } + value, err := strconv.Atoi(row.Value) + if err != nil || value <= 0 { + return defaultPendingPaymentTimeoutMinutes + } + return value +} + func (r *Repository) baseQuery() *gorm.DB { return r.db.Table("rental_orders AS o"). Select("o.*, a.title, a.server_region, a.login_platform"). diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index 5f27be1..9555b15 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -7,6 +7,8 @@ var ( ErrInvalidRentHours = errors.New("invalid rent hours") ErrListingUnavailable = errors.New("listing unavailable") ErrCannotRentOwnListing = errors.New("cannot rent own listing") + ErrInsufficientBalance = errors.New("insufficient balance") + ErrOrderCannotPay = errors.New("order cannot pay") ErrOrderCannotCancel = errors.New("order cannot cancel") ErrOrderCannotHandoff = errors.New("order cannot handoff") ErrOrderCannotReceive = errors.New("order cannot receive") @@ -42,6 +44,16 @@ func (s *Service) Cancel(userID uint64, orderID uint64) error { return s.repo.Cancel(userID, orderID) } +func (s *Service) Pay(userID uint64, orderID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if orderID == 0 { + return ErrOrderCannotPay + } + return s.repo.Pay(userID, orderID) +} + func (s *Service) SubmitHandoff(userID uint64, orderID uint64, req SubmitHandoffRequest) (*HandoffRecordDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 040fa49..455aa6f 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -31,6 +31,7 @@ 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.pending_payment_timeout_minutes", Value: "15", Description: "订单待支付超时取消分钟数"}, {Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后归还宽限分钟数"}, {Key: "deposit.min_amount", Value: "50", Description: "发布租号最低押金"}, {Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"}, diff --git a/backend/internal/modules/wallet/dto.go b/backend/internal/modules/wallet/dto.go index 81fcc4d..ecc86c2 100644 --- a/backend/internal/modules/wallet/dto.go +++ b/backend/internal/modules/wallet/dto.go @@ -9,6 +9,10 @@ type AccountDTO struct { Status string `json:"status"` } +type RechargeRequest struct { + Amount float64 `json:"amount" binding:"required"` +} + type LedgerDTO struct { ID uint64 `json:"id"` LedgerNo string `json:"ledger_no"` diff --git a/backend/internal/modules/wallet/handler.go b/backend/internal/modules/wallet/handler.go index d6499ac..1a6c5f3 100644 --- a/backend/internal/modules/wallet/handler.go +++ b/backend/internal/modules/wallet/handler.go @@ -46,6 +46,25 @@ func (h *Handler) Ledger(c *gin.Context) { response.OK(c, gin.H{"items": items}) } +func (h *Handler) Recharge(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + var req RechargeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "充值金额不正确") + return + } + account, err := h.service.Recharge(userID, req) + if err != nil { + writeWalletError(c, err) + return + } + response.OK(c, account) +} + func (h *Handler) AdminLedger(c *gin.Context) { query, ok := parseAdminLedgerQuery(c) if !ok { @@ -102,6 +121,10 @@ func writeWalletError(c *gin.Context, err error) { switch { case errors.Is(err, ErrDependencyUnavailable): response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrInvalidAmount): + response.BadRequest(c, "充值金额不正确") + case errors.Is(err, ErrInsufficientBalance): + response.Error(c, 409, "insufficient_balance", "钱包余额不足") default: response.ServiceUnavailable(c, "钱包服务暂时不可用") } diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index 55cb182..b2f3cd4 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -57,6 +57,24 @@ func (r *Repository) Ledger(userID uint64) ([]LedgerDTO, error) { return items, nil } +func (r *Repository) Recharge(userID uint64, amount float64) (*AccountDTO, error) { + err := r.db.Transaction(func(tx *gorm.DB) error { + return AppendEntries(tx, Entry{ + UserID: userID, + Direction: "in", + Amount: amount, + BalanceType: "available", + BizType: "dev_recharge", + BizNo: "DEV", + Remark: "开发环境充值", + }) + }) + if err != nil { + return nil, err + } + return r.Account(userID) +} + func (r *Repository) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) { limit := query.Limit if limit <= 0 || limit > 500 { @@ -146,6 +164,9 @@ func applyEntry(account *model.WalletAccount, entry Entry) (float64, error) { if entry.Direction == "in" { account.AvailableBalance += entry.Amount } else { + if account.AvailableBalance < entry.Amount { + return 0, ErrInsufficientBalance + } account.AvailableBalance -= entry.Amount } return account.AvailableBalance, nil @@ -153,6 +174,9 @@ func applyEntry(account *model.WalletAccount, entry Entry) (float64, error) { if entry.Direction == "in" { account.FrozenBalance += entry.Amount } else { + if account.FrozenBalance < entry.Amount { + return 0, ErrInsufficientBalance + } account.FrozenBalance -= entry.Amount } return account.FrozenBalance, nil diff --git a/backend/internal/modules/wallet/service.go b/backend/internal/modules/wallet/service.go index 89445c8..89da186 100644 --- a/backend/internal/modules/wallet/service.go +++ b/backend/internal/modules/wallet/service.go @@ -2,7 +2,11 @@ package wallet import "errors" -var ErrDependencyUnavailable = errors.New("dependency unavailable") +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInvalidAmount = errors.New("invalid amount") + ErrInsufficientBalance = errors.New("insufficient balance") +) type Service struct { repo *Repository @@ -26,6 +30,16 @@ func (s *Service) Ledger(userID uint64) ([]LedgerDTO, error) { return s.repo.Ledger(userID) } +func (s *Service) Recharge(userID uint64, req RechargeRequest) (*AccountDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.Amount <= 0 { + return nil, ErrInvalidAmount + } + return s.repo.Recharge(userID, req.Amount) +} + func (s *Service) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index cdb5f9c..17ddf78 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -166,6 +166,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { orderRoutes.POST("", orderHandler.Create) orderRoutes.GET("", orderHandler.List) orderRoutes.GET("/:id", orderHandler.Detail) + orderRoutes.POST("/:id/pay", orderHandler.Pay) orderRoutes.POST("/:id/cancel", orderHandler.Cancel) orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff) orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords) @@ -185,6 +186,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { { walletRoutes.GET("/balance", walletHandler.Balance) walletRoutes.GET("/ledger", walletHandler.Ledger) + walletRoutes.POST("/recharge", walletHandler.Recharge) } fileRoutes := api.Group("/files", requireAuth) diff --git a/backend/migrations/000001_init.sql b/backend/migrations/000001_init.sql index a00c851..4f5a298 100644 --- a/backend/migrations/000001_init.sql +++ b/backend/migrations/000001_init.sql @@ -57,6 +57,7 @@ CREATE TABLE rental_listings ( price_daily DECIMAL(12,2) NOT NULL DEFAULT 0.00, price_weekly DECIMAL(12,2) NOT NULL DEFAULT 0.00, deposit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, + in_transaction TINYINT(1) NOT NULL DEFAULT 0, status VARCHAR(32) NOT NULL DEFAULT 'draft', review_status VARCHAR(32) NOT NULL DEFAULT 'none', review_reason VARCHAR(255) NOT NULL DEFAULT '', diff --git a/backend/migrations/000002_order_payment_stepwise.sql b/backend/migrations/000002_order_payment_stepwise.sql new file mode 100644 index 0000000..96eeb4d --- /dev/null +++ b/backend/migrations/000002_order_payment_stepwise.sql @@ -0,0 +1,20 @@ +SET @listing_in_transaction_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'rental_listings' + AND column_name = 'in_transaction' +); + +SET @add_listing_in_transaction_sql := IF( + @listing_in_transaction_exists = 0, + 'ALTER TABLE rental_listings ADD COLUMN in_transaction TINYINT(1) NOT NULL DEFAULT 0 AFTER deposit_amount', + 'SELECT 1' +); +PREPARE add_listing_in_transaction_stmt FROM @add_listing_in_transaction_sql; +EXECUTE add_listing_in_transaction_stmt; +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`); diff --git a/scripts/dev.sh b/scripts/dev.sh index 28a76cc..616144a 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -70,6 +70,10 @@ init_database() { log "数据库结构已存在,跳过迁移" fi + if [[ -f "${ROOT_DIR}/backend/migrations/000002_order_payment_stepwise.sql" ]]; then + log "应用订单支付增量迁移..." + docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000002_order_payment_stepwise.sql" + fi } load_env_file() {