Explorar o código

feat(listing-ai): add scoring workbench and JD diagnostics

Yi Jiarui hai 1 mes
pai
achega
12fde57df3
Modificáronse 33 ficheiros con 1692 adicións e 5 borrados
  1. 3 0
      .gitignore
  2. 14 3
      docs/JD-FIRST-BATCH-IMPORT.md
  3. 228 0
      docs/JD-PRODUCT-DETAIL-PARSE-VERIFICATION-2026-08-21.md
  4. 613 0
      docs/LISTING-AI-IMPLEMENTATION-PLAN.md
  5. 182 0
      docs/LISTING-AI-IMPLEMENTATION-REPORT-2026-08-21.md
  6. 6 0
      proxy.conf.json
  7. 5 0
      src/app/app.routes.ts
  8. 2 0
      src/app/core/config/runtime-config.ts
  9. 63 0
      src/modules/listing-ai/components/listing-dimension-panel/listing-dimension-panel.component.ts
  10. 26 0
      src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.html
  11. 0 0
      src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.scss
  12. 11 0
      src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.ts
  13. 5 0
      src/modules/listing-ai/components/listing-score-summary/listing-score-summary.component.ts
  14. 9 0
      src/modules/listing-ai/listing-ai.routes.ts
  15. 0 0
      src/modules/listing-ai/listing-ai.shared.scss
  16. 19 0
      src/modules/listing-ai/models/listing-ai.models.ts
  17. 49 0
      src/modules/listing-ai/pages/product/listing-ai-product.component.html
  18. 45 0
      src/modules/listing-ai/pages/product/listing-ai-product.component.ts
  19. 1 0
      src/modules/listing-ai/pages/task-detail/listing-score-job-detail.component.html
  20. 18 0
      src/modules/listing-ai/pages/task-detail/listing-score-job-detail.component.ts
  21. 1 0
      src/modules/listing-ai/pages/tasks/listing-score-job-list.component.html
  22. 10 0
      src/modules/listing-ai/pages/tasks/listing-score-job-list.component.ts
  23. 1 0
      src/modules/listing-ai/pages/versions/listing-version-list.component.html
  24. 10 0
      src/modules/listing-ai/pages/versions/listing-version-list.component.ts
  25. 23 0
      src/modules/listing-ai/pages/workbench/listing-ai-workbench.component.html
  26. 37 0
      src/modules/listing-ai/pages/workbench/listing-ai-workbench.component.ts
  27. 11 0
      src/modules/listing-ai/services/listing-ai-api.service.spec.ts
  28. 23 0
      src/modules/listing-ai/services/listing-ai-api.service.ts
  29. 13 0
      src/modules/listing-ai/services/listing-ai-workbench.store.ts
  30. 3 0
      src/modules/shared/components/navigation/navigation.component.ts
  31. 122 0
      tools/jd-detail-diagnostic.mjs
  32. 26 2
      tools/jd-import-products.mjs
  33. 113 0
      tools/parse-target-diagnostic.mjs

+ 3 - 0
.gitignore

@@ -9,6 +9,9 @@ coverage/
 .env.*
 !.env.example
 
+# Local credential-bearing connection notes must never be committed.
+docs/JD-CONNECTION-CONFIG.local.md
+
 # Local source workbooks are not committed. Generated normalized data is.
 data/source/
 

+ 14 - 3
docs/JD-FIRST-BATCH-IMPORT.md

@@ -3,8 +3,11 @@
 ## 当前状态
 
 - 京东商品、SKU、库存接口已经在同一授权进程中验证成功。
-- 当前授权店铺商品总数会随店铺实时变化;最近一次复测为 821 条。
-- 线上 `https://server.fmode.cn/backend/vO3T8gAdwB/data` 仍返回 404,说明多应用 Parse 后端还没有挂载,暂时不能写入目标库。
+- `getProduct` 已修复:URL path 中的 `productId` 必须加入 MD5 待签名字段,不能重复放入 query。
+- 当前授权店铺商品总数会随店铺实时变化;2026-08-21 最近一次复测为 824 条。
+- `https://server.fmode.cn/backend/vO3T8gAdwB/data` 是挂载前缀,直接 GET 返回 404;正确 class 资源为 `{base}/classes/<ClassName>`。
+- 已通过 `{base}/classes/ProductDetail` 完成读取和正式幂等写入。详细证据见 `JD-PRODUCT-DETAIL-PARSE-VERIFICATION-2026-08-21.md`。
+- Listing AI 新同步器已完成 824 个动态目录商品的全量源快照同步和评分验收;结果见 `LISTING-AI-IMPLEMENTATION-REPORT-2026-08-21.md`。本页旧 `import:jd-products` 命令仅保留兼容/小批诊断用途。
 
 ## 导入内容
 
@@ -26,7 +29,7 @@ $env:JD_SOURCE_PARSE_URL = "https://server.fmode.cn/parse"
 $env:JD_SOURCE_PARSE_APP_ID = "主 Parse 应用 ID"
 $env:JD_SOURCE_PARSE_MASTER_KEY = "主 Parse Master Key"
 $env:JD_TARGET_PARSE_URL = "https://server.fmode.cn/backend/vO3T8gAdwB/data"
-$env:JD_TARGET_PARSE_APP_ID = "vO3T8gAdwB_main"
+$env:JD_TARGET_PARSE_APP_ID = "目标 Parse Application ID"
 $env:JD_TARGET_PARSE_MASTER_KEY = "目标应用 Master Key"
 $env:JD_APP_KEY = "京东 AppKey"
 $env:JD_APP_SECRET = "京东 AppSecret"
@@ -36,3 +39,11 @@ npm run import:jd-products -- --limit 10
 ```
 
 `--dry-run` 只读取京东并输出数量和商品 ID;正式导入使用业务键更新,重复运行不会重复创建同一商品、SKU 或当天库存快照。
+
+不要用目标 Parse 挂载前缀的根 GET 作为健康检查,也不要依赖当前环境的 `count=1` 结果判断是否有数据。现阶段 readiness 应请求:
+
+```text
+{JD_TARGET_PARSE_URL}/classes/ProductDetail?limit=1
+```
+
+并校验 HTTP 200、JSON body 和 `results` 数组。上游存在间歇性 HTTP 403 / Parse code 119;仅对此精确错误做有限退避重试。

+ 228 - 0
docs/JD-PRODUCT-DETAIL-PARSE-VERIFICATION-2026-08-21.md

@@ -0,0 +1,228 @@
+# JD 商品详情与目标 Parse 联调复测记录(2026-08-21)
+
+## 1. 结论
+
+本次先完成连接层修复,不进入 Listing AI 页面开发。
+
+1. `getProduct` 的 HTTP 400 已定位并修复。根因不是 `scene` 缺失,也不是 URL 路径错误,而是路径参数 `productId` 没有参与 JD MD5 签名。
+2. 目标 Parse 的 `/backend/vO3T8gAdwB/data` 是应用挂载前缀,不是可直接 GET 的健康检查资源;直接请求该前缀返回 404 是预期的路由表现。实际 REST 资源必须追加 `/classes/<ClassName>`。
+3. 已用 `ProductDetail` 资源完成读取与正式幂等写入。目标路径已可用:`/backend/vO3T8gAdwB/data/classes/ProductDetail`。
+4. 写入过程中发现并修复了 `ProductDetail.itemStatus` 的真实 schema 冲突:JD 详情中的状态是 Object,而目标 Parse 字段已是 String,现统一提取为字符串后再写入。
+5. 当前店铺商品总数由接口实时返回。本次 `paginationData.totalItems` 为 **824**;不得继续把 614、821 或 824 写死在页面或任务代码中。
+
+## 2. 安全边界
+
+- 凭据只从环境变量或本机忽略文件读取,本文不记录 AppSecret、MasterKey、access token 或 refresh token。
+- `docs/JD-CONNECTION-CONFIG.local.md` 已加入 `.gitignore`。
+- 诊断脚本只打印状态码、业务错误码、参与签名的**字段名**和响应字段路径,不打印签名原文、签名值或凭据值。
+- JD 和 Parse 调用仍必须留在 Node/服务端边界内,不能移动到 Angular 运行时。
+
+## 3. `getProduct` 400 复测矩阵
+
+测试资源:店铺中的真实商品 `10026651532867`。
+
+请求路径:
+
+```text
+GET https://api-cn.jd.com/rest/sp-product/v0/products/{productId}?scene=pop
+```
+
+| 变体 | 查询参数 | MD5 签名字段 | HTTP | JD 错误码 | 结果 |
+|---|---|---|---:|---|---|
+| 原实现 | `scene=pop` | `X-JOS-Access-Token`, `X-JOS-App-Key`, `X-JOS-Timestamp`, `scene` | 400 | `99904000013` | `请求缺少参数或参数无效,无法解析。` |
+| 修复实现 | `scene=pop` | 上述字段 + `productId` | 200 | — | `success=true`,返回商品详情 |
+| JSON request + path 签名 | `request={"scene":"pop"}` | 公共字段 + `productId`, `request` | 503 | `10110531002` | `ProductDetailRequest.scene is empty` |
+| JSON request,无 path 签名 | `request={"scene":"pop"}` | 公共字段 + `request` | 400 | `99904000013` | 参数无法解析 |
+
+### 正确签名规则
+
+该接口需要区分“发送位置”和“参与签名”两个概念:
+
+- `productId` 只在 URL path 中出现,但仍必须进入待签名字段集合;
+- `scene=pop` 作为 query 参数发送,同时进入待签名字段集合;
+- 公共字段 `X-JOS-App-Key`、`X-JOS-Access-Token`、`X-JOS-Timestamp` 进入签名;
+- `X-JOS-Sign`、`X-JOS-Sign-Method`、`X-JOS-Request-Identity` 不进入业务字段串;
+- 不能为了“让它参与签名”而把 `productId` 再重复放入 query。
+
+修复后的调用形态:
+
+```js
+jdGet(
+  `/sp-product/v0/products/${productId}`,
+  { scene: 'pop' },
+  accessToken,
+  { productId },
+);
+```
+
+## 4. 成功响应的真实字段
+
+以下仅记录本次 200 响应中已观察到的字段路径;字段是否为空仍需按每个商品单独建模。
+
+### 4.1 商品主信息
+
+- `productInfo.productId`
+- `productInfo.productName`
+- `productInfo.productTitle.title`
+- `productInfo.productTitle.titleBrandName`
+- `productInfo.brandInfo.brandId`
+- `productInfo.brandInfo.brandName`
+- `productInfo.categoryDetail.lastCategoryId`
+- `productInfo.categoryDetail.thirdCategoryId`
+- `productInfo.priceInfo.jdPrice`
+- `productInfo.priceInfo.costPrice`
+- `productInfo.itemNum`
+- `productInfo.upcCode`
+- `productInfo.createdTime`
+- `productInfo.modifiedTime`
+- `productInfo.productStatus.yn`
+- `productInfo.productStatus.productStatus`
+- `productInfo.productStatus.productStatusNew`
+- `productInfo.wareStatus`
+
+### 4.2 Listing 内容
+
+- `productInfo.productDetailDesc.desc`
+- `productInfo.productDetailDesc.mobileDesc`
+- `productInfo.features[].key`
+- `productInfo.features[].value`
+- `productInfo.goodsAttrInfos[].attrId`
+- `productInfo.goodsAttrInfos[].attrName`
+- `productInfo.goodsAttrInfos[].values[].attrValue`
+- `productInfo.goodsAttrInfos[].values[].attrValueAlias`
+- `material.mainImages[].imageInfoList[].imgUrl`
+- `material.mainImages[].imageInfoList[].orderSort`
+- `material.mainImages[].imageInfoList[].primaryFlag`
+- `material.mainImages[].imageInfoList[].gptFlag`
+
+### 4.3 SKU 与履约
+
+- `skuList[].skuId`
+- `skuList[].productId`
+- `skuList[].skuName`
+- `skuList[].outerId`
+- `skuList[].upcCode`
+- `skuList[].logo`
+- `skuList[].priceInfo.jdPrice`
+- `skuList[].stockNum`
+- `skuList[].skuEnableStatus`
+- `skuList[].skuStatus.onOffShelfStatus`
+- `skuList[].goodsAttrInfos[]`
+- `productInfo.length`, `width`, `height`, `weight`
+- `productInfo.logisticsInfo.delivery`
+- `productInfo.logisticsInfo.promiseId`
+- `productInfo.logisticsInfo.transportId`
+- `productInfo.logisticsInfo.packSpecification`
+- `productInfo.afterServiceInfo`
+- `productInfo.to7ReturnFlag`
+
+这些字段足以支持标题、主图元数据、详情、规格、SKU 与内容完整度的第一版评分;它们**不能**证明当前 AI 网关已具备图片视觉理解能力。
+
+## 5. 目标 Parse 404 诊断与修复
+
+### 5.1 路由矩阵
+
+| URL 形态 | 本次结果 | 判断 |
+|---|---:|---|
+| `/backend/vO3T8gAdwB/data` | 404 HTML | 仅是挂载前缀,不是健康资源 |
+| `/backend/vO3T8gAdwB/data/classes/ProductDetail?limit=1` | 200 JSON | 正确的目标 REST class 资源 |
+| `/backend/vO3T8gAdwB/classes/ProductDetail?limit=1` | 404 HTML | 缺少 `/data` |
+| `/backend/vO3T8gAdwB/data/parse/classes/ProductDetail?limit=1` | 404 HTML | 多拼了一层 `/parse` |
+| `/parse/classes/ProductDetail?limit=1` | 200 JSON | 全局 Parse 路由也可命中同一应用凭据,但不是本项目约定入口 |
+
+因此,代码中的目标 Parse 配置值保持为:
+
+```text
+https://server.fmode.cn/backend/vO3T8gAdwB/data
+```
+
+真正的读取/写入 URL 由客户端拼接为:
+
+```text
+{JD_TARGET_PARSE_URL}/classes/ProductDetail
+```
+
+“修复 404”不是把配置改成 `/parse`,而是停止对挂载前缀做根 GET,并改用真实资源 endpoint 做 readiness 验证。
+
+### 5.2 写入时发现的 schema 冲突
+
+修复签名后首次正式写入返回:
+
+```text
+HTTP 400: schema mismatch for ProductDetail.itemStatus;
+expected String but got Object
+```
+
+原因是 `getProduct.productInfo.productStatus` 为对象,而旧归一化逻辑可能原样写入 `itemStatus`。现已新增 `extractProductStatus()`,按以下优先级提取标量:
+
+```text
+code -> status -> value -> name -> label -> flattenText
+```
+
+目标 `ProductDetail.itemStatus` 继续保持 String,避免破坏既有 schema。
+
+### 5.3 上游不稳定性
+
+对正确 class 资源各连续采样 10 次,`/backend/.../data/classes/...` 与 `/parse/classes/...` 都出现间歇性:
+
+```json
+{ "code": 119, "error": "unauthorized" }
+```
+
+表现为 HTTP 403,随后重试可恢复为 200。这说明是上游节点/鉴权漂移,而不是 class 路由缺失。当前导入器保留了针对这一**精确错误**的有限重试;不能把所有 401/403 都无条件重试。
+
+另一个实测差异是:`limit=0&count=1` 可能返回 `count: 0`,而普通列表请求能返回真实 `results`。因此现阶段不能把 Parse `count` 当作导入完成度真值。
+
+## 6. 修复文件
+
+| 文件 | 变更 |
+|---|---|
+| `.gitignore` | 忽略本机凭据文档 |
+| `tools/jd-import-products.mjs` | `productId` 参与详情签名;状态对象归一化为 String |
+| `tools/jd-detail-diagnostic.mjs` | 新增安全的 JD 签名变体与字段路径诊断;源 Parse 精确 403 有限重试 |
+| `tools/parse-target-diagnostic.mjs` | 新增 Parse 路由、schema、class 查询与稳定性诊断 |
+| `docs/JD-FIRST-BATCH-IMPORT.md` | 更新真实状态和正确 readiness 方式 |
+| `docs/LISTING-AI-IMPLEMENTATION-PLAN.md` | 在开发前固化实际实施计划 |
+
+## 7. 最终复测结果
+
+### dry-run
+
+```json
+{
+  "mode": "dry-run",
+  "totalProducts": 824,
+  "products": 1,
+  "skuMappings": 1,
+  "stockSnapshots": 1,
+  "detailAvailable": 1
+}
+```
+
+### 正式幂等写入
+
+```json
+{
+  "mode": "imported",
+  "totalProducts": 824,
+  "products": 1,
+  "skuMappings": 5,
+  "stockSnapshots": 5,
+  "detailAvailable": 1
+}
+```
+
+商品/SKU 数量由被抽中的真实商品决定,不能断言每个商品只有一个 SKU。
+
+### 本阶段完成判定
+
+- [x] 本机凭据文档不再出现在 Git 未跟踪列表中;
+- [x] 复现旧签名的 HTTP 400 与 JD 错误码 `99904000013`;
+- [x] 加入 `productId` 签名字段后同一商品 HTTP 200;
+- [x] 记录实际响应字段路径;
+- [x] 正确 Parse class URL HTTP 200;
+- [x] 修复 `itemStatus` schema mismatch;
+- [x] 正式导入 `detailAvailable=1`;
+- [x] 明确上游间歇性 403 和 `count` 不可信风险;
+- [ ] 全量分页导入与 824 个商品评分尚未执行,必须按实施计划分阶段完成。
+

+ 613 - 0
docs/LISTING-AI-IMPLEMENTATION-PLAN.md

@@ -0,0 +1,613 @@
+# Listing AI 优化工作台实施计划
+
+> 状态:已实施并完成首轮全量验收(2026-08-21)  
+> 适用仓库:`Saas-voc` 前端、`Saas-voc-server` 后端  
+> 本文是实施契约;实际落地、全量数据结果和保留门禁见 `LISTING-AI-IMPLEMENTATION-REPORT-2026-08-21.md`。任何字段、路由、表结构或验收口径变更,都应先修改本文并记录原因。
+
+## 0. 2026-08-21 AI 评分目标纠偏(本节优先于旧 MVP 描述)
+
+产品目标确认是**真实 Listing AI 评分**,不是“规则评分后由 AI 生成优化文案”。因此新增 `listing-jd-ai-v1`,并保留 `listing-jd-v2` 作为规则基线:
+
+- AI 不自由输出 0–100 总分,只对固定原子标准返回 `unknown/fail/weak/pass/strong`、原文证据、理由和置信度;
+- 服务端按冻结权重将等级映射为分数、校验范围并求和,模型无权修改维度、权重和总分;
+- 标题、核心卖点、详情和规格使用“确定性结构分 + AI 语义分”;当前文本模型不具备已验证的视觉能力,主图维度暂沿用结构规则分并明确标注,不能宣称完成视觉评分;
+- 正常读取使用 `sourceHash + rubricVersion + model + promptVersion` 稳定键;同键命中既有结果时不再次调用模型;模型、Prompt 或权重变化必须发布新版本,不覆盖旧结果;
+- AI 评分与 AI 优化是两条独立链路。本次 AI 评分不生成标题、卖点或详情候选,不创建优化版本;
+- 本次修正和真实验收最多选择 **10 个商品**。未选择的其余商品继续展示 `listing-jd-v2` 规则分,并在 UI 标注“规则评分,尚未执行 AI 评分”;
+- 当前发布门禁将单个 AI 评分任务限制为 10 个商品,禁止用筛选范围对 824 个商品直接发起模型调用。
+
+### 0.1 `listing-jd-ai-v1` 固定权重
+
+| 维度 | 确定性结构分 | AI 语义分 | 合计 |
+|---|---:|---:|---:|
+| 标题 | 8 | 搜索意图 4、信息层级 3、差异化 3、事实与合规 2 | 20 |
+| 核心卖点 | 4 | 客户收益 4、具体可信 4、差异化 3、购买顾虑 3、一致性 2 | 20 |
+| 主图 | 20(仅数量、主图标记、URL、重复和顺序) | 0;视觉语义等待多模态模型 | 20 |
+| 详情 | 6 | 完整度 4、结构可读性 3、卖点证据 3、顾虑处理 2、一致性 2 | 20 |
+| 规格 | 10 | 决策有效性 3、命名清晰 2、类目完整性 3、参数一致性 2 | 20 |
+
+等级映射固定为:`fail=0%`、`weak=35%`、`pass=70%`、`strong=100%`;`unknown` 不伪装为通过,维度标记 partial。最终分数保留 0.5 分精度。相同输入绕过缓存重复评分时,整体漂移超过 2 分即不允许扩大样本。
+
+## 1. 目标、范围与明确不做的事
+
+### 1.1 MVP 目标
+
+针对京东授权店铺当前 API 返回的全部商品,完成一个可恢复、可审计、可解释的 Listing 评分批任务,并提供:
+
+- 商品评分总览、筛选、分页和批量发起任务;
+- 单商品标题、核心卖点、主图、详情、规格五个维度的原文、证据、得分和优化建议;
+- AI 建议版本的生成、对比、采纳和审计;
+- 批任务进度、成功/部分/阻塞/失败数量、单项失败原因和重试;
+- 不在浏览器持有 JD/Parse 密钥,不把缺失数据伪装成 0 分。
+
+页面上显示的商品总数必须来自同步摘要/API,**不得写死 614、821 或本次实测的 824**。
+
+### 1.2 MVP 不做
+
+- 不调用京东写接口,不自动发布标题、详情或图片;
+- 不把 AI 建议直接覆盖源 Listing,只生成版本草稿;
+- 不把卖家 Listing 文本当作 VOC 用户证据;
+- 不声称做了图片审美/视觉语义评分。当前 AI 网关只有文本消息能力,MVP 主图评分仅使用数量、顺序、主图标记、URL 可访问性等确定性规则;
+- 不接天猫、抖音、拼多多。样例图的平台 tab 仅京东可用,其余显示“即将支持”且不可点击发起任务;
+- 不重写现有 VOC、行动、验证链路;
+- 不让 Angular 直连目标 Parse 或 JD SP-API。
+
+## 2. 已验证的输入事实
+
+1. JD `GET /sp-product/v0/products` 返回动态总数,本次为 824。
+2. `GET /sp-product/v0/products/{productId}?scene=pop` 已成功;`productId` 必须参与 MD5 签名。
+3. 目标 Parse class 资源为 `{JD_TARGET_PARSE_URL}/classes/<ClassName>`;挂载前缀根 GET 的 404 不能作为不可用证据。
+4. 目标 Parse 偶发 HTTP 403 / Parse code 119,有限重试可恢复;`count=1` 在当前托管环境不可靠。
+5. 导入器当前仍只读取 `page=1`,且 `--limit` 上限为 20,只能做联调,不能承担全量生产同步。
+6. JD 商品详情已观察到标题、品牌、类目、描述、主图元数据、属性、SKU、尺寸重量、价格与履约字段。
+7. 现有 Angular 的 `DomesticDatasetService` 加载的是 VOC 快照,不是本店实时 Listing 目录;不能用它替代 Listing 源数据。
+8. 现有 `listing-api.service.ts` 是停用的 Amazon 旧边界,不复用。
+9. 现有 AI 调用发生在浏览器,且网关 schema 只接受字符串消息。824 商品批处理必须移到服务端 worker。
+
+复测原始结论见 `JD-PRODUCT-DETAIL-PARSE-VERIFICATION-2026-08-21.md`。
+
+## 3. 总体调用链
+
+```text
+源 Parse EcomAuth(服务端读取最新 JD token)
+  -> JdTokenProvider
+  -> JdSpClient(path/query/header 统一签名、超时、错误分类、限流)
+  -> JdProductClient(列表分页 / 商品详情 / SKU / 库存)
+  -> ListingCatalogSyncWorker(分页、详情并发、断点、幂等、raw hash)
+  -> 目标 Parse ProductDetail 等源快照(现有兼容写入)
+  -> ListingSourceRepository(归一化为内部 DTO)
+  -> ListingScoreJobWorker
+       -> DeterministicRuleEngine(结构基线、覆盖率、硬规则证据)
+       -> ListingAiRubricJudge(固定原子标准、等级与证据)
+       -> AI Gateway(语义裁判;temperature=0;严格 JSON)
+       -> ServerScoreComposer(固定权重映射与总分计算)
+       -> ListingScoreRepository / ListingVersionRepository
+  -> /api/listing-ai/*(RBAC + Zod + cursor pagination)
+  -> Angular ListingAiApiService
+  -> Workbench / Product / Versions / Tasks 页面
+```
+
+关键决策:**AI 对固定语义标准作结构化判定,服务端确定性计算数值分。** AI 失败时不覆盖既有 `listing-jd-v2` 规则分;AI 成功后以独立 `listing-jd-ai-v1` 结果展示,且必须显示模型、Prompt 版本、评分类型和证据。
+
+## 4. 信息架构与页面流程
+
+### 4.1 路由
+
+| 路由 | 页面类型 | 职责 |
+|---|---|---|
+| `/listing-ai/workbench` | Hub | 商品总览、评分分布、筛选、批量评分入口 |
+| `/listing-ai/workbench/:productId` | Entity | 单商品预览、五维评分、证据、建议和采纳 |
+| `/listing-ai/versions` | Workspace | 优化版本列表、状态、创建人、源快照和 diff |
+| `/listing-ai/tasks` | Workspace | 批任务列表、进度、错误数、重试入口 |
+| `/listing-ai/tasks/:jobId` | Entity | 任务明细、各商品状态、错误与重试 |
+
+在 `app.routes.ts` 中新增一个受现有 `protectedRoutes` 保护的 `listing-ai` 父路由,不修改现有路由含义,不删除旧 route。
+
+### 4.2 工作台布局
+
+1. 顶部 `page-header`:标题、数据更新时间、京东状态、保存筛选、发起评分任务。
+2. 平台切换:京东 active;其他平台 disabled + “即将支持”,不制造空数据。
+3. 指标卡:商品总数、可评分、已评分、部分/阻塞、失败、平均分。每个指标带口径 tooltip。
+4. `page-toolbar`:搜索、类目、上下架、评分状态、分数区间、数据完整度、更新时间、排序。
+5. `data-table-shell`:商品、标题、类目、SKU 数、数据完整度、总分、五维分、rubric 版本、最后评分时间、状态、操作。
+6. 批量操作只传筛选快照或选中 ID,不把 824 条完整记录塞进 request body。
+7. 分页使用 cursor,默认 25,最大 100;浏览器不预载全量数据。
+
+### 4.3 单商品页
+
+- 左栏:移动端只读预览,HTML 详情必须先清洗;图片失败显示占位和原因。
+- 右栏:`board-tabs` 五个维度:标题、核心卖点、主图、详情、规格。
+- 每个 tab 使用统一结构:当前内容、覆盖度、规则证据、扣分项、AI 建议、来源字段路径、源快照时间。
+- “采用此版本”只写入 `ListingVersion.status=adopted` 和审计事件,不调用京东发布。
+- 缺失字段显示 `—`,同时显示 `not_returned`、`empty` 或 `parse_failed`,不能显示为 0。
+
+### 4.4 任务页
+
+- 状态机:`queued -> running -> completed | partial | failed | cancelled`。
+- 单商品状态:`queued -> rules_scored -> ai_pending -> scored | partial | blocked | failed`。
+- 展示总量必须来自创建任务时冻结的 scope 解析结果;进度为 terminal item 数 / frozen total。
+- MVP 使用 2–5 秒 polling;先不引入 SSE/WebSocket。页面刷新后凭 `jobId` 恢复。
+
+## 5. JD 字段接收与内部 DTO
+
+后端先保留受控 raw JSON 与 hash,再生成稳定 DTO。前端只接收 DTO,不感知 JD 原始层级。
+
+| 内部字段 | JD 实际路径 | 类型/处理 | 缺失语义 |
+|---|---|---|---|
+| `productId` | `productInfo.productId`;fallback 列表 `productId` | String,禁止 JS number 身份比较 | `blocked` |
+| `shopId` | 授权记录/同步上下文 | String | `blocked` |
+| `title` | `productInfo.productTitle.title` -> `productInfo.productName` | trim String | `empty` |
+| `titleBrandName` | `productInfo.productTitle.titleBrandName` | String/null | `not_returned` |
+| `brand.id/name` | `productInfo.brandInfo.*` | ID 转 String | `not_returned` |
+| `categoryIds` | `categoryDetail.thirdCategoryId`, `lastCategoryId` | String[],去重 | `not_returned` |
+| `price.jd/cost` | `productInfo.priceInfo.*` | number/null,不用 0 代替 null | `not_returned` |
+| `descriptions.desktopHtml` | `productDetailDesc.desc` | 原始 HTML 仅服务端留存;输出 sanitized HTML | `empty` |
+| `descriptions.mobileHtml` | `productDetailDesc.mobileDesc` | 同上 | `empty` |
+| `features[]` | `productInfo.features[].key/value` | 丢弃全空项,保留顺序 | `empty` |
+| `attributes[]` | `productInfo.goodsAttrInfos[]` | attrId/name/values | `empty` |
+| `images[]` | `material.mainImages[].imageInfoList[]` | 展平、按 orderSort 排序、去 URL 重复 | `empty` |
+| `images[].isPrimary` | `primaryFlag` | boolean/null | `not_returned` |
+| `images[].gptFlag` | `gptFlag` | boolean/null;不推导审美分 | `not_returned` |
+| `skus[]` | `skuList[]` | skuId String、名称、价格、库存、属性、状态 | `empty` |
+| `dimensions` | `length/width/height/weight` | number/null + 明确单位配置 | `not_returned` |
+| `logistics` | `productInfo.logisticsInfo` | 白名单字段 | `not_returned` |
+| `afterService` | `productInfo.afterServiceInfo`, `to7ReturnFlag` | 白名单归一化 | `not_returned` |
+| `sourceModifiedAt` | `productInfo.modifiedTime` | epoch 校验后 ISO | `not_returned` |
+| `sourceHash` | 归一化前关键内容 canonical JSON | SHA-256 | 必填 |
+
+### API DTO 草案
+
+```ts
+interface ListingProductSummaryDto {
+  productId: string;
+  shopId: string;
+  platform: 'jd';
+  title: string | null;
+  imageUrl: string | null;
+  categoryIds: string[];
+  itemStatus: string | null;
+  skuCount: number | null;
+  sourceUpdatedAt: string | null;
+  syncedAt: string;
+  sourceHash: string;
+  coverage: { percent: number; missing: string[]; status: 'eligible' | 'partial' | 'blocked' };
+  latestScore: ListingScoreSummaryDto | null;
+}
+```
+
+DTO 中 ID 一律 String;金额仍用 number 是展示折中,后续涉及财务计算再升级 decimal 字符串。
+
+## 6. 评分模型
+
+### 6.1 结果结构
+
+```ts
+type ListingDimension = 'title' | 'selling_points' | 'images' | 'description' | 'specifications';
+
+interface ListingDimensionScoreDto {
+  dimension: ListingDimension;
+  score: number | null;
+  maxScore: number;
+  coverage: number;
+  status: 'scored' | 'partial' | 'blocked';
+  evidence: Array<{
+    ruleId: string;
+    fieldPath: string;
+    outcome: 'pass' | 'fail' | 'unknown';
+    delta: number;
+    message: string;
+  }>;
+  suggestions: string[];
+}
+```
+
+- 默认五维各 20 分,总分 100;权重只存在 rubric 配置,不散落在组件。
+- `overallScore` 只在所有必需维度满足 rubric 的最低覆盖门槛时计算。
+- `coverage` 与 `quality score` 是两条独立指标。没有详情不能得“详情 0 分”,而应是 `score=null,status=blocked`。
+- rubric 从 `listing-jd-v1` 开始;发现 v1 将 JD 传输控制标记误判为重复卖点后,未原地修改,而是发布 `listing-jd-v2` 并完成全量重算,v1 继续保留审计。
+
+### 6.2 第一版规则边界
+
+| 维度 | 可确定性评分 | 仅建议/待确认 |
+|---|---|---|
+| 标题 | 是否存在、长度、品牌/品类/型号覆盖、重复片段、异常符号、SKU 冲突 | 类目禁限词词库需业务确认 |
+| 核心卖点 | features 数量、非空、重复、规格覆盖、服务信息覆盖 | 京东是否有独立卖点字段需更多样本确认 |
+| 主图 | 图片数、主图标识、顺序、URL 结构/可访问、重复 URL | 构图、清晰度、文字占比需多模态模型,MVP 不评分 |
+| 详情 | desktop/mobile 是否存在、清洗后文本长度、结构、两端一致性、危险标签 | 内容说服力由 AI 建议,不直接决定数值分 |
+| 规格 | SPU/SKU 属性数量、必填规格覆盖、尺寸重量、属性一致性 | 类目必填属性清单需要规则数据源 |
+
+在 20 商品 shadow benchmark 完成前,不把暂定阈值写死为产品真理。阈值由 rubric JSON 管理,并在样本评审后冻结 v1。
+
+## 7. 后端设计
+
+### 7.1 模块边界
+
+新增 `src/modules/listing-ai/`,不把评分任务塞进现有 `analysis_run`。现有 `analysis_run` 的类型和生命周期服务于 VOC 洞察,强行复用会污染语义和权限。
+
+实际落地结构(小文件按职责合并,避免无意义拆分):
+
+```text
+src/modules/listing-ai/
+  domain.ts
+  schemas.ts
+  routes.ts
+  listing-ai.service.ts
+  clients/
+    jd-token.provider.ts
+    jd-sp.client.ts
+    jd-product.client.ts
+  normalization/
+    domestic-dataset.adapter.ts
+    jd-listing.normalizer.ts
+    html-sanitizer.ts
+  repositories/
+    in-memory-listing-ai.repository.ts
+    parse-rest-listing-ai.repository.ts
+    postgres-listing-ai.repository.ts
+  scoring/
+    rule-engine.ts
+```
+
+`canonicalHash` 与五维 v1 rubric 同置于 `rule-engine.ts`,批任务领取/恢复置于 `ListingAiService`,全量目录 worker 作为显式 `scripts/sync-jd-listings.ts` 运行。这样保留清晰边界,同时不为单一实现增加空包装层。
+
+若 `postgres` 和 `parse_rest` 两种 storage driver 都必须继续支持,则 repository 定义接口,并各有实现;local demo 使用显式内存实现,不在 route 里写条件分支。
+
+### 7.2 全量商品同步必须先工程化
+
+现有 `tools/jd-import-products.mjs` 仅保留为联调工具。生产同步必须满足:
+
+1. 从 page 1 循环到 API 返回的实际末页,不依据历史总数;
+2. 记录每页请求、返回条数、productId 去重数和 pagination 元数据;
+3. 详情请求使用配置化并发(初始 3),遇限流/5xx 指数退避并尊重 retry-after;
+4. access token 每批获取最新值,失效时只允许一次受控刷新/重取;
+5. 使用 `(shopId, productId)` 作为身份,现有兼容业务键 `jd:${shopId}:${productId}` 保留;
+6. 每条源记录写 `sourceHash`、`sourceModifiedAt`、`syncedAt`、`detailStatus` 和错误分类;
+7. checkpoint 保存 page、最后成功 productId、统计值;worker 重启可恢复;
+8. upsert 幂等,相同 sourceHash 不重复触发评分;
+9. `ProductDetail`、`Product`、`AsinSkuMapping`、`JdStockSnapshot` 兼容写入先保留;迁移完成前不删类、不改业务键;
+10. 完成度以本次冻结的 productId 清单和逐条终态计算,不使用当前异常的 Parse count。
+
+### 7.3 持久化实体
+
+Postgres 下一迁移文件建议为 `migrations/007_listing_ai.sql`:
+
+| 实体/表 | 主键与唯一约束 | 关键字段 |
+|---|---|---|
+| `voc.listing_source_snapshot` | unique `(workspace_id, platform, shop_id, product_id, source_hash)` | normalized_json, raw_ref/raw_json, detail_status, source_modified_at, observed_at |
+| `voc.listing_score_job` | unique `(workspace_id, idempotency_key)` | scope_json, rubric_version, source_cutoff, totals, status, checkpoint, timestamps |
+| `voc.listing_score_item` | unique `(job_id, product_id, source_hash)` | status, attempts, score_result_id, error_code, error_detail_redacted |
+| `voc.listing_score_result` | unique `(workspace_id, product_id, source_hash, rubric_version)` | overall_score nullable, coverage, dimension_json, model_info, prompt_version |
+| `voc.listing_version` | unique `(workspace_id, product_id, version_no)` | base_source_hash, content_json, status, created_by, adopted_at |
+
+所有写操作追加现有 audit log:`listing.score.requested`、`listing.score.retried`、`listing.version.created`、`listing.version.adopted`。
+
+Parse REST driver 对应类名使用 `VocListingSourceSnapshot`、`VocListingScoreJob`、`VocListingScoreItem`、`VocListingScoreResult`、`VocListingVersion`,字段语义与 Postgres 对齐。已有外部目标类 `ProductDetail` 等只作为 JD 源镜像,不直接承担 SaaS 工作区/RBAC 结果存储。
+
+### 7.4 AI 执行
+
+- worker 调用 `FmodeAiClient`,前端不再逐商品请求 `/api/ai/chat/completions`;
+- prompt 输入只包含白名单后的 Listing DTO、规则失败项和 rubric 版本;
+- 返回必须通过 Zod schema;失败记录 `ai_invalid_output` 并可重试;
+- AI 返回只产生建议和候选版本,不修改规则 evidence 或 numeric score;
+- 结果记录 model、promptVersion,job 记录 requestHash;当前上游未稳定返回 usage,正式 AI 放量前在可用时补充耗时/token usage 指标,任何情况下都不记录 token/密钥;
+- `LISTING_AI_CONCURRENCY` 与 `LISTING_AI_MAX_ITEMS_PER_JOB` 配置并发和单任务硬预算;超限在入队前返回 429,未配置模型时规则结果保留且任务为 partial,不无限重试。跨任务每日总预算仍是全量 AI 发布门禁。
+
+## 8. 后端 API 契约
+
+统一前缀:`/api/listing-ai`。沿用现有 AuthenticationMiddleware、WorkspaceAccessService、Zod、ApiError、audit,不创造第二套鉴权。
+
+### 8.1 商品目录
+
+```http
+GET /api/listing-ai/products?workspaceId=&platform=jd&limit=25&cursor=&search=&categoryId=&itemStatus=&scoreStatus=&minScore=&maxScore=&coverageStatus=&sort=
+```
+
+```json
+{
+  "items": [],
+  "nextCursor": null,
+  "summary": {
+    "sourceTotal": 824,
+    "eligible": 0,
+    "scored": 0,
+    "partial": 0,
+    "blocked": 0,
+    "failed": 0,
+    "lastCatalogSyncAt": "2026-08-21T00:00:00.000Z"
+  }
+}
+```
+
+`sourceTotal` 来自最近一次成功同步摘要,不来自不可靠的 Parse count。
+
+```http
+GET /api/listing-ai/products/:productId?workspaceId=&platform=jd
+```
+
+返回 `source`、`coverage`、`latestScore`、`versionsSummary`。默认不返回完整 raw JD JSON。
+
+### 8.2 评分任务
+
+```http
+POST /api/listing-ai/score-jobs
+Idempotency-Key: <client generated UUID>
+Content-Type: application/json
+
+{
+  "workspaceId": "demashi",
+  "platform": "jd",
+  "scope": {
+    "mode": "filter",
+    "filter": { "itemStatus": "on_shelf", "scoreStatus": "unscored" }
+  },
+  "rubricVersion": "listing-jd-v1",
+  "includeAiSuggestions": true
+}
+```
+
+也支持 `scope.mode=selected`,但 `productIds` 上限 100;全量必须用 filter scope。创建时服务端冻结 productId + sourceHash 清单,返回 202。
+
+```http
+GET  /api/listing-ai/score-jobs?workspaceId=&status=&limit=&cursor=
+GET  /api/listing-ai/score-jobs/:jobId?workspaceId=
+GET  /api/listing-ai/score-jobs/:jobId/items?workspaceId=&status=&limit=&cursor=
+POST /api/listing-ai/score-jobs/:jobId/retry
+POST /api/listing-ai/score-jobs/:jobId/cancel
+```
+
+### 8.3 评分与版本
+
+```http
+GET  /api/listing-ai/products/:productId/scores/latest?workspaceId=&rubricVersion=
+GET  /api/listing-ai/products/:productId/versions?workspaceId=&limit=&cursor=
+POST /api/listing-ai/products/:productId/versions
+GET  /api/listing-ai/versions/:versionId?workspaceId=
+POST /api/listing-ai/versions/:versionId/adopt
+```
+
+创建版本 body 必须包含 `baseSourceHash` 和 `baseScoreResultId`,若源商品已变化则返回 409 `listing_source_changed`,防止采纳过期建议。
+
+### 8.4 错误码与 HTTP
+
+| HTTP | error | 场景 |
+|---:|---|---|
+| 400 | `invalid_request` | Zod 校验失败 |
+| 401 | `unauthenticated` | 未登录/会话失效 |
+| 403 | `workspace_forbidden` | 无工作区权限 |
+| 404 | `listing_product_not_found` / `score_job_not_found` | 资源不存在 |
+| 409 | `listing_source_changed` / `score_job_not_retryable` / `idempotency_conflict` | 状态或幂等冲突 |
+| 422 | `listing_source_incomplete` | 明确请求单品评分但必要源数据缺失 |
+| 429 | `listing_ai_budget_exceeded` | AI 并发/预算限制 |
+| 502 | `jd_upstream_error` / `ai_upstream_error` | 上游明确失败 |
+| 503 | `listing_storage_unavailable` | 目标存储不可用 |
+
+列表中的单商品失败不得让整批 GET 500;它应成为 item 的终态和脱敏错误码。
+
+## 9. 前端设计与文件变更
+
+### 9.1 新增文件
+
+```text
+src/modules/listing-ai/
+  listing-ai.routes.ts
+  listing-ai.shared.scss
+  models/listing-ai.models.ts
+  services/listing-ai-api.service.ts
+  services/listing-ai-api.service.spec.ts
+  services/listing-ai-workbench.store.ts
+  pages/workbench/listing-ai-workbench.component.{ts,html}
+  pages/product/listing-ai-product.component.{ts,html}
+  pages/versions/listing-version-list.component.{ts,html}
+  pages/tasks/listing-score-job-list.component.{ts,html}
+  pages/task-detail/listing-score-job-detail.component.{ts,html}
+  components/listing-mobile-preview/*
+  components/listing-score-summary/*
+  components/listing-dimension-panel/*
+```
+
+source evidence 已合并到统一维度面板,version diff 已合并到版本页;页面共享样式集中在 `listing-ai.shared.scss`。API service 单测覆盖参数与写请求,页面/API 流程由后端 route 集成测试、全量 Angular suite 和 production build 共同验收。
+
+新组件使用 standalone + OnPush。状态用 Angular signals/RxJS 组合的小型 feature store;MVP 不为了单模块引入 NgRx。
+
+### 9.2 修改文件
+
+| 文件 | 修改内容 |
+|---|---|
+| `src/app/app.routes.ts` | lazy load `listing-ai` 子路由,沿用 protectedRoutes |
+| `src/app/core/config/runtime-config.ts` | 增加 `listingAiPath`,默认 `/api/listing-ai` |
+| `src/modules/shared/components/navigation/navigation.component.ts` | 在“行动”或独立组中增加 Listing AI 工作台、版本、任务入口 |
+| `src/app/core/services/index.ts` | 若该项目继续使用 barrel,则导出新 API service;否则不改 |
+| `proxy.conf.json` | 只有现有 `/api` 代理不能覆盖时才改;优先不改 |
+
+### 9.3 组件复用
+
+必须优先复用 `COMPONENT-INDEX.md` 中已有的:`page-header`、`page-toolbar`、`summary-metric-card`、`data-table-shell`、`content-card`、`board-tabs`、`entity-property-list`、loading/empty/error、`neu-button`、`split-panel`、`product-identity`。
+
+不引入 Angular Material、ng-zorro、Tailwind 或第二套图标库。若现有组件缺能力,先用向后兼容的 Input/slot 扩展,并给现有用例回归测试。
+
+## 10. 后端文件变更
+
+### 10.1 新增
+
+- 上文 `src/modules/listing-ai/**`;
+- `migrations/007_listing_ai.sql`;
+- `test/listing-ai.routes.test.ts`;
+- `test/listing-ai.rule-engine.test.ts`;
+- `test/jd-sp-client.signature.test.ts`;
+- `scripts/sync-jd-listings.ts`(全量/显式同步,支持 checkpoint/resume,不在启动时偷偷同步);
+- `scripts/score-listings.ts`、`scripts/verify-listing-rollout.ts`。
+
+worker 的冻结 scope、执行、重试、恢复和 stale 行为由 `listing-ai.routes.test.ts` 以真实 service/repository 流程覆盖,没有创建只重复同一流程的空壳 `listing-ai.worker.test.ts`。
+
+### 10.2 修改
+
+| 文件 | 修改内容 |
+|---|---|
+| `src/config/env.ts` | 增加 JD/目标 Parse/worker 并发/预算配置;按 storage/worker 条件校验 |
+| `src/app.ts` | 注入 listing repositories/service,挂载 `/api/listing-ai` |
+| `src/local-app.ts` | 注入 local demo repository,保证本地启动不因无 JD 凭据崩溃 |
+| `src/server.ts`、`src/local-server.ts` | 注入正确 storage repository;评分任务由 service 启动恢复,进程不执行隐式 JD 全量同步 |
+| `src/db/migrations.ts` | 只有 readiness 表清单为硬编码时才加入新表检查 |
+| `.env.example` | 只增加变量名和占位说明,不填真实值 |
+| `package.json` | 增加 `sync:jd-listings` / `score:jd-listings` / `verify:listing-rollout` 显式脚本 |
+
+### 10.3 删除
+
+第一阶段**不删除任何现有文件、route、表或 Parse class**。`tools/jd-import-products.mjs` 在服务端同步达到字段、幂等和故障恢复对等后才能标记 deprecated;再经过一个发布周期才讨论删除。
+
+## 11. 承重墙:禁止直接触碰
+
+1. **认证与权限**:不绕过 `/api` 认证中间件、WorkspaceAccessService 和权限名;生产不能启用 disabled auth。
+2. **身份键**:不把 JD productId 当 number,不改变 `jd:${shopId}:${productId}` 兼容业务键,不用 legacy asin 取代它。
+3. **数据语义**:不把 Listing 文本当用户 VOC;不把 missing/null 变成 0;不把库存推导为销量。
+4. **密钥边界**:AppSecret、MasterKey、access/refresh token 不进 Angular、assets、URL、localStorage、日志或 API response。
+5. **现有生命周期**:不扩大/修改现有 `analysis_run` enum 来承载 Listing 批任务;不破坏 insight -> decision -> action -> validation 链路。
+6. **UI 体系**:不引入新框架,不改 AI 视觉报告全局样式块,不用页面私有 CSS 复制已有组件。
+7. **源数据**:保留 raw/normalized 分层和 sourceHash;优化版本不覆盖源快照。
+8. **外部写操作**:MVP 禁止 JD 发布、上下架、改价、改库存。
+9. **路由兼容**:只增加 child routes,不改已有 URL 的含义或重定向。
+10. **上游判活**:不以 Parse base root 404 或异常 count 作为失败真值;必须探测 class 资源。
+
+## 12. 实施顺序与 PR 边界
+
+### PR-0:已完成的连接修复
+
+- `.gitignore`;
+- getProduct path 签名;
+- `itemStatus` schema 归一化;
+- JD/Parse 诊断脚本与复测文档。
+
+### PR-1:后端源目录与全量同步
+
+- 服务端 token/sign/client;
+- 全分页、详情并发、checkpoint、幂等;
+- source snapshot repository/API;
+- 20 商品 shadow run,再执行全量目录同步;
+- 不含 AI、不含页面。
+
+### PR-2:版本化规则引擎
+
+- v1 rubric、五维规则、coverage、证据;
+- 20 个样本由产品/运营人工复核并冻结阈值;
+- 单商品评分 API;
+- 不含批量 AI。
+
+### PR-3:批任务与 AI 建议
+
+- job/item/result/version 表;
+- worker、幂等、恢复、预算、Zod 输出校验;
+- 先跑 20,再跑 100,满足门禁后才跑当前全量;
+- 记录成本和耗时基线后再定义 SLA。
+
+### PR-4:Angular 工作台
+
+- 先接真实 API 合约和错误态;
+- 工作台 -> 商品页 -> 任务页 -> 版本页;
+- 不用 mock 数量覆盖真实 summary;
+- 做 keyboard、窄屏、空态、partial 状态回归。
+
+### PR-5:全量验收与灰度
+
+- 新建全量 job,冻结当时实际商品清单;
+- 运维监控、失败重试、重启恢复、安全扫描;
+- 只开放给指定 workspace/role;
+- 通过 go/no-go 后再扩大访问。
+
+## 13. 测试与成功判定
+
+### 13.1 连接与同步
+
+- [x] 签名单测固定验证:path `productId` 在签名串字段中但不在 query 中;
+- [x] 同一真实商品 getProduct HTTP 200,旧变体稳定复现 `99904000013`;
+- [x] 遍历所有页,页面汇总等于本次同步返回的动态 total;
+- [x] productId 去重后无重复、无空 ID;
+- [x] 每个冻结 productId 有 `available|empty|failed` 明确详情状态;
+- [x] 相同源数据重复同步,业务记录数不增长且 sourceHash 不变;
+- [x] 进程中断后从 checkpoint 恢复,不从头制造重复;
+- [x] 精确 403/code119 有限重试,永久 403 不被掩盖;
+- [x] readiness 请求 class 资源,不请求 base root。
+
+### 13.2 规则与 AI
+
+- [x] 相同 sourceHash + rubricVersion 重跑得到完全相同 numeric score/evidence;
+- [x] 必需字段缺失时 score=null/blocked,而不是 0;
+- [x] 每个扣分项能定位 `ruleId` 和 JD/DTO fieldPath;
+- [x] AI 超时、429、5xx、坏 JSON 时规则分仍保存,item 为 partial;
+- [x] AI 建议不修改规则分,不覆盖源快照;
+- [x] sourceHash 变化后旧版本明确标为 stale;
+- [ ] 20 商品人工复核通过后才能冻结 rubric v1;
+- [x] 20 -> 100 -> 全量规则评分逐级放量,每级统计错误、重试与耗时;AI 全量成本仍受预算门禁。
+
+### 13.3 API
+
+- [x] 所有 endpoint 有 Zod 输入校验和 workspace RBAC;
+- [x] 同一 Idempotency-Key + 同一 body 返回同一 job;同 key 不同 body 返回 409;
+- [x] cursor 不重复、不漏项,limit 最大 100;
+- [x] cancel/retry 只允许合法状态;
+- [x] 任务重启后总量、terminal 数与 item 明细一致;
+- [x] response 不含 raw token、MasterKey、AppSecret、完整 raw JD payload;
+- [x] audit log 包含发起、重试、取消、版本创建和采纳。
+
+### 13.4 前端
+
+- [x] UI 商品总数完全来自 API,代码与模板中不存在 614/821/824;
+- [x] 首屏只拉 summary + 当前页,不把全量商品载入浏览器;
+- [x] filter/sort/page 状态进入 URL,刷新可恢复;
+- [ ] loading、empty、error、partial、blocked、stale 均有不同展示;
+- [x] polling 在终态/离开页面时停止,无重复订阅;
+- [x] 单商品页五个 tab 的证据、建议、版本引用同一 sourceHash;
+- [x] “采用”只改变内部版本状态,浏览器网络中没有 JD 写请求;
+- [x] 京东以外平台不可发起任务;
+- [x] `ng build`、Listing API service 单测和后端关键路由集成测试通过。
+
+### 13.5 安全与发布门禁
+
+- [x] `git check-ignore docs/JD-CONNECTION-CONFIG.local.md` 命中;
+- [ ] Git 历史/工作树、`dist`、source map、浏览器请求和日志扫描无真实凭据;
+- [x] HTML 详情经过 allowlist sanitizer,无 script/event handler/javascript URL;
+- [x] 图片只通过安全 URL 策略展示,失败不执行任意协议;
+- [ ] 生产 `API_AUTH_MODE=parse`,跨 workspace 访问用例返回 403;
+- [x] worker concurrency、retry、timeout 均有上限;AI 通过显式开关且未获预算批准时不执行;
+- [x] 824(或当时动态总数)个冻结 item 最终全部进入 terminal 状态,无 orphan;
+- [x] `completed` 仅表示全部成功;混合结果必须是 `partial`;
+- [ ] 未完成性能基线前不承诺“几分钟完成”。在 20/100 商品实测后,将吞吐、P95 和成本阈值补入 rubric/release 文档。
+
+## 14. 风险、待确认项与决策责任
+
+| 风险/问题 | 当前处理 | 谁确认/何时阻塞 |
+|---|---|---|
+| Parse 间歇 403 | 精确错误有限重试 + 指标告警 | 连续失败超过重试预算时阻塞全量 |
+| Parse count 不可靠 | 使用同步摘要和冻结 ID 清单 | 后端实现时验证 |
+| JD 限流/分页边界未知 | 3 并发起步,20/100 shadow run | 全量前必须有实测 |
+| 核心卖点真实字段口径 | 先以 features/服务/规格归一化 | 产品与运营冻结 rubric 前确认 |
+| 类目禁限词/必填属性缺数据源 | v1 标 unknown,不擅自扣分 | 业务提供规则数据后进入 v2 |
+| 图片视觉质量 | MVP 不评分视觉语义 | 多模态网关接入后单独设计 |
+| AI 成本和速率 | 分级放量、预算门禁 | 全量任务前批准预算 |
+| HTML XSS/外链图片 | 服务端清洗 + URL 策略 | 安全测试不通过则阻塞上线 |
+| 商品同步期间变化 | job 冻结 productId+sourceHash | 变化商品标 stale,另建任务 |
+
+## 15. 回滚策略
+
+- 新路由可由 feature flag/导航权限隐藏;回滚前端不删除后端结果。
+- worker 可停止领取新任务;单实例重启会扫描并恢复持久化的 `queued/running` 任务。多实例 lease 是正式横向扩容前的门禁,见实施报告。
+- 新表/Parse class 为旁路新增,不修改旧 VOC 表;回滚应用版本无需 drop table。
+- rubric v1 不原地改;错误规则发布 v2 并重算,保留旧结果审计。
+- 外部 `ProductDetail` 兼容镜像在新 source repository 稳定一个发布周期前不移除。
+
+## 16. 开发开始前的 Go / No-Go 清单
+
+只有以下条件同时满足才开始 PR-1,而不是直接画页面:
+
+- [x] 本机凭据文件已忽略;
+- [x] getProduct 400 的真实错误码、错误消息和签名字段已记录;
+- [x] getProduct 修复后返回真实详情;
+- [x] 目标 Parse class 路由读写成功,root 404 语义澄清;
+- [x] `itemStatus` schema mismatch 已修复;
+- [x] 前后端边界、API DTO、表/类、路由和文件清单已写入本文;
+- [x] 承重墙、验收点、风险和回滚已写入本文;
+- [ ] 产品/运营确认 v1 五维权重、标题阈值、卖点口径;
+- [x] Postgres + parse_rest 两驱动均已实现;
+- [x] 目标 Parse 间歇 403 使用仅针对 119 的有限退避重试;
+- [ ] AI 负责人确认 20/100/全量预算与并发。
+
+前三个未决责任项可以与 PR-1 的纯同步工程并行,但在 PR-2 rubric 冻结和 PR-3 全量 AI 任务前必须关闭。

+ 182 - 0
docs/LISTING-AI-IMPLEMENTATION-REPORT-2026-08-21.md

@@ -0,0 +1,182 @@
+# Listing AI 评分工作台实施与验收报告
+
+> 日期:2026-08-21  
+> 范围:`Saas-voc` Angular 前端、`Saas-voc-server` 后端、目标 Parse 首轮全量数据  
+> 结论:**规则评分基线已完成;固定 Rubric 的真实 AI 评分已初步落地并仅对受控样本验证,未对全量商品调用模型。**
+
+## 0. 当前初步发布状态(优先于下文历史说明)
+
+- `listing-jd-v2` 继续作为 824 个商品的确定性规则基线;当前最新结果分布为 **823 个规则评分 + 1 个真实 AI 评分**。
+- `listing-jd-ai-v1` 不是自由打分:模型只返回固定原子标准的等级、证据、理由和置信度,服务端按冻结权重合成分数。
+- 真实成功样本 `10020401158454`:规则基线 100,AI 综合分 62,模型 `deepseek-v4-flash`,Prompt `listing-ai-score-p1`。
+- 相同 `sourceHash + rubricVersion + model + promptVersion` 再次评分复用同一结果,不重复调用模型;实测缓存任务约 3.5 秒完成。
+- 前后端均限制单个 AI 任务最多 10 个商品;11 个商品请求在创建任务前返回 HTTP 429 `listing_ai_budget_exceeded`。
+- AI 失败不会覆盖既有规则分;源数据不足时结果为 `partial`,不会把 `unknown` 伪装成 0 分或通过。
+- 当前已知 UX 限制:创建任务后会进入任务详情;`partial` 页面的“重试失败项”文案可能造成误解,后续应改为“部分完成/缺少输入证据”。
+
+## 1. 最终结果
+
+本轮 JD 列表接口返回的动态商品总数为 **824**。全量同步与冻结评分任务结果如下:
+
+| 指标 | 结果 |
+|---|---:|
+| 源快照(去重 productId) | 824 |
+| 同步成功 / 失败 | 824 / 0 |
+| 任务冻结总数 / terminal | 824 / 824 |
+| 有效数值评分 | 765 |
+| 部分结果 | 59 |
+| blocked / failed | 0 / 0 |
+| 平均分(仅统计有数值结果) | 97.0 |
+| missing / orphan / stale result | 0 / 0 / 0 |
+| 五维结构异常 | 0 |
+
+最新规则任务:`985b4dd5-f4c2-4d0c-9764-56652e496ed3`,rubric 为 `listing-jd-v2`,状态为 `partial`。824 个 item 全部完成,其中 59 个商品因上游缺少必需字段而保留部分结果;维度不可评分数为卖点 4、详情 22、规格 40,各维度有重叠。系统没有把缺失字段伪装成 0 分。
+
+`listing-jd-v1` 曾错误地把 JD `features` 中大量值为 0/1 的传输控制标记当成营销卖点,导致多数商品误中“合并重复卖点”扣分。按“rubric 不原地修改”的承重墙发布 v2:内部标记不再参与卖点评分;上游没有独立营销卖点字段时,重复性规则为 unknown 且不扣分。v1 结果仍保留用于审计。
+
+任务时间为 2026-08-21 05:35:22.517Z 至 05:41:21.010Z,约 5 分 58 秒。该数据只作为当前环境基线,不承诺固定 SLA。
+
+## 2. 已落地调用链
+
+```text
+源 Parse EcomAuth(服务端取最新 token)
+  -> JdTokenProvider
+  -> JdSpClient(path/query/header 签名、超时、重试)
+  -> JdProductClient(多页列表 + 商品详情)
+  -> sync-jd-listings(去重、并发、checkpoint、幂等)
+  -> ListingSourceSnapshot(Parse 或 PostgreSQL)
+  -> ListingAiService + listing-jd-v2 rule engine
+  -> Job / Item / Result / Version(可恢复、可审计)
+  -> /api/listing-ai/*(认证、workspace RBAC、Zod、cursor)
+  -> Angular ListingAiApiService / Store
+  -> 工作台 / 商品详情 / 任务 / 版本页面
+```
+
+规则基线由版本化确定性规则产生;AI 评分只对固定语义标准作结构化判定,数值权重和总分仍由服务端计算。AI 不覆盖 JD 源快照,也不调用 JD 写接口。
+
+## 3. 后端落地
+
+### 3.1 核心能力
+
+- JD `productId` 作为 path 签名字段参与 MD5,但不重复出现在 query;`itemStatus` 兼容对象/字符串返回。
+- 列表全分页、多轮防抖去重、详情并发 3、超时/有限重试、checkpoint 原子串行写入和 `--resume`。
+- JD 图片相对路径归一化到受控 HTTPS CDN;HTML 服务端 allowlist 清洗。
+- `listing-jd-v2` 五维评分:标题、核心卖点、主图、详情、规格;每维保存 coverage、ruleId、fieldPath、证据和建议。
+- `sourceHash + rubricVersion` 保证确定性和结果幂等;源变化后旧分不作为当前分返回,旧版本标记 `stale`。
+- 批任务冻结 productId + sourceHash,支持幂等键、进度、取消、失败/部分重试,以及进程启动时恢复 `queued/running` 任务。
+- AI 输出通过严格 Zod schema;上游失败时不写入 AI 分并保留既有规则分。`LISTING_AI_CONCURRENCY` 和 `LISTING_AI_MAX_ITEMS_PER_JOB` 对并发与单任务费用上限做 fail-closed 控制。
+- 工作台真实 AI 入口只接受已选商品,前端和后端共同限制每任务最多 10 个商品。API 使用 `scoringMode=ai|rules` 区分 `listing-jd-ai-v1` 和 `listing-jd-v2`;后端拒绝超限请求时不会创建任务。
+- 版本只在内部创建/采纳;采纳前再次校验 sourceHash,并写 audit log。
+- Parse REST 与 PostgreSQL 两种持久化驱动均已实现;新增 PostgreSQL migration `007_listing_ai.sql` 和 Parse schema。
+
+### 3.2 API
+
+| Method | Path | 用途 |
+|---|---|---|
+| GET | `/api/listing-ai/products` | 当前页商品 + 动态 summary/filter/sort/cursor |
+| GET | `/api/listing-ai/products/:productId` | 清洗后的源详情、当前分、版本摘要 |
+| GET | `/api/listing-ai/products/:productId/scores/latest` | 当前 sourceHash 的最新评分 |
+| POST | `/api/listing-ai/score-jobs` | 以选中 ID 或筛选快照创建冻结任务 |
+| GET | `/api/listing-ai/score-jobs` | 任务列表 |
+| GET | `/api/listing-ai/score-jobs/:jobId` | 任务进度 |
+| GET | `/api/listing-ai/score-jobs/:jobId/items` | terminal/item/error 明细 |
+| POST | `/api/listing-ai/score-jobs/:jobId/retry` | 合法状态下重试 |
+| POST | `/api/listing-ai/score-jobs/:jobId/cancel` | 合法状态下取消 |
+| GET | `/api/listing-ai/versions` | workspace 版本列表 |
+| GET/POST | `/api/listing-ai/products/:productId/versions` | 商品版本列表/创建草稿 |
+| GET | `/api/listing-ai/versions/:versionId` | 版本详情 |
+| POST | `/api/listing-ai/versions/:versionId/adopt` | 内部采纳,不发布 JD |
+
+所有 endpoint 位于现有 `/api` 认证中间件之后,并使用 `WorkspaceAccessService`。写操作要求 `analysis:run` 或 `action:write`,且发起、重试、取消、创建版本、采纳都会写审计。
+
+### 3.3 运维命令
+
+真实凭据只通过服务端环境变量加载,不进入命令参数、Angular、日志或文档。
+
+```powershell
+# 建表/建 Parse class 后,全量同步;中断后使用同一 checkpoint 恢复
+npm run sync:jd-listings -- --workspace <workspace-id> --checkpoint logs/jd-listing-sync.json --resume
+
+# 默认只执行确定性规则评分,不产生 AI 费用
+npm run score:jd-listings -- --workspace <workspace-id>
+
+# 校验 sources/results/job/items/orphan/stale/维度完整性
+npm run verify:listing-rollout -- --workspace <workspace-id>
+```
+
+## 4. Angular 落地
+
+新增 lazy route `/listing-ai`,包含:
+
+- `/listing-ai/workbench`:动态汇总、筛选/排序 URL 持久化、cursor 分页、选中/筛选批量评分;
+- `/listing-ai/workbench/:productId`:移动端预览、五维规则/AI证据、模型、Prompt、置信度和改进建议;
+- `/listing-ai/tasks`、`/listing-ai/tasks/:jobId`:任务列表、2–5 秒 polling、进度、item 错误、重试/取消;
+- `/listing-ai/versions`:版本、源 hash、状态、内容差异和内部采纳。
+
+京东 tab 可用,天猫/抖音/拼多多明确禁用并显示“即将支持”。前端代码没有写死 614、821 或 824,首屏仅请求 summary 和当前页。证据展示合并在 `listing-dimension-panel`,版本 diff 合并在版本页面,没有增加无业务逻辑的包装组件。
+
+## 5. 主要文件变动
+
+### 后端新增
+
+- `src/modules/listing-ai/domain.ts`
+- `src/modules/listing-ai/listing-ai.service.ts`
+- `src/modules/listing-ai/routes.ts`、`schemas.ts`
+- `src/modules/listing-ai/clients/*`
+- `src/modules/listing-ai/normalization/*`
+- `src/modules/listing-ai/scoring/rule-engine.ts`
+- `src/modules/listing-ai/repositories/*`
+- `migrations/007_listing_ai.sql`
+- `scripts/sync-jd-listings.ts`
+- `scripts/score-listings.ts`
+- `scripts/verify-listing-rollout.ts`
+- `test/listing-ai.*.test.ts`、`test/jd-sp-client.signature.test.ts`
+
+后端同时修改 `src/app.ts`、`src/server.ts`、`src/local-app.ts`、`src/local-server.ts`、`src/config/env.ts`、`src/db/parse-rest.schema.ts`、`.env.example` 和 `package.json`,只做依赖注入、路由、配置、readiness 和脚本接线。
+
+### 前端新增
+
+- `src/modules/listing-ai/models/*`
+- `src/modules/listing-ai/services/*`
+- `src/modules/listing-ai/components/*`
+- `src/modules/listing-ai/pages/*`
+- `src/modules/listing-ai/listing-ai.routes.ts`
+- `src/modules/listing-ai/listing-ai.shared.scss`
+
+前端同时修改 `src/app/app.routes.ts`、`src/app/core/config/runtime-config.ts` 和现有导航配置。未删除旧 route、旧页面、旧表或旧 Parse class。
+
+## 6. 验证证据
+
+| 验证 | 结果 |
+|---|---|
+| 后端 `npm run typecheck` | 通过 |
+| 后端 `npm test` | 80/80 通过 |
+| Angular production build | 通过,hash `a89262792de71d81` |
+| Angular Listing AI 定向测试 | 2/2 ChromeHeadless 通过 |
+| 本地 API smoke | 目录分页、任务创建/执行、partial 语义通过 |
+| 真实 JD shadow | 20、100、824 逐级执行;最终同步 824/824 |
+| 真实规则评分 | 824/824 terminal;765 scored、59 partial、0 blocked、0 failed |
+| rollout verifier | missing/orphan/stale/invalidDimensions 均为 0 |
+
+## 7. 承重墙验证
+
+- 没有让 Angular 直连 JD 或 Parse;真实密钥只存在于被 `.gitignore` 命中的本地配置/服务端环境变量。
+- 没有修改现有 VOC 的 `analysis_run` 状态机,也没有改写 insight -> decision -> action -> validation 链路。
+- productId 全程按字符串处理;不从库存推导销量,不把 Listing 当 VOC。
+- 源快照、评分、建议版本分层;采纳不覆盖源,不调用 JD 写接口。
+- Parse 健康检查使用 class 资源;挂载根 404 不再被误判,HTTP 403/code119 只做有限重试。
+
+## 8. 尚未解除的发布门禁
+
+1. **AI 成本与质量批准**:当前硬限制为每任务最多 10 个商品,并且只完成受控样本验证;在人工 golden set、一致性回归、总预算和日预算冻结前,不开放 824 商品全量 AI 评分。
+2. **rubric 业务冻结**:工程规则已升级为 `listing-jd-v2` 并保留 v1 审计记录;标题阈值、核心卖点口径仍需产品/运营对 20 个样本人工复核。后续口径变化继续发布新版本,不覆盖既有结果。
+3. **多实例强租约**:当前启动恢复、幂等键和数据库唯一约束满足单 worker/受控部署恢复;若部署多个并发 worker 实例,需在领取任务前增加数据库级 lease/`SKIP LOCKED`。在此之前,同 workspace 只启一个 Listing score worker。
+
+以上门禁不影响已经完成的 824 商品确定性评分和工作台联调,但会阻止“全量 AI 优化建议”和多实例 worker 直接开放生产。
+
+### 8.1 任务状态语义
+
+- 创建成功后前端当前会导航到任务详情,用于观察异步执行进度;这是现有交互,不是服务端重定向。
+- `failed` 表示模型、网络、schema 或存储失败;此时不覆盖规则分。
+- `partial` 表示模型调用可能已经成功,但至少一个固定维度因源数据不足被判为 `unknown`,因此不生成伪造总分。
+- 例如商品 `10020422869649` 的真实 AI 请求已完成,但京东输入没有独立卖点文本,卖点维度为 `unknown`,任务因此为 `partial` 而不是调用失败。

+ 6 - 0
proxy.conf.json

@@ -29,6 +29,12 @@
     "changeOrigin": true,
     "logLevel": "debug"
   },
+  "/api/listing-ai": {
+    "target": "http://127.0.0.1:4400",
+    "secure": false,
+    "changeOrigin": true,
+    "logLevel": "debug"
+  },
   "/parse": {
     "target": "http://127.0.0.1:4400",
     "secure": false,

+ 5 - 0
src/app/app.routes.ts

@@ -75,6 +75,11 @@ export const routes: Routes = [
       },
     ],
   },
+  {
+    path: 'listing-ai',
+    canActivate: protectedRoutes,
+    loadChildren: () => import('../modules/listing-ai/listing-ai.routes').then((m) => m.LISTING_AI_ROUTES),
+  },
   {
     path: 'feedback-inbox',
     canActivate: protectedRoutes,

+ 2 - 0
src/app/core/config/runtime-config.ts

@@ -9,6 +9,7 @@ export interface SaasVocRuntimeConfig {
   domesticVocPath: string;
   domesticWorkspaceId: string;
   domesticPlatform: 'jd';
+  listingAiPath: string;
 }
 
 declare global {
@@ -31,4 +32,5 @@ export const RUNTIME_CONFIG: Readonly<SaasVocRuntimeConfig> = Object.freeze({
   domesticVocPath: browserConfig.domesticVocPath || '/api/domestic-voc',
   domesticWorkspaceId: browserConfig.domesticWorkspaceId || 'demashi',
   domesticPlatform: 'jd',
+  listingAiPath: browserConfig.listingAiPath || '/api/listing-ai',
 });

+ 63 - 0
src/modules/listing-ai/components/listing-dimension-panel/listing-dimension-panel.component.ts

@@ -0,0 +1,63 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import type { ListingDimensionScore } from '../../models/listing-ai.models';
+
+@Component({
+  selector: 'app-listing-dimension-panel',
+  standalone: true,
+  imports: [CommonModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    @if(score){
+      <section class="panel">
+        <header><div><span>当前得分</span><strong>{{score.score ?? '—'}} / {{score.maxScore}}</strong></div><div><span>数据覆盖</span><strong>{{score.coverage}}%</strong></div><mark [class]="score.status">{{statusLabel(score.status)}}</mark></header>
+        <h3>评分证据</h3>
+        <div class="evidence">
+          @for(item of score.evidence;track item.ruleId){
+            <article [class]="item.outcome">
+              <b>{{item.outcome==='pass'?'通过':item.outcome==='fail'?'需优化':'未知'}}</b>
+              <div class="evidence-copy">
+                <strong>{{item.message}}</strong>
+                <span class="source-label">{{item.source==='ai'?'AI语义裁判':'确定性规则'}}@if(item.level){ · {{levelLabel(item.level)}}}@if(item.pointsAwarded!==undefined&&item.maxPoints!==undefined){ · {{item.pointsAwarded}} / {{item.maxPoints}} 分}</span>
+                <span class="source-label">数据来源:{{fieldLabel(item.fieldPath)}}</span>
+                @if(item.citations?.length){<div class="citations">证据:@for(citation of item.citations;track citation){<q>{{citation}}</q>}</div>}
+                <details><summary>查看技术标识</summary><code>{{item.fieldPath}} · {{item.ruleId}}</code></details>
+              </div>
+              <em>{{item.delta}}</em>
+            </article>
+          }
+        </div>
+        @if(score.suggestions.length){<h3>优化动作</h3><ul>@for(item of score.suggestions;track item){<li>{{item}}</li>}</ul>}
+      </section>
+    }@else{<p class="empty">该维度尚未评分。</p>}
+  `,
+  styles: [`
+    .panel header{display:flex;gap:24px;align-items:center;padding-bottom:16px;border-bottom:1px solid #e8ecf3}.panel header div span{display:block;color:#7b8494;font-size:12px}.panel header strong{font-size:22px}.panel mark{margin-left:auto;border-radius:20px;padding:5px 10px;background:#eef3ff;color:#2f64cd}.panel mark.blocked{background:#fff2f1;color:#c54038}.panel h3{font-size:14px;margin:18px 0 10px}.evidence{display:grid;gap:8px}.evidence article{display:grid;grid-template-columns:58px minmax(0,1fr) 52px;gap:10px;align-items:center;padding:10px;border:1px solid #e5e9f1;border-radius:8px}.evidence article>b{font-size:12px;color:#758095}.evidence article.fail{border-color:#ffd0cc;background:#fff8f7}.evidence article.pass{border-color:#cdebdc;background:#f8fffb}.evidence-copy>strong{display:block}.source-label{display:block;margin-top:4px;color:#758195;font-size:11px}.citations{margin-top:6px;color:#5f6b7d;font-size:11px}.citations q{display:block;margin-top:3px}.evidence details{margin-top:3px;color:#8993a3;font-size:11px}.evidence summary{cursor:pointer;user-select:none}.evidence code{display:block;margin-top:4px;overflow-wrap:anywhere;color:#7f8999;font-size:10px}.evidence em{text-align:right;color:#c54038}.panel ul{padding-left:20px}.panel li{margin:6px 0}.empty{padding:30px;text-align:center;color:#8791a2}
+  `],
+})
+export class ListingDimensionPanelComponent {
+  @Input() score: ListingDimensionScore|null = null;
+
+  statusLabel(status: ListingDimensionScore['status']): string {
+    return status === 'scored' ? '已评分' : status === 'partial' ? '部分数据' : '数据不足';
+  }
+
+  levelLabel(level:NonNullable<ListingDimensionScore['evidence'][number]['level']>):string{
+    return {unknown:'证据不足',fail:'不达标',weak:'较弱',pass:'达标',strong:'优秀'}[level];
+  }
+
+  fieldLabel(path: string): string {
+    if (path.startsWith('derivedSellingPoints')) return '从标题、型号和规格派生的卖点信息';
+    if (path.startsWith('ai.')) return 'AI读取的标准化 Listing 内容';
+    if (path.startsWith('brand')) return '品牌名称';
+    if (path.startsWith('title')) return '商品标题';
+    if (path.startsWith('features')) return '核心卖点与商品特征';
+    if (path.startsWith('afterService')) return '售后服务信息';
+    if (path.startsWith('images')) return '商品图片';
+    if (path.startsWith('descriptions')) return '商品图文详情';
+    if (path.startsWith('attributes')) return '商品规格属性';
+    if (path.startsWith('skus')) return 'SKU 信息';
+    if (path.startsWith('dimensions')) return '尺寸与重量';
+    return 'Listing 源数据';
+  }
+}

+ 26 - 0
src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.html

@@ -0,0 +1,26 @@
+<div class="preview-shell">
+  <header class="preview-controls">
+    <div><strong>移动端 Listing 预览</strong><span>可滚动、切图并切换详情</span></div>
+    <div class="mode-switch" aria-label="预览模式">
+      <button type="button" [class.active]="mode()==='product'" (click)="mode.set('product')">商品页</button>
+      <button type="button" [class.active]="mode()==='detail'" (click)="mode.set('detail')">详情页</button>
+    </div>
+  </header>
+  <section class="phone" aria-label="移动端 Listing 预览">
+    <div class="phone-bar">9:41 <strong>JD</strong> 5G</div>
+    <div class="phone-screen">
+      @if(mode()==='product'){
+        <div class="hero">@if(source.images[selectedImage()];as image){<img [src]="image.url" [alt]="source.title || '商品主图'">}@else{<span>暂无主图</span>}</div>
+        <div class="price">@if(source.price.jd!==null){¥{{source.price.jd | number:'1.0-2'}}}@else{—}</div>
+        <h2>{{source.title || '—'}}</h2>
+        <div class="badges"><span>AI 评分</span><span>{{source.brand.name || '品牌未返回'}}</span><span>{{source.skus.length}} SKU</span></div>
+        <div class="gallery">@for(image of source.images.slice(0,6);track image.url;let index=$index){<button type="button" [class.active]="selectedImage()===index" (click)="selectImage(index)"><img [src]="image.url" [alt]="'商品图 '+(index+1)"></button>}</div>
+        <button type="button" class="detail-entry" (click)="mode.set('detail')">查看图文详情 <span>›</span></button>
+      }@else{
+        <div class="detail-title"><button type="button" (click)="mode.set('product')">‹</button><strong>商品详情</strong><span></span></div>
+        <div class="description" [innerHTML]="source.descriptions.mobileHtml || source.descriptions.desktopHtml || '暂无详情内容'"></div>
+      }
+    </div>
+    <div class="home-indicator"></div>
+  </section>
+</div>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.scss


+ 11 - 0
src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.ts

@@ -0,0 +1,11 @@
+import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import type { ListingSource } from '../../models/listing-ai.models';
+
+@Component({selector:'app-listing-mobile-preview',standalone:true,imports:[CommonModule],changeDetection:ChangeDetectionStrategy.OnPush,templateUrl:'./listing-mobile-preview.component.html',styleUrl:'./listing-mobile-preview.component.scss'})
+export class ListingMobilePreviewComponent {
+  @Input({required:true}) source!: ListingSource;
+  readonly mode = signal<'product'|'detail'>('product');
+  readonly selectedImage = signal(0);
+  selectImage(index: number): void { this.selectedImage.set(index); }
+}

+ 5 - 0
src/modules/listing-ai/components/listing-score-summary/listing-score-summary.component.ts

@@ -0,0 +1,5 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import type { ListingScoreResult } from '../../models/listing-ai.models';
+@Component({selector:'app-listing-score-summary',standalone:true,imports:[CommonModule],changeDetection:ChangeDetectionStrategy.OnPush,template:`<section class="score"><div class="overall"><strong>{{score?.overallScore ?? '—'}}</strong><span>/ 100</span></div><div class="dimensions">@for(item of score?.dimensions || [];track item.dimension){<div><span>{{labels[item.dimension]}}</span><b>{{item.score ?? '—'}} / {{item.maxScore}}</b><i><em [style.width.%]="item.score===null?0:item.score/item.maxScore*100"></em></i></div>}</div></section>`,styles:[`:host{display:block}.score{display:grid;grid-template-columns:140px 1fr;gap:24px;align-items:center}.overall{text-align:center}.overall strong{display:block;font-size:42px;color:#2563eb}.overall span{color:#8791a2}.dimensions{display:grid;gap:8px}.dimensions div{display:grid;grid-template-columns:90px 70px 1fr;gap:10px;align-items:center;font-size:13px}.dimensions b{text-align:right}.dimensions i{height:7px;background:#e9edf5;border-radius:8px;overflow:hidden}.dimensions em{display:block;height:100%;background:#3876f6}@media(max-width:700px){.score{grid-template-columns:1fr}.dimensions div{grid-template-columns:78px 60px 1fr}}`]})
+export class ListingScoreSummaryComponent{@Input()score:ListingScoreResult|null=null;readonly labels:Record<string,string>={title:'标题',selling_points:'核心卖点',images:'主图',description:'详情',specifications:'规格'};}

+ 9 - 0
src/modules/listing-ai/listing-ai.routes.ts

@@ -0,0 +1,9 @@
+import type { Routes } from '@angular/router';
+export const LISTING_AI_ROUTES:Routes=[
+  {path:'',pathMatch:'full',redirectTo:'workbench'},
+  {path:'workbench',title:'Listing AI 优化工作台',loadComponent:()=>import('./pages/workbench/listing-ai-workbench.component').then((m)=>m.ListingAiWorkbenchComponent)},
+  {path:'workbench/:productId',title:'Listing AI 商品详情',loadComponent:()=>import('./pages/product/listing-ai-product.component').then((m)=>m.ListingAiProductComponent)},
+  {path:'versions',title:'Listing 优化版本',loadComponent:()=>import('./pages/versions/listing-version-list.component').then((m)=>m.ListingVersionListComponent)},
+  {path:'tasks',title:'Listing 评分任务',loadComponent:()=>import('./pages/tasks/listing-score-job-list.component').then((m)=>m.ListingScoreJobListComponent)},
+  {path:'tasks/:jobId',title:'Listing 评分任务详情',loadComponent:()=>import('./pages/task-detail/listing-score-job-detail.component').then((m)=>m.ListingScoreJobDetailComponent)},
+];

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
src/modules/listing-ai/listing-ai.shared.scss


+ 19 - 0
src/modules/listing-ai/models/listing-ai.models.ts

@@ -0,0 +1,19 @@
+export type ListingCoverageStatus = 'eligible' | 'partial' | 'blocked';
+export type ListingJobStatus = 'queued' | 'running' | 'completed' | 'partial' | 'failed' | 'cancelled';
+export type ListingItemStatus = 'queued' | 'rules_scored' | 'ai_pending' | 'scored' | 'partial' | 'blocked' | 'failed';
+export type ListingDimension = 'title' | 'selling_points' | 'images' | 'description' | 'specifications';
+
+export interface ListingCoverage { percent: number; missing: string[]; status: ListingCoverageStatus; }
+export interface ListingEvidence { ruleId: string; fieldPath: string; outcome: 'pass' | 'fail' | 'unknown'; delta: number; message: string; source?:'rule'|'ai';level?:'unknown'|'fail'|'weak'|'pass'|'strong';pointsAwarded?:number;maxPoints?:number;confidence?:number;citations?:string[]; }
+export interface ListingDimensionScore { dimension: ListingDimension; score: number | null; maxScore: number; coverage: number; status: 'scored' | 'partial' | 'blocked'; evidence: ListingEvidence[]; suggestions: string[]; }
+export interface ListingScoreResult { id: string; productId: string; sourceHash: string; rubricVersion: string; overallScore: number | null; coverage: ListingCoverage; dimensions: ListingDimensionScore[]; aiStatus: 'not_requested'|'pending'|'completed'|'failed'|'budget_exceeded'; aiSuggestions: string[]; aiCandidate:{title:string|null;sellingPoints:string[];descriptionHtml:string|null;specifications:ListingAttribute[];imageUrls:string[]}|null; model: string|null; promptVersion:string|null;scoreKind?:'rules'|'hybrid_ai';baselineOverallScore?:number|null;aiConfidence?:number|null; createdAt:string; }
+export interface ListingProductSummary { productId:string;shopId:string;platform:'jd';title:string|null;imageUrl:string|null;categoryIds:string[];itemStatus:string|null;skuCount:number|null;sourceUpdatedAt:string|null;syncedAt:string;sourceHash:string;coverage:ListingCoverage;latestScore:ListingScoreResult|null; }
+export interface ListingCatalogSummary { sourceTotal:number;eligible:number;scored:number;partial:number;blocked:number;failed:number;averageScore:number|null;lastCatalogSyncAt:string|null; }
+export interface CursorPage<T>{items:T[];nextCursor:string|null;}
+export interface ListingProductPage extends CursorPage<ListingProductSummary>{summary:ListingCatalogSummary;}
+export interface ListingAttribute{id:string;name:string;values:string[];}
+export interface ListingSource {id:string;workspaceId:string;platform:'jd';shopId:string;productId:string;sourceHash:string;title:string|null;titleBrandName:string|null;brand:{id:string|null;name:string|null};categoryIds:string[];itemStatus:string|null;price:{jd:number|null;cost:number|null};descriptions:{desktopHtml:string|null;mobileHtml:string|null};features:Array<{key:string;value:string}>;attributes:ListingAttribute[];images:Array<{url:string;order:number|null;isPrimary:boolean|null;gptFlag:boolean|null}>;skus:Array<{skuId:string;name:string|null;price:number|null;stock:number|null;status:string|null;attributes:ListingAttribute[]}>;dimensions:{length:number|null;width:number|null;height:number|null;weight:number|null};logistics:Record<string,unknown>;afterService:Record<string,unknown>;sourceModifiedAt:string|null;syncedAt:string;detailStatus:'available'|'empty'|'failed';}
+export interface ListingVersion{id:string;workspaceId:string;productId:string;versionNo:number;baseSourceHash:string;baseScoreResultId:string|null;content:{title:string|null;sellingPoints:string[];descriptionHtml:string|null;specifications:ListingAttribute[];imageUrls:string[]};status:'draft'|'adopted'|'stale'|'archived';createdBy:string;createdAt:string;adoptedAt:string|null;}
+export interface ListingProductDetailResponse{source:ListingSource;coverage:ListingCoverage|null;latestScore:ListingScoreResult|null;versionsSummary:CursorPage<ListingVersion>;}
+export interface ListingScoreJob{id:string;workspaceId:string;platform:'jd';rubricVersion:string;includeAiSuggestions:boolean;status:ListingJobStatus;total:number;processed:number;succeeded:number;partial:number;blocked:number;failed:number;requestedBy:string;requestedAt:string;startedAt:string|null;completedAt:string|null;updatedAt:string;}
+export interface ListingScoreJobItem{id:string;jobId:string;workspaceId:string;productId:string;sourceHash:string;status:ListingItemStatus;attempts:number;scoreResultId:string|null;errorCode:string|null;errorDetail:string|null;updatedAt:string;}

+ 49 - 0
src/modules/listing-ai/pages/product/listing-ai-product.component.html

@@ -0,0 +1,49 @@
+<main class="listing-page">@if(loading()){<div class="state">正在加载商品详情…</div>}@else if(error()){<div class="state error">{{error()}} <button class="link" (click)="load()">重试</button></div>}@else{@if(detail();as vm){
+  <app-page-header [title]="vm.source.title || 'Listing 商品详情'" eyebrow="LISTING ENTITY" [description]="'京东商品 '+vm.source.productId+' · 源快照 '+vm.source.sourceHash.slice(0,10)" backLabel="返回工作台" backRoute="/listing-ai/workbench" [badges]="[{label:vm.source.detailStatus,tone:vm.source.detailStatus==='available'?'green':'amber'}]">
+    <div pageHeaderActions class="header-actions"><button class="btn secondary" (click)="load()">刷新</button></div>
+  </app-page-header>
+  @if(action()){<div class="alert" [class.error]="action().includes('失败')">{{action()}}</div>}
+  <div class="detail-grid"><aside class="sticky"><app-listing-mobile-preview [source]="vm.source"></app-listing-mobile-preview></aside><section class="listing-detail-content">
+    <app-content-card title="评分总览" [subtitle]="vm.latestScore ? vm.latestScore.rubricVersion+' · '+(vm.latestScore.createdAt|date:'yyyy-MM-dd HH:mm') : '尚未评分'">
+      <app-listing-score-summary [score]="vm.latestScore"></app-listing-score-summary>
+      @if(vm.latestScore;as score){<div class="source-box"><h3>评分来源</h3><p>{{score.scoreKind==='hybrid_ai'||score.rubricVersion.startsWith('listing-jd-ai-')?'真实 AI 语义评分 + 确定性结构分':'确定性规则评分,尚未执行 AI 评分'}}</p>@if(score.model){<p class="field-note">模型 {{score.model}} · Prompt {{score.promptVersion}} · AI置信度 {{score.aiConfidence===null?'—':score.aiConfidence}}</p>}@if(score.baselineOverallScore!==null&&score.baselineOverallScore!==undefined){<p class="field-note">同源规则基线:{{score.baselineOverallScore}} 分</p>}</div>}
+      @if(vm.latestScore?.aiSuggestions?.length){<div class="source-box"><h3>AI 评分改进建议</h3><ul>@for(item of vm.latestScore?.aiSuggestions;track item){<li>{{item}}</li>}</ul></div>}
+    </app-content-card>
+    <app-content-card title="Listing 内容与证据" subtitle="当前内容、AI/规则证据和评分结果均绑定同一 sourceHash">
+      <app-board-tabs [tabs]="tabs" [activeKey]="active" (tabChange)="select($event)"></app-board-tabs>
+      <div class="tab-content source-grid" [class.description-layout]="active==='description'">
+        <div class="source-box">
+          <h3>当前内容</h3>
+          @switch(active){
+            @case('title'){<p>{{vm.source.title || '—'}}</p>}
+            @case('selling_points'){
+              @if(sellingPointRows(vm.source);as points){@if(points.length){
+                <p class="field-note">京东接口未提供独立营销卖点字段,以下为可用于卖点表达的商品信息;系统内部标记字段已隐藏。</p>
+                <div class="selling-points">@for(item of points;track item.label+item.value){<article><span>{{item.label}}</span><strong>{{item.value}}</strong></article>}</div>
+              }@else{<p>—</p>}}
+            }
+            @case('images'){
+              @if(vm.source.images.length){<div class="gallery">@for(image of vm.source.images;track image.url){<img [src]="image.url" alt="Listing 图片">}</div>}@else{<p>—</p>}
+            }
+            @case('description'){<div class="listing-description-content" [innerHTML]="vm.source.descriptions.mobileHtml || vm.source.descriptions.desktopHtml || '—'"></div>}
+            @case('specifications'){
+              @if(vm.source.attributes.length){<dl>@for(item of vm.source.attributes;track item.id){<dt>{{item.name}}</dt><dd>{{item.values.join('、')}}</dd>}</dl>}@else{<p>—</p>}
+            }
+          }
+        </div>
+        <div class="source-box"><app-listing-dimension-panel [score]="dimension()"></app-listing-dimension-panel></div>
+      </div>
+    </app-content-card>
+    <app-content-card title="优化版本" subtitle="保存 AI 或人工优化后的内部草稿;可对比和采纳,但不会自动发布到京东">
+      @if(vm.versionsSummary.items.length){<div class="version-list">@for(version of vm.versionsSummary.items;track version.id){
+        <article class="version-card">
+          <div class="version-heading"><strong>V{{version.versionNo}} · {{version.status}}</strong><span [class.changed]="versionChangeCount(version,vm.source)>0">{{versionChangeCount(version,vm.source)}} 项内容变化</span></div>
+          <span>{{version.createdAt|date:'yyyy-MM-dd HH:mm'}} · 源 {{version.baseSourceHash.slice(0,10)}}</span>
+          <p>{{version.content.title || '—'}}</p>
+          @if(versionChangeCount(version,vm.source)===0){<p class="field-note">该版本与源 Listing 内容相同,属于旧测试快照,不会产生可见优化效果。</p>}
+          <div><button class="btn primary" [disabled]="version.status==='adopted'||versionChangeCount(version,vm.source)===0" (click)="adopt(version)">{{version.status==='adopted'?'已采用':versionChangeCount(version,vm.source)===0?'无变化可采用':'采用此版本'}}</button></div>
+        </article>
+      }</div>}@else{<div class="state">尚无优化版本。需要先完成 AI 建议或人工编辑,才能生成有内容差异的草稿。</div>}
+    </app-content-card>
+  </section></div>
+}}</main>

+ 45 - 0
src/modules/listing-ai/pages/product/listing-ai-product.component.ts

@@ -0,0 +1,45 @@
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ActivatedRoute } from '@angular/router';
+import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
+import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import { BoardTabsComponent, type BoardTabItem } from '../../../shared/components/board-tabs/board-tabs.component';
+import { ListingMobilePreviewComponent } from '../../components/listing-mobile-preview/listing-mobile-preview.component';
+import { ListingScoreSummaryComponent } from '../../components/listing-score-summary/listing-score-summary.component';
+import { ListingDimensionPanelComponent } from '../../components/listing-dimension-panel/listing-dimension-panel.component';
+import type { ListingDimension, ListingDimensionScore, ListingProductDetailResponse, ListingVersion } from '../../models/listing-ai.models';
+import { ListingAiApiService } from '../../services/listing-ai-api.service';
+
+@Component({selector:'app-listing-ai-product',standalone:true,imports:[CommonModule,PageHeaderComponent,ContentCardComponent,BoardTabsComponent,ListingMobilePreviewComponent,ListingScoreSummaryComponent,ListingDimensionPanelComponent],changeDetection:ChangeDetectionStrategy.OnPush,templateUrl:'./listing-ai-product.component.html',styleUrl:'../../listing-ai.shared.scss'})
+export class ListingAiProductComponent implements OnInit{
+  private readonly route=inject(ActivatedRoute);private readonly api=inject(ListingAiApiService);readonly workspaceId=RUNTIME_CONFIG.domesticWorkspaceId;readonly detail=signal<ListingProductDetailResponse|null>(null);readonly loading=signal(true);readonly error=signal('');readonly action=signal('');active:ListingDimension='title';
+  readonly tabs:BoardTabItem[]=[{key:'title',label:'标题'},{key:'selling_points',label:'核心卖点'},{key:'images',label:'主图'},{key:'description',label:'详情'},{key:'specifications',label:'规格'}];
+  ngOnInit():void{this.load();}
+  load():void{const productId=this.route.snapshot.paramMap.get('productId')??'';this.loading.set(true);this.api.product(this.workspaceId,productId).subscribe({next:(detail)=>this.detail.set(detail),error:(error)=>this.error.set(error instanceof Error?error.message:'商品加载失败'),complete:()=>this.loading.set(false)});}
+  select(key:string):void{this.active=key as ListingDimension;}
+  dimension():ListingDimensionScore|null{return this.detail()?.latestScore?.dimensions.find((item)=>item.dimension===this.active)??null;}
+  sellingPointRows(source: ListingProductDetailResponse['source']): Array<{label:string;value:string}> {
+    const rows: Array<{label:string;value:string}> = [];
+    const description = source.features.find((item) => item.key === 'nameWithoutBrand')?.value.trim();
+    const model = source.features.find((item) => item.key === 'model')?.value.trim();
+    if (description) rows.push({label:'商品核心描述',value:description});
+    if (model) rows.push({label:'型号',value:model});
+    for (const attribute of source.attributes.slice(0, 8)) {
+      const value = attribute.values.filter(Boolean).join('、');
+      if (attribute.name && value) rows.push({label:attribute.name,value});
+    }
+    return rows;
+  }
+  versionChangeCount(version: ListingVersion, source: ListingProductDetailResponse['source']): number {
+    const sourcePoints = source.features.map((item) => item.value).filter(Boolean);
+    const sourceDescription = source.descriptions.mobileHtml ?? source.descriptions.desktopHtml ?? null;
+    return Number(version.content.title !== source.title)
+      + Number(JSON.stringify(version.content.sellingPoints) !== JSON.stringify(sourcePoints))
+      + Number((version.content.descriptionHtml ?? null) !== sourceDescription)
+      + Number(JSON.stringify(version.content.specifications) !== JSON.stringify(source.attributes))
+      + Number(JSON.stringify(version.content.imageUrls) !== JSON.stringify(source.images.map((item) => item.url)));
+  }
+  createVersion():void{const value=this.detail();if(!value)return;this.action.set('正在创建版本…');this.api.createVersion(this.workspaceId,value.source.productId,value.source.sourceHash,value.latestScore?.id??null).subscribe({next:({version})=>{this.detail.update((current)=>current?{...current,versionsSummary:{...current.versionsSummary,items:[version,...current.versionsSummary.items]}}:current);this.action.set('优化草稿已创建');},error:(error)=>this.action.set(error instanceof Error?error.message:'版本创建失败')});}
+  adopt(version:ListingVersion):void{this.action.set('正在采用版本…');this.api.adoptVersion(this.workspaceId,version.id).subscribe({next:({version:updated})=>{this.detail.update((current)=>current?{...current,versionsSummary:{...current.versionsSummary,items:current.versionsSummary.items.map((item)=>item.id===updated.id?updated:item)}}:current);this.action.set('已采用内部版本;未向京东发布');},error:(error)=>this.action.set(error instanceof Error?error.message:'采用失败')});}
+}

+ 1 - 0
src/modules/listing-ai/pages/task-detail/listing-score-job-detail.component.html

@@ -0,0 +1 @@
+<main class="listing-page">@if(job();as vm){<app-page-header eyebrow="LISTING JOB" [title]="'评分任务 '+vm.id.slice(0,8)" [description]="vm.rubricVersion+' · '+vm.status" backLabel="返回任务列表" backRoute="/listing-ai/tasks"><div pageHeaderActions class="actions">@if(vm.status==='partial'||vm.status==='failed'){<button class="btn primary" (click)="retry()">重试失败项</button>}@if(vm.status==='queued'||vm.status==='running'){<button class="btn secondary" (click)="cancel()">取消任务</button>}</div></app-page-header>@if(action()){<div class="alert">{{action()}}</div>}<app-content-card title="任务进度"><div class="job-progress"><i [style.width.%]="percent()"></i></div><div class="job-stat"><div><b>{{vm.total}}</b><span>冻结总量</span></div><div><b>{{vm.processed}}</b><span>已终态</span></div><div><b>{{vm.succeeded}}</b><span>成功</span></div><div><b>{{vm.partial}}</b><span>部分</span></div><div><b>{{vm.blocked}}</b><span>阻塞</span></div><div><b>{{vm.failed}}</b><span>失败</span></div></div></app-content-card><app-content-card title="商品明细"><div class="table-wrap"><table><thead><tr><th>商品 ID</th><th>状态</th><th>尝试</th><th>错误码</th><th>更新时间</th><th></th></tr></thead><tbody>@for(item of items();track item.id){<tr><td>{{item.productId}}</td><td><span [class]="'status '+item.status">{{item.status}}</span></td><td>{{item.attempts}}</td><td>{{item.errorCode || '—'}}</td><td>{{item.updatedAt|date:'MM-dd HH:mm:ss'}}</td><td><a class="link" [routerLink]="['/listing-ai/workbench',item.productId]">商品详情</a></td></tr>}</tbody></table></div></app-content-card>}@else if(error()){<div class="state error">{{error()}}</div>}@else{<div class="state">正在读取任务…</div>}</main>

+ 18 - 0
src/modules/listing-ai/pages/task-detail/listing-score-job-detail.component.ts

@@ -0,0 +1,18 @@
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+import { Subscription, interval, startWith, switchMap } from 'rxjs';
+import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
+import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import type { ListingScoreJob, ListingScoreJobItem } from '../../models/listing-ai.models';
+import { ListingAiApiService } from '../../services/listing-ai-api.service';
+@Component({selector:'app-listing-score-job-detail',standalone:true,imports:[CommonModule,RouterLink,PageHeaderComponent,ContentCardComponent],changeDetection:ChangeDetectionStrategy.OnPush,templateUrl:'./listing-score-job-detail.component.html',styleUrl:'../../listing-ai.shared.scss'})
+export class ListingScoreJobDetailComponent implements OnInit,OnDestroy{private readonly api=inject(ListingAiApiService);private readonly route=inject(ActivatedRoute);private subscription?:Subscription;readonly workspaceId=RUNTIME_CONFIG.domesticWorkspaceId;readonly job=signal<ListingScoreJob|null>(null);readonly items=signal<ListingScoreJobItem[]>([]);readonly error=signal('');readonly action=signal('');
+  ngOnInit():void{const id=this.route.snapshot.paramMap.get('jobId')??'';this.subscription=interval(2500).pipe(startWith(0),switchMap(()=>this.api.job(this.workspaceId,id))).subscribe({next:({job})=>{this.job.set(job);this.loadItems(id);if(['completed','partial','failed','cancelled'].includes(job.status))this.subscription?.unsubscribe();},error:(error)=>this.error.set(error instanceof Error?error.message:'任务加载失败')});}
+  ngOnDestroy():void{this.subscription?.unsubscribe();}
+  loadItems(id=this.job()?.id??''):void{if(!id)return;this.api.jobItems(this.workspaceId,id).subscribe({next:(page)=>this.items.set(page.items)});}
+  retry():void{const id=this.job()?.id;if(!id)return;this.api.retryJob(this.workspaceId,id).subscribe({next:({job})=>{this.job.set(job);this.action.set('任务已重新排队');this.ngOnDestroy();this.ngOnInit();},error:(error)=>this.action.set(error instanceof Error?error.message:'重试失败')});}
+  cancel():void{const id=this.job()?.id;if(!id)return;this.api.cancelJob(this.workspaceId,id).subscribe({next:({job})=>{this.job.set(job);this.action.set('任务已取消');},error:(error)=>this.action.set(error instanceof Error?error.message:'取消失败')});}
+  percent():number{const job=this.job();return job?.total?Math.round(job.processed/job.total*100):100;}
+}

+ 1 - 0
src/modules/listing-ai/pages/tasks/listing-score-job-list.component.html

@@ -0,0 +1 @@
+<main class="listing-page"><app-page-header eyebrow="LISTING JOBS" title="Listing 评分任务" description="查看批量评分进度、部分结果和失败重试" backLabel="返回工作台" backRoute="/listing-ai/workbench"><div pageHeaderActions><button class="btn secondary" (click)="load()">刷新</button></div></app-page-header><app-content-card title="任务记录">@if(loading()){<div class="state">加载中…</div>}@else if(error()){<div class="state error">{{error()}}</div>}@else if(!items().length){<div class="state">尚无评分任务。</div>}@else{<div class="version-list">@for(job of items();track job.id){<a class="version-card" [routerLink]="['/listing-ai/tasks',job.id]"><div class="actions"><strong>{{job.status}} · {{job.rubricVersion}}</strong><span>{{job.requestedAt|date:'yyyy-MM-dd HH:mm'}}</span></div><div class="job-progress"><i [style.width.%]="percent(job)"></i></div><span>{{job.processed}} / {{job.total}} · 成功 {{job.succeeded}} · 部分 {{job.partial}} · 阻塞 {{job.blocked}} · 失败 {{job.failed}}</span></a>}</div>}</app-content-card></main>

+ 10 - 0
src/modules/listing-ai/pages/tasks/listing-score-job-list.component.ts

@@ -0,0 +1,10 @@
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { RouterLink } from '@angular/router';
+import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
+import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import type { ListingScoreJob } from '../../models/listing-ai.models';
+import { ListingAiApiService } from '../../services/listing-ai-api.service';
+@Component({selector:'app-listing-score-job-list',standalone:true,imports:[CommonModule,RouterLink,PageHeaderComponent,ContentCardComponent],changeDetection:ChangeDetectionStrategy.OnPush,templateUrl:'./listing-score-job-list.component.html',styleUrl:'../../listing-ai.shared.scss'})
+export class ListingScoreJobListComponent implements OnInit{private readonly api=inject(ListingAiApiService);readonly workspaceId=RUNTIME_CONFIG.domesticWorkspaceId;readonly items=signal<ListingScoreJob[]>([]);readonly loading=signal(true);readonly error=signal('');ngOnInit():void{this.load();}load():void{this.loading.set(true);this.api.jobs(this.workspaceId).subscribe({next:(page)=>this.items.set(page.items),error:(error)=>this.error.set(error instanceof Error?error.message:'任务加载失败'),complete:()=>this.loading.set(false)});}percent(job:ListingScoreJob):number{return job.total?Math.round(job.processed/job.total*100):100;}}

+ 1 - 0
src/modules/listing-ai/pages/versions/listing-version-list.component.html

@@ -0,0 +1 @@
+<main class="listing-page"><app-page-header eyebrow="LISTING VERSIONS" title="Listing 优化版本" description="版本与源快照 hash 绑定,源数据变化后禁止直接采用" backLabel="返回工作台" backRoute="/listing-ai/workbench"><div pageHeaderActions><button class="btn secondary" (click)="load()">刷新</button></div></app-page-header>@if(action()){<div class="alert">{{action()}}</div>}<app-content-card title="版本记录">@if(loading()){<div class="state">加载中…</div>}@else if(error()){<div class="state error">{{error()}}</div>}@else if(!items().length){<div class="state">尚无优化版本。</div>}@else{<div class="version-list">@for(version of items();track version.id){<article class="version-card"><div class="actions"><strong>商品 {{version.productId}} · V{{version.versionNo}}</strong><span class="status" [class]="'status '+version.status">{{version.status}}</span></div><p>{{version.content.title || '—'}}</p><span>源 {{version.baseSourceHash.slice(0,12)}} · {{version.createdAt|date:'yyyy-MM-dd HH:mm'}}</span><div class="actions"><a class="link" [routerLink]="['/listing-ai/workbench',version.productId]">查看商品</a><button class="btn primary" [disabled]="version.status==='adopted'||version.status==='stale'" (click)="adopt(version)">{{version.status==='adopted'?'已采用':'采用版本'}}</button></div></article>}</div>}</app-content-card></main>

+ 10 - 0
src/modules/listing-ai/pages/versions/listing-version-list.component.ts

@@ -0,0 +1,10 @@
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { RouterLink } from '@angular/router';
+import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
+import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import type { ListingVersion } from '../../models/listing-ai.models';
+import { ListingAiApiService } from '../../services/listing-ai-api.service';
+@Component({selector:'app-listing-version-list',standalone:true,imports:[CommonModule,RouterLink,PageHeaderComponent,ContentCardComponent],changeDetection:ChangeDetectionStrategy.OnPush,templateUrl:'./listing-version-list.component.html',styleUrl:'../../listing-ai.shared.scss'})
+export class ListingVersionListComponent implements OnInit{private readonly api=inject(ListingAiApiService);readonly workspaceId=RUNTIME_CONFIG.domesticWorkspaceId;readonly items=signal<ListingVersion[]>([]);readonly loading=signal(true);readonly error=signal('');readonly action=signal('');ngOnInit():void{this.load();}load():void{this.loading.set(true);this.api.versions(this.workspaceId).subscribe({next:(page)=>this.items.set(page.items),error:(error)=>this.error.set(error instanceof Error?error.message:'版本加载失败'),complete:()=>this.loading.set(false)});}adopt(version:ListingVersion):void{this.api.adoptVersion(this.workspaceId,version.id).subscribe({next:({version:next})=>{this.items.update((items)=>items.map((item)=>item.id===next.id?next:item));this.action.set('已采用内部版本;未发布到京东');},error:(error)=>this.action.set(error instanceof Error?error.message:'采用失败')});}}

+ 23 - 0
src/modules/listing-ai/pages/workbench/listing-ai-workbench.component.html

@@ -0,0 +1,23 @@
+<main class="listing-page">
+  <app-page-header eyebrow="LISTING OPTIMIZATION STUDIO" title="Listing AI 优化工作台" description="以真实京东商品详情为输入,执行可解释、可恢复的版本化评分" groupBadge="京东" groupBadgeTone="blue" [lastUpdated]="store.summary()?.lastCatalogSyncAt || ''" [sampleSize]="store.summary()?.sourceTotal ?? null">
+    <div pageHeaderActions class="header-actions"><button class="btn secondary" routerLink="/listing-ai/tasks">任务记录</button><button class="btn primary" [disabled]="!selected.size||selected.size>10||creating()" title="调用真实模型按 listing-jd-ai-v1 评分;本轮最多10个商品" (click)="runAiScore()">{{creating()?'创建中…':'Listing AI评分已选 '+selected.size+' / 10'}}</button></div>
+  </app-page-header>
+  <div class="platform-tabs"><button class="active">京东</button><button disabled>天猫 · 即将支持</button><button disabled>抖音 · 即将支持</button><button disabled>拼多多 · 即将支持</button></div>
+  @if(actionError()){<div class="alert error">{{actionError()}}</div>}
+  <div class="alert">本轮仅允许对最多 10 个已选商品执行真实 AI 评分;其余商品继续保留 listing-jd-v2 规则评分。主图视觉语义尚未接入已验证的多模态模型。</div>
+  <section class="metric-grid">
+    <app-summary-metric-card label="商品总数" [value]="store.summary()?.sourceTotal ?? null" description="最近同步冻结口径" tone="blue"></app-summary-metric-card>
+    <app-summary-metric-card label="可评分" [value]="store.summary()?.eligible ?? null" description="覆盖达到门槛" tone="green"></app-summary-metric-card>
+    <app-summary-metric-card label="已评分" [value]="store.summary()?.scored ?? null" description="存在有效总分" tone="purple"></app-summary-metric-card>
+    <app-summary-metric-card label="部分 / 阻塞" [value]="(store.summary()?.partial || 0) + (store.summary()?.blocked || 0)" description="不把缺失记为 0 分" tone="amber"></app-summary-metric-card>
+    <app-summary-metric-card label="平均分" [value]="store.summary()?.averageScore ?? null" unit="分" description="只统计有效总分"></app-summary-metric-card>
+  </section>
+  <app-content-card title="商品评分目录" subtitle="总数和状态均来自服务端;每页最多加载 25 条" [hasHeaderActions]="true">
+    <div contentCardActions><span class="field-note">勾选商品后从页面顶部发起 Listing AI 评分</span></div>
+    <form class="toolbar" (ngSubmit)="apply()"><input name="search" [(ngModel)]="search" placeholder="搜索商品 ID 或标题"><select name="scoreStatus" [(ngModel)]="scoreStatus"><option value="">全部评分状态</option><option value="unscored">未评分</option><option value="scored">已评分</option><option value="partial">部分完成</option><option value="blocked">数据阻塞</option></select><select name="coverageStatus" [(ngModel)]="coverageStatus"><option value="">全部覆盖状态</option><option value="eligible">可评分</option><option value="partial">部分</option><option value="blocked">阻塞</option></select><select name="sort" [(ngModel)]="sort"><option value="productId">商品 ID</option><option value="score_desc">评分从高到低</option><option value="score_asc">评分从低到高</option><option value="updated_desc">最近同步</option></select><button class="btn primary" type="submit">应用筛选</button></form>
+    @if(store.loading()&&!store.items().length){<div class="state">正在加载 Listing 商品…</div>}@else if(store.error()){<div class="state error">{{store.error()}} <button class="link" (click)="apply(false)">重试</button></div>}@else if(store.empty()){<div class="state">当前筛选没有商品。不会用模拟数据填充。</div>}@else{
+      <div class="table-wrap"><table><thead><tr><th></th><th>商品</th><th>类目 / SKU</th><th>覆盖度</th><th>总分</th><th>评分来源</th><th>评分版本</th><th>更新时间</th><th></th></tr></thead><tbody>@for(item of store.items();track item.productId){<tr><td><input type="checkbox" [checked]="selected.has(item.productId)" (change)="toggle(item.productId,$any($event.target).checked)" [attr.aria-label]="'选择 '+item.productId"></td><td><div class="product-cell">@if(item.imageUrl){<img [src]="item.imageUrl" [alt]="item.title || '商品图'">}<div><strong>{{item.title || '—'}}</strong><span>{{item.productId}} · {{item.itemStatus || '状态未返回'}}</span></div></div></td><td>{{item.categoryIds.join(' / ') || '—'}}<small>{{item.skuCount ?? '—'}} SKU</small></td><td><span class="status" [class]="'status '+item.coverage.status">{{item.coverage.percent}}% · {{item.coverage.status}}</span></td><td class="score-cell">{{score(item.latestScore?.overallScore ?? null)}}</td><td><span class="status" [class.scored]="scoreSource(item.latestScore)==='AI评分'">{{scoreSource(item.latestScore)}}</span></td><td>{{item.latestScore?.rubricVersion || '—'}}</td><td>{{item.syncedAt | date:'MM-dd HH:mm'}}</td><td><a class="link" [routerLink]="['/listing-ai/workbench',item.productId]">查看详情</a></td></tr>}</tbody></table></div>
+      @if(store.nextCursor()){<button class="load-more" [disabled]="store.loading()" (click)="store.load(workspaceId,true)">加载下一页</button>}
+    }
+  </app-content-card>
+</main>

+ 37 - 0
src/modules/listing-ai/pages/workbench/listing-ai-workbench.component.ts

@@ -0,0 +1,37 @@
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router, RouterLink } from '@angular/router';
+import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
+import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import { ListingAiApiService } from '../../services/listing-ai-api.service';
+import { ListingAiWorkbenchStore } from '../../services/listing-ai-workbench.store';
+
+@Component({selector:'app-listing-ai-workbench',standalone:true,imports:[CommonModule,FormsModule,RouterLink,PageHeaderComponent,SummaryMetricCardComponent,ContentCardComponent],providers:[ListingAiWorkbenchStore],changeDetection:ChangeDetectionStrategy.OnPush,templateUrl:'./listing-ai-workbench.component.html',styleUrl:'../../listing-ai.shared.scss'})
+export class ListingAiWorkbenchComponent implements OnInit{
+  readonly store=inject(ListingAiWorkbenchStore);private readonly api=inject(ListingAiApiService);private readonly route=inject(ActivatedRoute);private readonly router=inject(Router);readonly workspaceId=RUNTIME_CONFIG.domesticWorkspaceId;
+  search='';scoreStatus='';coverageStatus='';sort='productId';readonly creating=signal(false);readonly actionError=signal('');readonly selected=new Set<string>();
+  ngOnInit():void{const query=this.route.snapshot.queryParamMap;this.search=query.get('search')??'';this.scoreStatus=query.get('scoreStatus')??'';this.coverageStatus=query.get('coverageStatus')??'';this.sort=query.get('sort')??'productId';this.apply(false);}
+  apply(updateUrl=true):void{this.store.patchFilters({search:this.search,scoreStatus:this.scoreStatus||undefined,coverageStatus:this.coverageStatus||undefined,sort:this.sort});if(updateUrl)void this.router.navigate([],{relativeTo:this.route,queryParams:{search:this.search||null,scoreStatus:this.scoreStatus||null,coverageStatus:this.coverageStatus||null,sort:this.sort==='productId'?null:this.sort},queryParamsHandling:'merge',replaceUrl:true});this.store.load(this.workspaceId);}
+  toggle(id:string,checked:boolean):void{checked?this.selected.add(id):this.selected.delete(id);}
+  runAiScore():void{
+    if(!this.selected.size)return;
+    if(this.selected.size>10){this.actionError.set('本轮 Listing AI 评分最多支持 10 个商品,请减少选择数量。');return;}
+    this.creating.set(true);
+    this.actionError.set('');
+    const scope={mode:'selected' as const,productIds:[...this.selected]};
+    this.api.createScoreJob(this.workspaceId,scope,'ai').subscribe({
+      next:({job})=>void this.router.navigate(['/listing-ai/tasks',job.id]),
+      error:(error)=>{
+        this.creating.set(false);
+        const code=error?.error?.error;
+        this.actionError.set(code==='listing_ai_budget_exceeded'?'本轮 Listing AI 评分最多支持 10 个商品。':code||'任务创建失败');
+      },
+      complete:()=>this.creating.set(false),
+    });
+  }
+  score(value:number|null):string{return value===null?'—':String(value);}
+  scoreSource(score:{scoreKind?:string;rubricVersion:string}|null):string{return score?.scoreKind==='hybrid_ai'||score?.rubricVersion.startsWith('listing-jd-ai-')?'AI评分':'规则评分';}
+}

+ 11 - 0
src/modules/listing-ai/services/listing-ai-api.service.spec.ts

@@ -0,0 +1,11 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { ListingAiApiService } from './listing-ai-api.service';
+
+describe('ListingAiApiService',()=>{
+  let service:ListingAiApiService;let http:HttpTestingController;
+  beforeEach(()=>{TestBed.configureTestingModule({imports:[HttpClientTestingModule]});service=TestBed.inject(ListingAiApiService);http=TestBed.inject(HttpTestingController);});
+  afterEach(()=>http.verify());
+it('queries one cursor page without hardcoding catalog totals',()=>{service.products('demashi',{search:'299L',limit:25}).subscribe();const request=http.expectOne((candidate)=>candidate.url==='/api/listing-ai/products');expect(request.request.method).toBe('GET');expect(request.request.params.get('workspaceId')).toBe('demashi');expect(request.request.params.get('search')).toBe('299L');expect(request.request.params.get('limit')).toBe('25');request.flush({items:[],nextCursor:null,summary:{sourceTotal:17,eligible:0,scored:0,partial:0,blocked:0,failed:0,averageScore:null,lastCatalogSyncAt:null}});});
+  it('creates an AI score job with a stable rubric and idempotency key',()=>{service.createScoreJob('demashi',{mode:'selected',productIds:['1001']},'ai').subscribe();const request=http.expectOne('/api/listing-ai/score-jobs');expect(request.request.method).toBe('POST');expect(request.request.headers.get('Idempotency-Key')).toBeTruthy();expect(request.request.body.scoringMode).toBe('ai');expect(request.request.body.rubricVersion).toBe('listing-jd-ai-v1');request.flush({job:{}});});
+});

+ 23 - 0
src/modules/listing-ai/services/listing-ai-api.service.ts

@@ -0,0 +1,23 @@
+import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
+import type { CursorPage, ListingProductDetailResponse, ListingProductPage, ListingScoreJob, ListingScoreJobItem, ListingVersion } from '../models/listing-ai.models';
+
+export interface ListingProductFilters {search?:string;categoryId?:string;itemStatus?:string;scoreStatus?:string;coverageStatus?:string;minScore?:number;maxScore?:number;sort?:string;cursor?:string;limit?:number;}
+@Injectable({providedIn:'root'})
+export class ListingAiApiService{
+  private readonly http=inject(HttpClient);
+  private readonly baseUrl=`${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/,'')}/${RUNTIME_CONFIG.listingAiPath.replace(/^\/+|\/+$/g,'')}`;
+  products(workspaceId:string,filters:ListingProductFilters={}):Observable<ListingProductPage>{let params=new HttpParams().set('workspaceId',workspaceId).set('platform','jd').set('limit',filters.limit??25);for(const [key,value] of Object.entries(filters)){if(value!==undefined&&value!==null&&value!=='')params=params.set(key,String(value));}return this.http.get<ListingProductPage>(`${this.baseUrl}/products`,{params});}
+  product(workspaceId:string,productId:string):Observable<ListingProductDetailResponse>{return this.http.get<ListingProductDetailResponse>(`${this.baseUrl}/products/${encodeURIComponent(productId)}`,{params:{workspaceId,platform:'jd'}});}
+  createScoreJob(workspaceId:string,scope:{mode:'filter';filter:Record<string,unknown>}|{mode:'selected';productIds:string[]},scoringMode:'rules'|'ai'='ai'):Observable<{job:ListingScoreJob}>{const idempotencyKey=crypto.randomUUID();const rubricVersion=scoringMode==='ai'?'listing-jd-ai-v1':'listing-jd-v2';return this.http.post<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs`,{workspaceId,platform:'jd',scope,scoringMode,rubricVersion},{headers:new HttpHeaders({'Idempotency-Key':idempotencyKey})});}
+  jobs(workspaceId:string,status='',cursor='',limit=25):Observable<CursorPage<ListingScoreJob>>{let params=new HttpParams().set('workspaceId',workspaceId).set('limit',limit);if(status)params=params.set('status',status);if(cursor)params=params.set('cursor',cursor);return this.http.get<CursorPage<ListingScoreJob>>(`${this.baseUrl}/score-jobs`,{params});}
+  job(workspaceId:string,jobId:string):Observable<{job:ListingScoreJob}>{return this.http.get<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs/${encodeURIComponent(jobId)}`,{params:{workspaceId}});}
+  jobItems(workspaceId:string,jobId:string,status='',cursor='',limit=100):Observable<CursorPage<ListingScoreJobItem>>{let params=new HttpParams().set('workspaceId',workspaceId).set('limit',limit);if(status)params=params.set('status',status);if(cursor)params=params.set('cursor',cursor);return this.http.get<CursorPage<ListingScoreJobItem>>(`${this.baseUrl}/score-jobs/${encodeURIComponent(jobId)}/items`,{params});}
+  retryJob(workspaceId:string,jobId:string):Observable<{job:ListingScoreJob}>{return this.http.post<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs/${encodeURIComponent(jobId)}/retry`,null,{params:{workspaceId}});}
+  cancelJob(workspaceId:string,jobId:string):Observable<{job:ListingScoreJob}>{return this.http.post<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs/${encodeURIComponent(jobId)}/cancel`,null,{params:{workspaceId}});}
+  versions(workspaceId:string,productId='',cursor='',limit=25):Observable<CursorPage<ListingVersion>>{let params=new HttpParams().set('workspaceId',workspaceId).set('limit',limit);if(productId)params=params.set('productId',productId);if(cursor)params=params.set('cursor',cursor);return this.http.get<CursorPage<ListingVersion>>(`${this.baseUrl}/versions`,{params});}
+  createVersion(workspaceId:string,productId:string,baseSourceHash:string,baseScoreResultId:string|null):Observable<{version:ListingVersion}>{return this.http.post<{version:ListingVersion}>(`${this.baseUrl}/products/${encodeURIComponent(productId)}/versions`,{workspaceId,platform:'jd',baseSourceHash,baseScoreResultId});}
+  adoptVersion(workspaceId:string,versionId:string):Observable<{version:ListingVersion}>{return this.http.post<{version:ListingVersion}>(`${this.baseUrl}/versions/${encodeURIComponent(versionId)}/adopt`,null,{params:{workspaceId,platform:'jd'}});}
+}

+ 13 - 0
src/modules/listing-ai/services/listing-ai-workbench.store.ts

@@ -0,0 +1,13 @@
+import { Injectable, computed, inject, signal } from '@angular/core';
+import { finalize } from 'rxjs';
+import type { ListingCatalogSummary, ListingProductSummary } from '../models/listing-ai.models';
+import { ListingAiApiService, type ListingProductFilters } from './listing-ai-api.service';
+
+@Injectable()
+export class ListingAiWorkbenchStore{
+  private readonly api=inject(ListingAiApiService);
+  readonly items=signal<ListingProductSummary[]>([]);readonly summary=signal<ListingCatalogSummary|null>(null);readonly loading=signal(false);readonly error=signal('');readonly nextCursor=signal<string|null>(null);readonly filters=signal<ListingProductFilters>({limit:25,sort:'productId'});
+  readonly empty=computed(()=>!this.loading()&&!this.error()&&this.items().length===0);
+  load(workspaceId:string,append=false):void{this.loading.set(true);this.error.set('');const query={...this.filters(),cursor:append?this.nextCursor()??undefined:undefined};this.api.products(workspaceId,query).pipe(finalize(()=>this.loading.set(false))).subscribe({next:(page)=>{this.items.set(append?[...this.items(),...page.items]:page.items);this.summary.set(page.summary);this.nextCursor.set(page.nextCursor);},error:(error)=>this.error.set(error instanceof Error?error.message:'Listing 商品加载失败')});}
+  patchFilters(value:Partial<ListingProductFilters>):void{this.filters.update((current)=>({...current,...value,cursor:undefined}));}
+}

+ 3 - 0
src/modules/shared/components/navigation/navigation.component.ts

@@ -163,6 +163,9 @@ export class NavigationComponent implements OnDestroy {
       expanded: false,
       section: '行动',
       children: [
+        { path: '/listing-ai/workbench', displayName: 'Listing AI 工作台', icon: Lightbulb, requiresAuth: false },
+        { path: '/listing-ai/versions', displayName: 'Listing 版本', icon: History, requiresAuth: false },
+        { path: '/listing-ai/tasks', displayName: 'Listing 任务', icon: ListChecks, requiresAuth: false },
         { path: '/action-suggestion/kanban', displayName: '行动工作台', icon: ListChecks, requiresAuth: false },
         { path: '/domestic/risk-alerts', displayName: '风险预警', icon: ShieldAlert, requiresAuth: false },
       ],

+ 122 - 0
tools/jd-detail-diagnostic.mjs

@@ -0,0 +1,122 @@
+#!/usr/bin/env node
+
+import crypto from 'node:crypto';
+
+const trim = (value) => String(value || '').replace(/\/+$/, '');
+const sourceHeaders = {
+  'X-Parse-Application-Id': process.env.JD_SOURCE_PARSE_APP_ID,
+  'X-Parse-Master-Key': process.env.JD_SOURCE_PARSE_MASTER_KEY,
+};
+const authQuery = new URLSearchParams({
+  where: JSON.stringify({ platform: 'jd', type: 'access_token' }),
+  limit: '1',
+  order: '-createdAt',
+});
+const productId = process.argv[2];
+if (!productId) throw new Error('usage: node tools/jd-detail-diagnostic.mjs <productId>');
+let successfulPayload = null;
+
+async function loadToken() {
+  const url = `${trim(process.env.JD_SOURCE_PARSE_URL)}/classes/EcomAuth?${authQuery}`;
+  for (let attempt = 1; attempt <= 4; attempt += 1) {
+    const response = await fetch(url, { headers: sourceHeaders });
+    const payload = await response.json().catch(() => null);
+    const transientUnauthorized = response.status === 403
+      && payload?.code === 119
+      && /unauthorized/i.test(String(payload?.error || ''));
+    if (response.ok) {
+      const accessToken = payload?.results?.[0]?.data?.access_token;
+      if (!accessToken) throw new Error('missing_token');
+      return accessToken;
+    }
+    if (!transientUnauthorized || attempt === 4) {
+      throw new Error(`source_parse_${response.status}`);
+    }
+    await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
+  }
+  throw new Error('source_parse_retry_exhausted');
+}
+
+const token = await loadToken();
+
+function signature(fields) {
+  const plain = Object.keys(fields).sort().map((key) => `${key}${fields[key] ?? ''}`).join('');
+  return crypto.createHash('md5')
+    .update(`${process.env.JD_APP_SECRET}${plain}${process.env.JD_APP_SECRET}`)
+    .digest('hex')
+    .toUpperCase();
+}
+
+async function probe(name, query, pathFields) {
+  const timestamp = String(Date.now());
+  const common = {
+    'X-JOS-App-Key': process.env.JD_APP_KEY,
+    'X-JOS-Access-Token': token,
+    'X-JOS-Timestamp': timestamp,
+  };
+  const signed = { ...query, ...pathFields, ...common };
+  const headers = {
+    ...common,
+    'X-JOS-Sign-Method': 'md5',
+    'X-JOS-Request-Identity': 'vender',
+    'X-JOS-Sign': signature(signed),
+  };
+  const queryString = new URLSearchParams(
+    Object.entries(query).map(([key, value]) => [key, String(value)]),
+  );
+  const response = await fetch(
+    `https://api-cn.jd.com/rest/sp-product/v0/products/${productId}?${queryString}`,
+    { headers },
+  );
+  let body = {};
+  try { body = await response.json(); } catch {}
+  if (response.ok && body?.success === true && successfulPayload == null) successfulPayload = body;
+  const error = body?.errorList?.[0] || body?.error || {};
+  const traceHeaders = {};
+  for (const [key, value] of response.headers) {
+    if (/request|trace/i.test(key)) traceHeaders[key] = value;
+  }
+  console.log(JSON.stringify({
+    name,
+    httpStatus: response.status,
+    success: body?.success ?? null,
+    errorCode: error?.code ?? body?.code ?? null,
+    errorMessage: error?.message ?? error?.details ?? body?.message ?? null,
+    signedFields: Object.keys(signed).sort(),
+    traceHeaders,
+  }, null, 2));
+}
+
+await probe('current_scene_without_path_field', { scene: 'pop' }, {});
+await probe('scene_with_productId_path_field', { scene: 'pop' }, { productId });
+await probe('request_json_with_path_field', { request: JSON.stringify({ scene: 'pop' }) }, { productId });
+await probe('request_json_without_path_field', { request: JSON.stringify({ scene: 'pop' }) }, {});
+
+function collectFieldPaths(value, prefix = '', paths = [], depth = 0) {
+  if (depth > 8 || paths.length >= 800 || value == null) return paths;
+  if (Array.isArray(value)) {
+    paths.push({ path: `${prefix}[]`, type: 'Array', count: value.length });
+    if (value[0] !== undefined) collectFieldPaths(value[0], `${prefix}[]`, paths, depth + 1);
+    return paths;
+  }
+  if (typeof value !== 'object') {
+    paths.push({ path: prefix, type: typeof value });
+    return paths;
+  }
+  for (const [key, child] of Object.entries(value)) {
+    const path = prefix ? `${prefix}.${key}` : key;
+    if (child && typeof child === 'object') {
+      paths.push({ path, type: Array.isArray(child) ? 'Array' : 'Object' });
+      collectFieldPaths(child, path, paths, depth + 1);
+    } else {
+      paths.push({ path, type: child === null ? 'null' : typeof child });
+    }
+  }
+  return paths;
+}
+
+console.log(JSON.stringify({
+  successfulResponseFieldPaths: successfulPayload
+    ? collectFieldPaths(successfulPayload.data ?? successfulPayload)
+    : [],
+}, null, 2));

+ 26 - 2
tools/jd-import-products.mjs

@@ -199,6 +199,23 @@ function extractPrice(row) {
   return Number.isFinite(number) ? number : null;
 }
 
+function extractProductStatus(...rows) {
+  for (const row of rows) {
+    const value = pickFirst(row?.productStatusNew, row?.productStatus, row?.saleState, row?.status);
+    if (value === undefined || value === null || value === '') continue;
+    if (typeof value !== 'object') return stringValue(value);
+    return stringValue(pickFirst(
+      value.code,
+      value.status,
+      value.value,
+      value.name,
+      value.label,
+      flattenText(value),
+    ));
+  }
+  return '';
+}
+
 function extractProductId(row) {
   return extractId(row, 'productId', 'wareId', 'id');
 }
@@ -236,7 +253,7 @@ function normalizeProduct(row, detail, shopId, collectedAt) {
     imageUrl,
     images: imageUrl ? [imageUrl] : [],
     price: extractPrice(row) ?? extractPrice(info),
-    itemStatus: pickFirst(row?.productStatus, row?.productStatusNew, info?.productStatus, null),
+    itemStatus: extractProductStatus(row, info),
     detailStatus: detailPayload ? 'available' : 'empty',
     rawJdProduct: row,
     rawJdDetail: detailPayload,
@@ -321,7 +338,14 @@ async function loadBatch(auth) {
     }
     let detail = null;
     try {
-      const detailResponse = await jdGet(`/sp-product/v0/products/${productId}`, { scene: 'pop' }, auth.token);
+      // JD SP-API requires the path parameter to participate in the signature,
+      // even though productId is not repeated in the query string.
+      const detailResponse = await jdGet(
+        `/sp-product/v0/products/${productId}`,
+        { scene: 'pop' },
+        auth.token,
+        { productId },
+      );
       detail = detailResponse?.data || detailResponse || null;
     } catch (error) {
       console.warn(`[jd-import] 详情查询跳过 ${productId}: ${error.message}`);

+ 113 - 0
tools/parse-target-diagnostic.mjs

@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+
+const configuredBase = String(process.env.JD_TARGET_PARSE_URL || '').replace(/\/+$/, '');
+const appId = process.env.JD_TARGET_PARSE_APP_ID;
+const masterKey = process.env.JD_TARGET_PARSE_MASTER_KEY;
+if (!configuredBase || !appId || !masterKey) {
+  throw new Error('JD target Parse configuration is incomplete');
+}
+
+const origin = new URL(configuredBase).origin;
+const appPathMatch = configuredBase.match(/\/backend\/([^/]+)/);
+const appPath = appPathMatch?.[1] || '';
+const candidates = [
+  configuredBase,
+  `${configuredBase}/classes/ProductDetail?limit=1`,
+  appPath ? `${origin}/backend/${appPath}/classes/ProductDetail?limit=1` : '',
+  appPath ? `${origin}/backend/${appPath}/data/parse/classes/ProductDetail?limit=1` : '',
+  `${origin}/parse/classes/ProductDetail?limit=1`,
+].filter(Boolean);
+
+async function fetchWithAuthorizationRetry(url) {
+  for (let attempt = 1; attempt <= 10; attempt += 1) {
+    const response = await fetch(url, {
+      headers: {
+        'X-Parse-Application-Id': appId,
+        'X-Parse-Master-Key': masterKey,
+      },
+    });
+    const body = await response.json().catch(() => null);
+    if (!(response.status === 403 && body?.error === 'unauthorized') || attempt === 10) {
+      return { response, body, attempt };
+    }
+  }
+  throw new Error('unreachable');
+}
+
+for (const url of [...new Set(candidates)]) {
+  const response = await fetch(url, {
+    headers: {
+      'X-Parse-Application-Id': appId,
+      'X-Parse-Master-Key': masterKey,
+    },
+  });
+  const text = await response.text();
+  let payload = null;
+  try { payload = text ? JSON.parse(text) : null; } catch {}
+  console.log(JSON.stringify({
+    url,
+    status: response.status,
+    contentType: response.headers.get('content-type'),
+    parseCode: payload?.code ?? null,
+    error: payload?.error ?? payload?.message ?? null,
+    resultCount: Array.isArray(payload?.results) ? payload.results.length : null,
+    bodyKind: payload ? 'json' : text ? 'non-json' : 'empty',
+  }, null, 2));
+}
+
+for (const className of ['ProductDetail', 'Product', 'AsinSkuMapping', 'JdStockSnapshot']) {
+  const schemaResponse = await fetch(`${configuredBase}/schemas/${className}`, {
+    headers: {
+      'X-Parse-Application-Id': appId,
+      'X-Parse-Master-Key': masterKey,
+    },
+  });
+  const schema = await schemaResponse.json().catch(() => null);
+  const countResponse = await fetch(`${configuredBase}/classes/${className}?limit=0&count=1`, {
+    headers: {
+      'X-Parse-Application-Id': appId,
+      'X-Parse-Master-Key': masterKey,
+    },
+  });
+  const countPayload = await countResponse.json().catch(() => null);
+  console.log(JSON.stringify({
+    schemaStatus: schemaResponse.status,
+    className: schema?.className ?? className,
+    countStatus: countResponse.status,
+    count: countPayload?.count ?? null,
+    fields: Object.fromEntries(
+      Object.entries(schema?.fields || {}).map(([name, definition]) => [name, definition?.type || null]),
+    ),
+  }, null, 2));
+}
+
+for (const endpoint of [
+  `${configuredBase}/classes/ProductDetail?limit=1`,
+  `${origin}/parse/classes/ProductDetail?limit=1`,
+]) {
+  const samples = [];
+  for (let attempt = 0; attempt < 10; attempt += 1) {
+    const response = await fetch(endpoint, {
+      headers: {
+        'X-Parse-Application-Id': appId,
+        'X-Parse-Master-Key': masterKey,
+      },
+    });
+    const body = await response.json().catch(() => null);
+    samples.push({ status: response.status, resultCount: Array.isArray(body?.results) ? body.results.length : null });
+  }
+  console.log(JSON.stringify({ endpoint, stabilitySamples: samples }, null, 2));
+}
+
+for (const className of ['ProductDetail', 'Product', 'AsinSkuMapping', 'JdStockSnapshot']) {
+  const { response, body, attempt } = await fetchWithAuthorizationRetry(
+    `${configuredBase}/classes/${className}?limit=100&count=1`,
+  );
+  console.log(JSON.stringify({
+    verifiedClass: className,
+    status: response.status,
+    attempts: attempt,
+    count: body?.count ?? null,
+    returnedItems: Array.isArray(body?.results) ? body.results.length : null,
+  }, null, 2));
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio