From 3a4e1d53a5ab80c53df0e0ffa006443c28abaa30 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Sat, 18 Jul 2026 13:56:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor(payment):=20=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E6=8A=BD=E8=B1=A1=E6=88=90=20Channel=20=E6=8E=A5=E5=8F=A3=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=8E=A5=E6=96=B0=E6=B8=A0=E9=81=93=3D?= =?UTF-8?q?=E5=8A=A0=20adapter=20=E4=B8=8D=E5=8A=A8=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PAYMENT_DESIGN.md | 21 ++-- .../internal/handler/billing_pay.go | 56 ++++++----- .../internal/handler/payment_admin.go | 8 +- .../internal/handler/payment_reconcile.go | 4 +- .../internal/handler/task_handler.go | 2 +- sundynix-gateway/internal/payment/channel.go | 44 +++++++++ sundynix-gateway/internal/payment/manager.go | 71 ++++++++++---- .../internal/payment/manager_test.go | 96 +++++++++++++++++++ sundynix-gateway/internal/payment/wechat.go | 37 ++++--- sundynix-gateway/internal/router/router.go | 2 +- 10 files changed, 261 insertions(+), 80 deletions(-) create mode 100644 sundynix-gateway/internal/payment/channel.go create mode 100644 sundynix-gateway/internal/payment/manager_test.go diff --git a/PAYMENT_DESIGN.md b/PAYMENT_DESIGN.md index fc9494f..43297fc 100644 --- a/PAYMENT_DESIGN.md +++ b/PAYMENT_DESIGN.md @@ -43,18 +43,23 @@ | **微信支付 Native**(P5.2) | 企业主体 + 商户号 | 二维码(code_url) | ✅ 已拍板 | | 支付宝 / Stripe | — | — | 不做,留 adapter 位 | -Adapter 接口(`internal/payment/channel.go`): +Adapter 接口(`internal/payment/channel.go`)✅ **已落地**: ```go type Channel interface { - Name() string - // CreatePay 依据订单生成支付凭据(二维码内容/跳转 URL/兑换码提示) - CreatePay(ctx, o *PaymentOrder) (PayIntent, error) - // VerifyCallback 验签并解析回调 → (orderID, channelTxnID, paidAmountFen, error) - VerifyCallback(req *http.Request) (CallbackResult, error) - // QueryOrder 主动查单(掉单补偿用) - QueryOrder(ctx, orderID string) (OrderStatus, error) + Name() string // 与 store.Channel*(wechat/alipay/stripe)一致,按订单渠道路由 + // CreatePay 依据订单生成支付凭据(微信 Native 出 code_url;其它渠道可出跳转 URL) + CreatePay(ctx, orderID, description string, amountFen int64) (PayIntent, error) + // QueryOrder 主动查单(前端轮询确认 + 掉单补偿共用) + QueryOrder(ctx, orderID string) (PayResult, error) + // VerifyCallback 验签并解析回调 → 统一 PayResult(验签失败必返 error) + VerifyCallback(req *http.Request) (PayResult, error) } ``` +实现说明(2026-07-18):入参用基本类型(orderID/amountFen)而非 `*store.PaymentOrder`,让 +`payment` 包不反依赖 `store`,渠道适配层保持纯粹。`Manager` 是渠道注册表(`Get(name)`/`Available()`/ +`Status(name)`/`ReloadWechat`),回调路由收敛为 `POST /billing/callback/:channel` 按名路由; +查单/补偿据 `order.Channel` 路由。微信是当前唯一真渠道,支付宝/Stripe = 实现本接口 + 注册即接入。 +唯一未泛化的是回调 **ack 应答格式**(现为微信 `{code:SUCCESS}`,其它渠道接入时按渠道分应答)。 ## 3b. 两层汇率,各管各的(用户 2026-07-17 强调:积分↔token 必须可动态调) diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index 00ffcb9..42715c2 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -21,10 +21,8 @@ func (h *Handler) BillingPacks(c *gin.Context) { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } - channels := []string{store.ChannelRedeem} - if h.pay.Current() != nil { - channels = append(channels, store.ChannelWechat) - } + // 兑换码渠道始终可用(无需配置);真渠道由 Manager 报告哪些已启用。 + channels := append([]string{store.ChannelRedeem}, h.pay.Available()...) c.JSON(http.StatusOK, gin.H{"packs": packs, "channels": channels}) } @@ -33,21 +31,27 @@ func (h *Handler) BillingPacks(c *gin.Context) { // 价格可能已经改过,不让旧价格的单无限期可付。 const orderTTL = 30 * time.Minute -// BillingCreateOrder: POST /api/v1/billing/orders {pack_id} —— 微信 Native 下单,返回 code_url。 -// 金额/积分由服务端按在售包锁定进订单行,前端只传包 id,不信任任何客户端金额。 +// BillingCreateOrder: POST /api/v1/billing/orders {pack_id, channel?} —— 真渠道下单,返回支付凭据。 +// channel 缺省 wechat(当前唯一真渠道);金额/积分由服务端按在售包锁定进订单行,前端只传包 id, +// 不信任任何客户端金额。订单记下所用渠道,后续查单/回调/补偿据此路由。 func (h *Handler) BillingCreateOrder(c *gin.Context) { - wc := h.pay.Current() - if wc == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "微信支付未配置,请用兑换码充值"}) - return - } var b struct { - PackID string `json:"pack_id"` + PackID string `json:"pack_id"` + Channel string `json:"channel"` } if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.PackID) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 必填"}) return } + channel := strings.TrimSpace(b.Channel) + if channel == "" { + channel = store.ChannelWechat + } + ch := h.pay.Get(channel) + if ch == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "渠道 " + channel + " 未启用,请换渠道或用兑换码充值"}) + return + } ctx := c.Request.Context() uid := userID(c) billing := h.db.ResolveBillingTenantID(ctx, uid, tenantID(c)) @@ -63,20 +67,20 @@ func (h *Handler) BillingCreateOrder(c *gin.Context) { o := &store.PaymentOrder{ TenantID: billing, UserID: uid, PackID: pk.ID, AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro, - Channel: store.ChannelWechat, Status: store.OrderPending, + Channel: channel, Status: store.OrderPending, } if err := h.db.CreateOrder(ctx, o); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } - codeURL, err := wc.CreatePay(ctx, o.ID, "sundynix 积分充值 · "+pk.Name, pk.PriceFen) + intent, err := ch.CreatePay(ctx, o.ID, "sundynix 积分充值 · "+pk.Name, pk.PriceFen) if err != nil { // 渠道下单失败的单直接作废,不留一堆永远付不了的 pending。 _ = h.db.ExpireOrder(ctx, o.ID) - c.JSON(http.StatusBadGateway, gin.H{"error": "微信下单失败: " + err.Error()}) + c.JSON(http.StatusBadGateway, gin.H{"error": channel + "下单失败: " + err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"order_id": o.ID, "code_url": codeURL, "amount_fen": o.AmountFen}) + c.JSON(http.StatusOK, gin.H{"order_id": o.ID, "code_url": intent.CodeURL, "amount_fen": o.AmountFen}) } // BillingOrderStatus: GET /api/v1/billing/orders/:id —— 前端轮询订单态。 @@ -106,11 +110,11 @@ func (h *Handler) BillingOrderStatus(c *gin.Context) { // 渠道关单/超 TTL→过期。返回最新订单 + 是否金额不符(不符则不入账、留人工对账)。 // 前端轮询与掉单补偿定时器共用这一份,避免两处「查单→落态」逻辑漂移。 func (h *Handler) reconcileOrder(ctx context.Context, o *store.PaymentOrder) (*store.PaymentOrder, bool) { - wc := h.pay.Current() - if o.Status != store.OrderPending || wc == nil { + ch := h.pay.Get(o.Channel) // 按订单实际所用渠道查单,不写死微信 + if o.Status != store.OrderPending || ch == nil { return o, false } - if r, err := wc.QueryOrder(ctx, o.ID); err == nil { + if r, err := ch.QueryOrder(ctx, o.ID); err == nil { switch { case r.Paid && r.AmountFen == o.AmountFen: if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil { @@ -130,15 +134,17 @@ func (h *Handler) reconcileOrder(ctx context.Context, o *store.PaymentOrder) (*s return o, false } -// WechatCallback: POST /api/v1/billing/callback/wechat —— 微信支付回调(公开路由,验签是唯一的门)。 -// 应答契约:入账成功/重复推送都回 200 {code:SUCCESS};验签失败 4xx;处理失败 5xx 让微信重试。 -func (h *Handler) WechatCallback(c *gin.Context) { - wc := h.pay.Current() - if wc == nil { +// PaymentCallback: POST /api/v1/billing/callback/:channel —— 渠道支付回调(公开路由,验签是唯一的门)。 +// 按 :channel 路由到对应适配器验签。应答契约当前为微信形态({code:SUCCESS}/{code:FAIL}): +// 入账成功/重复推送都回 200 SUCCESS;验签失败 4xx;处理失败 5xx 让渠道重试。 +// 注:其它渠道的 ack 格式不同(如支付宝要求纯文本 "success"),接入时按渠道分应答即可。 +func (h *Handler) PaymentCallback(c *gin.Context) { + ch := h.pay.Get(c.Param("channel")) + if ch == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"code": "FAIL", "message": "渠道未配置"}) return } - r, err := wc.VerifyCallback(c.Request) + r, err := ch.VerifyCallback(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"code": "FAIL", "message": "验签失败"}) return diff --git a/sundynix-gateway/internal/handler/payment_admin.go b/sundynix-gateway/internal/handler/payment_admin.go index 37dca0e..e02ea5e 100644 --- a/sundynix-gateway/internal/handler/payment_admin.go +++ b/sundynix-gateway/internal/handler/payment_admin.go @@ -38,13 +38,13 @@ func (h *Handler) loadWechatConfig(ctx context.Context) payment.Config { // InitWechat 启动时装配微信渠道(DB 优先 → env)。失败只降级隐藏,不阻断启动。 func (h *Handler) InitWechat(ctx context.Context) { - _ = h.pay.Reload(ctx, h.loadWechatConfig(ctx)) + _ = h.pay.ReloadWechat(ctx, h.loadWechatConfig(ctx)) } // AdminGetWechatPay: GET /api/v1/admin/payment/wechat —— 当前配置(密钥不回显)+ 渠道状态。 func (h *Handler) AdminGetWechatPay(c *gin.Context) { cfg := h.loadWechatConfig(c.Request.Context()) - enabled, reason := h.pay.Status() + enabled, reason := h.pay.Status(payment.ChannelWechat) c.JSON(http.StatusOK, gin.H{ "config": gin.H{ "mchid": cfg.MchID, @@ -108,7 +108,7 @@ func (h *Handler) AdminSaveWechatPay(c *gin.Context) { return } // 热重载:配置有毛病只会让渠道隐藏并给出原因,不影响其它功能。 - _ = h.pay.Reload(ctx, cfg) - enabled, reason := h.pay.Status() + _ = h.pay.ReloadWechat(ctx, cfg) + enabled, reason := h.pay.Status(payment.ChannelWechat) c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": enabled, "reason": reason}) } diff --git a/sundynix-gateway/internal/handler/payment_reconcile.go b/sundynix-gateway/internal/handler/payment_reconcile.go index 79e854c..ff82621 100644 --- a/sundynix-gateway/internal/handler/payment_reconcile.go +++ b/sundynix-gateway/internal/handler/payment_reconcile.go @@ -4,6 +4,8 @@ import ( "context" "log" "time" + + "github.com/sundynix/sundynix-gateway/internal/payment" ) // 掉单补偿(P5.3,设计见 PAYMENT_DESIGN.md §5): @@ -33,7 +35,7 @@ func (h *Handler) StartReconcile(ctx context.Context) { // reconcilePending 扫一轮待补偿的 pending 微信单。渠道未配置时直接返回(不打扰)。 func (h *Handler) reconcilePending(ctx context.Context) { - if h.pay.Current() == nil { + if h.pay.Get(payment.ChannelWechat) == nil { return } orders, err := h.db.PendingWechatOrders(ctx, 200) diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index b6cabe9..b532b69 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -29,7 +29,7 @@ type Handler struct { cache *store.Redis bus *nats.Bus blob *blob.Store - pay *payment.Manager // 微信支付渠道管理器(DB 配置热重载;Current()==nil 即渠道隐藏) + pay *payment.Manager // 充值渠道注册表(按渠道名持有适配器,DB 配置热重载;Get(name)==nil 即隐藏) } func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blob *blob.Store) *Handler { diff --git a/sundynix-gateway/internal/payment/channel.go b/sundynix-gateway/internal/payment/channel.go new file mode 100644 index 0000000..2ff70e9 --- /dev/null +++ b/sundynix-gateway/internal/payment/channel.go @@ -0,0 +1,44 @@ +package payment + +import ( + "context" + "net/http" +) + +// Channel 是一个「真渠道」充值适配器(设计见 PAYMENT_DESIGN.md §3)。 +// 兑换码不走这里(无「待支付」态,核销即入账);本接口面向需要下单-回调/查单确认的渠道。 +// 接新渠道(支付宝/Stripe)= 实现这四个方法 + 在 Manager 注册,不动上层充值/入账骨架。 +// +// 刻意用基本类型入参(orderID/amountFen)而非 *store.PaymentOrder:让 payment 包不反向依赖 +// store,渠道适配层保持纯粹。金额一律以「分」为单位、服务端锁定值,绝不信任客户端。 +type Channel interface { + // Name 渠道名,与 store.Channel*("wechat"/"alipay"/"stripe")一致,用于按订单渠道路由。 + Name() string + // CreatePay 依据订单生成支付凭据(微信 Native 出 code_url;支付宝/Stripe 可出跳转 URL)。 + CreatePay(ctx context.Context, orderID, description string, amountFen int64) (PayIntent, error) + // QueryOrder 主动查单(前端轮询确认 + 掉单补偿共用)。 + QueryOrder(ctx context.Context, orderID string) (PayResult, error) + // VerifyCallback 验签并解析渠道回调 → 统一结果。验签失败必须返回 error(回调路由公开,签名是唯一门)。 + VerifyCallback(req *http.Request) (PayResult, error) +} + +// 渠道名常量(与 store.Channel* 取值一致;此处独立定义以免 payment 反依赖 store)。 +const ( + ChannelWechat = "wechat" + ChannelAlipay = "alipay" // 预留:未实现 + ChannelStripe = "stripe" // 预留:未实现 +) + +// PayIntent 是下单后给前端的支付凭据(不同渠道形态不同,按需取用)。 +type PayIntent struct { + CodeURL string // 微信 Native:二维码内容。其它渠道可放跳转 URL / client_secret。 +} + +// PayResult 是查单/回调验签解析后的统一结果(各渠道适配器把自家响应归一到这里)。 +type PayResult struct { + OrderID string // 商户订单号(out_trade_no) + ChannelTxn string // 渠道流水号(transaction_id),入账时落库备查 + Paid bool // 支付成功 + Closed bool // 关单/撤销/支付失败等终态 + AmountFen int64 // 用户实付(分);供与订单金额核对,防金额被篡改 +} diff --git a/sundynix-gateway/internal/payment/manager.go b/sundynix-gateway/internal/payment/manager.go index 83edf2b..93e8873 100644 --- a/sundynix-gateway/internal/payment/manager.go +++ b/sundynix-gateway/internal/payment/manager.go @@ -5,6 +5,7 @@ import ( "errors" "log" "os" + "sort" "strings" "sync" ) @@ -63,25 +64,42 @@ func ConfigFromEnv() Config { } } -// Manager 持有当前微信渠道实例,支持 admin 改配置后热重载(学 prompt 控制面:改完即生效, -// 不重启 gateway)。所有业务路径经 Current() 取用——nil 即渠道隐藏。 +// Manager 是充值渠道注册表:按渠道名持有已装配的 Channel 实例,支持 admin 改配置后热重载 +// (学 prompt 控制面:改完即生效,不重启 gateway)。业务路径经 Get(name) 按订单渠道取用—— +// 取到 nil 即该渠道未启用/隐藏。接新渠道(支付宝/Stripe)= 加一个 ReloadXxx + 各自 Channel 实现。 type Manager struct { - mu sync.RWMutex - w *Wechat - reason string // 未启用原因(给 admin 状态面看) + mu sync.RWMutex + channels map[string]Channel // 渠道名 → 实例(仅含已成功装配的) + reasons map[string]string // 渠道名 → 未启用原因(给 admin 状态面看,含未启用的) } func NewManager() *Manager { - return &Manager{reason: "未配置"} + return &Manager{ + channels: map[string]Channel{}, + reasons: map[string]string{ChannelWechat: "未配置"}, + } } -// Reload 按给定配置重建渠道实例。失败只降级为隐藏并记录原因,绝不 panic/拖垮服务。 -func (m *Manager) Reload(ctx context.Context, c Config) error { - w, reason, err := build(ctx, c) +// register 装配结果登记:成功则入表、失败则从表移除并记原因(内部用,调用方持有意图日志)。 +func (m *Manager) register(name string, ch Channel, reason string) { m.mu.Lock() - m.w = w - m.reason = reason - m.mu.Unlock() + defer m.mu.Unlock() + if ch != nil { + m.channels[name] = ch + } else { + delete(m.channels, name) + } + m.reasons[name] = reason +} + +// ReloadWechat 按给定配置重建微信渠道。失败只降级为隐藏并记录原因,绝不 panic/拖垮服务。 +func (m *Manager) ReloadWechat(ctx context.Context, c Config) error { + w, reason, err := buildWechat(ctx, c) + var ch Channel + if w != nil { + ch = w + } + m.register(ChannelWechat, ch, reason) if w != nil { log.Printf("[payment] 微信支付 Native 渠道已启用 (mchid=%s)", c.MchID) } else if !c.Empty() { @@ -90,22 +108,35 @@ func (m *Manager) Reload(ctx context.Context, c Config) error { return err } -// Current 当前渠道实例;nil = 未配置/配置失败(渠道隐藏)。 -func (m *Manager) Current() *Wechat { +// Get 按渠道名取实例;nil = 未配置/配置失败(渠道隐藏)。 +func (m *Manager) Get(name string) Channel { m.mu.RLock() defer m.mu.RUnlock() - return m.w + return m.channels[name] } -// Status 给 admin 状态面:是否启用 + 未启用原因。 -func (m *Manager) Status() (bool, string) { +// Available 返回已启用的渠道名(供前端展示可选渠道)。 +func (m *Manager) Available() []string { m.mu.RLock() defer m.mu.RUnlock() - return m.w != nil, m.reason + out := make([]string, 0, len(m.channels)) + for name := range m.channels { + out = append(out, name) + } + sort.Strings(out) + return out } -// build 装配渠道实例;返回 (实例, 未启用原因, 错误)。配置为空不算错。 -func build(ctx context.Context, c Config) (*Wechat, string, error) { +// Status 给 admin 状态面:某渠道是否启用 + 未启用原因。 +func (m *Manager) Status(name string) (bool, string) { + m.mu.RLock() + defer m.mu.RUnlock() + _, ok := m.channels[name] + return ok, m.reasons[name] +} + +// buildWechat 装配微信渠道实例;返回 (实例, 未启用原因, 错误)。配置为空不算错。 +func buildWechat(ctx context.Context, c Config) (*Wechat, string, error) { if c.Empty() { return nil, "未配置", nil } diff --git a/sundynix-gateway/internal/payment/manager_test.go b/sundynix-gateway/internal/payment/manager_test.go new file mode 100644 index 0000000..9b0792a --- /dev/null +++ b/sundynix-gateway/internal/payment/manager_test.go @@ -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 +} + +// 空 Manager:Get 任意渠道为 nil,Available 为空,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("配置不全应给出缺失原因") + } +} diff --git a/sundynix-gateway/internal/payment/wechat.go b/sundynix-gateway/internal/payment/wechat.go index b66334c..fe4b24f 100644 --- a/sundynix-gateway/internal/payment/wechat.go +++ b/sundynix-gateway/internal/payment/wechat.go @@ -33,6 +33,12 @@ type Wechat struct { pubKeyID string } +// 编译期断言:Wechat 实现 Channel 接口。 +var _ Channel = (*Wechat)(nil) + +// Name 渠道名(用于按订单渠道路由查单/回调)。 +func (w *Wechat) Name() string { return ChannelWechat } + // New 按完整配置装配微信渠道(商户私钥/微信支付公钥都从磁盘路径加载;调用方保证字段齐全)。 // 验签体系钉死「微信支付公钥」模式(WithWechatPayPublicKeyAuthCipher):本项目商户 // 2025-09 开户,只有公钥体系;老商户的平台证书模式(AutoAuthCipher)不支持。 @@ -58,7 +64,7 @@ func New(ctx context.Context, c Config) (*Wechat, error) { } // CreatePay Native 下单:返回 code_url(前端渲染成二维码)。金额取订单锁定值。 -func (w *Wechat) CreatePay(ctx context.Context, orderID, description string, amountFen int64) (string, error) { +func (w *Wechat) CreatePay(ctx context.Context, orderID, description string, amountFen int64) (PayIntent, error) { resp, _, err := w.svc.Prepay(ctx, native.PrepayRequest{ Appid: core.String(w.appID), Mchid: core.String(w.mchID), @@ -68,25 +74,16 @@ func (w *Wechat) CreatePay(ctx context.Context, orderID, description string, amo Amount: &native.Amount{Total: core.Int64(amountFen), Currency: core.String("CNY")}, }) if err != nil { - return "", err + return PayIntent{}, err } if resp.CodeUrl == nil || *resp.CodeUrl == "" { - return "", errors.New("微信未返回 code_url") + return PayIntent{}, errors.New("微信未返回 code_url") } - return *resp.CodeUrl, nil + return PayIntent{CodeURL: *resp.CodeUrl}, nil } -// QueryResult 查单/回调解析后的统一结果。 -type QueryResult struct { - OrderID string // out_trade_no - ChannelTxn string // transaction_id - Paid bool // TradeState == SUCCESS - Closed bool // CLOSED/REVOKED/PAYERROR 等终态失败 - AmountFen int64 // 用户实付(分);回调/查单都带,供金额核对 -} - -func fromTransaction(t *payments.Transaction) QueryResult { - r := QueryResult{} +func fromTransaction(t *payments.Transaction) PayResult { + r := PayResult{} if t.OutTradeNo != nil { r.OrderID = *t.OutTradeNo } @@ -110,28 +107,28 @@ func fromTransaction(t *payments.Transaction) QueryResult { } // QueryOrder 主动查单(本地开发确认到账、生产掉单补偿共用)。 -func (w *Wechat) QueryOrder(ctx context.Context, orderID string) (QueryResult, error) { +func (w *Wechat) QueryOrder(ctx context.Context, orderID string) (PayResult, error) { t, _, err := w.svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{ OutTradeNo: core.String(orderID), Mchid: core.String(w.mchID), }) if err != nil { - return QueryResult{}, err + return PayResult{}, err } return fromTransaction(t), nil } // VerifyCallback 验签 + 解密支付回调(微信支付公钥验签 + APIv3 密钥 AES-GCM 解密资源)。 // 验签失败一律拒绝——回调路由是公开的,签名是唯一的门。 -func (w *Wechat) VerifyCallback(req *http.Request) (QueryResult, error) { +func (w *Wechat) VerifyCallback(req *http.Request) (PayResult, error) { h, err := notify.NewRSANotifyHandler(w.apiv3Key, verifiers.NewSHA256WithRSAPubkeyVerifier(w.pubKeyID, *w.pubKey)) if err != nil { - return QueryResult{}, fmt.Errorf("回调处理器初始化失败: %w", err) + return PayResult{}, fmt.Errorf("回调处理器初始化失败: %w", err) } txn := new(payments.Transaction) if _, err := h.ParseNotifyRequest(req.Context(), req, txn); err != nil { - return QueryResult{}, err + return PayResult{}, err } return fromTransaction(txn), nil } diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 6aa34ce..d8038f6 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -55,7 +55,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_id 寻址) api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址) api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx) - api.POST("/billing/callback/wechat", h.WechatCallback) // 支付回调(渠道服务器带不了 Bearer;APIv3 验签是唯一的门) + api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 Bearer;渠道验签是唯一的门) // —— 受保护:owner 作用域业务,必须携带有效 JWT —— p := api.Group("", middleware.RequireAuth())