diff --git a/ymt_v3_2/generate.md b/ymt_v3_2/generate.md new file mode 100644 index 0000000..a9c4ca2 --- /dev/null +++ b/ymt_v3_2/generate.md @@ -0,0 +1,767 @@ +// File: ymt_v3_2/go.mod +```go +module ymt_v3_2 + +go 1.21 +``` + +// File: ymt_v3_2/client.go +```go +package ymt_v3_2 + +import ( + "bytes" + "context" + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client 是 SDK 的客户端,封装了 HTTP 通信、加密、签名等逻辑。 +type Client struct { + httpClient *http.Client + baseURL string + appID string + privateKey *rsa.PrivateKey + publicKey *rsa.PublicKey + aesKey []byte + signType string +} + +// Option 是配置 Client 的函数选项。 +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 + } +} + +// WithAppID 设置应用 ID。 +func WithAppID(appID string) Option { + return func(c *Client) { + c.appID = appID + } +} + +// WithPrivateKeyPEM 设置应用私钥(PEM 格式字符串)。 +func WithPrivateKeyPEM(pemData string) Option { + return func(c *Client) { + key, err := parsePrivateKey(pemData) + if err != nil { + panic(fmt.Sprintf("failed to parse private key: %v", err)) + } + c.privateKey = key + } +} + +// WithPublicKeyPEM 设置平台公钥(PEM 格式字符串),用于验签。 +func WithPublicKeyPEM(pemData string) Option { + return func(c *Client) { + key, err := parsePublicKey(pemData) + if err != nil { + panic(fmt.Sprintf("failed to parse public key: %v", err)) + } + c.publicKey = key + } +} + +// WithAESKey 设置业务参数加解密密钥(AES 密钥,16/24/32 字节)。 +func WithAESKey(key string) Option { + return func(c *Client) { + c.aesKey = []byte(key) + } +} + +// WithSignType 设置签名类型,目前仅支持 "RSA"。 +func WithSignType(signType string) Option { + return func(c *Client) { + c.signType = signType + } +} + +// NewClient 创建一个新的客户端实例。 +func NewClient(opts ...Option) *Client { + c := &Client{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: "https://gateway.dev.cdlsxd.cn", + signType: "RSA", + } + for _, opt := range opts { + opt(c) + } + return c +} + +// Do 执行一个 API 请求,自动处理加密、签名、解密。 +// bizReq 为业务请求结构体,bizResp 为业务响应结构体指针。 +func (c *Client) Do(ctx context.Context, method, path string, bizReq interface{}, bizResp interface{}) error { + // 1. 业务参数过滤零值、排序、序列化 + plaintext, err := marshalSortedNoZero(bizReq) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + // 2. AES 加密 + ciphertext, err := aesEncryptECB(plaintext, c.aesKey) + if err != nil { + return fmt.Errorf("encrypt: %w", err) + } + + // 3. 生成时间戳 + timestamp := time.Now().Format("2006-01-02 15:04:05") + + // 4. 签名 + signStr := c.appID + timestamp + ciphertext + sign, err := rsaSign([]byte(signStr), c.privateKey) + if err != nil { + return fmt.Errorf("sign: %w", err) + } + + // 5. 构造请求体 + reqBody := cipherRequest{Ciphertext: ciphertext} + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + + // 6. 创建 HTTP 请求 + url := c.baseURL + path + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Appid", c.appID) + req.Header.Set("Timestamp", timestamp) + req.Header.Set("Sign", sign) + req.Header.Set("Content-Type", "application/json") + + // 7. 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + // 8. 解析公共响应 + var commonResp commonResponse + if err := json.Unmarshal(respBytes, &commonResp); err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + + if commonResp.Code != 200 { + return &APIError{ + Code: commonResp.Code, + Message: commonResp.Message, + Reason: commonResp.Reason, + } + } + + // 9. 解密业务数据 + if commonResp.Data == nil || commonResp.Data.Ciphertext == "" { + // 某些接口可能直接返回 data 中的明文(如查询接口文档示例中 ciphertext 为空) + // 此时尝试直接解析整个 data 到 bizResp + if commonResp.Data != nil { + // 如果 data 中有其他字段,尝试直接反序列化 data + dataBytes, _ := json.Marshal(commonResp.Data) + if err := json.Unmarshal(dataBytes, bizResp); err != nil { + return fmt.Errorf("unmarshal plain data: %w", err) + } + return nil + } + return nil + } + + // 解密 ciphertext + decrypted, err := aesDecryptECB(commonResp.Data.Ciphertext, c.aesKey) + if err != nil { + return fmt.Errorf("decrypt response: %w", err) + } + + if err := json.Unmarshal(decrypted, bizResp); err != nil { + return fmt.Errorf("unmarshal decrypted response: %w", err) + } + + return nil +} + +// cipherRequest 请求体结构 +type cipherRequest struct { + Ciphertext string `json:"ciphertext"` +} + +// commonResponse 公共响应结构 +type commonResponse struct { + Code int32 `json:"code"` + Message string `json:"message"` + Reason string `json:"reason,omitempty"` + Data *cipherData `json:"data,omitempty"` +} + +// cipherData 加密数据载体 +type cipherData struct { + Ciphertext string `json:"ciphertext"` +} +``` + +// File: ymt_v3_2/types.go +```go +package ymt_v3_2 + +// GetKeyRequest 获取券码请求参数 +type GetKeyRequest struct { + OutBizNo string `json:"out_biz_no"` + ActivityNo string `json:"activity_no"` + Account string `json:"account,omitempty"` + NotifyURL string `json:"notify_url,omitempty"` +} + +// GetKeyResponse 获取券码响应参数 +type GetKeyResponse 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 = GetKeyResponse + +// 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_2/crypto.go +```go +package ymt_v3_2 + +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" +) + +// parsePrivateKey 解析 PEM 格式的 RSA 私钥。 +func parsePrivateKey(pemData string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemData)) + if block == nil { + return nil, errors.New("failed to decode PEM block") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + // 尝试 PKCS1 + key, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + } + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("not an RSA private key") + } + return rsaKey, nil +} + +// parsePublicKey 解析 PEM 格式的 RSA 公钥。 +func parsePublicKey(pemData string) (*rsa.PublicKey, error) { + block, _ := pem.Decode([]byte(pemData)) + if block == nil { + return nil, errors.New("failed to decode PEM block") + } + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse public key: %w", err) + } + rsaKey, ok := key.(*rsa.PublicKey) + if !ok { + return nil, errors.New("not an RSA public key") + } + return rsaKey, nil +} + +// marshalSortedNoZero 将结构体转为 map,过滤零值,按 key 排序后序列化为 JSON 字符串。 +func marshalSortedNoZero(v interface{}) ([]byte, error) { + // 先转为 map + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + // 过滤零值 + filtered := removeZeroValues(raw) + + // 按 key 排序 + keys := make([]string, 0, len(filtered)) + for k := range filtered { + keys = append(keys, k) + } + sort.Strings(keys) + + // 构建有序的 JSON 字符串 + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyBytes, _ := json.Marshal(k) + buf.Write(keyBytes) + buf.WriteByte(':') + valBytes, err := json.Marshal(filtered[k]) + if err != nil { + return nil, err + } + buf.Write(valBytes) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +// removeZeroValues 递归移除 map 中的零值(nil, 空字符串, 0, false, 空切片/数组, 空 map)。 +func removeZeroValues(m map[string]interface{}) map[string]interface{} { + res := make(map[string]interface{}) + for k, v := range m { + if isZeroValue(v) { + continue + } + // 递归处理嵌套 map + if sub, ok := v.(map[string]interface{}); ok { + subFiltered := removeZeroValues(sub) + if len(subFiltered) > 0 { + res[k] = subFiltered + } + } else { + res[k] = v + } + } + return res +} + +// isZeroValue 判断值是否为零值。 +func isZeroValue(v interface{}) bool { + if v == nil { + return true + } + switch val := v.(type) { + case string: + return val == "" + case float64: + return val == 0 + case bool: + return !val + case []interface{}: + return len(val) == 0 + case map[string]interface{}: + return len(val) == 0 + default: + return false + } +} + +// aesEncryptECB 使用 AES ECB 模式加密,PKCS7 填充。 +func aesEncryptECB(plaintext, key []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + blockSize := block.BlockSize() + plaintext = pkcs7Padding(plaintext, blockSize) + + ciphertext := make([]byte, len(plaintext)) + // ECB 模式:逐块加密 + for i := 0; i < len(plaintext); i += blockSize { + block.Encrypt(ciphertext[i:i+blockSize], plaintext[i:i+blockSize]) + } + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// aesDecryptECB 使用 AES ECB 模式解密,PKCS7 去填充。 +func aesDecryptECB(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 + } + blockSize := block.BlockSize() + if len(data)%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 += blockSize { + block.Decrypt(plaintext[i:i+blockSize], data[i:i+blockSize]) + } + plaintext, err = pkcs7Unpadding(plaintext, blockSize) + if err != nil { + return nil, err + } + return plaintext, nil +} + +// 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, blockSize int) ([]byte, error) { + length := len(data) + if length == 0 { + return nil, errors.New("empty data") + } + padding := int(data[length-1]) + if padding > blockSize || padding == 0 { + return nil, errors.New("invalid padding") + } + for i := length - padding; i < length; i++ { + if data[i] != byte(padding) { + return nil, errors.New("invalid padding") + } + } + return data[:length-padding], nil +} + +// rsaSign 使用 RSA 私钥对数据进行 SHA256 签名,返回 Base64 编码的签名。 +func rsaSign(data []byte, privateKey *rsa.PrivateKey) (string, error) { + hashed := sha256.Sum256(data) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:]) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(signature), nil +} + +// rsaVerify 使用 RSA 公钥验证签名。 +func rsaVerify(data []byte, sign string, publicKey *rsa.PublicKey) error { + signBytes, err := base64.StdEncoding.DecodeString(sign) + if err != nil { + return err + } + hashed := sha256.Sum256(data) + return rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], signBytes) +} + +// VerifyCallbackSign 用于回调验签:根据回调数据、签名、appID、timestamp 验证签名。 +// callbackData 为回调 body 中的 data 字段(JSON 对象),需要过滤零值排序后加密再验签。 +func (c *Client) VerifyCallbackSign(appID, timestamp, sign string, callbackData map[string]interface{}) error { + // 过滤零值并排序 + filtered := removeZeroValues(callbackData) + keys := make([]string, 0, len(filtered)) + for k := range filtered { + keys = append(keys, k) + } + sort.Strings(keys) + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyBytes, _ := json.Marshal(k) + buf.Write(keyBytes) + buf.WriteByte(':') + valBytes, err := json.Marshal(filtered[k]) + if err != nil { + return err + } + buf.Write(valBytes) + } + buf.WriteByte('}') + plaintext := buf.Bytes() + + // 加密 + ciphertext, err := aesEncryptECB(plaintext, c.aesKey) + if err != nil { + return err + } + + // 拼接签名字符串 + signStr := appID + timestamp + ciphertext + return rsaVerify([]byte(signStr), sign, c.publicKey) +} + +// 确保 crypto 包被使用(避免未使用导入的编译错误,实际已被使用) +var _ = cipher.NewCBCEncrypter +var _ = strings.ReplaceAll +``` + +// File: ymt_v3_2/errors.go +```go +package ymt_v3_2 + +import "fmt" + +// APIError 表示 API 返回的业务错误。 +type APIError struct { + Code int32 `json:"code"` + Message string `json:"message"` + Reason string `json:"reason,omitempty"` +} + +// Error 实现 error 接口。 +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_2/api_key.go +```go +package ymt_v3_2 + +import "context" + +// GetKey 获取券码 +func (c *Client) GetKey(ctx context.Context, req *GetKeyRequest) (*GetKeyResponse, error) { + var resp GetKeyResponse + 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 *QueryKeyRequest) (*QueryKeyResponse, error) { + var resp QueryKeyResponse + 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 *DiscardKeyRequest) (*DiscardKeyResponse, error) { + var resp DiscardKeyResponse + 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_2/example_test.go +```go +package ymt_v3_2_test + +import ( + "context" + "fmt" + "testing" + "time" + + "ymt_v3_2" +) + +// 测试参数,请替换为真实值 +const ( + testAppID = "xxx" + testPrivateKey = `-----BEGIN PRIVATE KEY----- +xxx +-----END PRIVATE KEY-----` + testPublicKey = `-----BEGIN PUBLIC KEY----- +xxx +-----END PUBLIC KEY-----` + testAESKey = "xxxx" + testActivityNo = "xxxx" +) + +func newTestClient() *ymt_v3_2.Client { + return ymt_v3_2.NewClient( + ymt_v3_2.WithBaseURL("https://gateway.dev.cdlsxd.cn"), + ymt_v3_2.WithAppID(testAppID), + ymt_v3_2.WithPrivateKeyPEM(testPrivateKey), + ymt_v3_2.WithPublicKeyPEM(testPublicKey), + ymt_v3_2.WithAESKey(testAESKey), + ymt_v3_2.WithTimeout(10*time.Second), + ) +} + +func TestGetKey(t *testing.T) { + client := newTestClient() + ctx := context.Background() + req := &ymt_v3_2.GetKeyRequest{ + OutBizNo: fmt.Sprintf("test_%d", time.Now().UnixNano()), + ActivityNo: testActivityNo, + Account: "18666666666", + NotifyURL: "https://notify.example.com/openapi", + } + resp, err := client.GetKey(ctx, req) + if err != nil { + t.Fatalf("GetKey failed: %v", err) + } + t.Logf("GetKey response: %+v", resp) +} + +func TestQueryKey(t *testing.T) { + client := newTestClient() + ctx := context.Background() + req := &ymt_v3_2.QueryKeyRequest{ + OutBizNo: "order_001", // 替换为真实的外部业务号 + } + resp, err := client.QueryKey(ctx, req) + if err != nil { + t.Fatalf("QueryKey failed: %v", err) + } + t.Logf("QueryKey response: %+v", resp) +} + +func TestDiscardKey(t *testing.T) { + client := newTestClient() + ctx := context.Background() + req := &ymt_v3_2.DiscardKeyRequest{ + TradeNo: "7251449503000383488", // 替换为真实的交易号 + } + resp, err := client.DiscardKey(ctx, req) + if err != nil { + t.Fatalf("DiscardKey failed: %v", err) + } + t.Logf("DiscardKey response: %+v", resp) +} + +func TestBatchOrder(t *testing.T) { + client := newTestClient() + ctx := context.Background() + req := &ymt_v3_2.BatchOrderRequest{ + OutBizNo: fmt.Sprintf("batch_%d", time.Now().UnixNano()), + ActivityNo: testActivityNo, + Number: 10, + NotifyURL: "https://notify.example.com/openapi", + } + resp, err := client.BatchOrder(ctx, req) + if err != nil { + t.Fatalf("BatchOrder failed: %v", err) + } + t.Logf("BatchOrder response: %+v", resp) +} + +func TestBatchQuery(t *testing.T) { + client := newTestClient() + ctx := context.Background() + req := &ymt_v3_2.BatchQueryRequest{ + OutBizNo: "batch_001", // 替换为真实的外部业务号 + } + resp, err := client.BatchQuery(ctx, req) + if err != nil { + t.Fatalf("BatchQuery failed: %v", err) + } + t.Logf("BatchQuery response: %+v", resp) +} +``` \ No newline at end of file