diff --git a/ymt_v3_2/valid.md b/ymt_v3_2/valid.md new file mode 100644 index 0000000..0920e90 --- /dev/null +++ b/ymt_v3_2/valid.md @@ -0,0 +1,695 @@ +// 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" +) + +type Client struct { + httpClient *http.Client + baseURL string + appID string + privateKey *rsa.PrivateKey + publicKey *rsa.PublicKey + aesKey []byte + signType string +} + +type Option func(*Client) + +func WithBaseURL(url string) Option { + return func(c *Client) { + c.baseURL = url + } +} + +func WithTimeout(d time.Duration) Option { + return func(c *Client) { + c.httpClient.Timeout = d + } +} + +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) { + c.httpClient = client + } +} + +func WithAppID(appID string) Option { + return func(c *Client) { + c.appID = appID + } +} + +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 + } +} + +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 + } +} + +func WithAESKey(key string) Option { + return func(c *Client) { + c.aesKey = []byte(key) + } +} + +func WithSignType(signType string) Option { + return func(c *Client) { + c.signType = signType + } +} + +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 +} + +func (c *Client) Do(ctx context.Context, method, path string, bizReq interface{}, bizResp interface{}) error { + plaintext, err := marshalSortedNoZero(bizReq) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + ciphertext, err := aesEncryptECB(plaintext, c.aesKey) + if err != nil { + return fmt.Errorf("encrypt: %w", err) + } + + timestamp := time.Now().Format("2006-01-02 15:04:05") + + signStr := c.appID + timestamp + ciphertext + sign, err := rsaSign([]byte(signStr), c.privateKey) + if err != nil { + return fmt.Errorf("sign: %w", err) + } + + reqBody := cipherRequest{Ciphertext: ciphertext} + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + + 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") + + 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) + } + + 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, + } + } + + if len(commonResp.Data) == 0 { + return nil + } + + var cd cipherData + if err := json.Unmarshal(commonResp.Data, &cd); err == nil && cd.Ciphertext != "" { + decrypted, err := aesDecryptECB(cd.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) + } + } else { + if err := json.Unmarshal(commonResp.Data, bizResp); err != nil { + return fmt.Errorf("unmarshal plain data: %w", err) + } + } + + return nil +} + +type cipherRequest struct { + Ciphertext string `json:"ciphertext"` +} + +type commonResponse struct { + Code int32 `json:"code"` + Message string `json:"message"` + Reason string `json:"reason,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +type cipherData struct { + Ciphertext string `json:"ciphertext"` +} +``` + +// File: ymt_v3_2/types.go +```go +package ymt_v3_2 + +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"` +} + +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"` +} + +type QueryKeyRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +type QueryKeyResponse = GetKeyResponse + +type DiscardKeyRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +type DiscardKeyResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status uint32 `json:"status"` +} + +type BatchOrderRequest struct { + OutBizNo string `json:"out_biz_no"` + ActivityNo string `json:"activity_no"` + Number int32 `json:"number"` + NotifyURL string `json:"notify_url,omitempty"` +} + +type BatchOrderResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status string `json:"status"` +} + +type BatchQueryRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +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" +) + +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 { + 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 +} + +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 +} + +func marshalSortedNoZero(v interface{}) ([]byte, error) { + 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) + + 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 nil, err + } + buf.Write(valBytes) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +func removeZeroValues(m map[string]interface{}) map[string]interface{} { + res := make(map[string]interface{}) + for k, v := range m { + if isZeroValue(v) { + continue + } + if sub, ok := v.(map[string]interface{}); ok { + subFiltered := removeZeroValues(sub) + if len(subFiltered) > 0 { + res[k] = subFiltered + } + } else { + res[k] = v + } + } + return res +} + +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 + } +} + +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)) + for i := 0; i < len(plaintext); i += blockSize { + block.Encrypt(ciphertext[i:i+blockSize], plaintext[i:i+blockSize]) + } + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +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 +} + +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, 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 +} + +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 +} + +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) +} + +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) +} + +var _ = cipher.NewCBCEncrypter +var _ = strings.ReplaceAll +``` + +// File: ymt_v3_2/errors.go +```go +package ymt_v3_2 + +import "fmt" + +type APIError struct { + Code int32 `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_2/api_key.go +```go +package ymt_v3_2 + +import "context" + +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 +} + +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 +} + +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 +} + +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 +} + +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