ymt_v3_generate-20260721-10.../ymt_v3_generate/res.md

22 KiB
Raw Blame History

// File: ymt_v3_generate/go.mod

module ymt_v3_generate

go 1.21

// File: ymt_v3_generate/errors.go

package ymt_v3_generate

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 {
	return fmt.Sprintf("API error: code=%d, message=%s, reason=%s", e.Code, e.Message, e.Reason)
}

// File: ymt_v3_generate/types.go

package ymt_v3_generate

// KeyOrderRequest 获取券码请求
type KeyOrderRequest struct {
	OutBizNo   string `json:"out_biz_no"`
	ActivityNo string `json:"activity_no"`
	Account    string `json:"account,omitempty"`
	NotifyURL  string `json:"notify_url,omitempty"`
}

// KeyOrderResponse 获取券码响应
type KeyOrderResponse 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"`
}

// KeyQueryRequest 券码查询请求
type KeyQueryRequest struct {
	OutBizNo string `json:"out_biz_no,omitempty"`
	TradeNo  string `json:"trade_no,omitempty"`
}

// KeyQueryResponse 券码查询响应(与 KeyOrderResponse 一致)
type KeyQueryResponse = KeyOrderResponse

// KeyDiscardRequest 券码作废请求
type KeyDiscardRequest struct {
	OutBizNo string `json:"out_biz_no,omitempty"`
	TradeNo  string `json:"trade_no,omitempty"`
}

// KeyDiscardResponse 券码作废响应
type KeyDiscardResponse 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"`
}

// ApiResponse 公共响应结构
type ApiResponse struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
	Reason  string `json:"reason,omitempty"`
	Data    struct {
		Ciphertext string `json:"ciphertext"`
	} `json:"data"`
}

// File: ymt_v3_generate/crypto.go

package ymt_v3_generate

import (
	"bytes"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"errors"
	"fmt"
	"sort"
	"strings"
)

// PKCS7Padding 填充
func pkcs7Padding(data []byte, blockSize int) []byte {
	padding := blockSize - len(data)%blockSize
	padtext := bytes.Repeat([]byte{byte(padding)}, padding)
	return append(data, padtext...)
}

// pkcs7UnPadding 去除填充
func pkcs7UnPadding(data []byte) ([]byte, error) {
	length := len(data)
	if length == 0 {
		return nil, errors.New("data is empty")
	}
	unpadding := int(data[length-1])
	if unpadding > length || unpadding == 0 {
		return nil, errors.New("invalid padding")
	}
	return data[:length-unpadding], nil
}

// aesECBEncrypt AES ECB 加密
func aesECBEncrypt(plaintext []byte, key []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	blockSize := block.BlockSize()
	plaintext = pkcs7Padding(plaintext, blockSize)
	ciphertext := make([]byte, len(plaintext))
	// ECB 模式:逐块加密
	for start := 0; start < len(plaintext); start += blockSize {
		block.Encrypt(ciphertext[start:start+blockSize], plaintext[start:start+blockSize])
	}
	return ciphertext, nil
}

// aesECBDecrypt AES ECB 解密
func aesECBDecrypt(ciphertext []byte, key []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	blockSize := block.BlockSize()
	if len(ciphertext)%blockSize != 0 {
		return nil, errors.New("ciphertext is not a multiple of block size")
	}
	plaintext := make([]byte, len(ciphertext))
	for start := 0; start < len(ciphertext); start += blockSize {
		block.Decrypt(plaintext[start:start+blockSize], ciphertext[start:start+blockSize])
	}
	return pkcs7UnPadding(plaintext)
}

// EncryptPlaintext 加密明文AES ECB返回 base64 字符串
func EncryptPlaintext(plaintext string, key []byte) (string, error) {
	ciphertext, err := aesECBEncrypt([]byte(plaintext), key)
	if err != nil {
		return "", err
	}
	return base64.StdEncoding.EncodeToString(ciphertext), nil
}

// DecryptCiphertext 解密密文base64 输入),返回明文字符串
func DecryptCiphertext(ciphertextBase64 string, key []byte) (string, error) {
	ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
	if err != nil {
		return "", err
	}
	plaintext, err := aesECBDecrypt(ciphertext, key)
	if err != nil {
		return "", err
	}
	return string(plaintext), nil
}

// Sign 生成签名appid + timestamp + ciphertext使用 RSA 私钥签名,返回 base64 签名
func Sign(appID, timestamp, ciphertext string, privateKey *rsa.PrivateKey) (string, error) {
	data := appID + timestamp + ciphertext
	hash := sha256.Sum256([]byte(data))
	signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash[:])
	if err != nil {
		return "", err
	}
	return base64.StdEncoding.EncodeToString(signature), nil
}

// VerifySign 验证签名
func VerifySign(appID, timestamp, ciphertext, signBase64 string, publicKey *rsa.PublicKey) error {
	data := appID + timestamp + ciphertext
	hash := sha256.Sum256([]byte(data))
	signature, err := base64.StdEncoding.DecodeString(signBase64)
	if err != nil {
		return err
	}
	return rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hash[:], signature)
}

// ParsePrivateKey 解析 PEM 格式的 RSA 私钥
func ParsePrivateKey(pemStr string) (*rsa.PrivateKey, error) {
	block, _ := pem.Decode([]byte(pemStr))
	if block == nil {
		return nil, errors.New("failed to parse PEM block containing private key")
	}
	// 尝试 PKCS1
	key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
	if err == nil {
		return key, nil
	}
	// 尝试 PKCS8
	key8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err == nil {
		if rsaKey, ok := key8.(*rsa.PrivateKey); ok {
			return rsaKey, nil
		}
		return nil, errors.New("parsed key is not RSA")
	}
	return nil, fmt.Errorf("failed to parse private key: %v", err)
}

// ParsePublicKey 解析 PEM 格式的 RSA 公钥
func ParsePublicKey(pemStr string) (*rsa.PublicKey, error) {
	block, _ := pem.Decode([]byte(pemStr))
	if block == nil {
		return nil, errors.New("failed to parse PEM block containing public key")
	}
	// 尝试 PKIX
	key, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err == nil {
		if rsaKey, ok := key.(*rsa.PublicKey); ok {
			return rsaKey, nil
		}
		return nil, errors.New("parsed key is not RSA")
	}
	// 尝试 PKCS1
	rsaKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
	if err == nil {
		return rsaKey, nil
	}
	return nil, fmt.Errorf("failed to parse public key: %v", err)
}

// RemoveZeroValuesAndSort 将结构体转为 map过滤零值按键排序返回 JSON 字符串
func RemoveZeroValuesAndSort(v interface{}) (string, error) {
	// 使用反射获取字段
	val := reflect.ValueOf(v)
	if val.Kind() == reflect.Ptr {
		val = val.Elem()
	}
	if val.Kind() != reflect.Struct {
		return "", errors.New("input must be a struct")
	}
	typ := val.Type()
	m := make(map[string]interface{})
	for i := 0; i < val.NumField(); i++ {
		field := typ.Field(i)
		jsonTag := field.Tag.Get("json")
		if jsonTag == "" || jsonTag == "-" {
			continue
		}
		// 处理 omitempty
		name := strings.Split(jsonTag, ",")[0]
		fieldVal := val.Field(i)
		// 检查零值
		if fieldVal.IsZero() {
			continue
		}
		m[name] = fieldVal.Interface()
	}
	// 按键排序
	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(',')
		}
		buf.WriteString(`"` + k + `":`)
		// 简单序列化(仅支持基本类型)
		v := m[k]
		switch val := v.(type) {
		case string:
			buf.WriteString(`"` + val + `"`)
		case int, int32, int64, uint, uint32, uint64:
			fmt.Fprintf(&buf, "%d", val)
		case float64:
			fmt.Fprintf(&buf, "%v", val)
		case bool:
			fmt.Fprintf(&buf, "%t", val)
		default:
			// 使用 json.Marshal 处理复杂类型
			b, err := json.Marshal(v)
			if err != nil {
				return "", err
			}
			buf.Write(b)
		}
	}
	buf.WriteByte('}')
	return buf.String(), nil
}

// File: ymt_v3_generate/client.go

package ymt_v3_generate

import (
	"bytes"
	"context"
	"crypto/rsa"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)

// Client 蓝色兄弟营销开放 API 客户端
type Client struct {
	appID      string
	privateKey *rsa.PrivateKey
	publicKey  *rsa.PublicKey
	key        []byte // AES 加密密钥
	baseURL    string
	httpClient *http.Client
	timeout    time.Duration
}

// Option 客户端配置选项
type Option func(*Client)

// NewClient 创建客户端
func NewClient(opts ...Option) *Client {
	c := &Client{
		baseURL: "https://gateway.dev.cdlsxd.cn", // 默认测试环境
		httpClient: &http.Client{
			Timeout: 30 * time.Second,
		},
	}
	for _, opt := range opts {
		opt(c)
	}
	return c
}

// WithAppID 设置应用 ID
func WithAppID(appID string) Option {
	return func(c *Client) {
		c.appID = appID
	}
}

// WithPrivateKey 设置私钥PEM 字符串)
func WithPrivateKey(privateKeyPEM string) Option {
	return func(c *Client) {
		key, err := ParsePrivateKey(privateKeyPEM)
		if err != nil {
			panic(fmt.Sprintf("invalid private key: %v", err))
		}
		c.privateKey = key
	}
}

// WithPublicKey 设置公钥PEM 字符串)
func WithPublicKey(publicKeyPEM string) Option {
	return func(c *Client) {
		key, err := ParsePublicKey(publicKeyPEM)
		if err != nil {
			panic(fmt.Sprintf("invalid public key: %v", err))
		}
		c.publicKey = key
	}
}

// WithKey 设置 AES 加密密钥base64 字符串)
func WithKey(keyBase64 string) Option {
	return func(c *Client) {
		key, err := base64.StdEncoding.DecodeString(keyBase64)
		if err != nil {
			panic(fmt.Sprintf("invalid AES key: %v", err))
		}
		c.key = key
	}
}

// WithBaseURL 设置基础 URL
func WithBaseURL(baseURL string) Option {
	return func(c *Client) {
		c.baseURL = baseURL
	}
}

// WithTimeout 设置超时时间
func WithTimeout(timeout time.Duration) Option {
	return func(c *Client) {
		c.timeout = timeout
		c.httpClient.Timeout = timeout
	}
}

// WithHTTPClient 设置自定义 HTTP 客户端
func WithHTTPClient(httpClient *http.Client) Option {
	return func(c *Client) {
		c.httpClient = httpClient
	}
}

// Do 发送 API 请求
func (c *Client) Do(ctx context.Context, method, path string, requestBody interface{}, responseBody interface{}) error {
	// 1. 将请求体转为排序后的 JSON 字符串plaintext
	plaintext, err := RemoveZeroValuesAndSort(requestBody)
	if err != nil {
		return fmt.Errorf("failed to serialize request body: %w", err)
	}

	// 2. 加密得到 ciphertext
	ciphertext, err := EncryptPlaintext(plaintext, c.key)
	if err != nil {
		return fmt.Errorf("failed to encrypt request: %w", err)
	}

	// 3. 生成时间戳
	timestamp := time.Now().Format("2006-01-02 15:04:05")

	// 4. 生成签名
	sign, err := Sign(c.appID, timestamp, ciphertext, c.privateKey)
	if err != nil {
		return fmt.Errorf("failed to sign request: %w", err)
	}

	// 5. 构造 HTTP 请求
	reqBody := map[string]string{"ciphertext": ciphertext}
	reqBytes, err := json.Marshal(reqBody)
	if err != nil {
		return fmt.Errorf("failed to marshal request body: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(reqBytes))
	if err != nil {
		return fmt.Errorf("failed to create 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("request failed: %w", err)
	}
	defer resp.Body.Close()

	respBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("failed to read response body: %w", err)
	}

	// 7. 解析公共响应
	var apiResp ApiResponse
	if err := json.Unmarshal(respBytes, &apiResp); err != nil {
		return fmt.Errorf("failed to parse response: %w", err)
	}

	if apiResp.Code != 200 {
		return &APIError{
			Code:    apiResp.Code,
			Message: apiResp.Message,
			Reason:  apiResp.Reason,
		}
	}

	// 8. 解密 data.ciphertext
	decrypted, err := DecryptCiphertext(apiResp.Data.Ciphertext, c.key)
	if err != nil {
		return fmt.Errorf("failed to decrypt response: %w", err)
	}

	// 9. 反序列化到业务响应
	if err := json.Unmarshal([]byte(decrypted), responseBody); err != nil {
		return fmt.Errorf("failed to unmarshal business response: %w", err)
	}

	return nil
}

// File: ymt_v3_generate/api_key.go

package ymt_v3_generate

import "context"

// CreateKeyOrder 获取券码
func (c *Client) CreateKeyOrder(ctx context.Context, req *KeyOrderRequest) (*KeyOrderResponse, error) {
	var resp KeyOrderResponse
	err := c.Do(ctx, "POST", "/openapi/v1/key/order", req, &resp)
	if err != nil {
		return nil, err
	}
	return &resp, nil
}

// QueryKey 券码查询
func (c *Client) QueryKey(ctx context.Context, req *KeyQueryRequest) (*KeyQueryResponse, error) {
	var resp KeyQueryResponse
	err := c.Do(ctx, "POST", "/openapi/v1/key/query", req, &resp)
	if err != nil {
		return nil, err
	}
	return &resp, nil
}

// DiscardKey 券码作废
func (c *Client) DiscardKey(ctx context.Context, req *KeyDiscardRequest) (*KeyDiscardResponse, error) {
	var resp KeyDiscardResponse
	err := c.Do(ctx, "POST", "/openapi/v1/key/discard", req, &resp)
	if err != nil {
		return nil, err
	}
	return &resp, nil
}

// BatchOrder 批量发卡
func (c *Client) BatchOrder(ctx context.Context, req *BatchOrderRequest) (*BatchOrderResponse, error) {
	var resp BatchOrderResponse
	err := c.Do(ctx, "POST", "/openapi/v1/key/batch_order", req, &resp)
	if err != nil {
		return nil, err
	}
	return &resp, nil
}

// BatchQuery 批量查询
func (c *Client) BatchQuery(ctx context.Context, req *BatchQueryRequest) (*BatchQueryResponse, error) {
	var resp BatchQueryResponse
	err := c.Do(ctx, "POST", "/openapi/v1/key/batch_query", req, &resp)
	if err != nil {
		return nil, err
	}
	return &resp, nil
}

// File: ymt_v3_generate/example_test.go

package ymt_v3_generate_test

import (
	"context"
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"encoding/base64"
	"encoding/json"
	"encoding/pem"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	sdk "ymt_v3_generate"
)

// 生成测试用的 RSA 密钥对和 AES key
func generateTestKeys() (privateKeyPEM, publicKeyPEM string, aesKey []byte, err error) {
	// RSA 密钥
	priv, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		return "", "", nil, err
	}
	privBytes := x509.MarshalPKCS1PrivateKey(priv)
	privPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes})
	publicKey := &priv.PublicKey
	pubBytes, err := x509.MarshalPKIXPublicKey(publicKey)
	if err != nil {
		return "", "", nil, err
	}
	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})

	// AES key (16字节)
	aesKey = make([]byte, 16)
	_, err = rand.Read(aesKey)
	if err != nil {
		return "", "", nil, err
	}
	return string(privPEM), string(pubPEM), aesKey, nil
}

// 模拟服务器,返回加密的响应
func mockServer(t *testing.T, aesKey []byte, responseBody interface{}) *httptest.Server {
	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 验证请求头
		appID := r.Header.Get("Appid")
		timestamp := r.Header.Get("Timestamp")
		sign := r.Header.Get("Sign")
		if appID == "" || timestamp == "" || sign == "" {
			http.Error(w, "missing headers", http.StatusBadRequest)
			return
		}
		// 读取请求体
		var reqBody map[string]string
		if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
			http.Error(w, "invalid body", http.StatusBadRequest)
			return
		}
		ciphertext := reqBody["ciphertext"]
		if ciphertext == "" {
			http.Error(w, "missing ciphertext", http.StatusBadRequest)
			return
		}
		// 这里不验证签名,仅测试流程

		// 构造响应
		respPlaintext, _ := json.Marshal(responseBody)
		// 加密
		encrypted, err := sdk.EncryptPlaintext(string(respPlaintext), aesKey)
		if err != nil {
			http.Error(w, "encrypt error", http.StatusInternalServerError)
			return
		}
		apiResp := map[string]interface{}{
			"code":    200,
			"message": "成功",
			"data": map[string]string{
				"ciphertext": encrypted,
			},
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(apiResp)
	}))
}

func TestCreateKeyOrder(t *testing.T) {
	privPEM, pubPEM, aesKey, err := generateTestKeys()
	if err != nil {
		t.Fatal(err)
	}
	server := mockServer(t, aesKey, sdk.KeyOrderResponse{
		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",
	})
	defer server.Close()

	client := sdk.NewClient(
		sdk.WithAppID("test_app"),
		sdk.WithPrivateKey(privPEM),
		sdk.WithPublicKey(pubPEM),
		sdk.WithKey(base64.StdEncoding.EncodeToString(aesKey)),
		sdk.WithBaseURL(server.URL),
	)

	req := &sdk.KeyOrderRequest{
		OutBizNo:   "order_001",
		ActivityNo: "ACT20260622001",
		Account:    "18666666666",
		NotifyURL:  "https://notify.example.com/openapi",
	}
	resp, err := client.CreateKeyOrder(context.Background(), req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.TradeNo != "7251449503000383488" {
		t.Errorf("expected trade_no 7251449503000383488, got %s", resp.TradeNo)
	}
}

func TestQueryKey(t *testing.T) {
	privPEM, pubPEM, aesKey, err := generateTestKeys()
	if err != nil {
		t.Fatal(err)
	}
	server := mockServer(t, aesKey, sdk.KeyQueryResponse{
		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",
	})
	defer server.Close()

	client := sdk.NewClient(
		sdk.WithAppID("test_app"),
		sdk.WithPrivateKey(privPEM),
		sdk.WithPublicKey(pubPEM),
		sdk.WithKey(base64.StdEncoding.EncodeToString(aesKey)),
		sdk.WithBaseURL(server.URL),
	)

	req := &sdk.KeyQueryRequest{
		OutBizNo: "order_001",
	}
	resp, err := client.QueryKey(context.Background(), req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.TradeNo != "7251449503000383488" {
		t.Errorf("expected trade_no 7251449503000383488, got %s", resp.TradeNo)
	}
}

func TestDiscardKey(t *testing.T) {
	privPEM, pubPEM, aesKey, err := generateTestKeys()
	if err != nil {
		t.Fatal(err)
	}
	server := mockServer(t, aesKey, sdk.KeyDiscardResponse{
		OutBizNo: "order_001",
		TradeNo:  "7251449503000383488",
		Status:   3,
	})
	defer server.Close()

	client := sdk.NewClient(
		sdk.WithAppID("test_app"),
		sdk.WithPrivateKey(privPEM),
		sdk.WithPublicKey(pubPEM),
		sdk.WithKey(base64.StdEncoding.EncodeToString(aesKey)),
		sdk.WithBaseURL(server.URL),
	)

	req := &sdk.KeyDiscardRequest{
		TradeNo: "7251449503000383488",
	}
	resp, err := client.DiscardKey(context.Background(), req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.Status != 3 {
		t.Errorf("expected status 3, got %d", resp.Status)
	}
}

func TestBatchOrder(t *testing.T) {
	privPEM, pubPEM, aesKey, err := generateTestKeys()
	if err != nil {
		t.Fatal(err)
	}
	server := mockServer(t, aesKey, sdk.BatchOrderResponse{
		OutBizNo: "batch_001",
		TradeNo:  "7251449503000383499",
		Status:   "processing",
	})
	defer server.Close()

	client := sdk.NewClient(
		sdk.WithAppID("test_app"),
		sdk.WithPrivateKey(privPEM),
		sdk.WithPublicKey(pubPEM),
		sdk.WithKey(base64.StdEncoding.EncodeToString(aesKey)),
		sdk.WithBaseURL(server.URL),
	)

	req := &sdk.BatchOrderRequest{
		OutBizNo:   "batch_001",
		ActivityNo: "ACT20260622001",
		Number:     100,
	}
	resp, err := client.BatchOrder(context.Background(), req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.Status != "processing" {
		t.Errorf("expected status processing, got %s", resp.Status)
	}
}

func TestBatchQuery(t *testing.T) {
	privPEM, pubPEM, aesKey, err := generateTestKeys()
	if err != nil {
		t.Fatal(err)
	}
	server := mockServer(t, aesKey, sdk.BatchQueryResponse{
		OutBizNo:    "batch_001",
		TradeNo:     "7251449503000383499",
		Status:      "success",
		DownloadURL: "https://oss.example.com/openapi_7251449503000383499.zip",
		ZipPassword: "123456",
	})
	defer server.Close()

	client := sdk.NewClient(
		sdk.WithAppID("test_app"),
		sdk.WithPrivateKey(privPEM),
		sdk.WithPublicKey(pubPEM),
		sdk.WithKey(base64.StdEncoding.EncodeToString(aesKey)),
		sdk.WithBaseURL(server.URL),
	)

	req := &sdk.BatchQueryRequest{
		TradeNo: "7251449503000383499",
	}
	resp, err := client.BatchQuery(context.Background(), req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.Status != "success" {
		t.Errorf("expected status success, got %s", resp.Status)
	}
	if resp.DownloadURL == "" {
		t.Error("expected download_url not empty")
	}
}