Просмотр исходного кода

feat(listing): update V7 scoring UI and JD API permissions

Yi Jiarui 3 недель назад
Родитель
Сommit
a0b5122a50
19 измененных файлов с 1184 добавлено и 141 удалено
  1. 402 0
      docs/JD-API-PERMISSIONS.md
  2. 9 57
      src/modules/listing-ai/components/listing-dimension-panel/listing-dimension-panel.component.ts
  3. 1 1
      src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.html
  4. 27 0
      src/modules/listing-ai/components/listing-score-summary/listing-score-summary.component.spec.ts
  5. 13 3
      src/modules/listing-ai/components/listing-score-summary/listing-score-summary.component.ts
  6. 10 0
      src/modules/listing-ai/listing-ai.shared.scss
  7. 10 8
      src/modules/listing-ai/models/listing-ai.models.ts
  8. 15 35
      src/modules/listing-ai/pages/product/listing-ai-product.component.html
  9. 29 0
      src/modules/listing-ai/pages/product/listing-ai-product.component.spec.ts
  10. 7 16
      src/modules/listing-ai/pages/product/listing-ai-product.component.ts
  11. 1 1
      src/modules/listing-ai/pages/task-detail/listing-score-job-detail.component.html
  12. 1 1
      src/modules/listing-ai/pages/tasks/listing-score-job-list.component.html
  13. 8 8
      src/modules/listing-ai/pages/workbench/listing-ai-workbench.component.html
  14. 6 7
      src/modules/listing-ai/pages/workbench/listing-ai-workbench.component.ts
  15. 2 1
      src/modules/listing-ai/services/listing-ai-api.service.spec.ts
  16. 3 3
      src/modules/listing-ai/services/listing-ai-api.service.ts
  17. 253 0
      tools/generate-jd-api-permissions-doc.mjs
  18. 232 0
      tools/jd-api-permission-probe.mjs
  19. 155 0
      tools/jd-jos-api-permission-probe.mjs

+ 402 - 0
docs/JD-API-PERMISSIONS.md

@@ -0,0 +1,402 @@
+# 京东官方 API 接口权限实测清单
+
+> 测试日期:2026-08-24  
+> 配置来源:`docs/JD-CONNECTION-CONFIG.local.md`,店铺 Token 来源为 Parse `EcomAuth`。  
+> 官方清单来源:[京东 SP-API 升级说明](https://open.jd.com/v2/#/doc/api?apiCateId=200436&articleId=1100596&gwType=1)。
+
+## 1. 测试范围和结果
+
+| 接口体系 | 官方接口数 | 已实际请求 | 通过/返回方法响应 | 明确拒绝 | 其他错误 |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| SP-API | 138 | 138 | 9 | 57 | 72 |
+| JOS 1.0 | 108 个官方映射方法 + 1 个评价方法 | 109 | 46 | 33 | 30 |
+
+SP-API 的 138 个接口均已发起请求,包括 80 个 GET 和 58 个非 GET。非 GET 使用占位路径参数和空 JSON 请求体执行,没有提交有效业务数据。
+
+判定口径:
+
+- SP-API 返回 `success: true`:通过。
+- JOS 返回对应方法响应对象,且未返回网关权限错误:方法已响应。
+- `99904030008`、JOS `24/88`:明确无接口调用权限。
+- `99904030005`、JOS `73`:明确要求云鼎调用。
+- 参数错误或后端服务错误:本次未通过,不等同于已拥有完整业务调用能力。
+
+## 2. 未通过接口的申请条件和申请方式
+
+### 2.1 当前失败结果分别怎么处理
+
+| 实测结果 | 当前涉及范围 | 申请条件 | 申请入口与操作 |
+| --- | --- | --- | --- |
+| SP `99904030008`;JOS `24/88` | 商品、订单物流、售后、财务、会员、客服、商家、通用服务 | 当前应用类型和应用场景下,该接口权限包必须出现在应用概览页的“可申请”列表 | 开发者后台 → 应用中心 → 应用列表 → 当前应用 → 应用概览 → 权限包清单 → 申请;填写申请原因并提交 |
+| SP `99904030005`;JOS `73` | 订单、部分售后、财务以及其他敏感接口 | 应用审核完成;使用与开放平台店铺主账号一致的京东账号激活京东云并完成企业实名认证;完成云鼎入驻和应用部署 | 先完成接口权限包申请,再进入云鼎入驻页选择“零售”提交申请;云鼎审核通过后在云鼎环境部署并调用 |
+| SP `99904000013`、`99904000001` 及各业务参数错误码;JOS `61/400` | 本轮使用空请求体、占位 ID 或缺省参数的接口 | 这类响应是请求参数校验结果,本轮响应不能判定为缺少权限包 | 按具体接口文档补齐必填参数、有效业务 ID、场景字段和请求体后再次调用;不走权限包申请 |
+| HTTP 503、JOS `65/67` | 商品、商家、通用等接口 | 本轮返回业务后端或服务异常,没有形成权限拒绝结论 | 使用完整参数复测;仍返回服务异常时,通过开放平台工单提交接口、时间和 requestId |
+
+### 2.2 普通接口权限包
+
+京东官方把权限包分为“默认开通”和“可申请”:
+
+- 默认开通:应用创建后直接拥有。
+- 可申请:需要单独申请,运营审核通过后获得。
+
+申请步骤:
+
+1. 登录[京东开放平台开发者后台](https://open-console.jd.com/v2#/app/index)。
+2. 进入“应用中心 → 应用列表”,点击当前应用。
+3. 在应用概览页下方查看权限包清单。
+4. 在“可申请”权限包上点击“申请”。
+5. 填写申请原因并提交。
+6. 官方记录的审核周期为 1—3 个工作日。
+
+如果所需接口在“已获得”和“可申请”中都不存在,官方给出的处理方式是提交工单,或加入咚咚群 `10220332728` 咨询。
+
+当前 `99904030008` 的 SP-API 分布:
+
+| 数据域 | 接口数 |
+| --- | ---: |
+| 商品API | 15 |
+| 客服API | 8 |
+| 售后API | 7 |
+| 会员API | 6 |
+| 订单API | 4 |
+| 商家API | 3 |
+| 财务API | 1 |
+| 通用API | 1 |
+
+### 2.3 云鼎敏感接口
+
+官方云鼎入驻条件和流程:
+
+1. 先在京东开放平台创建并完成应用审核;官方云鼎文档注明,自研应用审核完成后再执行云鼎入驻。
+2. 与开放平台商家店铺主账号一致的京东账号,需要在京东云激活并完成企业实名认证。
+3. 使用该账号进入[云鼎入驻页](https://yd.jdcloud.com/#/enter),业务选择“零售”;官方记录的审核周期为 1—2 个工作日。
+4. 审核通过后,在云鼎应用管理平台完成应用部署。
+5. 按云鼎文档配置云资源;文档将对外提供 HTTP/HTTPS 的 Web 服务器、访问 JDO 敏感接口的应用服务器,以及存储敏感字段的云 RDS 列为基础部署资源。
+6. 从云鼎环境重新调用订单、售后、财务等返回 `99904030005` / JOS `73` 的接口。
+
+当前 `99904030005` 的 SP-API 分布:
+
+| 数据域 | 接口数 |
+| --- | ---: |
+| 财务API | 5 |
+| 订单API | 4 |
+| 售后API | 2 |
+| 通用API | 1 |
+
+### 2.4 自营供应链接口的特殊流程
+
+京东官方注明:自营供应链接口权限除在应用的 API 权限管理中申请权限包外,还需要使用核心产品线账号向主采销发起邮件;采销邮件审核通过后,平台才审核开通。该流程只适用于自营供应链接口。
+
+### 2.5 服务对象和店铺授权前置条件
+
+- 自研 POP 应用需要添加 POP 店铺账号作为服务对象;自营应用需要添加供应商账号。
+- 同一公司账号可自动审核;同一主体、同法人、同集团等关系需要提交关系证明,官方记录的人工审核时限为 1 个工作日。
+- 服务对象绑定成功后才能执行店铺授权并获得 Token。当前项目已经取得有效 Token,此项不是本次商品接口成功/失败差异的阻断点。
+
+### 2.6 官方依据
+
+- [接口权限包申请](https://open.jd.com/v2/#/doc/guide?listId=1911)
+- [开放平台新手指南](https://open.jd.com/v2/#/doc/begin-guide?listId=1900)
+- [自研与 ISV 授权 Token 获取](https://open.jd.com/v2/#/doc/dev-guide?listId=1100981)
+- [云鼎入驻流程](https://yd-doc.jdcloud.com/)
+
+## 3. 通过的接口
+
+### 3.1 SP-API:业务响应成功
+
+| 数据域 | 方法与路径 | 官方名称 | 结果 |
+| --- | --- | --- | --- |
+| 商品API | `GET /sp-product/v0/products` | 商品列表查询 | HTTP 200,`success: true` |
+| 商品API | `GET /sp-product/v0/skus` | SKU列表查询 | HTTP 200,`success: true` |
+| 商品API | `GET /sp-product/v0/products/{productId}` | 获取商品详情 | HTTP 200,`success: true` |
+| 商品API | `GET /sp-product/v0/violating-products` | 违规商品查询 | HTTP 200,`success: true` |
+| 商品API | `GET /sp-product/v0/sku-stocks` | 批量查询商品库存 | HTTP 200,`success: true` |
+| 商品API | `GET /sp-product/v0/limit-buy-strategies/{id}` | 获取限购策略 | HTTP 200,`success: true` |
+| 商品API | `GET /sp-product/v0/area-restriction-templates` | 查询商家下区域限制模板列表 | HTTP 200,`success: true` |
+| 商品API | `GET /sp-product/v0/limit-buy-strategies/{id}/products` | 查询限购策略绑定的商品列表 | HTTP 200,`success: true` |
+| 商品API | `GET /sp-product/v0/media-zones` | 查询媒体空间信息 | HTTP 200,`success: true` |
+
+### 3.2 JOS:方法已响应
+
+| 数据域 | JOS 方法 | 对应 SP-API | 结果 |
+| --- | --- | --- | --- |
+| 商品API | `jingdong.draft.commitDraft` | `POST /sp-product/v0/product-applies` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.draft.getLastestDraft` | `GET /sp-product/v0/product-applies` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.pop.draft.write.updateProduct` | `PATCH /sp-product/v0/product-applies/{applyId}` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.ware.read.searchWare4Valid` | `GET /sp-product/v0/products` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.new.ware.vender.skus.query` | `GET /sp-product/v0/skus` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.ware.audit.getWareAuditDetail` | `GET /sp-product/v0/violating-products/{productId}` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.ware.stock.sku.query` | `GET /sp-product/v0/sku-stocks` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.ware.stock.sku.set` | `PUT /sp-product/v0/sku-stocks/batch` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.ware.reserve.data.commit` | `POST /sp-product/v0/sku-bookings/batch` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.ware.reserve.data.list` | `GET /sp-product/v0/sku-bookings` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.imgzone.userinfo.query` | `GET /sp-product/v0/media-zones` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.imgzone.category.add` | `POST /sp-product/v0/media-zones/{zoneId}/categories` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.imgzone.category.query` | `GET /sp-product/v0/media-zones/{zoneId}/categories` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.imgzone.image.queryAll` | `GET /sp-product/v0/media-zones/{zoneId}/images` | HTTP 200,返回方法响应对象 |
+| 商品API | `jingdong.imgzone.picture.upload` | `POST /sp-product/v0/media-zones/{zoneId}/images` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.jos.order.oaid.merge` | `POST /sp-order/v0/merge-delivery-groups` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.pop.order.serialCode.upload` | `POST /sp-order/v0/orders/{orderId}/serial-codes` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.ldop.alpha.provider.query` | `GET /sp-order/v0/carriers` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.logistics.ewaybill.contract.query` | `GET /sp-order/v0/contract-carrier-outlets` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.com.jd.ldop.alpha.waybill.api.outageQuery` | `POST /sp-order/v0/waybills/validations` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.ldop.alpha.vendor.bigshot.query` | `GET /sp-order/v0/sorting-codes` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.logistics.ewaybill.waybill.cancel` | `PATCH /sp-order/v0/waybills/{waybillCode}` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.ldop.middle.waybill.Waybill2CTraceApi` | `GET /sp-order/v0/waybill-traces` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.pop.order.shipment` | `POST /sp-order/v0/orders/{orderId}/shipments` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.pop.order.modifyVenderRemark` | `PUT /sp-order/v0/orders/{orderId}/vender-remarks` | HTTP 200,返回方法响应对象 |
+| 订单API | `jingdong.pop.oto.CheckNumber.consumer` | `PUT /sp-order/v0/orders/{orderId}/verification-codes` | HTTP 200,返回方法响应对象 |
+| 售后API | `jingdong.pop.afs.soa.refundapply.replyRefund` | `PATCH /sp-aftercare/v0/afs-orders/{afsOrderId}` | HTTP 200,返回方法响应对象 |
+| 售后API | `jingdong.pop.afs.soa.compensate.queryCompensateList` | `GET /sp-aftercare/v0/compensation-forms` | HTTP 200,返回方法响应对象 |
+| 售后API | `jingdong.pop.afs.priceprotect.page` | `GET /sp-aftercare/v0/price-protect-orders` | HTTP 200,返回方法响应对象 |
+| 售后API | `jingdong.pop.afs.priceprotect.detail` | `GET /sp-aftercare/v0/price-protect-orders/{priceProtectOrderId}` | HTTP 200,返回方法响应对象 |
+| 售后API | `jingdong.vcrma.directreturn.salesreturnorder.query` | `GET /sp-aftercare/v0/sales-return-orders` | HTTP 200,返回方法响应对象 |
+| 售后API | `jingdong.vcrma.directreturn.executeorder.callWaybillByVc` | `POST /sp-aftercare/v0/sales-return-orders/{sroId}/waybill-orders/batch` | HTTP 200,返回方法响应对象 |
+| 财务API | `jingdong.pop.invoice.self.query` | `GET /sp-finance/v0/invoices` | HTTP 200,返回方法响应对象 |
+| 财务API | `jingdong.pop.invoice.self.apply` | `POST /sp-finance/v0/invoices` | HTTP 200,返回方法响应对象 |
+| 财务API | `jingdong.pop.invoice.self.red` | `PUT /sp-finance/v0/invoices/{invoiceNo}` | HTTP 200,返回方法响应对象 |
+| 客服API | `jingdong.im.pop.groupinfo.get` | `GET /sp-support/v0/dongdong-waiters` | HTTP 200,返回方法响应对象 |
+| 商家API | `jingdong.vender.info.queryByPin` | `GET /sp-seller/v0/venders` | HTTP 200,返回方法响应对象 |
+| 商家API | `jingdong.seller.vender.info.get` | `GET /sp-seller/v0/venderinfo` | HTTP 200,返回方法响应对象 |
+| 商家API | `jingdong.seller.qua.center.CategoryOperateIsvFacade.queryOperateCategoryTree` | `GET /sp-seller/v0/vender-categories` | HTTP 200,返回方法响应对象 |
+| 商家API | `jingdong.seller.qua.center.BrandOperateFacade.queryOperateBrand` | `GET /sp-seller/v0/vender-brands` | HTTP 200,返回方法响应对象 |
+| 商家API | `jingdong.seller.qua.center.BrandQuaFacade.checkBrandCategory` | `POST /sp-seller/v0/brand-categories/validations` | HTTP 200,返回方法响应对象 |
+| 通用API | `jingdong.area.province.get` | `GET /sp-address/v0/areas` | HTTP 200,返回方法响应对象 |
+| 通用API | `jingdong.jos.voucher.info.get` | `GET /open-security/v0/credentials` | HTTP 200,返回方法响应对象 |
+| 通用API | `jingdong.jos.master.key.get` | `POST /open-security/v0/secret-keys` | HTTP 200,返回方法响应对象 |
+| 通用API | `jingdong.pop.fw.order.listwithpage` | `GET /open-service/v0/fwmarket-orders` | HTTP 200,返回方法响应对象 |
+| 商品评价 | `jingdong.pop.PopCommentJsfService.getVenderCommentsForJos` | 商品评价旧接口 | HTTP 200,返回方法响应对象 |
+
+## 4. 未通过的 SP-API
+
+### 4.1 明确被京东网关拒绝
+
+| 数据域 | 方法与路径 | 官方名称 | 实测结果 |
+| --- | --- | --- | --- |
+| 商品API | `GET /sp-product/v0/price-stars` | 查询商品价格星级 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `DELETE /sp-product/v0/media-zones/{zoneId}/categories/{categoryId}` | 删除空间分类 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `GET /sp-product/v0/media-zones/{zoneId}/images` | 查询图片空间图片列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `GET /sp-product/v0/media-zones/{zoneId}/images/{imageId}` | 获取图片空间图片信息 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `DELETE /sp-product/v0/media-zones/{zoneId}/images/batch` | 批量删除图片信息 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `GET /sp-product/v0/media-zones/{zoneId}/upload-urls` | 获取上传视频地址 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `GET /sp-product/v0/media-zones/{zoneId}/videos` | 查询视频列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `DELETE /sp-product/v0/media-zones/{zoneId}/videos/batch` | 批量删除视频信息 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `GET /sp-product/v0/sku-materials` | 获取SKU素材 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `GET /sp-product/v0/sku-material-tags` | 获取素材标签信息 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `DELETE /sp-product/v0/sku-materials/batch` | 删除解绑SKU素材信息 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `GET /sp-product/v0/material-experiment-rules` | 获取SKU实验规则信息 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `GET /sp-product/v0/material-experiments/{experimentId}` | 查询实验详情信息 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `DELETE /sp-product/v0/material-experiments/{experimentId}` | 删除AB实验 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商品API | `GET /sp-product/v0/material-experiments` | 查询素材AB实验列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 订单API | `GET /sp-order/v0/orders` | 订单列表查询 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 订单API | `GET /sp-order/v0/orders/{orderId}` | 订单详情查询 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 订单API | `GET /sp-order/v0/order-oaid-addresses` | 批量解密订单收件人信息 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 订单API | `GET /sp-order/v0/carriers` | 获取全量承运商 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 订单API | `GET /sp-order/v0/contract-carrier-outlets` | 获取签约网点信息 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 订单API | `GET /sp-order/v0/sorting-codes` | 查询运单大头笔信息 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 订单API | `GET /sp-order/v0/waybill-traces` | 物流轨迹查询 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 订单API | `GET /sp-order/v0/orders/{orderId}/verification-codes` | 获取核销码信息 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 售后API | `GET /sp-aftercare/v0/afs-orders` | 分页查询售后服务单列表 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 售后API | `GET /sp-aftercare/v0/afs-orders/{afsOrderId}` | 查询售后服务单详情 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 售后API | `GET /sp-aftercare/v0/afs-orders/{afsOrderId}/logs` | 查询服务单日志列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 售后API | `GET /sp-aftercare/v0/payment-forms` | 分页查询小额打款列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 售后API | `GET /sp-aftercare/v0/compensation-forms` | 分页查询赔付单记录 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 售后API | `GET /sp-aftercare/v0/price-protect-orders` | 分页查询价保服务单列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 售后API | `GET /sp-aftercare/v0/price-protect-orders/{priceProtectOrderId}` | 查询价保详情 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 售后API | `GET /sp-aftercare/v0/sales-return-orders` | 销退单列表查询 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 售后API | `GET /sp-aftercare/v0/sales-return-orders/{salesReturnOrderId}` | 销退单详情查询 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 财务API | `GET /sp-finance/v0/accounts/{memberId}/records` | 查询钱包流水明细 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 财务API | `GET /sp-finance/v0/bills` | 查询商家账单明细 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 财务API | `GET /sp-finance/v0/invoice-applications` | 查询发票申请列表 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 财务API | `GET /sp-finance/v0/invoices` | 自开电子发票分页查询接口 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 财务API | `GET /sp-finance/v0/rebate-bills` | 自营财务返利查询 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 财务API | `GET /sp-finance/v0/settlement-bills` | 查询结算单列表 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 会员API | `GET /sp-marketing/v0/member-systems/{memberSystemId}` | 获取会员体系详情 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 会员API | `GET /sp-marketing/v0/member-systems` | 查询会员体系列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 会员API | `GET /sp-marketing/v0/vender-member-rights` | 查询商家会员权益列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 会员API | `GET /sp-marketing/v0/member-agg-statistics` | 查询会员详情及统计数据列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 会员API | `GET /sp-marketing/v0/members` | 查询会员列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 会员API | `GET /sp-marketing/v0/omids` | XID和会员ID批量互转接口 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 客服API | `GET /sp-support/v0/dongdong-chat-details` | 获取咚咚会话详情 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 客服API | `GET /sp-support/v0/dongdong-sessions/{sid}/chat-logs` | 查询会话下的聊天记录 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 客服API | `GET /sp-support/v0/dongdong-sessions` | 获取咚咚会话列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 客服API | `GET /sp-support/v0/dongdong-waiter-evaluations` | 查询客服评价列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 客服API | `GET /sp-support/v0/dongdong-waiter-performance` | 查询客服绩效列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 客服API | `GET /sp-support/v0/dongdong-waiters` | 查询商家客服列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 客服API | `GET /sp-support/v0/chatbot-performance/{robotCode}` | 获取聊天机器人绩效数据 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 客服API | `GET /sp-support/v0/chatbots/{robotCode}/evaluations` | 查询机器人评价明细 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商家API | `GET /sp-seller/v0/venders` | 分页查询商家基本信息列表 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商家API | `GET /sp-seller/v0/venderinfo` | 获取商家基本信息 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 商家API | `GET /sp-seller/v0/venders/self/addresses` | 获取商家地址 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 通用API | `GET /open-service/v0/fwmarket-orders` | 查询服务市场订单 | HTTP 403,`99904030005`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+| 通用API | `GET /open-service/v0/fwmarket-purchase-records` | 查询服务市场订购记录 | HTTP 403,`99904030008`,禁止访问资源。可能的原因包括拒绝访问、未经授权、令牌过期或签名无效。 |
+
+### 4.2 参数或服务错误,本次未成功
+
+| 数据域 | 方法与路径 | 官方名称 | 实测结果 |
+| --- | --- | --- | --- |
+| 商品API | `GET /sp-product/v0/product-schemas` | 根据类目获取商品发布模板列表 | HTTP 503,`10105001002`,入参对象或者类目Id为null |
+| 商品API | `GET /sp-product/v0/category-rule-templates/{templateId}/components` | 组件列表类目规则模版查询 | HTTP 503,`500`,系统异常 |
+| 商品API | `POST /sp-product/v0/product-applies` | 提交商品创建或者整版编辑申请 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `GET /sp-product/v0/product-apply-demos` | 查询商品schema发品样例 | HTTP 503,`10112001002`,参数错误:productName不能为空 |
+| 商品API | `GET /sp-product/v0/product-applies` | 查询商品申请列表 | HTTP 503,`10103004000`,参数错误!返回字段必填 |
+| 商品API | `GET /sp-product/v0/product-apply/{applyId}` | 查询申请单详情 | HTTP 503,`10110531002`,ProductDetailRequest.scene is empty |
+| 商品API | `PATCH /sp-product/v0/product-applies/{applyId}` | 提交商品申请单部分更新 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `PUT /sp-product/v0/product-states/batch` | 批量编辑商品状态 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `PUT /sp-product/v0/sku-states/batch` | 批量编辑sku状态 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `POST /sp-product/v0/product-schemas/validations` | 商品信息合规检测 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `GET /sp-product/v0/violating-products/{productId}` | 获取商品审核详情 | HTTP 503,`10161011001`,查询场景不能为空 |
+| 商品API | `PUT /sp-product/v0/sku-stocks/batch` | 批量设置商品库存 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `DELETE /sp-product/v0/area-restriction-templates/{templateId}/products/batch` | 删除区域限制模板上绑定的商品 | HTTP 503,`10106004000`,禁售规则禁售id为空 |
+| 商品API | `DELETE /sp-product/v0/limit-buy-strategies/{id}/products/batch` | 批量把商品与限购策略解绑 | HTTP 503,`10107011002`,参数错误:request.productList is null |
+| 商品API | `POST /sp-product/v0/area-restriction-templates/{templateId}/products/batch` | 店铺或商品批量绑定区域限制模板 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `POST /sp-product/v0/limit-buy-strategies/{id}/products/batch` | 批量绑定商品到限购策略 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `POST /sp-product/v0/sku-bookings/batch` | 批量设置商品预定 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `DELETE /sp-product/v0/area-restriction-templates/{templateId}` | 删除区域限制模板 | HTTP 503,`10106004000`,禁售规则禁售id为空 |
+| 商品API | `DELETE /sp-product/v0/limit-buy-strategies/{id}` | 删除限购策略 | HTTP 503,`10107004000`,策略Id不合法 |
+| 商品API | `GET /sp-product/v0/area-restriction-templates/{templateId}` | 获取区域限制模板详情 | HTTP 503,`10106004000`,禁售查询入参参数错误 |
+| 商品API | `GET /sp-product/v0/area-restriction-templates/{templateId}/products` | 查询区域限制模板上绑定的商品列表 | HTTP 503,`10106004000`,禁售查询入参参数错误 |
+| 商品API | `GET /sp-pro/v0/limit-buy-strategies` | 查询限购策略列表 | HTTP 400,`99904000001`,请求参数缺失或无效 |
+| 商品API | `GET /sp-product/v0/sku-bookings` | 批量查询商品预定 | HTTP 503,`10111001002`,商品预定列表为空,请至少传入一个商品ID |
+| 商品API | `POST /sp-product/v0/area-restriction-templates` | 发布区域限制模板 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `POST /sp-product/v0/limit-buy-strategies` | 发布限购策略 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `PUT /sp-product/v0/area-restriction-templates/{templateId}` | 修改区域限制模板 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `PATCH /sp-product/v0/product-applies-unbind/{productId}` | 商品归堆解绑提交申请单 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `GET /sp-order/v0/suggested-prices` | 查询商品建议价格 | HTTP 400,`99904000001`,请求参数缺失或无效 |
+| 商品API | `POST /sp-product/v0/media-zones/{zoneId}/categories` | 创建空间分类信息 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `GET /sp-product/v0/media-zones/{zoneId}/categories` | 查询空间分类信息 | HTTP 503,`10108011002`,图片空间ID为空或不合法的图片空间ID |
+| 商品API | `PATCH /sp-product/v0/media-zones/{zoneId}/categories/{categoryId}` | 更新空间分类信息 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `POST /sp-product/v0/media-zones/{zoneId}/images` | 上传保存图片 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `PATCH /sp-product/v0/media-zones/{zoneId}/images/{imageId}` | 更新图片空间图片信息 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `POST /sp-product/v0/media-zones/{zoneId}/videos` | 保存视频信息 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `PATCH /sp-product/v0/media-zones/{zoneId}/videos/{videoId}` | 更新视频信息 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `POST /sp-product/v0/sku-materials/batch` | 绑定SKU素材信息 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `POST /sp-product/v0/material-experiments` | 创建ab实验 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `PATCH /sp-product/v0/material-experiments/{experimentId}` | 更新ab实验 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商品API | `PUT /sp-product/v0/product-shop-categories/batch` | 更新商品店内分类 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `POST /sp-order/v0/merge-delivery-groups` | 合单条件校验 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `POST /sp-order/v0/orders/{orderId}/serial-codes` | 国补订单序列号上传 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `POST /sp-order/v0/waybills` | 获取运单号 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `POST /sp-order/v0/waybills/validations` | 下运单前置校验 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `PATCH /sp-order/v0/waybills/{waybillCode}` | 更新运单 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `POST /sp-order/v0/orders/{orderId}/shipments` | 订单发货信息回传 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `PUT /sp-order/v0/orders/{orderId}/shipments` | 更新发货物流信息 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `POST /sp-order/v0/orders/{orderId}/child-orders` | 订单拆分 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `PUT /sp-order/v0/orders/{orderId}/consignees` | 修改订单收货地址 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `PUT /sp-order/v0/orders/{orderId}/vender-remarks` | 添加订单备注 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `POST /sp-order/v0/orders/{orderId}/verification-codes` | 回传核销码 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 订单API | `PUT /sp-order/v0/orders/{orderId}/verification-codes` | 消费核销码 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 售后API | `PATCH /sp-aftercare/v0/afs-orders/{afsOrderId}` | 售后服务单操作 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 售后API | `POST /sp-aftercare/v0/afs-orders/{afsOrderId}/notes` | 保存服务单备注 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 售后API | `PATCH /sp-aftercare/v0/sales-return-orders/{salesReturnOrderId}` | 销退单确认收货/提报异常 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 售后API | `POST /sp-aftercare/v0/sales-return-orders/{sroId}/waybill-orders/batch` | 销退单原返(呼叫运力) | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 财务API | `POST /sp-finance/v0/invoices` | 自开电子发票蓝票上传 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 财务API | `PUT /sp-finance/v0/invoices/{invoiceNo}` | 自开电子发票红票上传 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 财务API | `PATCH /sp-finance/v0/settlement-bills/batch` | 自营结算单确认 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 会员API | `DELETE /sp-marketing/v0/members/{omid}` | 解绑会籍 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 会员API | `GET /sp-marketing/v0/members/{omid}` | 查询会员详情 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 会员API | `GET /sp-marketing/v0/member-agg-statistics/{omid}` | 查询会员详情及统计信息 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 会员API | `PATCH /sp-marketing/v0/members/{omid}` | 更新会员信息 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 会员API | `GET /sp-marketing/v0/members/{omid}/points` | 查询会员积分变更列表 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 会员API | `POST /sp-marketing/v0/members/{omid}/points` | 变更会员积分 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 客服API | `POST /sp-support/v0/dongdong-messages` | 发送京东客服消息 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 客服API | `POST /sp-support/v0/dongdong-notices` | 发送客服通知 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 商家API | `GET /sp-seller/v0/vender-categories` | 获取商家经营类目 | HTTP 503,`10402001001`,查询场景不能为空 |
+| 商家API | `GET /sp-seller/v0/vender-brands` | 获取商家经营品牌 | HTTP 503,`10403001001`,查询类型不能为空 |
+| 商家API | `POST /sp-seller/v0/brand-categories/validations` | 批量校验品牌类目发品权限 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+| 通用API | `GET /sp-address/v0/areas` | 获取京标四级地域信息 | HTTP 503,`11101001002`,请求参数:listAreasRequest.parentId 不正确 |
+| 通用API | `GET /open-security/v0/credentials` | 获取凭证 | HTTP 503,`11502000001`,accessToken or userId is null. |
+| 通用API | `POST /open-security/v0/secret-keys` | 生成密钥 | HTTP 400,`99904000013`,请求缺少参数或参数无效,无法解析。 |
+
+## 5. 未通过的 JOS 接口
+
+### 5.1 明确无权限或要求云鼎
+
+| 数据域 | JOS 方法 | 对应 SP-API | 实测结果 |
+| --- | --- | --- | --- |
+| 商品API | `jingdong.ware.violationWares` | `GET /sp-product/v0/violating-products` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.ware.violationWares(解决方案参考…) |
+| 商品API | `jingdong.grow.ware.price.star.query` | `GET /sp-product/v0/price-stars` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 商品API | `jingdong.grow.ware.price.suggest.query` | `GET /sp-order/v0/suggested-prices` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 商品API | `jingdong.house.uploadVideo` | `GET /sp-product/v0/media-zones/{zoneId}/upload-urls` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 商品API | `jingdong.pop.video.info.save` | `POST /sp-product/v0/media-zones/{zoneId}/videos` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.pop.video.info.save(解决方案参考…) |
+| 商品API | `jingdong.ab.experiment.save` | `POST /sp-product/v0/material-experiments` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.ab.experiment.save(解决方案参考:…) |
+| 商品API | `jingdong.ab.experiment.get` | `GET /sp-product/v0/material-experiments/{experimentId}` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.ab.experiment.get(解决方案参考: …) |
+| 商品API | `jingdong.ab.experiment.status.update` | `PATCH /sp-product/v0/material-experiments/{experimentId}` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.ab.experiment.status.updat…) |
+| 商品API | `jingdong.ab.experimentInfo.query` | `GET /sp-product/v0/material-experiments` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.ab.experimentInfo.query(解决…) |
+| 订单API | `jingdong.pop.order.search` | `GET /sp-order/v0/orders` | 要求云鼎调用(HTTP 200,`73`,该api是敏感api,请将您的app入驻云鼎平台方可调用,ip:<redacted>(解决方案参考: https://open.…) |
+| 订单API | `jingdong.pop.order.get` | `GET /sp-order/v0/orders/{orderId}` | 要求云鼎调用(HTTP 200,`73`,该api是敏感api,请将您的app入驻云鼎平台方可调用,ip:<redacted>(解决方案参考: https://open.…) |
+| 订单API | `jingdong.jos.order.oaid.decrypt` | `GET /sp-order/v0/order-oaid-addresses` | 要求云鼎调用(HTTP 200,`73`,该api是敏感api,请将您的app入驻云鼎平台方可调用,ip:<redacted>(解决方案参考: https://open.…) |
+| 订单API | `jingdong.pop.oto.locorderinfo.get` | `GET /sp-order/v0/orders/{orderId}/verification-codes` | 要求云鼎调用(HTTP 200,`73`,该api是敏感api,请将您的app入驻云鼎平台方可调用,ip:<redacted>(解决方案参考: https://open.…) |
+| 售后API | `jingdong.b2c.shop.aftersales.page` | `GET /sp-aftercare/v0/afs-orders` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.b2c.shop.aftersales.page(解…) |
+| 售后API | `jingdong.b2c.shop.aftersales.get` | `GET /sp-aftercare/v0/afs-orders/{afsOrderId}` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.b2c.shop.aftersales.get(解决…) |
+| 售后API | `jingdong.b2c.shop.aftersale.indemnity.query` | `GET /sp-aftercare/v0/payment-forms` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 售后API | `jingdong.retail.shop.afs.servicebill.abnormal.report.submit` | `PATCH /sp-aftercare/v0/sales-return-orders/{salesReturnOrderId}` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.retail.shop.afs.servicebil…) |
+| 财务API | `jingdong.pop.ledger.bill.queryJdpayDetail` | `GET /sp-finance/v0/accounts/{memberId}/records` | 要求云鼎调用(HTTP 200,`73`,该api是敏感api,请将您的app入驻云鼎平台方可调用,ip:<redacted>(解决方案参考: https://open.…) |
+| 财务API | `jingdong.pop.ledger.bill.queryBillDetail` | `GET /sp-finance/v0/bills` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.pop.ledger.bill.queryBillD…) |
+| 财务API | `jingdong.shop.cinvoice.apply.order.list` | `GET /sp-finance/v0/invoice-applications` | 要求云鼎调用(HTTP 200,`73`,该api是敏感api,请将您的app入驻云鼎平台方可调用,ip:<redacted>(解决方案参考: https://open.…) |
+| 会员API | `jingdong.crm.unbindCustomer` | `DELETE /sp-marketing/v0/members/{omid}` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 会员API | `jingdong.pop.crm.customer.getCustomer` | `GET /sp-marketing/v0/members/{omid}` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 会员API | `jingdong.pop.vender.getVenderStatus` | `GET /sp-marketing/v0/member-systems/{memberSystemId}` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 会员API | `jingdong.pop.brand.client.queryBrandsIdByVenderId` | `GET /sp-marketing/v0/member-systems` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 会员API | `jingdong.pop.crm.getMemeberDiscount` | `GET /sp-marketing/v0/vender-member-rights` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 会员API | `jingdong.crm.gateway.downclient.jdSyncQuery` | `GET /sp-marketing/v0/members` | 要求云鼎调用(HTTP 200,`73`,该api是敏感api,请将您的app入驻云鼎平台方可调用,ip:<redacted>(解决方案参考: https://open.…) |
+| 会员API | `jingdong.jos.openid.getPointDetail` | `GET /sp-marketing/v0/members/{omid}/points` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 会员API | `jingdong.jos.openid.sendPoints` | `POST /sp-marketing/v0/members/{omid}/points` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 客服API | `jingdong.im.pop.chatlog.openapi.get` | `GET /sp-support/v0/dongdong-sessions/{sid}/chat-logs` | 无接口调用权限(HTTP 200,`88`,该appKey=<redacted>无权调用method=jingdong.im.pop.chatlog.openapi.get…) |
+| 客服API | `jingdong.dd.notice.NoticeDispatcher` | `POST /sp-support/v0/dongdong-notices` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 客服API | `jingdong.com.jd.im.datacenter.api.isv.IsvImChatLogService.queryEvaluationsStat` | `GET /sp-support/v0/chatbot-performance/{robotCode}` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 客服API | `jingdong.com.jd.im.datacenter.api.isv.IsvImChatLogService.queryEvaluationsDetailList` | `GET /sp-support/v0/chatbots/{robotCode}/evaluations` | 无接口调用权限(HTTP 200,`24`,该appKey=<redacted>无权调用(解决方案参考: https://open.jd.com/v2/#/doc/guid…) |
+| 商家API | `jingdong.vender.returnaddress.query` | `GET /sp-seller/v0/venders/self/addresses` | 要求云鼎调用(HTTP 200,`73`,该api是敏感api,请将您的app入驻云鼎平台方可调用,ip:<redacted>(解决方案参考: https://open.…) |
+
+### 5.2 参数或服务错误,本次未成功
+
+| 数据域 | JOS 方法 | 对应 SP-API | 实测结果 |
+| --- | --- | --- | --- |
+| 商品API | `jingdong.category.read.findValuesByAttrIdUnlimit` | `GET /sp-product/v0/product-schemas` | 请求参数缺失(HTTP 200,`400`,入参categoryAttrId不可为空#69365c0d9e1148b988fb70990a6a6675(解决方案参考: ht…) |
+| 商品API | `jingdong.vc.item.product.apply.get` | `GET /sp-product/v0/product-apply/{applyId}` | 请求参数缺失(HTTP 200,`61`,applyId-The parameter cannot be empty. Please fill in the parame…) |
+| 商品API | `jingdong.ware.write.upOrDown` | `PUT /sp-product/v0/product-states/batch` | 后端服务异常(HTTP 200,`65`,平台连接后端服务超时(解决方案参考: https://open.jd.com/v2/#/doc/guide?listId=533…) |
+| 商品API | `jingdong.sku.write.deleteSku` | `PUT /sp-product/v0/sku-states/batch` | 后端服务异常(HTTP 200,`65`,平台连接后端服务超时(解决方案参考: https://open.jd.com/v2/#/doc/guide?listId=533…) |
+| 商品API | `jingdong.ware.read.findWareById` | `GET /sp-product/v0/products/{productId}` | 后端服务异常(HTTP 200,`65`,平台连接后端服务超时(解决方案参考: https://open.jd.com/v2/#/doc/guide?listId=533…) |
+| 商品API | `jingdong.pop.ware.preDetect` | `POST /sp-product/v0/product-schemas/validations` | 后端服务异常(HTTP 200,`67`,java.lang.NullPointerException(解决方案参考: https://open.jd.com/v2/#/…) |
+| 商品API | `jingdong.imgzone.category.update` | `PATCH /sp-product/v0/media-zones/{zoneId}/categories/{categoryId}` | 请求参数缺失(HTTP 200,`61`,cate_id-The parameter cannot be empty. Please fill in the parame…) |
+| 商品API | `jingdong.imgzone.picture.update` | `PATCH /sp-product/v0/media-zones/{zoneId}/images/{imageId}` | 请求参数缺失(HTTP 200,`61`,picture_id-The parameter cannot be empty. Please fill in the par…) |
+| 商品API | `jingdong.imgzone.picture.delete` | `DELETE /sp-product/v0/media-zones/{zoneId}/images/batch` | 请求参数缺失(HTTP 200,`61`,picture_ids-The parameter cannot be empty. Please fill in the pa…) |
+| 商品API | `jingdong.pop.video.info.query` | `GET /sp-product/v0/media-zones/{zoneId}/videos` | 请求参数缺失(HTTP 200,`61`,page_index-The parameter cannot be empty. Please fill in the par…) |
+| 商品API | `jingdong.pop.video.info.update` | `PATCH /sp-product/v0/media-zones/{zoneId}/videos/{videoId}` | 后端服务异常(HTTP 200,`67`,网关依赖服务异常, 请提交工单联系平台解决(解决方案参考: https://open.jd.com/v2/#/doc/guide…) |
+| 商品API | `jingdong.pop.video.infos.delete` | `DELETE /sp-product/v0/media-zones/{zoneId}/videos/batch` | 后端服务异常(HTTP 200,`67`,网关依赖服务异常, 请提交工单联系平台解决(解决方案参考: https://open.jd.com/v2/#/doc/guide…) |
+| 商品API | `jingdong.pop.video.skus.relative.insert` | `POST /sp-product/v0/sku-materials/batch` | 后端服务异常(HTTP 200,`67`,网关依赖服务异常, 请提交工单联系平台解决(解决方案参考: https://open.jd.com/v2/#/doc/guide…) |
+| 商品API | `jingdong.pop.video.sku.relative.by.sku.ids.query` | `GET /sp-product/v0/sku-materials` | 后端服务异常(HTTP 200,`67`,网关依赖服务异常, 请提交工单联系平台解决(解决方案参考: https://open.jd.com/v2/#/doc/guide…) |
+| 商品API | `jingdong.pop.video.skus.relative.delete` | `DELETE /sp-product/v0/sku-materials/batch` | 后端服务异常(HTTP 200,`67`,网关依赖服务异常, 请提交工单联系平台解决(解决方案参考: https://open.jd.com/v2/#/doc/guide…) |
+| 商品API | `jingdong.shopcategories.write.saveWareShopCategories` | `PUT /sp-product/v0/product-shop-categories/batch` | 后端服务异常(HTTP 200,`65`,平台连接后端服务超时(解决方案参考: https://open.jd.com/v2/#/doc/guide?listId=533…) |
+| 商品API | `jingdong.price.write.updateSkuJdPrice` | `PATCH /sp-product/v0/product-applies/{applyId}` | 后端服务异常(HTTP 200,`65`,平台连接后端服务超时(解决方案参考: https://open.jd.com/v2/#/doc/guide?listId=533…) |
+| 订单API | `jingdong.ldop.alpha.waybill.receive` | `POST /sp-order/v0/waybills` | 返回请求错误(HTTP 200) |
+| 订单API | `jingdong.pop.order.sop.logistics.update` | `PUT /sp-order/v0/orders/{orderId}/shipments` | 后端服务异常(HTTP 200,`67`,com.jd.pop.order.api.models.exception.BizException: uuid:f36d5cc…) |
+| 订单API | `jingdong.pop.order.orderSplitCommitXmlApi` | `POST /sp-order/v0/orders/{orderId}/child-orders` | 后端服务异常(HTTP 200,`67`,com.jd.pop.order.api.models.exception.BizException: uuid:705c03c…) |
+| 订单API | `jingdong.pop.order.modifyOrderAddr` | `PUT /sp-order/v0/orders/{orderId}/consignees` | 后端服务异常(HTTP 200,`67`,网关依赖服务异常, 请提交工单联系平台解决(解决方案参考: https://open.jd.com/v2/#/doc/guide…) |
+| 订单API | `jingdong.pop.oto.checkNumbers.upload` | `POST /sp-order/v0/orders/{orderId}/verification-codes` | 后端服务异常(HTTP 200,`67`,网关依赖服务异常, 请提交工单联系平台解决(解决方案参考: https://open.jd.com/v2/#/doc/guide…) |
+| 售后API | `jingdong.asc.log.list` | `GET /sp-aftercare/v0/afs-orders/{afsOrderId}/logs` | 请求参数缺失(HTTP 200,`61`,buId-The parameter cannot be empty. Please fill in the parameter…) |
+| 售后API | `jingdong.asc.track.save` | `POST /sp-aftercare/v0/afs-orders/{afsOrderId}/notes` | 请求参数缺失(HTTP 200,`61`,buId-The parameter cannot be empty. Please fill in the parameter…) |
+| 客服API | `jingdong.im.pop.chatlog.get` | `GET /sp-support/v0/dongdong-chat-details` | 请求参数缺失(HTTP 200,`61`,customer-The parameter cannot be empty. Please fill in the param…) |
+| 客服API | `jingdong.im.pop.sessionlist.get` | `GET /sp-support/v0/dongdong-sessions` | 请求参数缺失(HTTP 200,`61`,startTime-The parameter cannot be empty. Please fill in the para…) |
+| 客服API | `jingdong.im.pop.evaluationlist.get` | `GET /sp-support/v0/dongdong-waiter-evaluations` | 请求参数缺失(HTTP 200,`61`,startTime-The parameter cannot be empty. Please fill in the para…) |
+| 客服API | `jingdong.im.pop.consult.avgwaittime.get` | `GET /sp-support/v0/dongdong-waiter-performance` | 请求参数缺失(HTTP 200,`61`,date-The parameter cannot be empty. Please fill in the parameter…) |
+| 客服API | `jingdong.dongdong.packet.josChatMessage.push2` | `POST /sp-support/v0/dongdong-messages` | 后端服务异常(HTTP 200,`67`,网关依赖服务异常, 请提交工单联系平台解决(解决方案参考: https://open.jd.com/v2/#/doc/guide…) |
+| 通用API | `jingdong.vas.subscribe.getByCode` | `GET /open-service/v0/fwmarket-purchase-records` | 返回请求错误(HTTP 200) |
+
+## 6. 项目内已经实现的京东调用
+
+| 项目位置 | 京东接口 | 用途 |
+| --- | --- | --- |
+| `tools/jd-import-products.mjs` | `GET /sp-product/v0/products` | 商品分页读取 |
+| `tools/jd-import-products.mjs` | `GET /sp-product/v0/skus` | SKU 读取 |
+| `tools/jd-import-products.mjs` | `GET /sp-product/v0/sku-stocks` | SKU 库存读取 |
+| `tools/jd-import-products.mjs` | `GET /sp-product/v0/products/{productId}` | 商品详情读取 |
+| `Saas-voc-server/scripts/sync-jd-listings.ts` | 商品列表、商品详情 | Listing 全量同步 |
+
+## 7. 测试文件
+
+- `logs/jd-official-upgrade-tables-2026-08-24.json`
+- `logs/jd-sp-api-probe-2026-08-24.json`
+- `logs/jd-jos-api-probe-2026-08-24.json`
+- `tools/jd-api-permission-probe.mjs`
+- `tools/jd-jos-api-permission-probe.mjs`

+ 9 - 57
src/modules/listing-ai/components/listing-dimension-panel/listing-dimension-panel.component.ts

@@ -1,63 +1,15 @@
 import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
 import { CommonModule } from '@angular/common';
-import type { ListingDimensionScore } from '../../models/listing-ai.models';
+import type { ListingScorePresentationDimension } 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}
-  `],
+  selector:'app-listing-dimension-panel',
+  standalone:true,
+  imports:[CommonModule],
+  changeDetection:ChangeDetectionStrategy.OnPush,
+  template:`@if(score){<section class="panel"><header><div><span>本项得分</span><strong>{{score.scoreText}}</strong></div><mark>{{score.conclusion}}</mark></header>@if(score.key==='images'){<p class="vision-note">目前只检查图片资产是否完整,不评价图片的构图、清晰度和内容质量。</p>}<h3>评分明细 <small>共 {{score.items.length}} 项</small></h3><div class="evidence">@for(item of score.items;track item.title){<article [class.pass]="item.resultLabel==='表现良好'||item.resultLabel==='表现优秀'" [class.warning]="item.resultLabel==='有待优化'" [class.fail]="item.resultLabel==='未达标'"><b>{{item.resultLabel}}</b><div><strong>{{item.title}}</strong><span>{{item.reason}}</span>@if(item.sources.length){<small>评分依据:{{item.sources.join('、')}}</small>}@if(item.action){<p>优化建议:{{item.action}}</p>}</div><em>{{item.impactText}}</em></article>}</div></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:24px;color:#245fda}.panel mark{margin-left:auto;border-radius:20px;padding:5px 10px;background:#eef3ff;color:#2f64cd;white-space:nowrap}.vision-note{padding:10px 12px;border-radius:8px;background:#fff7df;color:#7b5b0b;font-size:12px}.panel h3{display:flex;align-items:center;gap:8px;font-size:14px;margin:18px 0 10px}.panel h3 small{font-size:12px;font-weight:500;color:#8993a3}.evidence{display:grid;gap:10px}.evidence article{display:grid;grid-template-columns:66px minmax(0,1fr) 78px;gap:12px;align-items:center;padding:13px;border:1px solid #e5e9f1;border-radius:10px;background:#fbfcfe}.evidence article.fail{border-color:#ffd0cc;background:#fff8f7}.evidence article.warning{border-color:#f4ddb0;background:#fffbf2}.evidence article.pass{border-color:#cdebdc;background:#f8fffb}.evidence article>b{font-size:12px;color:#758095;white-space:nowrap}.evidence article.pass>b{color:#168455}.evidence article.warning>b{color:#a66a09}.evidence article.fail>b{color:#c54038}.evidence article div>strong,.evidence article div>span,.evidence article small{display:block}.evidence article div>span{margin-top:5px;color:#526077;font-size:13px;line-height:1.55}.evidence article small{margin-top:5px;color:#8993a3;font-size:12px}.evidence article p{margin:7px 0 0;font-size:12px;color:#7d5900}.evidence em{text-align:right;color:#38516f;font-style:normal;font-weight:650;white-space:nowrap}.empty{padding:30px;text-align:center;color:#8791a2}@media(max-width:700px){.evidence article{grid-template-columns:1fr}.evidence em{text-align:left}}`],
 })
-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 源数据';
-  }
+export class ListingDimensionPanelComponent{
+  @Input()score:ListingScorePresentationDimension|null=null;
 }

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

@@ -11,7 +11,7 @@
     <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>
+        <div class="price">@if(source.price?.jd !== null && source.price?.jd !== undefined){¥{{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>

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

@@ -0,0 +1,27 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ListingScoreSummaryComponent } from './listing-score-summary.component';
+
+describe('ListingScoreSummaryComponent', () => {
+  let fixture: ComponentFixture<ListingScoreSummaryComponent>;
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({ imports: [ListingScoreSummaryComponent] }).compileComponents();
+    fixture = TestBed.createComponent(ListingScoreSummaryComponent);
+  });
+
+  it('shows the user-facing waiting state without a dynamic denominator', () => {
+    fixture.componentInstance.score = {score:null,scoreText:'尚未完成智能评分',statusLabel:'自动检查已完成,等待智能评分',methodLabel:'自动检查',standardLabel:'京东五维评分 V7',dataUpdatedAtText:'',scopeNote:'',dimensions:[]};
+    fixture.detectChanges();
+    const text=(fixture.nativeElement as HTMLElement).textContent??'';
+    expect(text).toContain('尚未完成智能评分');
+    expect(text).toContain('等待智能评分');
+    expect(text).not.toContain('Rubric');
+  });
+
+  it('shows a formal fixed-100 score', () => {
+    fixture.componentInstance.score = {score:88,scoreText:'88 / 100',statusLabel:'评分完成',methodLabel:'智能评分',standardLabel:'京东五维评分 V7',dataUpdatedAtText:'',scopeNote:'',dimensions:[]};
+    fixture.detectChanges();
+    const text=(fixture.nativeElement as HTMLElement).textContent??'';
+    expect(text).toContain('88 / 100');
+    expect(text).toContain('评分完成');
+  });
+});

+ 13 - 3
src/modules/listing-ai/components/listing-score-summary/listing-score-summary.component.ts

@@ -1,5 +1,15 @@
 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:'规格'};}
+import type { ListingScorePresentation } 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?.scoreText || '等待智能评分'}}</strong><span>{{score?.statusLabel || '尚未评分'}}</span></div><div class="dimensions">@for(item of score?.dimensions || [];track item.key){<div><span>{{item.name}}</span><b>{{item.scoreText}}</b><i><em [style.width.%]="item.maxScore && item.score!==null ? item.score/item.maxScore*100 : 0"></em></i></div>}</div></section>`,
+  styles:[`:host{display:block}.score{display:grid;grid-template-columns:210px 1fr;gap:24px;align-items:center}.overall{text-align:center}.overall strong{display:block;font-size:30px;color:#2563eb}.overall span{display:block;margin-top:5px;color:#8791a2;font-size:12px}.dimensions{display:grid;gap:8px}.dimensions div{display:grid;grid-template-columns:100px 120px 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 110px 1fr}}`],
+})
+export class ListingScoreSummaryComponent{
+  @Input()score:ListingScorePresentation|null=null;
+}

+ 10 - 0
src/modules/listing-ai/listing-ai.shared.scss

@@ -26,3 +26,13 @@
 .source-grid.description-layout{grid-template-columns:1fr}
 .listing-detail-content{display:grid;gap:18px;width:100%;min-width:0;max-width:none;margin:0;padding:0}
 .version-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.version-heading span{padding:4px 8px;border-radius:16px;background:#f0f2f6;color:#727d8e;font-size:12px}.version-heading span.changed{background:#eaf8f1;color:#168455}
+
+/* Workbench table: keep compact labels on one line and give product copy room. */
+.table-wrap{border:1px solid #e7ebf2;border-radius:10px}
+.table-wrap table{min-width:1320px}
+.table-wrap th{white-space:nowrap}
+.table-wrap tbody tr:hover{background:#f8fbff}
+.table-wrap .product-cell{min-width:360px}
+.table-wrap .status{display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;line-height:1.35;padding:4px 9px}
+.table-wrap .score-cell{white-space:nowrap}
+.table-wrap td:nth-child(3),.table-wrap td:nth-child(9),.table-wrap td:last-child{white-space:nowrap}

Разница между файлами не показана из-за своего большого размера
+ 10 - 8
src/modules/listing-ai/models/listing-ai.models.ts


+ 15 - 35
src/modules/listing-ai/pages/product/listing-ai-product.component.html

@@ -1,49 +1,29 @@
 <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'}]">
+  <app-page-header [title]="vm.source.title || '商品详情'" eyebrow="商品评分详情" [description]="'京东商品 '+vm.source.productId" backLabel="返回工作台" backRoute="/listing-ai/workbench" [badges]="[{label:vm.source.detailStatus==='available'?'数据完整':'数据待补充',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 title="评分总览" [subtitle]="vm.currentScore?.dataUpdatedAtText || '尚未完成智能评分'">
+      <app-listing-score-summary [score]="vm.currentScore || vm.rulePrecheck"></app-listing-score-summary>
+      @if(vm.currentScore;as score){<div class="source-box"><h3>本次评分说明</h3><p>{{score.scopeNote}}</p><p>{{score.methodLabel}} · {{score.standardLabel}}</p></div>}@else if(vm.rulePrecheck){<div class="alert">基础检查已经完成。标题和核心卖点将在智能评分完成后计入,届时生成固定满分 100 分的正式结果。</div>}
     </app-content-card>
-    <app-content-card title="Listing 内容与证据" subtitle="当前内容、AI/规则证据和评分结果均绑定同一 sourceHash">
+    <app-content-card title="商品内容与评分说明" subtitle="每项评分均使用当前可从京东接口读取的商品信息">
       <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"><h3>当前商品内容</h3>@switch(active){
+          @case('title'){<p>{{vm.source.title || '暂无商品标题'}}</p>}
+          @case('selling_points'){@if(sellingPointRows(vm.source);as points){@if(points.length){<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'){<p class="field-note">当前只检查图片资产完整性,不进行真实视觉质量评分。</p>@if(vm.source.images.length){<div class="gallery">@for(image of vm.source.images;track image.url){<img [src]="image.url" alt="商品图片">}</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 title="优化版本" subtitle="保存人工或智能优化后的内部草稿;采纳不会自动发布到京东">
+      <div class="header-actions"><button class="btn secondary" (click)="createVersion()">保存当前内容为草稿</button></div>
+      @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>版本 {{version.versionNo}} · {{version.status==='adopted'?'已采用':'草稿'}}</strong><span [class.changed]="versionChangeCount(version,vm.source)>0">{{versionChangeCount(version,vm.source)}} 项内容变化</span></div><span>{{version.createdAt|date:'yyyy-MM-dd HH:mm'}}</span><p>{{version.content.title || '暂无标题'}}</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">尚无优化草稿。</div>}
     </app-content-card>
   </section></div>
 }}</main>

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

@@ -0,0 +1,29 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
+import { ListingAiProductComponent } from './listing-ai-product.component';
+import { ListingAiApiService } from '../../services/listing-ai-api.service';
+
+describe('ListingAiProductComponent',()=>{
+  it('renders the current user score without score history or technical scoring fields',async()=>{
+    const detail:any={
+      source:{productId:'1001',sourceHash:'hash',title:'测试商品',detailStatus:'available',descriptions:{desktopHtml:null,mobileHtml:null},marketing:{sellingPoints:[]},images:[],attributes:[],brand:{id:null,name:null},categoryIds:[],features:[],skus:[],dimensions:{length:null,width:null,height:null,weight:null},logistics:{},afterService:{},syncedAt:'2026-08-25T00:00:00.000Z'},
+      coverage:null,
+      currentScore:{score:88,scoreText:'88 / 100',statusLabel:'评分完成',methodLabel:'智能评分',standardLabel:'京东五维评分 V7',dataUpdatedAtText:'2026-08-25T00:00:00.000Z',scopeNote:'只评价当前可读取的信息。',dimensions:[]},
+      rulePrecheck:null,versionsSummary:{items:[],nextCursor:null},
+    };
+    await TestBed.configureTestingModule({imports:[ListingAiProductComponent],providers:[
+      {provide:ActivatedRoute,useValue:{snapshot:{paramMap:{get:()=> '1001'}}}},
+      {provide:ListingAiApiService,useValue:{product:()=>of(detail),createVersion:()=>of({}),adoptVersion:()=>of({})}},
+    ]}).compileComponents();
+    const fixture=TestBed.createComponent(ListingAiProductComponent);
+    fixture.detectChanges();
+    const text=(fixture.nativeElement as HTMLElement).textContent??'';
+    expect(text).toContain('88 / 100');
+    expect(text).not.toContain('评分历史');
+    expect(text).not.toContain('Rubric');
+    expect(text).not.toContain('sourceHash');
+    expect(text).not.toContain('CTR');
+    expect(text).not.toContain('CVR');
+  });
+});

+ 7 - 16
src/modules/listing-ai/pages/product/listing-ai-product.component.ts

@@ -8,31 +8,22 @@ import { BoardTabsComponent, type BoardTabItem } from '../../../shared/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 type { ListingDimension, ListingProductDetailResponse, ListingScorePresentationDimension, 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:'规格'}];
+  readonly tabs:BoardTabItem[]=[{key:'title',label:'商品标题 · 30分'},{key:'selling_points',label:'核心卖点 · 25分'},{key:'images',label:'图片资产 · 20分'},{key:'description',label:'商品详情 · 15分'},{key:'specifications',label:'规格与履约 · 10分'}];
   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)});}
+  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:()=>{this.error.set('商品加载失败,请稍后重试');this.loading.set(false);},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;}
+  dimension():ListingScorePresentationDimension|null{const score=this.detail()?.currentScore??this.detail()?.rulePrecheck;return score?.dimensions.find((item)=>item.key===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;
+    return (source.marketing?.sellingPoints??[]).map((item)=>({label:item.source==='product_adword'?'商品广告词':`SKU 短标题${item.skuId?' · '+item.skuId:''}`,value:item.value}));
   }
   versionChangeCount(version: ListingVersion, source: ListingProductDetailResponse['source']): number {
-    const sourcePoints = source.features.map((item) => item.value).filter(Boolean);
+    const sourcePoints = source.marketing?.sellingPoints.map((item)=>item.value)??[];
     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))
@@ -40,6 +31,6 @@ export class ListingAiProductComponent implements OnInit{
       + 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:'版本创建失败')});}
+  createVersion():void{const value=this.detail();if(!value)return;const source=value.source;const content={title:source.title,sellingPoints:source.marketing?.sellingPoints.map((item)=>item.value)??[],descriptionHtml:source.descriptions.mobileHtml??source.descriptions.desktopHtml,specifications:source.attributes,imageUrls:source.images.map((item)=>item.url)};this.action.set('正在创建版本…');this.api.createVersion(this.workspaceId,source.productId,source.sourceHash,content).subscribe({next:({version})=>{this.detail.update((current)=>current?{...current,versionsSummary:{...current.versionsSummary,items:[version,...current.versionsSummary.items]}}:current);this.action.set('优化草稿已创建');},error:()=>this.action.set('版本创建失败,请稍后重试')});}
   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 - 1
src/modules/listing-ai/pages/task-detail/listing-score-job-detail.component.html

@@ -1 +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>
+<main class="listing-page">@if(job();as vm){<app-page-header eyebrow="智能评分任务" [title]="'评分任务 '+vm.id.slice(0,8)" [description]="vm.statusLabel || '正在读取任务状态'" backLabel="返回任务列表" backRoute="/listing-ai/tasks"><div pageHeaderActions class="actions">@if(vm.failed>0){<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.statusLabel || '等待处理'}}</span></td><td>{{item.attempts}}</td><td>{{item.errorMessage || '—'}}</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>

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

@@ -1 +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>
+<main class="listing-page"><app-page-header eyebrow="智能评分" title="评分任务" 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.statusLabel || '等待处理'}}</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>

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

@@ -1,22 +1,22 @@
 <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 eyebrow="商品内容优化" title="Listing 智能评分工作台" description="依据当前可读取的京东商品资料,执行固定满分 100 分的五维评分" 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="对所选商品更新当前智能评分;本轮最多10个商品" (click)="runAiScore()">{{creating()?'创建中…':'智能评分 '+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>
+  <div class="alert">京东五维评分标准:商品标题 30 分、核心卖点 25 分、图片资产 20 分、商品详情 15 分、规格与履约 10 分。只评价当前接口可读取的信息;图片暂不评价构图、清晰度和内容质量。</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()?.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()?.partial || 0) + (store.summary()?.blocked || 0)" description="商品资料获取状态" 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>
+    <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><option value="failed">评分失败</option></select><select name="aiScoreStatus" [(ngModel)]="aiScoreStatus"><option value="">全部 AI 状态</option><option value="not_scored">未进行 AI 评分</option><option value="completed">AI 评分完成</option><option value="partial">AI 部分完成</option><option value="failed">AI 评分失败</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>类目 / 商品规格</th><th>资料状态</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.itemStatusLabel}}</span></div></div></td><td>{{item.categoryText}}<small>{{item.specificationCount ?? '—'}} 个商品规格</small></td><td><span class="status">{{item.dataStatusLabel}}</span></td><td><span class="status">{{item.scoreStatusLabel}}</span></td><td><span class="status">{{item.aiStatusLabel}}</span></td><td class="score-cell">{{item.scoreText}}</td><td><span class="status" [class.scored]="item.methodLabel==='智能评分'">{{item.methodLabel}}</span></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>

+ 6 - 7
src/modules/listing-ai/pages/workbench/listing-ai-workbench.component.ts

@@ -12,26 +12,25 @@ import { ListingAiWorkbenchStore } from '../../services/listing-ai-workbench.sto
 @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);}
+  search='';scoreStatus='';aiScoreStatus='';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.aiScoreStatus=query.get('aiScoreStatus')??'';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,aiScoreStatus:this.aiScoreStatus||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,aiScoreStatus:this.aiScoreStatus||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;}
+    if(!window.confirm('将对已选商品进行智能评分,并用本次结果更新当前分数。是否继续?'))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({
+    this.api.createScoreJob(this.workspaceId,scope,'ai','force').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||'任务创建失败');
+        this.actionError.set(code==='listing_ai_budget_exceeded'?'本轮智能评分最多支持 10 个商品。':'任务创建失败,请稍后重试');
       },
       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评分':'规则评分';}
 }

+ 2 - 1
src/modules/listing-ai/services/listing-ai-api.service.spec.ts

@@ -7,5 +7,6 @@ describe('ListingAiApiService',()=>{
   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:{}});});
+  it('creates a forced AI score job and lets the server select the current rubric',()=>{service.createScoreJob('demashi',{mode:'selected',productIds:['1001']},'ai','force').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).toBeUndefined();expect(request.request.body.rescorePolicy).toBe('force');request.flush({job:{}});});
+  it('creates a version from explicit current content without a score-history id',()=>{const content={title:'标题',sellingPoints:['卖点'],descriptionHtml:null,specifications:[],imageUrls:[]};service.createVersion('demashi','1001','hash',content).subscribe();const request=http.expectOne('/api/listing-ai/products/1001/versions');expect(request.request.body.content).toEqual(content);expect(request.request.body.baseScoreResultId).toBeUndefined();request.flush({version:{}});});
 });

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

@@ -4,20 +4,20 @@ 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;}
+export interface ListingProductFilters {search?:string;categoryId?:string;itemStatus?:string;scoreStatus?:string;aiScoreStatus?: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})});}
+  createScoreJob(workspaceId:string,scope:{mode:'filter';filter:Record<string,unknown>}|{mode:'selected';productIds:string[]},scoringMode:'rules'|'ai'='ai',rescorePolicy:'reuse'|'force'='reuse'):Observable<{job:ListingScoreJob}>{const idempotencyKey=crypto.randomUUID();return this.http.post<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs`,{workspaceId,platform:'jd',scope,scoringMode,rescorePolicy},{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});}
+  createVersion(workspaceId:string,productId:string,baseSourceHash:string,content:ListingVersion['content']):Observable<{version:ListingVersion}>{return this.http.post<{version:ListingVersion}>(`${this.baseUrl}/products/${encodeURIComponent(productId)}/versions`,{workspaceId,platform:'jd',baseSourceHash,content});}
   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'}});}
 }

+ 253 - 0
tools/generate-jd-api-permissions-doc.mjs

@@ -0,0 +1,253 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs/promises';
+
+const TABLES_FILE = new URL('../logs/jd-official-upgrade-tables-2026-08-24.json', import.meta.url);
+const SP_PROBE_FILE = new URL('../logs/jd-sp-api-probe-2026-08-24.json', import.meta.url);
+const JOS_PROBE_FILE = new URL('../logs/jd-jos-api-probe-2026-08-24.json', import.meta.url);
+const DOC_FILE = new URL('../docs/JD-API-PERMISSIONS.md', import.meta.url);
+
+const escapeCell = (value) => String(value ?? '').replace(/\r?\n/g, ' ').replace(/\|/g, '\\|').trim();
+const compact = (value, limit = 100) => {
+  const text = escapeCell(value).replace(/\s+/g, ' ');
+  return text.length > limit ? `${text.slice(0, limit)}…` : text;
+};
+
+function parseOfficial(tables) {
+  const endpoints = new Map();
+  const mappingRows = [];
+  for (const table of tables.filter((entry) => entry.ti >= 4 && entry.ti <= 11)) {
+    let current = null;
+    for (const cells of table.rows.slice(1)) {
+      if (/^(GET|POST|PUT|PATCH|DELETE)\s/.test(cells[2] || '')) {
+        const [method, ...rest] = cells[2].split(/\s+/);
+        const path = `/${rest.join(' ')}`;
+        current = {
+          domain: cells[0] || '', subdomain: cells[1] || '', method, path,
+          name: cells[3] || '', jos: [],
+        };
+        if (!endpoints.has(`${method} ${path}`)) endpoints.set(`${method} ${path}`, current);
+        else current = endpoints.get(`${method} ${path}`);
+      }
+      if (!current) continue;
+      const josApi = cells[4] || '';
+      if (josApi) {
+        const mapping = {
+          domain: current.domain, spMethod: current.method, spPath: current.path, spName: current.name,
+          josApi, josName: cells[5] || '', josCategory: cells[6] || '',
+        };
+        current.jos.push(mapping);
+        mappingRows.push(mapping);
+      }
+    }
+  }
+  return { endpoints: [...endpoints.values()], mappingRows };
+}
+
+function groupCount(rows, field) {
+  const result = new Map();
+  for (const row of rows) result.set(row[field], (result.get(row[field]) || 0) + 1);
+  return [...result.entries()].sort((a, b) => b[1] - a[1]);
+}
+
+function spResultTextSafe(result) {
+  if (!result) return '未执行(非 GET)';
+  if (result.status === '成功') return '成功(HTTP 200,`success: true`)';
+  const code = result.code ? `,\`${escapeCell(result.code)}\`` : '';
+  const message = result.message ? `,${compact(result.message, 64)}` : '';
+  return `HTTP ${result.httpStatus}${code}${message}`;
+}
+
+function josResultText(result) {
+  const code = result.code ? `,\`${escapeCell(result.code)}\`` : '';
+  const message = result.message ? `,${compact(result.message, 64)}` : '';
+  return `${escapeCell(result.status)}(HTTP ${result.httpStatus}${code}${message})`;
+}
+
+const tables = JSON.parse(await fs.readFile(TABLES_FILE, 'utf8'));
+const spProbe = JSON.parse(await fs.readFile(SP_PROBE_FILE, 'utf8'));
+const josProbe = JSON.parse(await fs.readFile(JOS_PROBE_FILE, 'utf8'));
+const official = parseOfficial(tables);
+const spByKey = new Map(spProbe.results.map((row) => [`${row.method} ${row.path}`, row]));
+const josByMethod = new Map(josProbe.results.map((row) => [row.josApi, row]));
+const officialJosMethods = new Set(official.mappingRows.map((row) => row.josApi).filter((value) => /^jingdong\./.test(value)));
+const testedOfficialJos = josProbe.results.filter((row) => officialJosMethods.has(row.josApi));
+const extraJos = josProbe.results.filter((row) => !officialJosMethods.has(row.josApi));
+const nonGet = official.endpoints.filter((row) => row.method !== 'GET');
+
+const spPassed = spProbe.results.filter((row) => row.status === '成功');
+const spBlocked = spProbe.results.filter((row) => ['99904030008', '99904030005'].includes(row.code));
+const spOther = spProbe.results.filter((row) => row.status !== '成功' && !['99904030008', '99904030005'].includes(row.code));
+const josPassed = josProbe.results.filter((row) => row.status === '方法响应');
+const josBlocked = josProbe.results.filter((row) => ['无接口调用权限', '要求云鼎调用'].includes(row.status));
+const josOther = josProbe.results.filter((row) => !['方法响应', '无接口调用权限', '要求云鼎调用'].includes(row.status));
+const endpointByKey = new Map(official.endpoints.map((row) => [`${row.method} ${row.path}`, row]));
+
+const lines = [];
+lines.push('# 京东官方 API 接口权限实测清单');
+lines.push('');
+lines.push('> 测试日期:2026-08-24  ');
+lines.push('> 配置来源:`docs/JD-CONNECTION-CONFIG.local.md`,店铺 Token 来源为 Parse `EcomAuth`。  ');
+lines.push('> 官方清单来源:[京东 SP-API 升级说明](https://open.jd.com/v2/#/doc/api?apiCateId=200436&articleId=1100596&gwType=1)。');
+lines.push('');
+lines.push('## 1. 测试范围和结果');
+lines.push('');
+lines.push('| 接口体系 | 官方接口数 | 已实际请求 | 通过/返回方法响应 | 明确拒绝 | 其他错误 |');
+lines.push('| --- | ---: | ---: | ---: | ---: | ---: |');
+lines.push(`| SP-API | ${official.endpoints.length} | ${spProbe.results.length} | ${spPassed.length} | ${spBlocked.length} | ${spOther.length} |`);
+lines.push(`| JOS 1.0 | ${officialJosMethods.size} 个官方映射方法 + ${extraJos.length} 个评价方法 | ${josProbe.results.length} | ${josPassed.length} | ${josBlocked.length} | ${josOther.length} |`);
+lines.push('');
+lines.push(`SP-API 的 ${official.endpoints.length} 个接口均已发起请求,包括 ${spProbe.results.filter((row) => row.method === 'GET').length} 个 GET 和 ${spProbe.results.filter((row) => row.method !== 'GET').length} 个非 GET。非 GET 使用占位路径参数和空 JSON 请求体执行,没有提交有效业务数据。`);
+lines.push('');
+lines.push('判定口径:');
+lines.push('');
+lines.push('- SP-API 返回 `success: true`:通过。');
+lines.push('- JOS 返回对应方法响应对象,且未返回网关权限错误:方法已响应。');
+lines.push('- `99904030008`、JOS `24/88`:明确无接口调用权限。');
+lines.push('- `99904030005`、JOS `73`:明确要求云鼎调用。');
+lines.push('- 参数错误或后端服务错误:本次未通过,不等同于已拥有完整业务调用能力。');
+lines.push('');
+lines.push('## 2. 未通过接口的申请条件和申请方式');
+lines.push('');
+lines.push('### 2.1 当前失败结果分别怎么处理');
+lines.push('');
+lines.push('| 实测结果 | 当前涉及范围 | 申请条件 | 申请入口与操作 |');
+lines.push('| --- | --- | --- | --- |');
+lines.push('| SP `99904030008`;JOS `24/88` | 商品、订单物流、售后、财务、会员、客服、商家、通用服务 | 当前应用类型和应用场景下,该接口权限包必须出现在应用概览页的“可申请”列表 | 开发者后台 → 应用中心 → 应用列表 → 当前应用 → 应用概览 → 权限包清单 → 申请;填写申请原因并提交 |');
+lines.push('| SP `99904030005`;JOS `73` | 订单、部分售后、财务以及其他敏感接口 | 应用审核完成;使用与开放平台店铺主账号一致的京东账号激活京东云并完成企业实名认证;完成云鼎入驻和应用部署 | 先完成接口权限包申请,再进入云鼎入驻页选择“零售”提交申请;云鼎审核通过后在云鼎环境部署并调用 |');
+lines.push('| SP `99904000013`、`99904000001` 及各业务参数错误码;JOS `61/400` | 本轮使用空请求体、占位 ID 或缺省参数的接口 | 这类响应是请求参数校验结果,本轮响应不能判定为缺少权限包 | 按具体接口文档补齐必填参数、有效业务 ID、场景字段和请求体后再次调用;不走权限包申请 |');
+lines.push('| HTTP 503、JOS `65/67` | 商品、商家、通用等接口 | 本轮返回业务后端或服务异常,没有形成权限拒绝结论 | 使用完整参数复测;仍返回服务异常时,通过开放平台工单提交接口、时间和 requestId |');
+lines.push('');
+lines.push('### 2.2 普通接口权限包');
+lines.push('');
+lines.push('京东官方把权限包分为“默认开通”和“可申请”:');
+lines.push('');
+lines.push('- 默认开通:应用创建后直接拥有。');
+lines.push('- 可申请:需要单独申请,运营审核通过后获得。');
+lines.push('');
+lines.push('申请步骤:');
+lines.push('');
+lines.push('1. 登录[京东开放平台开发者后台](https://open-console.jd.com/v2#/app/index)。');
+lines.push('2. 进入“应用中心 → 应用列表”,点击当前应用。');
+lines.push('3. 在应用概览页下方查看权限包清单。');
+lines.push('4. 在“可申请”权限包上点击“申请”。');
+lines.push('5. 填写申请原因并提交。');
+lines.push('6. 官方记录的审核周期为 1—3 个工作日。');
+lines.push('');
+lines.push('如果所需接口在“已获得”和“可申请”中都不存在,官方给出的处理方式是提交工单,或加入咚咚群 `10220332728` 咨询。');
+lines.push('');
+lines.push('当前 `99904030008` 的 SP-API 分布:');
+lines.push('');
+lines.push('| 数据域 | 接口数 |');
+lines.push('| --- | ---: |');
+for (const [domain, count] of [...new Map(spProbe.results.filter((row) => row.code === '99904030008').reduce((items, row) => items.set(row.domain, (items.get(row.domain) || 0) + 1), new Map())).entries()].sort((a, b) => b[1] - a[1])) {
+  lines.push(`| ${escapeCell(domain)} | ${count} |`);
+}
+lines.push('');
+lines.push('### 2.3 云鼎敏感接口');
+lines.push('');
+lines.push('官方云鼎入驻条件和流程:');
+lines.push('');
+lines.push('1. 先在京东开放平台创建并完成应用审核;官方云鼎文档注明,自研应用审核完成后再执行云鼎入驻。');
+lines.push('2. 与开放平台商家店铺主账号一致的京东账号,需要在京东云激活并完成企业实名认证。');
+lines.push('3. 使用该账号进入[云鼎入驻页](https://yd.jdcloud.com/#/enter),业务选择“零售”;官方记录的审核周期为 1—2 个工作日。');
+lines.push('4. 审核通过后,在云鼎应用管理平台完成应用部署。');
+lines.push('5. 按云鼎文档配置云资源;文档将对外提供 HTTP/HTTPS 的 Web 服务器、访问 JDO 敏感接口的应用服务器,以及存储敏感字段的云 RDS 列为基础部署资源。');
+lines.push('6. 从云鼎环境重新调用订单、售后、财务等返回 `99904030005` / JOS `73` 的接口。');
+lines.push('');
+lines.push('当前 `99904030005` 的 SP-API 分布:');
+lines.push('');
+lines.push('| 数据域 | 接口数 |');
+lines.push('| --- | ---: |');
+for (const [domain, count] of [...new Map(spProbe.results.filter((row) => row.code === '99904030005').reduce((items, row) => items.set(row.domain, (items.get(row.domain) || 0) + 1), new Map())).entries()].sort((a, b) => b[1] - a[1])) {
+  lines.push(`| ${escapeCell(domain)} | ${count} |`);
+}
+lines.push('');
+lines.push('### 2.4 自营供应链接口的特殊流程');
+lines.push('');
+lines.push('京东官方注明:自营供应链接口权限除在应用的 API 权限管理中申请权限包外,还需要使用核心产品线账号向主采销发起邮件;采销邮件审核通过后,平台才审核开通。该流程只适用于自营供应链接口。');
+lines.push('');
+lines.push('### 2.5 服务对象和店铺授权前置条件');
+lines.push('');
+lines.push('- 自研 POP 应用需要添加 POP 店铺账号作为服务对象;自营应用需要添加供应商账号。');
+lines.push('- 同一公司账号可自动审核;同一主体、同法人、同集团等关系需要提交关系证明,官方记录的人工审核时限为 1 个工作日。');
+lines.push('- 服务对象绑定成功后才能执行店铺授权并获得 Token。当前项目已经取得有效 Token,此项不是本次商品接口成功/失败差异的阻断点。');
+lines.push('');
+lines.push('### 2.6 官方依据');
+lines.push('');
+lines.push('- [接口权限包申请](https://open.jd.com/v2/#/doc/guide?listId=1911)');
+lines.push('- [开放平台新手指南](https://open.jd.com/v2/#/doc/begin-guide?listId=1900)');
+lines.push('- [自研与 ISV 授权 Token 获取](https://open.jd.com/v2/#/doc/dev-guide?listId=1100981)');
+lines.push('- [云鼎入驻流程](https://yd-doc.jdcloud.com/)');
+lines.push('');
+lines.push('## 3. 通过的接口');
+lines.push('');
+lines.push('### 3.1 SP-API:业务响应成功');
+lines.push('');
+lines.push('| 数据域 | 方法与路径 | 官方名称 | 结果 |');
+lines.push('| --- | --- | --- | --- |');
+for (const row of spPassed) lines.push(`| ${escapeCell(row.domain)} | \`${row.method} ${row.path}\` | ${escapeCell(row.name)} | HTTP ${row.httpStatus},\`success: true\` |`);
+lines.push('');
+lines.push('### 3.2 JOS:方法已响应');
+lines.push('');
+lines.push('| 数据域 | JOS 方法 | 对应 SP-API | 结果 |');
+lines.push('| --- | --- | --- | --- |');
+for (const row of josPassed) {
+  const sp = row.spPath ? `\`${row.spMethod} ${row.spPath}\`` : '商品评价旧接口';
+  lines.push(`| ${escapeCell(row.domain)} | \`${escapeCell(row.josApi)}\` | ${sp} | HTTP ${row.httpStatus},返回方法响应对象 |`);
+}
+lines.push('');
+lines.push('## 4. 未通过的 SP-API');
+lines.push('');
+lines.push('### 4.1 明确被京东网关拒绝');
+lines.push('');
+lines.push('| 数据域 | 方法与路径 | 官方名称 | 实测结果 |');
+lines.push('| --- | --- | --- | --- |');
+for (const row of spBlocked) lines.push(`| ${escapeCell(row.domain)} | \`${row.method} ${row.path}\` | ${escapeCell(row.name)} | ${spResultTextSafe(row)} |`);
+lines.push('');
+lines.push('### 4.2 参数或服务错误,本次未成功');
+lines.push('');
+lines.push('| 数据域 | 方法与路径 | 官方名称 | 实测结果 |');
+lines.push('| --- | --- | --- | --- |');
+for (const row of spOther) lines.push(`| ${escapeCell(row.domain)} | \`${row.method} ${row.path}\` | ${escapeCell(row.name)} | ${spResultTextSafe(row)} |`);
+lines.push('');
+lines.push('## 5. 未通过的 JOS 接口');
+lines.push('');
+lines.push('### 5.1 明确无权限或要求云鼎');
+lines.push('');
+lines.push('| 数据域 | JOS 方法 | 对应 SP-API | 实测结果 |');
+lines.push('| --- | --- | --- | --- |');
+for (const row of josBlocked) {
+  const sp = row.spPath ? `\`${row.spMethod} ${row.spPath}\`` : '无对应 SP 路径';
+  lines.push(`| ${escapeCell(row.domain)} | \`${escapeCell(row.josApi)}\` | ${sp} | ${josResultText(row)} |`);
+}
+lines.push('');
+lines.push('### 5.2 参数或服务错误,本次未成功');
+lines.push('');
+lines.push('| 数据域 | JOS 方法 | 对应 SP-API | 实测结果 |');
+lines.push('| --- | --- | --- | --- |');
+for (const row of josOther) {
+  const sp = row.spPath ? `\`${row.spMethod} ${row.spPath}\`` : '无对应 SP 路径';
+  lines.push(`| ${escapeCell(row.domain)} | \`${escapeCell(row.josApi)}\` | ${sp} | ${josResultText(row)} |`);
+}
+lines.push('');
+lines.push('## 6. 项目内已经实现的京东调用');
+lines.push('');
+lines.push('| 项目位置 | 京东接口 | 用途 |');
+lines.push('| --- | --- | --- |');
+lines.push('| `tools/jd-import-products.mjs` | `GET /sp-product/v0/products` | 商品分页读取 |');
+lines.push('| `tools/jd-import-products.mjs` | `GET /sp-product/v0/skus` | SKU 读取 |');
+lines.push('| `tools/jd-import-products.mjs` | `GET /sp-product/v0/sku-stocks` | SKU 库存读取 |');
+lines.push('| `tools/jd-import-products.mjs` | `GET /sp-product/v0/products/{productId}` | 商品详情读取 |');
+lines.push('| `Saas-voc-server/scripts/sync-jd-listings.ts` | 商品列表、商品详情 | Listing 全量同步 |');
+lines.push('');
+lines.push('## 7. 测试文件');
+lines.push('');
+lines.push('- `logs/jd-official-upgrade-tables-2026-08-24.json`');
+lines.push('- `logs/jd-sp-api-probe-2026-08-24.json`');
+lines.push('- `logs/jd-jos-api-probe-2026-08-24.json`');
+lines.push('- `tools/jd-api-permission-probe.mjs`');
+lines.push('- `tools/jd-jos-api-permission-probe.mjs`');
+
+await fs.writeFile(DOC_FILE, `${lines.join('\n')}\n`, 'utf8');
+console.log(`Generated ${DOC_FILE.pathname}: ${official.endpoints.length} SP endpoints, ${official.mappingRows.length} mapping rows.`);

+ 232 - 0
tools/jd-api-permission-probe.mjs

@@ -0,0 +1,232 @@
+#!/usr/bin/env node
+
+import crypto from 'node:crypto';
+import fs from 'node:fs/promises';
+
+const ROOT = new URL('../', import.meta.url);
+const CONFIG_FILE = new URL('../docs/JD-CONNECTION-CONFIG.local.md', import.meta.url);
+const OFFICIAL_TABLES = new URL('../logs/jd-official-upgrade-tables-2026-08-24.json', import.meta.url);
+const OUTPUT_FILE = new URL('../logs/jd-sp-api-probe-2026-08-24.json', import.meta.url);
+const API_BASE = 'https://api-cn.jd.com/rest';
+
+function parseLocalDotenv(text) {
+  const values = {};
+  for (const line of text.split(/\r?\n/)) {
+    const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
+    if (match) values[match[1]] = match[2].trim();
+  }
+  return values;
+}
+
+function signature(secret, params) {
+  const plain = Object.keys(params).sort().map((key) => `${key}${params[key] ?? ''}`).join('');
+  return crypto.createHash('md5').update(`${secret}${plain}${secret}`).digest('hex').toUpperCase();
+}
+
+function queryString(query) {
+  const params = new URLSearchParams();
+  for (const [key, value] of Object.entries(query)) {
+    params.set(key, Array.isArray(value) ? JSON.stringify(value) : String(value));
+  }
+  return params.toString();
+}
+
+async function requestJson(url, init = {}, timeoutMs = 30000) {
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), timeoutMs);
+  try {
+    const response = await fetch(url, { ...init, signal: controller.signal });
+    const text = await response.text();
+    let body = null;
+    try { body = text ? JSON.parse(text) : null; }
+    catch { body = { raw: text.slice(0, 500) }; }
+    return { httpStatus: response.status, body };
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+async function fetchToken(config) {
+  const base = config.JD_SOURCE_PARSE_URL.replace(/\/+$/, '');
+  const params = new URLSearchParams({
+    where: JSON.stringify({ platform: 'jd', type: 'access_token' }),
+    limit: '1',
+    order: '-createdAt',
+  });
+  for (let attempt = 1; attempt <= 8; attempt += 1) {
+    const result = await requestJson(`${base}/classes/EcomAuth?${params}`, {
+      headers: {
+        'X-Parse-Application-Id': config.JD_SOURCE_PARSE_APP_ID,
+        'X-Parse-Master-Key': config.JD_SOURCE_PARSE_MASTER_KEY,
+      },
+    });
+    const token = result.body?.results?.[0]?.data?.access_token;
+    if (token) return token;
+    await new Promise((resolve) => setTimeout(resolve, 350 * attempt));
+  }
+  throw new Error('EcomAuth 中没有可用京东 access_token');
+}
+
+function officialRows(tables) {
+  const rows = [];
+  for (const table of tables.filter((entry) => entry.ti >= 4 && entry.ti <= 11)) {
+    let inherited = ['', '', '', ''];
+    for (const cells of table.rows.slice(1)) {
+      const next = [...cells];
+      for (let i = 0; i < 4; i += 1) {
+        if (next[i]) inherited[i] = next[i];
+        else next[i] = inherited[i];
+      }
+      if (/^(GET|POST|PUT|PATCH|DELETE)\s/.test(next[2] || '')) inherited = next.slice(0, 4);
+      rows.push({
+        domain: next[0] || '',
+        subdomain: next[1] || '',
+        spApi: next[2] || '',
+        spName: next[3] || '',
+        josApi: next[4] || '',
+        josName: next[5] || '',
+        josCategory: next[6] || '',
+      });
+    }
+  }
+  return rows;
+}
+
+function endpointInventory(rows) {
+  const map = new Map();
+  for (const row of rows) {
+    if (!/^(GET|POST|PUT|PATCH|DELETE)\s/.test(row.spApi)) continue;
+    if (!map.has(row.spApi)) map.set(row.spApi, row);
+  }
+  return [...map.values()].map((row) => {
+    const [method, ...parts] = row.spApi.split(/\s+/);
+    return { ...row, method, template: `/${parts.join(' ')}` };
+  });
+}
+
+function replacePathParams(template, ids) {
+  const pathParams = {};
+  const path = template.replace(/\{([^}]+)\}/g, (_, name) => {
+    const value = ids[name] ?? '0';
+    pathParams[name] = value;
+    return encodeURIComponent(value);
+  });
+  return { path, pathParams };
+}
+
+function queryFor(template, ids) {
+  const pagination = { page: 1, pageSize: 1 };
+  const byPath = {
+    '/sp-product/v0/products': { ...pagination, scopeSet: 'productName' },
+    '/sp-product/v0/skus': { ...pagination, productIdList: ids.productId, scopeSet: 'skuName' },
+    '/sp-product/v0/sku-stocks': { skuIdList: ids.skuId },
+    '/sp-product/v0/products/{productId}': { scene: 'pop' },
+    '/sp-address/v0/areas': { level: 1 },
+  };
+  return byPath[template] || pagination;
+}
+
+function summarize(httpStatus, body) {
+  const error = body?.errorList?.[0] || body?.error_response || body?.error || {};
+  const code = String(error?.code ?? error?.numberCode ?? body?.code ?? '');
+  const message = String(error?.message ?? error?.zh_desc ?? error?.msg ?? body?.message ?? '').slice(0, 300);
+  let status = '返回请求错误';
+  if (body?.success === true) status = '成功';
+  else if (code === '99904030008') status = '无接口调用权限';
+  else if (code === '99904030005') status = '要求云鼎调用';
+  else if (code === '99904000016' || code === '19') status = 'Token 校验失败';
+  else if (httpStatus >= 500) status = '服务端错误';
+  return { status, code, message };
+}
+
+async function spRequest(config, token, method, template, ids) {
+  const { path, pathParams } = replacePathParams(template, ids);
+  const query = queryFor(template, ids);
+  const timestamp = String(Date.now());
+  const common = {
+    'X-JOS-App-Key': config.JD_APP_KEY,
+    'X-JOS-Access-Token': token,
+    'X-JOS-Timestamp': timestamp,
+  };
+  const headers = {
+    ...common,
+    'X-JOS-Sign-Method': 'md5',
+    'X-JOS-Request-Identity': 'vender',
+    'X-JOS-Sign': signature(config.JD_APP_SECRET, { ...query, ...pathParams, ...common }),
+  };
+  const init = { method, headers };
+  if (['POST', 'PUT', 'PATCH'].includes(method)) {
+    headers['Content-Type'] = 'application/json';
+    init.body = '{}';
+  }
+  const qs = queryString(query);
+  const started = Date.now();
+  try {
+    const result = await requestJson(`${API_BASE}${path}${qs ? `?${qs}` : ''}`, init);
+    return { ...summarize(result.httpStatus, result.body), httpStatus: result.httpStatus, durationMs: Date.now() - started };
+  } catch (error) {
+    return { status: '网络或超时错误', code: '', message: String(error?.message || error).slice(0, 300), httpStatus: 0, durationMs: Date.now() - started };
+  }
+}
+
+const configText = await fs.readFile(CONFIG_FILE, 'utf8');
+const config = { ...parseLocalDotenv(configText), ...process.env };
+for (const name of ['JD_APP_KEY', 'JD_APP_SECRET', 'JD_SOURCE_PARSE_URL', 'JD_SOURCE_PARSE_APP_ID', 'JD_SOURCE_PARSE_MASTER_KEY']) {
+  if (!config[name]) throw new Error(`缺少配置 ${name}`);
+}
+
+const tables = JSON.parse(await fs.readFile(OFFICIAL_TABLES, 'utf8'));
+const rows = officialRows(tables);
+const endpoints = endpointInventory(rows);
+const token = await fetchToken(config);
+
+// Use real product/SKU identifiers only to make read-only detail/list probes meaningful.
+const seedProduct = await spRequest(config, token, 'GET', '/sp-product/v0/products', { productId: '0', skuId: '0' });
+let ids = { productId: '10026650610613', skuId: '10116518781681' };
+if (seedProduct.status === '成功') {
+  const timestamp = String(Date.now());
+  const query = { page: 1, pageSize: 1, scopeSet: 'productName' };
+  const common = { 'X-JOS-App-Key': config.JD_APP_KEY, 'X-JOS-Access-Token': token, 'X-JOS-Timestamp': timestamp };
+  const headers = { ...common, 'X-JOS-Sign-Method': 'md5', 'X-JOS-Request-Identity': 'vender', 'X-JOS-Sign': signature(config.JD_APP_SECRET, { ...query, ...common }) };
+  const productResponse = await requestJson(`${API_BASE}/sp-product/v0/products?${queryString(query)}`, { headers });
+  const first = productResponse.body?.data?.[0] || {};
+  ids.productId = String(first.productId ?? first.wareId ?? ids.productId);
+  const skuTimestamp = String(Date.now());
+  const skuQuery = { page: 1, pageSize: 1, scopeSet: 'skuName', productIdList: ids.productId };
+  const skuCommon = { 'X-JOS-App-Key': config.JD_APP_KEY, 'X-JOS-Access-Token': token, 'X-JOS-Timestamp': skuTimestamp };
+  const skuHeaders = { ...skuCommon, 'X-JOS-Sign-Method': 'md5', 'X-JOS-Request-Identity': 'vender', 'X-JOS-Sign': signature(config.JD_APP_SECRET, { ...skuQuery, ...skuCommon }) };
+  const skuResponse = await requestJson(`${API_BASE}/sp-product/v0/skus?${queryString(skuQuery)}`, { headers: skuHeaders });
+  const sku = skuResponse.body?.data?.[0] || {};
+  ids.skuId = String(sku.skuId ?? ids.skuId);
+}
+
+const results = [];
+for (let index = 0; index < endpoints.length; index += 1) {
+  const endpoint = endpoints[index];
+  const result = await spRequest(config, token, endpoint.method, endpoint.template, ids);
+  results.push({
+    domain: endpoint.domain,
+    subdomain: endpoint.subdomain,
+    method: endpoint.method,
+    path: endpoint.template,
+    name: endpoint.spName,
+    ...result,
+  });
+  console.log(`[${index + 1}/${endpoints.length}] ${endpoint.method} ${endpoint.template} -> ${result.httpStatus} ${result.code || result.status}`);
+  await new Promise((resolve) => setTimeout(resolve, 120));
+}
+
+const output = {
+  testedAt: new Date().toISOString(),
+  officialSource: 'https://open.jd.com/v2/#/doc/api?apiCateId=200436&articleId=1100596&gwType=1',
+  inventory: {
+    endpointCount: endpoints.length,
+    testedEndpointCount: endpoints.length,
+    readEndpointCount: endpoints.filter((entry) => entry.method === 'GET').length,
+    nonReadEndpointCount: endpoints.filter((entry) => entry.method !== 'GET').length,
+    josMappingCount: rows.filter((row) => row.josApi).length,
+  },
+  results,
+};
+await fs.writeFile(OUTPUT_FILE, JSON.stringify(output, null, 2), 'utf8');
+console.log(`Saved ${OUTPUT_FILE.pathname}`);

+ 155 - 0
tools/jd-jos-api-permission-probe.mjs

@@ -0,0 +1,155 @@
+#!/usr/bin/env node
+
+import crypto from 'node:crypto';
+import fs from 'node:fs/promises';
+
+const CONFIG_FILE = new URL('../docs/JD-CONNECTION-CONFIG.local.md', import.meta.url);
+const OFFICIAL_TABLES = new URL('../logs/jd-official-upgrade-tables-2026-08-24.json', import.meta.url);
+const OUTPUT_FILE = new URL('../logs/jd-jos-api-probe-2026-08-24.json', import.meta.url);
+
+function parseLocalDotenv(text) {
+  const values = {};
+  for (const line of text.split(/\r?\n/)) {
+    const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
+    if (match) values[match[1]] = match[2].trim();
+  }
+  return values;
+}
+
+function sign(secret, params) {
+  const value = Object.keys(params).sort().map((key) => `${key}${params[key] ?? ''}`).join('');
+  return crypto.createHash('md5').update(`${secret}${value}${secret}`).digest('hex').toUpperCase();
+}
+
+function jdTime() {
+  const parts = new Intl.DateTimeFormat('sv-SE', {
+    timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
+    hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
+  }).formatToParts(new Date());
+  const get = (type) => parts.find((part) => part.type === type)?.value || '';
+  return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
+}
+
+async function requestJson(url, init = {}, timeoutMs = 30000) {
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), timeoutMs);
+  try {
+    const response = await fetch(url, { ...init, signal: controller.signal });
+    const text = await response.text();
+    let body = null;
+    try { body = text ? JSON.parse(text) : null; }
+    catch { body = { raw: text.slice(0, 500) }; }
+    return { httpStatus: response.status, body };
+  } finally { clearTimeout(timer); }
+}
+
+async function fetchToken(config) {
+  const base = config.JD_SOURCE_PARSE_URL.replace(/\/+$/, '');
+  const params = new URLSearchParams({
+    where: JSON.stringify({ platform: 'jd', type: 'access_token' }), limit: '1', order: '-createdAt',
+  });
+  for (let attempt = 1; attempt <= 8; attempt += 1) {
+    const result = await requestJson(`${base}/classes/EcomAuth?${params}`, {
+      headers: {
+        'X-Parse-Application-Id': config.JD_SOURCE_PARSE_APP_ID,
+        'X-Parse-Master-Key': config.JD_SOURCE_PARSE_MASTER_KEY,
+      },
+    });
+    const token = result.body?.results?.[0]?.data?.access_token;
+    if (token) return token;
+    await new Promise((resolve) => setTimeout(resolve, 350 * attempt));
+  }
+  throw new Error('EcomAuth 中没有可用京东 access_token');
+}
+
+function readJosMappings(tables) {
+  const mappings = [];
+  for (const table of tables.filter((entry) => entry.ti >= 4 && entry.ti <= 11)) {
+    let current = null;
+    for (const cells of table.rows.slice(1)) {
+      if (/^(GET|POST|PUT|PATCH|DELETE)\s/.test(cells[2] || '')) {
+        const [method, ...path] = cells[2].split(/\s+/);
+        current = { domain: cells[0] || '', spMethod: method, spPath: `/${path.join(' ')}`, spName: cells[3] || '' };
+      }
+      const josApi = cells[4] || '';
+      if (current && /^jingdong\./.test(josApi)) {
+        mappings.push({ ...current, josApi, josName: cells[5] || '', josCategory: cells[6] || '' });
+      }
+    }
+  }
+  const byMethod = new Map();
+  for (const entry of mappings) if (!byMethod.has(entry.josApi)) byMethod.set(entry.josApi, entry);
+  const extras = [
+    {
+      domain: '商品评价', spMethod: '', spPath: '', spName: '',
+      josApi: 'jingdong.pop.PopCommentJsfService.getVenderCommentsForJos',
+      josName: '商家商品评价查询', josCategory: '评价API',
+    },
+  ];
+  for (const entry of extras) if (!byMethod.has(entry.josApi)) byMethod.set(entry.josApi, entry);
+  return [...byMethod.values()];
+}
+
+function summarize(httpStatus, body) {
+  const error = body?.error_response || body?.error || {};
+  const code = String(error?.code ?? body?.code ?? '');
+  const message = String(error?.zh_desc ?? error?.en_desc ?? error?.message ?? body?.message ?? '')
+    .replace(/appKey=[A-Z0-9]+/gi, 'appKey=<redacted>')
+    .replace(/ip:\s*[0-9a-f:.]+/gi, 'ip:<redacted>')
+    .slice(0, 300);
+  const topKeys = body && typeof body === 'object' ? Object.keys(body) : [];
+  let status = topKeys.some((key) => key !== 'error_response' && (/_response$/i.test(key) || /_responce$/i.test(key)))
+    ? '方法响应'
+    : '返回请求错误';
+  if (code === '19') status = 'Token 校验失败';
+  else if (code === '21') status = 'AppKey 禁用';
+  else if (code === '73') status = '要求云鼎调用';
+  else if (code === '24' || code === '88') status = '无接口调用权限';
+  else if (code === '61' || code === '400') status = '请求参数缺失';
+  else if (code === '65' || code === '67') status = '后端服务异常';
+  return { status, code, message, responseKeys: topKeys.slice(0, 5) };
+}
+
+async function probe(config, token, method) {
+  const common = {
+    method,
+    access_token: token,
+    app_key: config.JD_APP_KEY,
+    timestamp: jdTime(),
+    v: '2.0',
+    sign_method: 'md5',
+    '360buy_param_json': '{}',
+  };
+  const params = new URLSearchParams({ ...common, sign: sign(config.JD_APP_SECRET, common) });
+  const started = Date.now();
+  try {
+    const result = await requestJson(`https://api.jd.com/routerjson?${params}`);
+    return { httpStatus: result.httpStatus, durationMs: Date.now() - started, ...summarize(result.httpStatus, result.body) };
+  } catch (error) {
+    return { httpStatus: 0, durationMs: Date.now() - started, status: '网络或超时错误', code: '', message: String(error?.message || error).slice(0, 300), responseKeys: [] };
+  }
+}
+
+const config = { ...parseLocalDotenv(await fs.readFile(CONFIG_FILE, 'utf8')), ...process.env };
+for (const name of ['JD_APP_KEY', 'JD_APP_SECRET', 'JD_SOURCE_PARSE_URL', 'JD_SOURCE_PARSE_APP_ID', 'JD_SOURCE_PARSE_MASTER_KEY']) {
+  if (!config[name]) throw new Error(`缺少配置 ${name}`);
+}
+const token = await fetchToken(config);
+const tables = JSON.parse(await fs.readFile(OFFICIAL_TABLES, 'utf8'));
+const mappings = readJosMappings(tables);
+const results = [];
+for (let index = 0; index < mappings.length; index += 1) {
+  const mapping = mappings[index];
+  const result = await probe(config, token, mapping.josApi);
+  results.push({ ...mapping, ...result });
+  console.log(`[${index + 1}/${mappings.length}] ${mapping.josApi} -> ${result.httpStatus} ${result.code || result.status}`);
+  await new Promise((resolve) => setTimeout(resolve, 120));
+}
+await fs.writeFile(OUTPUT_FILE, JSON.stringify({
+  testedAt: new Date().toISOString(),
+  officialSource: 'https://open.jd.com/v2/#/doc/api?apiCateId=200436&articleId=1100596&gwType=1',
+  methodCount: mappings.length,
+  requestPayload: '{}',
+  results,
+}, null, 2), 'utf8');
+console.log(`Saved ${OUTPUT_FILE.pathname}`);

Некоторые файлы не были показаны из-за большого количества измененных файлов