hb-20260724155322/hb/client.go

299 lines
7.1 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 hb
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// Client 手机支付平台客户端
type Client struct {
merchantId string // 商户编号
signKey string // 商户密钥MD5签名使用
privateKeyPEM string // 商户RSA私钥RSA签名使用
baseURL string // 接口基础URL
httpClient *http.Client
}
// ClientOption 客户端配置选项
type ClientOption func(*Client)
// WithSignKey 设置MD5签名密钥
func WithSignKey(signKey string) ClientOption {
return func(c *Client) {
c.signKey = signKey
}
}
// WithPrivateKey 设置RSA私钥PEM格式
func WithPrivateKey(privateKeyPEM string) ClientOption {
return func(c *Client) {
c.privateKeyPEM = privateKeyPEM
}
}
// WithBaseURL 设置接口基础URL
func WithBaseURL(baseURL string) ClientOption {
return func(c *Client) {
c.baseURL = baseURL
}
}
// WithHTTPClient 设置自定义HTTP客户端
func WithHTTPClient(httpClient *http.Client) ClientOption {
return func(c *Client) {
c.httpClient = httpClient
}
}
// NewClient 创建新的手机支付平台客户端
// merchantId: 商户编号
// opts: 配置选项
func NewClient(merchantId string, opts ...ClientOption) (*Client, error) {
if merchantId == "" {
return nil, ErrMerchantIdEmpty
}
c := &Client{
merchantId: merchantId,
baseURL: "https://ipos.10086.cn",
httpClient: &http.Client{},
}
for _, opt := range opts {
opt(c)
}
return c, nil
}
// Refund 退款接口
// 通过中国移动手机支付渠道,将已成功交易的款项退还给用户
func (c *Client) Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error) {
if req == nil {
return nil, fmt.Errorf("request cannot be nil")
}
// 设置固定参数
req.MerchantId = c.merchantId
req.Type = InterfaceTypeOrderRefund
req.Version = Version
// 参数校验
if req.RequestId == "" {
return nil, ErrRequestIdEmpty
}
if req.OrderId == "" {
return nil, ErrOrderIdEmpty
}
if req.Amount == "" {
return nil, ErrAmountEmpty
}
if req.SignType == "" {
return nil, ErrSignTypeInvalid
}
if req.SignType != SignTypeMD5 && req.SignType != SignTypeRSA {
return nil, ErrSignTypeInvalid
}
// 生成签名
hmac, err := c.generateRequestSign(req)
if err != nil {
return nil, fmt.Errorf("generate sign failed: %w", err)
}
req.Hmac = hmac
// 构建请求参数
params := c.buildRequestParams(req)
// 发送请求
resp, err := c.doPost(ctx, params)
if err != nil {
return nil, err
}
// 验证响应签名
if err := c.verifyResponseSign(resp); err != nil {
return nil, err
}
return resp, nil
}
// generateRequestSign 生成请求签名
func (c *Client) generateRequestSign(req *RefundRequest) (string, error) {
// 构建参数字典
params := map[string]string{
"merchantId": req.MerchantId,
"requestId": req.RequestId,
"signType": req.SignType,
"type": req.Type,
"version": req.Version,
"orderId": req.OrderId,
"amount": req.Amount,
}
// 按文档指定顺序拼接签名串
signStr := buildSignString(params, getRequestSignKeys())
switch req.SignType {
case SignTypeMD5:
if c.signKey == "" {
return "", ErrSignKeyEmpty
}
return SignMD5(signStr, c.signKey), nil
case SignTypeRSA:
if c.privateKeyPEM == "" {
return "", ErrPrivateKeyInvalid
}
return SignRSA(signStr, c.privateKeyPEM)
default:
return "", ErrSignTypeInvalid
}
}
// buildRequestParams 构建请求参数用于HTTP请求
func (c *Client) buildRequestParams(req *RefundRequest) url.Values {
params := url.Values{}
params.Set("merchantId", req.MerchantId)
params.Set("requestId", req.RequestId)
params.Set("signType", req.SignType)
params.Set("type", req.Type)
params.Set("version", req.Version)
params.Set("orderId", req.OrderId)
params.Set("amount", req.Amount)
params.Set("hmac", req.Hmac)
return params
}
// doPost 发送POST请求
func (c *Client) doPost(ctx context.Context, params url.Values) (*RefundResponse, error) {
apiURL := c.baseURL + "/ips/cmpayService"
bodyStr := params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(bodyStr))
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
return parseResponse(string(body))
}
// parseResponse 解析响应字符串为结构体
func parseResponse(raw string) (*RefundResponse, error) {
resp := &RefundResponse{}
pairs := strings.Split(raw, "&")
for _, pair := range pairs {
eqIdx := strings.IndexByte(pair, '=')
if eqIdx < 0 {
continue
}
key := pair[:eqIdx]
value := pair[eqIdx+1:]
switch key {
case "merchantId":
resp.MerchantId = value
case "payNo":
resp.PayNo = value
case "returnCode":
resp.ReturnCode = value
case "message":
resp.Message = value
case "signType":
resp.SignType = value
case "type":
resp.Type = value
case "version":
resp.Version = value
case "amount":
resp.Amount = value
case "orderId":
resp.OrderId = value
case "status":
resp.Status = value
case "serverCert":
resp.ServerCert = value
case "hmac":
resp.Hmac = value
}
}
return resp, nil
}
// verifyResponseSign 验证响应签名
func (c *Client) verifyResponseSign(resp *RefundResponse) error {
// 构建参数字典
params := map[string]string{
"merchantId": resp.MerchantId,
"payNo": resp.PayNo,
"returnCode": resp.ReturnCode,
"message": resp.Message,
"signType": resp.SignType,
"type": resp.Type,
"version": resp.Version,
"amount": resp.Amount,
"orderId": resp.OrderId,
"status": resp.Status,
}
// 按文档指定顺序拼接签名串
signStr := buildSignString(params, getResponseSignKeys())
switch resp.SignType {
case SignTypeMD5:
if c.signKey == "" {
return ErrSignKeyEmpty
}
if !VerifyMD5(signStr, c.signKey, resp.Hmac) {
return ErrSignVerifyFailed
}
return nil
case SignTypeRSA:
if resp.ServerCert == "" {
return fmt.Errorf("response missing serverCert for RSA verification")
}
// 从证书中提取公钥
pubKeyPEM, err := extractPublicKeyFromCert(resp.ServerCert)
if err != nil {
return fmt.Errorf("extract public key from cert failed: %w", err)
}
return VerifyRSA(signStr, resp.Hmac, pubKeyPEM)
default:
return ErrSignTypeInvalid
}
}
// IsRefundSuccess 判断退款是否成功
// 返回码为 000000 或 MCG00000 表示退款请求成功
// 退款最终结果以 status 字段为准
func IsRefundRequestSuccess(returnCode string) bool {
return returnCode == ReturnCodeSuccess || returnCode == ReturnCodeSuccessAlt
}
// IsRefundSuccess 判断退款最终结果是否成功
func IsRefundSuccess(status string) bool {
return status == RefundStatusSuccess
}