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:
2026-07-31 15:48:30 +08:00
co-authored by Claude Opus 4.7
parent fd13efb9c0
commit 76100a7131
35 changed files with 1835 additions and 7 deletions
+2 -2
View File
@@ -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
View File
@@ -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"
+4
View File
@@ -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
View File
@@ -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
+233
View File
@@ -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
}
+177
View File
@@ -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)
}
+208
View File
@@ -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)
}
+34
View File
@@ -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{},
}
+27
View File
@@ -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"
)
+5 -1
View File
@@ -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
}
+57
View File
@@ -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
}
+55
View File
@@ -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
}
+102
View File
@@ -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
}
+7 -2
View File
@@ -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
}
+63
View File
@@ -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
}
+15
View File
@@ -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"`
}
+15
View File
@@ -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"`
}
+31
View File
@@ -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"`
}
+14
View File
@@ -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"`
}
+16
View File
@@ -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"`
}
+21
View File
@@ -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"`
}
+1
View File
@@ -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)
}
+211
View File
@@ -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
}