Files
slogan/server/styleagent/agent/cps_tb.go
T
admin a6de9ebd12 Add 'server/' from commit 'e64421295fff83acbb6d6ab3d3b27f3ef8368f00'
git-subtree-dir: server
git-subtree-mainline: c4e617ada7
git-subtree-split: e64421295f
2026-08-04 15:02:35 +08:00

209 lines
6.0 KiB
Go

package agent
import (
"context"
"crypto/hmac"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// 淘宝联盟适配器(电商类目)
// 接口以 eco.taobao.com TOP 开放平台为准:taobao.tbk.dg.material.optional(选品)/ taobao.tbk.tpwd.create(淘口令转链)
type tbProvider struct{}
func (tbProvider) Source() string { return "tb_ecom" }
func (tbProvider) Enabled() bool {
ctx := context.Background()
return g.Cfg().MustGet(ctx, "cps.tb_appkey", "").String() != "" &&
g.Cfg().MustGet(ctx, "cps.tb_secret", "").String() != "" &&
g.Cfg().MustGet(ctx, "cps.tb_pid", "").String() != ""
}
func (tbProvider) apiBase(ctx context.Context) string {
return strings.TrimRight(g.Cfg().MustGet(ctx, "cps.tb_base",
"https://eco.taobao.com/router/rest").String(), "/")
}
// SyncProducts 选品(按类目,cat 传淘宝叶子类目 ID)
func (p tbProvider) SyncProducts(ctx context.Context, city, catCode string) ([]CpsProduct, error) {
biz := map[string]any{
"adzone_id": g.Cfg().MustGet(ctx, "cps.tb_adzone_id", "").String(),
"cat": catCode,
"page_no": 1,
"page_size": 20,
"sort": "total_sales_des",
}
resp, err := p.doRequest(ctx, "taobao.tbk.dg.material.optional", biz)
if err != nil {
return nil, err
}
return p.parseProducts(resp, catCode)
}
// Search 关键词实时搜索
func (p tbProvider) Search(ctx context.Context, keyword, catCode string, page int) ([]CpsProduct, error) {
biz := map[string]any{
"adzone_id": g.Cfg().MustGet(ctx, "cps.tb_adzone_id", "").String(),
"q": keyword,
"page_no": page,
"page_size": 20,
"sort": "total_sales_des",
}
resp, err := p.doRequest(ctx, "taobao.tbk.dg.material.optional", biz)
if err != nil {
return nil, err
}
return p.parseProducts(resp, catCode)
}
// GetLink 淘口令转链(pid 归因;返回口令文本,客户端复制跳转)
func (p tbProvider) GetLink(ctx context.Context, outerId string) (string, error) {
biz := map[string]any{
"text": "好物分享",
"url": "https://item.taobao.com/item.htm?id=" + outerId,
"user_id": g.Cfg().MustGet(ctx, "cps.tb_pid", "").String(),
}
resp, err := p.doRequest(ctx, "taobao.tbk.tpwd.create", biz)
if err != nil {
return "", err
}
var d struct {
Data struct {
Model string `json:"model"`
} `json:"data"`
}
if err := json.Unmarshal(resp, &d); err != nil {
return "", err
}
if d.Data.Model == "" {
return "", fmt.Errorf("淘宝转链返回空")
}
return d.Data.Model, nil
}
// doRequest 淘宝 TOP 签名请求(sign = HMAC-MD5(参数键排序拼接, secret),大写)
// 公共参数与业务参数统一排序拼接,sign_method=hmac
func (p tbProvider) doRequest(ctx context.Context, method string, biz map[string]any) ([]byte, error) {
appKey := g.Cfg().MustGet(ctx, "cps.tb_appkey", "").String()
secret := g.Cfg().MustGet(ctx, "cps.tb_secret", "").String()
params := map[string]string{
"method": method,
"app_key": appKey,
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
"format": "json",
"v": "2.0",
"sign_method": "hmac",
}
for k, v := range biz {
params[k] = fmt.Sprint(v)
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for _, k := range keys {
sb.WriteString(k)
sb.WriteString(params[k])
}
mac := hmac.New(md5.New, []byte(secret))
mac.Write([]byte(sb.String()))
params["sign"] = strings.ToUpper(hex.EncodeToString(mac.Sum(nil)))
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
p.apiBase(ctx), strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("淘宝接口 %s 返回 %d: %s", method, resp.StatusCode, string(body))
}
// TOP 错误响应 {error_response:{code,msg}} 以 HTTP 200 返回,必须显式拦截
var er struct {
ErrorResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
} `json:"error_response"`
}
if json.Unmarshal(body, &er) == nil && er.ErrorResponse.Code != 0 {
return nil, fmt.Errorf("淘宝接口 %s 错误 %d: %s", method, er.ErrorResponse.Code, er.ErrorResponse.Msg)
}
return body, nil
}
func (p tbProvider) parseProducts(body []byte, catCode string) ([]CpsProduct, error) {
var d struct {
Resp struct {
ResultList struct {
MapData []struct {
NumIID int64 `json:"num_iid"`
Title string `json:"title"`
PictURL string `json:"pict_url"`
ZkFinalPrice string `json:"zk_final_price"`
ShopTitle string `json:"shop_title"`
CommissionRate string `json:"commission_rate"`
} `json:"map_data"`
} `json:"result_list"`
} `json:"tbk_dg_material_optional_response"`
}
if err := json.Unmarshal(body, &d); err != nil {
return nil, fmt.Errorf("淘宝选品响应解析失败: %v", err)
}
out := make([]CpsProduct, 0, len(d.Resp.ResultList.MapData))
for _, it := range d.Resp.ResultList.MapData {
// commission_rate 是百分比字符串(如 "3.5" = 3.5%),转万分比
rate := parseRateWanfen(it.CommissionRate)
out = append(out, CpsProduct{
Source: p.Source(),
OuterId: strconv.FormatInt(it.NumIID, 10),
CategoryCode: catCode,
Name: it.Title,
CoverUrl: it.PictURL,
PriceFen: parseFen(it.ZkFinalPrice),
ShopName: it.ShopTitle,
CommissionRate: rate,
})
}
return out, nil
}
// parseRateWanfen 佣金百分比字符串("3.5" 表示 3.5%)→ 万分比(350)
func parseRateWanfen(percent string) int {
f, err := strconv.ParseFloat(strings.TrimSpace(percent), 64)
if err != nil {
return 0
}
return int(f * 100)
}