refactor(payment): 支付插件 - 宿主仅通过 RPC 包加载并保护订单版本
This commit is contained in:
1 parent
d6bbdbe27b
commit
38e715ea3f
45 files changed
+1584
-203
No files matched your search
@@ -1,406 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultAlipayGateway = "https://openapi.alipay.com/gateway.do"
|
||||
|
||||
type AlipayProvider struct {
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewAlipayProvider(client *http.Client) *AlipayProvider {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &AlipayProvider{client: client, now: time.Now}
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) Descriptor() Descriptor {
|
||||
return Descriptor{ID: ProviderAlipayPage, PluginID: PluginAlipayPage, Name: "支付宝电脑网站支付", Icon: "brand:alipay", CheckoutMode: "redirect"}
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) ValidateConfig(config Config) error {
|
||||
for _, key := range []string{"appId", "sellerId", "merchantPrivateKey", "alipayPublicKey"} {
|
||||
if strings.TrimSpace(config[key]) == "" {
|
||||
return fmt.Errorf("支付宝配置缺少 %s", key)
|
||||
}
|
||||
}
|
||||
if _, err := parseRSAPrivateKey(config["merchantPrivateKey"]); err != nil {
|
||||
return fmt.Errorf("支付宝应用私钥无效:%w", err)
|
||||
}
|
||||
if _, err := parseRSAPublicKey(config["alipayPublicKey"]); err != nil {
|
||||
return fmt.Errorf("支付宝公钥无效:%w", err)
|
||||
}
|
||||
gateway := strings.TrimSpace(config["gateway"])
|
||||
if gateway == "" {
|
||||
gateway = defaultAlipayGateway
|
||||
}
|
||||
parsed, err := url.Parse(gateway)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || parsed.Path != "/gateway.do" || (parsed.Port() != "" && parsed.Port() != "443") {
|
||||
return errors.New("支付宝网关必须是官方 HTTPS gateway.do 地址")
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if host != "openapi.alipay.com" && host != "openapi-sandbox.dl.alipaydev.com" {
|
||||
return errors.New("支付宝网关仅支持官方生产或沙箱域名")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) CreateOrder(_ context.Context, config Config, request CreateRequest) (Checkout, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Checkout{}, err
|
||||
}
|
||||
if request.AmountFen <= 0 || request.Currency != "CNY" || request.MerchantOrderNo == "" || request.NotifyURL == "" {
|
||||
return Checkout{}, errors.New("支付宝电脑网站支付下单参数无效")
|
||||
}
|
||||
remaining := request.ExpiresAt.Sub(p.now())
|
||||
timeoutMinutes := int((remaining + time.Minute - 1) / time.Minute)
|
||||
if remaining <= 0 || timeoutMinutes < 1 {
|
||||
timeoutMinutes = 1
|
||||
}
|
||||
bizContent, err := json.Marshal(map[string]any{
|
||||
"out_trade_no": request.MerchantOrderNo,
|
||||
"total_amount": formatFen(request.AmountFen),
|
||||
"subject": truncateUTF8(request.Description, 256),
|
||||
"product_code": "FAST_INSTANT_TRADE_PAY",
|
||||
"timeout_express": fmt.Sprintf("%dm", timeoutMinutes),
|
||||
})
|
||||
if err != nil {
|
||||
return Checkout{}, err
|
||||
}
|
||||
params := p.commonParams(config, "alipay.trade.page.pay")
|
||||
params.Set("notify_url", request.NotifyURL)
|
||||
if request.ReturnURL != "" {
|
||||
params.Set("return_url", request.ReturnURL)
|
||||
}
|
||||
params.Set("biz_content", string(bizContent))
|
||||
if err := signAlipayParams(config, params); err != nil {
|
||||
return Checkout{}, err
|
||||
}
|
||||
gateway := alipayGateway(config)
|
||||
separator := "?"
|
||||
if strings.Contains(gateway, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return Checkout{Mode: "redirect", Value: gateway + separator + params.Encode(), ExpiresAt: request.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
response, err := p.call(ctx, config, "alipay.trade.query", map[string]any{"out_trade_no": request.MerchantOrderNo})
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return alipayResult(response), nil
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
response, err := p.call(ctx, config, "alipay.trade.close", map[string]any{"out_trade_no": request.MerchantOrderNo})
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result := alipayResult(response)
|
||||
result.MerchantOrderNo = request.MerchantOrderNo
|
||||
result.ProviderStatus = "CLOSED"
|
||||
result.Closed = true
|
||||
result.Currency = "CNY"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) VerifyNotification(_ context.Context, config Config, _ http.Header, rawBody []byte) (Notification, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Notification{}, err
|
||||
}
|
||||
values, err := url.ParseQuery(string(rawBody))
|
||||
if err != nil {
|
||||
return Notification{}, fmt.Errorf("解析支付宝异步通知:%w", err)
|
||||
}
|
||||
signature := values.Get("sign")
|
||||
if signature == "" || values.Get("sign_type") != "RSA2" {
|
||||
return Notification{}, errors.New("支付宝异步通知签名参数缺失")
|
||||
}
|
||||
verifyValues := cloneURLValues(values)
|
||||
verifyValues.Del("sign")
|
||||
verifyValues.Del("sign_type")
|
||||
publicKey, err := parseRSAPublicKey(config["alipayPublicKey"])
|
||||
if err != nil {
|
||||
return Notification{}, err
|
||||
}
|
||||
if err := rsaSHA256Verify(publicKey, []byte(canonicalAlipayParams(verifyValues)), signature); err != nil {
|
||||
return Notification{}, err
|
||||
}
|
||||
status := values.Get("trade_status")
|
||||
if values.Get("app_id") != config["appId"] || values.Get("seller_id") != config["sellerId"] || (status != "TRADE_SUCCESS" && status != "TRADE_FINISHED") {
|
||||
return Notification{}, errors.New("支付宝异步通知商户身份或交易状态不匹配")
|
||||
}
|
||||
amount, err := parseYuanToFen(values.Get("total_amount"))
|
||||
if err != nil {
|
||||
return Notification{}, errors.New("支付宝异步通知金额无效")
|
||||
}
|
||||
paidAt, _ := time.ParseInLocation("2006-01-02 15:04:05", values.Get("gmt_payment"), time.FixedZone("CST", 8*60*60))
|
||||
eventID := strings.TrimSpace(values.Get("notify_id"))
|
||||
if eventID == "" {
|
||||
eventID = values.Get("trade_no") + ":" + status + ":" + values.Get("gmt_payment")
|
||||
}
|
||||
return Notification{EventID: eventID, Result: Result{
|
||||
MerchantOrderNo: values.Get("out_trade_no"), ProviderTradeNo: values.Get("trade_no"), ProviderStatus: status,
|
||||
AmountFen: amount, Currency: "CNY", Paid: true, PaidAt: paidAt,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) DownloadTradeBill(ctx context.Context, config Config, billDate time.Time) ([]BillRecord, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bill struct {
|
||||
BillDownloadURL string `json:"bill_download_url"`
|
||||
}
|
||||
if err := p.callJSON(ctx, config, "alipay.data.dataservice.bill.downloadurl.query", map[string]any{
|
||||
"bill_type": "trade", "bill_date": billDate.Format("2006-01-02"),
|
||||
}, &bill); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alipayHTTPBillHosts := []string{"dwbillcenter.alipay.com", "dwbillcenter.alipaydev.com"}
|
||||
target, err := trustedDownloadURLWithPolicy(bill.BillDownloadURL, alipayGateway(config), alipayHTTPBillHosts, "alipay.com", "alipayobjects.com", "alipaydev.com")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := clientWithTrustedRedirectPolicy(p.client, alipayGateway(config), alipayHTTPBillHosts, "alipay.com", "alipayobjects.com", "alipaydev.com").Do(request)
|
||||
if err != nil {
|
||||
return nil, &ProviderError{Code: "alipay_bill_download_error", Message: "下载支付宝交易账单失败", Temporary: true, Cause: err}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.Request == nil {
|
||||
return nil, errors.New("支付宝交易账单下载响应无效")
|
||||
}
|
||||
if _, err := trustedDownloadURLWithPolicy(response.Request.URL.String(), alipayGateway(config), alipayHTTPBillHosts, "alipay.com", "alipayobjects.com", "alipaydev.com"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, &ProviderError{Code: "alipay_bill_http_error", Message: "支付宝交易账单下载失败", Temporary: response.StatusCode >= 500}
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, maxTradeBillBytes+1))
|
||||
if err != nil {
|
||||
return nil, &ProviderError{Code: "alipay_bill_read_error", Message: "读取支付宝交易账单失败", Temporary: true, Cause: err}
|
||||
}
|
||||
if len(raw) > maxTradeBillBytes {
|
||||
return nil, errors.New("支付宝交易账单超过安全限制")
|
||||
}
|
||||
return parseAlipayTradeBill(raw)
|
||||
}
|
||||
|
||||
type alipayTradeResponse struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
SubCode string `json:"sub_code"`
|
||||
SubMsg string `json:"sub_msg"`
|
||||
OutTradeNo string `json:"out_trade_no"`
|
||||
TradeNo string `json:"trade_no"`
|
||||
TradeStatus string `json:"trade_status"`
|
||||
TotalAmount string `json:"total_amount"`
|
||||
SendPayDate string `json:"send_pay_date"`
|
||||
ReceiptAmount string `json:"receipt_amount"`
|
||||
}
|
||||
|
||||
func alipayResult(response alipayTradeResponse) Result {
|
||||
amount, _ := parseYuanToFen(response.TotalAmount)
|
||||
paidAt, _ := time.ParseInLocation("2006-01-02 15:04:05", response.SendPayDate, time.FixedZone("CST", 8*60*60))
|
||||
paid := response.TradeStatus == "TRADE_SUCCESS" || response.TradeStatus == "TRADE_FINISHED"
|
||||
return Result{
|
||||
MerchantOrderNo: response.OutTradeNo, ProviderTradeNo: response.TradeNo, ProviderStatus: response.TradeStatus,
|
||||
AmountFen: amount, Currency: "CNY", Paid: paid, Closed: response.TradeStatus == "TRADE_CLOSED", PaidAt: paidAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) call(ctx context.Context, config Config, method string, biz any) (alipayTradeResponse, error) {
|
||||
var tradeResponse alipayTradeResponse
|
||||
if err := p.callJSON(ctx, config, method, biz, &tradeResponse); err != nil {
|
||||
return alipayTradeResponse{}, err
|
||||
}
|
||||
return tradeResponse, nil
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) callJSON(ctx context.Context, config Config, method string, biz any, output any) error {
|
||||
bizContent, err := json.Marshal(biz)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := p.commonParams(config, method)
|
||||
params.Set("biz_content", string(bizContent))
|
||||
if err := signAlipayParams(config, params); err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, alipayGateway(config), strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
|
||||
response, err := p.client.Do(request)
|
||||
if err != nil {
|
||||
return &ProviderError{Code: "alipay_transport_error", Message: "支付宝网络请求失败", Temporary: true, Cause: err}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
||||
if err != nil {
|
||||
return &ProviderError{Code: "alipay_response_read_error", Message: "读取支付宝响应失败", Temporary: true, Cause: err}
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return &ProviderError{Code: "alipay_http_error", Message: "支付宝接口返回失败", Temporary: response.StatusCode >= 500}
|
||||
}
|
||||
responseKey := strings.ReplaceAll(method, ".", "_") + "_response"
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return fmt.Errorf("解析支付宝响应:%w", err)
|
||||
}
|
||||
responseRaw := envelope[responseKey]
|
||||
if len(responseRaw) == 0 {
|
||||
return errors.New("支付宝响应缺少业务节点")
|
||||
}
|
||||
var signature string
|
||||
if err := json.Unmarshal(envelope["sign"], &signature); err != nil || signature == "" {
|
||||
return errors.New("支付宝响应缺少签名")
|
||||
}
|
||||
publicKey, err := parseRSAPublicKey(config["alipayPublicKey"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rsaSHA256Verify(publicKey, responseRaw, signature); err != nil {
|
||||
return fmt.Errorf("支付宝响应验签失败:%w", err)
|
||||
}
|
||||
var status struct {
|
||||
Code string `json:"code"`
|
||||
SubCode string `json:"sub_code"`
|
||||
}
|
||||
if err := json.Unmarshal(responseRaw, &status); err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Code != "10000" {
|
||||
if status.SubCode == "ACQ.TRADE_NOT_EXIST" {
|
||||
return fmt.Errorf("%w: %s", ErrOrderNotFound, status.SubCode)
|
||||
}
|
||||
if status.SubCode == "BILL_NOT_EXIST" {
|
||||
return fmt.Errorf("%w: %s", ErrTradeBillNotFound, status.SubCode)
|
||||
}
|
||||
return &ProviderError{Code: status.SubCode, Message: "支付宝业务请求失败", Temporary: false}
|
||||
}
|
||||
if output == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(responseRaw, output)
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) commonParams(config Config, method string) url.Values {
|
||||
return url.Values{
|
||||
"app_id": {config["appId"]},
|
||||
"method": {method},
|
||||
"format": {"JSON"},
|
||||
"charset": {"utf-8"},
|
||||
"sign_type": {"RSA2"},
|
||||
"timestamp": {p.now().In(time.FixedZone("CST", 8*60*60)).Format("2006-01-02 15:04:05")},
|
||||
"version": {"1.0"},
|
||||
}
|
||||
}
|
||||
|
||||
func signAlipayParams(config Config, params url.Values) error {
|
||||
privateKey, err := parseRSAPrivateKey(config["merchantPrivateKey"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signature, err := rsaSHA256Sign(privateKey, []byte(canonicalAlipayParams(params)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params.Set("sign", signature)
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalAlipayParams(params url.Values) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for key, values := range params {
|
||||
if key != "sign" && len(values) > 0 && values[0] != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+params.Get(key))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func cloneURLValues(input url.Values) url.Values {
|
||||
result := make(url.Values, len(input))
|
||||
for key, values := range input {
|
||||
result[key] = append([]string(nil), values...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func alipayGateway(config Config) string {
|
||||
if value := strings.TrimSpace(config["gateway"]); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultAlipayGateway
|
||||
}
|
||||
|
||||
func formatFen(value int64) string {
|
||||
return fmt.Sprintf("%d.%02d", value/100, value%100)
|
||||
}
|
||||
|
||||
func parseYuanToFen(value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.HasPrefix(value, "-") {
|
||||
return 0, errors.New("invalid amount")
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 {
|
||||
return 0, errors.New("invalid amount")
|
||||
}
|
||||
yuan, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || yuan > (1<<63-1)/100 {
|
||||
return 0, errors.New("invalid amount")
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
}
|
||||
if len(fraction) > 2 {
|
||||
return 0, errors.New("invalid amount precision")
|
||||
}
|
||||
fraction += strings.Repeat("0", 2-len(fraction))
|
||||
fen := int64(0)
|
||||
if fraction != "" {
|
||||
fen, err = strconv.ParseInt(fraction, 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("invalid amount")
|
||||
}
|
||||
}
|
||||
const maxInt64 = int64(1<<63 - 1)
|
||||
if yuan > (maxInt64-fen)/100 {
|
||||
return 0, errors.New("invalid amount")
|
||||
}
|
||||
return yuan*100 + fen, nil
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTradeBillBytes = 64 << 20
|
||||
maxTradeBillRecords = 500_000
|
||||
)
|
||||
|
||||
func parseWeChatTradeBill(raw []byte) ([]BillRecord, error) {
|
||||
rows, err := readBillCSV(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析微信交易账单:%w", err)
|
||||
}
|
||||
headerIndex, columns := findBillHeader(rows, []string{"商户订单号", "微信订单号", "交易状态"})
|
||||
if headerIndex < 0 {
|
||||
return nil, errors.New("微信交易账单缺少明细表头")
|
||||
}
|
||||
amountColumn := firstBillColumn(columns, "订单金额", "总金额", "应结订单金额")
|
||||
if amountColumn < 0 {
|
||||
return nil, errors.New("微信交易账单缺少订单金额")
|
||||
}
|
||||
records := make([]BillRecord, 0, len(rows)-headerIndex-1)
|
||||
for _, row := range rows[headerIndex+1:] {
|
||||
if len(records) >= maxTradeBillRecords {
|
||||
return nil, errors.New("微信交易账单记录数超过安全限制")
|
||||
}
|
||||
status := billCell(row, columns["交易状态"])
|
||||
if status != "SUCCESS" {
|
||||
continue
|
||||
}
|
||||
merchantOrderNo := billCell(row, columns["商户订单号"])
|
||||
providerTradeNo := billCell(row, columns["微信订单号"])
|
||||
if merchantOrderNo == "" || providerTradeNo == "" {
|
||||
continue
|
||||
}
|
||||
amountFen, err := parseYuanToFen(billCell(row, amountColumn))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("微信订单 %s 金额无效", merchantOrderNo)
|
||||
}
|
||||
currency := "CNY"
|
||||
if column := firstBillColumn(columns, "货币种类"); column >= 0 {
|
||||
if value := billCell(row, column); value != "" {
|
||||
currency = value
|
||||
}
|
||||
}
|
||||
paidAt := parseBillTime(billCell(row, firstBillColumn(columns, "交易时间")))
|
||||
records = append(records, BillRecord{
|
||||
MerchantOrderNo: merchantOrderNo, ProviderTradeNo: providerTradeNo,
|
||||
ProviderStatus: status, AmountFen: amountFen, Currency: currency, PaidAt: paidAt,
|
||||
})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func parseAlipayTradeBill(raw []byte) ([]BillRecord, error) {
|
||||
files, err := alipayBillFiles(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recordsByOrder := make(map[string]BillRecord)
|
||||
foundHeader := false
|
||||
for _, file := range files {
|
||||
rows, err := readBillCSV(decodeAlipayBill(file))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
headerIndex, columns := findBillHeader(rows, []string{"商户订单号", "支付宝交易号"})
|
||||
if headerIndex < 0 {
|
||||
continue
|
||||
}
|
||||
foundHeader = true
|
||||
amountColumn := firstBillColumn(columns, "订单金额(元)", "订单金额(元)", "订单金额")
|
||||
if amountColumn < 0 {
|
||||
continue
|
||||
}
|
||||
businessColumn := firstBillColumn(columns, "业务类型", "交易状态")
|
||||
paidAtColumn := firstBillColumn(columns, "完成时间", "交易付款时间", "交易时间")
|
||||
for _, row := range rows[headerIndex+1:] {
|
||||
if len(recordsByOrder) >= maxTradeBillRecords {
|
||||
return nil, errors.New("支付宝交易账单记录数超过安全限制")
|
||||
}
|
||||
business := billCell(row, businessColumn)
|
||||
if strings.Contains(business, "退款") || strings.Contains(business, "撤销") || strings.Contains(business, "关闭") {
|
||||
continue
|
||||
}
|
||||
merchantOrderNo := billCell(row, columns["商户订单号"])
|
||||
providerTradeNo := billCell(row, columns["支付宝交易号"])
|
||||
if merchantOrderNo == "" || providerTradeNo == "" {
|
||||
continue
|
||||
}
|
||||
amountFen, err := parseYuanToFen(billCell(row, amountColumn))
|
||||
if err != nil || amountFen <= 0 {
|
||||
continue
|
||||
}
|
||||
recordsByOrder[merchantOrderNo] = BillRecord{
|
||||
MerchantOrderNo: merchantOrderNo, ProviderTradeNo: providerTradeNo,
|
||||
ProviderStatus: "SUCCESS", AmountFen: amountFen, Currency: "CNY",
|
||||
PaidAt: parseBillTime(billCell(row, paidAtColumn)),
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundHeader {
|
||||
return nil, errors.New("支付宝交易账单缺少业务明细表头")
|
||||
}
|
||||
records := make([]BillRecord, 0, len(recordsByOrder))
|
||||
for _, record := range recordsByOrder {
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func alipayBillFiles(raw []byte) ([][]byte, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
// A few sandbox environments return an uncompressed CSV. Supporting it
|
||||
// also makes the parser tolerant without weakening archive limits.
|
||||
return [][]byte{raw}, nil
|
||||
}
|
||||
files := make([][]byte, 0, len(reader.File))
|
||||
var total int64
|
||||
for _, file := range reader.File {
|
||||
if file.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(file.Name), ".csv") {
|
||||
continue
|
||||
}
|
||||
if file.UncompressedSize64 > maxTradeBillBytes || total+int64(file.UncompressedSize64) > maxTradeBillBytes {
|
||||
return nil, errors.New("支付宝账单压缩包超过安全限制")
|
||||
}
|
||||
stream, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(stream, maxTradeBillBytes+1))
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, closeErr
|
||||
}
|
||||
if len(data) > maxTradeBillBytes {
|
||||
return nil, errors.New("支付宝账单文件超过安全限制")
|
||||
}
|
||||
total += int64(len(data))
|
||||
files = append(files, data)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, errors.New("支付宝账单压缩包不包含 CSV")
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func decodeAlipayBill(raw []byte) []byte {
|
||||
if utf8.Valid(raw) {
|
||||
return raw
|
||||
}
|
||||
decoded, err := simplifiedchinese.GBK.NewDecoder().Bytes(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func readBillCSV(raw []byte) ([][]string, error) {
|
||||
reader := csv.NewReader(bytes.NewReader(raw))
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.LazyQuotes = true
|
||||
reader.TrimLeadingSpace = true
|
||||
return reader.ReadAll()
|
||||
}
|
||||
|
||||
func findBillHeader(rows [][]string, required []string) (int, map[string]int) {
|
||||
for rowIndex, row := range rows {
|
||||
columns := make(map[string]int, len(row))
|
||||
for columnIndex, value := range row {
|
||||
columns[normalizeBillCell(value)] = columnIndex
|
||||
}
|
||||
valid := true
|
||||
for _, field := range required {
|
||||
if _, ok := columns[field]; !ok {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
return rowIndex, columns
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func firstBillColumn(columns map[string]int, names ...string) int {
|
||||
for _, name := range names {
|
||||
if column, ok := columns[name]; ok {
|
||||
return column
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func billCell(row []string, column int) string {
|
||||
if column < 0 || column >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return normalizeBillCell(row[column])
|
||||
}
|
||||
|
||||
func normalizeBillCell(value string) string {
|
||||
value = strings.TrimSpace(strings.TrimPrefix(value, "\ufeff"))
|
||||
value = strings.TrimSpace(strings.TrimPrefix(value, "`"))
|
||||
value = strings.TrimSpace(strings.TrimPrefix(value, "'"))
|
||||
return value
|
||||
}
|
||||
|
||||
func parseBillTime(value string) time.Time {
|
||||
location := time.FixedZone("CST", 8*60*60)
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
|
||||
if parsed, err := time.ParseInLocation(layout, strings.TrimSpace(value), location); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func trustedDownloadURL(raw, baseURL string, allowedSuffixes ...string) (*url.URL, error) {
|
||||
return trustedDownloadURLWithPolicy(raw, baseURL, nil, allowedSuffixes...)
|
||||
}
|
||||
|
||||
func trustedDownloadURLWithPolicy(raw, baseURL string, allowedHTTPHosts []string, allowedSuffixes ...string) (*url.URL, error) {
|
||||
target, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || target.Host == "" || target.User != nil || target.Fragment != "" {
|
||||
return nil, errors.New("支付渠道返回了无效账单下载地址")
|
||||
}
|
||||
host := strings.ToLower(target.Hostname())
|
||||
base, _ := url.Parse(baseURL)
|
||||
baseMatch := base != nil && strings.EqualFold(target.Host, base.Host)
|
||||
suffixMatch := false
|
||||
for _, suffix := range allowedSuffixes {
|
||||
suffix = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(suffix), "."))
|
||||
if host == suffix || strings.HasSuffix(host, "."+suffix) {
|
||||
suffixMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !baseMatch && !suffixMatch {
|
||||
return nil, errors.New("支付渠道账单下载地址不在可信域名内")
|
||||
}
|
||||
if target.Scheme == "http" {
|
||||
if baseMatch && base != nil && base.Scheme == "http" {
|
||||
return target, nil
|
||||
}
|
||||
allowed := false
|
||||
for _, allowedHost := range allowedHTTPHosts {
|
||||
if strings.EqualFold(host, strings.TrimSpace(allowedHost)) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed || (target.Port() != "" && target.Port() != "80") {
|
||||
return nil, errors.New("支付渠道账单下载地址必须使用 HTTPS")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
if target.Scheme != "https" {
|
||||
return nil, errors.New("支付渠道账单下载地址必须使用 HTTPS")
|
||||
}
|
||||
if target.Port() != "" && target.Port() != "443" {
|
||||
return nil, errors.New("支付渠道账单下载地址使用了非标准端口")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// clientWithTrustedRedirects validates every redirect before it is followed.
|
||||
// Validating only response.Request would be too late because an HTTP client may
|
||||
// already have contacted an attacker-controlled or internal redirect target.
|
||||
func clientWithTrustedRedirects(client *http.Client, baseURL string, allowedSuffixes ...string) *http.Client {
|
||||
return clientWithTrustedRedirectPolicy(client, baseURL, nil, allowedSuffixes...)
|
||||
}
|
||||
|
||||
func clientWithTrustedRedirectPolicy(client *http.Client, baseURL string, allowedHTTPHosts []string, allowedSuffixes ...string) *http.Client {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
copyClient := *client
|
||||
previous := client.CheckRedirect
|
||||
copyClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return errors.New("支付渠道账单下载重定向过多")
|
||||
}
|
||||
if _, err := trustedDownloadURLWithPolicy(request.URL.String(), baseURL, allowedHTTPHosts, allowedSuffixes...); err != nil {
|
||||
return err
|
||||
}
|
||||
if previous != nil {
|
||||
return previous(request, via)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return ©Client
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseWeChatSuccessTradeBill(t *testing.T) {
|
||||
raw := []byte("`交易时间,`微信订单号,`商户订单号,`交易状态,`货币种类,`订单金额\n" +
|
||||
"`2026-09-01 12:00:00,`420000001,`0123456789abcdef0123456789abcdef,`SUCCESS,`CNY,`1.23\n" +
|
||||
"`总交易单数,`1\n")
|
||||
records, err := parseWeChatTradeBill(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 1 || records[0].MerchantOrderNo != "0123456789abcdef0123456789abcdef" || records[0].ProviderTradeNo != "420000001" || records[0].AmountFen != 123 {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
if records[0].PaidAt.IsZero() {
|
||||
t.Fatal("expected paid time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAlipayTradeBillArchiveSkipsRefundRows(t *testing.T) {
|
||||
var archive bytes.Buffer
|
||||
writer := zip.NewWriter(&archive)
|
||||
file, err := writer.Create("20260901_业务明细.csv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = file.Write([]byte("支付宝交易号,商户订单号,业务类型,完成时间,订单金额(元)\n" +
|
||||
"202609010001,0123456789abcdef0123456789abcdef,交易,2026-09-01 12:00:00,2.50\n" +
|
||||
"202609010001,0123456789abcdef0123456789abcdef,退款,2026-09-01 13:00:00,-2.50\n"))
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := parseAlipayTradeBill(archive.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 1 || records[0].ProviderTradeNo != "202609010001" || records[0].AmountFen != 250 {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedDownloadURLRejectsUnrelatedHosts(t *testing.T) {
|
||||
if _, err := trustedDownloadURL("https://evil.example/bill.csv", "https://openapi.alipay.com", "alipay.com"); err == nil {
|
||||
t.Fatal("expected untrusted host error")
|
||||
}
|
||||
if _, err := trustedDownloadURL("https://dwbillcenter.alipay.com/bill.zip", "https://openapi.alipay.com", "alipay.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := trustedDownloadURLWithPolicy("http://dwbillcenter.alipay.com/bill.zip", "https://openapi.alipay.com", []string{"dwbillcenter.alipay.com"}, "alipay.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := trustedDownloadURLWithPolicy("http://other.alipay.com/bill.zip", "https://openapi.alipay.com", []string{"dwbillcenter.alipay.com"}, "alipay.com"); err == nil {
|
||||
t.Fatal("expected non-bill-center HTTP URL error")
|
||||
}
|
||||
if _, err := trustedDownloadURL("https://api.mch.weixin.qq.com:8443/bill.csv", "https://api.mch.weixin.qq.com", "api.mch.weixin.qq.com"); err == nil {
|
||||
t.Fatal("expected non-standard port error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedDownloadRedirectIsRejectedBeforeFollow(t *testing.T) {
|
||||
client := clientWithTrustedRedirects(&http.Client{}, "https://openapi.alipay.com", "alipay.com")
|
||||
target, err := url.Parse("https://internal.invalid/bill.zip")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.CheckRedirect(&http.Request{URL: target}, nil); err == nil {
|
||||
t.Fatal("expected untrusted redirect error")
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseRSAPrivateKey(value string) (*rsa.PrivateKey, error) {
|
||||
der, ok := decodePEMOrBase64(value)
|
||||
if !ok {
|
||||
return nil, errors.New("RSA 私钥 PEM 或 Base64 格式无效")
|
||||
}
|
||||
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
|
||||
return rsaKey, nil
|
||||
}
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, errors.New("RSA 私钥必须为 PKCS#1 或 PKCS#8 格式")
|
||||
}
|
||||
|
||||
func parseRSAPublicKey(value string) (*rsa.PublicKey, error) {
|
||||
der, ok := decodePEMOrBase64(value)
|
||||
if !ok {
|
||||
return nil, errors.New("RSA 公钥 PEM 或 Base64 格式无效")
|
||||
}
|
||||
if key, err := x509.ParsePKIXPublicKey(der); err == nil {
|
||||
if rsaKey, ok := key.(*rsa.PublicKey); ok {
|
||||
return rsaKey, nil
|
||||
}
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PublicKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if certificate, err := x509.ParseCertificate(der); err == nil {
|
||||
if rsaKey, ok := certificate.PublicKey.(*rsa.PublicKey); ok {
|
||||
return rsaKey, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("RSA 公钥格式无效")
|
||||
}
|
||||
|
||||
func decodePEMOrBase64(value string) ([]byte, bool) {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if block, _ := pem.Decode([]byte(normalized)); block != nil {
|
||||
return block.Bytes, true
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(normalized), ""))
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return decoded, true
|
||||
}
|
||||
|
||||
func rsaSHA256Sign(privateKey *rsa.PrivateKey, content []byte) (string, error) {
|
||||
digest := sha256.Sum256(content)
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
func rsaSHA256Verify(publicKey *rsa.PublicKey, content []byte, signature string) error {
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signature))
|
||||
if err != nil {
|
||||
return fmt.Errorf("签名不是有效 Base64:%w", err)
|
||||
}
|
||||
digest := sha256.Sum256(content)
|
||||
if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], decoded); err != nil {
|
||||
return errors.New("RSA2 签名验证失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWeChatNativeCreateOrderSignsRequestAndReturnsQRCode(t *testing.T) {
|
||||
merchantPrivate, merchantPublic := testRSAKeyPair(t)
|
||||
platformPrivate, platformPublic := testRSAKeyPair(t)
|
||||
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
config := testWeChatConfig(merchantPrivate, platformPublic)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/v3/pay/transactions/native" {
|
||||
t.Fatalf("request = %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(request.Header.Get("Authorization"), `mchid="1900000001"`) {
|
||||
t.Fatalf("authorization = %q", request.Header.Get("Authorization"))
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["out_trade_no"] != "0123456789abcdef0123456789abcdef" {
|
||||
t.Fatalf("out_trade_no = %v", payload["out_trade_no"])
|
||||
}
|
||||
responseBody := []byte(`{"code_url":"weixin://wxpay/bizpayurl?pr=test"}`)
|
||||
timestamp, nonce := fixedNow.Unix(), "response-nonce"
|
||||
signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(timestamp, nonce, responseBody)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer.Header().Set("Wechatpay-Timestamp", "1788321600")
|
||||
writer.Header().Set("Wechatpay-Nonce", nonce)
|
||||
writer.Header().Set("Wechatpay-Serial", "PUB_KEY_ID_3000000001")
|
||||
writer.Header().Set("Wechatpay-Signature", signature)
|
||||
_, _ = writer.Write(responseBody)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewWeChatProvider(server.Client(), server.URL)
|
||||
provider.now = func() time.Time { return fixedNow }
|
||||
checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{
|
||||
MerchantOrderNo: "0123456789abcdef0123456789abcdef", Description: "100 积分",
|
||||
AmountFen: 100, Currency: "CNY", ExpiresAt: fixedNow.Add(30 * time.Minute), NotifyURL: "https://merchant.example/api/notify",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checkout.Mode != "qr_code" || !strings.HasPrefix(checkout.Value, "weixin://") || !checkout.ExpiresAt.Equal(fixedNow.Add(30*time.Minute)) {
|
||||
t.Fatalf("checkout = %#v", checkout)
|
||||
}
|
||||
_ = merchantPublic
|
||||
}
|
||||
|
||||
func TestWeChatNotificationVerifiesAndDecrypts(t *testing.T) {
|
||||
merchantPrivate, _ := testRSAKeyPair(t)
|
||||
platformPrivate, platformPublic := testRSAKeyPair(t)
|
||||
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
config := testWeChatConfig(merchantPrivate, platformPublic)
|
||||
transaction := []byte(`{"appid":"wx-app","mchid":"1900000001","out_trade_no":"0123456789abcdef0123456789abcdef","transaction_id":"420000001","trade_type":"NATIVE","trade_state":"SUCCESS","success_time":"2026-09-02T11:59:00+08:00","amount":{"total":100,"currency":"CNY"}}`)
|
||||
ciphertext := encryptWeChatTestResource(t, config["apiV3Key"], "0123456789ab", "transaction", transaction)
|
||||
envelope, err := json.Marshal(map[string]any{
|
||||
"id": "EV-1", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource",
|
||||
"resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": ciphertext, "associated_data": "transaction", "nonce": "0123456789ab"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := "notification-nonce"
|
||||
signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(fixedNow.Unix(), nonce, envelope)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := http.Header{
|
||||
"Wechatpay-Timestamp": {"1788321600"}, "Wechatpay-Nonce": {nonce},
|
||||
"Wechatpay-Serial": {"PUB_KEY_ID_3000000001"}, "Wechatpay-Signature": {signature},
|
||||
}
|
||||
provider := NewWeChatProvider(http.DefaultClient, "https://example.invalid")
|
||||
provider.now = func() time.Time { return fixedNow }
|
||||
notification, err := provider.VerifyNotification(context.Background(), config, headers, envelope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notification.EventID != "EV-1" || !notification.Paid || notification.AmountFen != 100 || notification.ProviderTradeNo != "420000001" {
|
||||
t.Fatalf("notification = %#v", notification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeChatMissingTradeBillUsesBillSentinel(t *testing.T) {
|
||||
merchantPrivate, _ := testRSAKeyPair(t)
|
||||
platformPrivate, platformPublic := testRSAKeyPair(t)
|
||||
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
config := testWeChatConfig(merchantPrivate, platformPublic)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body := []byte(`{"code":"NO_STATEMENT_EXIST","message":"not ready"}`)
|
||||
nonce := "bill-response-nonce"
|
||||
signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(fixedNow.Unix(), nonce, body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer.Header().Set("Wechatpay-Timestamp", strconv.FormatInt(fixedNow.Unix(), 10))
|
||||
writer.Header().Set("Wechatpay-Nonce", nonce)
|
||||
writer.Header().Set("Wechatpay-Serial", "PUB_KEY_ID_3000000001")
|
||||
writer.Header().Set("Wechatpay-Signature", signature)
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
_, _ = writer.Write(body)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewWeChatProvider(server.Client(), server.URL)
|
||||
provider.now = func() time.Time { return fixedNow }
|
||||
_, err := provider.DownloadTradeBill(context.Background(), config, fixedNow.AddDate(0, 0, -1))
|
||||
if !errors.Is(err, ErrTradeBillNotFound) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlipayPagePayBuildsSignedDesktopCheckout(t *testing.T) {
|
||||
merchantPrivate, merchantPublic := testRSAKeyPair(t)
|
||||
_, alipayPublic := testRSAKeyPair(t)
|
||||
config := testAlipayConfig(merchantPrivate, alipayPublic)
|
||||
provider := NewAlipayProvider(http.DefaultClient)
|
||||
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
provider.now = func() time.Time { return fixedNow.Add(time.Second) }
|
||||
checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{
|
||||
MerchantOrderNo: "0123456789abcdef0123456789abcdef", Description: "100 积分",
|
||||
AmountFen: 100, Currency: "CNY", ExpiresAt: fixedNow.Add(30 * time.Minute),
|
||||
NotifyURL: "https://merchant.example/api/notify", ReturnURL: "https://merchant.example/api/return",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(checkout.Value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := parsed.Query()
|
||||
signature := params.Get("sign")
|
||||
params.Del("sign")
|
||||
if err := rsaSHA256Verify(merchantPublic, []byte(canonicalAlipayParams(params)), signature); err != nil {
|
||||
t.Fatalf("checkout signature: %v", err)
|
||||
}
|
||||
if params.Get("method") != "alipay.trade.page.pay" || !strings.Contains(params.Get("biz_content"), `"product_code":"FAST_INSTANT_TRADE_PAY"`) || !strings.Contains(params.Get("biz_content"), `"timeout_express":"30m"`) {
|
||||
t.Fatalf("params = %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlipayRejectsNonOfficialGateway(t *testing.T) {
|
||||
merchantPrivate, _ := testRSAKeyPair(t)
|
||||
_, alipayPublic := testRSAKeyPair(t)
|
||||
config := testAlipayConfig(merchantPrivate, alipayPublic)
|
||||
config["gateway"] = "https://evil.example/gateway.do"
|
||||
if err := NewAlipayProvider(http.DefaultClient).ValidateConfig(config); err == nil {
|
||||
t.Fatal("expected non-official gateway error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlipayNotificationVerifiesMerchantAndAmount(t *testing.T) {
|
||||
merchantPrivate, _ := testRSAKeyPair(t)
|
||||
alipayPrivate, alipayPublic := testRSAKeyPair(t)
|
||||
config := testAlipayConfig(merchantPrivate, alipayPublic)
|
||||
values := url.Values{
|
||||
"notify_id": {"notify-1"}, "app_id": {"2026000000000001"}, "seller_id": {"2088000000000001"},
|
||||
"out_trade_no": {"0123456789abcdef0123456789abcdef"}, "trade_no": {"2026090200001"},
|
||||
"trade_status": {"TRADE_SUCCESS"}, "total_amount": {"1.00"}, "gmt_payment": {"2026-09-02 12:00:00"},
|
||||
"sign_type": {"RSA2"},
|
||||
}
|
||||
unsigned := cloneURLValues(values)
|
||||
unsigned.Del("sign_type")
|
||||
signature, err := rsaSHA256Sign(alipayPrivate, []byte(canonicalAlipayParams(unsigned)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values.Set("sign", signature)
|
||||
provider := NewAlipayProvider(http.DefaultClient)
|
||||
notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(values.Encode()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notification.EventID != "notify-1" || !notification.Paid || notification.AmountFen != 100 || notification.Currency != "CNY" {
|
||||
t.Fatalf("notification = %#v", notification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYuanToFenUsesExactDecimalArithmetic(t *testing.T) {
|
||||
for value, expected := range map[string]int64{"0.01": 1, "1": 100, "1.2": 120, "1000000.99": 100000099} {
|
||||
actual, err := parseYuanToFen(value)
|
||||
if err != nil || actual != expected {
|
||||
t.Fatalf("parseYuanToFen(%q) = %d, %v; want %d", value, actual, err, expected)
|
||||
}
|
||||
}
|
||||
if _, err := parseYuanToFen("1.001"); err == nil {
|
||||
t.Fatal("expected precision error")
|
||||
}
|
||||
if _, err := parseYuanToFen("92233720368547758.08"); err == nil {
|
||||
t.Fatal("expected overflow error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSAKeyParsersAcceptRawBase64(t *testing.T) {
|
||||
privateKey, publicKey := testRSAKeyPair(t)
|
||||
privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicDER, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseRSAPrivateKey(base64.StdEncoding.EncodeToString(privateDER)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseRSAPublicKey(base64.StdEncoding.EncodeToString(publicDER)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func testRSAKeyPair(t *testing.T) (*rsa.PrivateKey, *rsa.PublicKey) {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return privateKey, &privateKey.PublicKey
|
||||
}
|
||||
|
||||
func testPrivatePEM(key *rsa.PrivateKey) string {
|
||||
encoded, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}))
|
||||
}
|
||||
|
||||
func testPublicPEM(key *rsa.PublicKey) string {
|
||||
encoded, _ := x509.MarshalPKIXPublicKey(key)
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: encoded}))
|
||||
}
|
||||
|
||||
func testWeChatConfig(merchantPrivate *rsa.PrivateKey, platformPublic *rsa.PublicKey) Config {
|
||||
return Config{
|
||||
"appId": "wx-app", "mchId": "1900000001", "merchantSerialNo": "MERCHANT-SERIAL",
|
||||
"merchantPrivateKey": testPrivatePEM(merchantPrivate), "apiV3Key": "0123456789abcdef0123456789abcdef",
|
||||
"wechatPayPublicKeyId": "PUB_KEY_ID_3000000001", "wechatPayPublicKey": testPublicPEM(platformPublic),
|
||||
}
|
||||
}
|
||||
|
||||
func testAlipayConfig(merchantPrivate *rsa.PrivateKey, alipayPublic *rsa.PublicKey) Config {
|
||||
return Config{
|
||||
"appId": "2026000000000001", "sellerId": "2088000000000001", "merchantPrivateKey": testPrivatePEM(merchantPrivate),
|
||||
"alipayPublicKey": testPublicPEM(alipayPublic), "gateway": "https://openapi.alipay.com/gateway.do",
|
||||
}
|
||||
}
|
||||
|
||||
func testWeChatMessage(timestamp int64, nonce string, body []byte) string {
|
||||
return strconv.FormatInt(timestamp, 10) + "\n" + nonce + "\n" + string(body) + "\n"
|
||||
}
|
||||
|
||||
func encryptWeChatTestResource(t *testing.T, key, nonce, associatedData string, plaintext []byte) string {
|
||||
t.Helper()
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), plaintext, []byte(associatedData)))
|
||||
}
|
||||
@@ -2,10 +2,8 @@ package payment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Registry struct {
|
||||
@@ -30,18 +28,6 @@ func NewRegistry(providers ...Provider) (*Registry, error) {
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func Builtins() *Registry {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
registry, err := NewRegistry(
|
||||
NewWeChatProvider(client, "https://api.mch.weixin.qq.com"),
|
||||
NewAlipayProvider(client),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func (r *Registry) Get(id string) (Provider, bool) {
|
||||
if r == nil {
|
||||
return nil, false
|
||||
@@ -61,3 +47,14 @@ func (r *Registry) Descriptors() []Descriptor {
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
|
||||
return items
|
||||
}
|
||||
|
||||
func (r *Registry) Providers() []Provider {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
items := make([]Provider, 0, len(r.providers))
|
||||
for _, provider := range r.providers {
|
||||
items = append(items, provider)
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package payment
|
||||
|
||||
// RPCProvider is the host side of the yingce.payment/v1 process ABI. The
|
||||
// process is shipped inside a validated plugin package and receives exactly
|
||||
// one JSON request on stdin, returning one JSON response on stdout. The host
|
||||
// still owns the order, credential storage, network policy and crediting
|
||||
// transaction; the process only translates a payment provider protocol.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pluginRPCVersion = "yingce.payment/v1"
|
||||
pluginRPCMaxOutput = 2 << 20
|
||||
pluginRPCTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type RPCProvider struct {
|
||||
descriptor Descriptor
|
||||
command string
|
||||
dir string
|
||||
}
|
||||
|
||||
func NewRPCProvider(descriptor Descriptor, packageDir, entry string) (*RPCProvider, error) {
|
||||
if strings.TrimSpace(descriptor.ID) == "" || strings.TrimSpace(descriptor.PluginID) == "" {
|
||||
return nil, errors.New("payment plugin descriptor requires id and plugin id")
|
||||
}
|
||||
if filepath.IsAbs(entry) || filepath.Clean(entry) != entry || !strings.HasPrefix(entry, "backend/") {
|
||||
return nil, errors.New("payment plugin entry must be relative to backend/")
|
||||
}
|
||||
if strings.TrimSpace(packageDir) == "" {
|
||||
return nil, errors.New("payment plugin package directory is empty")
|
||||
}
|
||||
return &RPCProvider{descriptor: descriptor, command: filepath.Join(packageDir, filepath.FromSlash(entry)), dir: packageDir}, nil
|
||||
}
|
||||
|
||||
func (p *RPCProvider) Descriptor() Descriptor { return p.descriptor }
|
||||
|
||||
type rpcRequest struct {
|
||||
Version string `json:"version"`
|
||||
Operation string `json:"operation"`
|
||||
Config Config `json:"config,omitempty"`
|
||||
Request any `json:"request,omitempty"`
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
Body string `json:"bodyBase64,omitempty"`
|
||||
BillDate string `json:"billDate,omitempty"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any) error {
|
||||
if p == nil || strings.TrimSpace(p.command) == "" {
|
||||
return errors.New("payment plugin runtime is unavailable")
|
||||
}
|
||||
if _, err := exec.LookPath(p.command); err != nil {
|
||||
return fmt.Errorf("payment plugin executable unavailable: %w", err)
|
||||
}
|
||||
if _, err := filepath.Abs(p.command); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, pluginRPCTimeout)
|
||||
defer cancel()
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, p.command)
|
||||
cmd.Dir = p.dir
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=/nonexistent"}
|
||||
cmd.Stdin = strings.NewReader(string(payload) + "\n")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return &ProviderError{Code: "plugin_start_failed", Message: "支付插件启动失败", Temporary: true, Cause: err}
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(stdout, pluginRPCMaxOutput+1))
|
||||
errData, _ := io.ReadAll(io.LimitReader(stderr, 64<<10))
|
||||
waitErr := cmd.Wait()
|
||||
if readErr != nil {
|
||||
return &ProviderError{Code: "plugin_read_failed", Message: "读取支付插件响应失败", Temporary: true, Cause: readErr}
|
||||
}
|
||||
if len(data) > pluginRPCMaxOutput {
|
||||
return errors.New("支付插件响应超过安全限制")
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return &ProviderError{Code: "plugin_timeout", Message: "支付插件执行超时", Temporary: true, Cause: ctx.Err()}
|
||||
}
|
||||
if waitErr != nil {
|
||||
message := strings.TrimSpace(string(errData))
|
||||
if message == "" {
|
||||
message = "支付插件进程异常退出"
|
||||
}
|
||||
return &ProviderError{Code: "plugin_process_failed", Message: message, Temporary: true, Cause: waitErr}
|
||||
}
|
||||
var response rpcResponse
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return fmt.Errorf("支付插件响应不是有效 JSON:%w", err)
|
||||
}
|
||||
if !response.OK {
|
||||
message := response.Message
|
||||
if message == "" {
|
||||
message = response.Error
|
||||
}
|
||||
if message == "" {
|
||||
message = "支付插件返回失败"
|
||||
}
|
||||
return &ProviderError{Code: response.Code, Message: message}
|
||||
}
|
||||
if output == nil || len(response.Data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(response.Data, output); err != nil {
|
||||
return fmt.Errorf("解析支付插件数据失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *RPCProvider) ValidateConfig(config Config) error {
|
||||
return p.call(context.Background(), rpcRequest{Version: pluginRPCVersion, Operation: "validate_config", Config: config}, nil)
|
||||
}
|
||||
|
||||
func (p *RPCProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) {
|
||||
var result Checkout
|
||||
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "create_order", Config: config, Request: request}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *RPCProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) {
|
||||
var result Result
|
||||
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "query_order", Config: config, Request: request}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *RPCProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
|
||||
var result Result
|
||||
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "close_order", Config: config, Request: request}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *RPCProvider) VerifyNotification(ctx context.Context, config Config, headers http.Header, rawBody []byte) (Notification, error) {
|
||||
var result Notification
|
||||
headerValues := map[string][]string(headers)
|
||||
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "verify_notification", Config: config, Headers: headerValues, Body: base64.StdEncoding.EncodeToString(rawBody)}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *RPCProvider) DownloadTradeBill(ctx context.Context, config Config, billDate time.Time) ([]BillRecord, error) {
|
||||
var result []BillRecord
|
||||
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "download_trade_bill", Config: config, BillDate: billDate.Format("2006-01-02")}, &result)
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRPCProviderUsesPaymentV1Contract(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
entry := filepath.Join(dir, "provider")
|
||||
program := "#!/bin/sh\nread request\nprintf '%s\\n' '{\"ok\":true,\"data\":{\"mode\":\"qr_code\",\"value\":\"https://pay.example/qr\"}}'\n"
|
||||
if err := os.WriteFile(entry, []byte(program), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider, err := NewRPCProvider(Descriptor{ID: "test-provider", PluginID: "test-plugin", CheckoutMode: "qr_code"}, dir, "backend/provider")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.command = entry
|
||||
checkout, err := provider.CreateOrder(context.Background(), Config{"merchantId": "m"}, CreateRequest{MerchantOrderNo: "order-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checkout.Mode != "qr_code" || checkout.Value != "https://pay.example/qr" {
|
||||
t.Fatalf("checkout = %#v", checkout)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +1,35 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
paymentsdk "infinite-canvas/backend/payment-sdk"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderWeChatNative = "wechat-native"
|
||||
ProviderAlipayPage = "alipay-page-pay"
|
||||
|
||||
PluginWeChatNative = "official-payment-wechat-native"
|
||||
PluginAlipayPage = "official-payment-alipay-page"
|
||||
)
|
||||
type Config = paymentsdk.Config
|
||||
type Descriptor = paymentsdk.Descriptor
|
||||
type NotificationResponse = paymentsdk.NotificationResponse
|
||||
type CreateRequest = paymentsdk.CreateRequest
|
||||
type Checkout = paymentsdk.Checkout
|
||||
type QueryRequest = paymentsdk.QueryRequest
|
||||
type CloseRequest = paymentsdk.CloseRequest
|
||||
type Result = paymentsdk.Result
|
||||
type Notification = paymentsdk.Notification
|
||||
type BillRecord = paymentsdk.BillRecord
|
||||
type Provider = paymentsdk.Provider
|
||||
type ProviderError = paymentsdk.ProviderError
|
||||
|
||||
var (
|
||||
ErrOrderNotFound = errors.New("payment provider order not found")
|
||||
ErrOrderNotPaid = errors.New("payment provider order is not paid")
|
||||
ErrTradeBillNotFound = errors.New("payment provider trade bill not found")
|
||||
ErrOrderNotFound = paymentsdk.ErrOrderNotFound
|
||||
ErrOrderNotPaid = paymentsdk.ErrOrderNotPaid
|
||||
ErrTradeBillNotFound = paymentsdk.ErrTradeBillNotFound
|
||||
)
|
||||
|
||||
type Config map[string]string
|
||||
|
||||
type Descriptor struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
CheckoutMode string `json:"checkoutMode"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
MerchantOrderNo string
|
||||
Description string
|
||||
AmountFen int64
|
||||
Currency string
|
||||
ExpiresAt time.Time
|
||||
NotifyURL string
|
||||
ReturnURL string
|
||||
}
|
||||
|
||||
type Checkout struct {
|
||||
Mode string
|
||||
Value string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type QueryRequest struct {
|
||||
MerchantOrderNo string
|
||||
}
|
||||
|
||||
type CloseRequest struct {
|
||||
MerchantOrderNo string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
MerchantOrderNo string `json:"merchantOrderNo"`
|
||||
ProviderTradeNo string `json:"providerTradeNo,omitempty"`
|
||||
ProviderStatus string `json:"providerStatus"`
|
||||
AmountFen int64 `json:"amountFen"`
|
||||
Currency string `json:"currency"`
|
||||
Paid bool `json:"paid"`
|
||||
Closed bool `json:"closed"`
|
||||
PaidAt time.Time `json:"paidAt,omitempty"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
EventID string `json:"eventId"`
|
||||
Result
|
||||
}
|
||||
|
||||
type BillRecord struct {
|
||||
MerchantOrderNo string
|
||||
ProviderTradeNo string
|
||||
ProviderStatus string
|
||||
AmountFen int64
|
||||
Currency string
|
||||
PaidAt time.Time
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Descriptor() Descriptor
|
||||
ValidateConfig(Config) error
|
||||
CreateOrder(context.Context, Config, CreateRequest) (Checkout, error)
|
||||
QueryOrder(context.Context, Config, QueryRequest) (Result, error)
|
||||
CloseOrder(context.Context, Config, CloseRequest) (Result, error)
|
||||
VerifyNotification(context.Context, Config, http.Header, []byte) (Notification, error)
|
||||
DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error)
|
||||
}
|
||||
|
||||
type ProviderError struct {
|
||||
Code string
|
||||
Message string
|
||||
Temporary bool
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ProviderError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
func DescriptorFromManifest(manifest protocol.Manifest, provider protocol.ManifestPaymentProvider) Descriptor {
|
||||
return Descriptor{
|
||||
ID: provider.ID, PluginID: manifest.Metadata.ID, PluginVersion: manifest.Metadata.Version, Name: provider.Label,
|
||||
Icon: provider.Icon, CheckoutMode: provider.CheckoutMode,
|
||||
IdentityFields: append([]string(nil), provider.IdentityFields...),
|
||||
NotificationSuccess: NotificationResponse{Status: provider.NotificationSuccess.Status, ContentType: provider.NotificationSuccess.ContentType, Body: provider.NotificationSuccess.Body},
|
||||
NotificationFailure: NotificationResponse{Status: provider.NotificationFailure.Status, ContentType: provider.NotificationFailure.ContentType, Body: provider.NotificationFailure.Body},
|
||||
}
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
|
||||
func (e *ProviderError) Unwrap() error { return e.Cause }
|
||||
@@ -1,369 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const wechatCheckoutLifetime = 2 * time.Hour
|
||||
|
||||
type WeChatProvider struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWeChatProvider(client *http.Client, baseURL string) *WeChatProvider {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &WeChatProvider{client: client, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now}
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) Descriptor() Descriptor {
|
||||
return Descriptor{ID: ProviderWeChatNative, PluginID: PluginWeChatNative, Name: "微信支付 Native", Icon: "brand:wechat-pay", CheckoutMode: "qr_code"}
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) ValidateConfig(config Config) error {
|
||||
for _, key := range []string{"appId", "mchId", "merchantSerialNo", "merchantPrivateKey", "apiV3Key", "wechatPayPublicKeyId", "wechatPayPublicKey"} {
|
||||
if strings.TrimSpace(config[key]) == "" {
|
||||
return fmt.Errorf("微信支付配置缺少 %s", key)
|
||||
}
|
||||
}
|
||||
if len([]byte(config["apiV3Key"])) != 32 {
|
||||
return errors.New("微信支付 APIv3 密钥必须为 32 字节")
|
||||
}
|
||||
if _, err := parseRSAPrivateKey(config["merchantPrivateKey"]); err != nil {
|
||||
return fmt.Errorf("微信支付商户私钥无效:%w", err)
|
||||
}
|
||||
if _, err := parseRSAPublicKey(config["wechatPayPublicKey"]); err != nil {
|
||||
return fmt.Errorf("微信支付公钥无效:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Checkout{}, err
|
||||
}
|
||||
if request.AmountFen <= 0 || request.Currency != "CNY" || request.MerchantOrderNo == "" || request.NotifyURL == "" {
|
||||
return Checkout{}, errors.New("微信 Native 下单参数无效")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"appid": config["appId"],
|
||||
"mchid": config["mchId"],
|
||||
"description": truncateUTF8(request.Description, 127),
|
||||
"out_trade_no": request.MerchantOrderNo,
|
||||
"time_expire": request.ExpiresAt.Format(time.RFC3339),
|
||||
"notify_url": request.NotifyURL,
|
||||
"amount": map[string]any{"total": request.AmountFen, "currency": request.Currency},
|
||||
}
|
||||
var response struct {
|
||||
CodeURL string `json:"code_url"`
|
||||
}
|
||||
if err := p.do(ctx, config, http.MethodPost, "/v3/pay/transactions/native", payload, &response); err != nil {
|
||||
return Checkout{}, err
|
||||
}
|
||||
if strings.TrimSpace(response.CodeURL) == "" {
|
||||
return Checkout{}, errors.New("微信 Native 下单未返回 code_url")
|
||||
}
|
||||
expiresAt := p.now().Add(wechatCheckoutLifetime)
|
||||
if request.ExpiresAt.Before(expiresAt) {
|
||||
expiresAt = request.ExpiresAt
|
||||
}
|
||||
return Checkout{Mode: "qr_code", Value: response.CodeURL, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
path := "/v3/pay/transactions/out-trade-no/" + url.PathEscape(request.MerchantOrderNo) + "?mchid=" + url.QueryEscape(config["mchId"])
|
||||
var response wechatTransaction
|
||||
if err := p.do(ctx, config, http.MethodGet, path, nil, &response); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if response.AppID != config["appId"] || response.MchID != config["mchId"] || response.OutTradeNo != request.MerchantOrderNo || (response.TradeType != "" && response.TradeType != "NATIVE") {
|
||||
return Result{}, errors.New("微信支付查单结果与商户订单不匹配")
|
||||
}
|
||||
return wechatResult(response), nil
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
path := "/v3/pay/transactions/out-trade-no/" + url.PathEscape(request.MerchantOrderNo) + "/close"
|
||||
if err := p.do(ctx, config, http.MethodPost, path, map[string]string{"mchid": config["mchId"]}, nil); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{MerchantOrderNo: request.MerchantOrderNo, ProviderStatus: "CLOSED", Currency: "CNY", Closed: true}, nil
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) VerifyNotification(_ context.Context, config Config, headers http.Header, rawBody []byte) (Notification, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Notification{}, err
|
||||
}
|
||||
if err := p.verifyMessage(config, headers, rawBody); err != nil {
|
||||
return Notification{}, err
|
||||
}
|
||||
var envelope struct {
|
||||
ID string `json:"id"`
|
||||
EventType string `json:"event_type"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Resource struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
AssociatedData string `json:"associated_data"`
|
||||
Nonce string `json:"nonce"`
|
||||
} `json:"resource"`
|
||||
}
|
||||
if err := json.Unmarshal(rawBody, &envelope); err != nil {
|
||||
return Notification{}, fmt.Errorf("解析微信支付通知:%w", err)
|
||||
}
|
||||
if envelope.ID == "" || envelope.EventType != "TRANSACTION.SUCCESS" || envelope.ResourceType != "encrypt-resource" || envelope.Resource.Algorithm != "AEAD_AES_256_GCM" {
|
||||
return Notification{}, errors.New("微信支付通知类型无效")
|
||||
}
|
||||
plaintext, err := decryptWeChatResource(config["apiV3Key"], envelope.Resource.Nonce, envelope.Resource.AssociatedData, envelope.Resource.Ciphertext)
|
||||
if err != nil {
|
||||
return Notification{}, err
|
||||
}
|
||||
var transaction wechatTransaction
|
||||
if err := json.Unmarshal(plaintext, &transaction); err != nil {
|
||||
return Notification{}, fmt.Errorf("解析微信支付通知资源:%w", err)
|
||||
}
|
||||
if transaction.AppID != config["appId"] || transaction.MchID != config["mchId"] || transaction.TradeType != "NATIVE" || transaction.TradeState != "SUCCESS" {
|
||||
return Notification{}, errors.New("微信支付通知商户身份或交易状态不匹配")
|
||||
}
|
||||
return Notification{EventID: envelope.ID, Result: wechatResult(transaction)}, nil
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) DownloadTradeBill(ctx context.Context, config Config, billDate time.Time) ([]BillRecord, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := "/v3/bill/tradebill?bill_date=" + url.QueryEscape(billDate.Format("2006-01-02")) + "&bill_type=SUCCESS"
|
||||
var bill struct {
|
||||
HashType string `json:"hash_type"`
|
||||
HashValue string `json:"hash_value"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
if err := p.do(ctx, config, http.MethodGet, path, nil, &bill); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.EqualFold(bill.HashType, "SHA1") || strings.TrimSpace(bill.HashValue) == "" {
|
||||
return nil, errors.New("微信交易账单摘要信息无效")
|
||||
}
|
||||
target, err := trustedDownloadURL(bill.DownloadURL, p.baseURL, "api.mch.weixin.qq.com", "api2.mch.weixin.qq.com")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/octet-stream")
|
||||
request.Header.Set("Wechatpay-Serial", config["wechatPayPublicKeyId"])
|
||||
request.Header.Set("Authorization", p.authorization(config, http.MethodGet, target.RequestURI(), nil))
|
||||
response, err := clientWithTrustedRedirects(p.client, p.baseURL, "api.mch.weixin.qq.com", "api2.mch.weixin.qq.com").Do(request)
|
||||
if err != nil {
|
||||
return nil, &ProviderError{Code: "wechat_bill_download_error", Message: "下载微信交易账单失败", Temporary: true, Cause: err}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.Request == nil {
|
||||
return nil, errors.New("微信交易账单下载响应无效")
|
||||
}
|
||||
if _, err := trustedDownloadURL(response.Request.URL.String(), p.baseURL, "api.mch.weixin.qq.com", "api2.mch.weixin.qq.com"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, &ProviderError{Code: "wechat_bill_http_error", Message: "微信交易账单下载失败", Temporary: response.StatusCode >= 500}
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, maxTradeBillBytes+1))
|
||||
if err != nil {
|
||||
return nil, &ProviderError{Code: "wechat_bill_read_error", Message: "读取微信交易账单失败", Temporary: true, Cause: err}
|
||||
}
|
||||
if len(raw) > maxTradeBillBytes {
|
||||
return nil, errors.New("微信交易账单超过安全限制")
|
||||
}
|
||||
digest := sha1.Sum(raw)
|
||||
if !strings.EqualFold(hex.EncodeToString(digest[:]), strings.TrimSpace(bill.HashValue)) {
|
||||
return nil, errors.New("微信交易账单摘要校验失败")
|
||||
}
|
||||
return parseWeChatTradeBill(raw)
|
||||
}
|
||||
|
||||
type wechatTransaction struct {
|
||||
AppID string `json:"appid"`
|
||||
MchID string `json:"mchid"`
|
||||
OutTradeNo string `json:"out_trade_no"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
TradeType string `json:"trade_type"`
|
||||
TradeState string `json:"trade_state"`
|
||||
TradeStateDesc string `json:"trade_state_desc"`
|
||||
SuccessTime string `json:"success_time"`
|
||||
Amount struct {
|
||||
Total int64 `json:"total"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"amount"`
|
||||
}
|
||||
|
||||
func wechatResult(transaction wechatTransaction) Result {
|
||||
paidAt, _ := time.Parse(time.RFC3339, transaction.SuccessTime)
|
||||
return Result{
|
||||
MerchantOrderNo: transaction.OutTradeNo,
|
||||
ProviderTradeNo: transaction.TransactionID,
|
||||
ProviderStatus: transaction.TradeState,
|
||||
AmountFen: transaction.Amount.Total,
|
||||
Currency: transaction.Amount.Currency,
|
||||
Paid: transaction.TradeState == "SUCCESS",
|
||||
Closed: transaction.TradeState == "CLOSED",
|
||||
PaidAt: paidAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) do(ctx context.Context, config Config, method, path string, payload any, output any) error {
|
||||
var body []byte
|
||||
var err error
|
||||
if payload != nil {
|
||||
body, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, p.baseURL+path, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payload != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Wechatpay-Serial", config["wechatPayPublicKeyId"])
|
||||
request.Header.Set("Authorization", p.authorization(config, method, request.URL.RequestURI(), body))
|
||||
response, err := p.client.Do(request)
|
||||
if err != nil {
|
||||
return &ProviderError{Code: "wechat_transport_error", Message: "微信支付网络请求失败", Temporary: true, Cause: err}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
||||
if err != nil {
|
||||
return &ProviderError{Code: "wechat_response_read_error", Message: "读取微信支付响应失败", Temporary: true, Cause: err}
|
||||
}
|
||||
if err := p.verifyMessage(config, response.Header, responseBody); err != nil {
|
||||
return fmt.Errorf("微信支付响应验签失败:%w", err)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
var detail struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(responseBody, &detail)
|
||||
if detail.Code == "NO_STATEMENT_EXIST" {
|
||||
return fmt.Errorf("%w: %s", ErrTradeBillNotFound, detail.Code)
|
||||
}
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("%w: %s", ErrOrderNotFound, detail.Code)
|
||||
}
|
||||
return &ProviderError{Code: detail.Code, Message: "微信支付接口返回失败", Temporary: response.StatusCode >= 500}
|
||||
}
|
||||
if output == nil || len(responseBody) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(responseBody, output); err != nil {
|
||||
return fmt.Errorf("解析微信支付响应:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) authorization(config Config, method, target string, body []byte) string {
|
||||
timestamp := strconv.FormatInt(p.now().Unix(), 10)
|
||||
nonce := randomHex(16)
|
||||
message := method + "\n" + target + "\n" + timestamp + "\n" + nonce + "\n" + string(body) + "\n"
|
||||
privateKey, _ := parseRSAPrivateKey(config["merchantPrivateKey"])
|
||||
signature, _ := rsaSHA256Sign(privateKey, []byte(message))
|
||||
return fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`, config["mchId"], nonce, signature, timestamp, config["merchantSerialNo"])
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) verifyMessage(config Config, headers http.Header, body []byte) error {
|
||||
timestamp := strings.TrimSpace(headers.Get("Wechatpay-Timestamp"))
|
||||
nonce := headers.Get("Wechatpay-Nonce")
|
||||
signature := headers.Get("Wechatpay-Signature")
|
||||
serial := headers.Get("Wechatpay-Serial")
|
||||
if timestamp == "" || nonce == "" || signature == "" || serial == "" {
|
||||
return errors.New("微信支付签名请求头不完整")
|
||||
}
|
||||
if serial != config["wechatPayPublicKeyId"] {
|
||||
return errors.New("微信支付公钥 ID 不匹配")
|
||||
}
|
||||
seconds, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil || absDuration(p.now().Sub(time.Unix(seconds, 0))) > 5*time.Minute {
|
||||
return errors.New("微信支付签名时间戳无效")
|
||||
}
|
||||
publicKey, err := parseRSAPublicKey(config["wechatPayPublicKey"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message := timestamp + "\n" + nonce + "\n" + string(body) + "\n"
|
||||
return rsaSHA256Verify(publicKey, []byte(message), signature)
|
||||
}
|
||||
|
||||
func decryptWeChatResource(apiV3Key, nonce, associatedData, ciphertext string) ([]byte, error) {
|
||||
block, err := aes.NewCipher([]byte(apiV3Key))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建微信支付解密器:%w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("微信支付通知密文无效:%w", err)
|
||||
}
|
||||
plaintext, err := gcm.Open(nil, []byte(nonce), decoded, []byte(associatedData))
|
||||
if err != nil {
|
||||
return nil, errors.New("微信支付通知解密失败")
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func randomHex(size int) string {
|
||||
value := make([]byte, size)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
sum := sha256.Sum256([]byte(strconv.FormatInt(time.Now().UnixNano(), 10)))
|
||||
return hex.EncodeToString(sum[:size])
|
||||
}
|
||||
return hex.EncodeToString(value)
|
||||
}
|
||||
|
||||
func absDuration(value time.Duration) time.Duration {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func truncateUTF8(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
for len([]byte(value)) > limit {
|
||||
runes := []rune(value)
|
||||
value = string(runes[:len(runes)-1])
|
||||
}
|
||||
return value
|
||||
}
|
||||
Reference in new issue
Block a user