Files
affiliate_dash/backend/internal/pkg/lewan/client.go
T
yml2213andCursor f7f48d0feb 功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 Webhook 可靠性
在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 15:48:03 +08:00

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
}