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 "云闪付" }