395 lines
12 KiB
Go
395 lines
12 KiB
Go
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 "云闪付" }
|