Skip to content

Go 完整代码

适用于 Go 1.20+,只使用标准库,包含:

  • MD5 + HMAC-SHA256 签名
  • 商品自动分页和 SKU 映射
  • 表格、JSON、UTF-8 BOM CSV 输出
  • 使用 sku_code 自动解析 sku_id
  • 创建、查询、取消订单
  • 等待交付并安全保存 payload

快速运行

powershell
$env:DUJIAO_BASE_URL = "https://cheapemail.cc"
$env:DUJIAO_API_KEY = "your_api_key"
$env:DUJIAO_API_SECRET = "your_api_secret"

go run dujiao_client.go -action ping
go run dujiao_client.go -action skus -format csv -output skus.csv
go run dujiao_client.go -action order -sku-code "SKU-1" -quantity 1 -wait

也可以先编译:

powershell
go build -o dujiao-client.exe dujiao_client.go
.\dujiao-client.exe -action skus

源码

go
// CheapEmail / Dujiao-Next Open API client using only the Go standard library.
package main

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/md5"
	"crypto/sha256"
	"encoding/csv"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"text/tabwriter"
	"time"
)

type APIError struct {
	StatusCode int
	Code       string
	Message    string
}

func (e *APIError) Error() string {
	if e.Code != "" {
		return fmt.Sprintf("%s: %s (HTTP %d)", e.Code, e.Message, e.StatusCode)
	}
	return fmt.Sprintf("%s (HTTP %d)", e.Message, e.StatusCode)
}

type APIBase struct {
	OK           bool   `json:"ok"`
	ErrorCode    string `json:"error_code"`
	ErrorMessage string `json:"error_message"`
}

type SKU struct {
	ID            int64          `json:"id"`
	SKUCode       string         `json:"sku_code"`
	SpecValues    map[string]any `json:"spec_values"`
	PriceAmount   string         `json:"price_amount"`
	StockStatus   string         `json:"stock_status"`
	StockQuantity int            `json:"stock_quantity"`
	IsActive      bool           `json:"is_active"`
}

type Product struct {
	ID              int64             `json:"id"`
	Slug            string            `json:"slug"`
	Title           map[string]string `json:"title"`
	Description     map[string]string `json:"description"`
	PriceAmount     string            `json:"price_amount"`
	FulfillmentType string            `json:"fulfillment_type"`
	CategoryID      int64             `json:"category_id"`
	SKUs            []SKU             `json:"skus"`
}

type ProductsResponse struct {
	APIBase
	Items    []Product `json:"items"`
	Total    int       `json:"total"`
	Page     int       `json:"page"`
	PageSize int       `json:"page_size"`
}

type Fulfillment struct {
	Type         string `json:"type"`
	Status       string `json:"status"`
	Payload      string `json:"payload"`
	DeliveryData any    `json:"delivery_data"`
	DeliveredAt  string `json:"delivered_at"`
}

type OrderResponse struct {
	APIBase
	OrderID     int64        `json:"order_id"`
	OrderNo     string       `json:"order_no"`
	Status      string       `json:"status"`
	Amount      string       `json:"amount"`
	Currency    string       `json:"currency"`
	Items       []any        `json:"items"`
	Fulfillment *Fulfillment `json:"fulfillment"`
}

type SKURow struct {
	SKUCode       string `json:"sku_code"`
	ProductTitle  string `json:"product_title"`
	Description   string `json:"description"`
	Specification string `json:"specification"`
	Price         string `json:"price"`
	StockStatus   string `json:"stock_status"`
	StockQuantity int    `json:"stock_quantity"`
	SKUID         int64  `json:"sku_id"`
	ProductID     int64  `json:"product_id"`
}

type Client struct {
	BaseURL    string
	APIKey     string
	APISecret  string
	HTTPClient *http.Client
}

func NewClient(baseURL, apiKey, apiSecret string, timeout time.Duration) *Client {
	return &Client{
		BaseURL:   strings.TrimRight(baseURL, "/"),
		APIKey:    apiKey,
		APISecret: apiSecret,
		HTTPClient: &http.Client{
			Timeout: timeout,
		},
	}
}

func (c *Client) signature(method, path, timestamp string, body []byte) string {
	bodyMD5 := md5.Sum(body)
	signString := strings.Join([]string{
		strings.ToUpper(method),
		path,
		timestamp,
		hex.EncodeToString(bodyMD5[:]),
	}, "\n")
	mac := hmac.New(sha256.New, []byte(c.APISecret))
	_, _ = mac.Write([]byte(signString))
	return hex.EncodeToString(mac.Sum(nil))
}

func (c *Client) request(
	ctx context.Context,
	method string,
	path string,
	query url.Values,
	payload any,
	out any,
) error {
	body := []byte{}
	var err error
	if payload != nil {
		body, err = json.Marshal(payload)
		if err != nil {
			return fmt.Errorf("encode request: %w", err)
		}
	}

	requestURL := c.BaseURL + path
	if len(query) > 0 {
		requestURL += "?" + query.Encode()
	}
	var reader io.Reader
	if method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch {
		reader = bytes.NewReader(body)
	}
	req, err := http.NewRequestWithContext(ctx, method, requestURL, reader)
	if err != nil {
		return fmt.Errorf("create request: %w", err)
	}
	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Dujiao-Next-Api-Key", c.APIKey)
	req.Header.Set("Dujiao-Next-Timestamp", timestamp)
	req.Header.Set("Dujiao-Next-Signature", c.signature(method, path, timestamp, body))

	response, err := c.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("request failed: %w", err)
	}
	defer response.Body.Close()
	raw, err := io.ReadAll(response.Body)
	if err != nil {
		return fmt.Errorf("read response: %w", err)
	}

	var base APIBase
	if err := json.Unmarshal(raw, &base); err != nil {
		return fmt.Errorf("server returned invalid JSON: %w", err)
	}
	if response.StatusCode < 200 || response.StatusCode >= 300 || !base.OK {
		message := base.ErrorMessage
		if message == "" {
			message = http.StatusText(response.StatusCode)
		}
		return &APIError{
			StatusCode: response.StatusCode,
			Code:       base.ErrorCode,
			Message:    message,
		}
	}
	if err := json.Unmarshal(raw, out); err != nil {
		return fmt.Errorf("decode response: %w", err)
	}
	return nil
}

func (c *Client) Ping(ctx context.Context) (map[string]any, error) {
	var result map[string]any
	err := c.request(ctx, http.MethodPost, "/api/v1/upstream/ping", nil, nil, &result)
	return result, err
}

func (c *Client) ListCategories(ctx context.Context) (map[string]any, error) {
	var result map[string]any
	err := c.request(ctx, http.MethodGet, "/api/v1/upstream/categories", nil, nil, &result)
	return result, err
}

func (c *Client) ListProducts(ctx context.Context, page, pageSize int) (*ProductsResponse, error) {
	query := url.Values{
		"page":      {strconv.Itoa(page)},
		"page_size": {strconv.Itoa(pageSize)},
	}
	var result ProductsResponse
	err := c.request(ctx, http.MethodGet, "/api/v1/upstream/products", query, nil, &result)
	return &result, err
}

func (c *Client) ListAllProducts(ctx context.Context) ([]Product, error) {
	const pageSize = 100
	products := make([]Product, 0)
	for page := 1; page <= 100; page++ {
		result, err := c.ListProducts(ctx, page, pageSize)
		if err != nil {
			return nil, err
		}
		products = append(products, result.Items...)
		if len(result.Items) == 0 || len(products) >= result.Total || len(result.Items) < pageSize {
			return products, nil
		}
	}
	return nil, errors.New("product pagination exceeded 100 pages")
}

func (c *Client) GetProduct(ctx context.Context, productID int64) (map[string]any, error) {
	var result map[string]any
	path := fmt.Sprintf("/api/v1/upstream/products/%d", productID)
	err := c.request(ctx, http.MethodGet, path, nil, nil, &result)
	return result, err
}

func localized(value map[string]string, language string) string {
	for _, key := range []string{language, "zh-CN", "zh-TW", "en-US", "en"} {
		if text := strings.TrimSpace(value[key]); text != "" {
			return text
		}
	}
	keys := make([]string, 0, len(value))
	for key := range value {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	for _, key := range keys {
		if text := strings.TrimSpace(value[key]); text != "" {
			return text
		}
	}
	return ""
}

func specificationText(values map[string]any) string {
	keys := make([]string, 0, len(values))
	for key := range values {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	parts := make([]string, 0, len(keys))
	for _, key := range keys {
		parts = append(parts, fmt.Sprintf("%s: %v", key, values[key]))
	}
	return strings.Join(parts, " / ")
}

func (c *Client) ListSKUs(ctx context.Context, language string) ([]SKURow, error) {
	products, err := c.ListAllProducts(ctx)
	if err != nil {
		return nil, err
	}
	rows := make([]SKURow, 0)
	for _, product := range products {
		for _, sku := range product.SKUs {
			if !sku.IsActive || sku.SKUCode == "" {
				continue
			}
			price := sku.PriceAmount
			if price == "" {
				price = product.PriceAmount
			}
			rows = append(rows, SKURow{
				SKUCode:       sku.SKUCode,
				ProductTitle:  localized(product.Title, language),
				Description:   localized(product.Description, language),
				Specification: specificationText(sku.SpecValues),
				Price:         price,
				StockStatus:   sku.StockStatus,
				StockQuantity: sku.StockQuantity,
				SKUID:         sku.ID,
				ProductID:     product.ID,
			})
		}
	}
	sort.Slice(rows, func(i, j int) bool { return rows[i].SKUCode < rows[j].SKUCode })
	return rows, nil
}

func (c *Client) FindSKU(ctx context.Context, skuCode string) (*SKURow, error) {
	rows, err := c.ListSKUs(ctx, "zh-CN")
	if err != nil {
		return nil, err
	}
	var match *SKURow
	for index := range rows {
		if rows[index].SKUCode != skuCode {
			continue
		}
		if match != nil {
			return nil, fmt.Errorf("duplicate SKU code returned by upstream: %s", skuCode)
		}
		match = &rows[index]
	}
	if match == nil {
		return nil, fmt.Errorf("SKU code not found: %s", skuCode)
	}
	return match, nil
}

func (c *Client) CreateOrder(
	ctx context.Context,
	skuID int64,
	quantity int,
) (*OrderResponse, error) {
	if quantity < 1 {
		return nil, errors.New("quantity must be at least 1")
	}
	payload := map[string]any{
		"sku_id":   skuID,
		"quantity": quantity,
	}
	var result OrderResponse
	err := c.request(ctx, http.MethodPost, "/api/v1/upstream/orders", nil, payload, &result)
	return &result, err
}

func (c *Client) CreateOrderBySKUCode(
	ctx context.Context,
	skuCode string,
	quantity int,
) (*OrderResponse, error) {
	sku, err := c.FindSKU(ctx, skuCode)
	if err != nil {
		return nil, err
	}
	if sku.StockStatus == "out_of_stock" {
		return nil, fmt.Errorf("SKU is out of stock: %s", skuCode)
	}
	return c.CreateOrder(ctx, sku.SKUID, quantity)
}

func (c *Client) GetOrder(ctx context.Context, orderID int64) (*OrderResponse, error) {
	var result OrderResponse
	path := fmt.Sprintf("/api/v1/upstream/orders/%d", orderID)
	err := c.request(ctx, http.MethodGet, path, nil, nil, &result)
	return &result, err
}

func (c *Client) CancelOrder(ctx context.Context, orderID int64) (*OrderResponse, error) {
	var result OrderResponse
	path := fmt.Sprintf("/api/v1/upstream/orders/%d/cancel", orderID)
	err := c.request(ctx, http.MethodPost, path, nil, nil, &result)
	return &result, err
}

func (c *Client) WaitForOrder(
	ctx context.Context,
	orderID int64,
	interval time.Duration,
	timeout time.Duration,
) (*OrderResponse, error) {
	deadline := time.Now().Add(timeout)
	for {
		order, err := c.GetOrder(ctx, orderID)
		if err != nil {
			return nil, err
		}
		if (order.Status == "delivered" || order.Status == "completed") && order.Fulfillment != nil {
			return order, nil
		}
		if order.Status == "canceled" {
			return nil, fmt.Errorf("order %d was canceled", orderID)
		}
		remaining := time.Until(deadline)
		if remaining <= 0 {
			return nil, fmt.Errorf(
				"timed out waiting for order %d; last status: %s",
				orderID,
				order.Status,
			)
		}
		wait := interval
		if remaining < wait {
			wait = remaining
		}
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-time.After(wait):
		}
	}
}

func writeJSON(value any, output string) error {
	content, err := json.MarshalIndent(value, "", "  ")
	if err != nil {
		return err
	}
	content = append(content, '\n')
	if output == "" {
		_, err = os.Stdout.Write(content)
		return err
	}
	return os.WriteFile(output, content, 0o600)
}

func writeSKUsTable(rows []SKURow) error {
	writer := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
	if _, err := fmt.Fprintln(writer, "SKU CODE\tPRODUCT\tDESCRIPTION\tSPEC\tPRICE\tSTOCK\tSKU ID"); err != nil {
		return err
	}
	for _, row := range rows {
		stock := fmt.Sprintf("%s:%d", row.StockStatus, row.StockQuantity)
		if _, err := fmt.Fprintf(
			writer,
			"%s\t%s\t%s\t%s\t%s\t%s\t%d\n",
			row.SKUCode,
			strings.ReplaceAll(row.ProductTitle, "\t", " "),
			strings.ReplaceAll(row.Description, "\t", " "),
			strings.ReplaceAll(row.Specification, "\t", " "),
			row.Price,
			stock,
			row.SKUID,
		); err != nil {
			return err
		}
	}
	return writer.Flush()
}

func writeSKUsCSV(rows []SKURow, output string) error {
	file, err := os.Create(output)
	if err != nil {
		return err
	}
	defer file.Close()
	if _, err := file.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
		return err
	}
	writer := csv.NewWriter(file)
	header := []string{
		"sku_code",
		"product_title",
		"description",
		"specification",
		"price",
		"stock_status",
		"stock_quantity",
		"sku_id",
		"product_id",
	}
	if err := writer.Write(header); err != nil {
		return err
	}
	for _, row := range rows {
		if err := writer.Write([]string{
			row.SKUCode,
			row.ProductTitle,
			row.Description,
			row.Specification,
			row.Price,
			row.StockStatus,
			strconv.Itoa(row.StockQuantity),
			strconv.FormatInt(row.SKUID, 10),
			strconv.FormatInt(row.ProductID, 10),
		}); err != nil {
			return err
		}
	}
	writer.Flush()
	return writer.Error()
}

var unsafeFilename = regexp.MustCompile(`[^A-Za-z0-9_-]+`)

func saveFulfillmentPayload(order *OrderResponse, directory string) (string, error) {
	if strings.TrimSpace(order.OrderNo) == "" {
		return "", errors.New("order response does not contain order_no")
	}
	if order.Fulfillment == nil || strings.TrimSpace(order.Fulfillment.Payload) == "" {
		return "", errors.New("order response does not contain fulfillment.payload")
	}
	normalized := strings.NewReplacer(
		`\r\n`, "\n",
		`\n`, "\n",
		`\r`, "\n",
	).Replace(order.Fulfillment.Payload)
	lines := make([]string, 0)
	for _, line := range strings.FieldsFunc(normalized, func(r rune) bool { return r == '\n' || r == '\r' }) {
		if text := strings.TrimSpace(line); text != "" {
			lines = append(lines, text)
		}
	}
	if len(lines) == 0 {
		return "", errors.New("fulfillment.payload does not contain any data")
	}
	safeOrderNo := strings.Trim(unsafeFilename.ReplaceAllString(order.OrderNo, "_"), "_")
	if safeOrderNo == "" {
		return "", errors.New("order_no cannot be converted to a safe filename")
	}
	if err := os.MkdirAll(directory, 0o755); err != nil {
		return "", err
	}
	output := filepath.Join(directory, "order_"+safeOrderNo+".txt")
	temporary := output + ".tmp"
	if err := os.WriteFile(temporary, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
		return "", err
	}
	if err := os.Rename(temporary, output); err != nil {
		return "", err
	}
	return output, nil
}

func main() {
	action := flag.String("action", "skus", "action: ping, skus, order, get-order, cancel-order")
	baseURL := flag.String("base-url", envOr("DUJIAO_BASE_URL", "https://cheapemail.cc"), "API site URL")
	apiKey := flag.String("api-key", os.Getenv("DUJIAO_API_KEY"), "API key (prefer environment variable)")
	apiSecret := flag.String("api-secret", os.Getenv("DUJIAO_API_SECRET"), "API secret (prefer environment variable)")
	timeout := flag.Duration("timeout", 30*time.Second, "HTTP request timeout")
	language := flag.String("language", "zh-CN", "preferred product language")
	format := flag.String("format", "table", "SKU output: table, json, csv")
	output := flag.String("output", "", "output file")
	skuCode := flag.String("sku-code", "", "SKU code for order action")
	quantity := flag.Int("quantity", 1, "order quantity")
	orderID := flag.Int64("order-id", 0, "numeric order ID")
	wait := flag.Bool("wait", false, "wait for fulfillment after creating an order")
	waitTimeout := flag.Duration("wait-timeout", 10*time.Minute, "maximum fulfillment wait")
	outputDir := flag.String("output-dir", "orders", "fulfillment output directory")
	flag.Parse()

	if *apiKey == "" || *apiSecret == "" {
		fatal(errors.New("DUJIAO_API_KEY and DUJIAO_API_SECRET are required"))
	}
	client := NewClient(*baseURL, *apiKey, *apiSecret, *timeout)
	ctx := context.Background()

	switch *action {
	case "ping":
		result, err := client.Ping(ctx)
		check(err)
		check(writeJSON(result, *output))
	case "skus":
		rows, err := client.ListSKUs(ctx, *language)
		check(err)
		switch *format {
		case "table":
			check(writeSKUsTable(rows))
		case "json":
			check(writeJSON(rows, *output))
		case "csv":
			filename := *output
			if filename == "" {
				filename = "skus.csv"
			}
			check(writeSKUsCSV(rows, filename))
			fmt.Printf("saved %d SKU rows to %s\n", len(rows), filename)
		default:
			fatal(fmt.Errorf("unknown format: %s", *format))
		}
	case "order":
		if *skuCode == "" {
			fatal(errors.New("-sku-code is required for order action"))
		}
		order, err := client.CreateOrderBySKUCode(
			ctx,
			*skuCode,
			*quantity,
		)
		check(err)
		check(writeJSON(order, ""))
		if *wait {
			detail, err := client.WaitForOrder(ctx, order.OrderID, 5*time.Second, *waitTimeout)
			check(err)
			filename, err := saveFulfillmentPayload(detail, *outputDir)
			check(err)
			fmt.Printf("fulfillment payload saved to %s\n", filename)
		}
	case "get-order":
		if *orderID <= 0 {
			fatal(errors.New("-order-id is required for get-order action"))
		}
		order, err := client.GetOrder(ctx, *orderID)
		check(err)
		check(writeJSON(order, *output))
	case "cancel-order":
		if *orderID <= 0 {
			fatal(errors.New("-order-id is required for cancel-order action"))
		}
		order, err := client.CancelOrder(ctx, *orderID)
		check(err)
		check(writeJSON(order, *output))
	default:
		fatal(fmt.Errorf("unknown action: %s", *action))
	}
}

func envOr(key, fallback string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return fallback
}

func check(err error) {
	if err != nil {
		fatal(err)
	}
}

func fatal(err error) {
	fmt.Fprintln(os.Stderr, "error:", err)
	os.Exit(1)
}

CheapEmail Open API Documentation