From 76100a71316b514baa2a1fa373afc44c6a38d55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 31 Jul 2026 15:48:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CPS=20=E5=BC=95=E6=93=8E=EF=BC=88?= =?UTF-8?q?=E4=B8=89=E8=81=94=E7=9B=9F=E9=80=82=E9=85=8D=E5=99=A8=20+=20?= =?UTF-8?q?=E6=96=B9=E6=A1=88=E9=A9=B1=E5=8A=A8=E6=8E=A8=E8=8D=90=20+=206?= =?UTF-8?q?=20=E6=8E=A5=E5=8F=A3=20+=20=E9=99=8D=E7=BA=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 美团/京东/淘宝联盟适配器(选品/搜索/转链,未配置 key 优雅降级), CPS 四表五层骨架,方案驱动推荐零新增 LLM(发型/买同款/到店试穿/场合), 定时同步 + 点击日志 + 最近优惠;outfit_plan 容错迁移 occasion 列; Dockerfile 补 mesa/nodejs 运行时。 Co-Authored-By: Claude Opus 4.7 --- Dockerfile | 4 +- config.yml | 18 ++ main.go | 4 + scripts/smoke.sh | 8 +- styleagent/agent/cps_jd.go | 233 ++++++++++++++++++ styleagent/agent/cps_meituan.go | 177 +++++++++++++ styleagent/agent/cps_tb.go | 208 ++++++++++++++++ styleagent/agent/cps_types.go | 34 +++ styleagent/consts/cps.go | 27 ++ styleagent/consts/table_name.go | 6 +- .../controller/cps_category_controller.go | 17 ++ .../controller/cps_click_log_controller.go | 20 ++ .../controller/cps_product_controller.go | 35 +++ .../scene_category_map_controller.go | 35 +++ styleagent/dao/cps_category_dao.go | 57 +++++ styleagent/dao/cps_click_log_dao.go | 55 +++++ styleagent/dao/cps_product_dao.go | 102 ++++++++ styleagent/dao/outfit_plan_dao.go | 9 +- styleagent/dao/scene_category_map_dao.go | 63 +++++ styleagent/model/dto/cps_category_dto.go | 15 ++ styleagent/model/dto/cps_click_log_dto.go | 15 ++ styleagent/model/dto/cps_product_dto.go | 31 +++ .../model/dto/scene_category_map_dto.go | 26 ++ styleagent/model/entity/cps_category.go | 14 ++ styleagent/model/entity/cps_click_log.go | 16 ++ styleagent/model/entity/cps_product.go | 21 ++ styleagent/model/entity/outfit_plan.go | 1 + styleagent/model/entity/scene_category_map.go | 13 + styleagent/service/cps_category_service.go | 17 ++ styleagent/service/cps_product_service.go | 211 ++++++++++++++++ .../service/cps_product_service_test.go | 85 +++++++ .../service/outfit_generation_task_service.go | 2 +- styleagent/service/outfit_plan_service.go | 5 + .../service/scene_category_map_service.go | 119 +++++++++ .../scene_category_map_service_test.go | 139 +++++++++++ 35 files changed, 1835 insertions(+), 7 deletions(-) create mode 100644 styleagent/agent/cps_jd.go create mode 100644 styleagent/agent/cps_meituan.go create mode 100644 styleagent/agent/cps_tb.go create mode 100644 styleagent/agent/cps_types.go create mode 100644 styleagent/consts/cps.go create mode 100644 styleagent/controller/cps_category_controller.go create mode 100644 styleagent/controller/cps_click_log_controller.go create mode 100644 styleagent/controller/cps_product_controller.go create mode 100644 styleagent/controller/scene_category_map_controller.go create mode 100644 styleagent/dao/cps_category_dao.go create mode 100644 styleagent/dao/cps_click_log_dao.go create mode 100644 styleagent/dao/cps_product_dao.go create mode 100644 styleagent/dao/scene_category_map_dao.go create mode 100644 styleagent/model/dto/cps_category_dto.go create mode 100644 styleagent/model/dto/cps_click_log_dto.go create mode 100644 styleagent/model/dto/cps_product_dto.go create mode 100644 styleagent/model/dto/scene_category_map_dto.go create mode 100644 styleagent/model/entity/cps_category.go create mode 100644 styleagent/model/entity/cps_click_log.go create mode 100644 styleagent/model/entity/cps_product.go create mode 100644 styleagent/model/entity/scene_category_map.go create mode 100644 styleagent/service/cps_category_service.go create mode 100644 styleagent/service/cps_product_service.go create mode 100644 styleagent/service/cps_product_service_test.go create mode 100644 styleagent/service/scene_category_map_service.go create mode 100644 styleagent/service/scene_category_map_service_test.go diff --git a/Dockerfile b/Dockerfile index 7505627..a0ac85d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/config.yml b/config.yml index b750337..829fd39 100644 --- a/config.yml +++ b/config.yml @@ -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" diff --git a/main.go b/main.go index c0831c4..c26e571 100644 --- a/main.go +++ b/main.go @@ -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() diff --git a/scripts/smoke.sh b/scripts/smoke.sh index ad86985..69768f9 100644 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -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) diff --git a/styleagent/agent/cps_jd.go b/styleagent/agent/cps_jd.go new file mode 100644 index 0000000..a5c7714 --- /dev/null +++ b/styleagent/agent/cps_jd.go @@ -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 +} diff --git a/styleagent/agent/cps_meituan.go b/styleagent/agent/cps_meituan.go new file mode 100644 index 0000000..e4c325f --- /dev/null +++ b/styleagent/agent/cps_meituan.go @@ -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) +} diff --git a/styleagent/agent/cps_tb.go b/styleagent/agent/cps_tb.go new file mode 100644 index 0000000..683dbf0 --- /dev/null +++ b/styleagent/agent/cps_tb.go @@ -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) +} diff --git a/styleagent/agent/cps_types.go b/styleagent/agent/cps_types.go new file mode 100644 index 0000000..017843b --- /dev/null +++ b/styleagent/agent/cps_types.go @@ -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{}, +} diff --git a/styleagent/consts/cps.go b/styleagent/consts/cps.go new file mode 100644 index 0000000..674a51d --- /dev/null +++ b/styleagent/consts/cps.go @@ -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" +) diff --git a/styleagent/consts/table_name.go b/styleagent/consts/table_name.go index f027bb7..3cf14b2 100644 --- a/styleagent/consts/table_name.go +++ b/styleagent/consts/table_name.go @@ -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" ) diff --git a/styleagent/controller/cps_category_controller.go b/styleagent/controller/cps_category_controller.go new file mode 100644 index 0000000..12edf4f --- /dev/null +++ b/styleagent/controller/cps_category_controller.go @@ -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 +} diff --git a/styleagent/controller/cps_click_log_controller.go b/styleagent/controller/cps_click_log_controller.go new file mode 100644 index 0000000..f0f9af8 --- /dev/null +++ b/styleagent/controller/cps_click_log_controller.go @@ -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 +} diff --git a/styleagent/controller/cps_product_controller.go b/styleagent/controller/cps_product_controller.go new file mode 100644 index 0000000..f391da3 --- /dev/null +++ b/styleagent/controller/cps_product_controller.go @@ -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 +} diff --git a/styleagent/controller/scene_category_map_controller.go b/styleagent/controller/scene_category_map_controller.go new file mode 100644 index 0000000..960a140 --- /dev/null +++ b/styleagent/controller/scene_category_map_controller.go @@ -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 +} diff --git a/styleagent/dao/cps_category_dao.go b/styleagent/dao/cps_category_dao.go new file mode 100644 index 0000000..cd01d65 --- /dev/null +++ b/styleagent/dao/cps_category_dao.go @@ -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 +} diff --git a/styleagent/dao/cps_click_log_dao.go b/styleagent/dao/cps_click_log_dao.go new file mode 100644 index 0000000..b0dbc32 --- /dev/null +++ b/styleagent/dao/cps_click_log_dao.go @@ -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 +} diff --git a/styleagent/dao/cps_product_dao.go b/styleagent/dao/cps_product_dao.go new file mode 100644 index 0000000..d730581 --- /dev/null +++ b/styleagent/dao/cps_product_dao.go @@ -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 +} diff --git a/styleagent/dao/outfit_plan_dao.go b/styleagent/dao/outfit_plan_dao.go index a5d0185..2130d76 100644 --- a/styleagent/dao/outfit_plan_dao.go +++ b/styleagent/dao/outfit_plan_dao.go @@ -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 } diff --git a/styleagent/dao/scene_category_map_dao.go b/styleagent/dao/scene_category_map_dao.go new file mode 100644 index 0000000..d9752c0 --- /dev/null +++ b/styleagent/dao/scene_category_map_dao.go @@ -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 +} diff --git a/styleagent/model/dto/cps_category_dto.go b/styleagent/model/dto/cps_category_dto.go new file mode 100644 index 0000000..b54855a --- /dev/null +++ b/styleagent/model/dto/cps_category_dto.go @@ -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"` +} diff --git a/styleagent/model/dto/cps_click_log_dto.go b/styleagent/model/dto/cps_click_log_dto.go new file mode 100644 index 0000000..b1f901f --- /dev/null +++ b/styleagent/model/dto/cps_click_log_dto.go @@ -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"` +} diff --git a/styleagent/model/dto/cps_product_dto.go b/styleagent/model/dto/cps_product_dto.go new file mode 100644 index 0000000..0ee7838 --- /dev/null +++ b/styleagent/model/dto/cps_product_dto.go @@ -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"` +} diff --git a/styleagent/model/dto/scene_category_map_dto.go b/styleagent/model/dto/scene_category_map_dto.go new file mode 100644 index 0000000..839eaf7 --- /dev/null +++ b/styleagent/model/dto/scene_category_map_dto.go @@ -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"` +} diff --git a/styleagent/model/entity/cps_category.go b/styleagent/model/entity/cps_category.go new file mode 100644 index 0000000..c5a023e --- /dev/null +++ b/styleagent/model/entity/cps_category.go @@ -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"` +} diff --git a/styleagent/model/entity/cps_click_log.go b/styleagent/model/entity/cps_click_log.go new file mode 100644 index 0000000..036ba75 --- /dev/null +++ b/styleagent/model/entity/cps_click_log.go @@ -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"` +} diff --git a/styleagent/model/entity/cps_product.go b/styleagent/model/entity/cps_product.go new file mode 100644 index 0000000..8a46330 --- /dev/null +++ b/styleagent/model/entity/cps_product.go @@ -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"` +} diff --git a/styleagent/model/entity/outfit_plan.go b/styleagent/model/entity/outfit_plan.go index be74d3c..7b3f978 100644 --- a/styleagent/model/entity/outfit_plan.go +++ b/styleagent/model/entity/outfit_plan.go @@ -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"` } diff --git a/styleagent/model/entity/scene_category_map.go b/styleagent/model/entity/scene_category_map.go new file mode 100644 index 0000000..b86ba8c --- /dev/null +++ b/styleagent/model/entity/scene_category_map.go @@ -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"` +} diff --git a/styleagent/service/cps_category_service.go b/styleagent/service/cps_category_service.go new file mode 100644 index 0000000..1d6342a --- /dev/null +++ b/styleagent/service/cps_category_service.go @@ -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) +} diff --git a/styleagent/service/cps_product_service.go b/styleagent/service/cps_product_service.go new file mode 100644 index 0000000..eda5302 --- /dev/null +++ b/styleagent/service/cps_product_service.go @@ -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) + } +} diff --git a/styleagent/service/cps_product_service_test.go b/styleagent/service/cps_product_service_test.go new file mode 100644 index 0000000..88a13a4 --- /dev/null +++ b/styleagent/service/cps_product_service_test.go @@ -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)) + } +} diff --git a/styleagent/service/outfit_generation_task_service.go b/styleagent/service/outfit_generation_task_service.go index 087e79f..970dc08 100644 --- a/styleagent/service/outfit_generation_task_service.go +++ b/styleagent/service/outfit_generation_task_service.go @@ -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) diff --git a/styleagent/service/outfit_plan_service.go b/styleagent/service/outfit_plan_service.go index ff13045..d6a1061 100644 --- a/styleagent/service/outfit_plan_service.go +++ b/styleagent/service/outfit_plan_service.go @@ -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 { diff --git a/styleagent/service/scene_category_map_service.go b/styleagent/service/scene_category_map_service.go new file mode 100644 index 0000000..8eb5cd6 --- /dev/null +++ b/styleagent/service/scene_category_map_service.go @@ -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 +} diff --git a/styleagent/service/scene_category_map_service_test.go b/styleagent/service/scene_category_map_service_test.go new file mode 100644 index 0000000..ec6a985 --- /dev/null +++ b/styleagent/service/scene_category_map_service_test.go @@ -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 +}