diff --git a/ymt_v3_generate/res.md b/ymt_v3_generate/res.md new file mode 100644 index 0000000..9349958 --- /dev/null +++ b/ymt_v3_generate/res.md @@ -0,0 +1,859 @@ +// File: ymt_v3_generate/go.mod +```go +module ymt_v3_generate + +go 1.21 +``` + +// File: ymt_v3_generate/errors.go +```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 +```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 +```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 +```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 +```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 +```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") + } +} +``` \ No newline at end of file