初始提交:岩美 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:
wangjia
2026-06-25 07:21:48 +08:00
commit 6da6964451
29 changed files with 2205 additions and 0 deletions
+103
View File
@@ -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
}