初始提交:岩美 pay 收款服务(支付宝当面付 + 多商户多渠道架构)
- Go/Gin/GORM + 纯 Go SQLite(无 cgo) - Channel 多渠道接口:支付宝当面付(precreate)/电脑网站支付(page.pay) 已实现,微信占位 - 多商户 merchants 表,回调验签+金额核对+幂等+查单兜底 - 收款页/结果页/二维码端点;docs/ 设计文档与部署 Runbook - 密钥走环境变量/Bitwarden,不入库 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
)
|
||||
|
||||
type alipayChannel struct {
|
||||
client *alipay.Client
|
||||
}
|
||||
|
||||
func newAlipay(m *model.Merchant) (Channel, error) {
|
||||
if m.AppID == "" || m.AppPrivateKey == "" || m.AlipayPublicKey == "" {
|
||||
return nil, fmt.Errorf("商户 %s 的支付宝凭证不完整(app_id/app_private_key/alipay_public_key)", m.Code)
|
||||
}
|
||||
client, err := alipay.New(m.AppID, m.AppPrivateKey, m.Production)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("初始化支付宝客户端失败: %w", err)
|
||||
}
|
||||
// 公钥模式(沙箱常用)。若改用证书模式,这里换成 LoadAppCertPublicKey 等。
|
||||
if err := client.LoadAliPayPublicKey(m.AlipayPublicKey); err != nil {
|
||||
return nil, fmt.Errorf("加载支付宝公钥失败: %w", err)
|
||||
}
|
||||
return &alipayChannel{client: client}, nil
|
||||
}
|
||||
|
||||
func (a *alipayChannel) Name() string { return "alipay" }
|
||||
|
||||
func (a *alipayChannel) PagePay(_ context.Context, req CreateReq) (string, error) {
|
||||
var p = alipay.TradePagePay{}
|
||||
p.OutTradeNo = req.OutTradeNo
|
||||
p.Subject = req.Subject
|
||||
p.TotalAmount = req.Amount
|
||||
p.ProductCode = "FAST_INSTANT_TRADE_PAY"
|
||||
p.NotifyURL = req.NotifyURL
|
||||
p.ReturnURL = req.ReturnURL
|
||||
u, err := a.client.TradePagePay(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("支付宝下单失败: %w", err)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (a *alipayChannel) PreCreate(ctx context.Context, req CreateReq) (string, error) {
|
||||
var p = alipay.TradePreCreate{}
|
||||
p.OutTradeNo = req.OutTradeNo
|
||||
p.Subject = req.Subject
|
||||
p.TotalAmount = req.Amount
|
||||
p.NotifyURL = req.NotifyURL
|
||||
rsp, err := a.client.TradePreCreate(ctx, p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("支付宝预下单调用失败: %w", err)
|
||||
}
|
||||
if rsp.IsFailure() {
|
||||
return "", fmt.Errorf("支付宝预下单失败: %s / %s", rsp.Msg, rsp.SubMsg)
|
||||
}
|
||||
return rsp.QRCode, nil
|
||||
}
|
||||
|
||||
func (a *alipayChannel) VerifyNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return nil, fmt.Errorf("解析回调表单失败: %w", err)
|
||||
}
|
||||
// DecodeNotification 内部用已加载的支付宝公钥验签,验签不过会返回错误。
|
||||
noti, err := a.client.DecodeNotification(ctx, r.Form)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("回调验签失败: %w", err)
|
||||
}
|
||||
paid := noti.TradeStatus == alipay.TradeStatusSuccess || noti.TradeStatus == alipay.TradeStatusFinished
|
||||
return &NotifyResult{
|
||||
OutTradeNo: noti.OutTradeNo,
|
||||
TradeNo: noti.TradeNo,
|
||||
Amount: noti.TotalAmount,
|
||||
BuyerLogonID: noti.BuyerLogonId,
|
||||
Paid: paid,
|
||||
Raw: r.Form.Encode(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *alipayChannel) Query(ctx context.Context, outTradeNo string) (*QueryResult, error) {
|
||||
var p = alipay.TradeQuery{OutTradeNo: outTradeNo}
|
||||
rsp, err := a.client.TradeQuery(ctx, p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("支付宝查单调用失败: %w", err)
|
||||
}
|
||||
if rsp.IsFailure() {
|
||||
// 交易不存在(TRADE_NOT_EXIST)等:视为未找到,不报错,交由上层决定。
|
||||
return &QueryResult{Found: false}, nil
|
||||
}
|
||||
paid := rsp.TradeStatus == alipay.TradeStatusSuccess || rsp.TradeStatus == alipay.TradeStatusFinished
|
||||
return &QueryResult{
|
||||
Found: true,
|
||||
OutTradeNo: rsp.OutTradeNo,
|
||||
TradeNo: rsp.TradeNo,
|
||||
Amount: rsp.TotalAmount,
|
||||
Paid: paid,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package channel 把不同支付渠道(支付宝/微信…)收口到统一接口。
|
||||
// 上层业务只面向 Channel,新增渠道 = 加一个实现 + 一行注册,主流程不动。
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
)
|
||||
|
||||
// CreateReq 统一下单参数。
|
||||
type CreateReq struct {
|
||||
OutTradeNo string
|
||||
Subject string
|
||||
Amount string // 元,两位小数
|
||||
ReturnURL string
|
||||
NotifyURL string
|
||||
}
|
||||
|
||||
// NotifyResult 异步通知验签解析后的统一结果。
|
||||
type NotifyResult struct {
|
||||
OutTradeNo string
|
||||
TradeNo string
|
||||
Amount string
|
||||
BuyerLogonID string
|
||||
Paid bool // 是否为支付成功状态
|
||||
Raw string
|
||||
}
|
||||
|
||||
// QueryResult 主动查单的统一结果。
|
||||
type QueryResult struct {
|
||||
Found bool
|
||||
OutTradeNo string
|
||||
TradeNo string
|
||||
Amount string
|
||||
Paid bool
|
||||
}
|
||||
|
||||
// Channel 支付渠道统一接口。
|
||||
type Channel interface {
|
||||
Name() string
|
||||
// PagePay 统一下单,返回收银台跳转 URL。
|
||||
PagePay(ctx context.Context, req CreateReq) (payURL string, err error)
|
||||
// PreCreate 扫码(当面付)预下单,返回二维码码串,由前端渲染成二维码供客户扫码付款。
|
||||
PreCreate(ctx context.Context, req CreateReq) (qrCode string, err error)
|
||||
// VerifyNotify 验签并解析异步通知(内部完成 ParseForm 与签名校验)。
|
||||
VerifyNotify(ctx context.Context, r *http.Request) (*NotifyResult, error)
|
||||
// Query 主动查单。
|
||||
Query(ctx context.Context, outTradeNo string) (*QueryResult, error)
|
||||
}
|
||||
|
||||
// Build 按商户配置构造渠道实例。
|
||||
func Build(m *model.Merchant) (Channel, error) {
|
||||
switch m.Channel {
|
||||
case "alipay":
|
||||
return newAlipay(m)
|
||||
case "wechat":
|
||||
return newWechat(m)
|
||||
default:
|
||||
return nil, fmt.Errorf("未知支付渠道: %q", m.Channel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
)
|
||||
|
||||
// Registry 按商户缓存已构造的渠道实例,避免每次下单都重新加载密钥。
|
||||
type Registry struct {
|
||||
db *gorm.DB
|
||||
mu sync.RWMutex
|
||||
cache map[uint64]Channel
|
||||
}
|
||||
|
||||
func NewRegistry(db *gorm.DB) *Registry {
|
||||
return &Registry{db: db, cache: make(map[uint64]Channel)}
|
||||
}
|
||||
|
||||
// ByMerchantID 取(或构造)指定商户的渠道。
|
||||
func (r *Registry) ByMerchantID(id uint64) (Channel, *model.Merchant, error) {
|
||||
var m model.Merchant
|
||||
if err := r.db.First(&m, "id = ? AND enabled = ?", id, true).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("商户不存在或已停用: %w", err)
|
||||
}
|
||||
ch, err := r.get(&m)
|
||||
return ch, &m, err
|
||||
}
|
||||
|
||||
// AlipayByAppID 异步回调时用 app_id 反查商户并取其渠道(用于验签)。
|
||||
func (r *Registry) AlipayByAppID(appID string) (Channel, *model.Merchant, error) {
|
||||
if appID == "" {
|
||||
return nil, nil, fmt.Errorf("回调缺少 app_id")
|
||||
}
|
||||
var m model.Merchant
|
||||
if err := r.db.First(&m, "channel = ? AND app_id = ? AND enabled = ?", "alipay", appID, true).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("找不到 app_id=%s 对应的支付宝商户: %w", appID, err)
|
||||
}
|
||||
ch, err := r.get(&m)
|
||||
return ch, &m, err
|
||||
}
|
||||
|
||||
func (r *Registry) get(m *model.Merchant) (Channel, error) {
|
||||
r.mu.RLock()
|
||||
if ch, ok := r.cache[m.ID]; ok {
|
||||
r.mu.RUnlock()
|
||||
return ch, nil
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
ch, err := Build(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.cache[m.ID] = ch
|
||||
r.mu.Unlock()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Invalidate 商户凭证变更后清缓存(预留给管理接口)。
|
||||
func (r *Registry) Invalidate(id uint64) {
|
||||
r.mu.Lock()
|
||||
delete(r.cache, id)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
)
|
||||
|
||||
// ErrNotImplemented 渠道尚未实现。
|
||||
var ErrNotImplemented = errors.New("微信支付渠道尚未实现:微信无可用沙箱,待真实商户号下来后补 V3 实现")
|
||||
|
||||
// wechatChannel 占位实现。接口已就绪,补齐 V3 下单/回调/查单即可启用。
|
||||
type wechatChannel struct{ m *model.Merchant }
|
||||
|
||||
func newWechat(m *model.Merchant) (Channel, error) { return &wechatChannel{m: m}, nil }
|
||||
|
||||
func (w *wechatChannel) Name() string { return "wechat" }
|
||||
|
||||
func (w *wechatChannel) PagePay(context.Context, CreateReq) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
}
|
||||
func (w *wechatChannel) PreCreate(context.Context, CreateReq) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
}
|
||||
func (w *wechatChannel) VerifyNotify(context.Context, *http.Request) (*NotifyResult, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (w *wechatChannel) Query(context.Context, string) (*QueryResult, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
Reference in New Issue
Block a user