增加支付功能

This commit is contained in:
2026-09-15 12:46:29 +08:00
parent 943c3708b0
commit a82b300b1a
46 changed files with 11675 additions and 21 deletions
+394
View File
@@ -0,0 +1,394 @@
package payment
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"server/models"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
// UnionPayChannel 银联全渠道(PC 网关支付 / 手机控件支付)
//
// 银联没有官方 Go SDK,这里按「全渠道支付平台产品接口规范」实现签名与请求:
// - 签名:SHA256withRSA,字段按 key ASCII 升序拼 key=value&... 后私钥签名,base64 放入 signature;
// - 下单:POST /gateway/api/frontTransReq.do(前台跳转,返回自动提交表单);
// - 查询:POST /gateway/api/queryTrans.do(txnType=00);
// - 退货:POST /gateway/api/backTransReq.do(txnType=04)。
//
// 渠道参数(config_json):
//
// mer_id 商户号
// acq_ins_code 收单机构代码
// cert_password 商户证书密码
// is_production "1" 生产网关 / 其他为测试网关
//
// 证书(cert_json):
//
// cert_path 商户证书(.pfx / .p12,或 .cer + .pem 私钥组合)
type UnionPayChannel struct{}
func (c *UnionPayChannel) Code() string { return ChannelUnionPay }
func (c *UnionPayChannel) Name() string { return "银联" }
const (
unionPayGatewayProd = "https://gateway.95516.com"
unionPayGatewayTest = "https://gateway.test.95516.com"
unionPayVersion = "5.1.0"
unionPayCurrencyCNY = "156"
unionPayDefaultBizType = "000201" // B2C 网关支付
)
func unionPayGateway(cfg *ChannelConfig) string {
if cfg.Get("is_production") == "1" {
return unionPayGatewayProd
}
return unionPayGatewayTest
}
// unionPayKey 加载商户私钥与证书(支持 .pfx/.p12 与 .pem+.cer 两种形式)
func unionPayKey(cfg *ChannelConfig) (*rsa.PrivateKey, *x509.Certificate, error) {
certPath := cfg.CertPaths["cert_path"]
if certPath == "" {
return nil, nil, fmt.Errorf("银联缺少商户证书:请先上传商户证书")
}
data, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, fmt.Errorf("读取银联证书失败: %w", err)
}
if block, _ := pem.Decode(data); block != nil && strings.Contains(block.Type, "PRIVATE KEY") {
keyData, kerr := os.ReadFile(cfg.CertPaths["key_path"])
if kerr != nil {
return nil, nil, fmt.Errorf("读取银联私钥失败: %w", kerr)
}
priv, perr := parsePKCS1Or8(string(keyData))
if perr != nil {
return nil, nil, perr
}
cert, cerr := loadCertFile(certPath)
if cerr != nil {
return nil, nil, cerr
}
return priv, cert, nil
}
privAny, cert, err := pkcs12.Decode(data, cfg.Get("cert_password"))
if err != nil {
return nil, nil, fmt.Errorf("解析银联 .pfx 证书失败(密码是否正确): %w", err)
}
priv, ok := privAny.(*rsa.PrivateKey)
if !ok {
return nil, nil, fmt.Errorf("银联证书私钥不是 RSA 私钥")
}
if cert == nil {
return nil, nil, fmt.Errorf("银联证书缺少证书信息")
}
return priv, cert, nil
}
func parsePKCS1Or8(pemStr string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("私钥不是有效的 PEM")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析私钥失败: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("私钥不是 RSA 私钥")
}
return rsaKey, nil
}
func loadCertFile(path string) (*x509.Certificate, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("证书不是有效的 PEM")
}
return x509.ParseCertificate(block.Bytes)
}
// unionPayCertID 银联证书序列号(SerialNumber 十六进制大写)
func unionPayCertID(cert *x509.Certificate) string {
return strings.ToUpper(fmt.Sprintf("%x", cert.SerialNumber))
}
// unionPaySign 银联签名:key 升序拼 key=value&... 后 SHA256withRSA
func unionPaySign(params map[string]string, key *rsa.PrivateKey) (string, error) {
keys := make([]string, 0, len(params))
for k, v := range params {
if k == "signature" || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, k+"="+params[k])
}
digest := sha256.Sum256([]byte(strings.Join(pairs, "&")))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// unionPaySigned 组装带签名与 certId 的请求参数
func unionPaySigned(cfg *ChannelConfig, priv *rsa.PrivateKey, cert *x509.Certificate, extra map[string]string) (map[string]string, error) {
params := map[string]string{
"version": unionPayVersion,
"encoding": "UTF-8",
"signMethod": "01",
"accessType": "0",
"merId": cfg.Get("mer_id"),
}
for k, v := range extra {
if v != "" {
params[k] = v
}
}
sig, err := unionPaySign(params, priv)
if err != nil {
return nil, fmt.Errorf("银联签名失败: %w", err)
}
params["signature"] = sig
params["certId"] = unionPayCertID(cert)
return params, nil
}
// unionPayPostRaw 提交表单并返回原始响应
func unionPayPostRaw(ctx context.Context, endpoint string, params map[string]string) (string, error) {
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return "", fmt.Errorf("请求银联网关失败: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("银联网关返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 200))
}
return string(body), nil
}
// unionPayPostForm 解析 form-urlencoded 响应(queryTrans / backTransReq.do)
func unionPayPostForm(ctx context.Context, endpoint string, params map[string]string) (url.Values, error) {
body, err := unionPayPostRaw(ctx, endpoint, params)
if err != nil {
return nil, err
}
vals, perr := url.ParseQuery(body)
if perr != nil || len(vals) == 0 {
return nil, fmt.Errorf("银联响应不是有效的表单数据: %s", truncateStr(body, 200))
}
return vals, nil
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func (c *UnionPayChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
priv, cert, err := unionPayKey(cfg)
if err != nil {
return nil, err
}
extra := map[string]string{
"txnType": "01",
"txnSubType": "01",
"bizType": unionPayDefaultBizType,
"orderId": order.PayNo,
"txnTime": time.Now().Format("20060102150405"),
"txnAmt": strconv.FormatInt(order.Amount, 10),
"currencyCode": unionPayCurrencyCNY,
"backUrl": cfg.CallbackURL,
"frontUrl": order.ReturnURL,
}
params, err := unionPaySigned(cfg, priv, cert, extra)
if err != nil {
return nil, err
}
// 前台跳转:银联返回自动提交表单,前端写入页面即可拉起收银台
html, err := unionPayPostRaw(ctx, unionPayGateway(cfg)+"/gateway/api/frontTransReq.do", params)
if err != nil {
return nil, fmt.Errorf("银联下单失败: %w", err)
}
payType := PayTypeWeb
if strings.EqualFold(opt.PayType, PayTypeH5) {
payType = PayTypeH5
}
return &PayParams{Channel: ChannelUnionPay, PayType: payType, Form: html}, nil
}
func (c *UnionPayChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
priv, cert, err := unionPayKey(cfg)
if err != nil {
return nil, err
}
extra := map[string]string{
"txnType": "00",
"txnSubType": "00",
"bizType": unionPayDefaultBizType,
"orderId": order.PayNo,
"txnTime": order.CreateTime.Format("20060102150405"),
}
params, err := unionPaySigned(cfg, priv, cert, extra)
if err != nil {
return nil, err
}
// TODO(银联): 查询响应字段(origRespCode/origTxnAmt/queryId 等)以入网时下发的接口文档为准,联调时校准。
vals, err := unionPayPostForm(ctx, unionPayGateway(cfg)+"/gateway/api/queryTrans.do", params)
if err != nil {
return nil, err
}
state := &ChannelState{Raw: fmt.Sprint(vals)}
if vals.Get("queryId") != "" {
state.ChannelTradeNo = vals.Get("queryId")
}
if vals.Get("origRespCode") == "00" {
state.TradeState = StateSuccess
if amt, aerr := strconv.ParseInt(vals.Get("origTxnAmt"), 10, 64); aerr == nil {
state.Amount = amt
}
now := time.Now()
state.PaidAt = &now
} else {
state.TradeState = StatePending
}
return state, nil
}
func (c *UnionPayChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
priv, _, err := unionPayKey(cfg)
if err != nil {
return nil, err
}
if err := r.ParseForm(); err != nil {
return nil, fmt.Errorf("解析银联通知失败: %w", err)
}
form := map[string]string{}
for k := range r.Form {
form[k] = r.Form.Get(k)
}
expect, err := unionPaySign(form, priv)
if err != nil {
return nil, err
}
if expect != r.Form.Get("signature") {
return nil, fmt.Errorf("银联通知验签失败")
}
result := &NotifyResult{
EventType: "UNIONPAY.NOTIFY",
AckBody: "ok",
Raw: fmt.Sprint(r.Form),
}
result.PayNo = form["orderId"]
result.OutTradeNo = form["orderId"]
result.ChannelTradeNo = form["queryId"]
if amt, aerr := strconv.ParseInt(form["txnAmt"], 10, 64); aerr == nil {
result.Amount = amt
}
if form["respCode"] == "00" {
result.TradeState = StateSuccess
result.Paid = true
} else {
result.TradeState = StateFailed
}
return result, nil
}
func (c *UnionPayChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
if order.ChannelTradeNo == "" {
return "", fmt.Errorf("银联退回需要原交易流水号,请先执行「手动查询渠道状态」")
}
priv, cert, err := unionPayKey(cfg)
if err != nil {
return "", err
}
extra := map[string]string{
"txnType": "04",
"txnSubType": "00",
"bizType": unionPayDefaultBizType,
"orderId": refundNo,
"txnTime": time.Now().Format("20060102150405"),
"txnAmt": strconv.FormatInt(amount, 10),
"origQryId": order.ChannelTradeNo,
"backUrl": cfg.CallbackURL,
}
params, err := unionPaySigned(cfg, priv, cert, extra)
if err != nil {
return "", err
}
// TODO(银联): 退货响应字段与重试策略以入网时下发的接口文档为准,联调时校准。
vals, err := unionPayPostForm(ctx, unionPayGateway(cfg)+"/gateway/api/backTransReq.do", params)
if err != nil {
return "", err
}
if vals.Get("respCode") != "00" {
return "", fmt.Errorf("银联退回失败: %s %s", vals.Get("respCode"), vals.Get("respMsg"))
}
return vals.Get("queryId"), nil
}
func (c *UnionPayChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
priv, cert, err := unionPayKey(cfg)
if err != nil {
return "", err
}
if _, err := unionPaySign(map[string]string{"merId": cfg.Get("mer_id"), "orderId": "__connect_test__"}, priv); err != nil {
return "", err
}
// TODO(银联): 如需校验网关连通性,可在入网报备后用一笔查询交易确认。
return fmt.Sprintf("连接成功:证书加载与签名校验通过(证书序列号 %s,网关 %s)", unionPayCertID(cert), unionPayGateway(cfg)), nil
}
// CloudPayChannel 云闪付:走银联通道,商户参数与证书完全复用银联配置;
// 收银台是否展示云闪付标识由 extra_json.show_logo 控制(由前端收银台读取),适配器层面与银联一致。
type CloudPayChannel struct {
UnionPayChannel
}
func (c *CloudPayChannel) Code() string { return ChannelCloudPay }
func (c *CloudPayChannel) Name() string { return "云闪付" }