添加文件: hb/client.go

This commit is contained in:
renzhiyuan 2026-07-24 15:53:25 +08:00
parent 6f5b142bfc
commit eefb14c0ec
1 changed files with 299 additions and 0 deletions

299
hb/client.go Normal file
View File

@ -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
}