refactor(payment): 渠道抽象成 Channel 接口 —— 接新渠道=加 adapter 不动骨架

PAYMENT_DESIGN §3 承诺的 internal/payment/channel.go 适配器接口此前不存在,微信硬编码在
manager/handler 里。补齐抽象:

- channel.go:Channel 接口(Name/CreatePay/QueryOrder/VerifyCallback)+ 统一 PayIntent/PayResult
  + 渠道名常量。入参用基本类型不吃 *store.PaymentOrder,payment 包不反依赖 store。
- Wechat 实现 Channel(编译期断言 var _ Channel);QueryResult 归一为 PayResult;CreatePay 返回 PayIntent。
- Manager 从「持一个 *Wechat」改为渠道注册表:Get(name)/Available()/Status(name)/ReloadWechat;
  按渠道名持有已装配实例,热重载不变。
- 回调路由收敛 /billing/callback/wechat → /billing/callback/:channel 按名路由(旧 notify URL 仍匹配);
  查单/掉单补偿据 order.Channel 路由,不再写死微信。下单支持可选 channel(缺省 wechat)。
- 支付宝/Stripe 现在真·只差一个 adapter+注册。唯一未泛化:回调 ack 应答格式(现微信态,注释标明)。
- payment 包首个测试:Manager 注册表 4 用例(空/注册摘除/空配置/配置不全)。build/vet/test/lint 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-18 13:56:24 +08:00
parent 86e79d76ac
commit 3a4e1d53a5
10 changed files with 261 additions and 80 deletions
@@ -0,0 +1,96 @@
package payment
import (
"context"
"net/http"
"testing"
)
// stubChannel 是测试用的假渠道,验证 Manager 注册表逻辑(不打真渠道网络)。
type stubChannel struct{ name string }
func (s stubChannel) Name() string { return s.name }
func (s stubChannel) CreatePay(_ context.Context, _, _ string, _ int64) (PayIntent, error) {
return PayIntent{CodeURL: "stub://" + s.name}, nil
}
func (s stubChannel) QueryOrder(_ context.Context, _ string) (PayResult, error) {
return PayResult{}, nil
}
func (s stubChannel) VerifyCallback(_ *http.Request) (PayResult, error) {
return PayResult{}, nil
}
// 空 ManagerGet 任意渠道为 nilAvailable 为空,Status 报未配置。
func TestManager_EmptyRegistry(t *testing.T) {
m := NewManager()
if m.Get(ChannelWechat) != nil {
t.Error("未注册渠道 Get 应为 nil")
}
if len(m.Available()) != 0 {
t.Errorf("空注册表 Available 应为空,得 %v", m.Available())
}
if ok, reason := m.Status(ChannelWechat); ok || reason == "" {
t.Errorf("空注册表 wechat 应未启用且有原因,得 ok=%v reason=%q", ok, reason)
}
}
// register 生效:注册后 Get 取到、Available 含之、Status 启用;置 nil 则摘除并留原因。
func TestManager_RegisterAndRemove(t *testing.T) {
m := NewManager()
m.register(ChannelWechat, stubChannel{name: ChannelWechat}, "")
if got := m.Get(ChannelWechat); got == nil || got.Name() != ChannelWechat {
t.Fatalf("注册后应能 Get 到 wechat,得 %v", got)
}
if ok, _ := m.Status(ChannelWechat); !ok {
t.Error("注册后 Status 应启用")
}
avail := m.Available()
if len(avail) != 1 || avail[0] != ChannelWechat {
t.Errorf("Available 应含 wechat,得 %v", avail)
}
// 再注册第二个渠道 → Available 有序返回两个。
m.register(ChannelAlipay, stubChannel{name: ChannelAlipay}, "")
if avail := m.Available(); len(avail) != 2 || avail[0] != ChannelAlipay || avail[1] != ChannelWechat {
t.Errorf("Available 应有序含 alipay,wechat,得 %v", avail)
}
// 置 nil(配置变坏/清空)→ 摘除,Get 为 nil、Status 未启用且保留原因。
m.register(ChannelWechat, nil, "配置不全,缺: mchid")
if m.Get(ChannelWechat) != nil {
t.Error("摘除后 Get 应为 nil")
}
if ok, reason := m.Status(ChannelWechat); ok || reason != "配置不全,缺: mchid" {
t.Errorf("摘除后应未启用且保留原因,得 ok=%v reason=%q", ok, reason)
}
}
// ReloadWechat 空配置:不算错、渠道保持隐藏、原因为「未配置」。
func TestManager_ReloadWechat_Empty(t *testing.T) {
m := NewManager()
if err := m.ReloadWechat(context.Background(), Config{}); err != nil {
t.Errorf("空配置 ReloadWechat 不应报错,得 %v", err)
}
if m.Get(ChannelWechat) != nil {
t.Error("空配置不应启用渠道")
}
if ok, reason := m.Status(ChannelWechat); ok || reason != "未配置" {
t.Errorf("空配置应报未配置,得 ok=%v reason=%q", ok, reason)
}
}
// ReloadWechat 配置不全:返回错误、渠道隐藏、原因列出缺失字段。
func TestManager_ReloadWechat_Incomplete(t *testing.T) {
m := NewManager()
err := m.ReloadWechat(context.Background(), Config{MchID: "123"}) // 只填一个字段
if err == nil {
t.Error("配置不全应返回错误")
}
if m.Get(ChannelWechat) != nil {
t.Error("配置不全不应启用渠道")
}
if _, reason := m.Status(ChannelWechat); reason == "" {
t.Error("配置不全应给出缺失原因")
}
}