Explorar o código

feat(listing-ai): add fixed-rubric AI scoring pipeline

Yi Jiarui hai 4 semanas
pai
achega
024c9a1514
Modificáronse 33 ficheiros con 2589 adicións e 6 borrados
  1. 13 0
      .env.example
  2. 98 0
      migrations/007_listing_ai.sql
  3. 13 0
      migrations/008_listing_ai_score_identity.sql
  4. 43 1
      package-lock.json
  5. 3 0
      package.json
  6. 17 0
      scripts/score-listings.ts
  7. 37 0
      scripts/sync-jd-listings.ts
  8. 21 0
      scripts/verify-listing-rollout.ts
  9. 30 1
      src/app.ts
  10. 44 0
      src/config/env.ts
  11. 63 1
      src/db/parse-rest.schema.ts
  12. 20 2
      src/local-app.ts
  13. 3 1
      src/local-server.ts
  14. 19 0
      src/modules/listing-ai/clients/jd-product.client.ts
  15. 31 0
      src/modules/listing-ai/clients/jd-sp.client.ts
  16. 14 0
      src/modules/listing-ai/clients/jd-token.provider.ts
  17. 240 0
      src/modules/listing-ai/domain.ts
  18. 345 0
      src/modules/listing-ai/listing-ai.service.ts
  19. 47 0
      src/modules/listing-ai/normalization/domestic-dataset.adapter.ts
  20. 11 0
      src/modules/listing-ai/normalization/html-sanitizer.ts
  21. 22 0
      src/modules/listing-ai/normalization/jd-listing.normalizer.ts
  22. 205 0
      src/modules/listing-ai/repositories/in-memory-listing-ai.repository.ts
  23. 39 0
      src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.ts
  24. 210 0
      src/modules/listing-ai/repositories/postgres-listing-ai.repository.ts
  25. 216 0
      src/modules/listing-ai/routes.ts
  26. 54 0
      src/modules/listing-ai/schemas.ts
  27. 254 0
      src/modules/listing-ai/scoring/ai-rubric.ts
  28. 190 0
      src/modules/listing-ai/scoring/rule-engine.ts
  29. 4 0
      src/server.ts
  30. 25 0
      test/jd-sp-client.signature.test.ts
  31. 70 0
      test/listing-ai.ai-rubric.test.ts
  32. 127 0
      test/listing-ai.routes.test.ts
  33. 61 0
      test/listing-ai.rule-engine.test.ts

+ 13 - 0
.env.example

@@ -34,8 +34,21 @@ FMODE_AI_BASE_URL=https://api.fmode.cn
 FMODE_AI_TOKEN=
 FMODE_AI_TOKEN=
 FMODE_AI_MODEL=deepseek-v4-pro
 FMODE_AI_MODEL=deepseek-v4-pro
 FMODE_AI_TIMEOUT_MS=120000
 FMODE_AI_TIMEOUT_MS=120000
+LISTING_AI_MODEL=deepseek-v4-flash
+LISTING_AI_CONCURRENCY=2
+LISTING_AI_MAX_ITEMS_PER_JOB=10
 SYNC_WORKER_ENABLED=true
 SYNC_WORKER_ENABLED=true
 SYNC_WORKER_POLL_MS=2000
 SYNC_WORKER_POLL_MS=2000
 SYNC_JOB_STALE_AFTER_MS=900000
 SYNC_JOB_STALE_AFTER_MS=900000
 JD_REVIEW_MAX_PAGES=1
 JD_REVIEW_MAX_PAGES=1
+# Server-side only. Never copy these values into Angular runtime config or assets.
+JD_APP_KEY=
+JD_APP_SECRET=
+JD_SOURCE_PARSE_URL=https://server.fmode.cn/parse
+JD_SOURCE_PARSE_APP_ID=
+JD_SOURCE_PARSE_MASTER_KEY=
+JD_LISTING_PAGE_SIZE=100
+JD_LISTING_DETAIL_CONCURRENCY=3
+JD_LISTING_TIMEOUT_MS=30000
+JD_LISTING_RETRIES=2
 CORS_ORIGINS=http://127.0.0.1:4202,http://localhost:4202,http://127.0.0.1:4200,http://localhost:4200
 CORS_ORIGINS=http://127.0.0.1:4202,http://localhost:4202,http://127.0.0.1:4200,http://localhost:4200

+ 98 - 0
migrations/007_listing_ai.sql

@@ -0,0 +1,98 @@
+CREATE TABLE IF NOT EXISTS voc.listing_source_snapshot (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  public_id text NOT NULL UNIQUE,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  platform text NOT NULL CHECK (platform IN ('jd')),
+  shop_id text NOT NULL,
+  product_id text NOT NULL,
+  source_hash text NOT NULL,
+  payload jsonb NOT NULL,
+  detail_status text NOT NULL CHECK (detail_status IN ('available', 'empty', 'failed')),
+  source_modified_at timestamptz,
+  observed_at timestamptz NOT NULL,
+  created_at timestamptz NOT NULL DEFAULT now(),
+  UNIQUE (workspace_id, platform, shop_id, product_id, source_hash)
+);
+CREATE INDEX IF NOT EXISTS listing_source_current_idx
+  ON voc.listing_source_snapshot (workspace_id, platform, product_id, observed_at DESC, id DESC);
+
+CREATE TABLE IF NOT EXISTS voc.listing_score_result (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  public_id text NOT NULL UNIQUE,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  product_id text NOT NULL,
+  source_hash text NOT NULL,
+  rubric_version text NOT NULL,
+  overall_score numeric(6,2),
+  coverage jsonb NOT NULL,
+  result jsonb NOT NULL,
+  model_info jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at timestamptz NOT NULL,
+  UNIQUE (workspace_id, product_id, source_hash, rubric_version)
+);
+CREATE INDEX IF NOT EXISTS listing_score_product_created_idx
+  ON voc.listing_score_result (workspace_id, product_id, created_at DESC, id DESC);
+
+CREATE TABLE IF NOT EXISTS voc.listing_score_job (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  public_id text NOT NULL UNIQUE,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  platform text NOT NULL CHECK (platform IN ('jd')),
+  idempotency_key text NOT NULL,
+  request_hash text NOT NULL,
+  rubric_version text NOT NULL,
+  include_ai_suggestions boolean NOT NULL DEFAULT true,
+  scope jsonb NOT NULL,
+  status text NOT NULL CHECK (status IN ('queued', 'running', 'completed', 'partial', 'failed', 'cancelled')),
+  total integer NOT NULL DEFAULT 0,
+  processed integer NOT NULL DEFAULT 0,
+  succeeded integer NOT NULL DEFAULT 0,
+  partial integer NOT NULL DEFAULT 0,
+  blocked integer NOT NULL DEFAULT 0,
+  failed integer NOT NULL DEFAULT 0,
+  requested_by_external_id text NOT NULL,
+  requested_at timestamptz NOT NULL,
+  started_at timestamptz,
+  completed_at timestamptz,
+  updated_at timestamptz NOT NULL,
+  UNIQUE (workspace_id, idempotency_key)
+);
+CREATE INDEX IF NOT EXISTS listing_score_job_status_idx
+  ON voc.listing_score_job (workspace_id, status, requested_at DESC, id DESC);
+
+CREATE TABLE IF NOT EXISTS voc.listing_score_item (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  public_id text NOT NULL UNIQUE,
+  job_id bigint NOT NULL REFERENCES voc.listing_score_job(id) ON DELETE CASCADE,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  product_id text NOT NULL,
+  source_hash text NOT NULL,
+  status text NOT NULL CHECK (status IN ('queued', 'rules_scored', 'ai_pending', 'scored', 'partial', 'blocked', 'failed')),
+  attempts integer NOT NULL DEFAULT 0,
+  score_result_public_id text,
+  error_code text,
+  error_detail_redacted text,
+  updated_at timestamptz NOT NULL,
+  UNIQUE (job_id, product_id, source_hash)
+);
+CREATE INDEX IF NOT EXISTS listing_score_item_status_idx
+  ON voc.listing_score_item (job_id, status, product_id, id);
+
+CREATE TABLE IF NOT EXISTS voc.listing_version (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  public_id text NOT NULL UNIQUE,
+  workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
+  product_id text NOT NULL,
+  version_no integer NOT NULL,
+  base_source_hash text NOT NULL,
+  base_score_result_public_id text,
+  content jsonb NOT NULL,
+  status text NOT NULL CHECK (status IN ('draft', 'adopted', 'stale', 'archived')),
+  created_by_external_id text NOT NULL,
+  created_at timestamptz NOT NULL,
+  adopted_at timestamptz,
+  UNIQUE (workspace_id, product_id, version_no)
+);
+CREATE INDEX IF NOT EXISTS listing_version_product_idx
+  ON voc.listing_version (workspace_id, product_id, created_at DESC, id DESC);
+

+ 13 - 0
migrations/008_listing_ai_score_identity.sql

@@ -0,0 +1,13 @@
+ALTER TABLE voc.listing_score_result
+  ADD COLUMN IF NOT EXISTS model_key text NOT NULL DEFAULT 'rules',
+  ADD COLUMN IF NOT EXISTS prompt_version text NOT NULL DEFAULT 'rules';
+
+ALTER TABLE voc.listing_score_result
+  DROP CONSTRAINT IF EXISTS listing_score_result_workspace_id_product_id_source_hash_rubric_version_key;
+
+ALTER TABLE voc.listing_score_result
+  DROP CONSTRAINT IF EXISTS listing_score_result_identity_uq;
+
+ALTER TABLE voc.listing_score_result
+  ADD CONSTRAINT listing_score_result_identity_uq
+  UNIQUE (workspace_id, product_id, source_hash, rubric_version, model_key, prompt_version);

+ 43 - 1
package-lock.json

@@ -67,6 +67,7 @@
       "resolved": "https://registry.npmjs.org/@apollo/server/-/server-5.5.0.tgz",
       "resolved": "https://registry.npmjs.org/@apollo/server/-/server-5.5.0.tgz",
       "integrity": "sha512-vWtodBOK/SZwBTJzItECOmLfL8E8pn/IdvP7pnxN5g2tny9iW4+9sxdajE798wV1H2+PYp/rRcl/soSHIBKMPw==",
       "integrity": "sha512-vWtodBOK/SZwBTJzItECOmLfL8E8pn/IdvP7pnxN5g2tny9iW4+9sxdajE798wV1H2+PYp/rRcl/soSHIBKMPw==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@apollo/cache-control-types": "^1.0.3",
         "@apollo/cache-control-types": "^1.0.3",
         "@apollo/server-gateway-interface": "^2.0.0",
         "@apollo/server-gateway-interface": "^2.0.0",
@@ -1775,6 +1776,7 @@
       "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz",
       "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz",
       "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==",
       "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "cluster-key-slot": "1.1.2"
         "cluster-key-slot": "1.1.2"
       },
       },
@@ -3592,6 +3594,7 @@
       "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
       "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
       "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
       "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "accepts": "^2.0.0",
         "accepts": "^2.0.0",
         "body-parser": "^2.2.1",
         "body-parser": "^2.2.1",
@@ -3635,6 +3638,7 @@
       "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
       "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
       "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
       "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "ip-address": "10.1.0"
         "ip-address": "10.1.0"
       },
       },
@@ -4305,6 +4309,7 @@
       "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz",
       "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz",
       "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==",
       "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "engines": {
       "engines": {
         "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
         "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
       }
       }
@@ -5612,6 +5617,42 @@
         "@node-rs/bcrypt": "1.10.7"
         "@node-rs/bcrypt": "1.10.7"
       }
       }
     },
     },
+    "node_modules/parse-server/node_modules/@types/express-serve-static-core": {
+      "version": "4.19.9",
+      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz",
+      "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/parse-server/node_modules/@types/send": {
+      "version": "0.17.6",
+      "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
+      "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@types/mime": "^1",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/parse-server/node_modules/@types/serve-static": {
+      "version": "1.15.10",
+      "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
+      "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*",
+        "@types/send": "<1"
+      }
+    },
     "node_modules/parse-server/node_modules/graphql-upload": {
     "node_modules/parse-server/node_modules/graphql-upload": {
       "version": "15.0.2",
       "version": "15.0.2",
       "resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-15.0.2.tgz",
       "resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-15.0.2.tgz",
@@ -5695,6 +5736,7 @@
       "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
       "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
       "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
       "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "pg-connection-string": "^2.14.0",
         "pg-connection-string": "^2.14.0",
         "pg-pool": "^3.14.0",
         "pg-pool": "^3.14.0",
@@ -5735,7 +5777,6 @@
       "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.21.0.tgz",
       "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.21.0.tgz",
       "integrity": "sha512-IYvk/j+Suhtbo/C3uOf4JLsLK/gWxOTUOmYbDsbKnLaVJDq+KwhwK6ngpRfiCk8eDMS3AmGQABZCv0cREEzHQw==",
       "integrity": "sha512-IYvk/j+Suhtbo/C3uOf4JLsLK/gWxOTUOmYbDsbKnLaVJDq+KwhwK6ngpRfiCk8eDMS3AmGQABZCv0cREEzHQw==",
       "license": "MIT",
       "license": "MIT",
-      "peer": true,
       "peerDependencies": {
       "peerDependencies": {
         "pg": "^8"
         "pg": "^8"
       }
       }
@@ -7130,6 +7171,7 @@
       "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
       "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
       "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
       "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@colors/colors": "^1.6.0",
         "@colors/colors": "^1.6.0",
         "@dabh/diagnostics": "^2.0.8",
         "@dabh/diagnostics": "^2.0.8",

+ 3 - 0
package.json

@@ -18,6 +18,9 @@
     "verify:parse-rest": "tsx scripts/verify-parse-rest.ts",
     "verify:parse-rest": "tsx scripts/verify-parse-rest.ts",
     "import:workbook:parse-rest": "tsx scripts/import-workbook-parse-rest.ts",
     "import:workbook:parse-rest": "tsx scripts/import-workbook-parse-rest.ts",
     "sync:competitors:parse-rest": "tsx scripts/sync-parse-rest-competitors.ts",
     "sync:competitors:parse-rest": "tsx scripts/sync-parse-rest-competitors.ts",
+    "sync:jd-listings": "tsx scripts/sync-jd-listings.ts",
+    "score:jd-listings": "tsx scripts/score-listings.ts",
+    "verify:listing-rollout": "tsx scripts/verify-listing-rollout.ts",
     "seed:knowledge:parse-rest": "tsx scripts/seed-product-knowledge.ts",
     "seed:knowledge:parse-rest": "tsx scripts/seed-product-knowledge.ts",
     "enrich:competitors:local": "tsx scripts/enrich-local-competitors.ts",
     "enrich:competitors:local": "tsx scripts/enrich-local-competitors.ts",
     "import:dataset": "tsx scripts/import-dataset.ts",
     "import:dataset": "tsx scripts/import-dataset.ts",

+ 17 - 0
scripts/score-listings.ts

@@ -0,0 +1,17 @@
+import 'dotenv/config';
+import { loadConfig } from '../src/config/env.js';
+import { createDatabasePool } from '../src/db/pool.js';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { ensureListingParseSchemas } from '../src/db/parse-rest.schema.js';
+import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js';
+import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+import { PostgresListingAiRepository } from '../src/modules/listing-ai/repositories/postgres-listing-ai.repository.js';
+import { LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js';
+
+async function main(){
+  const config=loadConfig();const workspaceId=process.env.SAAS_DEFAULT_WORKSPACE_ID??'demashi';let close=async()=>{};let repository;
+  if(config.storageDriver==='parse_rest'){const client=new ParseRestClient({serverUrl:config.parse.serverUrl,appId:config.parse.appId,masterKey:config.parse.masterKey,timeoutMs:config.parse.timeoutMs});await ensureListingParseSchemas(client);repository=new ParseRestListingAiRepository(client);}else{const pool=createDatabasePool(config);repository=new PostgresListingAiRepository(pool);close=async()=>pool.end();}
+  const service=new ListingAiService(repository,undefined,()=>new Date(),5);const key=process.env.LISTING_SCORE_IDEMPOTENCY_KEY??`${LISTING_RUBRIC_VERSION}-${new Date().toISOString().slice(0,10)}`;
+  try{const job=await service.enqueueScoreJob({workspaceId,platform:'jd',scope:{mode:'filter',filter:{}},rubricVersion:LISTING_RUBRIC_VERSION,includeAiSuggestions:false,idempotencyKey:key,requestedBy:'listing-score-script'});await service.processJob(workspaceId,job.id);let completed=await repository.getJob(workspaceId,job.id);while(completed&&['queued','running'].includes(completed.status)){await new Promise((resolve)=>setTimeout(resolve,1_000));completed=await repository.getJob(workspaceId,job.id);}console.log(JSON.stringify({mode:'completed',job:completed},null,2));if(!completed||!['completed','partial'].includes(completed.status))process.exitCode=2;}finally{await close();}
+}
+main().catch((error)=>{console.error(`[score-listings] ${error instanceof Error?error.message:error}`);process.exitCode=1;});

+ 37 - 0
scripts/sync-jd-listings.ts

@@ -0,0 +1,37 @@
+import 'dotenv/config';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { z } from 'zod';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { ensureListingParseSchemas } from '../src/db/parse-rest.schema.js';
+import { createDatabasePool } from '../src/db/pool.js';
+import { loadConfig } from '../src/config/env.js';
+import { JdProductClient } from '../src/modules/listing-ai/clients/jd-product.client.js';
+import { JdSpClient } from '../src/modules/listing-ai/clients/jd-sp.client.js';
+import { JdTokenProvider } from '../src/modules/listing-ai/clients/jd-token.provider.js';
+import { normalizeJdListing } from '../src/modules/listing-ai/normalization/jd-listing.normalizer.js';
+import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+import { PostgresListingAiRepository } from '../src/modules/listing-ai/repositories/postgres-listing-ai.repository.js';
+
+const args=new Map(process.argv.slice(2).map((arg)=>{const [key,...rest]=arg.split('=');return[key!,rest.join('=')||'true'];}));
+const maxProducts=Math.max(1,Number(args.get('--max-products')??Number.POSITIVE_INFINITY));
+const workspaceId=args.get('--workspace')??process.env.SAAS_DEFAULT_WORKSPACE_ID??'demashi';
+const checkpointPath=resolve(args.get('--checkpoint')??'logs/jd-listing-sync-checkpoint.json');
+const resume=args.get('--resume')==='true';
+const sourceSchema=z.object({JD_SOURCE_PARSE_URL:z.url(),JD_SOURCE_PARSE_APP_ID:z.string().min(1),JD_SOURCE_PARSE_MASTER_KEY:z.string().min(1),JD_APP_KEY:z.string().min(1),JD_APP_SECRET:z.string().min(1)});
+
+async function main(){
+  const source=sourceSchema.parse(process.env);const config=loadConfig();
+  const authClient=new ParseRestClient({serverUrl:source.JD_SOURCE_PARSE_URL,appId:source.JD_SOURCE_PARSE_APP_ID,masterKey:source.JD_SOURCE_PARSE_MASTER_KEY,timeoutMs:config.jdListing.timeoutMs});
+  const auth=await new JdTokenProvider(authClient).latest();
+  const jd=new JdProductClient(new JdSpClient({baseUrl:'https://api-cn.jd.com/rest',appKey:source.JD_APP_KEY,appSecret:source.JD_APP_SECRET,timeoutMs:config.jdListing.timeoutMs,retries:config.jdListing.retries}),config.jdListing.pageSize);
+  let close=async()=>{};let repository;
+  if(config.storageDriver==='parse_rest'){const client=new ParseRestClient({serverUrl:config.parse.serverUrl,appId:config.parse.appId,masterKey:config.parse.masterKey,timeoutMs:config.parse.timeoutMs});await ensureListingParseSchemas(client);repository=new ParseRestListingAiRepository(client);}else{const pool=createDatabasePool(config);repository=new PostgresListingAiRepository(pool);close=async()=>pool.end();}
+  let completed=new Set<string>();
+  if(resume){try{const checkpoint=JSON.parse(await readFile(checkpointPath,'utf8')) as {completed?:string[]};completed=new Set(checkpoint.completed??[]);}catch{} }
+  let listed=0,synced=0,failed=0,total:number|null=null;const pending:Array<Promise<void>>=[];let checkpointWrites=Promise.resolve();
+  const persist=()=>{checkpointWrites=checkpointWrites.then(async()=>{await mkdir(dirname(checkpointPath),{recursive:true});await writeFile(checkpointPath,JSON.stringify({workspaceId,total,listed,synced,failed,completed:[...completed],updatedAt:new Date().toISOString()},null,2));});return checkpointWrites;};
+  const consume=async(row:Record<string,unknown>)=>{const productId=String(row['productId']??row['wareId']??row['id']??'');if(!productId||completed.has(productId))return;try{const detail=await jd.detail(auth.accessToken,productId);const snapshot=normalizeJdListing({workspaceId,shopId:auth.shopId,row,detail});await repository.upsertSources([snapshot]);completed.add(productId);synced+=1;}catch(error){failed+=1;console.error(JSON.stringify({event:'jd_listing_failed',productId,error:error instanceof Error?error.message:'unknown'}));}await persist();};
+  try{for await(const entry of jd.listAll(auth.accessToken,maxProducts)){listed+=1;total=entry.total;const task=consume(entry.row).finally(()=>pending.splice(pending.indexOf(task),1));pending.push(task);if(pending.length>=config.jdListing.detailConcurrency)await Promise.race(pending);}await Promise.all(pending);await persist();console.log(JSON.stringify({mode:'completed',workspaceId,total,listed,synced,failed,completed:completed.size,checkpointPath},null,2));if(failed)process.exitCode=2;}finally{await close();}
+}
+main().catch((error)=>{console.error(`[sync-jd-listings] ${error instanceof Error?error.message:error}`);process.exitCode=1;});

+ 21 - 0
scripts/verify-listing-rollout.ts

@@ -0,0 +1,21 @@
+import 'dotenv/config';
+import { z } from 'zod';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+import type { ListingScoreJob, ListingScoreJobItem, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+
+interface Stored<T>{workspaceId:string;productId?:string;jobId?:string;idempotencyKey?:string;payload:T}
+async function main(){const env=z.object({PARSE_SERVER_URL:z.url(),PARSE_APP_ID:z.string().min(1),PARSE_MASTER_KEY:z.string().min(1),SAAS_DEFAULT_WORKSPACE_ID:z.string().default('demashi')}).parse(process.env);const client=new ParseRestClient({serverUrl:env.PARSE_SERVER_URL,appId:env.PARSE_APP_ID,masterKey:env.PARSE_MASTER_KEY});const workspaceId=env.SAAS_DEFAULT_WORKSPACE_ID;
+  const [sourceRows,resultRows,itemRows,jobRows]=await Promise.all([
+    client.findAll<Stored<ListingSourceSnapshot>>(VOC_PARSE_CLASSES.listingSourceSnapshot,{workspaceId}),
+    client.findAll<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingScoreResult,{workspaceId}),
+    client.findAll<Stored<ListingScoreJobItem>>(VOC_PARSE_CLASSES.listingScoreItem,{workspaceId}),
+    client.findAll<Stored<ListingScoreJob>&{idempotencyKey:string}>(VOC_PARSE_CLASSES.listingScoreJob,{workspaceId}),
+  ]);
+  const latestSources=new Map<string,ListingSourceSnapshot>();for(const row of sourceRows){const value=row.payload;const current=latestSources.get(value.productId);if(!current||value.syncedAt>current.syncedAt)latestSources.set(value.productId,value);}
+  const latestResults=new Map<string,ListingScoreResult>();for(const row of resultRows){const value=row.payload;const current=latestResults.get(value.productId);if(!current||value.createdAt>current.createdAt)latestResults.set(value.productId,value);}
+  const orphanResults=[...latestResults.values()].filter((result)=>!latestSources.has(result.productId));const missingResults=[...latestSources.values()].filter((source)=>!latestResults.has(source.productId));const invalidDimensions=[...latestResults.values()].filter((result)=>result.dimensions.length!==5);const staleResults=[...latestResults.values()].filter((result)=>latestSources.get(result.productId)?.sourceHash!==result.sourceHash);const scores=[...latestResults.values()].map((result)=>result.overallScore).filter((value):value is number=>value!==null);const job=jobRows.map((row)=>row.payload).sort((a,b)=>b.requestedAt.localeCompare(a.requestedAt))[0]??null;const jobItems=job?itemRows.map((row)=>row.payload).filter((item)=>item.jobId===job.id):[];
+  const dimensionUnavailable=Object.fromEntries(['title','selling_points','images','description','specifications'].map((dimension)=>[dimension,[...latestResults.values()].filter((result)=>result.dimensions.find((item)=>item.dimension===dimension)?.score===null).length]));
+  const report={workspaceId,sources:latestSources.size,results:latestResults.size,scored:scores.length,partialResults:latestResults.size-scores.length,blocked:[...latestResults.values()].filter((result)=>result.coverage.status==='blocked').length,averageScore:scores.length?Math.round(scores.reduce((sum,value)=>sum+value,0)/scores.length*10)/10:null,dimensionUnavailable,missingResults:missingResults.length,orphanResults:orphanResults.length,staleResults:staleResults.length,invalidDimensions:invalidDimensions.length,latestJob:job?{id:job.id,status:job.status,total:job.total,processed:job.processed,succeeded:job.succeeded,partial:job.partial,blocked:job.blocked,failed:job.failed}:null,latestJobItems:jobItems.length};console.log(JSON.stringify(report,null,2));if(missingResults.length||orphanResults.length||staleResults.length||invalidDimensions.length||!job||job.processed!==job.total)process.exitCode=2;
+}
+main().catch((error)=>{console.error(`[verify-listing-rollout] ${error instanceof Error?error.message:error}`);process.exitCode=1;});

+ 30 - 1
src/app.ts

@@ -17,6 +17,10 @@ import { createAiGatewayRouter } from './modules/ai-gateway/routes.js';
 import type { AiPromptConfigStore } from './modules/ai-gateway/prompt-config.repository.js';
 import type { AiPromptConfigStore } from './modules/ai-gateway/prompt-config.repository.js';
 import { createProductKnowledgeRouter } from './modules/product-knowledge/routes.js';
 import { createProductKnowledgeRouter } from './modules/product-knowledge/routes.js';
 import type { ProductKnowledgeStore } from './modules/product-knowledge/product-knowledge.store.js';
 import type { ProductKnowledgeStore } from './modules/product-knowledge/product-knowledge.store.js';
+import { InMemoryListingAiRepository } from './modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
+import { FmodeListingAiScoringProvider, ListingAiService } from './modules/listing-ai/listing-ai.service.js';
+import { createListingAiRouter } from './modules/listing-ai/routes.js';
+import type { ListingAiRepository } from './modules/listing-ai/domain.js';
 
 
 export function createApp(input: {
 export function createApp(input: {
   config: AppConfig;
   config: AppConfig;
@@ -28,6 +32,7 @@ export function createApp(input: {
   healthCheck?: () => Promise<{ ready: boolean; missingObjects: string[] }>;
   healthCheck?: () => Promise<{ ready: boolean; missingObjects: string[] }>;
   aiPromptConfigs?: AiPromptConfigStore;
   aiPromptConfigs?: AiPromptConfigStore;
   productKnowledge?: ProductKnowledgeStore;
   productKnowledge?: ProductKnowledgeStore;
+  listingAiRepository?: ListingAiRepository;
 }) {
 }) {
   const app = express();
   const app = express();
   app.disable('x-powered-by');
   app.disable('x-powered-by');
@@ -76,6 +81,11 @@ export function createApp(input: {
           to_regclass('voc.action_item')::text AS action_item,
           to_regclass('voc.action_item')::text AS action_item,
           to_regclass('voc.alert')::text AS alert,
           to_regclass('voc.alert')::text AS alert,
           to_regclass('voc.audit_log')::text AS audit_log
           to_regclass('voc.audit_log')::text AS audit_log
+          ,to_regclass('voc.listing_source_snapshot')::text AS listing_source_snapshot
+          ,to_regclass('voc.listing_score_job')::text AS listing_score_job
+          ,to_regclass('voc.listing_score_item')::text AS listing_score_item
+          ,to_regclass('voc.listing_score_result')::text AS listing_score_result
+          ,to_regclass('voc.listing_version')::text AS listing_version
       `);
       `);
       const readiness = result.rows[0] ?? {};
       const readiness = result.rows[0] ?? {};
       const missingTables = Object.entries(readiness)
       const missingTables = Object.entries(readiness)
@@ -113,7 +123,8 @@ export function createApp(input: {
   const platform = input.platformRepository ?? new PostgresPlatformRepository(input.pool!);
   const platform = input.platformRepository ?? new PostgresPlatformRepository(input.pool!);
   const access = new WorkspaceAccessService(platform);
   const access = new WorkspaceAccessService(platform);
   app.use('/api', createAuthenticationMiddleware(createAuthenticator(input.config)));
   app.use('/api', createAuthenticationMiddleware(createAuthenticator(input.config)));
-  app.use('/api/ai', createAiGatewayRouter(new FmodeAiClient(input.config.ai), input.aiPromptConfigs));
+  const aiClient = new FmodeAiClient(input.config.ai);
+  app.use('/api/ai', createAiGatewayRouter(aiClient, input.aiPromptConfigs));
 
 
   const jobs = input.jobs ?? new SyncJobRepository(input.pool!);
   const jobs = input.jobs ?? new SyncJobRepository(input.pool!);
   const sync = new SyncService(jobs);
   const sync = new SyncService(jobs);
@@ -127,6 +138,24 @@ export function createApp(input: {
     defaultWorkspaceId: input.config.auth.defaultWorkspaceId,
     defaultWorkspaceId: input.config.auth.defaultWorkspaceId,
   }));
   }));
   app.use('/api/saas', createSaasPlatformRouter({ repository: platform, access }));
   app.use('/api/saas', createSaasPlatformRouter({ repository: platform, access }));
+  const listingAi = new ListingAiService(
+    input.listingAiRepository ?? new InMemoryListingAiRepository(),
+    new FmodeListingAiScoringProvider(aiClient, input.config.listingAi.model),
+    () => new Date(),
+    input.config.listingAi.concurrency,
+    input.config.listingAi.maxAiItemsPerJob,
+  );
+  app.use('/api/listing-ai', createListingAiRouter({
+    service: listingAi,
+    access,
+    audit: platform,
+    defaultWorkspaceId: input.config.auth.defaultWorkspaceId,
+  }));
+  queueMicrotask(() => {
+    void listingAi.resumePendingJobs(input.config.auth.defaultWorkspaceId).catch((error: unknown) => {
+      console.error('[listing-ai] failed to resume persisted score jobs', error);
+    });
+  });
   if (input.productKnowledge) {
   if (input.productKnowledge) {
     app.use('/api/knowledge', createProductKnowledgeRouter({
     app.use('/api/knowledge', createProductKnowledgeRouter({
       store: input.productKnowledge,
       store: input.productKnowledge,

+ 44 - 0
src/config/env.ts

@@ -36,10 +36,22 @@ const environmentSchema = z.object({
   FMODE_AI_TOKEN: optionalNonEmptyString,
   FMODE_AI_TOKEN: optionalNonEmptyString,
   FMODE_AI_MODEL: z.string().min(1).default('deepseek-v4-pro'),
   FMODE_AI_MODEL: z.string().min(1).default('deepseek-v4-pro'),
   FMODE_AI_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(300_000).default(120_000),
   FMODE_AI_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(300_000).default(120_000),
+  LISTING_AI_MODEL: z.string().min(1).default('deepseek-v4-flash'),
+  LISTING_AI_CONCURRENCY: z.coerce.number().int().min(1).max(10).default(2),
+  LISTING_AI_MAX_ITEMS_PER_JOB: z.coerce.number().int().min(1).max(10_000).default(10),
   SYNC_WORKER_ENABLED: z.enum(['true', 'false']).default('true'),
   SYNC_WORKER_ENABLED: z.enum(['true', 'false']).default('true'),
   SYNC_WORKER_POLL_MS: z.coerce.number().int().min(500).max(60_000).default(2_000),
   SYNC_WORKER_POLL_MS: z.coerce.number().int().min(500).max(60_000).default(2_000),
   SYNC_JOB_STALE_AFTER_MS: z.coerce.number().int().min(60_000).max(86_400_000).default(900_000),
   SYNC_JOB_STALE_AFTER_MS: z.coerce.number().int().min(60_000).max(86_400_000).default(900_000),
   JD_REVIEW_MAX_PAGES: z.coerce.number().int().min(1).max(10).default(1),
   JD_REVIEW_MAX_PAGES: z.coerce.number().int().min(1).max(10).default(1),
+  JD_APP_KEY: optionalNonEmptyString,
+  JD_APP_SECRET: optionalNonEmptyString,
+  JD_SOURCE_PARSE_URL: optionalNonEmptyString,
+  JD_SOURCE_PARSE_APP_ID: optionalNonEmptyString,
+  JD_SOURCE_PARSE_MASTER_KEY: optionalNonEmptyString,
+  JD_LISTING_PAGE_SIZE: z.coerce.number().int().min(1).max(100).default(100),
+  JD_LISTING_DETAIL_CONCURRENCY: z.coerce.number().int().min(1).max(10).default(3),
+  JD_LISTING_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(120_000).default(30_000),
+  JD_LISTING_RETRIES: z.coerce.number().int().min(0).max(5).default(2),
   CORS_ORIGINS: z.string().min(1),
   CORS_ORIGINS: z.string().min(1),
 });
 });
 
 
@@ -84,12 +96,28 @@ export type AppConfig = {
     defaultModel: string;
     defaultModel: string;
     timeoutMs: number;
     timeoutMs: number;
   };
   };
+  listingAi: {
+    model: string;
+    concurrency: number;
+    maxAiItemsPerJob: number;
+  };
   worker: {
   worker: {
     enabled: boolean;
     enabled: boolean;
     pollMs: number;
     pollMs: number;
     staleAfterMs: number;
     staleAfterMs: number;
     reviewMaxPages: number;
     reviewMaxPages: number;
   };
   };
+  jdListing: {
+    appKey: string;
+    appSecret: string;
+    sourceParseUrl: string;
+    sourceParseAppId: string;
+    sourceParseMasterKey: string;
+    pageSize: number;
+    detailConcurrency: number;
+    timeoutMs: number;
+    retries: number;
+  };
   corsOrigins: string[];
   corsOrigins: string[];
 };
 };
 
 
@@ -169,12 +197,28 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
       defaultModel: value.FMODE_AI_MODEL,
       defaultModel: value.FMODE_AI_MODEL,
       timeoutMs: value.FMODE_AI_TIMEOUT_MS,
       timeoutMs: value.FMODE_AI_TIMEOUT_MS,
     },
     },
+    listingAi: {
+      model: value.LISTING_AI_MODEL,
+      concurrency: value.LISTING_AI_CONCURRENCY,
+      maxAiItemsPerJob: value.LISTING_AI_MAX_ITEMS_PER_JOB,
+    },
     worker: {
     worker: {
       enabled: value.SYNC_WORKER_ENABLED === 'true',
       enabled: value.SYNC_WORKER_ENABLED === 'true',
       pollMs: value.SYNC_WORKER_POLL_MS,
       pollMs: value.SYNC_WORKER_POLL_MS,
       staleAfterMs: value.SYNC_JOB_STALE_AFTER_MS,
       staleAfterMs: value.SYNC_JOB_STALE_AFTER_MS,
       reviewMaxPages: value.JD_REVIEW_MAX_PAGES,
       reviewMaxPages: value.JD_REVIEW_MAX_PAGES,
     },
     },
+    jdListing: {
+      appKey: value.JD_APP_KEY ?? '',
+      appSecret: value.JD_APP_SECRET ?? '',
+      sourceParseUrl: value.JD_SOURCE_PARSE_URL ?? '',
+      sourceParseAppId: value.JD_SOURCE_PARSE_APP_ID ?? '',
+      sourceParseMasterKey: value.JD_SOURCE_PARSE_MASTER_KEY ?? '',
+      pageSize: value.JD_LISTING_PAGE_SIZE,
+      detailConcurrency: value.JD_LISTING_DETAIL_CONCURRENCY,
+      timeoutMs: value.JD_LISTING_TIMEOUT_MS,
+      retries: value.JD_LISTING_RETRIES,
+    },
     corsOrigins: value.CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean),
     corsOrigins: value.CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean),
   };
   };
 }
 }

+ 63 - 1
src/db/parse-rest.schema.ts

@@ -29,6 +29,11 @@ export const VOC_PARSE_CLASSES = {
   auditLog: 'VocAuditLog',
   auditLog: 'VocAuditLog',
   promptConfig: 'VocPromptConfig',
   promptConfig: 'VocPromptConfig',
   productKnowledge: 'VocProductKnowledge',
   productKnowledge: 'VocProductKnowledge',
+  listingSourceSnapshot: 'VocListingSourceSnapshot',
+  listingScoreJob: 'VocListingScoreJob',
+  listingScoreItem: 'VocListingScoreItem',
+  listingScoreResult: 'VocListingScoreResult',
+  listingVersion: 'VocListingVersion',
 } as const;
 } as const;
 
 
 export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
 export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
@@ -242,6 +247,47 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       'updatedAt',
       'updatedAt',
     ),
     ),
   },
   },
+  {
+    className: VOC_PARSE_CLASSES.listingSourceSnapshot,
+    fields: {
+      naturalKey: string(true), workspaceId: string(true), platform: string(true), shopId: string(true),
+      productId: string(true), sourceHash: string(true), detailStatus: string(true), payload: object(true),
+      sourceModifiedAt: date(), observedAt: date(true),
+    },
+    indexes: indexes('voc_listing_source', 'naturalKey', 'workspaceId', 'platform', 'productId', 'sourceHash', 'observedAt'),
+  },
+  {
+    className: VOC_PARSE_CLASSES.listingScoreJob,
+    fields: {
+      publicId: string(true), naturalKey: string(true), workspaceId: string(true), platform: string(true),
+      idempotencyKey: string(true), requestHash: string(true), status: string(true), payload: object(true), requestedAt: date(true),
+    },
+    indexes: indexes('voc_listing_job', 'publicId', 'naturalKey', 'workspaceId', 'status', 'requestedAt'),
+  },
+  {
+    className: VOC_PARSE_CLASSES.listingScoreItem,
+    fields: {
+      publicId: string(true), naturalKey: string(true), workspaceId: string(true), jobId: string(true),
+      productId: string(true), sourceHash: string(true), status: string(true), payload: object(true),
+    },
+    indexes: indexes('voc_listing_item', 'publicId', 'naturalKey', 'workspaceId', 'jobId', 'status', 'productId'),
+  },
+  {
+    className: VOC_PARSE_CLASSES.listingScoreResult,
+    fields: {
+      publicId: string(true), naturalKey: string(true), workspaceId: string(true), productId: string(true),
+      sourceHash: string(true), rubricVersion: string(true), overallScore: number(), payload: object(true), scoredAt: date(true),
+    },
+    indexes: indexes('voc_listing_result', 'publicId', 'naturalKey', 'workspaceId', 'productId', 'sourceHash', 'rubricVersion', 'scoredAt'),
+  },
+  {
+    className: VOC_PARSE_CLASSES.listingVersion,
+    fields: {
+      publicId: string(true), naturalKey: string(true), workspaceId: string(true), productId: string(true),
+      versionNo: number(true), baseSourceHash: string(true), status: string(true), payload: object(true), versionCreatedAt: date(true),
+    },
+    indexes: indexes('voc_listing_version', 'publicId', 'naturalKey', 'workspaceId', 'productId', 'status', 'versionCreatedAt'),
+  },
 ];
 ];
 
 
 export interface ParseSchemaSyncResult {
 export interface ParseSchemaSyncResult {
@@ -251,9 +297,25 @@ export interface ParseSchemaSyncResult {
 }
 }
 
 
 export async function ensureVocParseSchemas(client: ParseRestClient): Promise<ParseSchemaSyncResult> {
 export async function ensureVocParseSchemas(client: ParseRestClient): Promise<ParseSchemaSyncResult> {
+  return ensureParseSchemas(client, VOC_PARSE_SCHEMAS);
+}
+
+export const LISTING_PARSE_SCHEMAS = VOC_PARSE_SCHEMAS.filter((schema) => (
+  schema.className === VOC_PARSE_CLASSES.listingSourceSnapshot
+  || schema.className === VOC_PARSE_CLASSES.listingScoreJob
+  || schema.className === VOC_PARSE_CLASSES.listingScoreItem
+  || schema.className === VOC_PARSE_CLASSES.listingScoreResult
+  || schema.className === VOC_PARSE_CLASSES.listingVersion
+));
+
+export async function ensureListingParseSchemas(client: ParseRestClient): Promise<ParseSchemaSyncResult> {
+  return ensureParseSchemas(client, LISTING_PARSE_SCHEMAS);
+}
+
+async function ensureParseSchemas(client: ParseRestClient, schemas: ParseClassSchema[]): Promise<ParseSchemaSyncResult> {
   const existing = new Map((await client.schemas()).map((schema) => [schema.className, schema]));
   const existing = new Map((await client.schemas()).map((schema) => [schema.className, schema]));
   const result: ParseSchemaSyncResult = { created: [], updated: [], unchanged: [] };
   const result: ParseSchemaSyncResult = { created: [], updated: [], unchanged: [] };
-  for (const schema of VOC_PARSE_SCHEMAS) {
+  for (const schema of schemas) {
     const current = existing.get(schema.className);
     const current = existing.get(schema.className);
     if (!current) {
     if (!current) {
       await client.createSchema(schema);
       await client.createSchema(schema);

+ 20 - 2
src/local-app.ts

@@ -15,13 +15,20 @@ import { createAiGatewayRouter } from './modules/ai-gateway/routes.js';
 import { createProductKnowledgeRouter } from './modules/product-knowledge/routes.js';
 import { createProductKnowledgeRouter } from './modules/product-knowledge/routes.js';
 import { LocalProductKnowledgeStore } from './modules/product-knowledge/local-product-knowledge.store.js';
 import { LocalProductKnowledgeStore } from './modules/product-knowledge/local-product-knowledge.store.js';
 import type { ProductKnowledgeStore } from './modules/product-knowledge/product-knowledge.store.js';
 import type { ProductKnowledgeStore } from './modules/product-knowledge/product-knowledge.store.js';
+import { InMemoryListingAiRepository } from './modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
+import { listingSourcesFromDomesticDataset } from './modules/listing-ai/normalization/domestic-dataset.adapter.js';
+import { FmodeListingAiScoringProvider, ListingAiService } from './modules/listing-ai/listing-ai.service.js';
+import { createListingAiRouter } from './modules/listing-ai/routes.js';
+import type { ListingAiRepository } from './modules/listing-ai/domain.js';
 
 
 export function createLocalDemoApp(input: {
 export function createLocalDemoApp(input: {
   dataset: DomesticDataset;
   dataset: DomesticDataset;
   corsOrigins: string[];
   corsOrigins: string[];
   workspaceId?: string;
   workspaceId?: string;
   ai?: AiGatewayConfig;
   ai?: AiGatewayConfig;
+  listingAiModel?: string;
   productKnowledge?: ProductKnowledgeStore;
   productKnowledge?: ProductKnowledgeStore;
+  listingAiRepository?: ListingAiRepository;
 }) {
 }) {
   const app = express();
   const app = express();
   app.disable('x-powered-by');
   app.disable('x-powered-by');
@@ -44,12 +51,13 @@ export function createLocalDemoApp(input: {
   const platform = new LocalPlatformRepository(input.dataset, jobs, localPrincipal, workspaceId);
   const platform = new LocalPlatformRepository(input.dataset, jobs, localPrincipal, workspaceId);
   const access = new WorkspaceAccessService(platform);
   const access = new WorkspaceAccessService(platform);
   app.use('/api', createAuthenticationMiddleware(new DisabledAuthenticator(localPrincipal)));
   app.use('/api', createAuthenticationMiddleware(new DisabledAuthenticator(localPrincipal)));
-  app.use('/api/ai', createAiGatewayRouter(new FmodeAiClient(input.ai ?? {
+  const aiClient = new FmodeAiClient(input.ai ?? {
     baseUrl: 'https://api.fmode.cn',
     baseUrl: 'https://api.fmode.cn',
     token: '',
     token: '',
     defaultModel: 'deepseek-v4-pro',
     defaultModel: 'deepseek-v4-pro',
     timeoutMs: 120_000,
     timeoutMs: 120_000,
-  })));
+  });
+  app.use('/api/ai', createAiGatewayRouter(aiClient));
   const sync = new SyncService(jobs);
   const sync = new SyncService(jobs);
   const snapshot = new LocalSnapshotService(input.dataset, workspaceId);
   const snapshot = new LocalSnapshotService(input.dataset, workspaceId);
   const productKnowledge = input.productKnowledge ?? new LocalProductKnowledgeStore(input.dataset, workspaceId);
   const productKnowledge = input.productKnowledge ?? new LocalProductKnowledgeStore(input.dataset, workspaceId);
@@ -80,6 +88,16 @@ export function createLocalDemoApp(input: {
     defaultWorkspaceId: workspaceId,
     defaultWorkspaceId: workspaceId,
   }));
   }));
   app.use('/api/saas', createSaasPlatformRouter({ repository: platform, access }));
   app.use('/api/saas', createSaasPlatformRouter({ repository: platform, access }));
+  const listingRepository = input.listingAiRepository ?? new InMemoryListingAiRepository(
+    listingSourcesFromDomesticDataset(input.dataset, workspaceId),
+  );
+  const listingAi = new ListingAiService(listingRepository, new FmodeListingAiScoringProvider(aiClient, input.listingAiModel ?? 'deepseek-v4-flash'));
+  app.use('/api/listing-ai', createListingAiRouter({
+    service: listingAi,
+    access,
+    audit: platform,
+    defaultWorkspaceId: workspaceId,
+  }));
   app.use('/api/knowledge', createProductKnowledgeRouter({
   app.use('/api/knowledge', createProductKnowledgeRouter({
     store: productKnowledge,
     store: productKnowledge,
     repository: platform,
     repository: platform,

+ 3 - 1
src/local-server.ts

@@ -24,10 +24,11 @@ const localEnvironmentSchema = z.object({
   FMODE_AI_TOKEN: z.string().default(''),
   FMODE_AI_TOKEN: z.string().default(''),
   FMODE_AI_MODEL: z.string().min(1).default('deepseek-v4-pro'),
   FMODE_AI_MODEL: z.string().min(1).default('deepseek-v4-pro'),
   FMODE_AI_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(300_000).default(120_000),
   FMODE_AI_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(300_000).default(120_000),
+  LISTING_AI_MODEL: z.string().min(1).default('deepseek-v4-flash'),
 });
 });
 
 
 function defaultDatasetPath(): string {
 function defaultDatasetPath(): string {
-  return resolve(process.cwd(), '..', '..', 'Saas-voc', 'src', 'assets', 'data', 'demashi-summary.json');
+  return resolve(process.cwd(), '..', 'Saas-voc', 'src', 'assets', 'data', 'demashi-summary.json');
 }
 }
 
 
 async function loadDataset(path: string): Promise<DomesticDataset> {
 async function loadDataset(path: string): Promise<DomesticDataset> {
@@ -126,6 +127,7 @@ async function main(): Promise<void> {
       defaultModel: config.FMODE_AI_MODEL,
       defaultModel: config.FMODE_AI_MODEL,
       timeoutMs: config.FMODE_AI_TIMEOUT_MS,
       timeoutMs: config.FMODE_AI_TIMEOUT_MS,
     },
     },
+    listingAiModel: config.LISTING_AI_MODEL,
   });
   });
   const server = createServer(app);
   const server = createServer(app);
 
 

+ 19 - 0
src/modules/listing-ai/clients/jd-product.client.ts

@@ -0,0 +1,19 @@
+import type { JdSpClient } from './jd-sp.client.js';
+
+interface JdPage<T>{success?:boolean;data?:T[];paginationData?:{totalItems?:number;totalPages?:number;page?:number;pageSize?:number}}
+export class JdProductClient {
+  constructor(private readonly client:JdSpClient,private readonly pageSize=100){}
+  async listPage(accessToken:string,page:number):Promise<JdPage<Record<string,unknown>>>{return this.client.get('/sp-product/v0/products',{scopeSet:'productName',pageSize:this.pageSize,page},accessToken);}
+  async detail(accessToken:string,productId:string):Promise<Record<string,unknown>>{const body=await this.client.get<JdPage<Record<string,unknown>> & {data?:Record<string,unknown>}> (`/sp-product/v0/products/${encodeURIComponent(productId)}`,{scene:'pop'},accessToken,{productId});return (body.data??body) as Record<string,unknown>;}
+  async *listAll(accessToken:string,maxProducts=Number.POSITIVE_INFINITY):AsyncGenerator<{row:Record<string,unknown>;index:number;total:number|null}>{
+    let emitted=0;let knownTotal:number|null=null;let stagnantPasses=0;const seen=new Set<string>();
+    for(let pass=1;pass<=10;pass+=1){let page=1;let addedThisPass=0;
+      for(;;){const response=await this.listPage(accessToken,page);const rows=Array.isArray(response.data)?response.data:[];knownTotal=response.paginationData?.totalItems??knownTotal;if(!rows.length)break;
+        for(const row of rows){const id=String(row['productId']??row['wareId']??row['id']??'');if(!id||seen.has(id))continue;seen.add(id);addedThisPass+=1;yield{row,index:emitted,total:knownTotal};emitted+=1;if(emitted>=maxProducts)return;if(knownTotal!==null&&emitted>=knownTotal)return;}
+        const totalPages=response.paginationData?.totalPages;if((totalPages!==undefined&&page>=totalPages)||rows.length<this.pageSize)break;page+=1;if(page>10_000)throw new Error('jd_product_pagination_guard');
+      }
+      const target=knownTotal===null?maxProducts:Math.min(maxProducts,knownTotal);if(emitted>=target)return;stagnantPasses=addedThisPass===0?stagnantPasses+1:0;if(stagnantPasses>=3)throw new Error(`jd_product_catalog_incomplete:${emitted}/${target}`);
+    }
+    const target=knownTotal===null?maxProducts:Math.min(maxProducts,knownTotal);if(emitted<target)throw new Error(`jd_product_catalog_incomplete:${emitted}/${target}`);
+  }
+}

+ 31 - 0
src/modules/listing-ai/clients/jd-sp.client.ts

@@ -0,0 +1,31 @@
+import { createHash } from 'node:crypto';
+
+export interface JdSpClientConfig { baseUrl: string; appKey: string; appSecret: string; timeoutMs: number; retries: number }
+export class JdSpError extends Error {
+  constructor(readonly httpStatus:number,readonly code:string|null,message:string){super(message);this.name='JdSpError';}
+}
+
+export class JdSpClient {
+  constructor(private readonly config:JdSpClientConfig,private readonly fetchImpl:typeof fetch=fetch){}
+  async get<T>(path:string,query:Record<string,string|number|boolean>,accessToken:string,pathFields:Record<string,string|number>={}):Promise<T>{
+    for(let attempt=0;attempt<=this.config.retries;attempt+=1){
+      const timestamp=String(Date.now());
+      const common={'X-JOS-App-Key':this.config.appKey,'X-JOS-Access-Token':accessToken,'X-JOS-Timestamp':timestamp};
+      const signed={...query,...pathFields,...common};
+      const plain=Object.keys(signed).sort().map((key)=>`${key}${signed[key as keyof typeof signed]??''}`).join('');
+      const signature=createHash('md5').update(`${this.config.appSecret}${plain}${this.config.appSecret}`).digest('hex').toUpperCase();
+      const search=new URLSearchParams(Object.entries(query).map(([key,value])=>[key,String(value)]));
+      let response:Response;
+      try{response=await this.fetchImpl(`${this.config.baseUrl.replace(/\/+$/,'')}${path}?${search}`,{headers:{...common,'X-JOS-Sign-Method':'md5','X-JOS-Request-Identity':'vender','X-JOS-Sign':signature},signal:AbortSignal.timeout(this.config.timeoutMs)});}catch(error){if(attempt<this.config.retries){await this.delay(attempt);continue;}throw new JdSpError(503,null,error instanceof Error?error.message:'JD request failed');}
+      const body=await response.json().catch(()=>null) as {success?:boolean;errorList?:Array<{code?:string;message?:string;details?:string}>;code?:string;message?:string}|null;
+      const error=body?.errorList?.[0];
+      if(response.ok&&body?.success!==false)return body as T;
+      const code=error?.code??body?.code??null;const message=error?.message??error?.details??body?.message??`JD HTTP ${response.status}`;
+      if((response.status===429||response.status>=500)&&attempt<this.config.retries){await this.delay(attempt);continue;}
+      throw new JdSpError(response.status,code,message);
+    }
+    throw new JdSpError(503,null,'JD retry budget exhausted');
+  }
+  private delay(attempt:number){return new Promise((resolve)=>setTimeout(resolve,Math.min(5_000,250*2**attempt)));}
+}
+

+ 14 - 0
src/modules/listing-ai/clients/jd-token.provider.ts

@@ -0,0 +1,14 @@
+import type { ParseRestClient } from '../../../db/parse-rest.client.js';
+
+interface EcomAuthRecord { shop_id?:string|number; data?:{access_token?:string;uid?:string|number} }
+export interface JdAuthorization { accessToken:string;shopId:string }
+export class JdTokenProvider {
+  constructor(private readonly client:ParseRestClient){}
+  async latest():Promise<JdAuthorization>{
+    const result=await this.client.find<EcomAuthRecord>('EcomAuth',{where:{platform:'jd',type:'access_token'},order:'-createdAt',limit:1});
+    const row=result.results[0];const accessToken=row?.data?.access_token?.trim();const shopId=String(row?.shop_id??row?.data?.uid??'').trim();
+    if(!accessToken||!shopId)throw new Error('jd_authorization_missing');
+    return{accessToken,shopId};
+  }
+}
+

+ 240 - 0
src/modules/listing-ai/domain.ts

@@ -0,0 +1,240 @@
+export type ListingPlatform = 'jd';
+export type ListingCoverageStatus = 'eligible' | 'partial' | 'blocked';
+export type ListingDimension = 'title' | 'selling_points' | 'images' | 'description' | 'specifications';
+export type ListingScoreItemStatus = 'queued' | 'rules_scored' | 'ai_pending' | 'scored' | 'partial' | 'blocked' | 'failed';
+export type ListingScoreJobStatus = 'queued' | 'running' | 'completed' | 'partial' | 'failed' | 'cancelled';
+
+export interface ListingFeature {
+  key: string;
+  value: string;
+}
+
+export interface ListingAttribute {
+  id: string;
+  name: string;
+  values: string[];
+}
+
+export interface ListingImage {
+  url: string;
+  order: number | null;
+  isPrimary: boolean | null;
+  gptFlag: boolean | null;
+}
+
+export interface ListingSku {
+  skuId: string;
+  name: string | null;
+  price: number | null;
+  stock: number | null;
+  status: string | null;
+  attributes: ListingAttribute[];
+}
+
+export interface ListingSourceSnapshot {
+  id: string;
+  workspaceId: string;
+  platform: ListingPlatform;
+  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: ListingFeature[];
+  attributes: ListingAttribute[];
+  images: ListingImage[];
+  skus: ListingSku[];
+  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 ListingCoverage {
+  percent: number;
+  missing: string[];
+  status: ListingCoverageStatus;
+}
+
+export interface ListingRuleEvidence {
+  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: ListingRuleEvidence[];
+  suggestions: string[];
+}
+
+export interface ListingScoreResult {
+  id: string;
+  workspaceId: 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: ListingVersion['content'] | 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: ListingPlatform;
+  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 ListingScoreJobItem {
+  id: string;
+  jobId: string;
+  workspaceId: string;
+  productId: string;
+  sourceHash: string;
+  status: ListingScoreItemStatus;
+  attempts: number;
+  scoreResultId: string | null;
+  errorCode: string | null;
+  errorDetail: string | null;
+  updatedAt: string;
+}
+
+export interface ListingScoreJob {
+  id: string;
+  workspaceId: string;
+  platform: ListingPlatform;
+  idempotencyKey: string;
+  requestHash: string;
+  rubricVersion: string;
+  includeAiSuggestions: boolean;
+  scope: ListingScoreScope;
+  status: ListingScoreJobStatus;
+  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 type ListingScoreScope =
+  | { mode: 'selected'; productIds: string[] }
+  | { mode: 'filter'; filter: ListingProductFilter };
+
+export interface ListingProductFilter {
+  search?: string | undefined;
+  categoryId?: string | undefined;
+  itemStatus?: string | undefined;
+  scoreStatus?: 'unscored' | 'scored' | 'partial' | 'blocked' | 'failed' | undefined;
+  coverageStatus?: ListingCoverageStatus | undefined;
+  minScore?: number | undefined;
+  maxScore?: number | undefined;
+}
+
+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 ListingCursorPage<T> {
+  items: T[];
+  nextCursor: string | null;
+}
+
+export interface ListingProductQuery extends ListingProductFilter {
+  workspaceId: string;
+  platform: ListingPlatform;
+  limit: number;
+  cursor: string | null;
+  sort?: 'productId' | 'score_asc' | 'score_desc' | 'updated_desc' | undefined;
+}
+
+export interface ListingAiRepository {
+  upsertSources(sources: ListingSourceSnapshot[]): Promise<void>;
+  listAllSources(workspaceId: string, platform: ListingPlatform): Promise<ListingSourceSnapshot[]>;
+  listProducts(query: ListingProductQuery): Promise<ListingCursorPage<ListingProductSummary>>;
+  getSource(workspaceId: string, platform: ListingPlatform, productId: string): Promise<ListingSourceSnapshot | null>;
+  getLatestScore(workspaceId: string, productId: string, rubricVersion?: string): Promise<ListingScoreResult | null>;
+  listLatestScores(workspaceId: string): Promise<ListingScoreResult[]>;
+  saveScore(result: ListingScoreResult): Promise<ListingScoreResult>;
+  createJob(job: ListingScoreJob, items: ListingScoreJobItem[]): Promise<{ job: ListingScoreJob; created: boolean }>;
+  updateJob(job: ListingScoreJob): Promise<void>;
+  getJob(workspaceId: string, jobId: string): Promise<ListingScoreJob | null>;
+  listJobs(workspaceId: string, status: string, limit: number, cursor: string | null): Promise<ListingCursorPage<ListingScoreJob>>;
+  listJobItems(workspaceId: string, jobId: string, status: string, limit: number, cursor: string | null): Promise<ListingCursorPage<ListingScoreJobItem>>;
+  getJobItems(workspaceId: string, jobId: string): Promise<ListingScoreJobItem[]>;
+  updateJobItem(item: ListingScoreJobItem): Promise<void>;
+  createVersion(version: ListingVersion): Promise<ListingVersion>;
+  getVersion(workspaceId: string, versionId: string): Promise<ListingVersion | null>;
+  updateVersion(version: ListingVersion): Promise<void>;
+  listVersions(workspaceId: string, productId: string | null, limit: number, cursor: string | null): Promise<ListingCursorPage<ListingVersion>>;
+}

+ 345 - 0
src/modules/listing-ai/listing-ai.service.ts

@@ -0,0 +1,345 @@
+import { randomUUID } from 'node:crypto';
+import { ApiError } from '../../http/api-error.js';
+import type { FmodeAiClient } from '../ai-gateway/client.js';
+import type {
+  ListingAiRepository,
+  ListingCatalogSummary,
+  ListingProductFilter,
+  ListingScoreJob,
+  ListingScoreJobItem,
+  ListingScoreResult,
+  ListingScoreScope,
+  ListingSourceSnapshot,
+  ListingVersion,
+} from './domain.js';
+import { canonicalHash, LISTING_RUBRIC_VERSION, listingCoverage, scoreListing } from './scoring/rule-engine.js';
+import {
+  composeListingAiScore,
+  LISTING_AI_PROMPT_VERSION,
+  LISTING_AI_RUBRIC_VERSION,
+  listingAiRubricPrompt,
+  parseListingAiScoreOutput,
+  type ListingAiScoreOutput,
+} from './scoring/ai-rubric.js';
+
+export interface ListingAiScoringProvider {
+  readonly configured: boolean;
+  readonly model: string;
+  score(source: ListingSourceSnapshot, baseline: ListingScoreResult): Promise<ListingAiScoreOutput>;
+}
+
+export class FmodeListingAiScoringProvider implements ListingAiScoringProvider {
+  readonly model: string;
+  constructor(private readonly client: FmodeAiClient, model?: string) { this.model = model?.trim() || client.config.defaultModel; }
+  get configured(): boolean { return this.client.configured; }
+
+  async score(source: ListingSourceSnapshot, baseline: ListingScoreResult): Promise<ListingAiScoreOutput> {
+    const readableFeatures = source.features
+      .filter((item) => item.value.trim() && !/^[01]$/.test(item.value.trim()))
+      .slice(0, 40);
+    const payload = {
+      productId: source.productId,
+      title: source.title,
+      brand: source.brand.name,
+      categoryIds: source.categoryIds,
+      features: readableFeatures,
+      attributes: source.attributes.slice(0, 60),
+      images: source.images.slice(0, 20).map((item) => ({ order: item.order, isPrimary: item.isPrimary, url: item.url })),
+      skus: source.skus.slice(0, 15).map((item) => ({ skuId: item.skuId, name: item.name, attributes: item.attributes })),
+      dimensions: source.dimensions,
+      afterService: source.afterService,
+      descriptionText: `${source.descriptions.desktopHtml ?? ''} ${source.descriptions.mobileHtml ?? ''}`
+        .replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 5_000),
+      structuralBaseline: baseline.dimensions.map((dimension) => ({
+        dimension: dimension.dimension,
+        score: dimension.score,
+        failures: dimension.evidence.filter((item) => item.outcome === 'fail').map((item) => ({ ruleId: item.ruleId, message: item.message })),
+      })),
+    };
+    let lastError: Error | null = null;
+    for (let attempt = 0; attempt < 2; attempt += 1) {
+      try {
+        const response = await this.client.createChatCompletion({
+          stream: false,
+          model: this.model,
+          temperature: 0,
+          // The selected DeepSeek gateway model emits private reasoning before
+          // the JSON payload. 4k truncates valid responses before the schema;
+          // 16k is a ceiling, not a target, and the per-job budget is capped at 10.
+          max_tokens: 16_000,
+          thinking: { type: 'disabled' },
+          response_format: { type: 'json_object' },
+          messages: [
+            { role: 'system', content: listingAiRubricPrompt() },
+            { role: 'user', content: JSON.stringify(payload) },
+          ],
+        });
+        if (!response.ok) { lastError = new Error(`ai_upstream_${response.status}`); continue; }
+        const body = await response.json() as { choices?: Array<{ message?: { content?: string; reasoning_content?: string } }> };
+        const content = body.choices?.[0]?.message?.content || body.choices?.[0]?.message?.reasoning_content;
+        const parsed = content ? parseListingAiScoreOutput(content) : null;
+        if (parsed) return parsed;
+        lastError = new Error('ai_invalid_score_output');
+      } catch (error) {
+        lastError = error instanceof Error ? error : new Error('ai_upstream_error');
+      }
+    }
+    throw lastError ?? new Error('ai_invalid_score_output');
+  }
+}
+
+export class ListingAiService {
+  private readonly runningJobs = new Set<string>();
+
+  constructor(
+    readonly repository: ListingAiRepository,
+    private readonly aiScoring?: ListingAiScoringProvider,
+    private readonly now: () => Date = () => new Date(),
+    private readonly concurrency = 3,
+    private readonly maxAiItemsPerJob = 10,
+  ) {}
+
+  async catalogSummary(workspaceId: string, platform: 'jd'): Promise<ListingCatalogSummary> {
+    const sources = await this.repository.listAllSources(workspaceId, platform);
+    const latestByProduct = new Map((await this.repository.listLatestScores(workspaceId)).map((score) => [score.productId, score]));
+    const scores = sources.map((source) => {
+      const score = latestByProduct.get(source.productId);
+      return score?.sourceHash === source.sourceHash ? score : null;
+    });
+    const coverage = sources.map(listingCoverage);
+    const numeric = scores.map((score) => score?.overallScore).filter((score): score is number => score !== null && score !== undefined);
+    return {
+      sourceTotal: sources.length,
+      eligible: coverage.filter((item) => item.status === 'eligible').length,
+      scored: scores.filter((score) => score?.overallScore !== null && score?.overallScore !== undefined).length,
+      partial: scores.filter((score) => score?.coverage.status === 'partial' || score?.aiStatus === 'failed').length,
+      blocked: coverage.filter((item) => item.status === 'blocked').length,
+      failed: 0,
+      averageScore: numeric.length ? Math.round((numeric.reduce((sum, value) => sum + value, 0) / numeric.length) * 10) / 10 : null,
+      lastCatalogSyncAt: sources.map((source) => source.syncedAt).sort().at(-1) ?? null,
+    };
+  }
+
+  async enqueueScoreJob(input: {
+    workspaceId: string;
+    platform: 'jd';
+    scope: ListingScoreScope;
+    rubricVersion?: string;
+    includeAiSuggestions: boolean;
+    idempotencyKey: string;
+    requestedBy: string;
+  }): Promise<ListingScoreJob> {
+    const sources = await this.resolveScope(input.workspaceId, input.platform, input.scope);
+    if (input.includeAiSuggestions && sources.length > this.maxAiItemsPerJob) {
+      throw new ApiError(429, 'listing_ai_budget_exceeded');
+    }
+    const rubricVersion = input.rubricVersion ?? (input.includeAiSuggestions ? LISTING_AI_RUBRIC_VERSION : LISTING_RUBRIC_VERSION);
+    if (input.includeAiSuggestions && rubricVersion !== LISTING_AI_RUBRIC_VERSION) throw new ApiError(422, 'listing_ai_rubric_required');
+    if (!input.includeAiSuggestions && rubricVersion === LISTING_AI_RUBRIC_VERSION) throw new ApiError(422, 'listing_rule_rubric_required');
+    const requestedAt = this.now().toISOString();
+    const requestHash = canonicalHash({ platform: input.platform, scope: input.scope, rubricVersion, includeAiSuggestions: input.includeAiSuggestions });
+    const job: ListingScoreJob = {
+      id: randomUUID(), workspaceId: input.workspaceId, platform: input.platform,
+      idempotencyKey: input.idempotencyKey, requestHash, rubricVersion,
+      includeAiSuggestions: input.includeAiSuggestions, scope: input.scope, status: sources.length ? 'queued' : 'completed',
+      total: sources.length, processed: 0, succeeded: 0, partial: 0, blocked: 0, failed: 0,
+      requestedBy: input.requestedBy, requestedAt, startedAt: null, completedAt: sources.length ? null : requestedAt, updatedAt: requestedAt,
+    };
+    const items = sources.map<ListingScoreJobItem>((source) => ({
+      id: randomUUID(), jobId: job.id, workspaceId: job.workspaceId, productId: source.productId, sourceHash: source.sourceHash,
+      status: 'queued', attempts: 0, scoreResultId: null, errorCode: null, errorDetail: null, updatedAt: requestedAt,
+    }));
+    const created = await this.repository.createJob(job, items);
+    if (created.created && job.total) queueMicrotask(() => void this.processJob(job.workspaceId, job.id));
+    return created.job;
+  }
+
+  async processJob(workspaceId: string, jobId: string): Promise<void> {
+    if (this.runningJobs.has(jobId)) return;
+    this.runningJobs.add(jobId);
+    try {
+      const job = await this.repository.getJob(workspaceId, jobId);
+      if (!job || job.status === 'cancelled' || job.status === 'completed') return;
+      const startedAt = job.startedAt ?? this.now().toISOString();
+      await this.repository.updateJob({ ...job, status: 'running', startedAt, updatedAt: startedAt });
+      const items = await this.repository.getJobItems(workspaceId, jobId);
+      const pending = items.filter((item) => !['scored', 'partial', 'blocked'].includes(item.status));
+      for (let index = 0; index < pending.length; index += this.concurrency) {
+        const currentJob = await this.repository.getJob(workspaceId, jobId);
+        if (!currentJob || currentJob.status === 'cancelled') break;
+        await Promise.all(pending.slice(index, index + this.concurrency).map((item) => this.processItem(currentJob, item)));
+        await this.recalculateJob(workspaceId, jobId);
+      }
+      await this.recalculateJob(workspaceId, jobId);
+    } finally {
+      this.runningJobs.delete(jobId);
+    }
+  }
+
+  /**
+   * Re-enqueues persisted jobs after a process restart. The repository remains
+   * the source of truth, so already-finished items are skipped by processJob.
+   * A database uniqueness constraint keeps score writes idempotent.
+   */
+  async resumePendingJobs(workspaceId: string): Promise<number> {
+    const jobIds = new Set<string>();
+    for (const status of ['queued', 'running'] as const) {
+      let cursor: string | null = null;
+      do {
+        const page = await this.repository.listJobs(workspaceId, status, 100, cursor);
+        for (const job of page.items) jobIds.add(job.id);
+        cursor = page.nextCursor;
+      } while (cursor);
+    }
+    for (const jobId of jobIds) queueMicrotask(() => void this.processJob(workspaceId, jobId));
+    return jobIds.size;
+  }
+
+  async retryJob(workspaceId: string, jobId: string): Promise<ListingScoreJob> {
+    const job = await this.repository.getJob(workspaceId, jobId);
+    if (!job) throw new ApiError(404, 'score_job_not_found');
+    if (!['partial', 'failed'].includes(job.status)) throw new ApiError(409, 'score_job_not_retryable');
+    const items = await this.repository.getJobItems(workspaceId, jobId);
+    for (const item of items.filter((candidate) => ['failed', 'partial'].includes(candidate.status))) {
+      await this.repository.updateJobItem({ ...item, status: 'queued', errorCode: null, errorDetail: null, updatedAt: this.now().toISOString() });
+    }
+    const next = { ...job, status: 'queued' as const, completedAt: null, updatedAt: this.now().toISOString() };
+    await this.repository.updateJob(next);
+    queueMicrotask(() => void this.processJob(workspaceId, jobId));
+    return next;
+  }
+
+  async cancelJob(workspaceId: string, jobId: string): Promise<ListingScoreJob> {
+    const job = await this.repository.getJob(workspaceId, jobId);
+    if (!job) throw new ApiError(404, 'score_job_not_found');
+    if (!['queued', 'running'].includes(job.status)) throw new ApiError(409, 'score_job_not_cancellable');
+    const now = this.now().toISOString();
+    const next = { ...job, status: 'cancelled' as const, completedAt: now, updatedAt: now };
+    await this.repository.updateJob(next);
+    return next;
+  }
+
+  async createVersion(input: {
+    workspaceId: string; platform: 'jd'; productId: string; baseSourceHash: string; baseScoreResultId: string | null;
+    content?: ListingVersion['content'] | undefined; createdBy: string;
+  }): Promise<ListingVersion> {
+    const source = await this.repository.getSource(input.workspaceId, input.platform, input.productId);
+    if (!source) throw new ApiError(404, 'listing_product_not_found');
+    if (source.sourceHash !== input.baseSourceHash) throw new ApiError(409, 'listing_source_changed');
+    const score = input.baseScoreResultId
+      ? await this.repository.getLatestScore(input.workspaceId, input.productId)
+      : null;
+    const candidate = score?.id === input.baseScoreResultId && score.sourceHash === input.baseSourceHash
+      ? score.aiCandidate
+      : null;
+    if (!input.content && !candidate) throw new ApiError(422, 'listing_ai_candidate_missing');
+    const version: ListingVersion = {
+      id: randomUUID(), workspaceId: input.workspaceId, productId: input.productId, versionNo: 0,
+      baseSourceHash: input.baseSourceHash, baseScoreResultId: input.baseScoreResultId,
+      content: input.content ?? candidate!,
+      status: 'draft', createdBy: input.createdBy, createdAt: this.now().toISOString(), adoptedAt: null,
+    };
+    return this.repository.createVersion(version);
+  }
+
+  async adoptVersion(workspaceId: string, platform: 'jd', versionId: string): Promise<ListingVersion> {
+    const version = await this.repository.getVersion(workspaceId, versionId);
+    if (!version) throw new ApiError(404, 'listing_version_not_found');
+    const source = await this.repository.getSource(workspaceId, platform, version.productId);
+    if (!source || source.sourceHash !== version.baseSourceHash) throw new ApiError(409, 'listing_source_changed');
+    const next = { ...version, status: 'adopted' as const, adoptedAt: this.now().toISOString() };
+    await this.repository.updateVersion(next);
+    return next;
+  }
+
+  private async resolveScope(workspaceId: string, platform: 'jd', scope: ListingScoreScope): Promise<ListingSourceSnapshot[]> {
+    const sources = await this.repository.listAllSources(workspaceId, platform);
+    if (scope.mode === 'selected') {
+      const selected = new Set(scope.productIds);
+      const resolved = sources.filter((source) => selected.has(source.productId));
+      if (resolved.length !== selected.size) throw new ApiError(422, 'listing_source_incomplete');
+      return resolved;
+    }
+    const selectedIds = new Set<string>();
+    let cursor: string | null = null;
+    do {
+      const result = await this.repository.listProducts({ workspaceId, platform, ...scope.filter, limit: 100, cursor, sort: 'productId' });
+      for (const item of result.items) selectedIds.add(item.productId);
+      cursor = result.nextCursor;
+    } while (cursor);
+    return sources.filter((source) => selectedIds.has(source.productId));
+  }
+
+  private matchesFilter(source: ListingSourceSnapshot, filter: ListingProductFilter): boolean {
+    const search = filter.search?.trim().toLocaleLowerCase();
+    if (search && !`${source.productId} ${source.title ?? ''}`.toLocaleLowerCase().includes(search)) return false;
+    if (filter.categoryId && !source.categoryIds.includes(filter.categoryId)) return false;
+    if (filter.itemStatus && source.itemStatus !== filter.itemStatus) return false;
+    if (filter.coverageStatus && listingCoverage(source).status !== filter.coverageStatus) return false;
+    return true;
+  }
+
+  private async processItem(job: ListingScoreJob, item: ListingScoreJobItem): Promise<void> {
+    const now = this.now().toISOString();
+    try {
+      const source = await this.repository.getSource(job.workspaceId, job.platform, item.productId);
+      if (!source || source.sourceHash !== item.sourceHash) {
+        await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, errorCode: 'listing_source_changed', errorDetail: 'Source snapshot is missing or changed', updatedAt: now });
+        return;
+      }
+      if (job.includeAiSuggestions) {
+        const cached = await this.repository.getLatestScore(job.workspaceId, item.productId, LISTING_AI_RUBRIC_VERSION);
+        if (cached?.sourceHash === source.sourceHash && cached.aiStatus === 'completed' && cached.model === this.aiScoring?.model && cached.promptVersion === LISTING_AI_PROMPT_VERSION) {
+          const cachedStatus: ListingScoreJobItem['status'] = cached.overallScore === null ? 'partial' : 'scored';
+          await this.repository.updateJobItem({ ...item, status: cachedStatus, attempts: item.attempts + 1, scoreResultId: cached.id, errorCode: null, errorDetail: null, updatedAt: now });
+          return;
+        }
+        const baseline = scoreListing(source, { rubricVersion: LISTING_RUBRIC_VERSION, now });
+        if (baseline.coverage.status === 'blocked' || baseline.overallScore === null) {
+          await this.repository.updateJobItem({ ...item, status: 'blocked', attempts: item.attempts + 1, scoreResultId: null, errorCode: 'listing_ai_source_incomplete', errorDetail: 'AI scoring requires all five baseline dimensions', updatedAt: now });
+          return;
+        }
+        if (!this.aiScoring?.configured) {
+          await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, scoreResultId: null, errorCode: 'listing_ai_not_configured', errorDetail: 'AI scoring gateway is not configured', updatedAt: now });
+          return;
+        }
+        try {
+          const output = await this.aiScoring.score(source, baseline);
+          const result = composeListingAiScore({ baseline, output, model: this.aiScoring.model, now });
+          const saved = await this.repository.saveScore(result);
+          const status: ListingScoreJobItem['status'] = result.overallScore === null ? 'partial' : 'scored';
+          await this.repository.updateJobItem({ ...item, status, attempts: item.attempts + 1, scoreResultId: saved.id, errorCode: status === 'partial' ? 'listing_ai_partial' : null, errorDetail: null, updatedAt: now });
+          return;
+        } catch (error) {
+          await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, scoreResultId: null, errorCode: error instanceof Error ? error.message.slice(0, 80) : 'ai_upstream_error', errorDetail: 'AI score generation failed; existing rule score was retained', updatedAt: now });
+          return;
+        }
+      }
+      const previous = await this.repository.getLatestScore(job.workspaceId, item.productId, job.rubricVersion);
+      const result = scoreListing(source, {
+        ...(previous?.sourceHash === source.sourceHash ? { id: previous.id } : {}),
+        rubricVersion: job.rubricVersion,
+        now,
+      });
+      const status: ListingScoreJobItem['status'] = result.coverage.status === 'blocked' ? 'blocked' : result.overallScore === null ? 'partial' : 'scored';
+      const saved = await this.repository.saveScore(result);
+      await this.repository.updateJobItem({ ...item, status, attempts: item.attempts + 1, scoreResultId: saved.id, errorCode: null, errorDetail: null, updatedAt: now });
+    } catch (error) {
+      await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, scoreResultId: null, errorCode: 'listing_score_failed', errorDetail: error instanceof Error ? error.message.slice(0, 200) : 'Unknown scoring failure', updatedAt: now });
+    }
+  }
+
+  private async recalculateJob(workspaceId: string, jobId: string): Promise<void> {
+    const job = await this.repository.getJob(workspaceId, jobId);
+    if (!job || job.status === 'cancelled') return;
+    const items = await this.repository.getJobItems(workspaceId, jobId);
+    const succeeded = items.filter((item) => item.status === 'scored').length;
+    const partial = items.filter((item) => item.status === 'partial').length;
+    const blocked = items.filter((item) => item.status === 'blocked').length;
+    const failed = items.filter((item) => item.status === 'failed').length;
+    const processed = succeeded + partial + blocked + failed;
+    const completedAt = processed === job.total ? this.now().toISOString() : null;
+    const status = processed < job.total ? 'running' : failed === job.total ? 'failed' : partial || blocked || failed ? 'partial' : 'completed';
+    await this.repository.updateJob({ ...job, status, processed, succeeded, partial, blocked, failed, completedAt, updatedAt: this.now().toISOString() });
+  }
+}

+ 47 - 0
src/modules/listing-ai/normalization/domestic-dataset.adapter.ts

@@ -0,0 +1,47 @@
+import { randomUUID } from 'node:crypto';
+import type { DomesticDataset } from '../../../types/domestic-dataset.js';
+import type { ListingSourceSnapshot } from '../domain.js';
+import { canonicalHash } from '../scoring/rule-engine.js';
+
+function numberOrNull(value: string | number | undefined): number | null {
+  const parsed = Number(value);
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
+}
+
+export function listingSourcesFromDomesticDataset(dataset: DomesticDataset, workspaceId: string): ListingSourceSnapshot[] {
+  return dataset.products.filter((product) => product.role === 'own').map((product) => {
+    const profile = product.detail;
+    const raw = {
+      productId: product.productId,
+      title: product.title,
+      brand: product.brand,
+      categories: [product.category1, product.category2, product.category3].filter(Boolean),
+      detail: profile ?? null,
+    };
+    const images = [...new Set([profile?.imageUrl, ...(profile?.images ?? [])].filter((url): url is string => Boolean(url)))];
+    return {
+      id: randomUUID(), workspaceId, platform: 'jd', shopId: profile?.shopId || 'local-demo',
+      productId: product.productId, sourceHash: canonicalHash(raw), title: product.title || null,
+      titleBrandName: product.brand || null, brand: { id: null, name: product.brand || null },
+      categoryIds: raw.categories, itemStatus: profile?.skuStatus || null,
+      price: { jd: product.market?.currentPrice ?? null, cost: null },
+      descriptions: { desktopHtml: null, mobileHtml: null },
+      features: profile?.sellPoint ? [{ key: 'sellPoint', value: profile.sellPoint }] : [],
+      attributes: [
+        profile?.color ? { id: 'color', name: '颜色', values: [profile.color] } : null,
+        profile?.specification ? { id: 'specification', name: '规格', values: [profile.specification] } : null,
+        profile?.origin ? { id: 'origin', name: '产地', values: [profile.origin] } : null,
+      ].filter((item): item is { id: string; name: string; values: string[] } => item !== null),
+      images: images.map((url, index) => ({ url, order: index + 1, isPrimary: index === 0, gptFlag: null })),
+      skus: [],
+      dimensions: {
+        length: numberOrNull(profile?.dimensionsMm.length), width: numberOrNull(profile?.dimensionsMm.width),
+        height: numberOrNull(profile?.dimensionsMm.height), weight: numberOrNull(profile?.weightKg),
+      },
+      logistics: profile ? { jdExpress: profile.jdExpress, localDelivery: profile.localDelivery } : {},
+      afterService: {}, sourceModifiedAt: profile?.collectedAt || null, syncedAt: profile?.collectedAt || dataset.generatedAt,
+      detailStatus: profile ? 'available' : 'empty',
+    };
+  });
+}
+

+ 11 - 0
src/modules/listing-ai/normalization/html-sanitizer.ts

@@ -0,0 +1,11 @@
+export function sanitizeListingHtml(value: string | null): string | null {
+  if (!value) return null;
+  return value
+    .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
+    .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
+    .replace(/\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
+    .replace(/(href|src)\s*=\s*(["'])\s*javascript:[\s\S]*?\2/gi, '$1="#"')
+    .replace(/<(iframe|object|embed|form|input|button|meta|link)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
+    .replace(/<(iframe|object|embed|form|input|button|meta|link)\b[^>]*\/?\s*>/gi, '');
+}
+

+ 22 - 0
src/modules/listing-ai/normalization/jd-listing.normalizer.ts

@@ -0,0 +1,22 @@
+import { randomUUID } from 'node:crypto';
+import type { ListingAttribute, ListingSourceSnapshot } from '../domain.js';
+import { canonicalHash } from '../scoring/rule-engine.js';
+
+type UnknownRecord=Record<string,unknown>;
+const record=(value:unknown):UnknownRecord=>value&&typeof value==='object'&&!Array.isArray(value)?value as UnknownRecord:{};
+const list=(value:unknown):unknown[]=>Array.isArray(value)?value:[];
+const str=(value:unknown):string=>value===null||value===undefined?'':String(value).trim();
+const num=(value:unknown):number|null=>{const parsed=Number(value);return Number.isFinite(parsed)?parsed:null;};
+function epoch(value:unknown):string|null{const parsed=Number(value);if(!Number.isFinite(parsed)||parsed<=0)return null;const millis=parsed<10_000_000_000?parsed*1_000:parsed;const date=new Date(millis);return Number.isNaN(date.valueOf())?null:date.toISOString();}
+function imageUrl(value:unknown):string{const url=str(value);if(!url)return'';if(/^https?:\/\//i.test(url))return url;if(url.startsWith('//'))return`https:${url}`;return`https://img10.360buyimg.com/n1/${url.replace(/^\/+/, '')}`;}
+function attrs(value:unknown):ListingAttribute[]{return list(value).map(record).map((item)=>({id:str(item['attrId']),name:str(item['attrName']),values:list(item['values']).map(record).map((child)=>str(child['attrValueAlias']||child['attrValue'])).filter(Boolean)})).filter((item)=>item.name&&item.values.length);}
+function status(value:unknown):string|null{if(value===null||value===undefined)return null;if(typeof value!=='object')return str(value)||null;const item=record(value);return str(item['code']??item['status']??item['productStatusNew']??item['productStatus']??item['yn'])||null;}
+
+export function normalizeJdListing(input:{workspaceId:string;shopId:string;row:UnknownRecord;detail:UnknownRecord;syncedAt?:string}):ListingSourceSnapshot{
+  const info=record(input.detail['productInfo']);const productId=str(info['productId']??input.row['productId']??input.row['wareId']??input.row['id']);if(!productId)throw new Error('jd_product_id_missing');
+  const productTitle=record(info['productTitle']);const brandInfo=record(info['brandInfo']);const category=record(info['categoryDetail']);const price=record(info['priceInfo']);const description=record(info['productDetailDesc']);const material=record(input.detail['material']);
+  const images=list(material['mainImages']).flatMap((group)=>list(record(group)['imageInfoList'])).map(record).map((item)=>({url:imageUrl(item['imgUrl']),order:num(item['orderSort']),isPrimary:typeof item['primaryFlag']==='boolean'?item['primaryFlag'] as boolean:null,gptFlag:typeof item['gptFlag']==='boolean'?item['gptFlag'] as boolean:null})).filter((item)=>Boolean(item.url));
+  const uniqueImages=[...new Map(images.map((item)=>[item.url,item])).values()].sort((a,b)=>(a.order??999)-(b.order??999));
+  const rawForHash={productInfo:info,material,skuList:input.detail['skuList']??[]};const syncedAt=input.syncedAt??new Date().toISOString();
+  return{id:randomUUID(),workspaceId:input.workspaceId,platform:'jd',shopId:input.shopId,productId,sourceHash:canonicalHash(rawForHash),title:str(productTitle['title']??info['productName']??input.row['productName'])||null,titleBrandName:str(productTitle['titleBrandName'])||null,brand:{id:str(brandInfo['brandId'])||null,name:str(brandInfo['brandName'])||null},categoryIds:[category['thirdCategoryId'],category['lastCategoryId']].map(str).filter((value,index,array)=>value&&array.indexOf(value)===index),itemStatus:status(info['productStatus']??input.row['productStatus']),price:{jd:num(price['jdPrice']),cost:num(price['costPrice'])},descriptions:{desktopHtml:str(description['desc'])||null,mobileHtml:str(description['mobileDesc'])||null},features:list(info['features']).map(record).map((item)=>({key:str(item['key']),value:str(item['value'])})).filter((item)=>item.key||item.value),attributes:attrs(info['goodsAttrInfos']),images:uniqueImages,skus:list(input.detail['skuList']).map(record).map((sku)=>({skuId:str(sku['skuId']),name:str(sku['skuName'])||null,price:num(record(sku['priceInfo'])['jdPrice']),stock:num(sku['stockNum']),status:status(sku['skuEnableStatus']??record(sku['skuStatus'])['onOffShelfStatus']),attributes:attrs(sku['goodsAttrInfos'])})).filter((sku)=>sku.skuId),dimensions:{length:num(info['length']),width:num(info['width']),height:num(info['height']),weight:num(info['weight'])},logistics:record(info['logisticsInfo']),afterService:{...record(info['afterServiceInfo']),to7ReturnFlag:info['to7ReturnFlag']},sourceModifiedAt:epoch(info['modifiedTime']),syncedAt,detailStatus:Object.keys(info).length?'available':'empty'};
+}

+ 205 - 0
src/modules/listing-ai/repositories/in-memory-listing-ai.repository.ts

@@ -0,0 +1,205 @@
+import { ApiError } from '../../../http/api-error.js';
+import type {
+  ListingAiRepository,
+  ListingCursorPage,
+  ListingProductQuery,
+  ListingProductSummary,
+  ListingScoreJob,
+  ListingScoreJobItem,
+  ListingScoreResult,
+  ListingSourceSnapshot,
+  ListingVersion,
+} from '../domain.js';
+import { listingCoverage } from '../scoring/rule-engine.js';
+
+function cursorEncode(id: string): string {
+  return Buffer.from(JSON.stringify({ id }), 'utf8').toString('base64url');
+}
+
+function cursorDecode(cursor: string | null): string | null {
+  if (!cursor) return null;
+  try {
+    const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { id?: unknown };
+    return typeof value.id === 'string' ? value.id : null;
+  } catch {
+    throw new ApiError(400, 'invalid_cursor');
+  }
+}
+
+function page<T>(items: T[], limit: number, cursor: string | null, id: (item: T) => string): ListingCursorPage<T> {
+  const cursorId = cursorDecode(cursor);
+  const start = cursorId ? items.findIndex((item) => id(item) === cursorId) + 1 : 0;
+  if (cursorId && start === 0) throw new ApiError(400, 'invalid_cursor');
+  const selected = items.slice(start, start + limit);
+  const hasMore = start + limit < items.length;
+  return { items: selected, nextCursor: hasMore && selected.length ? cursorEncode(id(selected.at(-1)!)) : null };
+}
+
+function sourceKey(source: Pick<ListingSourceSnapshot, 'workspaceId' | 'platform' | 'productId'>): string {
+  return `${source.workspaceId}|${source.platform}|${source.productId}`;
+}
+
+function scoreKey(score: Pick<ListingScoreResult, 'workspaceId' | 'productId' | 'sourceHash' | 'rubricVersion' | 'model' | 'promptVersion'>): string {
+  return `${score.workspaceId}|${score.productId}|${score.sourceHash}|${score.rubricVersion}|${score.model ?? 'rules'}|${score.promptVersion ?? 'rules'}`;
+}
+
+export class InMemoryListingAiRepository implements ListingAiRepository {
+  private readonly sources = new Map<string, ListingSourceSnapshot>();
+  private readonly scores = new Map<string, ListingScoreResult>();
+  private readonly jobs = new Map<string, ListingScoreJob>();
+  private readonly jobItems = new Map<string, ListingScoreJobItem[]>();
+  private readonly versions = new Map<string, ListingVersion>();
+
+  constructor(sources: ListingSourceSnapshot[] = []) {
+    for (const source of sources) this.sources.set(sourceKey(source), structuredClone(source));
+  }
+
+  async upsertSources(sources: ListingSourceSnapshot[]): Promise<void> {
+    for (const source of sources) {
+      const previous = this.sources.get(sourceKey(source));
+      if (previous && previous.sourceHash !== source.sourceHash) {
+        for (const [id, version] of this.versions) {
+          if (version.workspaceId === source.workspaceId && version.productId === source.productId && version.baseSourceHash !== source.sourceHash && version.status !== 'archived') {
+            this.versions.set(id, { ...version, status: 'stale' });
+          }
+        }
+      }
+      this.sources.set(sourceKey(source), structuredClone(source));
+    }
+  }
+
+  async listAllSources(workspaceId: string, platform: 'jd'): Promise<ListingSourceSnapshot[]> {
+    return [...this.sources.values()].filter((source) => source.workspaceId === workspaceId && source.platform === platform).map((source) => structuredClone(source));
+  }
+
+  async listProducts(query: ListingProductQuery): Promise<ListingCursorPage<ListingProductSummary>> {
+    let items = (await this.listAllSources(query.workspaceId, query.platform)).map((source) => this.summary(source));
+    const search = query.search?.trim().toLocaleLowerCase() ?? '';
+    if (search) items = items.filter((item) => `${item.productId} ${item.title ?? ''}`.toLocaleLowerCase().includes(search));
+    if (query.categoryId) items = items.filter((item) => item.categoryIds.includes(query.categoryId!));
+    if (query.itemStatus) items = items.filter((item) => item.itemStatus === query.itemStatus);
+    if (query.coverageStatus) items = items.filter((item) => item.coverage.status === query.coverageStatus);
+    if (query.scoreStatus) {
+      items = items.filter((item) => {
+        if (query.scoreStatus === 'unscored') return !item.latestScore;
+        if (query.scoreStatus === 'scored') return item.latestScore?.overallScore !== null;
+        if (query.scoreStatus === 'blocked') return item.latestScore?.coverage.status === 'blocked';
+        if (query.scoreStatus === 'partial') return item.latestScore?.coverage.status === 'partial' || item.latestScore?.aiStatus === 'failed';
+        return false;
+      });
+    }
+    if (query.minScore !== undefined) items = items.filter((item) => (item.latestScore?.overallScore ?? -1) >= query.minScore!);
+    if (query.maxScore !== undefined) items = items.filter((item) => (item.latestScore?.overallScore ?? 101) <= query.maxScore!);
+    items.sort((left, right) => {
+      if (query.sort === 'score_asc') return (left.latestScore?.overallScore ?? 101) - (right.latestScore?.overallScore ?? 101) || left.productId.localeCompare(right.productId);
+      if (query.sort === 'score_desc') return (right.latestScore?.overallScore ?? -1) - (left.latestScore?.overallScore ?? -1) || left.productId.localeCompare(right.productId);
+      if (query.sort === 'updated_desc') return right.syncedAt.localeCompare(left.syncedAt) || left.productId.localeCompare(right.productId);
+      return left.productId.localeCompare(right.productId);
+    });
+    return page(items, query.limit, query.cursor, (item) => item.productId);
+  }
+
+  async getSource(workspaceId: string, platform: 'jd', productId: string): Promise<ListingSourceSnapshot | null> {
+    const source = this.sources.get(`${workspaceId}|${platform}|${productId}`);
+    return source ? structuredClone(source) : null;
+  }
+
+  async getLatestScore(workspaceId: string, productId: string, rubricVersion?: string): Promise<ListingScoreResult | null> {
+    const scores = [...this.scores.values()].filter((score) => score.workspaceId === workspaceId && score.productId === productId && (!rubricVersion || score.rubricVersion === rubricVersion));
+    scores.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
+    return scores[0] ? structuredClone(scores[0]) : null;
+  }
+
+  async listLatestScores(workspaceId: string): Promise<ListingScoreResult[]> {
+    const latest = new Map<string, ListingScoreResult>();
+    for (const score of this.scores.values()) {
+      if (score.workspaceId !== workspaceId) continue;
+      const current = latest.get(score.productId);
+      if (!current || score.createdAt > current.createdAt) latest.set(score.productId, score);
+    }
+    return [...latest.values()].map((score) => structuredClone(score));
+  }
+
+  async saveScore(result: ListingScoreResult): Promise<ListingScoreResult> {
+    this.scores.set(scoreKey(result), structuredClone(result));
+    return structuredClone(result);
+  }
+
+  async createJob(job: ListingScoreJob, items: ListingScoreJobItem[]): Promise<{ job: ListingScoreJob; created: boolean }> {
+    const existing = [...this.jobs.values()].find((item) => item.workspaceId === job.workspaceId && item.idempotencyKey === job.idempotencyKey);
+    if (existing) {
+      if (existing.requestHash !== job.requestHash) throw new ApiError(409, 'idempotency_conflict');
+      return { job: structuredClone(existing), created: false };
+    }
+    this.jobs.set(job.id, structuredClone(job));
+    this.jobItems.set(job.id, structuredClone(items));
+    return { job: structuredClone(job), created: true };
+  }
+
+  async updateJob(job: ListingScoreJob): Promise<void> { this.jobs.set(job.id, structuredClone(job)); }
+
+  async getJob(workspaceId: string, jobId: string): Promise<ListingScoreJob | null> {
+    const job = this.jobs.get(jobId);
+    return job?.workspaceId === workspaceId ? structuredClone(job) : null;
+  }
+
+  async listJobs(workspaceId: string, status: string, limit: number, cursor: string | null): Promise<ListingCursorPage<ListingScoreJob>> {
+    const items = [...this.jobs.values()].filter((job) => job.workspaceId === workspaceId && (!status || job.status === status)).sort((a, b) => b.requestedAt.localeCompare(a.requestedAt) || a.id.localeCompare(b.id));
+    return page(items.map((item) => structuredClone(item)), limit, cursor, (item) => item.id);
+  }
+
+  async listJobItems(workspaceId: string, jobId: string, status: string, limit: number, cursor: string | null): Promise<ListingCursorPage<ListingScoreJobItem>> {
+    const job = await this.getJob(workspaceId, jobId);
+    if (!job) return { items: [], nextCursor: null };
+    const items = (this.jobItems.get(jobId) ?? []).filter((item) => !status || item.status === status).sort((a, b) => a.productId.localeCompare(b.productId));
+    return page(structuredClone(items), limit, cursor, (item) => item.id);
+  }
+
+  async getJobItems(workspaceId: string, jobId: string): Promise<ListingScoreJobItem[]> {
+    return (await this.getJob(workspaceId, jobId)) ? structuredClone(this.jobItems.get(jobId) ?? []) : [];
+  }
+
+  async updateJobItem(item: ListingScoreJobItem): Promise<void> {
+    const items = this.jobItems.get(item.jobId) ?? [];
+    const index = items.findIndex((candidate) => candidate.id === item.id);
+    if (index >= 0) items[index] = structuredClone(item);
+  }
+
+  async createVersion(version: ListingVersion): Promise<ListingVersion> {
+    const versions = [...this.versions.values()].filter((item) => item.workspaceId === version.workspaceId && item.productId === version.productId);
+    const output = { ...version, versionNo: versions.reduce((max, item) => Math.max(max, item.versionNo), 0) + 1 };
+    this.versions.set(output.id, structuredClone(output));
+    return structuredClone(output);
+  }
+
+  async getVersion(workspaceId: string, versionId: string): Promise<ListingVersion | null> {
+    const version = this.versions.get(versionId);
+    return version?.workspaceId === workspaceId ? structuredClone(version) : null;
+  }
+
+  async updateVersion(version: ListingVersion): Promise<void> { this.versions.set(version.id, structuredClone(version)); }
+
+  async listVersions(workspaceId: string, productId: string | null, limit: number, cursor: string | null): Promise<ListingCursorPage<ListingVersion>> {
+    const items = [...this.versions.values()].filter((item) => item.workspaceId === workspaceId && (!productId || item.productId === productId)).sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id));
+    return page(items.map((item) => structuredClone(item)), limit, cursor, (item) => item.id);
+  }
+
+  private summary(source: ListingSourceSnapshot): ListingProductSummary {
+    const scores = [...this.scores.values()].filter((score) => score.workspaceId === source.workspaceId && score.productId === source.productId && score.sourceHash === source.sourceHash).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+    return {
+      productId: source.productId,
+      shopId: source.shopId,
+      platform: source.platform,
+      title: source.title,
+      imageUrl: source.images[0]?.url ?? null,
+      categoryIds: [...source.categoryIds],
+      itemStatus: source.itemStatus,
+      skuCount: source.skus.length,
+      sourceUpdatedAt: source.sourceModifiedAt,
+      syncedAt: source.syncedAt,
+      sourceHash: source.sourceHash,
+      coverage: listingCoverage(source),
+      latestScore: scores[0] ? structuredClone(scores[0]) : null,
+    };
+  }
+}

+ 39 - 0
src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.ts

@@ -0,0 +1,39 @@
+import { ApiError } from '../../../http/api-error.js';
+import { parseDate, type ParseObject, type ParseRestClient } from '../../../db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../../../db/parse-rest.schema.js';
+import type {
+  ListingAiRepository, ListingCursorPage, ListingProductQuery, ListingScoreJob, ListingScoreJobItem,
+  ListingScoreResult, ListingSourceSnapshot, ListingVersion,
+} from '../domain.js';
+import { InMemoryListingAiRepository } from './in-memory-listing-ai.repository.js';
+
+interface Stored<T> { publicId?: string; naturalKey: string; workspaceId: string; productId?: string; status?: string; payload: T }
+function encode(id: string): string { return Buffer.from(id).toString('base64url'); }
+function decode(value: string | null): string | null { if (!value) return null; try { return Buffer.from(value,'base64url').toString(); } catch { throw new ApiError(400,'invalid_cursor'); } }
+function page<T>(items:T[],limit:number,cursor:string|null,id:(item:T)=>string):ListingCursorPage<T>{const value=decode(cursor);const index=value?items.findIndex((item)=>id(item)===value):-1;if(value&&index<0)throw new ApiError(400,'invalid_cursor');const start=index+1;const selected=items.slice(start,start+limit);return{items:selected,nextCursor:start+limit<items.length&&selected.length?encode(id(selected.at(-1)!)):null};}
+
+export class ParseRestListingAiRepository implements ListingAiRepository {
+  constructor(private readonly client: ParseRestClient) {}
+
+  async upsertSources(sources: ListingSourceSnapshot[]): Promise<void> {
+    for(const source of sources){const previous=await this.getSource(source.workspaceId,source.platform,source.productId);const naturalKey=`${source.workspaceId}|${source.platform}|${source.shopId}|${source.productId}|${source.sourceHash}`;const existing=await this.client.findOne<Stored<ListingSourceSnapshot>>(VOC_PARSE_CLASSES.listingSourceSnapshot,{naturalKey});const body={naturalKey,workspaceId:source.workspaceId,platform:source.platform,shopId:source.shopId,productId:source.productId,sourceHash:source.sourceHash,detailStatus:source.detailStatus,payload:source,sourceModifiedAt:source.sourceModifiedAt?parseDate(source.sourceModifiedAt):undefined,observedAt:parseDate(source.syncedAt)};if(existing)await this.client.update(VOC_PARSE_CLASSES.listingSourceSnapshot,existing.objectId,body);else await this.client.create(VOC_PARSE_CLASSES.listingSourceSnapshot,body);if(previous&&previous.sourceHash!==source.sourceHash){const versions=await this.client.findAll<Stored<ListingVersion>&ParseObject>(VOC_PARSE_CLASSES.listingVersion,{workspaceId:source.workspaceId,productId:source.productId});for(const row of versions){if(row.payload.baseSourceHash!==source.sourceHash&&row.payload.status!=='archived'){const value={...row.payload,status:'stale' as const};await this.client.update(VOC_PARSE_CLASSES.listingVersion,row.objectId,{status:value.status,payload:value});}}}}
+  }
+  async listAllSources(workspaceId:string,platform:'jd'):Promise<ListingSourceSnapshot[]>{const rows=await this.client.findAll<Stored<ListingSourceSnapshot>&{platform:string}>(VOC_PARSE_CLASSES.listingSourceSnapshot,{workspaceId,platform});const latest=new Map<string,(typeof rows)[number]>();for(const row of rows){const current=latest.get(row.payload.productId);if(!current||row.payload.syncedAt>current.payload.syncedAt)latest.set(row.payload.productId,row);}return[...latest.values()].map((row)=>row.payload);}
+  async listProducts(query:ListingProductQuery){const memory=new InMemoryListingAiRepository(await this.listAllSources(query.workspaceId,query.platform));const scores=await this.client.findAll<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingScoreResult,{workspaceId:query.workspaceId});for(const row of scores)await memory.saveScore(row.payload);return memory.listProducts(query);}
+  async getSource(workspaceId:string,platform:'jd',productId:string){const rows=await this.client.find<Stored<ListingSourceSnapshot>>(VOC_PARSE_CLASSES.listingSourceSnapshot,{where:{workspaceId,platform,productId},order:'-observedAt',limit:1});return rows.results[0]?.payload??null;}
+  async getLatestScore(workspaceId:string,productId:string,rubricVersion?:string){const where:Record<string,unknown>={workspaceId,productId};if(rubricVersion)where.rubricVersion=rubricVersion;const rows=await this.client.find<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingScoreResult,{where,order:'-scoredAt',limit:1});return rows.results[0]?.payload??null;}
+  async listLatestScores(workspaceId:string){const rows=await this.client.findAll<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingScoreResult,{workspaceId});const latest=new Map<string,ListingScoreResult>();for(const row of rows){const value=row.payload;const current=latest.get(value.productId);if(!current||value.createdAt>current.createdAt)latest.set(value.productId,value);}return[...latest.values()];}
+  async saveScore(score:ListingScoreResult){const naturalKey=`${score.workspaceId}|${score.productId}|${score.sourceHash}|${score.rubricVersion}|${score.model??'rules'}|${score.promptVersion??'rules'}`;const existing=await this.client.findOne<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingScoreResult,{naturalKey});const value=existing?{...score,id:existing.payload.id}:score;const body={publicId:value.id,naturalKey,workspaceId:value.workspaceId,productId:value.productId,sourceHash:value.sourceHash,rubricVersion:value.rubricVersion,model:value.model??'rules',promptVersion:value.promptVersion??'rules',overallScore:value.overallScore,payload:value,scoredAt:parseDate(value.createdAt)};if(existing)await this.client.update(VOC_PARSE_CLASSES.listingScoreResult,existing.objectId,body);else await this.client.create(VOC_PARSE_CLASSES.listingScoreResult,body);return value;}
+  async createJob(job:ListingScoreJob,items:ListingScoreJobItem[]){const naturalKey=`${job.workspaceId}|${job.idempotencyKey}`;const existing=await this.client.findOne<Stored<ListingScoreJob>>(VOC_PARSE_CLASSES.listingScoreJob,{naturalKey});if(existing){if(existing.payload.requestHash!==job.requestHash)throw new ApiError(409,'idempotency_conflict');return{job:existing.payload,created:false};}await this.client.create(VOC_PARSE_CLASSES.listingScoreJob,{publicId:job.id,naturalKey,workspaceId:job.workspaceId,platform:job.platform,idempotencyKey:job.idempotencyKey,requestHash:job.requestHash,status:job.status,payload:job,requestedAt:parseDate(job.requestedAt)});for(const item of items)await this.createItem(item);return{job,created:true};}
+  private async createItem(item:ListingScoreJobItem){const naturalKey=`${item.jobId}|${item.productId}|${item.sourceHash}`;await this.client.create(VOC_PARSE_CLASSES.listingScoreItem,{publicId:item.id,naturalKey,workspaceId:item.workspaceId,jobId:item.jobId,productId:item.productId,sourceHash:item.sourceHash,status:item.status,payload:item});}
+  async updateJob(job:ListingScoreJob){const row=await this.client.findOne<Stored<ListingScoreJob>>(VOC_PARSE_CLASSES.listingScoreJob,{publicId:job.id,workspaceId:job.workspaceId});if(!row)throw new ApiError(404,'score_job_not_found');await this.client.update(VOC_PARSE_CLASSES.listingScoreJob,row.objectId,{status:job.status,payload:job});}
+  async getJob(workspaceId:string,jobId:string){const row=await this.client.findOne<Stored<ListingScoreJob>>(VOC_PARSE_CLASSES.listingScoreJob,{workspaceId,publicId:jobId});return row?.payload??null;}
+  async listJobs(workspaceId:string,status:string,limit:number,cursor:string|null){const where:Record<string,unknown>={workspaceId};if(status)where.status=status;const rows=await this.client.findAll<Stored<ListingScoreJob>>(VOC_PARSE_CLASSES.listingScoreJob,where);const items=rows.map((row)=>row.payload).sort((a,b)=>b.requestedAt.localeCompare(a.requestedAt)||a.id.localeCompare(b.id));return page(items,limit,cursor,(item)=>item.id);}
+  async listJobItems(workspaceId:string,jobId:string,status:string,limit:number,cursor:string|null){const where:Record<string,unknown>={workspaceId,jobId};if(status)where.status=status;const rows=await this.client.findAll<Stored<ListingScoreJobItem>>(VOC_PARSE_CLASSES.listingScoreItem,where);const items=rows.map((row)=>row.payload).sort((a,b)=>a.productId.localeCompare(b.productId)||a.id.localeCompare(b.id));return page(items,limit,cursor,(item)=>item.id);}
+  async getJobItems(workspaceId:string,jobId:string){return (await this.client.findAll<Stored<ListingScoreJobItem>>(VOC_PARSE_CLASSES.listingScoreItem,{workspaceId,jobId})).map((row)=>row.payload);}
+  async updateJobItem(item:ListingScoreJobItem){const row=await this.client.findOne<Stored<ListingScoreJobItem>>(VOC_PARSE_CLASSES.listingScoreItem,{workspaceId:item.workspaceId,publicId:item.id});if(!row)return;await this.client.update(VOC_PARSE_CLASSES.listingScoreItem,row.objectId,{status:item.status,payload:item});}
+  async createVersion(version:ListingVersion){const rows=await this.client.findAll<Stored<ListingVersion>>(VOC_PARSE_CLASSES.listingVersion,{workspaceId:version.workspaceId,productId:version.productId});const output={...version,versionNo:rows.reduce((max,row)=>Math.max(max,row.payload.versionNo),0)+1};const naturalKey=`${output.workspaceId}|${output.productId}|${output.versionNo}`;await this.client.create(VOC_PARSE_CLASSES.listingVersion,{publicId:output.id,naturalKey,workspaceId:output.workspaceId,productId:output.productId,versionNo:output.versionNo,baseSourceHash:output.baseSourceHash,status:output.status,payload:output,versionCreatedAt:parseDate(output.createdAt)});return output;}
+  async getVersion(workspaceId:string,versionId:string){const row=await this.client.findOne<Stored<ListingVersion>>(VOC_PARSE_CLASSES.listingVersion,{workspaceId,publicId:versionId});return row?.payload??null;}
+  async updateVersion(version:ListingVersion){const row=await this.client.findOne<Stored<ListingVersion>>(VOC_PARSE_CLASSES.listingVersion,{workspaceId:version.workspaceId,publicId:version.id});if(!row)throw new ApiError(404,'listing_version_not_found');await this.client.update(VOC_PARSE_CLASSES.listingVersion,row.objectId,{status:version.status,payload:version});}
+  async listVersions(workspaceId:string,productId:string|null,limit:number,cursor:string|null){const where:Record<string,unknown>={workspaceId};if(productId)where.productId=productId;const rows=await this.client.findAll<Stored<ListingVersion>&ParseObject>(VOC_PARSE_CLASSES.listingVersion,where);const items=rows.map((row)=>row.payload).sort((a,b)=>b.createdAt.localeCompare(a.createdAt)||a.id.localeCompare(b.id));return page(items,limit,cursor,(item)=>item.id);}
+}

+ 210 - 0
src/modules/listing-ai/repositories/postgres-listing-ai.repository.ts

@@ -0,0 +1,210 @@
+import type { Pool, PoolClient } from 'pg';
+import { ApiError } from '../../../http/api-error.js';
+import type {
+  ListingAiRepository, ListingCursorPage, ListingProductQuery, ListingScoreJob, ListingScoreJobItem,
+  ListingScoreResult, ListingSourceSnapshot, ListingVersion,
+} from '../domain.js';
+import { InMemoryListingAiRepository } from './in-memory-listing-ai.repository.js';
+
+type JsonRow<T> = { payload: T };
+
+function encodeCursor(id: string): string { return Buffer.from(id, 'utf8').toString('base64url'); }
+function decodeCursor(cursor: string | null): string | null {
+  if (!cursor) return null;
+  try { return Buffer.from(cursor, 'base64url').toString('utf8'); } catch { throw new ApiError(400, 'invalid_cursor'); }
+}
+function page<T>(items: T[], limit: number, cursor: string | null, id: (item: T) => string): ListingCursorPage<T> {
+  const decoded = decodeCursor(cursor);
+  const index = decoded ? items.findIndex((item) => id(item) === decoded) : -1;
+  if (decoded && index < 0) throw new ApiError(400, 'invalid_cursor');
+  const start = index + 1;
+  const selected = items.slice(start, start + limit);
+  return { items: selected, nextCursor: start + limit < items.length && selected.length ? encodeCursor(id(selected.at(-1)!)) : null };
+}
+
+function iso(value: Date | string | null): string | null { return value ? new Date(value).toISOString() : null; }
+
+interface JobRow {
+  public_id: string; workspace_public_id: string; platform: 'jd'; idempotency_key: string; request_hash: string;
+  rubric_version: string; include_ai_suggestions: boolean; scope: ListingScoreJob['scope']; status: ListingScoreJob['status'];
+  total: number; processed: number; succeeded: number; partial: number; blocked: number; failed: number;
+  requested_by_external_id: string; requested_at: Date | string; started_at: Date | string | null;
+  completed_at: Date | string | null; updated_at: Date | string;
+}
+
+interface ItemRow {
+  public_id: string; job_public_id: string; workspace_public_id: string; product_id: string; source_hash: string;
+  status: ListingScoreJobItem['status']; attempts: number; score_result_public_id: string | null;
+  error_code: string | null; error_detail_redacted: string | null; updated_at: Date | string;
+}
+
+interface VersionRow {
+  public_id: string; workspace_public_id: string; product_id: string; version_no: number; base_source_hash: string;
+  base_score_result_public_id: string | null; content: ListingVersion['content']; status: ListingVersion['status'];
+  created_by_external_id: string; created_at: Date | string; adopted_at: Date | string | null;
+}
+
+function jobFromRow(row: JobRow): ListingScoreJob {
+  return { id: row.public_id, workspaceId: row.workspace_public_id, platform: row.platform, idempotencyKey: row.idempotency_key,
+    requestHash: row.request_hash, rubricVersion: row.rubric_version, includeAiSuggestions: row.include_ai_suggestions,
+    scope: row.scope, status: row.status, total: row.total, processed: row.processed, succeeded: row.succeeded,
+    partial: row.partial, blocked: row.blocked, failed: row.failed, requestedBy: row.requested_by_external_id,
+    requestedAt: iso(row.requested_at)!, startedAt: iso(row.started_at), completedAt: iso(row.completed_at), updatedAt: iso(row.updated_at)! };
+}
+function itemFromRow(row: ItemRow): ListingScoreJobItem {
+  return { id: row.public_id, jobId: row.job_public_id, workspaceId: row.workspace_public_id, productId: row.product_id,
+    sourceHash: row.source_hash, status: row.status, attempts: row.attempts, scoreResultId: row.score_result_public_id,
+    errorCode: row.error_code, errorDetail: row.error_detail_redacted, updatedAt: iso(row.updated_at)! };
+}
+function versionFromRow(row: VersionRow): ListingVersion {
+  return { id: row.public_id, workspaceId: row.workspace_public_id, productId: row.product_id, versionNo: row.version_no,
+    baseSourceHash: row.base_source_hash, baseScoreResultId: row.base_score_result_public_id, content: row.content,
+    status: row.status, createdBy: row.created_by_external_id, createdAt: iso(row.created_at)!, adoptedAt: iso(row.adopted_at) };
+}
+
+const JOB_SELECT = `SELECT j.*, w.public_id AS workspace_public_id FROM voc.listing_score_job j JOIN voc.workspace w ON w.id=j.workspace_id`;
+const ITEM_SELECT = `SELECT i.*, j.public_id AS job_public_id, w.public_id AS workspace_public_id FROM voc.listing_score_item i JOIN voc.listing_score_job j ON j.id=i.job_id JOIN voc.workspace w ON w.id=i.workspace_id`;
+const VERSION_SELECT = `SELECT v.*, w.public_id AS workspace_public_id FROM voc.listing_version v JOIN voc.workspace w ON w.id=v.workspace_id`;
+
+export class PostgresListingAiRepository implements ListingAiRepository {
+  constructor(private readonly pool: Pool) {}
+
+  async upsertSources(sources: ListingSourceSnapshot[]): Promise<void> {
+    const client = await this.pool.connect();
+    try {
+      await client.query('BEGIN');
+      for (const source of sources) {
+        await client.query(`INSERT INTO voc.listing_source_snapshot
+          (public_id,workspace_id,platform,shop_id,product_id,source_hash,payload,detail_status,source_modified_at,observed_at)
+          SELECT $1,w.id,$3,$4,$5,$6,$7::jsonb,$8,$9,$10 FROM voc.workspace w WHERE w.public_id=$2
+          ON CONFLICT (workspace_id,platform,shop_id,product_id,source_hash) DO UPDATE SET payload=EXCLUDED.payload, observed_at=EXCLUDED.observed_at`,
+        [source.id, source.workspaceId, source.platform, source.shopId, source.productId, source.sourceHash, JSON.stringify(source), source.detailStatus, source.sourceModifiedAt, source.syncedAt]);
+        await client.query(`UPDATE voc.listing_version v SET status='stale'
+          WHERE v.workspace_id=(SELECT id FROM voc.workspace WHERE public_id=$1)
+            AND v.product_id=$2 AND v.base_source_hash<>$3 AND v.status<>'archived'`,
+        [source.workspaceId, source.productId, source.sourceHash]);
+      }
+      await client.query('COMMIT');
+    } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); }
+  }
+
+  async listAllSources(workspaceId: string, platform: 'jd'): Promise<ListingSourceSnapshot[]> {
+    const result = await this.pool.query<JsonRow<ListingSourceSnapshot>>(`SELECT DISTINCT ON (s.product_id) s.payload
+      FROM voc.listing_source_snapshot s JOIN voc.workspace w ON w.id=s.workspace_id
+      WHERE w.public_id=$1 AND s.platform=$2 ORDER BY s.product_id,s.observed_at DESC,s.id DESC`, [workspaceId, platform]);
+    return result.rows.map((row) => row.payload);
+  }
+
+  async listProducts(query: ListingProductQuery) {
+    const memory = new InMemoryListingAiRepository(await this.listAllSources(query.workspaceId, query.platform));
+    const results = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT DISTINCT ON (r.product_id) r.result AS payload
+      FROM voc.listing_score_result r JOIN voc.workspace w ON w.id=r.workspace_id WHERE w.public_id=$1
+      ORDER BY r.product_id,r.created_at DESC,r.id DESC`, [query.workspaceId]);
+    for (const row of results.rows) await memory.saveScore(row.payload);
+    return memory.listProducts(query);
+  }
+
+  async getSource(workspaceId: string, platform: 'jd', productId: string): Promise<ListingSourceSnapshot | null> {
+    const result = await this.pool.query<JsonRow<ListingSourceSnapshot>>(`SELECT s.payload FROM voc.listing_source_snapshot s JOIN voc.workspace w ON w.id=s.workspace_id
+      WHERE w.public_id=$1 AND s.platform=$2 AND s.product_id=$3 ORDER BY s.observed_at DESC,s.id DESC LIMIT 1`, [workspaceId, platform, productId]);
+    return result.rows[0]?.payload ?? null;
+  }
+
+  async getLatestScore(workspaceId: string, productId: string, rubricVersion?: string): Promise<ListingScoreResult | null> {
+    const result = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT r.result AS payload FROM voc.listing_score_result r JOIN voc.workspace w ON w.id=r.workspace_id
+      WHERE w.public_id=$1 AND r.product_id=$2 AND ($3::text IS NULL OR r.rubric_version=$3) ORDER BY r.created_at DESC,r.id DESC LIMIT 1`, [workspaceId, productId, rubricVersion ?? null]);
+    return result.rows[0]?.payload ?? null;
+  }
+
+
+  async listLatestScores(workspaceId: string): Promise<ListingScoreResult[]> {
+    const result = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT DISTINCT ON (r.product_id) r.result AS payload
+      FROM voc.listing_score_result r JOIN voc.workspace w ON w.id=r.workspace_id
+      WHERE w.public_id=$1 ORDER BY r.product_id,r.created_at DESC,r.id DESC`, [workspaceId]);
+    return result.rows.map((row) => row.payload);
+  }
+
+  async saveScore(score: ListingScoreResult): Promise<ListingScoreResult> {
+    await this.pool.query(`INSERT INTO voc.listing_score_result
+      (public_id,workspace_id,product_id,source_hash,rubric_version,model_key,prompt_version,overall_score,coverage,result,model_info,created_at)
+      SELECT $1,w.id,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11::jsonb,$12 FROM voc.workspace w WHERE w.public_id=$2
+      ON CONFLICT (workspace_id,product_id,source_hash,rubric_version,model_key,prompt_version) DO UPDATE SET
+        overall_score=EXCLUDED.overall_score,coverage=EXCLUDED.coverage,result=EXCLUDED.result,model_info=EXCLUDED.model_info,created_at=EXCLUDED.created_at`,
+    [score.id, score.workspaceId, score.productId, score.sourceHash, score.rubricVersion, score.model ?? 'rules', score.promptVersion ?? 'rules', score.overallScore, JSON.stringify(score.coverage), JSON.stringify(score), JSON.stringify({ model: score.model, promptVersion: score.promptVersion }), score.createdAt]);
+    return (await this.getLatestScore(score.workspaceId, score.productId, score.rubricVersion)) ?? score;
+  }
+
+  async createJob(job: ListingScoreJob, items: ListingScoreJobItem[]): Promise<{ job: ListingScoreJob; created: boolean }> {
+    const client = await this.pool.connect();
+    try {
+      await client.query('BEGIN');
+      const existing = await client.query<JobRow>(`${JOB_SELECT} WHERE w.public_id=$1 AND j.idempotency_key=$2 LIMIT 1 FOR UPDATE`, [job.workspaceId, job.idempotencyKey]);
+      if (existing.rows[0]) {
+        const value = jobFromRow(existing.rows[0]);
+        if (value.requestHash !== job.requestHash) throw new ApiError(409, 'idempotency_conflict');
+        await client.query('COMMIT'); return { job: value, created: false };
+      }
+      await client.query(`INSERT INTO voc.listing_score_job
+        (public_id,workspace_id,platform,idempotency_key,request_hash,rubric_version,include_ai_suggestions,scope,status,total,processed,succeeded,partial,blocked,failed,requested_by_external_id,requested_at,started_at,completed_at,updated_at)
+        SELECT $1,w.id,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20 FROM voc.workspace w WHERE w.public_id=$2`,
+      [job.id,job.workspaceId,job.platform,job.idempotencyKey,job.requestHash,job.rubricVersion,job.includeAiSuggestions,JSON.stringify(job.scope),job.status,job.total,job.processed,job.succeeded,job.partial,job.blocked,job.failed,job.requestedBy,job.requestedAt,job.startedAt,job.completedAt,job.updatedAt]);
+      for (const item of items) await this.insertItem(client, item);
+      await client.query('COMMIT'); return { job, created: true };
+    } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); }
+  }
+
+  private async insertItem(client: PoolClient, item: ListingScoreJobItem): Promise<void> {
+    await client.query(`INSERT INTO voc.listing_score_item
+      (public_id,job_id,workspace_id,product_id,source_hash,status,attempts,score_result_public_id,error_code,error_detail_redacted,updated_at)
+      SELECT $1,j.id,w.id,$4,$5,$6,$7,$8,$9,$10,$11 FROM voc.listing_score_job j,voc.workspace w WHERE j.public_id=$2 AND w.public_id=$3
+      ON CONFLICT (job_id,product_id,source_hash) DO NOTHING`,
+    [item.id,item.jobId,item.workspaceId,item.productId,item.sourceHash,item.status,item.attempts,item.scoreResultId,item.errorCode,item.errorDetail,item.updatedAt]);
+  }
+
+  async updateJob(job: ListingScoreJob): Promise<void> {
+    await this.pool.query(`UPDATE voc.listing_score_job SET status=$3,total=$4,processed=$5,succeeded=$6,partial=$7,blocked=$8,failed=$9,started_at=$10,completed_at=$11,updated_at=$12
+      WHERE public_id=$1 AND workspace_id=(SELECT id FROM voc.workspace WHERE public_id=$2)`, [job.id,job.workspaceId,job.status,job.total,job.processed,job.succeeded,job.partial,job.blocked,job.failed,job.startedAt,job.completedAt,job.updatedAt]);
+  }
+  async getJob(workspaceId: string, jobId: string): Promise<ListingScoreJob | null> {
+    const result = await this.pool.query<JobRow>(`${JOB_SELECT} WHERE w.public_id=$1 AND j.public_id=$2 LIMIT 1`, [workspaceId, jobId]); return result.rows[0] ? jobFromRow(result.rows[0]) : null;
+  }
+  async listJobs(workspaceId: string, status: string, limit: number, cursor: string | null) {
+    const result = await this.pool.query<JobRow>(`${JOB_SELECT} WHERE w.public_id=$1 AND ($2='' OR j.status=$2) ORDER BY j.requested_at DESC,j.public_id`, [workspaceId,status]);
+    return page(result.rows.map(jobFromRow),limit,cursor,(item)=>item.id);
+  }
+  async listJobItems(workspaceId: string, jobId: string, status: string, limit: number, cursor: string | null) {
+    const result = await this.pool.query<ItemRow>(`${ITEM_SELECT} WHERE w.public_id=$1 AND j.public_id=$2 AND ($3='' OR i.status=$3) ORDER BY i.product_id,i.public_id`, [workspaceId,jobId,status]);
+    return page(result.rows.map(itemFromRow),limit,cursor,(item)=>item.id);
+  }
+  async getJobItems(workspaceId: string, jobId: string): Promise<ListingScoreJobItem[]> {
+    const result = await this.pool.query<ItemRow>(`${ITEM_SELECT} WHERE w.public_id=$1 AND j.public_id=$2 ORDER BY i.product_id,i.public_id`, [workspaceId,jobId]); return result.rows.map(itemFromRow);
+  }
+  async updateJobItem(item: ListingScoreJobItem): Promise<void> {
+    await this.pool.query(`UPDATE voc.listing_score_item SET status=$4,attempts=$5,score_result_public_id=$6,error_code=$7,error_detail_redacted=$8,updated_at=$9
+      WHERE public_id=$1 AND job_id=(SELECT id FROM voc.listing_score_job WHERE public_id=$2) AND workspace_id=(SELECT id FROM voc.workspace WHERE public_id=$3)`,
+    [item.id,item.jobId,item.workspaceId,item.status,item.attempts,item.scoreResultId,item.errorCode,item.errorDetail,item.updatedAt]);
+  }
+
+  async createVersion(version: ListingVersion): Promise<ListingVersion> {
+    const client = await this.pool.connect();
+    try {
+      await client.query('BEGIN');
+      await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [`${version.workspaceId}|${version.productId}`]);
+      const next = await client.query<{ next: number }>(`SELECT COALESCE(MAX(v.version_no),0)+1 AS next FROM voc.listing_version v JOIN voc.workspace w ON w.id=v.workspace_id WHERE w.public_id=$1 AND v.product_id=$2`, [version.workspaceId,version.productId]);
+      const value={...version,versionNo:next.rows[0]?.next??1};
+      await client.query(`INSERT INTO voc.listing_version (public_id,workspace_id,product_id,version_no,base_source_hash,base_score_result_public_id,content,status,created_by_external_id,created_at,adopted_at)
+        SELECT $1,w.id,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,$11 FROM voc.workspace w WHERE w.public_id=$2`, [value.id,value.workspaceId,value.productId,value.versionNo,value.baseSourceHash,value.baseScoreResultId,JSON.stringify(value.content),value.status,value.createdBy,value.createdAt,value.adoptedAt]);
+      await client.query('COMMIT'); return value;
+    } catch(error){await client.query('ROLLBACK');throw error;} finally{client.release();}
+  }
+  async getVersion(workspaceId: string, versionId: string): Promise<ListingVersion | null> {
+    const result=await this.pool.query<VersionRow>(`${VERSION_SELECT} WHERE w.public_id=$1 AND v.public_id=$2 LIMIT 1`,[workspaceId,versionId]);return result.rows[0]?versionFromRow(result.rows[0]):null;
+  }
+  async updateVersion(version: ListingVersion): Promise<void> {
+    await this.pool.query(`UPDATE voc.listing_version SET content=$3::jsonb,status=$4,adopted_at=$5 WHERE public_id=$1 AND workspace_id=(SELECT id FROM voc.workspace WHERE public_id=$2)`,[version.id,version.workspaceId,JSON.stringify(version.content),version.status,version.adoptedAt]);
+  }
+  async listVersions(workspaceId: string, productId: string | null, limit: number, cursor: string | null) {
+    const result=await this.pool.query<VersionRow>(`${VERSION_SELECT} WHERE w.public_id=$1 AND ($2::text IS NULL OR v.product_id=$2) ORDER BY v.created_at DESC,v.public_id`,[workspaceId,productId]);
+    return page(result.rows.map(versionFromRow),limit,cursor,(item)=>item.id);
+  }
+}

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

@@ -0,0 +1,216 @@
+import { Router } from 'express';
+import { z } from 'zod';
+import { ApiError } from '../../http/api-error.js';
+import { getPrincipal, type WorkspaceAccessService } from '../saas-platform/auth.js';
+import type { PlatformRepository } from '../saas-platform/domain.js';
+import type { ListingAiService } from './listing-ai.service.js';
+import { sanitizeListingHtml } from './normalization/html-sanitizer.js';
+import { listingCoverage } from './scoring/rule-engine.js';
+import { LISTING_AI_RUBRIC_VERSION } from './scoring/ai-rubric.js';
+import { LISTING_RUBRIC_VERSION } from './scoring/rule-engine.js';
+import {
+  createVersionRequestSchema,
+  listingPageQuerySchema,
+  listingProductQuerySchema,
+  listingWorkspaceQuerySchema,
+  scoreJobRequestSchema,
+} from './schemas.js';
+
+export function createListingAiRouter(dependencies: {
+  service: ListingAiService;
+  access: WorkspaceAccessService;
+  audit: PlatformRepository;
+  defaultWorkspaceId: string;
+}): Router {
+  const router = Router();
+
+  router.get('/products', async (request, response, next) => {
+    try {
+      const query = listingProductQuerySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      await dependencies.access.require(request, workspaceId, 'workspace:read');
+      const page = await dependencies.service.repository.listProducts({
+        ...query, workspaceId, cursor: query.cursor ?? null,
+      });
+      const summary = await dependencies.service.catalogSummary(workspaceId, query.platform);
+      response.json({ ...page, summary });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/products/:productId', async (request, response, next) => {
+    try {
+      const query = listingWorkspaceQuerySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      const productId = z.string().min(1).max(100).parse(request.params.productId);
+      await dependencies.access.require(request, workspaceId, 'workspace:read');
+      const source = await dependencies.service.repository.getSource(workspaceId, query.platform, productId);
+      if (!source) throw new ApiError(404, 'listing_product_not_found');
+      const storedScore = await dependencies.service.repository.getLatestScore(workspaceId, productId);
+      const latestScore = storedScore?.sourceHash === source.sourceHash ? storedScore : null;
+      const versions = await dependencies.service.repository.listVersions(workspaceId, productId, 10, null);
+      response.json({
+        source: {
+          ...source,
+          descriptions: {
+            desktopHtml: sanitizeListingHtml(source.descriptions.desktopHtml),
+            mobileHtml: sanitizeListingHtml(source.descriptions.mobileHtml),
+          },
+        },
+        coverage: latestScore?.coverage ?? listingCoverage(source),
+        latestScore,
+        versionsSummary: versions,
+      });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/products/:productId/scores/latest', async (request, response, next) => {
+    try {
+      const query = listingWorkspaceQuerySchema.extend({ rubricVersion: z.string().max(100).optional() }).parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      const productId = z.string().min(1).max(100).parse(request.params.productId);
+      await dependencies.access.require(request, workspaceId, 'workspace:read');
+      const source = await dependencies.service.repository.getSource(workspaceId, query.platform, productId);
+      const score = await dependencies.service.repository.getLatestScore(workspaceId, productId, query.rubricVersion);
+      if (!source || !score || score.sourceHash !== source.sourceHash) throw new ApiError(404, 'listing_score_not_found');
+      response.json({ score });
+    } catch (error) { next(error); }
+  });
+
+  router.post('/score-jobs', async (request, response, next) => {
+    try {
+      const input = scoreJobRequestSchema.parse(request.body);
+      const workspaceId = input.workspaceId ?? dependencies.defaultWorkspaceId;
+      await dependencies.access.require(request, workspaceId, 'analysis:run');
+      const idempotencyKey = request.header('Idempotency-Key')?.trim() || input.idempotencyKey;
+      if (!idempotencyKey || idempotencyKey.length < 8) throw new ApiError(400, 'idempotency_key_required');
+      const scoringMode = input.scoringMode ?? (input.includeAiSuggestions ? 'ai' : 'rules');
+      const includeAiScoring = scoringMode === 'ai';
+      const job = await dependencies.service.enqueueScoreJob({
+        workspaceId, platform: input.platform, scope: input.scope,
+        rubricVersion: input.rubricVersion ?? (includeAiScoring ? LISTING_AI_RUBRIC_VERSION : LISTING_RUBRIC_VERSION),
+        includeAiSuggestions: includeAiScoring,
+        idempotencyKey, requestedBy: getPrincipal(request).userId,
+      });
+      await dependencies.audit.appendAudit({
+        workspaceId, actorUserId: getPrincipal(request).userId,
+        action: 'listing.score.requested', entityType: 'listing_score_job', entityId: job.id,
+        metadata: { platform: job.platform, total: job.total, rubricVersion: job.rubricVersion, scoringMode },
+      });
+      response.status(202).json({ job });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/score-jobs', async (request, response, next) => {
+    try {
+      const query = listingPageQuerySchema.extend({ status: z.enum(['queued', 'running', 'completed', 'partial', 'failed', 'cancelled']).or(z.literal('')).default('') }).parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      await dependencies.access.require(request, workspaceId, 'workspace:read');
+      response.json(await dependencies.service.repository.listJobs(workspaceId, query.status, query.limit, query.cursor ?? null));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/score-jobs/:jobId', async (request, response, next) => {
+    try {
+      const query = listingWorkspaceQuerySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      const jobId = z.uuid().parse(request.params.jobId);
+      await dependencies.access.require(request, workspaceId, 'workspace:read');
+      const job = await dependencies.service.repository.getJob(workspaceId, jobId);
+      if (!job) throw new ApiError(404, 'score_job_not_found');
+      response.json({ job });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/score-jobs/:jobId/items', async (request, response, next) => {
+    try {
+      const query = listingPageQuerySchema.extend({ status: z.enum(['queued', 'rules_scored', 'ai_pending', 'scored', 'partial', 'blocked', 'failed']).or(z.literal('')).default('') }).parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      const jobId = z.uuid().parse(request.params.jobId);
+      await dependencies.access.require(request, workspaceId, 'workspace:read');
+      const job = await dependencies.service.repository.getJob(workspaceId, jobId);
+      if (!job) throw new ApiError(404, 'score_job_not_found');
+      response.json(await dependencies.service.repository.listJobItems(workspaceId, jobId, query.status, query.limit, query.cursor ?? null));
+    } catch (error) { next(error); }
+  });
+
+  router.post('/score-jobs/:jobId/retry', async (request, response, next) => {
+    try {
+      const query = listingWorkspaceQuerySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      const jobId = z.uuid().parse(request.params.jobId);
+      await dependencies.access.require(request, workspaceId, 'analysis:run');
+      const job = await dependencies.service.retryJob(workspaceId, jobId);
+      await dependencies.audit.appendAudit({ workspaceId, actorUserId: getPrincipal(request).userId, action: 'listing.score.retried', entityType: 'listing_score_job', entityId: job.id, metadata: {} });
+      response.status(202).json({ job });
+    } catch (error) { next(error); }
+  });
+
+  router.post('/score-jobs/:jobId/cancel', async (request, response, next) => {
+    try {
+      const query = listingWorkspaceQuerySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      const jobId = z.uuid().parse(request.params.jobId);
+      await dependencies.access.require(request, workspaceId, 'analysis:run');
+      const job = await dependencies.service.cancelJob(workspaceId, jobId);
+      await dependencies.audit.appendAudit({ workspaceId, actorUserId: getPrincipal(request).userId, action: 'listing.score.cancelled', entityType: 'listing_score_job', entityId: job.id, metadata: {} });
+      response.json({ job });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/versions', async (request, response, next) => {
+    try {
+      const query = listingPageQuerySchema.extend({ productId: z.string().max(100).optional() }).parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      await dependencies.access.require(request, workspaceId, 'workspace:read');
+      response.json(await dependencies.service.repository.listVersions(workspaceId, query.productId ?? null, query.limit, query.cursor ?? null));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/products/:productId/versions', async (request, response, next) => {
+    try {
+      const query = listingPageQuerySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      const productId = z.string().min(1).max(100).parse(request.params.productId);
+      await dependencies.access.require(request, workspaceId, 'workspace:read');
+      response.json(await dependencies.service.repository.listVersions(workspaceId, productId, query.limit, query.cursor ?? null));
+    } catch (error) { next(error); }
+  });
+
+  router.post('/products/:productId/versions', async (request, response, next) => {
+    try {
+      const input = createVersionRequestSchema.parse(request.body);
+      const workspaceId = input.workspaceId ?? dependencies.defaultWorkspaceId;
+      const productId = z.string().min(1).max(100).parse(request.params.productId);
+      await dependencies.access.require(request, workspaceId, 'action:write');
+      const version = await dependencies.service.createVersion({ ...input, workspaceId, productId, createdBy: getPrincipal(request).userId });
+      await dependencies.audit.appendAudit({ workspaceId, actorUserId: getPrincipal(request).userId, action: 'listing.version.created', entityType: 'listing_version', entityId: version.id, metadata: { productId } });
+      response.status(201).json({ version });
+    } catch (error) { next(error); }
+  });
+
+  router.get('/versions/:versionId', async (request, response, next) => {
+    try {
+      const query = listingWorkspaceQuerySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      const versionId = z.uuid().parse(request.params.versionId);
+      await dependencies.access.require(request, workspaceId, 'workspace:read');
+      const version = await dependencies.service.repository.getVersion(workspaceId, versionId);
+      if (!version) throw new ApiError(404, 'listing_version_not_found');
+      response.json({ version });
+    } catch (error) { next(error); }
+  });
+
+  router.post('/versions/:versionId/adopt', async (request, response, next) => {
+    try {
+      const query = listingWorkspaceQuerySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      const versionId = z.uuid().parse(request.params.versionId);
+      await dependencies.access.require(request, workspaceId, 'action:write');
+      const version = await dependencies.service.adoptVersion(workspaceId, query.platform, versionId);
+      await dependencies.audit.appendAudit({ workspaceId, actorUserId: getPrincipal(request).userId, action: 'listing.version.adopted', entityType: 'listing_version', entityId: version.id, metadata: { productId: version.productId } });
+      response.json({ version });
+    } catch (error) { next(error); }
+  });
+
+  return router;
+}

+ 54 - 0
src/modules/listing-ai/schemas.ts

@@ -0,0 +1,54 @@
+import { z } from 'zod';
+
+export const listingWorkspaceQuerySchema = z.object({
+  workspaceId: z.string().min(1).optional(),
+  platform: z.literal('jd').default('jd'),
+});
+
+export const listingPageQuerySchema = listingWorkspaceQuerySchema.extend({
+  limit: z.coerce.number().int().min(1).max(100).default(25),
+  cursor: z.string().max(1_000).optional(),
+});
+
+export const listingFilterSchema = z.object({
+  search: z.string().max(200).optional(),
+  categoryId: z.string().max(100).optional(),
+  itemStatus: z.string().max(100).optional(),
+  scoreStatus: z.enum(['unscored', 'scored', 'partial', 'blocked', 'failed']).optional(),
+  coverageStatus: z.enum(['eligible', 'partial', 'blocked']).optional(),
+  minScore: z.coerce.number().min(0).max(100).optional(),
+  maxScore: z.coerce.number().min(0).max(100).optional(),
+});
+
+export const listingProductQuerySchema = listingPageQuerySchema.extend({
+  ...listingFilterSchema.shape,
+  sort: z.enum(['productId', 'score_asc', 'score_desc', 'updated_desc']).default('productId'),
+});
+
+export const scoreJobRequestSchema = z.object({
+  workspaceId: z.string().min(1).optional(),
+  platform: z.literal('jd').default('jd'),
+  scope: z.discriminatedUnion('mode', [
+    z.object({ mode: z.literal('selected'), productIds: z.array(z.string().min(1).max(100)).min(1).max(100).transform((items) => [...new Set(items)]) }),
+    z.object({ mode: z.literal('filter'), filter: listingFilterSchema.default({}) }),
+  ]),
+  scoringMode: z.enum(['rules', 'ai']).optional(),
+  rubricVersion: z.string().min(1).max(100).optional(),
+  includeAiSuggestions: z.boolean().optional(),
+  idempotencyKey: z.string().min(8).max(200).optional(),
+});
+
+const attributeSchema = z.object({ id: z.string(), name: z.string(), values: z.array(z.string()) });
+export const createVersionRequestSchema = z.object({
+  workspaceId: z.string().min(1).optional(),
+  platform: z.literal('jd').default('jd'),
+  baseSourceHash: z.string().length(64),
+  baseScoreResultId: z.string().nullable().default(null),
+  content: z.object({
+    title: z.string().max(500).nullable(),
+    sellingPoints: z.array(z.string().max(2_000)).max(30),
+    descriptionHtml: z.string().max(200_000).nullable(),
+    specifications: z.array(attributeSchema).max(500),
+    imageUrls: z.array(z.url()).max(100),
+  }).optional(),
+});

+ 254 - 0
src/modules/listing-ai/scoring/ai-rubric.ts

@@ -0,0 +1,254 @@
+import { z } from 'zod';
+import type {
+  ListingDimension,
+  ListingDimensionScore,
+  ListingRuleEvidence,
+  ListingScoreResult,
+} from '../domain.js';
+
+export const LISTING_AI_RUBRIC_VERSION = 'listing-jd-ai-v1';
+export const LISTING_AI_PROMPT_VERSION = 'listing-ai-score-p1';
+
+const levelSchema = z.enum(['unknown', 'fail', 'weak', 'pass', 'strong']);
+export type ListingAiLevel = z.infer<typeof levelSchema>;
+
+const criterionIds = [
+  'title.search_intent', 'title.information_hierarchy', 'title.differentiation', 'title.factual_compliance',
+  'selling_points.customer_benefit', 'selling_points.specific_credible', 'selling_points.differentiation',
+  'selling_points.purchase_concerns', 'selling_points.consistency',
+  'description.completeness', 'description.structure_readability', 'description.benefit_evidence',
+  'description.objection_handling', 'description.consistency',
+  'specifications.decision_relevance', 'specifications.naming_clarity',
+  'specifications.category_completeness', 'specifications.consistency',
+] as const;
+
+export type ListingAiCriterionId = typeof criterionIds[number];
+
+export interface ListingAiCriterionDefinition {
+  id: ListingAiCriterionId;
+  dimension: Exclude<ListingDimension, 'images'>;
+  label: string;
+  maxPoints: number;
+}
+
+export const LISTING_AI_CRITERIA: readonly ListingAiCriterionDefinition[] = [
+  { id: 'title.search_intent', dimension: 'title', label: '搜索意图匹配', maxPoints: 4 },
+  { id: 'title.information_hierarchy', dimension: 'title', label: '信息层级与可读性', maxPoints: 3 },
+  { id: 'title.differentiation', dimension: 'title', label: '差异化价值表达', maxPoints: 3 },
+  { id: 'title.factual_compliance', dimension: 'title', label: '事实一致性与合规', maxPoints: 2 },
+  { id: 'selling_points.customer_benefit', dimension: 'selling_points', label: '客户收益表达', maxPoints: 4 },
+  { id: 'selling_points.specific_credible', dimension: 'selling_points', label: '具体性与可信度', maxPoints: 4 },
+  { id: 'selling_points.differentiation', dimension: 'selling_points', label: '卖点差异化', maxPoints: 3 },
+  { id: 'selling_points.purchase_concerns', dimension: 'selling_points', label: '购买顾虑覆盖', maxPoints: 3 },
+  { id: 'selling_points.consistency', dimension: 'selling_points', label: '与标题规格一致', maxPoints: 2 },
+  { id: 'description.completeness', dimension: 'description', label: '详情信息完整度', maxPoints: 4 },
+  { id: 'description.structure_readability', dimension: 'description', label: '详情结构与可读性', maxPoints: 3 },
+  { id: 'description.benefit_evidence', dimension: 'description', label: '卖点与证据表达', maxPoints: 3 },
+  { id: 'description.objection_handling', dimension: 'description', label: '购买顾虑处理', maxPoints: 2 },
+  { id: 'description.consistency', dimension: 'description', label: '详情与标题规格一致', maxPoints: 2 },
+  { id: 'specifications.decision_relevance', dimension: 'specifications', label: '购买决策有效性', maxPoints: 3 },
+  { id: 'specifications.naming_clarity', dimension: 'specifications', label: '规格命名清晰度', maxPoints: 2 },
+  { id: 'specifications.category_completeness', dimension: 'specifications', label: '类目规格完整性', maxPoints: 3 },
+  { id: 'specifications.consistency', dimension: 'specifications', label: '规格参数一致性', maxPoints: 2 },
+] as const;
+
+const assessmentSchema = z.object({
+  criterionId: z.enum(criterionIds),
+  level: levelSchema,
+  evidence: z.array(z.string().min(1).max(500)).max(3),
+  reason: z.string().min(1).max(1_000),
+  confidence: z.number().min(0).max(1),
+}).strict();
+
+export const listingAiScoreOutputSchema = z.object({
+  assessments: z.array(assessmentSchema).length(criterionIds.length),
+  summary: z.string().min(1).max(2_000),
+  suggestions: z.array(z.string().min(1).max(1_000)).max(8),
+}).strict().superRefine((value, context) => {
+  const ids = value.assessments.map((item) => item.criterionId);
+  for (const id of criterionIds) {
+    const count = ids.filter((candidate) => candidate === id).length;
+    if (count !== 1) context.addIssue({ code: 'custom', path: ['assessments'], message: `criterion ${id} must appear exactly once` });
+  }
+});
+
+export type ListingAiScoreOutput = z.infer<typeof listingAiScoreOutputSchema>;
+
+export function parseListingAiScoreOutput(content: string): ListingAiScoreOutput | null {
+  const trimmed = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
+  const start = trimmed.indexOf('{');
+  const end = trimmed.lastIndexOf('}');
+  if (start < 0 || end <= start) return null;
+  let parsed: unknown;
+  try { parsed = JSON.parse(trimmed.slice(start, end + 1)); } catch { return null; }
+  if (!parsed || typeof parsed !== 'object') return null;
+  const root = parsed as Record<string, unknown>;
+  const nested = root['result'] && typeof root['result'] === 'object' ? root['result'] as Record<string, unknown> : root;
+  const assessmentContainer = nested['assessments'] ?? nested['criteria'] ?? nested['scores'];
+  const rawAssessments = Array.isArray(assessmentContainer)
+    ? assessmentContainer
+    : assessmentContainer && typeof assessmentContainer === 'object'
+      ? Object.entries(assessmentContainer as Record<string, unknown>).map(([criterionId, value]) => (
+        value && typeof value === 'object' ? { criterionId, ...(value as Record<string, unknown>) } : value
+      ))
+      : [];
+  const byId = new Map<string, Record<string, unknown>>();
+  for (const item of rawAssessments) {
+    if (!item || typeof item !== 'object') continue;
+    const value = item as Record<string, unknown>;
+    const candidateId = value['criterionId'] ?? value['criterion_id'] ?? value['id'];
+    const rawId = typeof candidateId === 'string' ? candidateId.replace(/^ai\./, '') : '';
+    if (criterionIds.includes(rawId as ListingAiCriterionId) && !byId.has(rawId)) byId.set(rawId, value);
+  }
+  // A fixed rubric is only reproducible when every criterion is explicitly returned.
+  // Do not silently turn a truncated/model-shaped response into a valid all-unknown score.
+  if (byId.size !== criterionIds.length) return null;
+  const assessments = criterionIds.map((criterionId) => {
+    const value = byId.get(criterionId);
+    if (!value) return { criterionId, level: 'unknown', evidence: [], reason: '模型未返回该固定评分项', confidence: 0 };
+    const rawEvidence = value['evidence'] ?? value['evidences'] ?? value['citations'];
+    const evidence = typeof rawEvidence === 'string'
+      ? rawEvidence.trim() ? [rawEvidence.trim()] : []
+      : Array.isArray(rawEvidence) ? rawEvidence.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()).slice(0, 3) : [];
+    const rawLevel = typeof value['level'] === 'string' ? value['level'].toLowerCase() : 'unknown';
+    const level = ['unknown', 'fail', 'weak', 'pass', 'strong'].includes(rawLevel) ? rawLevel : 'unknown';
+    const rawConfidence = typeof value['confidence'] === 'number' && Number.isFinite(value['confidence']) ? value['confidence'] : 0;
+    const confidence = Math.max(0, Math.min(1, rawConfidence > 1 ? rawConfidence / 100 : rawConfidence));
+    return {
+      criterionId,
+      level,
+      evidence,
+      reason: typeof (value['reason'] ?? value['rationale']) === 'string' && String(value['reason'] ?? value['rationale']).trim()
+        ? String(value['reason'] ?? value['rationale']).trim().slice(0, 1_000)
+        : '模型未提供判定理由',
+      confidence,
+    };
+  });
+  const normalized = {
+    assessments,
+    summary: typeof nested['summary'] === 'string' && nested['summary'].trim() ? nested['summary'].slice(0, 2_000) : 'AI 已按固定 Rubric 完成评分',
+    suggestions: Array.isArray(nested['suggestions']) ? nested['suggestions'].filter((item): item is string => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim()).slice(0, 8) : [],
+  };
+  const validated = listingAiScoreOutputSchema.safeParse(normalized);
+  return validated.success ? validated.data : null;
+}
+
+const hardMaximum: Record<ListingDimension, number> = {
+  title: 8,
+  selling_points: 4,
+  images: 20,
+  description: 6,
+  specifications: 10,
+};
+
+const levelFactor: Record<Exclude<ListingAiLevel, 'unknown'>, number> = {
+  fail: 0,
+  weak: 0.35,
+  pass: 0.7,
+  strong: 1,
+};
+
+function half(value: number): number { return Math.round(value * 2) / 2; }
+
+function hardEvidence(dimension: ListingDimension, points: number, maximum: number): ListingRuleEvidence {
+  return {
+    ruleId: `baseline.${dimension}`,
+    fieldPath: dimension,
+    outcome: points >= maximum * 0.7 ? 'pass' : 'fail',
+    delta: half(points - maximum),
+    message: `结构化规则部分:${points} / ${maximum} 分`,
+    source: 'rule',
+    pointsAwarded: points,
+    maxPoints: maximum,
+  };
+}
+
+function aiEvidence(definition: ListingAiCriterionDefinition, assessment: ListingAiScoreOutput['assessments'][number]): ListingRuleEvidence {
+  const points = assessment.level === 'unknown' ? 0 : half(definition.maxPoints * levelFactor[assessment.level]);
+  return {
+    ruleId: `ai.${definition.id}`,
+    fieldPath: `ai.${definition.dimension}`,
+    outcome: assessment.level === 'unknown' ? 'unknown' : ['pass', 'strong'].includes(assessment.level) ? 'pass' : 'fail',
+    delta: assessment.level === 'unknown' ? 0 : half(points - definition.maxPoints),
+    message: `${definition.label}:${assessment.reason}`,
+    source: 'ai',
+    level: assessment.level,
+    pointsAwarded: points,
+    maxPoints: definition.maxPoints,
+    confidence: assessment.confidence,
+    citations: assessment.evidence,
+  };
+}
+
+export function composeListingAiScore(input: {
+  baseline: ListingScoreResult;
+  output: ListingAiScoreOutput;
+  model: string;
+  now: string;
+}): ListingScoreResult {
+  const assessmentById = new Map(input.output.assessments.map((item) => [item.criterionId, item]));
+  const dimensions = input.baseline.dimensions.map<ListingDimensionScore>((baselineDimension) => {
+    if (baselineDimension.dimension === 'images') {
+      const score = baselineDimension.score;
+      return {
+        ...baselineDimension,
+        evidence: score === null ? baselineDimension.evidence : [
+          hardEvidence('images', score, 20),
+          {
+            ruleId: 'ai.images.visual_unavailable', fieldPath: 'images', outcome: 'unknown', delta: 0,
+            message: '当前模型未验证多模态视觉能力;本维度仅使用图片结构规则评分', source: 'ai', level: 'unknown',
+            pointsAwarded: 0, maxPoints: 0, confidence: 0, citations: [],
+          },
+        ],
+        suggestions: score === null ? baselineDimension.suggestions : ['接入并验证多模态模型后,再评估构图、清晰度和文字可读性'],
+      };
+    }
+
+    const maximum = hardMaximum[baselineDimension.dimension];
+    if (baselineDimension.score === null) return baselineDimension;
+    const hardPoints = half((baselineDimension.score / 20) * maximum);
+    const definitions = LISTING_AI_CRITERIA.filter((item) => item.dimension === baselineDimension.dimension);
+    const rows = definitions.map((definition) => aiEvidence(definition, assessmentById.get(definition.id)!));
+    const hasUnknown = rows.some((row) => row.level === 'unknown');
+    const semanticPoints = rows.reduce((sum, row) => sum + (row.pointsAwarded ?? 0), 0);
+    const score = hasUnknown ? null : half(Math.min(20, hardPoints + semanticPoints));
+    return {
+      dimension: baselineDimension.dimension,
+      score,
+      maxScore: 20,
+      coverage: baselineDimension.coverage,
+      status: hasUnknown ? 'partial' : baselineDimension.status,
+      evidence: [hardEvidence(baselineDimension.dimension, hardPoints, maximum), ...rows],
+      suggestions: rows.filter((row) => row.outcome === 'fail').map((row) => row.message),
+    };
+  });
+  const numeric = dimensions.map((item) => item.score).filter((value): value is number => value !== null);
+  const confidences = input.output.assessments.filter((item) => item.level !== 'unknown').map((item) => item.confidence);
+  return {
+    ...input.baseline,
+    rubricVersion: LISTING_AI_RUBRIC_VERSION,
+    overallScore: numeric.length === dimensions.length ? half(numeric.reduce((sum, value) => sum + value, 0)) : null,
+    dimensions,
+    aiStatus: 'completed',
+    aiSuggestions: input.output.suggestions.map((item) => item.trim()).filter(Boolean).slice(0, 8),
+    aiCandidate: null,
+    model: input.model,
+    promptVersion: LISTING_AI_PROMPT_VERSION,
+    scoreKind: 'hybrid_ai',
+    baselineOverallScore: input.baseline.overallScore,
+    aiConfidence: confidences.length ? Math.round((confidences.reduce((sum, value) => sum + value, 0) / confidences.length) * 1000) / 1000 : null,
+    createdAt: input.now,
+  };
+}
+
+export function listingAiRubricPrompt(): string {
+  const criteria = LISTING_AI_CRITERIA.map((item) => `- ${item.id}(${item.label},${item.maxPoints}分)`).join('\n');
+  return [
+    '你是京东 Listing 质量评分裁判,不是文案生成器。只能依据输入 JSON,不得使用外部知识补造商品事实。',
+    '你不能直接给总分,也不能改变标准和权重。必须对下列每个 criterionId 恰好返回一次判定:',
+    criteria,
+    '等级锚点固定:strong=证据充分且表现优秀;pass=基本达标且有明确证据;weak=部分满足或表达较弱;fail=明确缺失、矛盾或明显不合格;unknown=输入没有足够证据。',
+    'evidence 必须逐字引用输入中的短文本或规格;没有证据时使用空数组并判定 unknown。禁止把销量、评价、竞品、认证或功效当成事实,除非输入明确提供。',
+    `只返回合法 JSON:{"assessments":[{"criterionId":"${criterionIds[0]}","level":"pass","evidence":["原文"],"reason":"理由","confidence":0.8}],"summary":"总体说明","suggestions":["改进建议"]}。assessments 必须包含全部 ${criterionIds.length} 项且不得重复。`,
+  ].join('\n');
+}

+ 190 - 0
src/modules/listing-ai/scoring/rule-engine.ts

@@ -0,0 +1,190 @@
+import { createHash, randomUUID } from 'node:crypto';
+import type {
+  ListingCoverage,
+  ListingDimension,
+  ListingDimensionScore,
+  ListingRuleEvidence,
+  ListingScoreResult,
+  ListingSourceSnapshot,
+} from '../domain.js';
+
+export const LISTING_RUBRIC_VERSION = 'listing-jd-v2';
+const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
+
+function textFromHtml(value: string | null): string {
+  return (value ?? '').replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
+    .replace(/<[^>]+>/g, ' ').replace(/&nbsp;/gi, ' ').replace(/\s+/g, ' ').trim();
+}
+
+function evidence(
+  ruleId: string,
+  fieldPath: string,
+  pass: boolean | null,
+  penalty: number,
+  message: string,
+): ListingRuleEvidence {
+  return { ruleId, fieldPath, outcome: pass === null ? 'unknown' : pass ? 'pass' : 'fail', delta: pass === false ? -penalty : 0, message };
+}
+
+function finish(dimension: ListingDimension, rows: ListingRuleEvidence[], covered: number, total: number): ListingDimensionScore {
+  const coverage = Math.round((covered / total) * 100);
+  if (covered === 0) {
+    return { dimension, score: null, maxScore: 20, coverage: 0, status: 'blocked', evidence: rows, suggestions: rows.filter((row) => row.outcome !== 'pass').map((row) => row.message) };
+  }
+  const score = Math.max(0, Math.min(20, 20 + rows.reduce((sum, row) => sum + row.delta, 0)));
+  return {
+    dimension,
+    score,
+    maxScore: 20,
+    coverage,
+    status: coverage < 60 ? 'partial' : 'scored',
+    evidence: rows,
+    suggestions: rows.filter((row) => row.outcome === 'fail').map((row) => row.message),
+  };
+}
+
+function scoreTitle(source: ListingSourceSnapshot): ListingDimensionScore {
+  const title = source.title?.trim() ?? '';
+  const brand = source.brand.name?.trim() ?? source.titleBrandName?.trim() ?? '';
+  const rows = [
+    evidence('title.present', 'title', Boolean(title), 20, '补充商品标题'),
+    evidence('title.length', 'title', title ? title.length >= 12 && title.length <= 60 : null, 5, '标题建议保持在 12–60 个字符'),
+    evidence('title.brand', 'brand.name', title && brand ? title.toLocaleLowerCase().includes(brand.toLocaleLowerCase()) : null, 3, '在标题中准确包含品牌'),
+    evidence('title.no_repeated_tokens', 'title', title ? !/(.{2,8})\1{2,}/.test(title) : null, 4, '删除标题中的重复词组'),
+    evidence('title.no_excess_symbols', 'title', title ? !/[!!]{2,}|[★☆]{2,}/.test(title) : null, 3, '减少连续营销符号'),
+  ];
+  return finish('title', rows, title ? (brand ? 5 : 4) : 0, 5);
+}
+
+function scoreSellingPoints(source: ListingSourceSnapshot): ListingDimensionScore {
+  // JD productInfo.features also contains transport/control flags such as 0/1.
+  // Those are not seller-facing selling points and must not participate in
+  // duplicate detection. Until JD exposes a dedicated marketing-points field,
+  // use only human-readable descriptors and attributes as derived candidates.
+  const readableFeatures = source.features
+    .filter((item) => ['nameWithoutBrand', 'model'].includes(item.key) || /[\u4e00-\u9fff]/.test(item.key))
+    .map((item) => item.value.trim())
+    .filter((value) => value && !/^[01]$/.test(value));
+  const attributePoints = source.attributes
+    .filter((item) => item.name.trim() && item.values.some((value) => value.trim()))
+    .map((item) => `${item.name.trim()}:${item.values.map((value) => value.trim()).filter(Boolean).join('、')}`);
+  const values = [...readableFeatures, ...attributePoints];
+  const hasService = Object.keys(source.afterService).length > 0;
+  const rows = [
+    evidence('selling_points.present', 'derivedSellingPoints', values.length > 0, 20, '补充结构化核心卖点'),
+    evidence('selling_points.count', 'derivedSellingPoints', values.length ? values.length >= 3 : null, 5, '至少提供 3 条可读的卖点信息'),
+    evidence('selling_points.unique', 'derivedSellingPoints', null, 0, '上游未返回独立营销卖点,暂不执行重复性扣分'),
+    evidence('selling_points.specific', 'derivedSellingPoints', values.length ? values.some((value) => /\d/.test(value)) : null, 3, '卖点中加入可验证的规格或数字'),
+    evidence('selling_points.service', 'afterService', hasService, 2, '补充售后或履约承诺'),
+  ];
+  return finish('selling_points', rows, values.length ? 3 + Number(hasService) : 0, 5);
+}
+
+function scoreImages(source: ListingSourceSnapshot): ListingDimensionScore {
+  const images = source.images;
+  const validUrls = images.filter((item) => /^https?:\/\//i.test(item.url));
+  const unique = new Set(images.map((item) => item.url));
+  const primary = images.some((item) => item.isPrimary === true);
+  const ordered = images.every((item, index) => item.order === null || index === 0 || (item.order ?? 0) >= (images[index - 1]?.order ?? 0));
+  const rows = [
+    evidence('images.present', 'images', images.length > 0, 20, '至少提供一张主图'),
+    evidence('images.count', 'images', images.length ? images.length >= 5 : null, 5, '建议提供至少 5 张不同角度的图片'),
+    evidence('images.primary', 'images[].isPrimary', images.length ? primary : null, 4, '明确设置主图'),
+    evidence('images.valid_url', 'images[].url', images.length ? validUrls.length === images.length : null, 4, '修复不可识别的图片 URL'),
+    evidence('images.unique_ordered', 'images[].order', images.length ? unique.size === images.length && ordered : null, 3, '去除重复图片并校正顺序'),
+  ];
+  return finish('images', rows, images.length ? 5 : 0, 5);
+}
+
+function scoreDescription(source: ListingSourceSnapshot): ListingDimensionScore {
+  const desktopRaw = source.descriptions.desktopHtml ?? '';
+  const mobileRaw = source.descriptions.mobileHtml ?? '';
+  const desktop = textFromHtml(desktopRaw);
+  const mobile = textFromHtml(mobileRaw);
+  const desktopPresent = Boolean(desktop || /<img\b/i.test(desktopRaw));
+  const mobilePresent = Boolean(mobile || /<img\b/i.test(mobileRaw));
+  const combinedPresent = desktopPresent || mobilePresent;
+  const unsafe = /<script|on\w+\s*=|javascript:/i.test(`${desktopRaw}${mobileRaw}`);
+  const rows = [
+    evidence('description.present', 'descriptions', combinedPresent, 20, '补充商品详情'),
+    evidence('description.desktop', 'descriptions.desktopHtml', desktopPresent ? desktopRaw.length >= 120 : null, 5, '完善桌面端详情内容'),
+    evidence('description.mobile', 'descriptions.mobileHtml', mobilePresent ? mobileRaw.length >= 80 : null, 4, '完善移动端详情内容'),
+    evidence('description.safe_html', 'descriptions', combinedPresent ? !unsafe : null, 6, '移除不安全 HTML'),
+    evidence('description.consistent', 'descriptions', desktopPresent && mobilePresent ? Math.min(desktopRaw.length, mobileRaw.length) / Math.max(desktopRaw.length, mobileRaw.length) >= 0.25 : null, 3, '保持桌面端与移动端信息一致'),
+  ];
+  return finish('description', rows, combinedPresent ? 3 + Number(desktopPresent) + Number(mobilePresent) : 0, 5);
+}
+
+function scoreSpecifications(source: ListingSourceSnapshot): ListingDimensionScore {
+  const attributes = source.attributes.filter((item) => item.name && item.values.length);
+  const skuAttributes = source.skus.flatMap((sku) => sku.attributes);
+  const dimensions = Object.values(source.dimensions).filter((value) => value !== null && value > 0);
+  const skuIds = new Set(source.skus.map((sku) => sku.skuId));
+  const rows = [
+    evidence('specifications.present', 'attributes', attributes.length > 0, 20, '补充商品规格属性'),
+    evidence('specifications.count', 'attributes', attributes.length ? attributes.length >= 3 : null, 5, '至少提供 3 个有效规格属性'),
+    evidence('specifications.sku_attrs', 'skus[].attributes', source.skus.length ? skuAttributes.length > 0 : null, 4, '补充 SKU 维度属性'),
+    evidence('specifications.dimensions', 'dimensions', dimensions.length > 0, 3, '补充尺寸或重量'),
+    evidence('specifications.unique_skus', 'skus[].skuId', source.skus.length ? skuIds.size === source.skus.length : null, 4, '修复重复 SKU 标识'),
+  ];
+  return finish('specifications', rows, attributes.length ? 3 + Number(source.skus.length > 0) + Number(dimensions.length > 0) : 0, 5);
+}
+
+export function listingCoverage(source: ListingSourceSnapshot): ListingCoverage {
+  const required = [
+    ['title', Boolean(source.title?.trim())],
+    ['features', source.features.some((item) => item.value.trim())],
+    ['images', source.images.length > 0],
+    ['descriptions', Boolean(textFromHtml(source.descriptions.desktopHtml) || textFromHtml(source.descriptions.mobileHtml) || /<img\b/i.test(`${source.descriptions.desktopHtml ?? ''}${source.descriptions.mobileHtml ?? ''}`))],
+    ['attributes', source.attributes.length > 0],
+  ] as const;
+  const present = required.filter(([, available]) => available).length;
+  const percent = Math.round((present / required.length) * 100);
+  return {
+    percent,
+    missing: required.filter(([, available]) => !available).map(([name]) => name),
+    status: source.detailStatus !== 'available' || present <= 1 ? 'blocked' : percent < 60 ? 'partial' : 'eligible',
+  };
+}
+
+export function scoreListing(
+  source: ListingSourceSnapshot,
+  options: { id?: string; now?: string; rubricVersion?: string } = {},
+): ListingScoreResult {
+  const dimensions = [scoreTitle(source), scoreSellingPoints(source), scoreImages(source), scoreDescription(source), scoreSpecifications(source)];
+  const coverage = listingCoverage(source);
+  const scores = dimensions.map((item) => item.score).filter((score): score is number => score !== null);
+  const canTotal = coverage.status !== 'blocked' && dimensions.every((item) => item.score !== null);
+  return {
+    id: options.id ?? randomUUID(),
+    workspaceId: source.workspaceId,
+    productId: source.productId,
+    sourceHash: source.sourceHash,
+    rubricVersion: options.rubricVersion ?? LISTING_RUBRIC_VERSION,
+    overallScore: canTotal ? scores.reduce((sum, value) => sum + value, 0) : null,
+    coverage,
+    dimensions,
+    aiStatus: 'not_requested',
+    aiSuggestions: [],
+    aiCandidate: null,
+    model: null,
+    promptVersion: null,
+    scoreKind: 'rules',
+    baselineOverallScore: null,
+    aiConfidence: null,
+    createdAt: options.now ?? new Date().toISOString(),
+  };
+}
+
+export function canonicalHash(value: unknown): string {
+  const canonical = (input: unknown): unknown => {
+    if (Array.isArray(input)) return input.map(canonical);
+    if (input && typeof input === 'object') {
+      return Object.fromEntries(Object.entries(input as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, canonical(child)]));
+    }
+    return input;
+  };
+  return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
+}
+
+export const LISTING_DIMENSIONS = DIMENSIONS;

+ 4 - 0
src/server.ts

@@ -17,6 +17,8 @@ import { DEFAULT_DOMESTIC_AI_PROMPT_CONFIGS } from './modules/ai-gateway/default
 import { ParseRestVocRepository } from './modules/saas-platform/parse-rest-voc.repository.js';
 import { ParseRestVocRepository } from './modules/saas-platform/parse-rest-voc.repository.js';
 import { PostgresPlatformRepository } from './modules/saas-platform/postgres-platform.repository.js';
 import { PostgresPlatformRepository } from './modules/saas-platform/postgres-platform.repository.js';
 import { ParseRestProductKnowledgeStore } from './modules/product-knowledge/product-knowledge.store.js';
 import { ParseRestProductKnowledgeStore } from './modules/product-knowledge/product-knowledge.store.js';
+import { ParseRestListingAiRepository } from './modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+import { PostgresListingAiRepository } from './modules/listing-ai/repositories/postgres-listing-ai.repository.js';
 
 
 async function main(): Promise<void> {
 async function main(): Promise<void> {
   const config = loadConfig();
   const config = loadConfig();
@@ -59,6 +61,7 @@ async function main(): Promise<void> {
       },
       },
       aiPromptConfigs: promptConfigs,
       aiPromptConfigs: promptConfigs,
       productKnowledge,
       productKnowledge,
+      listingAiRepository: new ParseRestListingAiRepository(client),
     });
     });
     const processor = new JdSyncService(gateway, repository, config.worker.reviewMaxPages);
     const processor = new JdSyncService(gateway, repository, config.worker.reviewMaxPages);
     worker = config.worker.enabled
     worker = config.worker.enabled
@@ -87,6 +90,7 @@ async function main(): Promise<void> {
       pool,
       pool,
       parseApp: parseServer.app as unknown as RequestHandler,
       parseApp: parseServer.app as unknown as RequestHandler,
       platformRepository,
       platformRepository,
+      listingAiRepository: new PostgresListingAiRepository(pool),
     });
     });
     const ingestion = new VocIngestionRepository(pool);
     const ingestion = new VocIngestionRepository(pool);
     const processor = new JdSyncService(gateway, ingestion, config.worker.reviewMaxPages);
     const processor = new JdSyncService(gateway, ingestion, config.worker.reviewMaxPages);

+ 25 - 0
test/jd-sp-client.signature.test.ts

@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import test from 'node:test';
+import { JdSpClient } from '../src/modules/listing-ai/clients/jd-sp.client.js';
+import { normalizeJdListing } from '../src/modules/listing-ai/normalization/jd-listing.normalizer.js';
+
+test('JD detail path productId participates in signature but not query string', async () => {
+  let capturedUrl='';let capturedHeaders:Headers|null=null;
+  const fetchImpl:typeof fetch=async(input,init)=>{capturedUrl=String(input);capturedHeaders=new Headers(init?.headers);return new Response(JSON.stringify({success:true,data:{productInfo:{productId:1001}}}),{status:200,headers:{'content-type':'application/json'}});};
+  const client=new JdSpClient({baseUrl:'https://api-cn.jd.com/rest',appKey:'app-key',appSecret:'app-secret',timeoutMs:1_000,retries:0},fetchImpl);
+  await client.get('/sp-product/v0/products/1001',{scene:'pop'},'access-token',{productId:'1001'});
+  assert.equal(new URL(capturedUrl).searchParams.get('scene'),'pop');
+  assert.equal(new URL(capturedUrl).searchParams.has('productId'),false);
+  const timestamp=capturedHeaders!.get('X-JOS-Timestamp')!;
+  const fields:Record<string,string>={'X-JOS-Access-Token':'access-token','X-JOS-App-Key':'app-key','X-JOS-Timestamp':timestamp,productId:'1001',scene:'pop'};
+  const plain=Object.keys(fields).sort().map((key)=>`${key}${fields[key]}`).join('');
+  const expected=createHash('md5').update(`app-secret${plain}app-secret`).digest('hex').toUpperCase();
+  assert.equal(capturedHeaders!.get('X-JOS-Sign'),expected);
+});
+
+test('JD image paths are converted to safe HTTPS CDN URLs',()=>{
+  const source=normalizeJdListing({workspaceId:'demashi',shopId:'shop',row:{productId:1001},detail:{productInfo:{productId:1001,productName:'商品'},material:{mainImages:[{imageInfoList:[{imgUrl:'jfs/t1/demo.jpg',orderSort:1,primaryFlag:true}]}]}}});
+  assert.equal(source.images[0]?.url,'https://img10.360buyimg.com/n1/jfs/t1/demo.jpg');
+});
+

+ 70 - 0
test/listing-ai.ai-rubric.test.ts

@@ -0,0 +1,70 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { ListingAiService, type ListingAiScoringProvider } from '../src/modules/listing-ai/listing-ai.service.js';
+import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
+import { LISTING_AI_CRITERIA, LISTING_AI_PROMPT_VERSION, LISTING_AI_RUBRIC_VERSION, parseListingAiScoreOutput, type ListingAiScoreOutput } from '../src/modules/listing-ai/scoring/ai-rubric.js';
+
+const source: ListingSourceSnapshot = {
+  id: 'source-ai-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: 'ai-1001', sourceHash: 'e'.repeat(64),
+  title: '星星 299L 一级能效风冷无霜商用冷藏展示柜', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10'], itemStatus: '1',
+  price: { jd: 1049, cost: 800 }, descriptions: { desktopHtml: `<p>${'299L大容量,一级能效,风冷无霜,适合便利店使用。'.repeat(20)}</p>`, mobileHtml: `<p>${'299L大容量,一级能效,风冷无霜。'.repeat(20)}</p>` },
+  features: [{ key: 'nameWithoutBrand', value: '299L 一级能效风冷无霜展示柜' }, { key: 'model', value: 'BC-299' }],
+  attributes: [{ id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '制冷方式', values: ['风冷'] }, { id: '3', name: '能效等级', values: ['一级'] }],
+  images: Array.from({ length: 5 }, (_, index) => ({ url: `https://img.test/${index}.jpg`, order: index + 1, isPrimary: index === 0, gptFlag: null })),
+  skus: [{ skuId: 'sku-1', name: '299L', price: 1049, stock: 10, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
+  dimensions: { length: 600, width: 620, height: 1900, weight: 60 }, logistics: {}, afterService: { return7Days: true },
+  sourceModifiedAt: null, syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available',
+};
+
+class StableAiJudge implements ListingAiScoringProvider {
+  configured = true;
+  model = 'deepseek-v4-pro';
+  calls = 0;
+  async score(): Promise<ListingAiScoreOutput> {
+    this.calls += 1;
+    return {
+      assessments: LISTING_AI_CRITERIA.map((criterion) => ({
+        criterionId: criterion.id, level: 'strong' as const, evidence: ['输入中的可验证事实'], reason: '证据充分', confidence: 0.9,
+      })),
+      summary: '结构和语义均完整', suggestions: ['保持标题、卖点与规格一致'],
+    };
+  }
+}
+
+async function waitForTerminal(service: ListingAiService, jobId: string): Promise<void> {
+  for (let index = 0; index < 100; index += 1) {
+    const job = await service.repository.getJob('demashi', jobId);
+    if (job && ['completed', 'partial', 'failed'].includes(job.status)) return;
+    await new Promise((resolve) => setTimeout(resolve, 5));
+  }
+  assert.fail('AI score job did not reach terminal state');
+}
+
+test('AI rubric uses fixed criteria, server-side composition, and stable cache identity', async () => {
+  const repository = new InMemoryListingAiRepository([source]);
+  const judge = new StableAiJudge();
+  const service = new ListingAiService(repository, judge, () => new Date('2026-08-21T10:00:00.000Z'), 1, 10);
+  const first = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-score-first', requestedBy: 'test' });
+  await waitForTerminal(service, first.id);
+  const result = await repository.getLatestScore('demashi', source.productId, LISTING_AI_RUBRIC_VERSION);
+  assert.equal(result?.overallScore, 100);
+  assert.equal(result?.scoreKind, 'hybrid_ai');
+  assert.equal(result?.promptVersion, LISTING_AI_PROMPT_VERSION);
+  assert.equal(result?.aiCandidate, null);
+  assert.equal(result?.dimensions.find((item) => item.dimension === 'images')?.evidence.some((item) => item.ruleId === 'ai.images.visual_unavailable'), true);
+  assert.equal(judge.calls, 1);
+
+  const second = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-score-second', requestedBy: 'test' });
+  await waitForTerminal(service, second.id);
+  assert.equal(judge.calls, 1, 'same source/rubric/model/prompt must reuse the stored AI score');
+});
+
+test('AI output parser normalizes a scalar evidence field without relaxing rubric completeness', () => {
+  const content = JSON.stringify({
+    assessments: LISTING_AI_CRITERIA.map((criterion) => ({ criterionId: criterion.id, level: 'pass', evidence: '原始字段', reason: '有直接证据', confidence: 0.8 })),
+    summary: '完成', suggestions: [],
+  });
+  const parsed = parseListingAiScoreOutput(content);
+  assert.deepEqual(parsed?.assessments[0]?.evidence, ['原始字段']);
+});

+ 127 - 0
test/listing-ai.routes.test.ts

@@ -0,0 +1,127 @@
+import assert from 'node:assert/strict';
+import type { AddressInfo } from 'node:net';
+import test from 'node:test';
+import { createLocalDemoApp } from '../src/local-app.js';
+import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
+import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js';
+import { ApiError } from '../src/http/api-error.js';
+import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import type { DomesticDataset, DomesticMetricSummary } from '../src/types/domestic-dataset.js';
+
+const metrics: DomesticMetricSummary = { gmv: 0, soldUnits: 0, transactionOrders: 0, transactionCustomers: 0, impressions: 0, clicks: 0, views: 0, visitors: 0, cartUnits: 0, orderAmount: 0, orderUnits: 0, orderCount: 0, refundAmount: 0, refundUnits: 0, refundOrders: 0, conversionRate: 0, clickThroughRate: 0, averageUnitPrice: 0, refundToGmvRate: 0 };
+const dataset: DomesticDataset = {
+  schemaVersion: 1, generatedAt: '2026-08-21T00:00:00.000Z', caseName: 'Listing test', platform: 'jd',
+  source: { sourceFile: 'test.json', sourceHash: 'test', dateRange: { start: '2026-08-21', end: '2026-08-21' } },
+  summary: { metricRows: 0, metricProducts: 1, mappingRows: 0, relations: 0, uniqueCompetitorProducts: 0, category2Count: 0, category3Count: 0, reviewCount: 0 },
+  dailyTotals: [], products: [{ platform: 'jd', productId: '1001', productKey: 'jd:1001', asin: '1001', role: 'own', brand: '星星', title: '测试商品', model: '', category1: '', category2: '', category3: '', source: 'test', relationCount: 0, summary: metrics, trend: [] }],
+  mappingGroups: [], relations: [], reviews: [], quality: { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] },
+};
+const listingSource: ListingSourceSnapshot = {
+  id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: '1001', sourceHash: 'b'.repeat(64), title: '星星 299L 一级能效风冷无霜展示柜', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['20'], itemStatus: '1', price: { jd: 1049, cost: null },
+  descriptions: { desktopHtml: '<script>alert(1)</script><p onclick="bad()">安全详情</p>', mobileHtml: '<p>移动详情</p>' },
+  features: [{ key: 'one', value: '299L 大容量' }, { key: 'two', value: '一级能效' }, { key: 'three', value: '风冷无霜' }],
+  attributes: [{ id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '能效', values: ['一级'] }, { id: '3', name: '制冷', values: ['风冷'] }],
+  images: Array.from({ length: 5 }, (_, i) => ({ url: `https://img.test/${i}.jpg`, order: i + 1, isPrimary: i === 0, gptFlag: null })),
+  skus: [{ skuId: 'sku1', name: '299L', price: 1049, stock: 1, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
+  dimensions: { length: 1, width: 1, height: 1, weight: 1 }, logistics: {}, afterService: { return7Days: true }, sourceModifiedAt: null, syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available',
+};
+
+test('listing API scores a frozen source, sanitizes HTML, and adopts an internal version', async () => {
+  const repository = new InMemoryListingAiRepository([listingSource]);
+  const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository });
+  const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
+  try {
+    const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai`;
+    const products = await fetch(`${base}/products`);
+    assert.equal(products.status, 200);
+    const catalog = await products.json() as { items: Array<{ productId: string }>; summary: { sourceTotal: number } };
+    assert.equal(catalog.summary.sourceTotal, 1);
+    assert.equal(catalog.items[0]?.productId, '1001');
+
+    const detail = await fetch(`${base}/products/1001`);
+    const detailBody = await detail.json() as { source: { descriptions: { desktopHtml: string } } };
+    assert.equal(detail.status, 200);
+    assert.doesNotMatch(detailBody.source.descriptions.desktopHtml, /script|onclick/i);
+
+    const jobResponse = await fetch(`${base}/score-jobs`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'listing-route-test-1' },
+      body: JSON.stringify({ scope: { mode: 'filter', filter: {} }, includeAiSuggestions: false }),
+    });
+    assert.equal(jobResponse.status, 202);
+    const jobId = (await jobResponse.json() as { job: { id: string } }).job.id;
+    let status = '';
+    for (let index = 0; index < 30; index += 1) {
+      const response = await fetch(`${base}/score-jobs/${jobId}`);
+      status = (await response.json() as { job: { status: string } }).job.status;
+      if (['completed', 'partial', 'failed'].includes(status)) break;
+      await new Promise((resolve) => setTimeout(resolve, 10));
+    }
+    assert.equal(status, 'completed');
+
+    const scored = await fetch(`${base}/products/1001`);
+    const scoredBody = await scored.json() as { latestScore: { id: string; overallScore: number } };
+    assert.equal(typeof scoredBody.latestScore.overallScore, 'number');
+
+    const versionResponse = await fetch(`${base}/products/1001/versions`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        baseSourceHash: listingSource.sourceHash,
+        baseScoreResultId: scoredBody.latestScore.id,
+        content: {
+          title: `${listingSource.title} 优化稿`,
+          sellingPoints: ['299L 大容量', '一级能效', '风冷无霜'],
+          descriptionHtml: '<p>优化详情</p>',
+          specifications: listingSource.attributes,
+          imageUrls: listingSource.images.map((item) => item.url),
+        },
+      }),
+    });
+    assert.equal(versionResponse.status, 201);
+    const versionId = (await versionResponse.json() as { version: { id: string } }).version.id;
+    const adopted = await fetch(`${base}/versions/${versionId}/adopt`, { method: 'POST' });
+    assert.equal(adopted.status, 200);
+    assert.equal((await adopted.json() as { version: { status: string } }).version.status, 'adopted');
+  } finally {
+    await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
+  }
+});
+
+test('a changed source hash hides stale scores and marks prior versions stale', async () => {
+  const repository = new InMemoryListingAiRepository([listingSource]);
+  const now = '2026-08-21T01:00:00.000Z';
+  await repository.saveScore({
+    id: 'score-old', workspaceId: listingSource.workspaceId, productId: listingSource.productId,
+    sourceHash: listingSource.sourceHash, rubricVersion: 'listing-jd-v1', overallScore: 88,
+    coverage: { percent: 100, missing: [], status: 'eligible' }, dimensions: [],
+    aiStatus: 'not_requested', aiSuggestions: [], aiCandidate: null, model: null, promptVersion: null, createdAt: now,
+  });
+  const version = await repository.createVersion({
+    id: 'version-old', workspaceId: listingSource.workspaceId, productId: listingSource.productId, versionNo: 0,
+    baseSourceHash: listingSource.sourceHash, baseScoreResultId: 'score-old',
+    content: { title: listingSource.title, sellingPoints: [], descriptionHtml: null, specifications: [], imageUrls: [] },
+    status: 'draft', createdBy: 'test', createdAt: now, adoptedAt: null,
+  });
+  const changed = { ...listingSource, id: 'source-2', sourceHash: 'c'.repeat(64), syncedAt: '2026-08-21T02:00:00.000Z' };
+  await repository.upsertSources([changed]);
+
+  assert.equal((await repository.listProducts({ workspaceId: changed.workspaceId, platform: 'jd', limit: 10, cursor: null })).items[0]?.latestScore, null);
+  assert.equal((await repository.getVersion(changed.workspaceId, version.id))?.status, 'stale');
+});
+
+test('AI jobs fail closed before enqueueing beyond the configured item budget', async () => {
+  const second = { ...listingSource, id: 'source-budget-2', productId: '1002', sourceHash: 'd'.repeat(64) };
+  const service = new ListingAiService(
+    new InMemoryListingAiRepository([listingSource, second]),
+    undefined,
+    () => new Date('2026-08-21T03:00:00.000Z'),
+    1,
+    1,
+  );
+  await assert.rejects(
+    service.enqueueScoreJob({
+      workspaceId: listingSource.workspaceId, platform: 'jd', scope: { mode: 'filter', filter: {} },
+      includeAiSuggestions: true, idempotencyKey: 'budget-guard-test', requestedBy: 'test',
+    }),
+    (error: unknown) => error instanceof ApiError && error.status === 429 && error.code === 'listing_ai_budget_exceeded',
+  );
+});

+ 61 - 0
test/listing-ai.rule-engine.test.ts

@@ -0,0 +1,61 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { canonicalHash, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
+
+function source(overrides: Partial<ListingSourceSnapshot> = {}): ListingSourceSnapshot {
+  return {
+    id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop-1', productId: '1001', sourceHash: 'a'.repeat(64),
+    title: '星星 299L 一级能效风冷无霜商用冷藏展示柜', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10', '20'], itemStatus: 'on_shelf',
+    price: { jd: 1049, cost: 800 }, descriptions: { desktopHtml: `<p>${'完整商品详情与使用场景。'.repeat(30)}</p>`, mobileHtml: `<p>${'移动端商品详情。'.repeat(30)}</p>` },
+    features: [
+      { key: 'capacity', value: '299L 大容量' }, { key: 'efficiency', value: '一级能效' }, { key: 'cooling', value: '风冷无霜' },
+    ],
+    attributes: [
+      { id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '制冷方式', values: ['风冷'] }, { id: '3', name: '能效', values: ['一级'] },
+    ],
+    images: Array.from({ length: 5 }, (_, index) => ({ url: `https://img.test/${index}.jpg`, order: index + 1, isPrimary: index === 0, gptFlag: false })),
+    skus: [{ skuId: 'sku-1', name: '299L', price: 1049, stock: 20, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
+    dimensions: { length: 600, width: 620, height: 1900, weight: 60 }, logistics: { delivery: '京东物流' }, afterService: { return7Days: true },
+    sourceModifiedAt: '2026-08-21T00:00:00.000Z', syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available', ...overrides,
+  };
+}
+
+test('rule engine is deterministic for the same source and rubric', () => {
+  const first = scoreListing(source(), { id: 'same', now: '2026-08-21T00:00:00.000Z' });
+  const second = scoreListing(source(), { id: 'same', now: '2026-08-21T00:00:00.000Z' });
+  assert.deepEqual(first, second);
+  assert.equal(first.overallScore, 100);
+  assert.equal(first.coverage.status, 'eligible');
+  assert.equal(first.dimensions.length, 5);
+});
+
+test('missing detail is blocked instead of receiving a zero quality score', () => {
+  const result = scoreListing(source({
+    title: null, features: [], images: [], attributes: [], skus: [], descriptions: { desktopHtml: null, mobileHtml: null }, detailStatus: 'empty',
+  }));
+  assert.equal(result.coverage.status, 'blocked');
+  assert.equal(result.overallScore, null);
+  assert.ok(result.dimensions.every((dimension) => dimension.score === null));
+});
+
+test('JD transport flags are not misclassified as duplicate selling points', () => {
+  const result = scoreListing(source({
+    features: [
+      { key: 'isPayFirst', value: '1' },
+      { key: 'is7ToReturn', value: '1' },
+      { key: 'popsfkc', value: '0' },
+      { key: 'nameWithoutBrand', value: '299L 一级能效风冷无霜展示柜' },
+      { key: 'model', value: 'BC-299' },
+    ],
+  }));
+  const sellingPoints = result.dimensions.find((item) => item.dimension === 'selling_points');
+  const duplicateRule = sellingPoints?.evidence.find((item) => item.ruleId === 'selling_points.unique');
+  assert.equal(result.rubricVersion, 'listing-jd-v2');
+  assert.equal(duplicateRule?.outcome, 'unknown');
+  assert.equal(duplicateRule?.delta, 0);
+});
+
+test('canonical source hash ignores object key insertion order', () => {
+  assert.equal(canonicalHash({ a: 1, b: { c: 2 } }), canonicalHash({ b: { c: 2 }, a: 1 }));
+});