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