299 lines
7.1 KiB
Go
299 lines
7.1 KiB
Go
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
|
||
} |