功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 Webhook 可靠性
在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
package lewan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
ApplicationID string
|
||||
WebhookSecret string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg Config, httpClient *http.Client) *Client {
|
||||
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "https://shop.cloudtentacles.com"
|
||||
}
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetHTTPClient(client *http.Client) {
|
||||
if client != nil {
|
||||
c.httpClient = client
|
||||
}
|
||||
}
|
||||
|
||||
// 统一包装响应
|
||||
type ResponseEnvelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
RequestId string `json:"requestId"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message interface{} `json:"message,omitempty"` // string or []string
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// Product 代表乐玩平台商品
|
||||
type Product struct {
|
||||
ID string `json:"id"`
|
||||
CategoryId string `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
ThumbnailUrl string `json:"thumbnailUrl"`
|
||||
Stock int64 `json:"stock"`
|
||||
RoleDeliveryLimit *int `json:"roleDeliveryLimit"`
|
||||
DeliveryType string `json:"deliveryType"` // LINK_BINDING, EMAIL_BINDING
|
||||
EffectiveUnitPrice string `json:"effectiveUnitPrice"`
|
||||
AccessMode string `json:"accessMode"` // PURCHASE, VIEW
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ProductVersion string `json:"productVersion"`
|
||||
}
|
||||
|
||||
type ProductListResult struct {
|
||||
Items []Product `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
// OrderItem 下单商品项
|
||||
type OrderItem struct {
|
||||
ProductID string `json:"productId"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// CreateOrderReq 下单请求
|
||||
type CreateOrderReq struct {
|
||||
CallerOrderID string `json:"callerOrderId"`
|
||||
RoleID string `json:"roleId"`
|
||||
Items []OrderItem `json:"items"`
|
||||
}
|
||||
|
||||
// BindingAction 绑定动作快照
|
||||
type BindingAction struct {
|
||||
BindingRequired bool `json:"bindingRequired"`
|
||||
BindingType string `json:"bindingType"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
BindingDeadline *string `json:"bindingDeadline"`
|
||||
BindingUrl string `json:"bindingUrl"`
|
||||
BindingUrlIssuedAt *string `json:"bindingUrlIssuedAt"`
|
||||
BindingUrlExpiresAt *string `json:"bindingUrlExpiresAt"`
|
||||
BindingUrlExpired bool `json:"bindingUrlExpired"`
|
||||
BindingActionEndpoint *string `json:"bindingActionEndpoint"`
|
||||
EmailCodeSent bool `json:"emailCodeSent"`
|
||||
AllocationInProgress bool `json:"allocationInProgress"`
|
||||
BindingActionInProgress bool `json:"bindingActionInProgress"`
|
||||
}
|
||||
|
||||
// LewanOrder 乐玩订单
|
||||
type LewanOrder struct {
|
||||
ID string `json:"id"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
CallerOrderID string `json:"callerOrderId"`
|
||||
OrderVersion int `json:"orderVersion"`
|
||||
RoleID string `json:"roleId"`
|
||||
CategoryId string `json:"categoryId"`
|
||||
DeliveryType string `json:"deliveryType"`
|
||||
OrderedAmount string `json:"orderedAmount"`
|
||||
SettledAmount string `json:"settledAmount"`
|
||||
ReleasedAmount string `json:"releasedAmount"`
|
||||
HeldAmount string `json:"heldAmount"`
|
||||
FeeAmount string `json:"feeAmount"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
SucceededQuantity int64 `json:"succeededQuantity"`
|
||||
FailedQuantity int64 `json:"failedQuantity"`
|
||||
ReleasedQuantity int64 `json:"releasedQuantity"`
|
||||
PendingQuantity int64 `json:"pendingQuantity"`
|
||||
FailureCodes []string `json:"failureCodes"`
|
||||
Status string `json:"status"` // WAITING_BINDING, QUEUED, PROCESSING, RECONCILING, SUCCEEDED, PARTIAL_SUCCESS, FAILED, CANCELLED
|
||||
BindingDeadline *string `json:"bindingDeadline"`
|
||||
BindingAction *BindingAction `json:"bindingAction,omitempty"`
|
||||
}
|
||||
|
||||
// CreateOrderResult 下单响应
|
||||
type CreateOrderResult struct {
|
||||
Order *LewanOrder `json:"order"`
|
||||
BindingAction *BindingAction `json:"bindingAction"`
|
||||
IdempotentReplay bool `json:"idempotentReplay"`
|
||||
}
|
||||
|
||||
// BindingResult 绑定刷新响应
|
||||
type BindingInfo struct {
|
||||
Status string `json:"status"`
|
||||
Verified bool `json:"verified"`
|
||||
Matched bool `json:"matched"`
|
||||
RoleID string `json:"roleId"`
|
||||
RoleName string `json:"roleName"`
|
||||
ServerName string `json:"serverName"`
|
||||
}
|
||||
|
||||
type RefreshBindingResult struct {
|
||||
Order *LewanOrder `json:"order"`
|
||||
Binding *BindingInfo `json:"binding"`
|
||||
}
|
||||
|
||||
type WalletBalance struct {
|
||||
AvailableBalance string `json:"availableBalance"`
|
||||
FrozenBalance string `json:"frozenBalance"`
|
||||
TotalBalance string `json:"totalBalance"`
|
||||
}
|
||||
|
||||
// ListProducts 查询当前可见商品列表
|
||||
func (c *Client) ListProducts(ctx context.Context, page, pageSize int, keyword string) (*ProductListResult, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
query := fmt.Sprintf("page=%d&pageSize=%d", page, pageSize)
|
||||
if keyword != "" {
|
||||
query += "&keyword=" + RFC3986Escape(keyword)
|
||||
}
|
||||
|
||||
var out ProductListResult
|
||||
if err := c.do(ctx, http.MethodGet, "/openapi/v1/products", query, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetProduct 查询单个商品详情
|
||||
func (c *Client) GetProduct(ctx context.Context, id string) (*Product, error) {
|
||||
var out Product
|
||||
if err := c.do(ctx, http.MethodGet, "/openapi/v1/products/"+id, "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CreateOrder 幂等创建聚合订单
|
||||
func (c *Client) CreateOrder(ctx context.Context, req CreateOrderReq) (*CreateOrderResult, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out CreateOrderResult
|
||||
if err := c.do(ctx, http.MethodPost, "/openapi/v1/orders", "", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetOrder 按订单 ID 查单
|
||||
func (c *Client) GetOrder(ctx context.Context, id string) (*LewanOrder, error) {
|
||||
var out LewanOrder
|
||||
if err := c.do(ctx, http.MethodGet, "/openapi/v1/orders/"+id, "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetOrderByCallerOrder 按调用方订单号精确查单(用于超时补偿)
|
||||
func (c *Client) GetOrderByCallerOrder(ctx context.Context, callerOrderID string) (*LewanOrder, error) {
|
||||
query := "callerOrderId=" + RFC3986Escape(callerOrderID)
|
||||
var out LewanOrder
|
||||
if err := c.do(ctx, http.MethodGet, "/openapi/v1/orders/by-caller-order-id", query, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// RestoreBindingAction 重新进入时显式获取或恢复原订单绑定动作
|
||||
func (c *Client) RestoreBindingAction(ctx context.Context, orderID string) (*CreateOrderResult, error) {
|
||||
var out CreateOrderResult
|
||||
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/openapi/v1/orders/%s/binding/action", orderID), "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// RefreshBinding LINK 用户绑定后刷新角色信息;匹配成功即进入发货
|
||||
func (c *Client) RefreshBinding(ctx context.Context, orderID string) (*RefreshBindingResult, error) {
|
||||
var out RefreshBindingResult
|
||||
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/openapi/v1/orders/%s/binding/refresh", orderID), "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CancelOrder 取消整单(仅在未越过渠道提交边界时有效)
|
||||
func (c *Client) CancelOrder(ctx context.Context, orderID string) (*LewanOrder, error) {
|
||||
var out LewanOrder
|
||||
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/openapi/v1/orders/%s/cancel", orderID), "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetWallet 查询钱包余额
|
||||
func (c *Client) GetWallet(ctx context.Context) (*WalletBalance, error) {
|
||||
var out WalletBalance
|
||||
if err := c.do(ctx, http.MethodGet, "/openapi/v1/wallets", "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path, query string, body []byte, out interface{}) error {
|
||||
if c.cfg.AccessKey == "" || c.cfg.SecretKey == "" {
|
||||
return errors.New("乐玩开放平台 AccessKey 或 SecretKey 未配置")
|
||||
}
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := GenerateNonce()
|
||||
bodySHA := SHA256Hex(body)
|
||||
|
||||
signingStr := BuildSigningString(method, path, query, bodySHA, ts, nonce)
|
||||
signature := SignRequest(c.cfg.SecretKey, signingStr)
|
||||
|
||||
reqURL := c.cfg.BaseURL + path
|
||||
if query != "" {
|
||||
reqURL += "?" + query
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if len(body) > 0 {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建请求失败: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set(HeaderAccessKey, c.cfg.AccessKey)
|
||||
req.Header.Set(HeaderTimestamp, ts)
|
||||
req.Header.Set(HeaderNonce, nonce)
|
||||
req.Header.Set(HeaderSignature, signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求乐玩平台网络异常: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1<<21)) // limit 2MB
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取乐玩响应失败: %w", err)
|
||||
}
|
||||
|
||||
var envelope ResponseEnvelope
|
||||
if err := json.Unmarshal(respBytes, &envelope); err != nil {
|
||||
return fmt.Errorf("解析乐玩响应失败 (HTTP %d): %s", resp.StatusCode, string(respBytes))
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
errMsg := ""
|
||||
switch v := envelope.Message.(type) {
|
||||
case string:
|
||||
errMsg = v
|
||||
case []interface{}:
|
||||
var parts []string
|
||||
for _, item := range v {
|
||||
parts = append(parts, fmt.Sprint(item))
|
||||
}
|
||||
errMsg = strings.Join(parts, "; ")
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = envelope.Error
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if envelope.Code != "" {
|
||||
return fmt.Errorf("[%s] %s", envelope.Code, errMsg)
|
||||
}
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
|
||||
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
|
||||
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
||||
return fmt.Errorf("解析乐玩业务数据失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package lewan
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EmptyBodySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
HeaderAccessKey = "X-OpenAPI-Access-Key"
|
||||
HeaderTimestamp = "X-OpenAPI-Timestamp"
|
||||
HeaderNonce = "X-OpenAPI-Nonce"
|
||||
HeaderSignature = "X-OpenAPI-Signature"
|
||||
HeaderRequestID = "X-Request-Id"
|
||||
|
||||
HeaderWebhookAppID = "X-OpenAPI-Application-Id"
|
||||
HeaderWebhookEventID = "X-OpenAPI-Event-Id"
|
||||
HeaderWebhookSchemaVer = "X-OpenAPI-Schema-Version"
|
||||
HeaderWebhookTimestamp = "X-OpenAPI-Timestamp"
|
||||
HeaderWebhookSignature = "X-OpenAPI-Signature"
|
||||
)
|
||||
|
||||
// RFC3986Escape encodes a string according to RFC 3986.
|
||||
// Space is encoded as %20 (not +).
|
||||
func RFC3986Escape(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if isRFC3986Unreserved(c) {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isRFC3986Unreserved(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') ||
|
||||
(b >= 'A' && b <= 'Z') ||
|
||||
(b >= '0' && b <= '9') ||
|
||||
b == '-' || b == '_' || b == '.' || b == '~'
|
||||
}
|
||||
|
||||
type queryPair struct {
|
||||
encodedKey string
|
||||
encodedVal string
|
||||
}
|
||||
|
||||
// NormalizeQuery parses a raw query string and normalizes it according to Lewan OpenAPI V1 rules:
|
||||
// RFC 3986 encoded key and value, sorted by encoded key then encoded value, joined by '&'.
|
||||
func NormalizeQuery(rawQuery string) string {
|
||||
if strings.TrimSpace(rawQuery) == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(rawQuery, "&")
|
||||
var pairs []queryPair
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
var rawKey, rawVal string
|
||||
idx := strings.IndexByte(part, '=')
|
||||
if idx >= 0 {
|
||||
rawKey = part[:idx]
|
||||
rawVal = part[idx+1:]
|
||||
} else {
|
||||
rawKey = part
|
||||
rawVal = ""
|
||||
}
|
||||
// Unescape first if percent-encoded, to canonicalize
|
||||
decKey, err := url.QueryUnescape(strings.ReplaceAll(rawKey, "+", "%2B"))
|
||||
if err != nil {
|
||||
decKey = rawKey
|
||||
}
|
||||
decVal, err := url.QueryUnescape(strings.ReplaceAll(rawVal, "+", "%2B"))
|
||||
if err != nil {
|
||||
decVal = rawVal
|
||||
}
|
||||
|
||||
pairs = append(pairs, queryPair{
|
||||
encodedKey: RFC3986Escape(decKey),
|
||||
encodedVal: RFC3986Escape(decVal),
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
if pairs[i].encodedKey != pairs[j].encodedKey {
|
||||
return pairs[i].encodedKey < pairs[j].encodedKey
|
||||
}
|
||||
return pairs[i].encodedVal < pairs[j].encodedVal
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
for i, p := range pairs {
|
||||
if i > 0 {
|
||||
sb.WriteByte('&')
|
||||
}
|
||||
sb.WriteString(p.encodedKey)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(p.encodedVal)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// SHA256Hex returns the hex-encoded SHA-256 of the byte slice.
|
||||
func SHA256Hex(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return EmptyBodySHA256
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// BuildSigningString constructs the 6-line string to be signed:
|
||||
// 1. HTTP_METHOD (UPPERCASE)
|
||||
// 2. PATH
|
||||
// 3. RFC3986_QUERY
|
||||
// 4. SHA256_HEX_OF_RAW_BODY
|
||||
// 5. UNIX_TIMESTAMP_SECONDS
|
||||
// 6. NONCE
|
||||
func BuildSigningString(method, path, query, bodySHA256, timestamp, nonce string) string {
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
path = strings.TrimSpace(path)
|
||||
normQuery := NormalizeQuery(query)
|
||||
if bodySHA256 == "" {
|
||||
bodySHA256 = EmptyBodySHA256
|
||||
}
|
||||
timestamp = strings.TrimSpace(timestamp)
|
||||
nonce = strings.TrimSpace(nonce)
|
||||
|
||||
return strings.Join([]string{
|
||||
method,
|
||||
path,
|
||||
normQuery,
|
||||
bodySHA256,
|
||||
timestamp,
|
||||
nonce,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// SignRequest computes the hex HMAC-SHA256 signature for a request.
|
||||
func SignRequest(secretKey, signingString string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secretKey))
|
||||
mac.Write([]byte(signingString))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// GenerateNonce creates a random hex nonce of given byte length (e.g. 16 bytes = 32 hex chars).
|
||||
func GenerateNonce() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return "n" + hex.EncodeToString(b)[1:] // ensure leading char is letter/number
|
||||
}
|
||||
|
||||
// SignWebhook computes the v1= signature for an incoming/outgoing webhook.
|
||||
func SignWebhook(secret, timestamp, eventID string, rawBody []byte) string {
|
||||
prefix := timestamp + "." + eventID + "."
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(prefix))
|
||||
mac.Write(rawBody)
|
||||
return "v1=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// VerifyWebhook validates a webhook signature with constant-time comparison and timestamp check.
|
||||
func VerifyWebhook(secret, signature, timestamp, eventID string, rawBody []byte, allowedSkew time.Duration) bool {
|
||||
if secret == "" || signature == "" || timestamp == "" || eventID == "" {
|
||||
return false
|
||||
}
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if allowedSkew > 0 {
|
||||
diff := time.Now().Unix() - ts
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if time.Duration(diff)*time.Second > allowedSkew {
|
||||
return false
|
||||
}
|
||||
}
|
||||
expected := SignWebhook(secret, timestamp, eventID, rawBody)
|
||||
actual := signature
|
||||
if !strings.HasPrefix(actual, "v1=") {
|
||||
actual = "v1=" + actual
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package lewan
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOfficialVectors(t *testing.T) {
|
||||
// 固定密钥与时间戳
|
||||
accessKey := "ak_test_fixed_20260905"
|
||||
secretKey := "sk_test_fixed_20260905"
|
||||
timestamp := "1788566400"
|
||||
_ = accessKey
|
||||
|
||||
// Vector 1: GET 查询
|
||||
t.Run("Vector1_GET_Query", func(t *testing.T) {
|
||||
method := "GET"
|
||||
path := "/openapi/v1/products"
|
||||
rawQuery := "pageSize=20&page=1"
|
||||
nonce := "nonce_get_20260905_fixed"
|
||||
bodySHA256 := EmptyBodySHA256
|
||||
expectedSig := "54ea478585e61230be670d664745c81ffb4907b87c8592bf082c9cc894232adf"
|
||||
|
||||
signingStr := BuildSigningString(method, path, rawQuery, bodySHA256, timestamp, nonce)
|
||||
expectedSigningStr := "GET\n/openapi/v1/products\npage=1&pageSize=20\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n1788566400\nnonce_get_20260905_fixed"
|
||||
if signingStr != expectedSigningStr {
|
||||
t.Fatalf("signing string mismatch:\ngot:\n%s\nwant:\n%s", signingStr, expectedSigningStr)
|
||||
}
|
||||
|
||||
sig := SignRequest(secretKey, signingStr)
|
||||
if sig != expectedSig {
|
||||
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
|
||||
}
|
||||
})
|
||||
|
||||
// Vector 2: POST JSON
|
||||
t.Run("Vector2_POST_JSON", func(t *testing.T) {
|
||||
method := "POST"
|
||||
path := "/openapi/v1/orders"
|
||||
rawQuery := ""
|
||||
body := []byte(`{"callerOrderId":"DOC-20260905-001","roleId":"1148711973","items":[{"productId":"00000000-0000-4000-8000-000000000101","quantity":2}]}`)
|
||||
nonce := "nonce_post_20260905_fixed"
|
||||
expectedBodySHA := "146e687459b70c7fdf2781f72593306a8041aa0d5212207459dc71cd76e59032"
|
||||
expectedSig := "1e1d3df46b885e76b1e992585215e488c29315e7207e62df4aa26b0124d04968"
|
||||
|
||||
bodySHA := SHA256Hex(body)
|
||||
if bodySHA != expectedBodySHA {
|
||||
t.Fatalf("body SHA256 mismatch:\ngot: %s\nwant: %s", bodySHA, expectedBodySHA)
|
||||
}
|
||||
|
||||
signingStr := BuildSigningString(method, path, rawQuery, bodySHA, timestamp, nonce)
|
||||
expectedSigningStr := "POST\n/openapi/v1/orders\n\n146e687459b70c7fdf2781f72593306a8041aa0d5212207459dc71cd76e59032\n1788566400\nnonce_post_20260905_fixed"
|
||||
if signingStr != expectedSigningStr {
|
||||
t.Fatalf("signing string mismatch:\ngot:\n%s\nwant:\n%s", signingStr, expectedSigningStr)
|
||||
}
|
||||
|
||||
sig := SignRequest(secretKey, signingStr)
|
||||
if sig != expectedSig {
|
||||
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
|
||||
}
|
||||
})
|
||||
|
||||
// Vector 3: 重复查询参数
|
||||
t.Run("Vector3_Duplicate_Query", func(t *testing.T) {
|
||||
method := "GET"
|
||||
path := "/openapi/v1/orders"
|
||||
rawQuery := "status=SUCCEEDED&keyword=%E4%BA%91%20%E8%A7%A6%E8%A7%92&status=FAILED"
|
||||
nonce := "nonce_repeat_20260905_fixed"
|
||||
expectedSig := "c1c2be18372e3ac89e8042f3477fff8287126e31777a18a593c0cce7028626d8"
|
||||
|
||||
normQuery := NormalizeQuery(rawQuery)
|
||||
expectedNormQuery := "keyword=%E4%BA%91%20%E8%A7%A6%E8%A7%92&status=FAILED&status=SUCCEEDED"
|
||||
if normQuery != expectedNormQuery {
|
||||
t.Fatalf("normalized query mismatch:\ngot: %s\nwant: %s", normQuery, expectedNormQuery)
|
||||
}
|
||||
|
||||
signingStr := BuildSigningString(method, path, rawQuery, EmptyBodySHA256, timestamp, nonce)
|
||||
sig := SignRequest(secretKey, signingStr)
|
||||
if sig != expectedSig {
|
||||
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
|
||||
}
|
||||
})
|
||||
|
||||
// Vector 4: POST 空请求体
|
||||
t.Run("Vector4_POST_Empty_Body", func(t *testing.T) {
|
||||
method := "POST"
|
||||
path := "/openapi/v1/orders/00000000-0000-4000-8000-000000000201/account/release"
|
||||
rawQuery := ""
|
||||
nonce := "nonce_empty_20260905_fixed"
|
||||
expectedSig := "167f6d7c116fc7e51b0d2418e65a0705f08076940025f2c013adbd8f27648908"
|
||||
|
||||
signingStr := BuildSigningString(method, path, rawQuery, EmptyBodySHA256, timestamp, nonce)
|
||||
sig := SignRequest(secretKey, signingStr)
|
||||
if sig != expectedSig {
|
||||
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
|
||||
}
|
||||
})
|
||||
|
||||
// Vector 5: Webhook 固定向量
|
||||
t.Run("Vector5_Webhook", func(t *testing.T) {
|
||||
webhookSecret := "whsec_test_fixed_20260905"
|
||||
eventID := "00000000-0000-4000-8000-000000000302"
|
||||
rawBody := []byte(`{"applicationId":"00000000-0000-4000-8000-000000000301","data":{"callerOrderId":"DOC-20260905-001","failedQuantity":1,"failureCodes":["SUPPLIER_REJECTED"],"orderId":"00000000-0000-4000-8000-000000000303","pendingQuantity":0,"releasedQuantity":0,"status":"PARTIAL_SUCCESS","succeededQuantity":1},"eventId":"00000000-0000-4000-8000-000000000302","occurredAt":"2026-09-05T00:00:00.000Z","orderVersion":7,"schemaVersion":"1.0","type":"ORDER_COMPLETED"}`)
|
||||
expectedSig := "v1=ee3968ef467a4f9c19c0a33e7af1bb3118fab8d02c5ebf9cbd8001cc34ebe6be"
|
||||
|
||||
sig := SignWebhook(webhookSecret, timestamp, eventID, rawBody)
|
||||
if sig != expectedSig {
|
||||
t.Fatalf("webhook signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
|
||||
}
|
||||
|
||||
// 测试验证函数(不限时限)
|
||||
if !VerifyWebhook(webhookSecret, sig, timestamp, eventID, rawBody, 0) {
|
||||
t.Fatalf("VerifyWebhook should succeed")
|
||||
}
|
||||
// 测试错误 secret
|
||||
if VerifyWebhook("wrong_secret", sig, timestamp, eventID, rawBody, 0) {
|
||||
t.Fatalf("VerifyWebhook should fail with wrong secret")
|
||||
}
|
||||
// 测试时间窗口超限
|
||||
if VerifyWebhook(webhookSecret, sig, timestamp, eventID, rawBody, 5*time.Minute) {
|
||||
t.Fatalf("VerifyWebhook should fail for expired timestamp")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user