af15f4736e
- attempt UPDATE 加 out_trade_no 过滤并检查 RowsAffected,防止 order 已翻 paid 但 providerRef 错配导致没有对应 attempt 被标记的账本背离(未命中 时返回 error 触发事务回滚,order 保持 pending) - 补测试覆盖 providerRef 错配场景:断言返回 error 且回滚后订单仍 pending - OpenTestDB 改为每次调用生成唯一内存库名(atomic 计数器),避免同包多 测试共享 file::memory:?cache=shared 导致数据串扰 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
89 lines
2.8 KiB
Go
89 lines
2.8 KiB
Go
// Package store holds v2 data-access for orders/attempts. Idempotency uses
|
|
// conditional UPDATE + RowsAffected (SQLite single-writer; no FOR UPDATE),
|
|
// matching pay's existing applyPaid pattern.
|
|
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/wangjia/pay/internal/model"
|
|
)
|
|
|
|
type OrderStore struct{ db *gorm.DB }
|
|
|
|
func NewOrderStore(db *gorm.DB) *OrderStore { return &OrderStore{db: db} }
|
|
|
|
func (s *OrderStore) CreateOrder(o *model.OrderV2) error {
|
|
if err := s.db.Create(o).Error; err != nil {
|
|
return fmt.Errorf("store.CreateOrder: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *OrderStore) CreateAttempt(a *model.Attempt) error {
|
|
if a.ProviderRef == "" {
|
|
return fmt.Errorf("store.CreateAttempt: empty ProviderRef")
|
|
}
|
|
if err := s.db.Create(a).Error; err != nil {
|
|
return fmt.Errorf("store.CreateAttempt: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkAttemptPaid flips a pending order (and its attempt) to paid inside a tx.
|
|
// Returns false if the order was not pending (already handled / canceled / expired).
|
|
func (s *OrderStore) MarkAttemptPaid(outTradeNo, channel, providerRef string, at time.Time) (bool, error) {
|
|
var flipped bool
|
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
res := tx.Model(&model.OrderV2{}).
|
|
Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPendingV2).
|
|
Updates(map[string]any{"status": model.OrderPaidV2, "paid_at": at})
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return nil // 非 pending → 幂等 no-op
|
|
}
|
|
ares := tx.Model(&model.Attempt{}).
|
|
Where("out_trade_no = ? AND channel = ? AND provider_ref = ?", outTradeNo, channel, providerRef).
|
|
Updates(map[string]any{"status": model.AttemptPaid, "paid_at": at})
|
|
if ares.Error != nil {
|
|
return ares.Error
|
|
}
|
|
if ares.RowsAffected == 0 {
|
|
return fmt.Errorf("store.MarkAttemptPaid: order %s flipped paid but no matching attempt (%s/%s)", outTradeNo, channel, providerRef)
|
|
}
|
|
flipped = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return false, fmt.Errorf("store.MarkAttemptPaid: %w", err)
|
|
}
|
|
return flipped, nil
|
|
}
|
|
|
|
func (s *OrderStore) CancelOrder(outTradeNo string) (bool, error) {
|
|
res := s.db.Model(&model.OrderV2{}).
|
|
Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPendingV2).
|
|
Update("status", model.OrderCanceledV2)
|
|
if res.Error != nil {
|
|
return false, fmt.Errorf("store.CancelOrder: %w", res.Error)
|
|
}
|
|
return res.RowsAffected > 0, nil
|
|
}
|
|
|
|
func (s *OrderStore) ListOrders(bizSystem, bizRef string, limit int) ([]model.OrderV2, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
var out []model.OrderV2
|
|
if err := s.db.Where("biz_system = ? AND biz_ref = ?", bizSystem, bizRef).
|
|
Order("id DESC").Limit(limit).Find(&out).Error; err != nil {
|
|
return nil, fmt.Errorf("store.ListOrders: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|