feat(pay-v2): P8 Task6 拒付 chargeback 记录 + chargeback.received

This commit is contained in:
wangjia
2026-07-10 18:52:13 +08:00
parent 02b2fcfa41
commit b0714ca758
19 changed files with 502 additions and 22 deletions
+29
View File
@@ -0,0 +1,29 @@
package store
import (
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wangjia/pay/internal/model"
)
// ChargebackStore 持久化拒付留痕(P8 Task6)。与 SubscriptionStore/OrderStore 同惯例:
// 幂等靠 DB 唯一约束 + ON CONFLICT DO NOTHING,不在应用层加锁。
type ChargebackStore struct{ db *gorm.DB }
func NewChargebackStore(db *gorm.DB) *ChargebackStore { return &ChargebackStore{db: db} }
// Create 幂等插入:重复 dispute_ref(渠道重投同一拒付)→ no-op(created=false)。
// Chargeback 表对"能否定位业务单"保持中立——OutTradeNo 为空也照常落一行留痕。
func (s *ChargebackStore) Create(cb *model.Chargeback) (bool, error) {
res := s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "dispute_ref"}},
DoNothing: true,
}).Create(cb)
if res.Error != nil {
return false, fmt.Errorf("store.ChargebackStore.Create: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
+44
View File
@@ -0,0 +1,44 @@
package store_test
import (
"testing"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/store"
)
func TestChargebackCreateIdempotent(t *testing.T) {
s := store.NewChargebackStore(model.OpenTestDB(t))
cb := &model.Chargeback{
DisputeRef: "dp_1", OutTradeNo: "PAY-1", Channel: "stripe",
ProviderPaymentRef: "pi_1", AmountMinor: 2999, Currency: "USD",
Reason: "fraudulent", Status: "received",
}
created, err := s.Create(cb)
if err != nil || !created {
t.Fatalf("first create: created=%v err=%v", created, err)
}
// 同 dispute_ref 重投(Stripe 重投 charge.dispute.created)→ 幂等 no-op,不双记。
again, err := s.Create(&model.Chargeback{
DisputeRef: "dp_1", OutTradeNo: "PAY-1", Channel: "stripe",
ProviderPaymentRef: "pi_1", AmountMinor: 2999, Currency: "USD",
Reason: "fraudulent", Status: "received",
})
if err != nil || again {
t.Fatalf("dup create: again=%v err=%v", again, err)
}
}
// 订阅拒付常无法解析出业务单号(PI 不带 out_trade_no metadata)——Chargeback 表对此
// 保持中立,OutTradeNo 空值也照常落一行留痕,不因空值而失败或跳过。
func TestChargebackCreateWithEmptyOutTradeNo(t *testing.T) {
s := store.NewChargebackStore(model.OpenTestDB(t))
created, err := s.Create(&model.Chargeback{
DisputeRef: "dp_sub_1", OutTradeNo: "", Channel: "stripe",
ProviderPaymentRef: "pi_sub_1", AmountMinor: 999, Currency: "USD",
Reason: "fraudulent", Status: "received",
})
if err != nil || !created {
t.Fatalf("create with empty out_trade_no: created=%v err=%v", created, err)
}
}
+13
View File
@@ -26,6 +26,19 @@ func (s *OrderStore) GetOrder(outTradeNo string) (*model.OrderV2, error) {
return &o, nil
}
// MarkDisputed 打拒付标(P8 Task6):条件 UPDATE 仅在 disputed=false 时翻转,幂等——
// 重投同一 dispute 命中 rows_affected=0,不报错,调用方(recordChargeback)不看返回值
// 也安全(打标不改状态机,不存在"取消标记"这一操作,单向翻转足够)。
func (s *OrderStore) MarkDisputed(outTradeNo string) (bool, error) {
res := s.db.Model(&model.OrderV2{}).
Where("out_trade_no = ? AND disputed = ?", outTradeNo, false).
Update("disputed", true)
if res.Error != nil {
return false, fmt.Errorf("store.MarkDisputed: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
// AttemptByProviderRef resolves an attempt from a bare provider_ref, so settlement
// can recover out_trade_no + channel from a callback/query that only carries the ref.
func (s *OrderStore) AttemptByProviderRef(providerRef string) (*model.Attempt, error) {
+22
View File
@@ -49,3 +49,25 @@ func TestListPendingAndExpire(t *testing.T) {
}
_ = time.Now
}
// TestMarkDisputed 覆盖 P8 Task6 打标:条件 UPDATE 只在 disputed=false 时翻转,重投同一
// dispute(第二次调用)幂等 no-op(rows_affected=0,不报错)。
func TestMarkDisputed(t *testing.T) {
s := store.NewOrderStore(model.OpenTestDB(t))
seedOrder(t, s, "PAY-Q3")
flipped, err := s.MarkDisputed("PAY-Q3")
if err != nil || !flipped {
t.Fatalf("first MarkDisputed: flipped=%v err=%v", flipped, err)
}
o, err := s.GetOrder("PAY-Q3")
if err != nil || !o.Disputed {
t.Fatalf("order after MarkDisputed = %+v, %v", o, err)
}
// 重投同一 dispute → 幂等 no-op,不报错。
again, err := s.MarkDisputed("PAY-Q3")
if err != nil || again {
t.Fatalf("dup MarkDisputed: again=%v err=%v", again, err)
}
}