Files
pay/docs/superpowers/plans/2026-07-10-pay-v2-p4-refunds.md
T

1609 lines
72 KiB
Markdown

# 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.1 `refund(provider_ref, amount, reason)`;§5 webhook v2 `refund.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`):
```go
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`(新增):
```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`:
```go
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()`:
```go
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`:
```go
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` 透传:
```go
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` 接口加参:
```go
// 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` 末行):
```go
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, "payment.succeeded", "", data)
```
`main.go` —— 投递门禁闭包放宽为已结算:
```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` 的替身一并跟签名(全仓 grep `func.*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**
```bash
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`
**决策:**
1. **可退余额守卫**:超退检查按「**非失败**退款额之和」(`requested`/`processing`/`manual_pending`/`succeeded` 全计入)——在途退款也占额度,防并发/重复请求把一单退穿。
2. **order 态机由「已成功退款额」驱动**:`ApplyRefundToOrder(fully)` 条件 UPDATE,`fully = succeeded 之和 ≥ 订单已付额``refunded`,否则 `partially_refunded`。守卫 `status IN (paid, partially_refunded, refunding)`(已 `refunded` 或未付一律 no-op,幂等)。
3. **`PaidAttempt`**:退款针对「哪笔已付」(原路退)——取订单 `status=paid` 的 attempt,拿它的 `channel`(选渠道)+ `provider_ref`(渠道退款定位原交易)。一单只一笔 paid attempt(P2「一单 N 渠道只一个成功」)。
4. **`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`:
```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`:
```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`):
```go
// 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**
```bash
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`
**决策(核心):**
1. **`RefundingProvider.Refund``refundID` 参**(P2 只声明未落地,无实现者,现扩展零破坏):
```go
Refund(ctx, providerRef, refundID string, amountMinor int64, reason string) (refundRef string, status PaidStatus, err error)
```
`refundID` = pay 侧 refund 单号,作**渠道幂等键**:alipay `OutRequestNo`(部分退款必传且须唯一稳定,重试不重复退)、stripe `Idempotency-Key`。没有它就无法给「同单多次部分退」做幂等——这是设计文档 §6「统一收敛成 refund 事件」落地的必需项。
2. **退款事件幂等键 = refund 单号**(承 Task 1):`enqueueRefundEvent` 走 `Enqueue(..., eventType, r.RefundID, data)`,payload 含 `refund_id`/`provider_refund_ref`/`amount_minor`/`reason`。同单多次部分退各自成 outbox 行、各自投递。
3. **crypto 人工向最小闭环**:crypto 不实现 `RefundingProvider`(且 `SupportsRefund=false`)→ `Refund` 建 `manual_pending` 单**即返回**(不动 order 态、不发事件——钱还没退)。运营链上转账后调 `CompleteManualRefund(refundID, tx)` 回填 → 走与渠道成功**同一条** `settleRefundSucceeded`(enqueue-before-flip + 态机推进 + `refund.succeeded` 事件)。`ListManualPendingRefunds` 供运营捞待办。
4. **order 态机翻转**:仅在退款**成功落地**时(`settleRefundSucceeded`)按「succeeded 之和」推进 `partially_refunded`/`refunded`。发起时不预翻 `refunding`(避免异步 pending 卡在 refunding 需回滚);态机只在有确定资金结果时前进。
5. **同步/异步结果归一**:`Refund()` 返回 `PaidSucceeded`→落地成功;`PaidFailed`→建过的 processing 单翻 failed + 发 `refund.failed`;`PaidPending`(stripe `requires_action` 等异步)→保持 `processing`,交 `CompleteManualRefund` 或 P6 退款查单收敛(记 Self-Review,P4 不做异步退款查单 job)。
6. **归属校验**:`RefundInput.BizSystem` 非空时须等于 `order.BizSystem`,防业务 A 退业务 B 的单。
- [ ] **Step 1: 写失败测试**
`internal/gateway/refund_test.go`:
```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`:
```go
// 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`):
```go
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` 改用字段:
```go
func (p *Provider) Capabilities() provider.Capabilities {
return provider.Capabilities{
RenderTypes: []provider.RenderType{provider.RenderCryptoAddress},
SupportsRefund: p.supportsRefund,
SettleCurrencies: []string{"USDT"},
Regions: []string{"global"},
}
}
```
文件末尾追加:
```go
// 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` 加参:
```go
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`(新建):
```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` 并传入(改动最小):
```go
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`:
```go
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**
```bash
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(与 P3 `alipay_test.go` 的 `signRSA2` 同款,只是签的是 response node)。
- [ ] **Step 1: 写失败测试**
`internal/provider/alipay/alipay_refund_test.go`:
```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`:
```go
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**
```bash
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`**:Stripe `RefundParams.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`:
```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`:
```go
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**
```bash
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`,env `PAY_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`:
```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` 的自动编码)。否则在本测试文件加:
> ```go
> 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`:
```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 组:
```go
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**
```bash
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` / stripe `Refunds.New`,`refund_id` 幂等键)→ 幂等落状态 → order 退款态机条件 UPDATE(`partially_refunded`/`refunded`)→ outbox `refund.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 明确不做,留后续):**
1. **异步退款结果收敛**:stripe `requires_action`/`pending` 退款(及未来 3DS 场景)`Refund()` 返 `PaidPending`,单保持 `processing`——P4 无「退款查单 job」主动收敛,靠人工 `complete` 或 P6 对账。真实卡类退款绝大多数同步 `succeeded`,影响面小。
2. **平台发起退款/拒付**:Stripe `charge.refunded`/`refund.updated` webhook、IAP 退款通知、信用卡 chargeback → 归 **P8**;P4 的 `VerifyCallback` 不分发退款事件。
3. **退款多账户路由**:退款走装配期注入的**单一渠道客户端**(与回调验签同法),多账户下「原路退到具体收款主体」需按 `attempt.account_id` 选客户端——归 P5 路由深化,记硬化项。
4. **存量库迁移**:`WebhookDelivery` 加 `refund_id` 并改 `uq_delivery` 复合唯一键,对**已有数据**的库 `AutoMigrate` 不保证重建旧唯一索引;本分支 v2 未上生产(新建库)故安全,若已部署需手写迁移(drop 旧 uq_delivery → 建新三列唯一索引)。
5. **stripe `reason` 未透传**:Stripe 只收枚举 reason,业务自由文案落 pay 本地 `Refund.Reason`,不传 Stripe(避免被拒)。
6. **admin 人工端点鉴权**:用 env `PAY_ADMIN_TOKEN` + 定长比较的最小闭环(token 未设即禁用),未做角色/审计日志分级——运营量级足够,精细化留后续。
**为何 `RefundingProvider` 扩了签名**:P2 仅声明该可选接口、无任何实现者,P4 首次落地即加 `refundID`(渠道幂等键)——这是「同单多次部分退款幂等」的硬需求(alipay `out_request_no` 必传且须稳定唯一;stripe `Idempotency-Key`),缺它无法安全落地设计文档 §6。零破坏(改前无实现者)。