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

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.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.OrderStoremoneyaccounts)。
  • 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 只加不删:WebhookDeliveryRefundID 列并改 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_typesize: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(EnqueueDeliveryrefundID 参、OnConflict 加列)
  • Modify: internal/webhook/notifier.go(EnqueuerefundID 参 → 透传 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 —— 加 RefundIDuq_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 —— EnqueueDeliveryrefundID:

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 —— EnqueuerefundID 透传:

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.gospyEnqueuer.Enqueuefunc (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error(签名加 refundID,函数体不变)。
  • internal/handler/gateway_test.gonopEnqueuer.Enqueuefunc (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
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,非空 refprovider_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.RefundrefundID 幂等键参)
  • 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.RefundrefundID(P2 只声明未落地,无实现者,现扩展零破坏):
    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):enqueueRefundEventEnqueue(..., eventType, r.RefundID, data),payload 含 refund_id/provider_refund_ref/amount_minor/reason。同单多次部分退各自成 outbox 行、各自投递。
  3. crypto 人工向最小闭环:crypto 不实现 RefundingProvider(且 SupportsRefund=false)→ Refundmanual_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:

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.ProviderEnableRefund,并在 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 —— RefundingProviderrefundID:

// 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)。CapabilitiesSupportsRefund 改用字段:

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 —— Gatewayrefunds 字段;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=reasonOutRequestNo=refundID(部分退款必传且须唯一稳定 → 用 pay 侧退款单号,天然幂等:同 out_request_no 重复请求支付宝按同一笔处理)。

  • 同步结果:支付宝退款是同步接口——rsp.IsFailure()==false 即受理成功(FundChangeY/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.gosignRSA2 同款,只是签的是 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.ProviderRefund 方法 / SupportsRefund=false)。

  • Step 3: 写实现

internal/provider/alipay/alipay.go —— Capabilitiestrue;文件末尾加 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:Stripe RefundParams.Reason 只收枚举(duplicate/fraudulent/requested_by_customer),业务自由文案会被拒;reason 只落 pay 本地 Refund.Reason,不传给 Stripe(决策写明)。

  • 状态归一 mapRefundStatus:succeeded→PaidSucceeded;pending/requires_action→PaidPending(异步,gateway 保持 processing);failed/canceled→PaidFailedrefundRef = rf.ID

  • 离线测法:沿用 P3 stripe_test.gonewStripe(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.ProviderRefund / SupportsRefund=false)。

  • Step 3: 写实现

internal/provider/stripe/stripe.go —— Capabilitiestrue;加 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→404ErrOrderNotRefundable→409ErrRefundAmountInvalid→422ErrAttemptNotFound→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:

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 / 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. 存量库迁移:WebhookDeliveryrefund_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。零破坏(改前无实现者)。