Procházet zdrojové kódy

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

Yi Jiarui před 1 měsícem
rodič
revize
80bf91306f
30 změnil soubory, kde provedl 674 přidání a 5 odebrání
  1. 8 0
      .gitignore
  2. 14 3
      docs/JD-FIRST-BATCH-IMPORT.md
  3. 6 0
      proxy.conf.json
  4. 5 0
      src/app/app.routes.ts
  5. 2 0
      src/app/core/config/runtime-config.ts
  6. 63 0
      src/modules/listing-ai/components/listing-dimension-panel/listing-dimension-panel.component.ts
  7. 26 0
      src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.html
  8. 0 0
      src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.scss
  9. 11 0
      src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.ts
  10. 5 0
      src/modules/listing-ai/components/listing-score-summary/listing-score-summary.component.ts
  11. 9 0
      src/modules/listing-ai/listing-ai.routes.ts
  12. 0 0
      src/modules/listing-ai/listing-ai.shared.scss
  13. 19 0
      src/modules/listing-ai/models/listing-ai.models.ts
  14. 49 0
      src/modules/listing-ai/pages/product/listing-ai-product.component.html
  15. 45 0
      src/modules/listing-ai/pages/product/listing-ai-product.component.ts
  16. 1 0
      src/modules/listing-ai/pages/task-detail/listing-score-job-detail.component.html
  17. 18 0
      src/modules/listing-ai/pages/task-detail/listing-score-job-detail.component.ts
  18. 1 0
      src/modules/listing-ai/pages/tasks/listing-score-job-list.component.html
  19. 10 0
      src/modules/listing-ai/pages/tasks/listing-score-job-list.component.ts
  20. 1 0
      src/modules/listing-ai/pages/versions/listing-version-list.component.html
  21. 10 0
      src/modules/listing-ai/pages/versions/listing-version-list.component.ts
  22. 23 0
      src/modules/listing-ai/pages/workbench/listing-ai-workbench.component.html
  23. 37 0
      src/modules/listing-ai/pages/workbench/listing-ai-workbench.component.ts
  24. 11 0
      src/modules/listing-ai/services/listing-ai-api.service.spec.ts
  25. 23 0
      src/modules/listing-ai/services/listing-ai-api.service.ts
  26. 13 0
      src/modules/listing-ai/services/listing-ai-workbench.store.ts
  27. 3 0
      src/modules/shared/components/navigation/navigation.component.ts
  28. 122 0
      tools/jd-detail-diagnostic.mjs
  29. 26 2
      tools/jd-import-products.mjs
  30. 113 0
      tools/parse-target-diagnostic.mjs

+ 8 - 0
.gitignore

@@ -9,6 +9,14 @@ coverage/
 .env.*
 !.env.example
 
+# Local credential-bearing connection notes must never be committed.
+docs/JD-CONNECTION-CONFIG.local.md
+
+# Local implementation/verification notes generated during development.
+docs/JD-PRODUCT-DETAIL-PARSE-VERIFICATION-*.md
+docs/LISTING-AI-IMPLEMENTATION-PLAN.md
+docs/LISTING-AI-IMPLEMENTATION-REPORT-*.md
+
 # Local source workbooks are not committed. Generated normalized data is.
 data/source/
 

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

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

+ 6 - 0
proxy.conf.json

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

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

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

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

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

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

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

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

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

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.scss


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

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

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

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

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

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

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
src/modules/listing-ai/listing-ai.shared.scss


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů