feat: CPS 引擎(三联盟适配器 + 方案驱动推荐 + 6 接口 + 降级)
美团/京东/淘宝联盟适配器(选品/搜索/转链,未配置 key 优雅降级), CPS 四表五层骨架,方案驱动推荐零新增 LLM(发型/买同款/到店试穿/场合), 定时同步 + 点击日志 + 最近优惠;outfit_plan 容错迁移 occasion 列; Dockerfile 补 mesa/nodejs 运行时。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -14,14 +14,14 @@ RUN go build -ldflags="-s -w" -o main ./main.go
|
||||
# 3D 化身渲染器(Node + headless-gl,需原生编译)
|
||||
FROM node:20-alpine AS renderer
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache git python3 make g++ mesa mesa-gles mesa-egl libglvnd-dev ca-certificates tzdata
|
||||
&& apk add --no-cache git python3 make g++ mesa mesa-dev ca-certificates tzdata
|
||||
WORKDIR /render
|
||||
COPY scripts/avatar-render/package*.json ./
|
||||
RUN npm ci --omit=dev --registry=https://registry.npmmirror.com
|
||||
|
||||
FROM alpine:3.19
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache ca-certificates tzdata libstdc++ libgcc libglvnd nodejs
|
||||
&& apk add --no-cache ca-certificates tzdata libstdc++ libgcc mesa nodejs
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
WORKDIR /app
|
||||
|
||||
+18
@@ -56,3 +56,21 @@ ad:
|
||||
render:
|
||||
enabled: true
|
||||
node_bin: "/Users/zhangbin/.nvm/versions/node/v18.20.4/bin/node"
|
||||
|
||||
# CPS 联盟(key 全空则联盟入口优雅降级隐藏)
|
||||
cps:
|
||||
sync_cron: "0 4 * * *" # 联盟商品定时同步
|
||||
meituan_appkey: ""
|
||||
meituan_secret: ""
|
||||
meituan_pid: ""
|
||||
meituan_base: "https://openapi.meituan.com"
|
||||
jd_appkey: ""
|
||||
jd_secret: ""
|
||||
jd_site_id: ""
|
||||
jd_pid: ""
|
||||
jd_base: "https://api.jd.com/routerjson"
|
||||
tb_appkey: ""
|
||||
tb_secret: ""
|
||||
tb_pid: ""
|
||||
tb_adzone_id: ""
|
||||
tb_base: "https://eco.taobao.com/router/rest"
|
||||
|
||||
@@ -34,6 +34,7 @@ func main() {
|
||||
controller.PartnerStore,
|
||||
controller.Member,
|
||||
controller.Ad,
|
||||
controller.Cps,
|
||||
})
|
||||
|
||||
// 虎皮棋支付回调(裸文本 "success",不走统一 JSON 包装)
|
||||
@@ -62,6 +63,9 @@ func main() {
|
||||
// 恢复未完成的生成任务(重启后标记失败,避免重复消耗 LLM 费用)
|
||||
service.OutfitService.StartWorker(ctx)
|
||||
|
||||
// CPS 联盟商品定时同步(未配置 key 时空转)
|
||||
service.CpsProductService.StartSyncLoop(ctx)
|
||||
|
||||
g.Log().Info(ctx, "slogan-agent started on :3007")
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
+7
-1
@@ -45,7 +45,12 @@ for item in \
|
||||
"GET /outfit/plan/list 0" \
|
||||
"GET /partner-store/list 0" \
|
||||
"GET /member/plan/list 0" \
|
||||
"GET /member/status 0" ; do
|
||||
"GET /member/status 0" \
|
||||
"GET /cps/category/list 0" \
|
||||
"GET /cps/product/list?source=meituan_ota&category_code=beauty 0" \
|
||||
"GET /cps/plan/recommend?plan_id=0&scene=haircut 50" \
|
||||
"GET /cps/wardrobe/upgrade?item_id=0 50" \
|
||||
"GET /cps/my/recent 0" ; do
|
||||
set -- $item
|
||||
METHOD="$1"; PATH_="$2"; ALLOW="${3:-0}"
|
||||
RESP=$(curl -s -X "$METHOD" "$BASE$PATH_" -H "$AUTH")
|
||||
@@ -64,6 +69,7 @@ post_check "/outfit/plan/review" '{"plan_id":0,"action":"fav"}' "50"
|
||||
post_check "/member/order/create" '{"plan_id":1}' "50"
|
||||
post_check "/ad/reward/claim" '{"ad_type":"effect_extra"}' "50"
|
||||
post_check "/outfit/generate" '{"start_date":"2026-08-01","end_date":"2026-08-07","location":"上海"}' "50"
|
||||
post_check "/cps/product/link" '{"product_id":0,"scene":"item_buy"}' "50,51"
|
||||
post_check "/user/change-password" '{"old_password":"smoketest123","new_password":"smoketest456"}'
|
||||
|
||||
# 4. 裸回调(无鉴权;no_order 预期返回 fail)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 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: int64(it.PriceInfo.Price * 100),
|
||||
ShopName: it.ShopName,
|
||||
CommissionRate: rate,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package agent
|
||||
|
||||
import "context"
|
||||
|
||||
// CpsProduct 联盟商品统一结构(各联盟适配器归一化后返回)
|
||||
type CpsProduct struct {
|
||||
Source string
|
||||
OuterId string
|
||||
CategoryCode string
|
||||
Name string
|
||||
CoverUrl string
|
||||
PriceFen int64
|
||||
ShopName string
|
||||
CommissionRate int // 万分比
|
||||
City string
|
||||
SceneTags []string
|
||||
Raw string
|
||||
}
|
||||
|
||||
// CpsProvider 联盟数据源适配器接口
|
||||
type Provider interface {
|
||||
Source() string // meituan_ota | jd_ecom | tb_ecom
|
||||
Enabled() bool // 未配置 key → false(优雅降级)
|
||||
SyncProducts(ctx context.Context, city, catCode string) ([]CpsProduct, error)
|
||||
Search(ctx context.Context, keyword, catCode string, page int) ([]CpsProduct, error)
|
||||
GetLink(ctx context.Context, outerId string) (string, error) // 转链(带 pid 归因)
|
||||
}
|
||||
|
||||
// CpsProviders 联盟注册表(仅 Enabled 的入 service 注册表)
|
||||
var CpsProviders = []Provider{
|
||||
meituanProvider{},
|
||||
jdProvider{},
|
||||
tbProvider{},
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package consts
|
||||
|
||||
// CPS 数据源
|
||||
const (
|
||||
CpsSourceMeituanOta = "meituan_ota"
|
||||
CpsSourceJdEcom = "jd_ecom"
|
||||
CpsSourceTbEcom = "tb_ecom"
|
||||
)
|
||||
|
||||
// CPS 推荐场景(scene_category_map.scene_type)
|
||||
const (
|
||||
CpsSceneHaircut = "haircut" // 发型卡「做同款发型」
|
||||
CpsSceneItemBuy = "item_buy" // 穿衣清单「买同款」
|
||||
CpsSceneItemUpgrade = "item_upgrade" // 穿衣清单「到店试穿」
|
||||
CpsSceneOccasion = "occasion" // 场合卡「延伸优惠」
|
||||
CpsSceneWardrobeUpgrade = "wardrobe_upgrade" // 衣橱「找升级款」
|
||||
CpsSceneMemberBenefit = "member_benefit" // 会员中心最近优惠
|
||||
)
|
||||
|
||||
// 点击日志场景(cps_click_log.scene)
|
||||
const (
|
||||
CpsClickScenePlanHaircut = "plan_haircut"
|
||||
CpsClickScenePlanItem = "plan_item"
|
||||
CpsClickScenePlanOccasion = "plan_occasion"
|
||||
CpsClickSceneWardrobeUpgrade = "wardrobe_upgrade"
|
||||
CpsClickSceneMemberBenefit = "member_benefit"
|
||||
)
|
||||
@@ -18,5 +18,9 @@ const (
|
||||
TableNamePaymentOrder = "slogan_payment_order"
|
||||
TableNameUserMember = "slogan_user_member"
|
||||
TableNamePayNotifyLog = "slogan_pay_notify_log"
|
||||
TableNameAdRewardLog = "slogan_ad_reward_log"
|
||||
TableNameAdRewardLog = "slogan_ad_reward_log"
|
||||
TableNameCpsCategory = "slogan_cps_category"
|
||||
TableNameCpsProduct = "slogan_cps_product"
|
||||
TableNameCpsClickLog = "slogan_cps_click_log"
|
||||
TableNameSceneCategoryMap = "slogan_scene_category_map"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
)
|
||||
|
||||
// CategoryList 联盟分类列表(客户端 chips)
|
||||
func (c *cps) CategoryList(ctx context.Context, req *dto.CpsCategoryListReq) (res *dto.CpsCategoryListRes, err error) {
|
||||
list, err := service.CpsCategoryService.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsCategoryListRes{List: list}, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// MyRecent 最近优惠(点击日志 → 商品)
|
||||
func (c *cps) MyRecent(ctx context.Context, req *dto.CpsMyRecentReq) (res *dto.CpsMyRecentRes, err error) {
|
||||
list, err := service.CpsProductService.MyRecent(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsMyRecentRes{List: list}, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// cps 共享 struct:/cps/* 簇的 6 个 handler 分文件挂在同一个类型上(组前缀路由零变更)
|
||||
type cps struct{}
|
||||
|
||||
var Cps = new(cps)
|
||||
|
||||
// ProductList 选品池分页列表
|
||||
func (c *cps) ProductList(ctx context.Context, req *dto.CpsProductListReq) (res *dto.CpsProductListRes, err error) {
|
||||
list, hasMore, err := service.CpsProductService.ListByCategory(ctx, req.Source, req.CategoryCode, req.City, req.Page, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsProductListRes{List: list, HasMore: hasMore}, nil
|
||||
}
|
||||
|
||||
// ProductLink 商品转链(记录点击日志)
|
||||
func (c *cps) ProductLink(ctx context.Context, req *dto.CpsProductLinkReq) (res *dto.CpsProductLinkRes, err error) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
link, err := service.CpsProductService.ClickLink(ctx, common.GetUserId(r), req.ProductId, req.Scene, req.PlanId, r.GetClientIp())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsProductLinkRes{Deeplink: link}, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// PlanRecommend 方案驱动推荐(发型/买同款/到店试穿/场合)
|
||||
func (c *cps) PlanRecommend(ctx context.Context, req *dto.CpsPlanRecommendReq) (res *dto.CpsPlanRecommendRes, err error) {
|
||||
userId := common.GetUserId(g.RequestFromCtx(ctx))
|
||||
plan, err := service.OutfitPlanService.GetPlan(ctx, userId, req.PlanId)
|
||||
if err != nil || plan == nil {
|
||||
return nil, errors.New("方案不存在")
|
||||
}
|
||||
list, err := service.SceneCategoryMapService.Recommend(ctx, plan, req.Scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsPlanRecommendRes{List: list}, nil
|
||||
}
|
||||
|
||||
// WardrobeUpgrade 衣橱升级款
|
||||
func (c *cps) WardrobeUpgrade(ctx context.Context, req *dto.CpsWardrobeUpgradeReq) (res *dto.CpsWardrobeUpgradeRes, err error) {
|
||||
list, err := service.SceneCategoryMapService.WardrobeUpgrade(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.ItemId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsWardrobeUpgradeRes{List: list}, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var CpsCategory = &cpsCategoryDao{}
|
||||
|
||||
type cpsCategoryDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsCategory+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
parent_code TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
source_cat_id TEXT NOT NULL DEFAULT '',
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create cps_category table failed: %v", err)
|
||||
}
|
||||
seedCpsCategories(ctx)
|
||||
}
|
||||
|
||||
func seedCpsCategories(ctx context.Context) {
|
||||
base := []struct{ code, name, source, sourceCatId string }{
|
||||
{"beauty", "丽人", consts.CpsSourceMeituanOta, ""},
|
||||
{"clothing", "服装", consts.CpsSourceMeituanOta, ""},
|
||||
{"food", "餐厅", consts.CpsSourceMeituanOta, ""},
|
||||
{"hotel", "酒店", consts.CpsSourceMeituanOta, ""},
|
||||
{"ticket", "票务", consts.CpsSourceMeituanOta, ""},
|
||||
{"digital", "数码", consts.CpsSourceJdEcom, ""},
|
||||
}
|
||||
for i, c := range base {
|
||||
if _, err := g.DB().Exec(ctx,
|
||||
"INSERT OR IGNORE INTO "+consts.TableNameCpsCategory+
|
||||
" (code, name, parent_code, source, source_cat_id, sort) VALUES (?, ?, '', ?, ?, ?)",
|
||||
c.code, c.name, c.source, c.sourceCatId, i); err != nil {
|
||||
g.Log().Warningf(ctx, "seed cps_category %s failed: %v", c.code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *cpsCategoryDao) List(ctx context.Context) ([]*entity.CpsCategory, error) {
|
||||
var list []*entity.CpsCategory
|
||||
err := g.DB().Model(consts.TableNameCpsCategory).Ctx(ctx).
|
||||
OrderAsc("sort").OrderAsc("id").Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var CpsClickLog = &cpsClickLogDao{}
|
||||
|
||||
type cpsClickLogDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsClickLog+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
outer_id TEXT NOT NULL DEFAULT '',
|
||||
scene TEXT NOT NULL DEFAULT '',
|
||||
plan_id INTEGER NOT NULL DEFAULT 0,
|
||||
category_code TEXT NOT NULL DEFAULT '',
|
||||
deeplink TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create cps_click_log table failed: %v", err)
|
||||
}
|
||||
_, err = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_click_user ON "+
|
||||
consts.TableNameCpsClickLog+"(user_id, created_at)")
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create cps_click_log index failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *cpsClickLogDao) Insert(ctx context.Context, log *entity.CpsClickLog) (int64, error) {
|
||||
r, err := g.DB().Exec(ctx, "INSERT INTO "+consts.TableNameCpsClickLog+
|
||||
" (user_id, source, outer_id, scene, plan_id, category_code, deeplink, ip, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
|
||||
log.UserId, log.Source, log.OuterId, log.Scene, log.PlanId,
|
||||
log.CategoryCode, log.Deeplink, log.Ip)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *cpsClickLogDao) ListByUser(ctx context.Context, userId int64, limit int) ([]*entity.CpsClickLog, error) {
|
||||
var list []*entity.CpsClickLog
|
||||
err := g.DB().Model(consts.TableNameCpsClickLog).Ctx(ctx).
|
||||
Where("user_id", userId).OrderDesc("id").Limit(limit).Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var CpsProduct = &cpsProductDao{}
|
||||
|
||||
type cpsProductDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsProduct+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
outer_id TEXT NOT NULL DEFAULT '',
|
||||
category_code TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
cover_url TEXT NOT NULL DEFAULT '',
|
||||
price_fen INTEGER NOT NULL DEFAULT 0,
|
||||
shop_name TEXT NOT NULL DEFAULT '',
|
||||
commission_rate INTEGER NOT NULL DEFAULT 0,
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
scene_tags TEXT NOT NULL DEFAULT '[]',
|
||||
raw TEXT NOT NULL DEFAULT '',
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
sync_at DATETIME,
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create cps_product table failed: %v", err)
|
||||
}
|
||||
_, err = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_product_cat ON "+
|
||||
consts.TableNameCpsProduct+"(source, category_code, status)")
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create cps_product index failed: %v", err)
|
||||
}
|
||||
// Upsert 的 ON CONFLICT 依赖唯一索引
|
||||
_, err = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_cps_product_outer ON "+
|
||||
consts.TableNameCpsProduct+"(source, outer_id)")
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create cps_product unique index failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *cpsProductDao) Upsert(ctx context.Context, p *entity.CpsProduct) error {
|
||||
_, err := g.DB().Exec(ctx, `INSERT INTO `+consts.TableNameCpsProduct+
|
||||
` (source, outer_id, category_code, name, cover_url, price_fen, shop_name, commission_rate, city, scene_tags, raw, status, sync_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now','localtime'), datetime('now','localtime'))
|
||||
ON CONFLICT(source, outer_id) DO UPDATE SET
|
||||
name=excluded.name, cover_url=excluded.cover_url, price_fen=excluded.price_fen,
|
||||
shop_name=excluded.shop_name, commission_rate=excluded.commission_rate,
|
||||
city=excluded.city, scene_tags=excluded.scene_tags, raw=excluded.raw,
|
||||
status=1, sync_at=datetime('now','localtime')`,
|
||||
p.Source, p.OuterId, p.CategoryCode, p.Name, p.CoverUrl, p.PriceFen,
|
||||
p.ShopName, p.CommissionRate, p.City, p.SceneTags, p.Raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *cpsProductDao) ListByCategory(ctx context.Context, source, categoryCode, city string, page, pageSize int) ([]*entity.CpsProduct, error) {
|
||||
var list []*entity.CpsProduct
|
||||
m := g.DB().Model(consts.TableNameCpsProduct).Ctx(ctx).
|
||||
Where("status", 1).Where("source", source).Where("category_code", categoryCode)
|
||||
if city != "" {
|
||||
m = m.Where("city", city)
|
||||
}
|
||||
err := m.OrderDesc("sync_at").Limit(pageSize).Offset((page - 1) * pageSize).Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (d *cpsProductDao) CountByCategory(ctx context.Context, source, categoryCode, city string) (int, error) {
|
||||
m := g.DB().Model(consts.TableNameCpsProduct).Ctx(ctx).
|
||||
Where("status", 1).Where("source", source).Where("category_code", categoryCode)
|
||||
if city != "" {
|
||||
m = m.Where("city", city)
|
||||
}
|
||||
return m.Count()
|
||||
}
|
||||
|
||||
func (d *cpsProductDao) Get(ctx context.Context, id int64) (*entity.CpsProduct, error) {
|
||||
var p entity.CpsProduct
|
||||
err := g.DB().Model(consts.TableNameCpsProduct).Ctx(ctx).Where("id", id).Scan(&p)
|
||||
if err != nil || p.Id == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetByOuter 按联盟来源 + 外部 ID 取商品(点击日志回填商品信息用)
|
||||
func (d *cpsProductDao) GetByOuter(ctx context.Context, source, outerId string) (*entity.CpsProduct, error) {
|
||||
var p entity.CpsProduct
|
||||
err := g.DB().Model(consts.TableNameCpsProduct).Ctx(ctx).
|
||||
Where("source", source).Where("outer_id", outerId).Scan(&p)
|
||||
if err != nil || p.Id == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
@@ -32,15 +32,20 @@ func init() {
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create outfit_plan table failed: %v", err)
|
||||
}
|
||||
// 容错迁移:CREATE TABLE IF NOT EXISTS 不给旧库加列,duplicate column 错误可忽略
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameOutfitPlan+
|
||||
" ADD COLUMN occasion TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
g.Log().Warningf(ctx, "migrate outfit_plan.occasion skipped: %v", err)
|
||||
}
|
||||
_, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_user ON "+consts.TableNameOutfitPlan+"(user_id, created_at)")
|
||||
_, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_task ON "+consts.TableNameOutfitPlan+"(task_id)")
|
||||
}
|
||||
|
||||
func (d *outfitPlanDao) Insert(ctx context.Context, data *entity.OutfitPlan) (int64, error) {
|
||||
r, err := g.DB().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNameOutfitPlan+" (task_id, user_id, date_range, location, title, source, score, main_flag, hairstyle_id, hair_color, weather_ref, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
|
||||
"INSERT INTO "+consts.TableNameOutfitPlan+" (task_id, user_id, date_range, location, title, source, score, main_flag, hairstyle_id, hair_color, weather_ref, occasion, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
|
||||
data.TaskId, data.UserId, data.DateRange, data.Location, data.Title, data.Source,
|
||||
data.Score, data.MainFlag, data.HairstyleId, data.HairColor, data.WeatherRef)
|
||||
data.Score, data.MainFlag, data.HairstyleId, data.HairColor, data.WeatherRef, data.Occasion)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var SceneCategoryMap = &sceneCategoryMapDao{}
|
||||
|
||||
type sceneCategoryMapDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameSceneCategoryMap+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scene_type TEXT NOT NULL DEFAULT '',
|
||||
occasion TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
category_code TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create scene_category_map table failed: %v", err)
|
||||
}
|
||||
seedSceneCategoryMap(ctx)
|
||||
}
|
||||
|
||||
func seedSceneCategoryMap(ctx context.Context) {
|
||||
seeds := []*entity.SceneCategoryMap{
|
||||
{SceneType: consts.CpsSceneHaircut, Source: consts.CpsSourceMeituanOta, CategoryCode: "beauty"},
|
||||
{SceneType: consts.CpsSceneItemBuy, Source: consts.CpsSourceJdEcom, CategoryCode: "clothing"},
|
||||
{SceneType: consts.CpsSceneItemUpgrade, Source: consts.CpsSourceMeituanOta, CategoryCode: "clothing"},
|
||||
{SceneType: consts.CpsSceneOccasion, Occasion: "通勤", Source: consts.CpsSourceMeituanOta, CategoryCode: "clothing", Priority: 1},
|
||||
{SceneType: consts.CpsSceneOccasion, Occasion: "约会", Source: consts.CpsSourceMeituanOta, CategoryCode: "food", Priority: 1},
|
||||
{SceneType: consts.CpsSceneOccasion, Occasion: "旅行", Source: consts.CpsSourceMeituanOta, CategoryCode: "hotel", Priority: 1},
|
||||
{SceneType: consts.CpsSceneOccasion, Occasion: "运动", Source: consts.CpsSourceMeituanOta, CategoryCode: "ticket", Priority: 1},
|
||||
}
|
||||
for _, s := range seeds {
|
||||
if _, err := g.DB().Exec(ctx,
|
||||
"INSERT OR IGNORE INTO "+consts.TableNameSceneCategoryMap+
|
||||
" (scene_type, occasion, source, category_code, priority) VALUES (?, ?, ?, ?, ?)",
|
||||
s.SceneType, s.Occasion, s.Source, s.CategoryCode, s.Priority); err != nil {
|
||||
g.Log().Warningf(ctx, "seed scene_category_map %s/%s failed: %v", s.SceneType, s.Occasion, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// QueryByScene 场景 → 映射列表(occasion 精确匹配优先,通用匹配兜底)
|
||||
func (d *sceneCategoryMapDao) QueryByScene(ctx context.Context, sceneType, occasion string) ([]*entity.SceneCategoryMap, error) {
|
||||
var list []*entity.SceneCategoryMap
|
||||
m := g.DB().Model(consts.TableNameSceneCategoryMap).Ctx(ctx).Where("scene_type", sceneType)
|
||||
if occasion != "" {
|
||||
m = m.Where("occasion", occasion).OrderAsc("priority")
|
||||
} else {
|
||||
m = m.Where("occasion", "")
|
||||
}
|
||||
err := m.Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type CpsCategoryListReq struct {
|
||||
g.Meta `path:"/category/list" method:"get" tags:"CPS" summary:"统一分类列表"`
|
||||
}
|
||||
|
||||
type CpsCategoryListRes struct {
|
||||
List []*entity.CpsCategory `json:"list"`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type CpsMyRecentReq struct {
|
||||
g.Meta `path:"/my/recent" method:"get" tags:"CPS" summary:"我的优惠记录"`
|
||||
}
|
||||
|
||||
type CpsMyRecentRes struct {
|
||||
List []*entity.CpsProduct `json:"list"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type CpsProductListReq struct {
|
||||
g.Meta `path:"/product/list" method:"get" tags:"CPS" summary:"选品池分页列表"`
|
||||
Source string `json:"source"`
|
||||
CategoryCode string `json:"category_code"`
|
||||
City string `json:"city"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
type CpsProductListRes struct {
|
||||
List []*entity.CpsProduct `json:"list"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
type CpsProductLinkReq struct {
|
||||
g.Meta `path:"/product/link" method:"post" tags:"CPS" summary:"商品转链"`
|
||||
ProductId int64 `v:"required" json:"product_id"`
|
||||
Scene string `json:"scene"`
|
||||
PlanId int64 `json:"plan_id"`
|
||||
}
|
||||
|
||||
type CpsProductLinkRes struct {
|
||||
Deeplink string `json:"deeplink"`
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type CpsPlanRecommendReq struct {
|
||||
g.Meta `path:"/plan/recommend" method:"get" tags:"CPS" summary:"方案驱动推荐"`
|
||||
PlanId int64 `v:"required" json:"plan_id"`
|
||||
Scene string `v:"required|in:haircut,item_buy,item_upgrade,occasion" json:"scene"`
|
||||
}
|
||||
|
||||
type CpsPlanRecommendRes struct {
|
||||
List []*entity.CpsProduct `json:"list"`
|
||||
}
|
||||
|
||||
type CpsWardrobeUpgradeReq struct {
|
||||
g.Meta `path:"/wardrobe/upgrade" method:"get" tags:"CPS" summary:"衣橱升级款"`
|
||||
ItemId int64 `v:"required" json:"item_id"`
|
||||
}
|
||||
|
||||
type CpsWardrobeUpgradeRes struct {
|
||||
List []*entity.CpsProduct `json:"list"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type CpsCategory struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
Code string `orm:"code" json:"code"`
|
||||
Name string `orm:"name" json:"name"`
|
||||
ParentCode string `orm:"parent_code" json:"parent_code"`
|
||||
Source string `orm:"source" json:"source"`
|
||||
SourceCatId string `orm:"source_cat_id" json:"source_cat_id"`
|
||||
Sort int `orm:"sort" json:"sort"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type CpsClickLog struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
UserId int64 `orm:"user_id" json:"user_id"`
|
||||
Source string `orm:"source" json:"source"`
|
||||
OuterId string `orm:"outer_id" json:"outer_id"`
|
||||
Scene string `orm:"scene" json:"scene"`
|
||||
PlanId int64 `orm:"plan_id" json:"plan_id"`
|
||||
CategoryCode string `orm:"category_code" json:"category_code"`
|
||||
Deeplink string `orm:"deeplink" json:"deeplink"`
|
||||
Ip string `orm:"ip" json:"ip"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type CpsProduct struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
Source string `orm:"source" json:"source"`
|
||||
OuterId string `orm:"outer_id" json:"outer_id"`
|
||||
CategoryCode string `orm:"category_code" json:"category_code"`
|
||||
Name string `orm:"name" json:"name"`
|
||||
CoverUrl string `orm:"cover_url" json:"cover_url"`
|
||||
PriceFen int64 `orm:"price_fen" json:"price_fen"`
|
||||
ShopName string `orm:"shop_name" json:"shop_name"`
|
||||
CommissionRate int `orm:"commission_rate" json:"commission_rate"`
|
||||
City string `orm:"city" json:"city"`
|
||||
SceneTags string `orm:"scene_tags" json:"scene_tags"`
|
||||
Raw string `orm:"raw" json:"raw"`
|
||||
Status int `orm:"status" json:"status"`
|
||||
SyncAt *gtime.Time `orm:"sync_at" json:"sync_at"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -15,5 +15,6 @@ type OutfitPlan struct {
|
||||
HairstyleId int64 `orm:"hairstyle_id" json:"hairstyle_id"`
|
||||
HairColor string `orm:"hair_color" json:"hair_color"`
|
||||
WeatherRef string `orm:"weather_ref" json:"weather_ref"`
|
||||
Occasion string `orm:"occasion" json:"occasion"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type SceneCategoryMap struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
SceneType string `orm:"scene_type" json:"scene_type"`
|
||||
Occasion string `orm:"occasion" json:"occasion"`
|
||||
Source string `orm:"source" json:"source"`
|
||||
CategoryCode string `orm:"category_code" json:"category_code"`
|
||||
Priority int `orm:"priority" json:"priority"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
)
|
||||
|
||||
type cpsCategoryService struct{}
|
||||
|
||||
var CpsCategoryService = new(cpsCategoryService)
|
||||
|
||||
// List 联盟分类(客户端 chips;未配置任何 key 时也无分类,前端隐藏入口)
|
||||
func (s *cpsCategoryService) List(ctx context.Context) ([]*entity.CpsCategory, error) {
|
||||
return dao.CpsCategory.List(ctx)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"slogan-agent/styleagent/agent"
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcron"
|
||||
)
|
||||
|
||||
type cpsProductService struct {
|
||||
providers []agent.Provider
|
||||
getLinkCache *agent.Cache
|
||||
}
|
||||
|
||||
var CpsProductService = new(cpsProductService)
|
||||
|
||||
func init() {
|
||||
CpsProductService.providers = agent.CpsProviders
|
||||
CpsProductService.getLinkCache = agent.NewTTLCache(24 * time.Hour)
|
||||
}
|
||||
|
||||
// enabledProviders 仅保留配置了 key 的联盟源(未配置 key 优雅降级)
|
||||
func (s *cpsProductService) enabledProviders() []agent.Provider {
|
||||
out := make([]agent.Provider, 0, len(s.providers))
|
||||
for _, p := range s.providers {
|
||||
if p.Enabled() {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetLink 转链(24h 缓存;缓存 key 带 source 前缀避免跨联盟 outerId 冲突)
|
||||
func (s *cpsProductService) GetLink(ctx context.Context, outerId string) (string, error) {
|
||||
providers := s.enabledProviders()
|
||||
if len(providers) == 0 {
|
||||
return "", errors.New("CPS 未开通")
|
||||
}
|
||||
for _, p := range providers {
|
||||
key := p.Source() + ":" + outerId
|
||||
if v, ok := s.getLinkCache.Get(key); ok {
|
||||
return v.(string), nil
|
||||
}
|
||||
link, err := p.GetLink(ctx, outerId)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "联盟 %s 转链失败: %v", p.Source(), err)
|
||||
continue
|
||||
}
|
||||
s.getLinkCache.Set(key, link)
|
||||
return link, nil
|
||||
}
|
||||
return "", errors.New("所有联盟转链失败")
|
||||
}
|
||||
|
||||
// Search 聚合搜索(单个联盟失败不阻断整体,返回空列表而非错误)
|
||||
func (s *cpsProductService) Search(ctx context.Context, keyword, catCode string, page int) ([]agent.CpsProduct, error) {
|
||||
providers := s.enabledProviders()
|
||||
if len(providers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var out []agent.CpsProduct
|
||||
for _, p := range providers {
|
||||
list, err := p.Search(ctx, keyword, catCode, page)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "联盟 %s 搜索失败: %v", p.Source(), err)
|
||||
continue
|
||||
}
|
||||
out = append(out, list...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SyncProducts 全量同步联盟商品(仅 Enabled 源;单联盟/单类目失败跳过不中断)
|
||||
func (s *cpsProductService) SyncProducts(ctx context.Context, city, catCode string) error {
|
||||
cats, err := dao.CpsCategory.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range s.enabledProviders() {
|
||||
for _, c := range cats {
|
||||
if c.Source != p.Source() {
|
||||
continue
|
||||
}
|
||||
s.syncCategory(ctx, p, city, c)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cpsProductService) syncCategory(ctx context.Context, p agent.Provider, city string, c *entity.CpsCategory) {
|
||||
products, err := p.SyncProducts(ctx, city, c.Code)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "联盟 %s 同步类目 %s 失败: %v", p.Source(), c.Code, err)
|
||||
return
|
||||
}
|
||||
for i := range products {
|
||||
prod := &products[i]
|
||||
if prod.Source == "" {
|
||||
prod.Source = p.Source()
|
||||
}
|
||||
if prod.CategoryCode == "" {
|
||||
prod.CategoryCode = c.Code
|
||||
}
|
||||
if err := dao.CpsProduct.Upsert(ctx, toCpsProductEntity(prod)); err != nil {
|
||||
g.Log().Warningf(ctx, "写入商品 %s 失败: %v", prod.OuterId, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toCpsProductEntity(p *agent.CpsProduct) *entity.CpsProduct {
|
||||
return &entity.CpsProduct{
|
||||
Source: p.Source,
|
||||
OuterId: p.OuterId,
|
||||
CategoryCode: p.CategoryCode,
|
||||
Name: p.Name,
|
||||
CoverUrl: p.CoverUrl,
|
||||
PriceFen: p.PriceFen,
|
||||
ShopName: p.ShopName,
|
||||
CommissionRate: p.CommissionRate,
|
||||
City: p.City,
|
||||
SceneTags: strings.Join(p.SceneTags, ","),
|
||||
Raw: p.Raw,
|
||||
}
|
||||
}
|
||||
|
||||
// ListByCategory 分页商品列表(hasMore 供客户端上滑分页)
|
||||
func (s *cpsProductService) ListByCategory(ctx context.Context, source, categoryCode, city string, page, pageSize int) ([]*entity.CpsProduct, bool, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
total, err := dao.CpsProduct.CountByCategory(ctx, source, categoryCode, city)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
list, err := dao.CpsProduct.ListByCategory(ctx, source, categoryCode, city, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return list, page*pageSize < total, nil
|
||||
}
|
||||
|
||||
// ClickLink 取转链并记录点击日志(/cps/product/link 调用)
|
||||
func (s *cpsProductService) ClickLink(ctx context.Context, userId, productId int64, scene string, planId int64, ip string) (string, error) {
|
||||
prod, err := dao.CpsProduct.Get(ctx, productId)
|
||||
if err != nil || prod == nil {
|
||||
return "", errors.New("商品不存在")
|
||||
}
|
||||
link, err := s.GetLink(ctx, prod.OuterId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, _ = dao.CpsClickLog.Insert(ctx, &entity.CpsClickLog{
|
||||
UserId: userId,
|
||||
Source: prod.Source,
|
||||
OuterId: prod.OuterId,
|
||||
Scene: scene,
|
||||
PlanId: planId,
|
||||
CategoryCode: prod.CategoryCode,
|
||||
Deeplink: link,
|
||||
Ip: ip,
|
||||
})
|
||||
return link, nil
|
||||
}
|
||||
|
||||
// MyRecent 最近优惠(点击日志 → 商品信息,去重倒序)
|
||||
func (s *cpsProductService) MyRecent(ctx context.Context, userId int64) ([]*entity.CpsProduct, error) {
|
||||
logs, err := dao.CpsClickLog.ListByUser(ctx, userId, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]bool, len(logs))
|
||||
out := make([]*entity.CpsProduct, 0, len(logs))
|
||||
for _, log := range logs {
|
||||
key := log.Source + ":" + log.OuterId
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
prod, err := dao.CpsProduct.GetByOuter(ctx, log.Source, log.OuterId)
|
||||
if err != nil || prod == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, prod)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StartSyncLoop 定时同步联盟商品(main 启动;未配置任何 key 时空转)
|
||||
func (s *cpsProductService) StartSyncLoop(ctx context.Context) {
|
||||
spec := g.Cfg().MustGet(ctx, "cps.sync_cron", "0 4 * * *").String()
|
||||
if _, err := gcron.Add(ctx, spec, func(ctx context.Context) {
|
||||
if len(s.enabledProviders()) == 0 {
|
||||
return
|
||||
}
|
||||
g.Log().Info(ctx, "CPS 定时同步开始")
|
||||
_ = s.SyncProducts(ctx, "", "")
|
||||
g.Log().Info(ctx, "CPS 定时同步结束")
|
||||
}); err != nil {
|
||||
g.Log().Warningf(ctx, "CPS 定时同步注册失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
|
||||
"slogan-agent/styleagent/agent"
|
||||
)
|
||||
|
||||
type stubCpsProvider struct {
|
||||
enabled bool
|
||||
link string
|
||||
linkErr error
|
||||
calls int
|
||||
search []agent.CpsProduct
|
||||
lastKw string
|
||||
lastCat string
|
||||
}
|
||||
|
||||
func (p *stubCpsProvider) Source() string { return "stub" }
|
||||
func (p *stubCpsProvider) Enabled() bool { return p.enabled }
|
||||
func (p *stubCpsProvider) SyncProducts(ctx context.Context, city, catCode string) ([]agent.CpsProduct, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (p *stubCpsProvider) Search(ctx context.Context, keyword, catCode string, page int) ([]agent.CpsProduct, error) {
|
||||
if !p.enabled {
|
||||
return nil, errors.New("not enabled")
|
||||
}
|
||||
p.lastKw = keyword
|
||||
p.lastCat = catCode
|
||||
return p.search, nil
|
||||
}
|
||||
func (p *stubCpsProvider) GetLink(ctx context.Context, outerId string) (string, error) {
|
||||
p.calls++
|
||||
if p.linkErr != nil {
|
||||
return "", p.linkErr
|
||||
}
|
||||
return p.link, nil
|
||||
}
|
||||
|
||||
func TestCpsDisabledHasNoProviders(t *testing.T) {
|
||||
s := &cpsProductService{providers: []agent.Provider{&stubCpsProvider{enabled: false}}}
|
||||
if len(s.enabledProviders()) != 0 {
|
||||
t.Fatal("未配置 key 的 provider 不应进入注册表")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCpsGetLinkCache(t *testing.T) {
|
||||
p := &stubCpsProvider{enabled: true, link: "https://t.cn/abc"}
|
||||
s := &cpsProductService{providers: []agent.Provider{p}, getLinkCache: agent.NewTTLCache(24 * time.Hour)}
|
||||
|
||||
link1, err := s.GetLink(context.Background(), "outer1")
|
||||
if err != nil || link1 != "https://t.cn/abc" {
|
||||
t.Fatalf("首次转链失败: %v %q", err, link1)
|
||||
}
|
||||
link2, err := s.GetLink(context.Background(), "outer1")
|
||||
if err != nil || link2 != "https://t.cn/abc" {
|
||||
t.Fatalf("缓存命中失败: %v %q", err, link2)
|
||||
}
|
||||
if p.calls != 1 {
|
||||
t.Fatalf("转链应只调用联盟 1 次(缓存),实际 %d 次", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCpsGetLinkNoProvider(t *testing.T) {
|
||||
s := &cpsProductService{providers: []agent.Provider{}}
|
||||
if _, err := s.GetLink(context.Background(), "outer1"); err == nil {
|
||||
t.Fatal("无 provider 时转链应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCpsSearchDegradesEmpty(t *testing.T) {
|
||||
s := &cpsProductService{providers: []agent.Provider{}}
|
||||
list, err := s.Search(context.Background(), "西装", "", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("降级应返回空而非错误: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("降级应返回空列表: %d", len(list))
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func runGenerateTask(ctx context.Context, taskId, userId int64) {
|
||||
TaskId: taskId, UserId: userId, DateRange: dateRange, Location: task.Location,
|
||||
Title: p.Title, Source: planSource(p), Score: scores[i],
|
||||
HairstyleId: matchHairstyle(p.Hairstyle, hairstylesAll), HairColor: p.HairColor,
|
||||
WeatherRef: weatherRef,
|
||||
WeatherRef: weatherRef, Occasion: occasion,
|
||||
})
|
||||
if err != nil {
|
||||
fail(err)
|
||||
|
||||
@@ -21,6 +21,11 @@ func (s *outfitPlanService) ListPlans(ctx context.Context, userId int64) ([]*ent
|
||||
return dao.OutfitPlan.ListByUser(ctx, userId)
|
||||
}
|
||||
|
||||
// GetPlan 按用户取方案(CPS 方案驱动推荐入口用)
|
||||
func (s *outfitPlanService) GetPlan(ctx context.Context, userId, planId int64) (*entity.OutfitPlan, error) {
|
||||
return dao.OutfitPlan.GetOne(ctx, planId, userId)
|
||||
}
|
||||
|
||||
func (s *outfitPlanService) GetPlanDetail(ctx context.Context, userId, planId int64) (*dto.OutfitPlanDetailRes, error) {
|
||||
plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId)
|
||||
if err != nil || plan == nil {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
)
|
||||
|
||||
// sceneCategoryMapService 方案驱动推荐(零新增 LLM:场景映射表 + 联盟选品/搜索)
|
||||
type sceneCategoryMapService struct {
|
||||
productSvc *cpsProductService // 测试注入;nil 时用全局 CpsProductService
|
||||
}
|
||||
|
||||
var SceneCategoryMapService = new(sceneCategoryMapService)
|
||||
|
||||
// Recommend 按场景给方案推荐联盟商品;查不到返回空列表(客户端隐藏入口)
|
||||
func (s *sceneCategoryMapService) Recommend(ctx context.Context, plan *entity.OutfitPlan, scene string) ([]*entity.CpsProduct, error) {
|
||||
svc := s.productSvc
|
||||
if svc == nil {
|
||||
svc = CpsProductService
|
||||
}
|
||||
switch scene {
|
||||
case consts.CpsSceneHaircut:
|
||||
return s.recommendHaircut(ctx, svc, plan)
|
||||
case consts.CpsSceneItemBuy:
|
||||
return s.recommendItemBuy(ctx, svc, plan)
|
||||
case consts.CpsSceneItemUpgrade:
|
||||
return s.recommendItemUpgrade(ctx, svc, plan)
|
||||
case consts.CpsSceneOccasion:
|
||||
return s.recommendOccasion(ctx, svc, plan)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// recommendHaircut 发型 → 映射表丽人(美团到店),城市过滤
|
||||
func (s *sceneCategoryMapService) recommendHaircut(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) {
|
||||
if plan.HairstyleId <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.byScene(ctx, svc, consts.CpsSceneHaircut, "", plan.Location)
|
||||
}
|
||||
|
||||
// recommendOccasion 场合 → 映射表(通勤/约会/旅行/运动),城市过滤
|
||||
func (s *sceneCategoryMapService) recommendOccasion(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) {
|
||||
return s.byScene(ctx, svc, consts.CpsSceneOccasion, plan.Occasion, plan.Location)
|
||||
}
|
||||
|
||||
// byScene 场景映射表(priority 最高者)→ 分页商品
|
||||
func (s *sceneCategoryMapService) byScene(ctx context.Context, svc *cpsProductService, sceneType, occasion, city string) ([]*entity.CpsProduct, error) {
|
||||
maps, err := dao.SceneCategoryMap.QueryByScene(ctx, sceneType, occasion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(maps) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
list, _, err := svc.ListByCategory(ctx, maps[0].Source, maps[0].CategoryCode, city, 1, 10)
|
||||
return list, err
|
||||
}
|
||||
|
||||
// recommendItemBuy 买同款:推荐条目名作关键词 → 电商搜索
|
||||
func (s *sceneCategoryMapService) recommendItemBuy(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) {
|
||||
items, err := dao.PlanOutfitItem.ListByPlan(ctx, plan.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyword := ""
|
||||
for _, it := range items {
|
||||
if it.Source == consts.PlanSourceRecommend && it.Name != "" {
|
||||
keyword = it.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if keyword == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return s.search(ctx, svc, keyword, "")
|
||||
}
|
||||
|
||||
// recommendItemUpgrade 到店试穿:首条条目名 → 服装类目(美团)
|
||||
func (s *sceneCategoryMapService) recommendItemUpgrade(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) {
|
||||
items, err := dao.PlanOutfitItem.ListByPlan(ctx, plan.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.search(ctx, svc, items[0].Name, "clothing")
|
||||
}
|
||||
|
||||
// WardrobeUpgrade 衣橱升级款:物品品类作关键词 → 服装类目搜索
|
||||
func (s *sceneCategoryMapService) WardrobeUpgrade(ctx context.Context, userId, itemId int64) ([]*entity.CpsProduct, error) {
|
||||
item, err := dao.WardrobeItem.GetOne(ctx, itemId, userId)
|
||||
if err != nil || item == nil {
|
||||
return nil, errors.New("衣橱物品不存在")
|
||||
}
|
||||
svc := s.productSvc
|
||||
if svc == nil {
|
||||
svc = CpsProductService
|
||||
}
|
||||
return s.search(ctx, svc, item.Category, "clothing")
|
||||
}
|
||||
|
||||
// search 联盟实时搜索,失败降级为空列表而非错误
|
||||
func (s *sceneCategoryMapService) search(ctx context.Context, svc *cpsProductService, keyword, catCode string) ([]*entity.CpsProduct, error) {
|
||||
raw, err := svc.Search(ctx, keyword, catCode, 1)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]*entity.CpsProduct, 0, len(raw))
|
||||
for i := range raw {
|
||||
out = append(out, toCpsProductEntity(&raw[i]))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"slogan-agent/styleagent/agent"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
)
|
||||
|
||||
func newTestSceneSvc(providers []agent.Provider) *sceneCategoryMapService {
|
||||
return &sceneCategoryMapService{productSvc: &cpsProductService{
|
||||
providers: providers,
|
||||
getLinkCache: agent.NewTTLCache(24 * time.Hour),
|
||||
}}
|
||||
}
|
||||
|
||||
func upsertTestProduct(t *testing.T, source, outerId, catCode, city, name string, priceFen int64) {
|
||||
t.Helper()
|
||||
err := dao.CpsProduct.Upsert(context.Background(), &entity.CpsProduct{
|
||||
Source: source, OuterId: outerId, CategoryCode: catCode,
|
||||
Name: name, PriceFen: priceFen, City: city,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("插入测试商品失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendHaircut(t *testing.T) {
|
||||
upsertTestProduct(t, consts.CpsSourceMeituanOta, "mt-beauty-1", "beauty", "北京", "明星剪发", 8800)
|
||||
svc := newTestSceneSvc(nil)
|
||||
list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{HairstyleId: 5, Location: "北京"}, consts.CpsSceneHaircut)
|
||||
if err != nil {
|
||||
t.Fatalf("发型场景推荐失败: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Name != "明星剪发" {
|
||||
t.Fatalf("发型场景应命中丽人商品,实际 %d 条", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendHaircutNoHairstyle(t *testing.T) {
|
||||
svc := newTestSceneSvc(nil)
|
||||
list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{HairstyleId: 0}, consts.CpsSceneHaircut)
|
||||
if err != nil {
|
||||
t.Fatalf("无发型时应返回空而非错误: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("无发型应返回空列表: %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendItemBuy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
planId := insertTestPlan(ctx, t)
|
||||
_, err := dao.PlanOutfitItem.Insert(ctx, &entity.PlanOutfitItem{
|
||||
PlanId: planId, Slot: consts.SlotTop, Source: consts.PlanSourceRecommend, Name: "黑色西装外套",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("插入方案条目失败: %v", err)
|
||||
}
|
||||
p := &stubCpsProvider{enabled: true, search: []agent.CpsProduct{
|
||||
{Source: consts.CpsSourceJdEcom, OuterId: "jd1", Name: "黑色西装外套 男", PriceFen: 19900},
|
||||
{Source: consts.CpsSourceJdEcom, OuterId: "jd2", Name: "黑色西装裤 男", PriceFen: 9900},
|
||||
}}
|
||||
svc := newTestSceneSvc([]agent.Provider{p})
|
||||
list, err := svc.Recommend(ctx, &entity.OutfitPlan{Id: planId}, consts.CpsSceneItemBuy)
|
||||
if err != nil {
|
||||
t.Fatalf("买同款推荐失败: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("买同款应返回 2 条,实际 %d", len(list))
|
||||
}
|
||||
if p.lastKw != "黑色西装外套" {
|
||||
t.Fatalf("买同款关键词应为推荐条目名,实际 %q", p.lastKw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendItemUpgrade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
planId := insertTestPlan(ctx, t)
|
||||
_, err := dao.PlanOutfitItem.Insert(ctx, &entity.PlanOutfitItem{
|
||||
PlanId: planId, Slot: consts.SlotTop, Source: consts.PlanSourceWardrobe, Name: "旧卫衣",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("插入方案条目失败: %v", err)
|
||||
}
|
||||
p := &stubCpsProvider{enabled: true, search: []agent.CpsProduct{
|
||||
{Source: consts.CpsSourceMeituanOta, OuterId: "mt-up", Name: "西装定制店", PriceFen: 29900},
|
||||
}}
|
||||
svc := newTestSceneSvc([]agent.Provider{p})
|
||||
list, err := svc.Recommend(ctx, &entity.OutfitPlan{Id: planId}, consts.CpsSceneItemUpgrade)
|
||||
if err != nil {
|
||||
t.Fatalf("到店试穿推荐失败: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("到店试穿应返回 1 条,实际 %d", len(list))
|
||||
}
|
||||
if p.lastCat != "clothing" {
|
||||
t.Fatalf("到店试穿应带服装类目,实际 %q", p.lastCat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendOccasion(t *testing.T) {
|
||||
upsertTestProduct(t, consts.CpsSourceMeituanOta, "mt-food-1", "food", "上海", "烛光晚餐双人套餐", 68800)
|
||||
svc := newTestSceneSvc(nil)
|
||||
list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{Occasion: "约会", Location: "上海"}, consts.CpsSceneOccasion)
|
||||
if err != nil {
|
||||
t.Fatalf("场合推荐失败: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Name != "烛光晚餐双人套餐" {
|
||||
t.Fatalf("约会场合应命中餐厅商品,实际 %d 条", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendEmptyDegrade(t *testing.T) {
|
||||
svc := newTestSceneSvc(nil)
|
||||
list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{Occasion: "旅行", Location: "北京"}, consts.CpsSceneOccasion)
|
||||
if err != nil {
|
||||
t.Fatalf("无商品时应返回空而非错误: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("无商品应返回空列表: %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func insertTestPlan(ctx context.Context, t *testing.T) int64 {
|
||||
t.Helper()
|
||||
id, err := dao.OutfitPlan.Insert(ctx, &entity.OutfitPlan{
|
||||
TaskId: 999001, UserId: 999001, DateRange: "2026-08-01 ~ 2026-08-07",
|
||||
Location: "北京", Title: "测试方案", Source: consts.PlanSourceWardrobe,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("插入测试方案失败: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
Reference in New Issue
Block a user