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"` Binding *BindingInfo `json:"binding,omitempty"` CompletedAt *string `json:"completedAt,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"` RoleArea string `json:"roleArea,omitempty"` 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 }