Files
2026-08-17 13:19:15 +08:00

236 lines
6.4 KiB
Go

package agent
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"slogan-agent/common"
"github.com/gogf/gf/v2/frame/g"
)
// 京东联盟适配器(电商类目)
// 接口以 api.jd.com 开放平台为准:jd.union.open.goods.query(选品)/ jd.union.open.promotion.common.get(转链)
type jdProvider struct{}
func (jdProvider) Source() string { return "jd_ecom" }
func (jdProvider) Enabled() bool {
ctx := context.Background()
return g.Cfg().MustGet(ctx, "cps.jd_appkey", "").String() != "" &&
g.Cfg().MustGet(ctx, "cps.jd_secret", "").String() != ""
}
func (jdProvider) apiBase(ctx context.Context) string {
return strings.TrimRight(g.Cfg().MustGet(ctx, "cps.jd_base",
"https://api.jd.com/routerjson").String(), "/")
}
// SyncProducts 商品选品(按类目)
func (p jdProvider) SyncProducts(ctx context.Context, city, catCode string) ([]CpsProduct, error) {
biz := map[string]any{
"goodsReqDTO": map[string]any{
"cid1": catCode,
"pageIndex": 1,
"pageSize": 20,
"eliteId": 1,
"sortName": "inOrderCount30Days",
"sort": "desc",
"fields": "skuId,skuName,imageUrl,priceInfo,shopName,commissionInfo,categoryInfo",
},
}
resp, err := p.doRequest(ctx, "jd.union.open.goods.query", biz)
if err != nil {
return nil, err
}
return p.parseProducts(resp, catCode)
}
// Search 关键词实时搜索
func (p jdProvider) Search(ctx context.Context, keyword, catCode string, page int) ([]CpsProduct, error) {
biz := map[string]any{
"goodsReqDTO": map[string]any{
"keyword": keyword,
"pageIndex": page,
"pageSize": 20,
"sortName": "inOrderCount30Days",
"sort": "desc",
"fields": "skuId,skuName,imageUrl,priceInfo,shopName,commissionInfo,categoryInfo",
},
}
resp, err := p.doRequest(ctx, "jd.union.open.goods.query", biz)
if err != nil {
return nil, err
}
return p.parseProducts(resp, catCode)
}
// GetLink 转链(pid 归因)
func (p jdProvider) GetLink(ctx context.Context, outerId string) (string, error) {
biz := map[string]any{
"promotionCodeReq": map[string]any{
"materialId": "https://item.jd.com/" + outerId + ".html",
"siteId": g.Cfg().MustGet(ctx, "cps.jd_site_id", "").String(),
"positionId": g.Cfg().MustGet(ctx, "cps.jd_pid", "").String(),
"type": 1,
},
}
resp, err := p.doRequest(ctx, "jd.union.open.promotion.common.get", biz)
if err != nil {
return "", err
}
var d struct {
Result string `json:"jd_union_open_promotion_common_get_responce"`
}
if err := json.Unmarshal(resp, &d); err != nil {
return "", err
}
var inner struct {
Result []struct {
Data struct {
ClickURL string `json:"clickURL"`
} `json:"data"`
Code int `json:"code"`
Message string `json:"message"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(d.Result), &inner); err != nil {
return "", err
}
if len(inner.Result) == 0 || inner.Result[0].Data.ClickURL == "" {
return "", fmt.Errorf("京东转链失败: %s", firstResultMsg(inner.Result))
}
return inner.Result[0].Data.ClickURL, nil
}
func firstResultMsg(results []struct {
Data struct {
ClickURL string `json:"clickURL"`
} `json:"data"`
Code int `json:"code"`
Message string `json:"message"`
}) string {
if len(results) == 0 {
return "empty result"
}
return results[0].Message
}
// doRequest 京东签名请求(sign = MD5(secret + 参数键值排序拼接 + secret),大写)
func (p jdProvider) doRequest(ctx context.Context, method string, biz map[string]any) ([]byte, error) {
appKey := g.Cfg().MustGet(ctx, "cps.jd_appkey", "").String()
secret := g.Cfg().MustGet(ctx, "cps.jd_secret", "").String()
payload, err := json.Marshal(biz)
if err != nil {
return nil, err
}
params := map[string]string{
"method": method,
"app_key": appKey,
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
"format": "json",
"v": "1.0",
"sign_method": "md5",
"360buy_param_json": string(payload),
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
sb.WriteString(secret)
for _, k := range keys {
sb.WriteString(k)
sb.WriteString(params[k])
}
sb.WriteString(secret)
sum := md5.Sum([]byte(sb.String()))
params["sign"] = strings.ToUpper(hex.EncodeToString(sum[:]))
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 func() { _ = 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))
}
return body, nil
}
func (p jdProvider) parseProducts(body []byte, catCode string) ([]CpsProduct, error) {
var d struct {
Result string `json:"jd_union_open_goods_query_responce"`
}
if err := json.Unmarshal(body, &d); err != nil {
return nil, fmt.Errorf("京东选品响应解析失败: %v", err)
}
var inner struct {
Result []struct {
SkuID int64 `json:"skuId"`
SkuName string `json:"skuName"`
ImageURL string `json:"imageUrl"`
ShopName string `json:"shopName"`
PriceInfo struct {
Price float64 `json:"price"`
} `json:"priceInfo"`
CommissionInfo struct {
Commission float64 `json:"commission"`
} `json:"commissionInfo"`
CategoryInfo struct {
Cid1 int64 `json:"cid1"`
} `json:"categoryInfo"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(d.Result), &inner); err != nil {
return nil, err
}
out := make([]CpsProduct, 0, len(inner.Result))
for _, it := range inner.Result {
rate := 0
if it.PriceInfo.Price > 0 {
rate = int(it.CommissionInfo.Commission / it.PriceInfo.Price * 10000)
}
out = append(out, CpsProduct{
Source: p.Source(),
OuterId: strconv.FormatInt(it.SkuID, 10),
CategoryCode: catCode,
Name: it.SkuName,
CoverUrl: it.ImageURL,
PriceFen: common.RoundInt(it.PriceInfo.Price * 100),
ShopName: it.ShopName,
CommissionRate: rate,
})
}
return out, nil
}