新增订单待支付与支付确认后端流程
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "发布账号是否需要后台人工审核"},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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, "钱包服务暂时不可用")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user