ymt_v3_2-20260721-151016/ymt_v3_2/valid.md

16 KiB

// File: ymt_v3_2/go.mod

module ymt_v3_2

go 1.21

// File: ymt_v3_2/client.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

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

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

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

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

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)
}