Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/apps/payment/err.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const (
ErrInvalidAmount = "金额必须大于 0 且最多 2 位小数"
ErrPriceRequiresOneForEach = "仅一码一用分发支持设置金额"
ErrCreatorNotConfigured = "项目创建者尚未配置支付凭据,无法发起支付"
ErrPendingOrderExists = "当前项目存在进行中或已完成订单,不可重复创建"
ErrPaymentConfigNotFound = "尚未配置支付凭据"
ErrEncryptionKeyMissing = "服务端未配置支付密钥加密密钥"
ErrInvalidClientCredentials = "clientID 与 clientSecret 不能为空"
Expand Down
5 changes: 3 additions & 2 deletions internal/apps/payment/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,19 @@ 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"`
OutTradeNo string `gorm:"size:64;uniqueIndex;not null" json:"out_trade_no"`
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"`
Expand Down
4 changes: 4 additions & 0 deletions internal/apps/payment/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
108 changes: 76 additions & 32 deletions internal/apps/payment/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down Expand Up @@ -156,48 +158,90 @@ 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 {
expireMin = 10
}
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 长度截断字符串,避免多字节字符被拦腰截断
Expand Down
20 changes: 9 additions & 11 deletions internal/apps/payment/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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)
}
Loading