Files
2026-09-15 12:46:29 +08:00

395 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 "云闪付" }