From eefb14c0ec34031e02982c8358da89c56e44c340 Mon Sep 17 00:00:00 2001 From: renzhiyuan <465386466@qq.com> Date: Fri, 24 Jul 2026 15:53:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=87=E4=BB=B6:=20hb/clie?= =?UTF-8?q?nt.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hb/client.go | 299 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 hb/client.go diff --git a/hb/client.go b/hb/client.go new file mode 100644 index 0000000..b27a9c4 --- /dev/null +++ b/hb/client.go @@ -0,0 +1,299 @@ +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 +} \ No newline at end of file