在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。 Co-authored-by: Cursor <cursoragent@cursor.com>
341 lines
11 KiB
Go
341 lines
11 KiB
Go
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
|
|
}
|