功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 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
|
||||
}
|
||||
Reference in New Issue
Block a user