diff --git a/internal/apps/payment/err.go b/internal/apps/payment/err.go index c5ac96eb..1c871a50 100644 --- a/internal/apps/payment/err.go +++ b/internal/apps/payment/err.go @@ -29,6 +29,7 @@ const ( ErrInvalidAmount = "金额必须大于 0 且最多 2 位小数" ErrPriceRequiresOneForEach = "仅一码一用分发支持设置金额" ErrCreatorNotConfigured = "项目创建者尚未配置支付凭据,无法发起支付" + ErrPendingOrderExists = "当前项目存在进行中或已完成订单,不可重复创建" ErrPaymentConfigNotFound = "尚未配置支付凭据" ErrEncryptionKeyMissing = "服务端未配置支付密钥加密密钥" ErrInvalidClientCredentials = "clientID 与 clientSecret 不能为空" diff --git a/internal/apps/payment/models.go b/internal/apps/payment/models.go index 44dc90a5..297e59cb 100644 --- a/internal/apps/payment/models.go +++ b/internal/apps/payment/models.go @@ -60,6 +60,7 @@ type UserPaymentConfig struct { // // 联合索引: // - idx_project_payer_status (project_id, payer_id, status):查询某用户在某项目的待支付订单 +// - idx_payer_status (payer_id, status):按用户快速查询待支付订单 // - idx_status_expire (status, expire_at):清理任务扫描超时 PENDING 订单 type PaymentOrder struct { ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` @@ -67,11 +68,11 @@ type PaymentOrder struct { TradeNo string `gorm:"size:64;index" json:"trade_no"` ProjectID string `gorm:"size:64;not null;index:idx_project_payer_status,priority:1" json:"project_id"` ItemID uint64 `gorm:"index;not null" json:"item_id"` - PayerID uint64 `gorm:"not null;index:idx_project_payer_status,priority:2" json:"payer_id"` + PayerID uint64 `gorm:"not null;index:idx_project_payer_status,priority:2;index:idx_payer_status,priority:1" json:"payer_id"` PayeeID uint64 `gorm:"index;not null" json:"payee_id"` PayeeClientID string `gorm:"size:64" json:"payee_client_id"` Amount decimal.Decimal `gorm:"type:decimal(10,2);not null" json:"amount"` - Status OrderStatus `gorm:"default:0;index:idx_project_payer_status,priority:3;index:idx_status_expire,priority:1" json:"status"` + Status OrderStatus `gorm:"default:0;index:idx_project_payer_status,priority:3;index:idx_payer_status,priority:2;index:idx_status_expire,priority:1" json:"status"` PaidAt *time.Time `json:"paid_at"` RefundedAt *time.Time `json:"refunded_at"` FailReason string `gorm:"size:255" json:"fail_reason"` diff --git a/internal/apps/payment/router.go b/internal/apps/payment/router.go index acd26156..662a0faf 100644 --- a/internal/apps/payment/router.go +++ b/internal/apps/payment/router.go @@ -141,6 +141,10 @@ func DispatchReceive(c *gin.Context) { if p.IsPaid() { init, err := InitiatePayment(ctx, p, currentUser, c.ClientIP()) if err != nil { + if err.Error() == ErrPendingOrderExists { + c.JSON(http.StatusBadRequest, Response{ErrorMsg: err.Error()}) + return + } c.JSON(http.StatusInternalServerError, Response{ErrorMsg: err.Error()}) return } diff --git a/internal/apps/payment/service.go b/internal/apps/payment/service.go index 740fed44..dece1b4d 100644 --- a/internal/apps/payment/service.go +++ b/internal/apps/payment/service.go @@ -36,8 +36,10 @@ import ( "github.com/linux-do/cdk/internal/apps/project" "github.com/linux-do/cdk/internal/config" "github.com/linux-do/cdk/internal/db" + "github.com/linux-do/cdk/internal/logger" "github.com/shopspring/decimal" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // GetUserPaymentConfig 读取指定用户的支付配置,不存在返回 (nil, nil)。 @@ -156,12 +158,6 @@ func InitiatePayment(ctx context.Context, p *project.Project, payer *oauth.User, return nil, err } - // 预占 item(Redis LPOP 原子) - itemID, err := p.PrepareReceive(ctx, payer.Username) - if err != nil { - return nil, err - } - // 订单过期时间 expireMin := config.Config.Payment.OrderExpireMinutes if expireMin <= 0 { @@ -169,35 +165,83 @@ func InitiatePayment(ctx context.Context, p *project.Project, payer *oauth.User, } expireAt := time.Now().Add(time.Duration(expireMin) * time.Minute) - outTradeNo := genOutTradeNo() - order := PaymentOrder{ - OutTradeNo: outTradeNo, - ProjectID: p.ID, - ItemID: itemID, - PayerID: payer.ID, - PayeeID: p.CreatorID, - PayeeClientID: cfg.ClientID, - Amount: p.Price, - Status: OrderStatusPending, - ExpireAt: expireAt, - ClientIP: clientIP, - } - if err := db.DB(ctx).Create(&order).Error; err != nil { - // 回滚预占 - db.Redis.RPush(ctx, p.ItemsKey(), itemID) + var ( + itemID uint64 + init PaymentInitiation + ) + err = db.DB(ctx).Transaction(func(tx *gorm.DB) error { + // 通过锁定用户行串行化同一用户的下单请求,避免并发重复建单。 + var lockUser oauth.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id"). + Where("id = ?", payer.ID). + First(&lockUser).Error; err != nil { + return err + } + + var existedCount int64 + if err := tx.Model(&PaymentOrder{}). + Where( + "project_id = ? AND payer_id = ? AND status IN ?", + p.ID, + payer.ID, + []OrderStatus{ + OrderStatusPending, + OrderStatusPaid, + OrderStatusCompleted, + }, + ). + Count(&existedCount).Error; err != nil { + return err + } + if existedCount > 0 { + return errors.New(ErrPendingOrderExists) + } + + // 预占 item(Redis LPOP 原子) + reservedItemID, err := p.PrepareReceive(ctx, payer.Username) + if err != nil { + return err + } + itemID = reservedItemID + logger.InfoF(ctx, "Reserved item %d for project %d and payer %d", itemID, p.ID, payer.ID) + + outTradeNo := genOutTradeNo() + order := PaymentOrder{ + OutTradeNo: outTradeNo, + ProjectID: p.ID, + ItemID: itemID, + PayerID: payer.ID, + PayeeID: p.CreatorID, + PayeeClientID: cfg.ClientID, + Amount: p.Price, + Status: OrderStatusPending, + ExpireAt: expireAt, + ClientIP: clientIP, + } + if err := tx.Create(&order).Error; err != nil { + return err + } + + // 构造支付跳转 URL(名称最长 64) + name := truncateRuneLen("CDK-"+p.Name, 60) + init = PaymentInitiation{ + OutTradeNo: outTradeNo, + PayURL: submitURL(cfg.ClientID, secret, name, moneyString(p.Price), outTradeNo, callbackNotifyURL(), callbackReturnURL()), + Amount: moneyString(p.Price), + ExpireAt: expireAt, + } + return nil + }) + if err != nil { + // 仅在已成功预占 item 的情况下回滚库存。 + if itemID > 0 { + db.Redis.RPush(ctx, p.ItemsKey(), itemID) + } return nil, err } - // 构造支付跳转 URL(名称最长 64) - name := truncateRuneLen("CDK-"+p.Name, 60) - payURL := submitURL(cfg.ClientID, secret, name, moneyString(p.Price), outTradeNo, callbackNotifyURL(), callbackReturnURL()) - - return &PaymentInitiation{ - OutTradeNo: outTradeNo, - PayURL: payURL, - Amount: moneyString(p.Price), - ExpireAt: expireAt, - }, nil + return &init, nil } // truncateRuneLen 按 rune 长度截断字符串,避免多字节字符被拦腰截断 diff --git a/internal/apps/payment/tasks.go b/internal/apps/payment/tasks.go index e75811a0..c8a6f5f2 100644 --- a/internal/apps/payment/tasks.go +++ b/internal/apps/payment/tasks.go @@ -31,16 +31,14 @@ import ( "github.com/hibiken/asynq" "github.com/linux-do/cdk/internal/apps/project" "github.com/linux-do/cdk/internal/db" - "go.uber.org/zap" + "github.com/linux-do/cdk/internal/logger" ) // HandleExpireStaleOrders 清理长时间未付款的 PENDING 订单。 // 查询条件:status=PENDING 且 expire_at 超时超过 5 分钟。 // 额外 5 分钟宽限期确保 epay 的异步 notify 回调在此之前已到达, // 避免"cleanup 先置 FAILED + RPush,notify 随后到达发现已无 PENDING 订单"的竞态。 -func HandleExpireStaleOrders(_ context.Context, _ *asynq.Task) error { - ctx := context.Background() - +func HandleExpireStaleOrders(ctx context.Context, _ *asynq.Task) error { // expire_at 已超过 5 分钟才认为真正超时 deadline := time.Now().Add(-5 * time.Minute) @@ -49,9 +47,10 @@ func HandleExpireStaleOrders(_ context.Context, _ *asynq.Task) error { Where("status = ? AND expire_at < ?", OrderStatusPending, deadline). Limit(200). Find(&orders).Error; err != nil { - zap.L().Error("payment cleanup: query failed", zap.Error(err)) + logger.ErrorF(ctx, "payment cleanup: failed to query stale orders: %v", err) return err } + logger.InfoF(ctx, "payment cleanup: found %d stale orders", len(orders)) for _, order := range orders { expireOrder(ctx, &order) @@ -67,16 +66,15 @@ func expireOrder(ctx context.Context, order *PaymentOrder) { Where("out_trade_no = ? AND status = ?", order.OutTradeNo, OrderStatusPending). Update("status", OrderStatusFailed). RowsAffected + + // 另一协程(notify 回调)已处理,跳过 if rows == 0 { - // 另一协程(notify 回调)已处理,跳过 + logger.InfoF(ctx, "payment cleanup: order %s already processed, skipping", order.OutTradeNo) return } // 归还预占的 item,恢复项目库存 db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) - - zap.L().Info("payment cleanup: order expired", - zap.String("out_trade_no", order.OutTradeNo), - zap.Uint64("item_id", order.ItemID), - ) + logger.InfoF(ctx, "payment cleanup: returned item %d to project %d stock", order.ItemID, order.ProjectID) + logger.InfoF(ctx, "payment cleanup: order %s expired and marked as FAILED", order.OutTradeNo) }