ymt_v3_api-20260721-143604/ymt_v3_api/generate.md

1085 lines
30 KiB
Markdown
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.

// File: ymt_v3_api/go.mod
```go
module ymt_v3_api
go 1.18
```
// File: ymt_v3_api/client.go
```go
package ymt_v3_api
import (
"bytes"
"context"
"crypto/rsa"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client 是营销开放平台 SDK 的客户端,封装了认证、加密、签名等逻辑。
type Client struct {
httpClient *http.Client
baseURL string
appID string
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
encryptKey []byte
encryptType string // "AES" 或 "SM4"
}
// Option 是客户端配置选项函数。
type Option func(*Client)
// WithBaseURL 设置 API 基础地址。
func WithBaseURL(url string) Option {
return func(c *Client) {
c.baseURL = url
}
}
// WithTimeout 设置 HTTP 请求超时时间。
func WithTimeout(d time.Duration) Option {
return func(c *Client) {
c.httpClient.Timeout = d
}
}
// WithHTTPClient 设置自定义的 HTTP 客户端。
func WithHTTPClient(client *http.Client) Option {
return func(c *Client) {
c.httpClient = client
}
}
// WithEncryptType 设置业务参数加密方式,支持 "AES" (默认) 或 "SM4"。
func WithEncryptType(t string) Option {
return func(c *Client) {
c.encryptType = t
}
}
// NewClient 创建一个新的客户端实例。
// appID: 应用 ID
// privateKey: 应用私钥PEM 格式字符串)
// publicKey: 平台公钥PEM 格式字符串)
// encryptKey: 业务参数加解密密钥字符串AES 或 SM4 密钥)
func NewClient(appID, privateKey, publicKey, encryptKey string, opts ...Option) (*Client, error) {
priv, err := parsePrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
pub, err := parsePublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
c := &Client{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: "https://market.api.86698.cn", // 默认正式环境
appID: appID,
privateKey: priv,
publicKey: pub,
encryptKey: []byte(encryptKey),
encryptType: "AES",
}
for _, opt := range opts {
opt(c)
}
return c, nil
}
// doRequest 执行一次完整的 API 调用:加密业务参数、签名、发送请求、解密响应。
func (c *Client) doRequest(ctx context.Context, path string, bizReq interface{}, bizResp interface{}) error {
// 1. 业务参数序列化为排序 JSON 并加密
ciphertext, err := c.encryptPayload(bizReq)
if err != nil {
return fmt.Errorf("encrypt payload: %w", err)
}
// 2. 生成时间戳
timestamp := time.Now().Format("2006-01-02 15:04:05")
// 3. 签名
sign, err := c.sign(c.appID, timestamp, ciphertext)
if err != nil {
return fmt.Errorf("sign: %w", err)
}
// 4. 构建请求体
reqBody := cipherRequest{Ciphertext: ciphertext}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal request body: %w", err)
}
// 5. 创建 HTTP 请求
url := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Appid", c.appID)
req.Header.Set("Timestamp", timestamp)
req.Header.Set("Sign", sign)
// 6. 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("http do: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response body: %w", err)
}
// 7. 解析公共响应
var apiResp apiResponse
if err := json.Unmarshal(respBytes, &apiResp); err != nil {
return fmt.Errorf("unmarshal api response: %w", err)
}
if apiResp.Code != 200 {
return &APIError{
Code: apiResp.Code,
Message: apiResp.Message,
Reason: apiResp.Reason,
}
}
// 8. 解密业务响应
if apiResp.Data == nil || apiResp.Data.Ciphertext == "" {
// 某些接口可能直接返回 data 对象(如查询接口文档示例中 data 直接包含字段,但实际是 ciphertext 为空?)
// 根据文档,成功时 data.ciphertext 存在,但查询接口示例中 data 直接是业务字段,没有 ciphertext 包裹。
// 仔细看文档:获取券码响应成功时 data 里有 ciphertext券码查询响应示例中 data 直接是业务字段,没有 ciphertext。
// 但文档说“响应中的 data.ciphertext 使用与请求相同的加密方式和 key 进行解密”,可能查询接口也加密了,但示例展示了解密后的。
// 为了兼容,如果 data.ciphertext 为空,尝试直接将 data 整体反序列化为业务响应。
// 这里我们统一处理:如果 ciphertext 非空则解密,否则直接解析 data 字段。
var resp struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(respBytes, &resp); err != nil {
return fmt.Errorf("unmarshal data field: %w", err)
}
return json.Unmarshal(resp.Data, bizResp)
}
if err := c.decryptPayload(apiResp.Data.Ciphertext, bizResp); err != nil {
return fmt.Errorf("decrypt payload: %w", err)
}
return nil
}
// encryptPayload 将业务请求对象加密为 ciphertext 字符串。
func (c *Client) encryptPayload(payload interface{}) (string, error) {
plaintext, err := toSortedJSON(payload)
if err != nil {
return "", err
}
switch c.encryptType {
case "SM4":
return sm4CbcEncrypt(plaintext, c.encryptKey)
default:
return aesEcbEncrypt(plaintext, c.encryptKey)
}
}
// decryptPayload 将 ciphertext 解密并反序列化到目标对象。
func (c *Client) decryptPayload(ciphertext string, target interface{}) error {
var plaintext []byte
var err error
switch c.encryptType {
case "SM4":
plaintext, err = sm4CbcDecrypt(ciphertext, c.encryptKey)
default:
plaintext, err = aesEcbDecrypt(ciphertext, c.encryptKey)
}
if err != nil {
return err
}
return json.Unmarshal(plaintext, target)
}
// sign 生成请求签名。
func (c *Client) sign(appID, timestamp, ciphertext string) (string, error) {
data := appID + timestamp + ciphertext
return rsaSign([]byte(data), c.privateKey)
}
// verifySign 验证回调签名。
func (c *Client) verifySign(appID, timestamp, ciphertext, sign string) error {
data := appID + timestamp + ciphertext
return rsaVerify([]byte(data), sign, c.publicKey)
}
// cipherRequest 请求体结构
type cipherRequest struct {
Ciphertext string `json:"ciphertext"`
}
// apiResponse 公共响应结构
type apiResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Reason string `json:"reason,omitempty"`
Data *cipherData `json:"data,omitempty"`
}
type cipherData struct {
Ciphertext string `json:"ciphertext"`
}
```
// File: ymt_v3_api/types.go
```go
package ymt_v3_api
// OrderKeyRequest 获取券码请求参数
type OrderKeyRequest struct {
OutBizNo string `json:"out_biz_no"`
ActivityNo string `json:"activity_no"`
Account string `json:"account,omitempty"`
NotifyURL string `json:"notify_url,omitempty"`
}
// OrderKeyResponse 获取券码响应参数
type OrderKeyResponse struct {
OutBizNo string `json:"out_biz_no"`
TradeNo string `json:"trade_no"`
Key string `json:"key,omitempty"`
URL string `json:"url,omitempty"`
ValidBeginTime string `json:"valid_begin_time,omitempty"`
ValidEndTime string `json:"valid_end_time,omitempty"`
UsableNum uint32 `json:"usable_num"`
UsageNum uint32 `json:"usage_num"`
Status uint32 `json:"status"`
SettlementPrice float64 `json:"settlement_price,omitempty"`
Account string `json:"account,omitempty"`
}
// QueryKeyRequest 券码查询请求参数
type QueryKeyRequest struct {
OutBizNo string `json:"out_biz_no,omitempty"`
TradeNo string `json:"trade_no,omitempty"`
}
// QueryKeyResponse 券码查询响应参数(与获取券码一致)
type QueryKeyResponse = OrderKeyResponse
// DiscardKeyRequest 券码作废请求参数
type DiscardKeyRequest struct {
OutBizNo string `json:"out_biz_no,omitempty"`
TradeNo string `json:"trade_no,omitempty"`
}
// DiscardKeyResponse 券码作废响应参数
type DiscardKeyResponse struct {
OutBizNo string `json:"out_biz_no"`
TradeNo string `json:"trade_no"`
Status uint32 `json:"status"`
}
// BatchOrderRequest 批量发卡请求参数
type BatchOrderRequest struct {
OutBizNo string `json:"out_biz_no"`
ActivityNo string `json:"activity_no"`
Number int32 `json:"number"`
NotifyURL string `json:"notify_url,omitempty"`
}
// BatchOrderResponse 批量发卡响应参数
type BatchOrderResponse struct {
OutBizNo string `json:"out_biz_no"`
TradeNo string `json:"trade_no"`
Status string `json:"status"`
}
// BatchQueryRequest 批量查询请求参数
type BatchQueryRequest struct {
OutBizNo string `json:"out_biz_no,omitempty"`
TradeNo string `json:"trade_no,omitempty"`
}
// BatchQueryResponse 批量查询响应参数
type BatchQueryResponse struct {
OutBizNo string `json:"out_biz_no"`
TradeNo string `json:"trade_no"`
Status string `json:"status"`
DownloadURL string `json:"download_url,omitempty"`
ZipPassword string `json:"zip_password,omitempty"`
}
```
// File: ymt_v3_api/crypto.go
```go
package ymt_v3_api
import (
"bytes"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"sort"
"strings"
)
// ---------- AES-ECB 实现 ----------
// aesEcbEncrypt 使用 AES-ECB 模式加密,结果进行 Base64 编码。
func aesEcbEncrypt(plaintext []byte, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
plaintext = pkcs7Padding(plaintext, block.BlockSize())
ciphertext := make([]byte, len(plaintext))
// ECB 模式:逐块加密
for i := 0; i < len(plaintext); i += block.BlockSize() {
block.Encrypt(ciphertext[i:i+block.BlockSize()], plaintext[i:i+block.BlockSize()])
}
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// aesEcbDecrypt 解密 Base64 编码的 AES-ECB 密文。
func aesEcbDecrypt(ciphertext string, key []byte) ([]byte, error) {
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data)%block.BlockSize() != 0 {
return nil, errors.New("ciphertext is not a multiple of the block size")
}
plaintext := make([]byte, len(data))
for i := 0; i < len(data); i += block.BlockSize() {
block.Decrypt(plaintext[i:i+block.BlockSize()], data[i:i+block.BlockSize()])
}
return pkcs7Unpadding(plaintext)
}
func pkcs7Padding(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padtext...)
}
func pkcs7Unpadding(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, errors.New("invalid padding size")
}
unpadding := int(data[length-1])
if unpadding > length {
return nil, errors.New("invalid padding")
}
return data[:(length - unpadding)], nil
}
// ---------- SM4-CBC 实现 ----------
// sm4CbcEncrypt 使用 SM4-CBC 模式加密IV 为 16 字节全 0结果 Base64 编码。
func sm4CbcEncrypt(plaintext []byte, key []byte) (string, error) {
block, err := newSM4Cipher(key)
if err != nil {
return "", err
}
plaintext = pkcs7Padding(plaintext, block.BlockSize())
iv := make([]byte, block.BlockSize()) // 默认全 0 IV
ciphertext := make([]byte, len(plaintext))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, plaintext)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// sm4CbcDecrypt 解密 Base64 编码的 SM4-CBC 密文。
func sm4CbcDecrypt(ciphertext string, key []byte) ([]byte, error) {
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return nil, err
}
block, err := newSM4Cipher(key)
if err != nil {
return nil, err
}
if len(data)%block.BlockSize() != 0 {
return nil, errors.New("ciphertext is not a multiple of the block size")
}
iv := make([]byte, block.BlockSize())
plaintext := make([]byte, len(data))
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(plaintext, data)
return pkcs7Unpadding(plaintext)
}
// ---------- SM4 算法实现(简化版,符合 GB/T 32907-2016 ----------
var sBox = [256]byte{
0xd6, 0x90, 0xe9, 0xfe, 0xcc, 0xe1, 0x3d, 0xb7, 0x16, 0xb6, 0x14, 0xc2, 0x28, 0xfb, 0x2c, 0x05,
0x2b, 0x67, 0x9a, 0x76, 0x2a, 0xbe, 0x04, 0xc3, 0xaa, 0x44, 0x13, 0x26, 0x49, 0x86, 0x06, 0x99,
0x9c, 0x42, 0x50, 0xf4, 0x91, 0xef, 0x98, 0x7a, 0x33, 0x54, 0x0b, 0x43, 0xed, 0xcf, 0xac, 0x62,
0xe4, 0xb3, 0x1c, 0xa9, 0xc9, 0x08, 0xe8, 0x95, 0x80, 0xdf, 0x94, 0xfa, 0x75, 0x8f, 0x3f, 0xa6,
0x47, 0x07, 0xa7, 0xfc, 0xf3, 0x73, 0x17, 0xba, 0x83, 0x59, 0x3c, 0x19, 0xe6, 0x85, 0x4f, 0xa8,
0x68, 0x6b, 0x81, 0xb2, 0x71, 0x64, 0xda, 0x8b, 0xf8, 0xeb, 0x0f, 0x4b, 0x70, 0x56, 0x9d, 0x35,
0x1e, 0x24, 0x0e, 0x5e, 0x63, 0x58, 0xd1, 0xa2, 0x25, 0x22, 0x7c, 0x3b, 0x01, 0x21, 0x78, 0x87,
0xd4, 0x00, 0x46, 0x57, 0x9f, 0xd3, 0x27, 0x52, 0x4c, 0x36, 0x02, 0xe7, 0xa0, 0xc4, 0xc8, 0x9e,
0xea, 0xbf, 0x8a, 0xd2, 0x40, 0xc7, 0x38, 0xb5, 0xa3, 0xf7, 0xf2, 0xce, 0xf9, 0x61, 0x15, 0xa1,
0xe0, 0xae, 0x5d, 0xa4, 0x9b, 0x34, 0x1a, 0x55, 0xad, 0x93, 0x32, 0x30, 0xf5, 0x8c, 0xb1, 0xe3,
0x1d, 0xf6, 0xe2, 0x2e, 0x82, 0x66, 0xca, 0x60, 0xc0, 0x29, 0x23, 0xab, 0x0d, 0x53, 0x4e, 0x6f,
0xd5, 0xdb, 0x37, 0x45, 0xde, 0xfd, 0x8e, 0x2f, 0x03, 0xff, 0x6a, 0x72, 0x6d, 0x6c, 0x5b, 0x51,
0x8d, 0x1b, 0xaf, 0x92, 0xbb, 0xdd, 0xbc, 0x7f, 0x11, 0xd9, 0x5c, 0x41, 0x1f, 0x10, 0x5a, 0xd8,
0x0a, 0xc1, 0x31, 0x88, 0xa5, 0xcd, 0x7b, 0xbd, 0x2d, 0x74, 0xd0, 0x12, 0xb8, 0xe5, 0xb4, 0xb0,
0x89, 0x69, 0x97, 0x4a, 0x0c, 0x96, 0x77, 0x7e, 0x65, 0xb9, 0xf1, 0x09, 0xc5, 0x6e, 0xc6, 0x84,
0x18, 0xf0, 0x7d, 0xec, 0x3a, 0xdc, 0x4d, 0x20, 0x79, 0xee, 0x5f, 0x3e, 0xd7, 0xcb, 0x39, 0x48,
}
var fk = [4]uint32{0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc}
var ck = [32]uint32{
0x00070e15, 0x1c232a31, 0x383f464d, 0x545b6269,
0x70777e85, 0x8c939aa1, 0xa8afb6bd, 0xc4cbd2d9,
0xe0e7eef5, 0xfc030a11, 0x181f262d, 0x343b4249,
0x50575e65, 0x6c737a81, 0x888f969d, 0xa4abb2b9,
0xc0c7ced5, 0xdce3eaf1, 0xf8ff060d, 0x141b2229,
0x30373e45, 0x4c535a61, 0x686f767d, 0x848b9299,
0xa0a7aeb5, 0xbcc3cad1, 0xd8dfe6ed, 0xf4fb0209,
0x10171e25, 0x2c333a41, 0x484f565d, 0x646b7279,
}
type sm4Cipher struct {
enc []uint32
dec []uint32
}
func newSM4Cipher(key []byte) (cipher.Block, error) {
if len(key) != 16 {
return nil, errors.New("SM4 key must be 16 bytes")
}
c := &sm4Cipher{}
c.enc = make([]uint32, 32)
c.dec = make([]uint32, 32)
c.expandKey(key)
return c, nil
}
func (c *sm4Cipher) BlockSize() int { return 16 }
func (c *sm4Cipher) Encrypt(dst, src []byte) {
if len(src) < 16 || len(dst) < 16 {
panic("sm4: invalid buffer")
}
x := bytesToUint32s(src)
for i := 0; i < 32; i++ {
x = sm4Round(x, c.enc[i])
}
putUint32s(dst, x)
}
func (c *sm4Cipher) Decrypt(dst, src []byte) {
if len(src) < 16 || len(dst) < 16 {
panic("sm4: invalid buffer")
}
x := bytesToUint32s(src)
for i := 0; i < 32; i++ {
x = sm4Round(x, c.dec[i])
}
putUint32s(dst, x)
}
func (c *sm4Cipher) expandKey(key []byte) {
mk := bytesToUint32s(key)
k := make([]uint32, 36)
k[0] = mk[0] ^ fk[0]
k[1] = mk[1] ^ fk[1]
k[2] = mk[2] ^ fk[2]
k[3] = mk[3] ^ fk[3]
for i := 0; i < 32; i++ {
k[i+4] = k[i] ^ sm4T(k[i+1]^k[i+2]^k[i+3]^ck[i])
c.enc[i] = k[i+4]
c.dec[31-i] = k[i+4]
}
}
func sm4Round(x []uint32, rk uint32) []uint32 {
return []uint32{
x[1],
x[2],
x[3],
x[0] ^ sm4T(x[1]^x[2]^x[3]^rk),
}
}
func sm4T(x uint32) uint32 {
return sm4L(sm4Tau(x))
}
func sm4Tau(a uint32) uint32 {
return uint32(sBox[a>>24])<<24 |
uint32(sBox[a>>16&0xff])<<16 |
uint32(sBox[a>>8&0xff])<<8 |
uint32(sBox[a&0xff])
}
func sm4L(b uint32) uint32 {
return b ^ rotl(b, 2) ^ rotl(b, 10) ^ rotl(b, 18) ^ rotl(b, 24)
}
func rotl(x uint32, n uint) uint32 {
return (x << n) | (x >> (32 - n))
}
func bytesToUint32s(b []byte) []uint32 {
return []uint32{
uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]),
uint32(b[4])<<24 | uint32(b[5])<<16 | uint32(b[6])<<8 | uint32(b[7]),
uint32(b[8])<<24 | uint32(b[9])<<16 | uint32(b[10])<<8 | uint32(b[11]),
uint32(b[12])<<24 | uint32(b[13])<<16 | uint32(b[14])<<8 | uint32(b[15]),
}
}
func putUint32s(dst []byte, x []uint32) {
_ = dst[15]
dst[0] = byte(x[0] >> 24)
dst[1] = byte(x[0] >> 16)
dst[2] = byte(x[0] >> 8)
dst[3] = byte(x[0])
dst[4] = byte(x[1] >> 24)
dst[5] = byte(x[1] >> 16)
dst[6] = byte(x[1] >> 8)
dst[7] = byte(x[1])
dst[8] = byte(x[2] >> 24)
dst[9] = byte(x[2] >> 16)
dst[10] = byte(x[2] >> 8)
dst[11] = byte(x[2])
dst[12] = byte(x[3] >> 24)
dst[13] = byte(x[3] >> 16)
dst[14] = byte(x[3] >> 8)
dst[15] = byte(x[3])
}
// ---------- RSA 签名与验签 ----------
// rsaSign 使用 RSA 私钥对数据进行 SHA256 签名,返回 Base64 编码的签名。
func rsaSign(data []byte, priv *rsa.PrivateKey) (string, error) {
hashed := sha256.Sum256(data)
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hashed[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// rsaVerify 使用 RSA 公钥验证 Base64 编码的签名。
func rsaVerify(data []byte, sign string, pub *rsa.PublicKey) error {
sig, err := base64.StdEncoding.DecodeString(sign)
if err != nil {
return err
}
hashed := sha256.Sum256(data)
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, hashed[:], sig)
}
// ---------- 密钥解析 ----------
func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, errors.New("failed to decode PEM block")
}
priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
priv, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
}
rsaPriv, ok := priv.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("not an RSA private key")
}
return rsaPriv, nil
}
func parsePublicKey(pemStr string) (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, errors.New("failed to decode PEM block")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("not an RSA public key")
}
return rsaPub, nil
}
// ---------- 业务参数排序 JSON ----------
// toSortedJSON 将结构体转为按 key 排序的 JSON 字符串,并忽略零值字段。
func toSortedJSON(v interface{}) ([]byte, error) {
// 先通过 json.Marshal 得到 map利用 omitempty 忽略零值
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
// 过滤掉值为 nil 的键json.Unmarshal 会将 null 转为 nil
for k, val := range m {
if val == nil {
delete(m, k)
}
}
// 按 key 排序
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
// 构建有序的 JSON
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
keyJSON, _ := json.Marshal(k)
valJSON, _ := json.Marshal(m[k])
buf.Write(keyJSON)
buf.WriteByte(':')
buf.Write(valJSON)
}
buf.WriteByte('}')
return buf.Bytes(), nil
}
// ---------- 回调验签辅助 ----------
// VerifyCallbackSign 验证回调请求的签名。
// body: 回调请求的原始 bodyJSON 字符串)
// appID: 应用 ID
// timestamp: 回调 Header 中的 Timestamp
// sign: 回调 Header 中的 Sign
func (c *Client) VerifyCallbackSign(body []byte, appID, timestamp, sign string) error {
// 1. 解析 body 中的 data 字段
var resp struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("unmarshal callback body: %w", err)
}
// 2. 将 data 转为排序 JSON
var dataMap map[string]interface{}
if err := json.Unmarshal(resp.Data, &dataMap); err != nil {
return fmt.Errorf("unmarshal data: %w", err)
}
// 过滤零值
for k, v := range dataMap {
if v == nil || isZeroValue(v) {
delete(dataMap, k)
}
}
plainBytes, err := json.Marshal(dataMap) // map 序列化自动排序
if err != nil {
return err
}
// 3. 加密得到 ciphertext
var ciphertext string
switch c.encryptType {
case "SM4":
ciphertext, err = sm4CbcEncrypt(plainBytes, c.encryptKey)
default:
ciphertext, err = aesEcbEncrypt(plainBytes, c.encryptKey)
}
if err != nil {
return err
}
// 4. 拼接签名字符串并验签
return c.verifySign(appID, timestamp, ciphertext, sign)
}
func isZeroValue(v interface{}) bool {
switch val := v.(type) {
case string:
return val == ""
case float64:
return val == 0
case bool:
return !val
default:
return false
}
}
// 为了兼容旧版 Go添加 strings 包的引用(实际未使用,但防止 import 被移除)
var _ = strings.TrimSpace
```
// File: ymt_v3_api/errors.go
```go
package ymt_v3_api
import "fmt"
// APIError 表示 API 返回的错误信息。
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
Reason string `json:"reason,omitempty"`
}
func (e *APIError) Error() string {
if e.Reason != "" {
return fmt.Sprintf("API error [%d]: %s (reason: %s)", e.Code, e.Message, e.Reason)
}
return fmt.Sprintf("API error [%d]: %s", e.Code, e.Message)
}
// 公共错误码常量
const (
ErrCodeSystemError = 500
ErrCodeInvalidPayload = 400
ErrCodeMissingParam = 400
ErrCodeInvalidTimestamp = 400
ErrCodeDecryptFailed = 400
ErrCodeAppNotFound = 401
ErrCodeInvalidSignature = 401
ErrCodeExpiredTimestamp = 401
ErrCodeDuplicateRequest = 429
ErrCodeActivityNotAuth = 401
ErrCodeMerchantNotExist = 401
ErrCodeMerchantNotAuth = 401
ErrCodeMerchantAppIncomplete = 401
ErrCodeMerchantAppNotAuth = 401
ErrCodeActivityExpire = 403
ErrCodeActivityOutOfStock = 403
ErrCodeActivityNotExist = 404
ErrCodeMerchantOrderNotExist = 404
ErrCodeKeyNotExist = 404
ErrCodeParamFail = 400
ErrCodeParamDecryptFail = 400
)
```
// File: ymt_v3_api/api_key.go
```go
package ymt_v3_api
import "context"
// OrderKey 获取券码
// 请求路由POST /openapi/v1/key/order
func (c *Client) OrderKey(ctx context.Context, req *OrderKeyRequest) (*OrderKeyResponse, error) {
var resp OrderKeyResponse
if err := c.doRequest(ctx, "/openapi/v1/key/order", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// QueryKey 券码查询
// 请求路由POST /openapi/v1/key/query
func (c *Client) QueryKey(ctx context.Context, req *QueryKeyRequest) (*QueryKeyResponse, error) {
var resp QueryKeyResponse
if err := c.doRequest(ctx, "/openapi/v1/key/query", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// DiscardKey 券码作废
// 请求路由POST /openapi/v1/key/discard
func (c *Client) DiscardKey(ctx context.Context, req *DiscardKeyRequest) (*DiscardKeyResponse, error) {
var resp DiscardKeyResponse
if err := c.doRequest(ctx, "/openapi/v1/key/discard", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// BatchOrderKey 批量发卡
// 请求路由POST /openapi/v1/key/batch_order
func (c *Client) BatchOrderKey(ctx context.Context, req *BatchOrderRequest) (*BatchOrderResponse, error) {
var resp BatchOrderResponse
if err := c.doRequest(ctx, "/openapi/v1/key/batch_order", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// BatchQueryKey 批量查询
// 请求路由POST /openapi/v1/key/batch_query
func (c *Client) BatchQueryKey(ctx context.Context, req *BatchQueryRequest) (*BatchQueryResponse, error) {
var resp BatchQueryResponse
if err := c.doRequest(ctx, "/openapi/v1/key/batch_query", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
```
// File: ymt_v3_api/example_test.go
```go
package ymt_v3_api
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// 生成测试用的 RSA 密钥对和 AES 密钥
func generateTestKeys() (appID, privateKeyPEM, publicKeyPEM, aesKey string, err error) {
appID = "test_app_id"
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return
}
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return
}
privateKeyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}))
pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
return
}
publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))
aesKey = "1234567890123456" // 16 字节
return
}
// 启动一个模拟服务器,返回加密的成功响应
func setupMockServer(t *testing.T, client *Client, bizResp interface{}) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 验证必要 Header
if r.Header.Get("Appid") == "" || r.Header.Get("Timestamp") == "" || r.Header.Get("Sign") == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(apiResponse{Code: 400, Message: "missing header"})
return
}
// 解密请求体,验证 ciphertext 存在
var reqBody cipherRequest
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil || reqBody.Ciphertext == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(apiResponse{Code: 400, Message: "invalid body"})
return
}
// 构造加密响应
ciphertext, err := client.encryptPayload(bizResp)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
resp := apiResponse{
Code: 200,
Message: "成功",
Data: &cipherData{Ciphertext: ciphertext},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
return server
}
func TestOrderKey(t *testing.T) {
appID, privPEM, pubPEM, aesKey, err := generateTestKeys()
if err != nil {
t.Fatal(err)
}
client, err := NewClient(appID, privPEM, pubPEM, aesKey, WithEncryptType("AES"))
if err != nil {
t.Fatal(err)
}
expectedResp := &OrderKeyResponse{
OutBizNo: "order_001",
TradeNo: "7251449503000383488",
Key: "aZKdU9BymzR6qGRzJM",
ValidBeginTime: "2026-06-22 15:30:00",
ValidEndTime: "2026-12-31 23:59:59",
UsableNum: 1,
UsageNum: 0,
Status: 1,
SettlementPrice: 9.9,
Account: "18666666666",
}
server := setupMockServer(t, client, expectedResp)
defer server.Close()
client.baseURL = server.URL
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := &OrderKeyRequest{
OutBizNo: "order_001",
ActivityNo: "ACT20260622001",
Account: "18666666666",
NotifyURL: "https://notify.example.com/openapi",
}
resp, err := client.OrderKey(ctx, req)
if err != nil {
t.Fatalf("OrderKey failed: %v", err)
}
if resp.TradeNo != expectedResp.TradeNo {
t.Errorf("expected trade_no %s, got %s", expectedResp.TradeNo, resp.TradeNo)
}
if resp.Key != expectedResp.Key {
t.Errorf("expected key %s, got %s", expectedResp.Key, resp.Key)
}
}
func TestQueryKey(t *testing.T) {
appID, privPEM, pubPEM, aesKey, err := generateTestKeys()
if err != nil {
t.Fatal(err)
}
client, err := NewClient(appID, privPEM, pubPEM, aesKey)
if err != nil {
t.Fatal(err)
}
expectedResp := &QueryKeyResponse{
OutBizNo: "order_001",
TradeNo: "7251449503000383488",
Status: 1,
}
server := setupMockServer(t, client, expectedResp)
defer server.Close()
client.baseURL = server.URL
ctx := context.Background()
req := &QueryKeyRequest{OutBizNo: "order_001"}
resp, err := client.QueryKey(ctx, req)
if err != nil {
t.Fatalf("QueryKey failed: %v", err)
}
if resp.OutBizNo != expectedResp.OutBizNo {
t.Errorf("expected out_biz_no %s, got %s", expectedResp.OutBizNo, resp.OutBizNo)
}
}
func TestDiscardKey(t *testing.T) {
appID, privPEM, pubPEM, aesKey, err := generateTestKeys()
if err != nil {
t.Fatal(err)
}
client, err := NewClient(appID, privPEM, pubPEM, aesKey)
if err != nil {
t.Fatal(err)
}
expectedResp := &DiscardKeyResponse{
OutBizNo: "order_001",
TradeNo: "7251449503000383488",
Status: 3,
}
server := setupMockServer(t, client, expectedResp)
defer server.Close()
client.baseURL = server.URL
ctx := context.Background()
req := &DiscardKeyRequest{TradeNo: "7251449503000383488"}
resp, err := client.DiscardKey(ctx, req)
if err != nil {
t.Fatalf("DiscardKey failed: %v", err)
}
if resp.Status != 3 {
t.Errorf("expected status 3, got %d", resp.Status)
}
}
func TestBatchOrderKey(t *testing.T) {
appID, privPEM, pubPEM, aesKey, err := generateTestKeys()
if err != nil {
t.Fatal(err)
}
client, err := NewClient(appID, privPEM, pubPEM, aesKey)
if err != nil {
t.Fatal(err)
}
expectedResp := &BatchOrderResponse{
OutBizNo: "batch_001",
TradeNo: "7251449503000383499",
Status: "processing",
}
server := setupMockServer(t, client, expectedResp)
defer server.Close()
client.baseURL = server.URL
ctx := context.Background()
req := &BatchOrderRequest{
OutBizNo: "batch_001",
ActivityNo: "ACT20260622001",
Number: 100,
}
resp, err := client.BatchOrderKey(ctx, req)
if err != nil {
t.Fatalf("BatchOrderKey failed: %v", err)
}
if resp.Status != "processing" {
t.Errorf("expected status processing, got %s", resp.Status)
}
}
func TestBatchQueryKey(t *testing.T) {
appID, privPEM, pubPEM, aesKey, err := generateTestKeys()
if err != nil {
t.Fatal(err)
}
client, err := NewClient(appID, privPEM, pubPEM, aesKey)
if err != nil {
t.Fatal(err)
}
expectedResp := &BatchQueryResponse{
OutBizNo: "batch_001",
TradeNo: "7251449503000383499",
Status: "success",
DownloadURL: "https://oss.example.com/openapi_7251449503000383499.zip",
ZipPassword: "123456",
}
server := setupMockServer(t, client, expectedResp)
defer server.Close()
client.baseURL = server.URL
ctx := context.Background()
req := &BatchQueryRequest{TradeNo: "7251449503000383499"}
resp, err := client.BatchQueryKey(ctx, req)
if err != nil {
t.Fatalf("BatchQueryKey failed: %v", err)
}
if resp.DownloadURL != expectedResp.DownloadURL {
t.Errorf("expected download_url %s, got %s", expectedResp.DownloadURL, resp.DownloadURL)
}
}
```