Files
gyxx-flow/docs/style-identity-refactor-plan.md
T
wangyunlong 6f1c193f4b feat(product-commerce): 款式主键统一 style_id 并下线评价采集
采集端与 data-hub 统一用 style_id (= dim_style.id) 关联款式,style_name 降级为显示冗余。

款式主键:
- 14 张采集表加 style_id BIGINT + style_match_method + *_style_id_idx 索引
- 一律逻辑外键:不建 FOREIGN KEY,早期 10 个 *_style_id_fk 在 schema.sql 里幂等 DROP
- loader 下发 dim_style.id;db 层 attach_style_ids / resolve_style_ids_by_product 兜底
- 新增 backfill_style_id.py 三段回填(款名 / 日报按商品 ID / 广告表按 spu_id)
- 新增视图 v_style_id_coverage、v_style_unmatched_product、v_style_unmatched_source
- 实库补录 199 个商品 ID 到 67 款 spus;日报 style_id 覆盖 49.1% -> 79.0%,
  tm 广告 99.4% / jd 92.4% / dy 83.2%,其余 11 张 100%

评价采集下线:
- product_reviews 归档为 product_reviews_archived_20261101
- 差评改由 shop.{tmall,jd,dy}_negative_reviews.daily 采集,消费方读 v_negative_reviews
- 删除 import_product_reviews.py / orchestrate_review_collection.py 及相关命令与绑定
- dy_product_scraping 默认流程摘除评论采集,保留 --reviews-only

天猫广告:
- 迁移到独立表 tm_product_ad_daily_metrics,不再写 product_daily_metrics.raw_data

同批附带:
- 光合上传支持天猫新店 new_bag(仅黑曜石系列)
- 京东商智双月日历排除 -diff-month 补位格
- 万相主体搜索词覆盖 STYLE_SEARCH_KEYWORDS
- ERP 部分编码失败时用已成功编码销量兜底,避免整款归零
2026-11-01 16:43:27 +08:00

22 KiB
Raw Blame History

款式主键统一 + 评论采集下线 方案

状态:已实施(2026-11-01),见文末「八、实施记录」 涉及项目:gyxx-flow(采集端)、data-hub(消费端,共用同一个 PostgreSQL) 编写日期:2026-11-01

一、三条结论

  1. product_reviews 可以下线,但不能直接删——它有两个消费方(风格分析、营销日报)要改成读三张差评表。
  2. style_id 就是 dim_style.id,与 data-hub 现有 style_id 同一体系,不会产生第二套 ID。已实测:erp_product.style_id 的 135 个值 135/135 命中 dim_style.id,own_brand_product.style_id 31/34 命中(3 条为历史脏数据)。
  3. 改造入口只有一处:DIM_STYLE_RUNTIME_SQL 早已 SELECT s.id,merge_lifecycle_style_records() 也已经拿到这个 id——只是被编码进 source_record_id = "lifecycle_style:<id>" 字符串里,没有作为独立字段下发给写入脚本。把它提成正式字段,全链路就都有了。

二、现状盘点

2.1 两套引用方式并存

侧 关联方式 表
data-hub(产品生命、营销中台、客服、内容) style_id BIGINT → dim_style.id erp_product、own_brand_product、erp_marketing_*、erp_cs_*、cmt_*、market_product_classification 等 22 张
gyxx-flow(采集端) style_name TEXT 15 张,见 2.3

2.2 实测匹配率(2026-11-01)

表 总行数 按 style_name 命中 dim_style
daily_style_metrics 17,352 17,352(100%)
erp_all_shop_style_daily_metrics 9,399 9,399(100%)
product_reviews 1,936 1,434(74%,全部来自抖音;京东 214 行 style_name 全 NULL)
product_daily_metrics 75,568 无款式列,只能靠 tm_spus/jd_spus/dy_spus @> to_jsonb(product_id) 现算

product_daily_metrics 按商品 ID 反查的现状:

平台 行数 可匹配 去重商品 ID 可匹配 ID
dy 28,972 13,615(47%) 253 83
jd 17,044 9,394(55%) 160 61
tm 29,552 14,089(48%) 281 92

一对多歧义 0 条。约一半行匹配不上,原因是 458 个商品 ID 从未写进 dim_style 的 spus 数组(dy 170 / jd 99 / tm 189),不是字段设计问题。

2.3 采集端 15 张只存 style_name 的表

daily_style_metrics、weekly_style_metrics、erp_all_shop_style_daily_metrics、daily_persona_metrics、main_image_creatives、fact_style_analysis_reports、decline_events、fact_alert_history、jd_self_weekly_sales、persona_screenshots、product_reviews、jd_product_ad_daily_metrics、dy_product_ad_daily_metrics、workflow_dynamic_styles、product_daily_metrics(无列,靠 ID 反查)。

三、目标模型

  • 唯一关联键 = style_id(dim_style.id),所有采集表落库时必须带上。
  • style_name 保留为显示冗余,不再作为 join 键;等下游切完 join 后可以只在视图里出现。
  • 匹配不上的行(未建档商品、广告未匹配 SKU、差评)允许 style_id IS NULL,但必须同时写 style_match_method(product_id / product_name / anchor / unmatched),便于统计漏维护。
  • 不动现有主键。daily_style_metrics 的 PK 仍是 (metric_date, style_name, platform)——data-hub 有 6 处 metric.style_name = style.name 的 join,改 PK 收益小、回归面大。加列、加索引、切 join 即可。

四、实施分期

P0 观测层(只读,不动数据)

新增两个视图,先把问题量化出来,方案落地后靠它们验收:

-- 未建档的平台商品 ID(推动在 dim_style 里补 spus)
CREATE OR REPLACE VIEW v_style_unmatched_product AS
SELECT m.platform, m.product_id,
       MAX(m.product_name) AS product_name,
       MIN(m.stat_date) AS first_date, MAX(m.stat_date) AS last_date,
       COUNT(*) AS rows
FROM product_daily_metrics m
WHERE NOT EXISTS (
        SELECT 1 FROM dim_style d
        WHERE (m.platform = 'tm' AND d.tm_spus @> to_jsonb(m.product_id))
           OR (m.platform = 'jd' AND d.jd_spus @> to_jsonb(m.product_id))
           OR (m.platform = 'dy' AND d.dy_spus @> to_jsonb(m.product_id)))
GROUP BY m.platform, m.product_id;

-- 各表 style_id 回填进度
CREATE OR REPLACE VIEW v_style_id_coverage AS
SELECT 'daily_style_metrics' AS tbl, COUNT(*) AS rows,
       COUNT(style_id) AS with_style_id FROM daily_style_metrics
UNION ALL SELECT 'erp_all_shop_style_daily_metrics', COUNT(*), COUNT(style_id)
       FROM erp_all_shop_style_daily_metrics
UNION ALL SELECT 'product_daily_metrics', COUNT(*), COUNT(style_id)
       FROM product_daily_metrics;

验收锚点:v_style_id_coverage 里前三张表 with_style_id 开始上涨到预期比例;v_style_unmatched_product 的条数随建档推进下降。

P1 评论采集链路下线

要删的东西

位置 动作
orchestrate_daily_collection.py:437-452 移除 import_product_reviews 步骤
import_product_reviews.py 删除
orchestrate_review_collection.py 删除
config/commands.json:516 product.review.orchestrate 移除命令
db/__init__.py upsert_product_reviews 删除
db/schema.sql:813-870 product_reviews 建表段 移除(库里的表归档不删)

要补的东西

  1. 建统一差评视图,字段对齐两个消费方现在的用法(platform / rating / content / style_id / comment_date):
CREATE OR REPLACE VIEW v_negative_reviews AS
SELECT 'tm' AS platform, n.business_date AS comment_date, n.review_level AS rating,
       n.content, NULL::text AS order_id, NULL::text AS product_id,
       n.product_name, n.review_id, n.review_at, n.raw_review
FROM tmall_negative_reviews n
UNION ALL
SELECT 'jd', n.business_date, n.review_level, n.content, n.order_id, NULL,
       n.product_name, n.review_id, n.review_at, n.raw_review
FROM jd_negative_reviews n
UNION ALL
SELECT 'dy', n.business_date, n.rating_level, n.content, n.order_id, n.product_id,
       n.product_name, n.review_id, n.review_at, n.raw_review
FROM dy_negative_reviews n;
  1. 改两个消费方:

    • analyze_style.py:112 pull_reviews() → 改查 v_negative_reviews(按 style_id 或 product_id 关联)
    • content_marketing/daily_marketing_report.py:1020 _review_context() → 同上
  2. product_reviews 归档而不是删除:

ALTER TABLE product_reviews RENAME TO product_reviews_archived_20261101;

必须明确的功能退化:product_reviews 存的是好/中/差全量,差评三表只有差评。下线后:

  • 风格分析里的「评价等级分布」从三档变成只有差评一档(差评样本反而更完整:天猫 97 + 京东 32 + 抖音 14,而 product_reviews 里天猫是 0、京东 8 月就断了)。
  • 营销日报的「近 7 天差评归因」不受影响,反而多了天猫来源。
  • 若产品侧确实需要好评样本沉淀,需要把三个差评采集器扩成「按等级采集」,不要恢复 product_reviews。

另外 dy_product_scraping.py 主流程里的评论采集动作只产出本地 JSON,product_reviews 停写后就没有消费方,建议同步关掉(或保留但不再进 product.daily)。

P2 loader 下发 style_id(1 处改动,收益最大)

src/gyxx_flow/adapters/workflow_config.py:322 merge_lifecycle_style_records():

output["style_id"] = lifecycle.get("id")          # dim_style.id
output["style_match_method"] = "dim_style"

同时 source_record_id 保留(控制台/账本还在用),只是不再承担传 ID 的职责。

影响面:所有通过 runtime_workflow_configs() 取款的脚本自动获得 style_id,包括日报聚合、周报、ERP 全店铺、人像、主图、声明式分析。这一步不改表、不改写入逻辑,可以先合入验证 loader 输出。

P3 采集表加列 + 回填 + 写入端填充

统一 DDL 模板(以 daily_style_metrics 为例,其余同构):

ALTER TABLE daily_style_metrics ADD COLUMN IF NOT EXISTS style_id BIGINT;
UPDATE daily_style_metrics d SET style_id = s.id
FROM dim_style s WHERE s.style_name = d.style_name AND d.style_id IS NULL;
CREATE INDEX IF NOT EXISTS daily_style_metrics_style_date_idx
    ON daily_style_metrics (style_id, metric_date DESC);

按表处理(必须匹配的加 NOT NULL,允许未匹配的保持可空):

表 款式来源 style_id 写入端改动
daily_style_metrics loader 款式清单 NOT NULL aggregate_daily_final.py + db.upsert_daily_metrics
weekly_style_metrics 从 daily 聚合 NOT NULL weekly_aggregate.py
erp_all_shop_style_daily_metrics loader ERP 款 NOT NULL backfill_erp_all_shop_daily.py
daily_persona_metrics loader NOT NULL collect_*_persona_to_bitable.py
main_image_creatives loader NOT NULL insert_main_image_records.py / insert_jd_main_image_records.py
fact_style_analysis_reports loader NOT NULL analyze_style.py
decline_events / fact_alert_history daily 派生 NOT NULL check_nine_day_decline.py
jd_self_weekly_sales loader NOT NULL jd_self_inventory_sales_collector.py
persona_screenshots loader NOT NULL taobao_dmp_item_crowd_insight_screenshots.py
product_daily_metrics 平台商品 ID 反查 可空 import_product_daily.py
jd_product_ad_daily_metrics / dy_product_ad_daily_metrics 名称/ID 反查 可空(已有 match_status) 两个 import_*_ad_*.py
差评三表 反查 可空 collectors/*_negative_reviews.py 入库侧

product_daily_metrics 的回填要处理系列聚合带来的歧义(当前 0 条歧义,但要按锚点优先写死优先级):

UPDATE product_daily_metrics m
SET style_id = pick.style_id
FROM LATERAL (
    SELECT d.id AS style_id
    FROM dim_style d
    WHERE (m.platform = 'tm' AND d.tm_spus @> to_jsonb(m.product_id))
       OR (m.platform = 'jd' AND d.jd_spus @> to_jsonb(m.product_id))
       OR (m.platform = 'dy' AND d.dy_spus @> to_jsonb(m.product_id))
    ORDER BY d.is_series_anchor DESC, LENGTH(d.style_name), d.id
    LIMIT 1
) pick
WHERE m.style_id IS NULL;

(真实脚本里用 Python 侧 _resolve_style_match() 复用同一套优先级,SQL 只用于一次性回填。)

回填后预期的非空比例 = 2.2 节的实测值:dy 47% / jd 55% / tm 48%。这不是 bug——剩下的是 458 个未建档商品 ID,要靠 P5 的人补 spus 才能涨。

P4 约束 + 下游切 join

  1. 对「必须匹配」的表加外键 → 改为不建物理外键(2026-11-01 定案)。

原始设想是给 10 张「必须匹配」的表加 FOREIGN KEY (style_id) REFERENCES dim_style(id) ON UPDATE CASCADE, 实施后又按业主意见拆掉了。理由是采集链路的语义与硬约束冲突:

  • 采集要求 fail-soft:一个商品 ID 没建档,正确行为是把该行 style_id 留 NULL 并标 unmatched,继续写其它行。物理外键会让整批 INSERT/UPDATE 直接报错,等于「一个商品 未建档 → 当天数据全丢」,比留 NULL 更糟。
  • 款式会动:拆系列、合并同款、重建 dim_style 行都要先卸约束再动数据,运维成本不小。
  • data-hub 侧 22 张表本来就用 style_id 关联、不带物理外键,两边保持一致才好维护。

改为逻辑外键,完整性由三处保证:

环节 手段
写入 loader 下发 style_id(源自 dim_style 本身);缺失时 attach_style_ids / resolve_style_ids_by_product 批量兜底
校验 backfill_style_id.py --dry-run 预演;v_style_id_coverage 报每张表覆盖率
观测 v_style_unmatched_product(日报口径)/ v_style_unmatched_source(全源口径)列未建档清单

老库里早期建过的 10 个 <表名>_style_id_fk 由 schema.sql 里的 DROP CONSTRAINT IF EXISTS 幂等清理;只保留 *_style_id_idx 索引(join 性能靠索引,不靠约束)。

  1. data-hub 侧把 metric.style_name = style.name 换成 metric.style_id = style.id。涉及 ProductLifecycleMapper.xml 第 120、155 行(daily_style_metrics),以及 JOIN dim_style dim ON dim.style_name = style.name(第 15 行)。改完后 text join 变 bigint join,是纯性能收益;style_name 列仍在,改错了可以立即回切。

  2. 加测试:

    • tests/.../test_daily_style_metrics_style_id.py:写入必须带 style_id,且等于 loader 输出的 id
    • tests/.../test_style_id_coverage.py:v_style_id_coverage 里「必须匹配」的表 with_style_id == rows
    • 现有 test_import_tmall_product_ads.py 的风格(断言 SQL 形状)继续用在新增字段上

P5 数据治理(不是代码活,但决定 P3 的收益)

v_style_unmatched_product 出来的 458 个商品 ID 需要在 dim_style 建档页补 tm_spus/jd_spus/dy_spus。分批推进:先补最近 90 天有销量的,历史行允许永久 NULL。

五、风险与取舍

风险 影响 应对
款名在 dim_style 被改,历史表 style_name 变孤儿 现在 join 直接丢数据,这正是要 style_id 的原因 P3 之后 join 走 id,款名改动不再影响
加 NOT NULL 时某天有未建档款写入 写入失败,工作流中断 先在写入端做「未匹配记 NULL + 打 WARN」,P5 补齐后再收紧为 NOT NULL
product_reviews 下线后没有好评样本 风格分析评价分布退化 明确接受;需要时扩差评采集器等级,不恢复旧表
改 PK(有人会想顺手做) data-hub 6 处 join + 飞书脚本全回归 本次明确不做,PK 保持 style_name
差评表天猫侧没有 product_id 只能靠商品名反查款式 采集器补 product_id(页面有商品链接可解析),或退化为名称匹配并标记 unmatched

六、验收标准

  1. v_style_id_coverage:daily_style_metrics、erp_all_shop_style_daily_metrics、weekly_style_metrics 的 with_style_id = rows(100%)。
  2. product_daily_metrics 的 style_id 非空比例不低于 2.2 节的实测基线(47%/55%/48%),且逐月上升。
  3. product_reviews 无新增行;两个消费方在 v_negative_reviews 上跑通,风格分析与营销日报输出结构不变。
  4. data-hub 产品生命页、营销中台预算页的款式指标与改造前同月数值一致(对账 2026-10 全月)。

七、建议执行顺序

P0 → P1(下线评论链路,独立可交付)→ P2(loader 一行改动,验证输出)→ P3(分表灰度,先做 daily_style_metrics)→ P4(约束 + 下游切换)→ P5(补建档)。

P1 与 P2/P3 没有依赖关系,可以先做 P1 拿到收益(少一条采集链路、少一张表),再从容做款式主键统一。

八、实施记录(2026-11-01)

已落地

阶段 成果
P0 v_style_unmatched_product、v_style_id_coverage 两个观测视图已建并验证
P1 删 import_product_reviews.py / orchestrate_review_collection.py;移除 product.review.orchestrate 命令与 runtime binding;run_collect 不再调评论入库;建 v_negative_reviews;两个消费方改读视图;product_reviews 归档为 product_reviews_archived_20261101(1936 行已备份到 var/tmp/product_reviews_archive_20261101.json)
P2 merge_lifecycle_style_records 输出 style_id + style_match_method;loader 透传;实库验证 204/204 款全部拿到 style_id
P3 14 张采集表加 style_id(+ style_match_method)+ 索引;db 层 attach_style_ids / resolve_style_ids_by_product 统一兜底;backfill_style_id.py 回填历史
P4 10 张「必须匹配」表加 style_id → dim_style(id) 物理外键 → 已按要求全部拆除,改逻辑外键(库里 0 个 FK,只留 14 个 style_id 索引);新增 tests/modules/product_commerce/test_style_id_identity.py(28 条契约测试)
P5 提供 v_style_unmatched_product 清单(dy 170 / jd 99 / tm 189),需业务侧补 spus

回填结果(实库)

表 行数 有 style_id 比例
daily_style_metrics 17,352 17,352 100%
weekly_style_metrics 532 532 100%
erp_all_shop_style_daily_metrics 9,399 9,399 100%
daily_persona_metrics 2,357 2,357 100%
main_image_creatives 9,094 9,094 100%
fact_style_analysis_reports 465 465 100%
decline_events / fact_alert_history 14 / 14 14 / 14 100%
persona_screenshots 25 25 100%
jd_self_weekly_sales 117 111 94.9%(6 款已不在 dim_style)
tm_product_ad_daily_metrics 11,727 11,108 94.7%
jd_product_ad_daily_metrics 33,808 24,707 73.1%
dy_product_ad_daily_metrics 18,202 10,875 59.7%
product_daily_metrics 75,568 37,098 49.1%(未建档商品 ID 所致)

与方案不同之处

  1. 天猫广告表已先行迁到独立表:方案编写时天猫指标还写在 product_daily_metrics.raw_data.tm_ad_metrics,实施期间该迁移已完成,因此改为给 tm_product_ad_daily_metrics 加 style_id(并纳入 v_style_id_coverage), 并未改动 raw_data 节点。
  2. 写入端没有逐个脚本改:方案原计划在 13 个写入脚本里各自填充,实际收敛到 db 层 的 attach_style_ids / resolve_style_ids_by_product(一次批量查询),再加 loader 下发。少改 13 处、不会漏,代价是 upsert 多一次按款名/商品 ID 的查询。
  3. dy_product_scraping.py 默认流程的评论采集直接摘除(仍保留 --reviews-only 显式入口),比方案建议的「保留但不再进 product.daily」更彻底。
  4. 物理外键最终没有保留:P4 建过的 10 个 <表名>_style_id_fk 已全部 DROP, schema.sql 改为「幂等清理 + 只建索引」。style_id 与 dim_style.id 是纯逻辑 外键关系,理由见 P4 段(fail-soft 采集语义、款式可重建、与 data-hub 对齐)。

仍待处理

  • data-hub 侧 join 未切(ProductLifecycleMapper.xml 6 处仍是 metric.style_name = style.name)。 现在两边都有 id,可以随时切;切换后是 bigint join 替代 text join。
  • P5 补建档:458 个商品 ID(dy 170 / jd 99 / tm 189)写进 dim_style.spus。
  • 既有红灯(与本次无关):test_tm_brand_profiles 的 backfill 用例、shop_intelligence 的 12 个 jd 竞店用例,改动前即失败。

九、P5 补 spus 实施记录(2026-11-01)

依据的权威字段

补 spus 的匹配依据不是商品标题,而是 product_daily_metrics.product_no —— 它存的就是款名:

平台 product_no 填充率 可用性
天猫 100%(29552/29552) 权威
京东 97.8%(16664/17044) 权威
抖音 0%(0/28972) 只能靠标题

这一条此前被忽略,导致「458 个未建档」被误判为只能猜标题。实际天猫/京东 136 个商品 靠 product_no 就精确命中,另有 31 个靠前后缀近似命中(「蓝鹊pro」→「蓝鹊」、 「知更大号」→「知更」、「星迹」→「星迹2」)。

注意 product_no 不是 ERP 编码:dim_style.erp_style_codes 存的是 ["10510"] 这种数字码,两者用 product_no = erp_style_codes 只能对上 2 个,别混淆。

匹配算法(四路,按优先级)

  1. product_no 精确 = style_name → 直接写;
  2. product_no 与款名互为前后缀且长度差 ≤ 3,且候选唯一 → 直接写;
  3. 标题包含完整款名(抖音只能走这条)→ 用「命中位置后的版本后缀」防串款: 标题「盖亚斜挎包3」、dim_style 有「盖亚斜挎3」时必须指后者而非「盖亚斜挎」; 若该版本款没建档,判「版本款未建档」而不是硬归到基础款;
  4. 编码与标题互相矛盾 → 进人工清单,不自动写。

实测第 4 类里最贵的一条:天猫 649545919334 商品编码写「理岩」、标题写「阿波罗air」, 支付额 50.2 万。猜错就是 50 万挂错款,只能人工定。

写入结果

项 数值
写入商品 ID 199 个
涉及款式 67 个
覆盖 product_daily_metrics 行 22,636
未写(物料 / 冲突待定) 87 个

合并策略:tm_spus / jd_spus / dy_spus 取并集追加,不覆盖已有值;写前备份到 var/tmp/dim_style_spus_backup_20261101.json(204 行)。

覆盖率变化

表 补前 补后
product_daily_metrics 49.1% 79.0%
tm_product_ad_daily_metrics 94.7% 99.4%
jd_product_ad_daily_metrics 73.1% 92.4%
dy_product_ad_daily_metrics 59.7% 83.2%

广告表的提升来自新增的第三段回填(按商品 ID + 补空 style_name):京东广告里 32 个商品 ID 就占了 9,101 行空值,补完 style_name 空白从 9,095 降到 2,554, 抖音从 7,327 降到 3,062。这意味着 data-hub 侧不改任何 SQL,按款名的 join 已经多认回 11,359 行。

剩余 259 个未建档 ID 的构成

分类 商品数 支付额 处置
待建档-真款 128 6.9 万 需在 dim_style 新建款(星云系列链接、拾影单肩包、幻月、纪元、极地冰冠、阿瑞斯、盖亚S1、漫游者、光/暗系列等)
待建档-ozko 线 20 0.2 万 ozko 子品牌款(逐云、微笑系列、甜酷系列、银鸥、独角兽、HB、cdbb、761X…)
白名单-物料配件 30 5.6 万 不该进 spus(内胆包、数据线、风扇、防丢器…)
白名单-服务/券 4 0 补邮费、兑换券、补贴
冲突待定 10 62.9 万 编码与标题矛盾,需人工定

清单:var/tmp/spus_todo.csv(按分档 + 金额排序)。

新增的长期机制

  • backfill_style_id.py 增加 AD_PRODUCT_TABLES 与 _backfill_ad_tables_by_product, 广告表按 spu_id / product_id 二次回填,并只在 style_name 为空时补权威款名 (COALESCE(NULLIF(BTRIM(style_name), ''), %s),不覆盖平台给出的值)。
  • 新增视图 v_style_unmatched_source:product_daily_metrics + 三张广告表统一口径, 补建档只看这一个视图就够(实测与原 v_style_unmatched_product 数值一致, 说明没有纯投流商品只出现在广告表)。