git-subtree-dir: server git-subtree-mainline:c4e617ada7git-subtree-split:e64421295f
178 lines
4.8 KiB
Go
178 lines
4.8 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"crypto/md5"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
)
|
||
|
||
// 美团联盟适配器(到店 OTA 类目:丽人/服装/餐厅/酒店/票务)
|
||
// 接口以 union.meituan.com 开放平台为准:选品(商品/POI 搜索)+ 转链(生成带 pid 的推广链接)
|
||
type meituanProvider struct{}
|
||
|
||
func (meituanProvider) Source() string { return "meituan_ota" }
|
||
|
||
func (meituanProvider) Enabled() bool {
|
||
ctx := context.Background()
|
||
return g.Cfg().MustGet(ctx, "cps.meituan_appkey", "").String() != "" &&
|
||
g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String() != ""
|
||
}
|
||
|
||
func (meituanProvider) apiBase(ctx context.Context) string {
|
||
return strings.TrimRight(g.Cfg().MustGet(ctx, "cps.meituan_base",
|
||
"https://openapi.meituan.com").String(), "/")
|
||
}
|
||
|
||
// SyncProducts 到店 POI/商品选品(按类目 + 城市)
|
||
func (p meituanProvider) SyncProducts(ctx context.Context, city, catCode string) ([]CpsProduct, error) {
|
||
biz := map[string]any{
|
||
"cityName": city,
|
||
"categoryId": catCode,
|
||
"pageNo": 1,
|
||
"pageSize": 50,
|
||
"isActivity": 0,
|
||
"promotionPid": g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String(),
|
||
}
|
||
resp, err := p.doRequest(ctx, "union/search", biz)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return p.parseProducts(resp, catCode, city)
|
||
}
|
||
|
||
// Search 实时搜索兜底
|
||
func (p meituanProvider) Search(ctx context.Context, keyword, catCode string, page int) ([]CpsProduct, error) {
|
||
biz := map[string]any{
|
||
"keyword": keyword,
|
||
"categoryId": catCode,
|
||
"pageNo": page,
|
||
"pageSize": 20,
|
||
"promotionPid": g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String(),
|
||
}
|
||
resp, err := p.doRequest(ctx, "union/search", biz)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return p.parseProducts(resp, catCode, "")
|
||
}
|
||
|
||
// GetLink 转链(pid 归因)
|
||
func (p meituanProvider) GetLink(ctx context.Context, outerId string) (string, error) {
|
||
biz := map[string]any{
|
||
"poiId": outerId,
|
||
"promotionPid": g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String(),
|
||
}
|
||
resp, err := p.doRequest(ctx, "union/link", biz)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
var d struct {
|
||
Data struct {
|
||
Link string `json:"link"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(resp, &d); err != nil {
|
||
return "", err
|
||
}
|
||
if d.Data.Link == "" {
|
||
return "", fmt.Errorf("美团转链返回空")
|
||
}
|
||
return d.Data.Link, nil
|
||
}
|
||
|
||
// doRequest 美团联盟签名请求(sign = MD5(appkey + secret + ts))
|
||
func (p meituanProvider) doRequest(ctx context.Context, path string, biz map[string]any) ([]byte, error) {
|
||
appKey := g.Cfg().MustGet(ctx, "cps.meituan_appkey", "").String()
|
||
secret := g.Cfg().MustGet(ctx, "cps.meituan_secret", "").String()
|
||
ts := fmt.Sprint(time.Now().Unix())
|
||
payload, err := json.Marshal(biz)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
h := md5.New()
|
||
io.WriteString(h, appKey+secret+ts)
|
||
sign := hex.EncodeToString(h.Sum(nil))
|
||
|
||
form := url.Values{}
|
||
form.Set("appkey", appKey)
|
||
form.Set("ts", ts)
|
||
form.Set("sign", sign)
|
||
form.Set("biz", string(payload))
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||
p.apiBase(ctx)+"/"+path, 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", path, resp.StatusCode, string(body))
|
||
}
|
||
return body, nil
|
||
}
|
||
|
||
func (p meituanProvider) parseProducts(body []byte, catCode, city string) ([]CpsProduct, error) {
|
||
var d struct {
|
||
Data struct {
|
||
List []struct {
|
||
PoiId string `json:"poiId"`
|
||
Title string `json:"title"`
|
||
ImageUrl string `json:"imageUrl"`
|
||
LowPrice string `json:"lowPrice"`
|
||
ShopName string `json:"shopName"`
|
||
CpsCoupon int `json:"cpsCoupon"`
|
||
Category string `json:"category"`
|
||
} `json:"list"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(body, &d); err != nil {
|
||
return nil, fmt.Errorf("美团选品响应解析失败: %v", err)
|
||
}
|
||
out := make([]CpsProduct, 0, len(d.Data.List))
|
||
for _, it := range d.Data.List {
|
||
price := parseFen(it.LowPrice)
|
||
out = append(out, CpsProduct{
|
||
Source: p.Source(),
|
||
OuterId: it.PoiId,
|
||
CategoryCode: catCode,
|
||
Name: it.Title,
|
||
CoverUrl: it.ImageUrl,
|
||
PriceFen: price,
|
||
ShopName: it.ShopName,
|
||
CommissionRate: it.CpsCoupon,
|
||
City: city,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// parseFen 金额字符串(元)→ 分
|
||
func parseFen(amount string) int64 {
|
||
f, err := strconv.ParseFloat(amount, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return int64(f * 100)
|
||
}
|