Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
72 KiB
pay v2 · P4 退款(refunds)Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. 每个 Task 自包含,给完整 Go 代码 + 测试 + 命令,可交给全新 implementer subagent。
设计文档(全景蓝图):
docs/pay-v2-unified-gateway-design.html(重点 §6 退款设计——退款三向:业务发起(卡/宝/微)/ crypto 人工 / 平台发起,统一收敛成 refund 事件;§4.1refund(provider_ref, amount, reason);§5 webhook v2refund.succeeded/refund.failed;§3.4 Refund 子实体、§3 Order 退款态paid→refunding→refunded/partially_refunded)。 前置计划(全部已完成、绿):
- P1
docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md(model.Refund/RefundStatus/OrderStatusV2退款态、store.OrderStore、money、accounts)。- P2
docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md(provider.Provider/RefundingProvider声明、gateway.Gateway、webhook outbox「先入队后翻转」不变量)。- P3
docs/superpowers/plans/2026-07-10-pay-v2-p3-channel-adapters.md(crypto/alipay/stripe 三 adapter,离线httptest测法)。- P5 多账户路由已就绪(
accounts.Router);本计划不做退款的多账户路由(adapter 只持装配期注入的单一渠道客户端,退款走同一客户端——与回调验签同法)。
Goal: 在 P2 管线 + P3 三渠道 adapter 上,落地退款机制(退款政策归业务,机制归 pay)。业务方经 POST /api/v2/refunds(HMAC 验签)发起 → pay 校验(订单已付、金额 ≤ 可退余额、支持部分/多次累计)→ 建 Refund 单 → 渠道支持则调 provider.RefundingProvider.Refund(alipay TradeRefund / stripe Refunds.New,refund_id 作幂等键)→ 幂等落状态 → order 退款态机条件 UPDATE 翻转 → outbox refund.succeeded/refund.failed(幂等键含 refund 单号,同单多次部分退不撞唯一键)。crypto 自托管无退款 API → 建 manual_pending 单 + 运营人工 sweep 后回填完成的最小闭环。全程 httptest 假渠道 + 免 docker + 不打真网 + 无真实密钥。
Architecture: 退款编排落 internal/gateway/refund.go(与 settle.go 同包同纪律),Gateway 新增 *store.RefundStore 依赖。退款态的幂等/事务纪律完全复刻 settle.go 的「先幂等入队 webhook、再条件 UPDATE 翻转」:顺序不变量升级为「refund 为 succeeded ⇒ outbox 行必已存在」。渠道差异仍封死在各 adapter 的 Refund();gateway/provider 核心保持中性。outbox 唯一键从 (out_trade_no,event_type) 扩为 (out_trade_no,event_type,refund_id)——payment 事件 refund_id=""(每单每事件仍唯一),refund 事件带 refund_id(同单多次部分退各自成行)。webhook 投递门禁从「订单 == paid」放宽为「订单已结算(paid/refunding/partially_refunded/refunded)」,否则退款态订单的 refund webhook 会被门禁永久卡住。
Tech Stack: Go 1.26.1 · github.com/wangjia/pay · Gin · GORM v1.31 · glebarez/sqlite · github.com/smartwalle/alipay/v3 v3.2.29(Client.TradeRefund 同步退款)· github.com/stripe/stripe-go/v79 v79.12.0(client.API.Refunds.New + CheckoutSessions.Get 取 payment_intent)· crypto 无退款 API(人工向)。
Global Constraints(继承 P2/P3)
- 复用 P1/P2/P3,不重造:模型
model.Refund(RefundID/OutTradeNo/AttemptProviderRef/AmountMinor/Currency/Reason/Status/ProviderRefundRef/InitiatedBy/CompletedAt)+RefundStatus(requested/processing/succeeded/failed,本计划新增manual_pending)+OrderStatusV2(refunding/partially_refunded/refunded已在 P1);数据访问store.OrderStore;金额int64最小单位 + 币种码(money.Format/Parse,CNY/USD=2 位、USDT=6 位);Provider 抽象provider.RefundingProvider。 - 金额一律 int64 最小单位 + 币种码,禁 float。退款额由业务方传(它才知道退多少),但 pay 强校验
已退(非失败)+ 本次 ≤ 订单已付额,拒绝超退。 - 幂等 = 条件 UPDATE + RowsAffected(SQLite 单写者,无
FOR UPDATE),复刻store.MarkAttemptPaid/applyPaid惯例。退款态翻转、order 态机推进均条件 UPDATE。 - 先入队后翻转(资金命脉):refund 成功事件先幂等入队 outbox、再翻转 refund 单 + order 态机。两步间崩溃 → outbox 躺一条 refund.succeeded,投递门禁放行(order 已结算),渠道/重试幂等自愈;绝不「已退款但业务方永不知情」。
- Provider 中性:adapter 只依赖
internal/provider(+ 自己的渠道 SDK +money);internal/gateway绝不 import 任何 adapter。退款只用装配期注入的单一渠道客户端(不做退款多账户路由,记 Self-Review)。 - 凭证 env only:渠道密钥经 P3 装配注入;人工闭环 admin 端点用 env
PAY_ADMIN_TOKEN(os.Getenv,严禁写 config.yaml/落库/提交 git)。 - GORM AutoMigrate 只加不删:
WebhookDelivery加RefundID列并改uq_delivery复合唯一键——本分支 v2 为新建库(P1-P5 未上生产),AutoMigrate重建索引安全;存量库迁移记 Self-Review。 - 每步
go build ./...通过;测试go test ./...(全:memory:/临时 sqlite +httptest,免 docker、不打真网、无真实密钥)。每任务严格 bite-sized TDD:写失败测试 → 跑失败 → 实现 → 跑通过 → commit。禁占位。
Task 1: outbox 退款感知 —— RefundID 唯一键 + 投递门禁放行退款态 + 依赖签名贯通
为什么先做: 退款事件与 payment 事件同表同惯例,但同单多次部分退同 event_type="refund.succeeded" 会撞现有 unique(out_trade_no,event_type) 唯一键(第二次被 DoNothing 丢弃)。且 event_type 列 size:32,塞不下 refund.succeeded:<refund_id>(refund_id ≈ 26 字符)。故正解 = 给 outbox 加 refund_id 列进复合唯一键;payment 事件 refund_id="" 语义不变。同时 webhook 投递门禁 orderPaid 现只认 == paid,退款后订单转 refunding/partially_refunded/refunded → refund webhook 会被门禁永久卡住,须放宽为「已结算」。
Files:
- Modify:
internal/model/webhook_delivery.go(RefundID列进uq_delivery) - Modify:
internal/model/v2.go(OrderStatusV2.Settled()helper +RefundManualPending状态) - Modify:
internal/store/webhook.go(EnqueueDelivery加refundID参、OnConflict加列) - Modify:
internal/webhook/notifier.go(Enqueue加refundID参 → 透传 store) - Modify:
internal/gateway/gateway.go(WebhookEnqueuer接口加refundID参) - Modify:
internal/gateway/settle.go(payment 入队传refundID="") - Modify:
main.go(投递门禁闭包用Settled()) - Test:
internal/store/webhook_test.go(refund_id 复合唯一键:同单同事件不同 refund_id 各成行;同 refund_id 幂等)、internal/model/v2_test.go(Settled())
Interfaces(改后签名,全仓贯通):
-
store.EnqueueDelivery(outTradeNo, bizSystem, eventType, refundID, payload string) error -
webhook.Notifier.Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error(实现gateway.WebhookEnqueuer) -
gateway.WebhookEnqueuer.Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error -
Step 1: 写失败测试
internal/store/webhook_test.go(新增用例;若文件不存在则新建 package store 内部测试,复用 model.OpenTestDB):
package store
import (
"testing"
"github.com/wangjia/pay/internal/model"
)
func TestEnqueueDeliveryRefundIDUnique(t *testing.T) {
ws := NewWebhookStore(model.OpenTestDB(t))
// payment:refund_id="" —— 同单同事件第二次幂等丢弃
must := func(err error) { if err != nil { t.Fatalf("enqueue: %v", err) } }
must(ws.EnqueueDelivery("PAY-1", "pangolin", "payment.succeeded", "", `{"a":1}`))
must(ws.EnqueueDelivery("PAY-1", "pangolin", "payment.succeeded", "", `{"a":2}`)) // 幂等 no-op
// refund:同单同事件、不同 refund_id —— 各自成行(部分退多次)
must(ws.EnqueueDelivery("PAY-1", "pangolin", "refund.succeeded", "rf-A", `{"r":"A"}`))
must(ws.EnqueueDelivery("PAY-1", "pangolin", "refund.succeeded", "rf-B", `{"r":"B"}`))
must(ws.EnqueueDelivery("PAY-1", "pangolin", "refund.succeeded", "rf-A", `{"r":"A2"}`)) // 同 refund_id 幂等
rows, err := ws.ListUndelivered(50)
if err != nil {
t.Fatal(err)
}
// 期望 3 行:1 payment + 2 refund(rf-A / rf-B)
if len(rows) != 3 {
t.Fatalf("undelivered rows = %d want 3: %+v", len(rows), rows)
}
}
internal/model/v2_test.go(新增):
package model
import "testing"
func TestOrderStatusSettled(t *testing.T) {
settled := []OrderStatusV2{OrderPaidV2, OrderRefundingV2, OrderPartRefundedV2, OrderRefundedV2}
for _, s := range settled {
if !s.Settled() {
t.Fatalf("%s should be settled", s)
}
}
for _, s := range []OrderStatusV2{OrderCreatedV2, OrderPendingV2, OrderCanceledV2, OrderExpiredV2} {
if s.Settled() {
t.Fatalf("%s should NOT be settled", s)
}
}
}
- Step 2: 跑测试确认失败
Run: cd /Users/wangjia/code/pay && go test ./internal/store/ ./internal/model/ -run 'RefundIDUnique|Settled' -v
Expected: 编译失败(EnqueueDelivery 参数不符 / Settled 未定义 / RefundManualPending 未定义)。
- Step 3: 写实现
internal/model/webhook_delivery.go —— 加 RefundID 进 uq_delivery:
type WebhookDelivery struct {
Base
OutTradeNo string `gorm:"size:64;not null;uniqueIndex:uq_delivery" json:"out_trade_no"`
EventType string `gorm:"size:32;not null;uniqueIndex:uq_delivery" json:"event_type"`
RefundID string `gorm:"size:64;uniqueIndex:uq_delivery" json:"refund_id,omitempty"` // 退款事件的幂等维度;payment 事件为空
BizSystem string `gorm:"index;size:32" json:"biz_system"`
Payload string `gorm:"type:text" json:"payload"`
Delivered bool `gorm:"index;default:false" json:"delivered"`
Attempts int `json:"attempts"`
LastError string `gorm:"size:255" json:"last_error,omitempty"`
}
internal/model/v2.go —— RefundStatus 常量块加一枚,并加 Settled():
const (
RefundRequested RefundStatus = "requested"
RefundProcessing RefundStatus = "processing"
RefundSucceeded RefundStatus = "succeeded"
RefundFailed RefundStatus = "failed"
RefundManualPending RefundStatus = "manual_pending" // crypto 自托管:待运营人工 sweep 退款
)
// Settled 报告订单是否「已真正收到过钱」(paid 及其后的退款态)。webhook 投递门禁用:
// 只有已结算订单的 outbox 才放行——payment 事件挡住崩溃窗口里的未付单,refund 事件
// 则因订单已 paid 过而正常放行(不会因订单转入退款态被卡)。
func (s OrderStatusV2) Settled() bool {
switch s {
case OrderPaidV2, OrderRefundingV2, OrderPartRefundedV2, OrderRefundedV2:
return true
}
return false
}
internal/store/webhook.go —— EnqueueDelivery 加 refundID:
func (s *WebhookStore) EnqueueDelivery(outTradeNo, bizSystem, eventType, refundID, payload string) error {
row := model.WebhookDelivery{
OutTradeNo: outTradeNo, BizSystem: bizSystem, EventType: eventType,
RefundID: refundID, Payload: payload,
}
err := s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "out_trade_no"}, {Name: "event_type"}, {Name: "refund_id"}},
DoNothing: true,
}).Create(&row).Error
if err != nil {
return fmt.Errorf("store.EnqueueDelivery: %w", err)
}
return nil
}
internal/webhook/notifier.go —— Enqueue 加 refundID 透传:
func (n *Notifier) Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error {
body, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("webhook.Enqueue marshal: %w", err)
}
return n.deliveries.EnqueueDelivery(outTradeNo, bizSystem, eventType, refundID, string(body))
}
internal/gateway/gateway.go —— WebhookEnqueuer 接口加参:
// WebhookEnqueuer receives a domain payload to deliver to the business system.
// refundID 为退款事件的幂等维度(payment 事件传 "")。
type WebhookEnqueuer interface {
Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error
}
internal/gateway/settle.go —— payment 入队传空 refundID(enqueuePaymentSucceeded 末行):
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, "payment.succeeded", "", data)
main.go —— 投递门禁闭包放宽为已结算:
notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) {
o, err := orderStore.GetOrder(no) // 投递门禁:订单已结算(paid 及退款态)才放行
if err != nil {
return false, err
}
return o.Status.Settled(), nil
})
同步跟签名的既有测试替身(否则
go build/既有测试编译失败):
internal/gateway/gateway_test.go的spyEnqueuer.Enqueue→func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error(签名加refundID,函数体不变)。internal/handler/gateway_test.go的nopEnqueuer.Enqueue→func (nopEnqueuer) Enqueue(string, string, string, string, map[string]any) error { return nil }(多一个string)。- 其它实现
WebhookEnqueuer的替身一并跟签名(全仓 grepfunc.*Enqueue(核对)。
- Step 4: 跑测试确认通过
Run: cd /Users/wangjia/code/pay && go build ./... && go test ./internal/store/ ./internal/model/ ./internal/webhook/ ./internal/gateway/ -v
Expected: 全 PASS(含既有 P2/P3 用例;RefundIDUnique 得 3 行、Settled 通过)。
- Step 5: Commit
cd /Users/wangjia/code/pay
git add internal/model/webhook_delivery.go internal/model/v2.go internal/model/v2_test.go internal/store/webhook.go internal/store/webhook_test.go internal/webhook/notifier.go internal/gateway/gateway.go internal/gateway/settle.go internal/gateway/gateway_test.go internal/handler/gateway_test.go main.go
git commit -m "feat(v2): outbox 退款感知(refund_id 进唯一键)+ 投递门禁放行退款态 + Enqueue 贯通 refundID"
Task 2: RefundStore + Order 退款态机(store 层纯 TDD)
Files:
- Create:
internal/store/refund.go(RefundStore:建单/查单/求和/条件翻转/人工待办列表) - Modify:
internal/store/order_query.go(PaidAttempt+ApplyRefundToOrder) - Test:
internal/store/refund_test.go
决策:
- 可退余额守卫:超退检查按「非失败退款额之和」(
requested/processing/manual_pending/succeeded全计入)——在途退款也占额度,防并发/重复请求把一单退穿。 - order 态机由「已成功退款额」驱动:
ApplyRefundToOrder(fully)条件 UPDATE,fully = succeeded 之和 ≥ 订单已付额→refunded,否则partially_refunded。守卫status IN (paid, partially_refunded, refunding)(已refunded或未付一律 no-op,幂等)。 PaidAttempt:退款针对「哪笔已付」(原路退)——取订单status=paid的 attempt,拿它的channel(选渠道)+provider_ref(渠道退款定位原交易)。一单只一笔 paid attempt(P2「一单 N 渠道只一个成功」)。MarkRefundStatus(refundID, from, to, ref, at):条件 UPDATE(WHERE refund_id=? AND status=?),返回RowsAffected>0;to∈{succeeded,failed}顺带写completed_at,非空ref写provider_refund_ref。
- Step 1: 写失败测试
internal/store/refund_test.go:
package store
import (
"testing"
"time"
"github.com/wangjia/pay/internal/model"
)
func seedPaid(t *testing.T, os *OrderStore, outNo string, amount int64) {
t.Helper()
if err := os.CreateOrder(&model.OrderV2{
OutTradeNo: outNo, BizSystem: "pangolin", BizCode: "pro_year",
AmountMinor: amount, Currency: "CNY", Status: model.OrderPaidV2,
}); err != nil {
t.Fatal(err)
}
if err := os.CreateAttempt(&model.Attempt{
OutTradeNo: outNo, Channel: "alipay", ProviderRef: outNo,
AmountMinor: amount, Currency: "CNY", Status: model.AttemptPaid,
}); err != nil {
t.Fatal(err)
}
}
func TestRefundStoreLifecycle(t *testing.T) {
db := model.OpenTestDB(t)
os := NewOrderStore(db)
rs := NewRefundStore(db)
seedPaid(t, os, "PAY-1", 19900)
// PaidAttempt 定位原交易
att, err := os.PaidAttempt("PAY-1")
if err != nil || att.Channel != "alipay" || att.ProviderRef != "PAY-1" {
t.Fatalf("PaidAttempt = %+v, %v", att, err)
}
// 建两笔部分退款(processing)
for _, id := range []string{"rf-1", "rf-2"} {
if err := rs.CreateRefund(&model.Refund{
RefundID: id, OutTradeNo: "PAY-1", AttemptProviderRef: "PAY-1",
AmountMinor: 5000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business",
}); err != nil {
t.Fatal(err)
}
}
// 非失败之和 = 10000(占额度)
if sum, _ := rs.RefundSum("PAY-1", model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded); sum != 10000 {
t.Fatalf("reserved sum = %d want 10000", sum)
}
// rf-1 翻 succeeded(条件正确)
ok, err := rs.MarkRefundStatus("rf-1", model.RefundProcessing, model.RefundSucceeded, "ali-refund-1", time.Now())
if err != nil || !ok {
t.Fatalf("mark rf-1 = %v, %v", ok, err)
}
// 再翻一次(from 已不符)→ no-op
if ok, _ := rs.MarkRefundStatus("rf-1", model.RefundProcessing, model.RefundSucceeded, "x", time.Now()); ok {
t.Fatal("重复翻转应 no-op")
}
r1, _ := rs.GetRefund("rf-1")
if r1.Status != model.RefundSucceeded || r1.ProviderRefundRef != "ali-refund-1" || r1.CompletedAt == nil {
t.Fatalf("rf-1 = %+v", r1)
}
// 部分退:succeeded 之和 5000 < 19900 → partially_refunded
succ, _ := rs.RefundSum("PAY-1", model.RefundSucceeded)
if flipped, _ := os.ApplyRefundToOrder("PAY-1", succ >= 19900); !flipped {
t.Fatal("ApplyRefundToOrder 应翻转")
}
if o, _ := os.GetOrder("PAY-1"); o.Status != model.OrderPartRefundedV2 {
t.Fatalf("order status = %s want partially_refunded", o.Status)
}
// rf-2 也成功 → 总退 10000 仍 < 19900,保持 partially_refunded(非 fully)
_, _ = rs.MarkRefundStatus("rf-2", model.RefundProcessing, model.RefundSucceeded, "ali-refund-2", time.Now())
succ, _ = rs.RefundSum("PAY-1", model.RefundSucceeded)
_, _ = os.ApplyRefundToOrder("PAY-1", succ >= 19900)
if o, _ := os.GetOrder("PAY-1"); o.Status != model.OrderPartRefundedV2 {
t.Fatalf("order status = %s want partially_refunded", o.Status)
}
}
func TestApplyRefundToOrderFully(t *testing.T) {
db := model.OpenTestDB(t)
os := NewOrderStore(db)
seedPaid(t, os, "PAY-2", 10000)
if _, err := os.ApplyRefundToOrder("PAY-2", true); err != nil {
t.Fatal(err)
}
if o, _ := os.GetOrder("PAY-2"); o.Status != model.OrderRefundedV2 {
t.Fatalf("status = %s want refunded", o.Status)
}
}
func TestListManualPending(t *testing.T) {
db := model.OpenTestDB(t)
rs := NewRefundStore(db)
_ = rs.CreateRefund(&model.Refund{RefundID: "rf-m1", OutTradeNo: "C-1", AmountMinor: 1, Currency: "USDT", Status: model.RefundManualPending})
_ = rs.CreateRefund(&model.Refund{RefundID: "rf-p1", OutTradeNo: "C-2", AmountMinor: 1, Currency: "USDT", Status: model.RefundProcessing})
list, err := rs.ListManualPending(50)
if err != nil || len(list) != 1 || list[0].RefundID != "rf-m1" {
t.Fatalf("manual pending = %+v, %v", list, err)
}
}
- Step 2: 跑测试确认失败
Run: cd /Users/wangjia/code/pay && go test ./internal/store/ -run 'RefundStore|ApplyRefund|ManualPending' -v
Expected: 编译失败(RefundStore/PaidAttempt/ApplyRefundToOrder 未定义)。
- Step 3: 写实现
internal/store/refund.go:
package store
import (
"errors"
"fmt"
"time"
"gorm.io/gorm"
"github.com/wangjia/pay/internal/model"
)
var ErrRefundNotFound = errors.New("store: refund not found")
type RefundStore struct{ db *gorm.DB }
func NewRefundStore(db *gorm.DB) *RefundStore { return &RefundStore{db: db} }
func (s *RefundStore) CreateRefund(r *model.Refund) error {
if r.RefundID == "" {
return fmt.Errorf("store.CreateRefund: empty RefundID")
}
if err := s.db.Create(r).Error; err != nil {
return fmt.Errorf("store.CreateRefund: %w", err)
}
return nil
}
func (s *RefundStore) GetRefund(refundID string) (*model.Refund, error) {
var r model.Refund
if err := s.db.Where("refund_id = ?", refundID).First(&r).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRefundNotFound
}
return nil, fmt.Errorf("store.GetRefund: %w", err)
}
return &r, nil
}
// RefundSum sums amount_minor of an order's refunds in the given statuses
// (no statuses = all). 用两种口径:守卫查非失败之和(占额度),态机查 succeeded 之和。
func (s *RefundStore) RefundSum(outTradeNo string, statuses ...model.RefundStatus) (int64, error) {
var total int64
q := s.db.Model(&model.Refund{}).Where("out_trade_no = ?", outTradeNo)
if len(statuses) > 0 {
q = q.Where("status IN ?", statuses)
}
if err := q.Select("COALESCE(SUM(amount_minor),0)").Scan(&total).Error; err != nil {
return 0, fmt.Errorf("store.RefundSum: %w", err)
}
return total, nil
}
// MarkRefundStatus flips a refund from an expected status to a new one
// (conditional UPDATE + RowsAffected). Returns false if not in the from-status.
func (s *RefundStore) MarkRefundStatus(refundID string, from, to model.RefundStatus, providerRefundRef string, at time.Time) (bool, error) {
updates := map[string]any{"status": to}
if providerRefundRef != "" {
updates["provider_refund_ref"] = providerRefundRef
}
if to == model.RefundSucceeded || to == model.RefundFailed {
updates["completed_at"] = at
}
res := s.db.Model(&model.Refund{}).
Where("refund_id = ? AND status = ?", refundID, from).
Updates(updates)
if res.Error != nil {
return false, fmt.Errorf("store.MarkRefundStatus: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
// ListManualPending lists refunds awaiting manual (crypto) settlement.
func (s *RefundStore) ListManualPending(limit int) ([]model.Refund, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
var out []model.Refund
if err := s.db.Where("status = ?", model.RefundManualPending).
Order("id ASC").Limit(limit).Find(&out).Error; err != nil {
return nil, fmt.Errorf("store.ListManualPending: %w", err)
}
return out, nil
}
internal/store/order_query.go —— 追加两个方法(文件已 import errors/gorm/model,已有 ErrAttemptNotFound):
// PaidAttempt returns the settled (paid) attempt of an order — the payment a
// refund reverses (channel + provider_ref for the original transaction).
func (s *OrderStore) PaidAttempt(outTradeNo string) (*model.Attempt, error) {
var a model.Attempt
err := s.db.Where("out_trade_no = ? AND status = ?", outTradeNo, model.AttemptPaid).First(&a).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAttemptNotFound
}
return nil, fmt.Errorf("store.PaidAttempt: %w", err)
}
return &a, nil
}
// ApplyRefundToOrder advances a settled order's status per cumulative refunds:
// fully refunded → refunded, else → partially_refunded. Guard: only from a
// post-paid, non-fully-refunded state (paid / partially_refunded / refunding).
func (s *OrderStore) ApplyRefundToOrder(outTradeNo string, fully bool) (bool, error) {
next := model.OrderPartRefundedV2
if fully {
next = model.OrderRefundedV2
}
res := s.db.Model(&model.OrderV2{}).
Where("out_trade_no = ? AND status IN ?", outTradeNo,
[]model.OrderStatusV2{model.OrderPaidV2, model.OrderPartRefundedV2, model.OrderRefundingV2}).
Update("status", next)
if res.Error != nil {
return false, fmt.Errorf("store.ApplyRefundToOrder: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
- Step 4: 跑测试确认通过
Run: cd /Users/wangjia/code/pay && go build ./... && go test ./internal/store/ -v
Expected: 全 PASS。
- Step 5: Commit
cd /Users/wangjia/code/pay
git add internal/store/refund.go internal/store/refund_test.go internal/store/order_query.go
git commit -m "feat(v2): RefundStore(建单/求和/条件翻转/人工待办)+ Order 退款态机(PaidAttempt/ApplyRefundToOrder)"
Task 3: RefundingProvider 签名扩展 + gateway.Refund 编排(含 crypto 人工向 + 人工闭环)
Files:
- Modify:
internal/provider/provider.go(RefundingProvider.Refund加refundID幂等键参) - Modify:
internal/provider/fake/fake.go(加退款测试缝:EnableRefund+Refund实现) - Modify:
internal/gateway/gateway.go(Gateway加*store.RefundStore依赖;New加参) - Create:
internal/gateway/refund.go(Refund/CompleteManualRefund/GetRefund/ListManualPendingRefunds编排) - Modify:
main.go(装配refundStore传入gateway.New) - Test:
internal/gateway/refund_test.go
决策(核心):
RefundingProvider.Refund加refundID参(P2 只声明未落地,无实现者,现扩展零破坏):Refund(ctx, providerRef, refundID string, amountMinor int64, reason string) (refundRef string, status PaidStatus, err error)refundID= pay 侧 refund 单号,作渠道幂等键:alipayOutRequestNo(部分退款必传且须唯一稳定,重试不重复退)、stripeIdempotency-Key。没有它就无法给「同单多次部分退」做幂等——这是设计文档 §6「统一收敛成 refund 事件」落地的必需项。- 退款事件幂等键 = refund 单号(承 Task 1):
enqueueRefundEvent走Enqueue(..., eventType, r.RefundID, data),payload 含refund_id/provider_refund_ref/amount_minor/reason。同单多次部分退各自成 outbox 行、各自投递。 - crypto 人工向最小闭环:crypto 不实现
RefundingProvider(且SupportsRefund=false)→Refund建manual_pending单即返回(不动 order 态、不发事件——钱还没退)。运营链上转账后调CompleteManualRefund(refundID, tx)回填 → 走与渠道成功同一条settleRefundSucceeded(enqueue-before-flip + 态机推进 +refund.succeeded事件)。ListManualPendingRefunds供运营捞待办。 - order 态机翻转:仅在退款成功落地时(
settleRefundSucceeded)按「succeeded 之和」推进partially_refunded/refunded。发起时不预翻refunding(避免异步 pending 卡在 refunding 需回滚);态机只在有确定资金结果时前进。 - 同步/异步结果归一:
Refund()返回PaidSucceeded→落地成功;PaidFailed→建过的 processing 单翻 failed + 发refund.failed;PaidPending(striperequires_action等异步)→保持processing,交CompleteManualRefund或 P6 退款查单收敛(记 Self-Review,P4 不做异步退款查单 job)。 - 归属校验:
RefundInput.BizSystem非空时须等于order.BizSystem,防业务 A 退业务 B 的单。
- Step 1: 写失败测试
internal/gateway/refund_test.go:
package gateway_test
import (
"context"
"errors"
"testing"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/provider"
)
// 把 fake 订单推到 paid(复用 P2/P3 的 SyncPendingAttempts + SetQueryResult 路径)。
func createAndPay(t *testing.T, g *gateway.Gateway, fp interface {
SetQueryResult(string, provider.PaidEvent)
}, orders interface {
ListAttemptsByStatus(model.AttemptStatus, int) ([]model.Attempt, error)
}) string {
t.Helper()
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1",
})
if err != nil {
t.Fatalf("create: %v", err)
}
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{
ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded,
PaidAmountMinor: 29990000, PaidCurrency: "USDT",
})
if _, err := g.SyncPendingAttempts(context.Background(), 10); err != nil {
t.Fatalf("sync: %v", err)
}
return res.OrderNo
}
func TestRefundPartialThenFull(t *testing.T) {
g, fp, spy, orders := newGateway(t)
fp.EnableRefund("fake-refund-ref", provider.PaidSucceeded, nil) // fake 变可退渠道,同步成功
no := createAndPay(t, g, fp, orders)
// 部分退 1/3(总价 29990000)
r1, err := g.Refund(context.Background(), gateway.RefundInput{
OutTradeNo: no, AmountMinor: 9990000, Reason: "test", BizSystem: "pangolin",
})
if err != nil || r1.Status != string(model.RefundSucceeded) {
t.Fatalf("refund1 = %+v, %v", r1, err)
}
if o, _ := orders.GetOrder(no); o.Status != model.OrderPartRefundedV2 {
t.Fatalf("after partial: order = %s want partially_refunded", o.Status)
}
// 退款事件已入队(refund.succeeded,带 refund_id)
last := spy.calls[len(spy.calls)-1]
if last["event_type"] != "refund.succeeded" || last["refund_id"] != r1.RefundID {
t.Fatalf("refund webhook = %+v", last)
}
// 超退守卫:再退 25000000 > 剩余 20000000 → 拒
if _, err := g.Refund(context.Background(), gateway.RefundInput{
OutTradeNo: no, AmountMinor: 25000000, BizSystem: "pangolin",
}); !errors.Is(err, gateway.ErrRefundAmountInvalid) {
t.Fatalf("over-refund err = %v want ErrRefundAmountInvalid", err)
}
// 退剩余 → refunded
r2, err := g.Refund(context.Background(), gateway.RefundInput{
OutTradeNo: no, AmountMinor: 20000000, BizSystem: "pangolin",
})
if err != nil || r2.Status != string(model.RefundSucceeded) {
t.Fatalf("refund2 = %+v, %v", r2, err)
}
if o, _ := orders.GetOrder(no); o.Status != model.OrderRefundedV2 {
t.Fatalf("after full: order = %s want refunded", o.Status)
}
}
func TestRefundNotRefundableWhenPending(t *testing.T) {
g, _, _, _ := newGateway(t)
res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake"})
if _, err := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: res.OrderNo, AmountMinor: 1}); !errors.Is(err, gateway.ErrOrderNotRefundable) {
t.Fatalf("err = %v want ErrOrderNotRefundable", err)
}
}
func TestRefundManualForCryptoLikeChannel(t *testing.T) {
g, fp, spy, orders := newGateway(t)
// fp 不 EnableRefund → SupportsRefund=false → 建 manual_pending,不动 order、不发事件
no := createAndPay(t, g, fp, orders)
nCalls := len(spy.calls)
r, err := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: no, AmountMinor: 9990000, BizSystem: "pangolin"})
if err != nil || r.Status != string(model.RefundManualPending) {
t.Fatalf("manual refund = %+v, %v", r, err)
}
if o, _ := orders.GetOrder(no); o.Status != model.OrderPaidV2 {
t.Fatalf("manual pending 不应动 order,得 %s", o.Status)
}
if len(spy.calls) != nCalls {
t.Fatal("manual pending 不应发退款事件")
}
// 待办可捞
list, _ := g.ListManualPendingRefunds(10)
if len(list) != 1 || list[0].RefundID != r.RefundID {
t.Fatalf("manual list = %+v", list)
}
// 运营回填完成 → 成功落地 + 事件 + 态机
done, err := g.CompleteManualRefund(context.Background(), r.RefundID, "tron-tx-hash")
if err != nil || done.Status != string(model.RefundSucceeded) {
t.Fatalf("complete = %+v, %v", done, err)
}
if o, _ := orders.GetOrder(no); o.Status != model.OrderPartRefundedV2 {
t.Fatalf("after complete: order = %s want partially_refunded", o.Status)
}
last := spy.calls[len(spy.calls)-1]
if last["event_type"] != "refund.succeeded" || last["provider_refund_ref"] != "tron-tx-hash" {
t.Fatalf("complete webhook = %+v", last)
}
}
newGateway现返回(*gateway.Gateway, *fake.Provider, *spyEnqueuer, *store.OrderStore)——本 Task 给fake.Provider加EnableRefund,并在newGateway里补refundStore装配(见实现)。spyEnqueuer.Enqueue已在 Task 1 跟到含refundID的签名;其data里含refund_id键即断言依据。
- Step 2: 跑测试确认失败
Run: cd /Users/wangjia/code/pay && go test ./internal/gateway/ -run 'Refund' -v
Expected: 编译失败(Gateway.Refund/ErrRefundAmountInvalid/fake.EnableRefund/gateway.New 参数不符)。
- Step 3: 写实现
internal/provider/provider.go —— RefundingProvider 加 refundID:
// RefundingProvider — 可选:支持渠道退款的 Provider 额外实现(P4)。refundID 为 pay 侧
// 退款单号,作渠道幂等键(alipay out_request_no / stripe Idempotency-Key)——同单多次
// 部分退款靠它去重,重试不重复退。不支持退款的渠道不实现本接口(capabilities=false)。
type RefundingProvider interface {
Provider
Refund(ctx context.Context, providerRef, refundID string, amountMinor int64, reason string) (refundRef string, status PaidStatus, err error)
}
internal/provider/fake/fake.go —— 加退款缝(Provider 结构体加字段、Capabilities 用字段、加 EnableRefund+Refund):
type Provider struct {
mu sync.Mutex
queryResults map[string]provider.PaidEvent
supportsRefund bool
refundRef string
refundStatus provider.PaidStatus
refundErr error
}
New 不变(零值 supportsRefund=false)。Capabilities 的 SupportsRefund 改用字段:
func (p *Provider) Capabilities() provider.Capabilities {
return provider.Capabilities{
RenderTypes: []provider.RenderType{provider.RenderCryptoAddress},
SupportsRefund: p.supportsRefund,
SettleCurrencies: []string{"USDT"},
Regions: []string{"global"},
}
}
文件末尾追加:
// EnableRefund 令 fake 表现为可退渠道并预置一次退款结果(测试缝)。
func (p *Provider) EnableRefund(refundRef string, status provider.PaidStatus, err error) {
p.mu.Lock()
defer p.mu.Unlock()
p.supportsRefund = true
p.refundRef = refundRef
p.refundStatus = status
p.refundErr = err
}
func (p *Provider) Refund(_ context.Context, _ /*providerRef*/, _ /*refundID*/ string, _ int64, _ string) (string, provider.PaidStatus, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.refundErr != nil {
return "", provider.PaidFailed, p.refundErr
}
return p.refundRef, p.refundStatus, nil
}
internal/gateway/gateway.go —— Gateway 加 refunds 字段;New 加参:
type Gateway struct {
orders *store.OrderStore
refunds *store.RefundStore
providers *provider.Registry
picker accounts.Picker
products ProductResolver
webhook WebhookEnqueuer
region string
}
func New(orders *store.OrderStore, refunds *store.RefundStore, providers *provider.Registry, picker accounts.Picker,
products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway {
return &Gateway{orders: orders, refunds: refunds, providers: providers, picker: picker,
products: products, webhook: webhook, region: region}
}
internal/gateway/refund.go(新建):
package gateway
import (
"context"
"errors"
"time"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/util"
)
var (
ErrOrderNotRefundable = errors.New("gateway: order not refundable (not settled or fully refunded)")
ErrRefundAmountInvalid = errors.New("gateway: refund amount invalid or exceeds refundable balance")
ErrRefundNotManual = errors.New("gateway: refund is not awaiting manual settlement")
)
// reservedStatuses 计入可退余额守卫(非失败:在途 + 已成功都占额度)。
var reservedStatuses = []model.RefundStatus{
model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded,
}
type RefundInput struct {
OutTradeNo string
AmountMinor int64
Reason string
BizSystem string // 非空则须等于 order.BizSystem(归属校验);平台发起可空
InitiatedBy string // business/platform;空默认 business
}
type RefundResult struct {
RefundID string `json:"refund_id"`
Status string `json:"status"`
}
// Refund 业务发起退款:校验 → 建单 → 渠道退款(或 crypto 人工向)→ 幂等落状态 + 态机 + 事件。
func (g *Gateway) Refund(ctx context.Context, in RefundInput) (*RefundResult, error) {
o, err := g.orders.GetOrder(in.OutTradeNo)
if err != nil {
return nil, err // ErrOrderNotFound
}
if in.BizSystem != "" && o.BizSystem != in.BizSystem {
return nil, ErrOrderNotRefundable // 非本业务的单
}
if !o.Status.Settled() || o.Status == model.OrderRefundedV2 {
return nil, ErrOrderNotRefundable
}
if in.AmountMinor <= 0 {
return nil, ErrRefundAmountInvalid
}
reserved, err := g.refunds.RefundSum(in.OutTradeNo, reservedStatuses...)
if err != nil {
return nil, err
}
if reserved+in.AmountMinor > o.AmountMinor {
return nil, ErrRefundAmountInvalid
}
att, err := g.orders.PaidAttempt(in.OutTradeNo)
if err != nil {
return nil, err // ErrAttemptNotFound
}
if in.InitiatedBy == "" {
in.InitiatedBy = "business"
}
refundID := util.NewOutTradeNo("rf")
prov, err := g.providers.Get(att.Channel)
if err != nil {
return nil, err
}
rp, canRefund := prov.(provider.RefundingProvider)
if !canRefund || !prov.Capabilities().SupportsRefund {
// crypto 自托管:无退款 API → 建"待人工"单即返回(钱未退,不动 order/不发事件)。
r := &model.Refund{
RefundID: refundID, OutTradeNo: in.OutTradeNo, AttemptProviderRef: att.ProviderRef,
AmountMinor: in.AmountMinor, Currency: o.Currency, Reason: in.Reason,
Status: model.RefundManualPending, InitiatedBy: in.InitiatedBy,
}
if err := g.refunds.CreateRefund(r); err != nil {
return nil, err
}
return &RefundResult{RefundID: refundID, Status: string(model.RefundManualPending)}, nil
}
// 渠道退款:先建 processing 单(refundID 作渠道幂等键),再调渠道 API。
r := &model.Refund{
RefundID: refundID, OutTradeNo: in.OutTradeNo, AttemptProviderRef: att.ProviderRef,
AmountMinor: in.AmountMinor, Currency: o.Currency, Reason: in.Reason,
Status: model.RefundProcessing, InitiatedBy: in.InitiatedBy,
}
if err := g.refunds.CreateRefund(r); err != nil {
return nil, err
}
refundRef, status, rerr := rp.Refund(ctx, att.ProviderRef, refundID, in.AmountMinor, in.Reason)
if rerr != nil {
_, _ = g.refunds.MarkRefundStatus(refundID, model.RefundProcessing, model.RefundFailed, "", time.Now())
_ = g.enqueueRefundEvent(o, att, r, "refund.failed", "")
return &RefundResult{RefundID: refundID, Status: string(model.RefundFailed)}, rerr
}
switch status {
case provider.PaidSucceeded:
if err := g.settleRefundSucceeded(o, att, r, refundRef); err != nil {
return nil, err
}
return &RefundResult{RefundID: refundID, Status: string(model.RefundSucceeded)}, nil
case provider.PaidFailed:
_, _ = g.refunds.MarkRefundStatus(refundID, model.RefundProcessing, model.RefundFailed, refundRef, time.Now())
_ = g.enqueueRefundEvent(o, att, r, "refund.failed", refundRef)
return &RefundResult{RefundID: refundID, Status: string(model.RefundFailed)}, nil
default: // PaidPending:stripe requires_action 等异步 → 保持 processing,交人工/P6 收敛
return &RefundResult{RefundID: refundID, Status: string(model.RefundProcessing)}, nil
}
}
// settleRefundSucceeded 复刻 settle 的「先入队后翻转」:先幂等入队 refund.succeeded
// (dedupe=refund_id,同单多次部分退不撞键),再翻转 refund 单 + 推进 order 退款态机。
// 顺序不变量:refund 为 succeeded ⇒ outbox 行必已存在。
func (g *Gateway) settleRefundSucceeded(o *model.OrderV2, att *model.Attempt, r *model.Refund, refundRef string) error {
if err := g.enqueueRefundEvent(o, att, r, "refund.succeeded", refundRef); err != nil {
return err
}
if _, err := g.refunds.MarkRefundStatus(r.RefundID, r.Status, model.RefundSucceeded, refundRef, time.Now()); err != nil {
return err
}
succeeded, err := g.refunds.RefundSum(o.OutTradeNo, model.RefundSucceeded)
if err != nil {
return err
}
_, err = g.orders.ApplyRefundToOrder(o.OutTradeNo, succeeded >= o.AmountMinor)
return err
}
func (g *Gateway) enqueueRefundEvent(o *model.OrderV2, att *model.Attempt, r *model.Refund, eventType, refundRef string) error {
if o.BizSystem == "" {
return nil // 独立收款无业务方回调
}
data := map[string]any{
"event_type": eventType,
"out_trade_no": o.OutTradeNo,
"biz_system": o.BizSystem,
"biz_ref": o.BizRef,
"product_biz_code": o.BizCode,
"refund_id": r.RefundID,
"provider_refund_ref": refundRef,
"amount_minor": r.AmountMinor,
"currency": r.Currency,
"channel": att.Channel,
"reason": r.Reason,
}
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, eventType, r.RefundID, data)
}
// CompleteManualRefund 收口 crypto 人工退款:运营链上转账后回填 tx,走与渠道成功同一落地路径。
func (g *Gateway) CompleteManualRefund(_ context.Context, refundID, providerRefundRef string) (*RefundResult, error) {
r, err := g.refunds.GetRefund(refundID)
if err != nil {
return nil, err // ErrRefundNotFound
}
if r.Status != model.RefundManualPending {
return nil, ErrRefundNotManual
}
o, err := g.orders.GetOrder(r.OutTradeNo)
if err != nil {
return nil, err
}
att, err := g.orders.PaidAttempt(r.OutTradeNo)
if err != nil {
return nil, err
}
if err := g.settleRefundSucceeded(o, att, r, providerRefundRef); err != nil {
return nil, err
}
return &RefundResult{RefundID: refundID, Status: string(model.RefundSucceeded)}, nil
}
func (g *Gateway) ListManualPendingRefunds(limit int) ([]model.Refund, error) {
return g.refunds.ListManualPending(limit)
}
// RefundStatusView / GetRefund 供 HTTP 查询退款状态(Task 6)。
type RefundStatusView struct {
RefundID string `json:"refund_id"`
OutTradeNo string `json:"out_trade_no"`
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
Status string `json:"status"`
ProviderRefundRef string `json:"provider_refund_ref,omitempty"`
}
func (g *Gateway) GetRefund(refundID string) (*RefundStatusView, error) {
r, err := g.refunds.GetRefund(refundID)
if err != nil {
return nil, err
}
return &RefundStatusView{
RefundID: r.RefundID, OutTradeNo: r.OutTradeNo, AmountMinor: r.AmountMinor,
Currency: r.Currency, Status: string(r.Status), ProviderRefundRef: r.ProviderRefundRef,
}, nil
}
internal/gateway/gateway_test.go —— newGateway 装配 refundStore 并传入(改动最小):
func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *store.OrderStore) {
t.Helper()
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
{AccountID: "fake-a2", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global")
return g, fp, spy, orders
}
main.go —— 装配 refundStore 传入 gateway.New:
orderStore := store.NewOrderStore(db)
refundStore := store.NewRefundStore(db)
webhookStore := store.NewWebhookStore(db)
// ...(notifier 等不变)...
gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn")
- Step 4: 跑测试确认通过
Run: cd /Users/wangjia/code/pay && go build ./... && go test ./internal/gateway/ ./internal/provider/... -v
Expected: 全 PASS(含 P2/P3 既有;TestRefundPartialThenFull/TestRefundManualForCryptoLikeChannel/TestRefundNotRefundableWhenPending)。
- Step 5: Commit
cd /Users/wangjia/code/pay
git add internal/provider/provider.go internal/provider/fake/fake.go internal/gateway/gateway.go internal/gateway/gateway_test.go internal/gateway/refund.go internal/gateway/refund_test.go main.go
git commit -m "feat(v2): gateway.Refund 编排(部分/多次累计 + 超退守卫 + crypto 人工向闭环)+ RefundingProvider 加 refundID 幂等键"
Task 4: 支付宝 adapter 落地 RefundingProvider(TradeRefund 同步退款)
Files:
- Modify:
internal/provider/alipay/alipay.go(Capabilities().SupportsRefund=true+Refund方法) - Test:
internal/provider/alipay/alipay_refund_test.go(临时 RSA 密钥 +httptest假支付宝网关,离线)
决策:
-
Client.TradeRefund(ctx, sw.TradeRefund{...}):OutTradeNo=providerRef(支付宝以商户单号归位,P3 里 alipay 的providerRef==OutTradeNo)、RefundAmount=money.Format(amount,"CNY")(元 string)、RefundReason=reason、OutRequestNo=refundID(部分退款必传且须唯一稳定 → 用 pay 侧退款单号,天然幂等:同out_request_no重复请求支付宝按同一笔处理)。 -
同步结果:支付宝退款是同步接口——
rsp.IsFailure()==false即受理成功(FundChange为Y/N都算成功:N表示重复退款已幂等,不是失败)。返回(refundRef=refundID, PaidSucceeded, nil);refundRef取 pay 侧退款单号(= 支付宝out_request_no,退款在支付宝侧的定位键)。失败返回("", PaidFailed, err)(sw.Error自带Error())。 -
离线测法:P3 里 alipay client 用
sw.New(...)+LoadAliPayPublicKey;退款是主动请求(非验签回调),TradeRefund会 POST 到支付宝网关。用sw.New(appID, appPriv, false, sw.WithGateway(ts.URL))把网关指向httptest,返回一份用"支付宝侧"私钥签名的alipay_trade_refund_response,client 内部会用装的支付宝公钥验签响应。WithGateway见 smartwalle v3.2.29(client option)。签名响应体的构造在测试里给出 helper(与 P3alipay_test.go的signRSA2同款,只是签的是 response node)。 -
Step 1: 写失败测试
internal/provider/alipay/alipay_refund_test.go:
package alipay_test
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
sw "github.com/smartwalle/alipay/v3"
"github.com/wangjia/pay/internal/provider"
ali "github.com/wangjia/pay/internal/provider/alipay"
)
// fakeAlipayRefund 返回一份用"支付宝侧"私钥签名的 alipay.trade.refund 响应。
func fakeAlipayRefund(t *testing.T, aliPriv *rsa.PrivateKey, fundChange string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
node := map[string]any{
"code": "10000", "msg": "Success",
"trade_no": "2021AAA", "out_trade_no": "PAY-1",
"refund_fee": "199.00", "fund_change": fundChange,
}
nodeJSON, _ := json.Marshal(node)
// 支付宝对 response node 的原文做 RSA2 签名(sign_type=RSA2)。
h := sha256.Sum256(nodeJSON)
sig, _ := rsa.SignPKCS1v15(rand.Reader, aliPriv, crypto.SHA256, h[:])
resp := map[string]any{
"alipay_trade_refund_response": json.RawMessage(nodeJSON),
"sign": base64.StdEncoding.EncodeToString(sig),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
}
func buildRefundClient(t *testing.T, gateway string, aliPub *rsa.PublicKey) *sw.Client {
app, _ := rsa.GenerateKey(rand.Reader, 2048)
c, err := sw.New("2021000000000000", base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(app)), false, sw.WithGateway(gateway))
if err != nil {
t.Fatalf("new client: %v", err)
}
if err := c.LoadAliPayPublicKey(base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PublicKey(aliPub))); err != nil {
t.Fatalf("load pub: %v", err)
}
return c
}
func TestAlipayRefundSyncSuccess(t *testing.T) {
aliKey, _ := rsa.GenerateKey(rand.Reader, 2048)
ts := fakeAlipayRefund(t, aliKey, "Y")
defer ts.Close()
p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey))
if !p.Capabilities().SupportsRefund {
t.Fatal("alipay Capabilities.SupportsRefund 应为 true")
}
ref, status, err := p.Refund(context.Background(), "PAY-1", "rf-123", 19900, "用户申请")
if err != nil {
t.Fatalf("refund: %v", err)
}
if status != provider.PaidSucceeded || ref != "rf-123" {
t.Fatalf("refund result: ref=%s status=%s", ref, status)
}
}
若
sw.WithGateway在 v3.2.29 的 option 名不同,查go doc github.com/smartwalle/alipay/v3定位设置网关的 client option(v3 提供WithGateway/Client.SetGateway之一),按实到名接;测试目的是不打真网。
- Step 2: 跑测试确认失败
Run: cd /Users/wangjia/code/pay && go test ./internal/provider/alipay/ -run 'Refund' -v
Expected: 编译失败(alipay.Provider 无 Refund 方法 / SupportsRefund=false)。
- Step 3: 写实现
internal/provider/alipay/alipay.go —— Capabilities 翻 true;文件末尾加 Refund:
func (p *Provider) Capabilities() provider.Capabilities {
return provider.Capabilities{
RenderTypes: []provider.RenderType{provider.RenderRedirect},
SupportsRefund: true, // P4:alipay.trade.refund 同步退款
SettleCurrencies: []string{"CNY"},
Regions: []string{"cn"},
}
}
// Refund 调 alipay.trade.refund(同步接口)。providerRef=out_trade_no(alipay 归位键),
// refundID 作 out_request_no(部分退款必传且须唯一稳定 → 幂等)。IsFailure()==false 即成功
// (FundChange=N 表重复退款已幂等,仍算成功)。
func (p *Provider) Refund(ctx context.Context, providerRef, refundID string, amountMinor int64, reason string) (string, provider.PaidStatus, error) {
amt, err := money.Format(amountMinor, "CNY")
if err != nil {
return "", provider.PaidFailed, err
}
rsp, err := p.client.TradeRefund(ctx, sw.TradeRefund{
OutTradeNo: providerRef,
RefundAmount: amt,
RefundReason: reason,
OutRequestNo: refundID,
})
if err != nil {
return "", provider.PaidFailed, fmt.Errorf("alipay: 退款请求失败: %w", err)
}
if rsp.IsFailure() {
return "", provider.PaidFailed, fmt.Errorf("alipay: 退款被拒: %w", rsp.Error)
}
// 退款在支付宝侧以 out_request_no 定位;refundRef 回传我方退款单号(= out_request_no)。
return refundID, provider.PaidSucceeded, nil
}
- Step 4: 跑测试确认通过
Run: cd /Users/wangjia/code/pay && go build ./... && go test ./internal/provider/alipay/ -v
Expected: 全 PASS(含 P3 既有 Create/VerifyCallback 用例)。
- Step 5: Commit
cd /Users/wangjia/code/pay
git add internal/provider/alipay/
git commit -m "feat(v2): 支付宝 adapter 落地 RefundingProvider(TradeRefund 同步退款,out_request_no=refund_id 幂等)"
Task 5: Stripe adapter 落地 RefundingProvider(session→payment_intent→Refunds.New)
Files:
- Modify:
internal/provider/stripe/stripe.go(Capabilities().SupportsRefund=true+Refund+mapRefundStatus) - Test:
internal/provider/stripe/stripe_refund_test.go(httptest假 Stripe:GET session 取 payment_intent + POST /v1/refunds)
决策:
-
两跳:P3 里 stripe
providerRef= Checkout Session ID(cs_...),退款需 PaymentIntent。先sc.CheckoutSessions.Get(providerRef, nil)拿sess.PaymentIntent.ID(未展开时 SDK 也把 id 反序列化进*PaymentIntent{ID}),再sc.Refunds.New(&RefundParams{PaymentIntent, Amount})。 -
幂等键:
params.SetIdempotencyKey(refundID)(Stripe 幂等头)——重试不重复退。 -
不传
Reason:StripeRefundParams.Reason只收枚举(duplicate/fraudulent/requested_by_customer),业务自由文案会被拒;reason只落 pay 本地Refund.Reason,不传给 Stripe(决策写明)。 -
状态归一
mapRefundStatus:succeeded→PaidSucceeded;pending/requires_action→PaidPending(异步,gateway 保持 processing);failed/canceled→PaidFailed。refundRef = rf.ID。 -
离线测法:沿用 P3
stripe_test.go的newStripe(gostripe.Backends{API: GetBackendWithConfig(URL: ts.URL)}注入 httptest 后端),假服务器处理GET /v1/checkout/sessions/{id}(回带payment_intent)与POST /v1/refunds。 -
Step 1: 写失败测试
internal/provider/stripe/stripe_refund_test.go:
package stripe_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
gostripe "github.com/stripe/stripe-go/v79"
"github.com/stripe/stripe-go/v79/client"
"github.com/wangjia/pay/internal/provider"
st "github.com/wangjia/pay/internal/provider/stripe"
)
func fakeStripeRefundAPI(t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/checkout/sessions/cs_test_123"):
// 未展开:payment_intent 是字符串 id,SDK 反序列化进 *PaymentIntent{ID}
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","payment_intent":"pi_test_456","amount_total":2999,"currency":"usd","payment_status":"paid"}`)
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/refunds"):
fmt.Fprint(w, `{"id":"re_test_789","object":"refund","amount":2999,"currency":"usd","payment_intent":"pi_test_456","status":"succeeded"}`)
default:
http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound)
}
}))
}
func newStripeRefund(t *testing.T, ts *httptest.Server) *st.Provider {
backends := &gostripe.Backends{
API: gostripe.GetBackendWithConfig(gostripe.APIBackend, &gostripe.BackendConfig{URL: gostripe.String(ts.URL)}),
}
return st.New(client.New("sk_test_x", backends), "whsec_test_secret")
}
func TestStripeRefundSuccess(t *testing.T) {
ts := fakeStripeRefundAPI(t)
defer ts.Close()
p := newStripeRefund(t, ts)
if !p.Capabilities().SupportsRefund {
t.Fatal("stripe Capabilities.SupportsRefund 应为 true")
}
ref, status, err := p.Refund(context.Background(), "cs_test_123", "rf-999", 2999, "requested_by_customer")
if err != nil {
t.Fatalf("refund: %v", err)
}
if status != provider.PaidSucceeded || ref != "re_test_789" {
t.Fatalf("refund result: ref=%s status=%s", ref, status)
}
}
- Step 2: 跑测试确认失败
Run: cd /Users/wangjia/code/pay && go test ./internal/provider/stripe/ -run 'Refund' -v
Expected: 编译失败(stripe.Provider 无 Refund / SupportsRefund=false)。
- Step 3: 写实现
internal/provider/stripe/stripe.go —— Capabilities 翻 true;加 Refund + mapRefundStatus:
func (p *Provider) Capabilities() provider.Capabilities {
return provider.Capabilities{
RenderTypes: []provider.RenderType{provider.RenderRedirect},
SupportsRefund: true, // P4:/v1/refunds
SettleCurrencies: []string{supportedCurrency},
Regions: []string{"global"},
}
}
// Refund 经 Checkout Session 取 PaymentIntent 再退款。refundID 作 Idempotency-Key。
// 不传 Stripe Reason(仅收枚举);业务文案只落 pay 本地。
func (p *Provider) Refund(_ context.Context, providerRef, refundID string, amountMinor int64, _ string) (string, provider.PaidStatus, error) {
sess, err := p.sc.CheckoutSessions.Get(providerRef, nil)
if err != nil {
return "", provider.PaidFailed, fmt.Errorf("stripe: 取 session 失败: %w", err)
}
if sess.PaymentIntent == nil || sess.PaymentIntent.ID == "" {
return "", provider.PaidFailed, fmt.Errorf("stripe: session %s 无 payment_intent,无法退款", providerRef)
}
params := &gostripe.RefundParams{
PaymentIntent: gostripe.String(sess.PaymentIntent.ID),
Amount: gostripe.Int64(amountMinor), // cent = USD minor,直传
}
params.SetIdempotencyKey(refundID)
rf, err := p.sc.Refunds.New(params)
if err != nil {
return "", provider.PaidFailed, fmt.Errorf("stripe: 退款请求失败: %w", err)
}
return rf.ID, mapRefundStatus(rf.Status), nil
}
func mapRefundStatus(s gostripe.RefundStatus) provider.PaidStatus {
switch s {
case gostripe.RefundStatusSucceeded:
return provider.PaidSucceeded
case gostripe.RefundStatusFailed, gostripe.RefundStatusCanceled:
return provider.PaidFailed
default: // pending / requires_action → 异步,交上层保持 processing
return provider.PaidPending
}
}
- Step 4: 跑测试确认通过
Run: cd /Users/wangjia/code/pay && go build ./... && go test ./internal/provider/stripe/ -v
Expected: 全 PASS(含 P3 既有 Create/Query/VerifyCallback)。
- Step 5: Commit
cd /Users/wangjia/code/pay
git add internal/provider/stripe/
git commit -m "feat(v2): Stripe adapter 落地 RefundingProvider(session→payment_intent→Refunds.New,refund_id 作幂等键)"
Task 6: HTTP 端点 + 路由 + 装配(业务发起验签 / 状态查询 / 人工闭环)
Files:
- Create:
internal/handler/refund.go(CreateRefund/GetRefund/ListManualRefunds/CompleteRefund) - Modify:
internal/router/router.go(SetupV2加退款路由 + admin 组) - Test:
internal/handler/refund_test.go(签名下单→付→退款端到端;admin 人工闭环;超退/未授权拒)
决策:
-
POST /api/v2/refunds:业务发起,body{out_trade_no, amount_minor, reason?, biz_system};biz_system非空且 HMAC 验签(复用verifyBizSign,与POST /orders同惯例)。InitiatedBy="business",BizSystem=req.BizSystem(gateway 内做归属校验)。错误映射:ErrOrderNotFound→404、ErrOrderNotRefundable→409、ErrRefundAmountInvalid→422、ErrAttemptNotFound→409、验签失败→401、其它→500。 -
GET /api/v2/refunds/:refund_id:查退款状态(公开只读,与GET /orders/:order_no同惯例;不泄敏)。 -
人工闭环走独立 admin 组
/api/v2/admin,envPAY_ADMIN_TOKEN+X-Pay-Admin头(crypto/subtle定长比较;token 未设→一律 403 视为禁用)。GET /api/v2/admin/manual-refunds(捞待办)、POST /api/v2/admin/refunds/:refund_id/complete(body{provider_refund_ref})。路由分组避坑:gin 不允许同层「静态段」与「:param 段」共存,故 admin 组里用manual-refunds(静态)与refunds/:refund_id/complete(param 在refunds下)两条不同首段路径,/api/v2/refunds与/api/v2/refunds/:refund_id在 v2 组内也无静态兄弟,均不冲突。 -
平台发起(IAP/Stripe refund webhook) 归 P8(拒付 chargeback)与后续:P4 不接平台异步退款通知(Stripe
charge.refunded/refund.updated事件分发),记 Self-Review。 -
Step 1: 写失败测试
internal/handler/refund_test.go:
package handler_test
import (
"context"
"encoding/json"
"net/http"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/provider/fake"
"github.com/wangjia/pay/internal/router"
"github.com/wangjia/pay/internal/store"
"github.com/wangjia/pay/internal/util"
)
const refundSecret = "s3cr3t-pangolin"
func buildRefundEngine(t *testing.T) (*gin.Engine, *gateway.Gateway, *store.OrderStore, *fake.Provider) {
t.Helper()
gin.SetMode(gin.TestMode)
config.C = config.Config{Biz: map[string]config.BizSystemConfig{
"pangolin": {Secret: refundSecret, CallbackURL: "http://x"},
}}
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}})
g := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), oneResolver{}, nopEnqueuer{}, "global")
r := gin.New()
router.SetupV2(r, g)
return r, g, orders, fp
}
// 用 pangolin secret 给退款请求签名(与 verifyBizSign 一致)。
func signedRefundReq(t *testing.T, r *gin.Engine, body map[string]any) *http.Response {
t.Helper()
raw, _ := json.Marshal(body)
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := uuid.NewString()
sign := util.HMACSign(refundSecret, "pangolin", ts, nonce, string(raw))
req, _ := http.NewRequest(http.MethodPost, "/api/v2/refunds", jsonBody(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Pay-System", "pangolin")
req.Header.Set("X-Pay-Timestamp", ts)
req.Header.Set("X-Pay-Nonce", nonce)
req.Header.Set("X-Pay-Sign", sign)
return serve(t, r, req)
}
func TestRefundEndpointE2E(t *testing.T) {
r, g, orders, fp := buildRefundEngine(t)
fp.EnableRefund("fake-ref", provider.PaidSucceeded, nil)
// 下单 + 付
res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"})
_, _ = g.SyncPendingAttempts(context.Background(), 10)
// 签名退款 → 200 succeeded
resp := signedRefundReq(t, r, map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 29990000, "biz_system": "pangolin", "reason": "test"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("refund status = %d want 200", resp.StatusCode)
}
if o, _ := orders.GetOrder(res.OrderNo); o.Status != model.OrderRefundedV2 {
t.Fatalf("order = %s want refunded", o.Status)
}
// 未签名 → 401
req, _ := http.NewRequest(http.MethodPost, "/api/v2/refunds", jsonBody(mustJSON(map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 1, "biz_system": "pangolin"})))
req.Header.Set("Content-Type", "application/json")
if resp := serve(t, r, req); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unsigned status = %d want 401", resp.StatusCode)
}
}
func TestManualRefundAdminFlow(t *testing.T) {
t.Setenv("PAY_ADMIN_TOKEN", "adm-tok")
r, g, orders, fp := buildRefundEngine(t)
// fp 不 EnableRefund → manual_pending
res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-2"})
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"})
_, _ = g.SyncPendingAttempts(context.Background(), 10)
mr, _ := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: res.OrderNo, AmountMinor: 29990000, BizSystem: "pangolin"})
// admin 无 token → 403
req, _ := http.NewRequest(http.MethodGet, "/api/v2/admin/manual-refunds", nil)
if resp := serve(t, r, req); resp.StatusCode != http.StatusForbidden {
t.Fatalf("no-token status = %d want 403", resp.StatusCode)
}
// admin 带 token → 200 且列出待办
req, _ = http.NewRequest(http.MethodGet, "/api/v2/admin/manual-refunds", nil)
req.Header.Set("X-Pay-Admin", "adm-tok")
if resp := serve(t, r, req); resp.StatusCode != http.StatusOK {
t.Fatalf("admin list status = %d want 200", resp.StatusCode)
}
// complete → order refunded
req, _ = http.NewRequest(http.MethodPost, "/api/v2/admin/refunds/"+mr.RefundID+"/complete", jsonBody(mustJSON(map[string]any{"provider_refund_ref": "tron-tx"})))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Pay-Admin", "adm-tok")
if resp := serve(t, r, req); resp.StatusCode != http.StatusOK {
t.Fatalf("complete status = %d want 200", resp.StatusCode)
}
if o, _ := orders.GetOrder(res.OrderNo); o.Status != model.OrderRefundedV2 {
t.Fatalf("order = %s want refunded", o.Status)
}
}
测试小工具
jsonBody/serve/mustJSON:若internal/handler/gateway_test.go已有等价 helper(如do(...))则复用/改写为直接构造http.Request(签名头必须逐个设,不能走do的自动编码)。否则在本测试文件加:func jsonBody(b []byte) *bytes.Reader { return bytes.NewReader(b) } func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b } func serve(t *testing.T, r *gin.Engine, req *http.Request) *http.Response { w := httptest.NewRecorder(); r.ServeHTTP(w, req); return w.Result() }(import
bytes/net/http/httptest。)config.C是包级var C Config(值类型),直接赋值即可。
- Step 2: 跑测试确认失败
Run: cd /Users/wangjia/code/pay && go test ./internal/handler/ -run 'Refund' -v
Expected: 编译失败(CreateRefund/ListManualRefunds handler 未定义;路由未注册)。
- Step 3: 写实现
internal/handler/refund.go:
package handler
import (
"crypto/subtle"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/store"
"github.com/wangjia/pay/internal/util"
)
type refundRequest struct {
OutTradeNo string `json:"out_trade_no"`
AmountMinor int64 `json:"amount_minor"`
Reason string `json:"reason,omitempty"`
BizSystem string `json:"biz_system"`
}
// CreateRefund POST /api/v2/refunds —— 业务发起退款(HMAC 验签)。
func (h *GatewayHandler) CreateRefund(c *gin.Context) {
raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes))
if err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大或读取失败")
return
}
var req refundRequest
if err := json.Unmarshal(raw, &req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误")
return
}
if req.OutTradeNo == "" || req.AmountMinor <= 0 || req.BizSystem == "" {
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 out_trade_no / amount_minor / biz_system")
return
}
if err := verifyBizSign(c, req.BizSystem, raw); err != nil {
util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
res, err := h.g.Refund(c.Request.Context(), gateway.RefundInput{
OutTradeNo: req.OutTradeNo, AmountMinor: req.AmountMinor, Reason: req.Reason,
BizSystem: req.BizSystem, InitiatedBy: "business",
})
if err != nil {
h.writeRefundErr(c, err)
return
}
util.RespondSuccess(c, res)
}
// GetRefund GET /api/v2/refunds/:refund_id
func (h *GatewayHandler) GetRefund(c *gin.Context) {
v, err := h.g.GetRefund(c.Param("refund_id"))
if err != nil {
util.RespondError(c, http.StatusNotFound, "refund_not_found", "退款单不存在")
return
}
util.RespondSuccess(c, v)
}
func (h *GatewayHandler) adminAuthorized(c *gin.Context) bool {
tok := os.Getenv("PAY_ADMIN_TOKEN")
if tok == "" {
return false // 未配置管理密钥 → 端点禁用
}
return subtle.ConstantTimeCompare([]byte(c.GetHeader("X-Pay-Admin")), []byte(tok)) == 1
}
// ListManualRefunds GET /api/v2/admin/manual-refunds —— 捞 crypto 人工待办。
func (h *GatewayHandler) ListManualRefunds(c *gin.Context) {
if !h.adminAuthorized(c) {
util.RespondError(c, http.StatusForbidden, "forbidden", "无管理权限")
return
}
rs, err := h.g.ListManualPendingRefunds(50)
if err != nil {
util.RespondError(c, http.StatusInternalServerError, "list_failed", "查询失败")
return
}
util.RespondSuccess(c, gin.H{"refunds": rs})
}
type completeRefundRequest struct {
ProviderRefundRef string `json:"provider_refund_ref"`
}
// CompleteRefund POST /api/v2/admin/refunds/:refund_id/complete —— 运营回填人工退款完成。
func (h *GatewayHandler) CompleteRefund(c *gin.Context) {
if !h.adminAuthorized(c) {
util.RespondError(c, http.StatusForbidden, "forbidden", "无管理权限")
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes)
var req completeRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误")
return
}
res, err := h.g.CompleteManualRefund(c.Request.Context(), c.Param("refund_id"), req.ProviderRefundRef)
if err != nil {
switch {
case errors.Is(err, store.ErrRefundNotFound):
util.RespondError(c, http.StatusNotFound, "refund_not_found", "退款单不存在")
case errors.Is(err, gateway.ErrRefundNotManual):
util.RespondError(c, http.StatusConflict, "not_manual", "该退款单非人工待办态")
default:
log.Printf("[v2 refund] 人工完成失败 %s: %v", c.Param("refund_id"), err)
util.RespondError(c, http.StatusInternalServerError, "complete_failed", "完成失败")
}
return
}
util.RespondSuccess(c, res)
}
func (h *GatewayHandler) writeRefundErr(c *gin.Context, err error) {
switch {
case errors.Is(err, store.ErrOrderNotFound):
util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在")
case errors.Is(err, gateway.ErrOrderNotRefundable):
util.RespondError(c, http.StatusConflict, "order_not_refundable", "订单不可退款(未支付/已全额退款/非本业务)")
case errors.Is(err, gateway.ErrRefundAmountInvalid):
util.RespondError(c, http.StatusUnprocessableEntity, "refund_amount_invalid", "退款金额无效或超过可退余额")
case errors.Is(err, store.ErrAttemptNotFound):
util.RespondError(c, http.StatusConflict, "no_paid_attempt", "订单无已支付记录")
default:
log.Printf("[v2 refund] 退款失败: %v", err)
util.RespondError(c, http.StatusInternalServerError, "refund_failed", "退款失败,请稍后重试")
}
}
internal/router/router.go —— SetupV2 加退款路由 + admin 组:
func SetupV2(r *gin.Engine, g *gateway.Gateway) {
h := handler.NewGatewayHandler(g)
v2 := r.Group("/api/v2")
{
v2.POST("/orders", h.CreateOrder)
v2.GET("/orders/:order_no", h.GetStatus)
v2.POST("/orders/:order_no/retry", h.Retry)
v2.POST("/orders/:order_no/cancel", h.Cancel)
v2.POST("/callback/:method", h.Callback)
v2.POST("/refunds", h.CreateRefund)
v2.GET("/refunds/:refund_id", h.GetRefund)
}
// 人工退款闭环:独立 admin 组(env PAY_ADMIN_TOKEN 保护),路径与 v2/refunds 分离避免路由冲突。
admin := r.Group("/api/v2/admin")
{
admin.GET("/manual-refunds", h.ListManualRefunds)
admin.POST("/refunds/:refund_id/complete", h.CompleteRefund)
}
}
- Step 4: 跑测试确认通过
Run: cd /Users/wangjia/code/pay && go build ./... && go test ./... 2>&1 | tail -30
Expected: 全仓 PASS(含 handler 端到端、admin 闭环、超退/未授权拒;P1-P5 既有全绿)。
- Step 5: Commit
cd /Users/wangjia/code/pay
git add internal/handler/refund.go internal/handler/refund_test.go internal/router/router.go
git commit -m "feat(v2): 退款 HTTP 端点(POST /refunds 验签发起 + 状态查询 + admin 人工闭环)"
Self-Review(取舍与硬化项)
已交付(P4 范围内):
- 退款生命周期端到端:业务发起(HMAC 验签)→ 校验(已付/归属/金额 ≤ 可退余额)→ 建单 → 渠道退款(alipay
TradeRefund/ stripeRefunds.New,refund_id幂等键)→ 幂等落状态 → order 退款态机条件 UPDATE(partially_refunded/refunded)→ outboxrefund.succeeded/refund.failed。 - 退款事件幂等键 = refund 单号:outbox 唯一键扩为
(out_trade_no,event_type,refund_id),同单多次部分退各自成行、各自投递;payment 事件refund_id=""语义不变。 - 部分退款模型:一单可多次部分退,累计 ≤ 已付;可退余额守卫按「非失败退款额之和」(在途也占额度,防并发退穿);态机由「succeeded 之和」驱动。
- crypto 人工向最小闭环:无退款 API → 建
manual_pending(不动 order/不发事件)→GET /admin/manual-refunds捞待办 →POST /admin/refunds/:id/complete回填 tx → 与渠道成功同一settleRefundSucceeded落地。 - 投递门禁放宽:
OrderStatusV2.Settled()让退款态订单的 refund webhook 正常放行,同时保留对未付单的 payment 事件保护。
取舍 / 硬化项(P4 明确不做,留后续):
- 异步退款结果收敛:stripe
requires_action/pending退款(及未来 3DS 场景)Refund()返PaidPending,单保持processing——P4 无「退款查单 job」主动收敛,靠人工complete或 P6 对账。真实卡类退款绝大多数同步succeeded,影响面小。 - 平台发起退款/拒付:Stripe
charge.refunded/refund.updatedwebhook、IAP 退款通知、信用卡 chargeback → 归 P8;P4 的VerifyCallback不分发退款事件。 - 退款多账户路由:退款走装配期注入的单一渠道客户端(与回调验签同法),多账户下「原路退到具体收款主体」需按
attempt.account_id选客户端——归 P5 路由深化,记硬化项。 - 存量库迁移:
WebhookDelivery加refund_id并改uq_delivery复合唯一键,对已有数据的库AutoMigrate不保证重建旧唯一索引;本分支 v2 未上生产(新建库)故安全,若已部署需手写迁移(drop 旧 uq_delivery → 建新三列唯一索引)。 - stripe
reason未透传:Stripe 只收枚举 reason,业务自由文案落 pay 本地Refund.Reason,不传 Stripe(避免被拒)。 - admin 人工端点鉴权:用 env
PAY_ADMIN_TOKEN+ 定长比较的最小闭环(token 未设即禁用),未做角色/审计日志分级——运营量级足够,精细化留后续。
为何 RefundingProvider 扩了签名:P2 仅声明该可选接口、无任何实现者,P4 首次落地即加 refundID(渠道幂等键)——这是「同单多次部分退款幂等」的硬需求(alipay out_request_no 必传且须稳定唯一;stripe Idempotency-Key),缺它无法安全落地设计文档 §6。零破坏(改前无实现者)。