增加支付功能
This commit is contained in:
@@ -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 "云闪付" }
|
||||
Reference in New Issue
Block a user