Sfoglia il codice sorgente

feat: add managed listing scoring and monitoring workflows

Yi Jiarui 1 settimana fa
parent
commit
df964469b9
76 ha cambiato i file con 6264 aggiunte e 95 eliminazioni
  1. 20 0
      .env.example
  2. 25 0
      TASKS.md
  3. 855 0
      cloud-functions/saas-voc-gateway.js
  4. 54 0
      cloud-functions/shared/audit.js
  5. 239 0
      cloud-functions/shared/auth.js
  6. 192 0
      cloud-functions/shared/constants.js
  7. 392 0
      cloud-functions/shared/repository.js
  8. 155 0
      cloud-functions/shared/storage.js
  9. 42 0
      cloud-functions/shared/utils.js
  10. 7 0
      package.json
  11. 133 0
      scripts/backfill-voc-products-from-listing-sources.ts
  12. 31 0
      scripts/benchmark-jd-voc-image-review.ts
  13. 51 0
      scripts/benchmark-jd-voc-migration.ts
  14. 178 0
      scripts/bootstrap-managed-user.mjs
  15. 38 0
      scripts/build-cloud-functions.mjs
  16. 110 0
      scripts/deploy-cloud-function.mjs
  17. 73 0
      scripts/migrate-jd-voc-score-slots.ts
  18. 71 0
      scripts/recover-all.mjs
  19. 67 0
      scripts/recover-gateway.mjs
  20. 68 0
      scripts/score-jd-voc-codex.ts
  21. 37 14
      scripts/sync-jd-listing-reviews.ts
  22. 54 0
      scripts/verify-cloud-functions.mjs
  23. 27 7
      scripts/verify-listing-rollout.ts
  24. 35 10
      src/app.ts
  25. 123 0
      src/cloud-functions/action-registry.ts
  26. 170 0
      src/cloud-functions/router.ts
  27. 45 0
      src/cloud-functions/special-actions.ts
  28. 17 1
      src/config/env.ts
  29. 17 2
      src/db/parse-rest.schema.ts
  30. 21 7
      src/local-app.ts
  31. 1 1
      src/modules/ai-gateway/routes.ts
  32. 55 0
      src/modules/competitor-listing-monitor/alerts.ts
  33. 160 1
      src/modules/competitor-listing-monitor/competitor-listing-monitor.service.ts
  34. 17 0
      src/modules/competitor-listing-monitor/impact-analysis.ts
  35. 12 0
      src/modules/competitor-listing-monitor/optimization-task.ts
  36. 27 1
      src/modules/competitor-listing-monitor/repositories/parse-rest-competitor-listing-monitor.repository.ts
  37. 52 0
      src/modules/competitor-listing-monitor/routes.ts
  38. 52 0
      src/modules/competitor-listing-monitor/schemas.ts
  39. 4 0
      src/modules/competitor-listing-monitor/validation.ts
  40. 151 5
      src/modules/listing-ai/domain.ts
  41. 54 0
      src/modules/listing-ai/image-review/gemini-image-review.provider.ts
  42. 52 0
      src/modules/listing-ai/image-review/image-review.service.ts
  43. 139 6
      src/modules/listing-ai/listing-ai.service.ts
  44. 9 0
      src/modules/listing-ai/presentation/jd-voc-score.presenter.ts
  45. 64 12
      src/modules/listing-ai/query/listing-overview.query.ts
  46. 25 0
      src/modules/listing-ai/repositories/in-memory-listing-ai.repository.ts
  47. 30 1
      src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.ts
  48. 35 1
      src/modules/listing-ai/repositories/postgres-listing-ai.repository.ts
  49. 56 8
      src/modules/listing-ai/routes.ts
  50. 127 4
      src/modules/listing-ai/schemas.ts
  51. 138 0
      src/modules/listing-ai/scoring/jd-voc-ai-rubric.ts
  52. 218 0
      src/modules/listing-ai/scoring/jd-voc-lab/rubric-config.js
  53. 458 0
      src/modules/listing-ai/scoring/jd-voc-lab/rubric-engine.js
  54. 167 0
      src/modules/listing-ai/scoring/jd-voc-rule-engine.ts
  55. 84 0
      src/modules/managed-tasks/parse-rest-managed-task-worker.ts
  56. 42 12
      src/server.ts
  57. 1 1
      test/ai-gateway.test.ts
  58. 110 0
      test/cloud-function-source.test.ts
  59. 62 0
      test/cloud-functions.router.test.ts
  60. 26 0
      test/cloud-functions.special-actions.test.ts
  61. 11 0
      test/competitor-listing-alerts.test.ts
  62. 18 0
      test/competitor-listing-history.test.ts
  63. 3 0
      test/competitor-listing-impact.test.ts
  64. 35 0
      test/competitor-listing-monitor.service.test.ts
  65. 2 0
      test/competitor-listing-optimization-task.test.ts
  66. 2 0
      test/competitor-listing-validation.test.ts
  67. 13 0
      test/env.test.ts
  68. 26 0
      test/fixtures/jd-voc-score-result.json
  69. 56 0
      test/image-review-shadow.test.ts
  70. 83 0
      test/jd-voc-ai-rubric.test.ts
  71. 38 0
      test/jd-voc-contract.test.ts
  72. 61 0
      test/jd-voc-rule-engine.test.ts
  73. 12 0
      test/listing-ai.overview-query.test.ts
  74. 66 0
      test/listing-ai.routes.test.ts
  75. 61 0
      test/parse-rest-managed-task-worker.test.ts
  76. 2 1
      tsconfig.json

+ 20 - 0
.env.example

@@ -16,6 +16,9 @@ DATABASE_STATEMENT_TIMEOUT_MS=30000
 PARSE_APP_ID=
 PARSE_MASTER_KEY=
 PARSE_MAINTENANCE_KEY=
+FUNCTION_REGISTRY_SERVER_URL=
+FUNCTION_REGISTRY_APP_ID=
+FUNCTION_REGISTRY_MASTER_KEY=
 PARSE_SERVER_URL=http://127.0.0.1:4400/parse
 PARSE_REST_TIMEOUT_MS=30000
 API_AUTH_MODE=parse
@@ -32,11 +35,28 @@ FMODE_TIMEOUT_MS=30000
 FMODE_RETRIES=2
 FMODE_AI_BASE_URL=https://api.fmode.cn
 FMODE_AI_TOKEN=
+AI_API_KEY=
+AMAZON_BASE_URL=
+AMAZON_API_KEY=
+SORFTIME_BASE_URL=
+SORFTIME_API_KEY=
+TIKHUB_BASE_URL=
+TIKHUB_API_KEY=
+UPSTREAM_TIMEOUT_MS=30000
 FMODE_AI_MODEL=deepseek-v4-pro
 FMODE_AI_TIMEOUT_MS=120000
 LISTING_AI_MODEL=deepseek-v4-flash
 LISTING_AI_CONCURRENCY=2
 LISTING_AI_MAX_ITEMS_PER_JOB=10
+# JD-VOC rollout switches. Keep false until the matching gate is approved.
+JD_VOC_ENABLED=false
+JD_VOC_AI_ENABLED=false
+JD_VOC_AI_MODEL=gpt-4o-mini
+JD_VOC_IMAGE_SHADOW_ENABLED=false
+JD_VOC_DISPLAY_DEFAULT=false
+# Server-side image-review gateway only; never expose to Angular.
+FMODE_LLM_BASE_URL=
+FMODE_LLM_API_KEY=
 SYNC_WORKER_ENABLED=true
 SYNC_WORKER_POLL_MS=2000
 SYNC_JOB_STALE_AFTER_MS=900000

+ 25 - 0
TASKS.md

@@ -74,3 +74,28 @@ Status date: 2026-07-24
 - Scheduled full-catalog crawling.
 - AI reports, sentiment, pain points, or recommendations without review evidence.
 - Any reuse of the cross-border production database or credentials.
+
+## 竞品变化驱动 Listing 闭环(2026-08-31)
+
+- [x] 切片 1:按 productId 提供历史快照、字段级 diff 与采集运行/失败记录 API。
+- [x] 切片 1:前端新增响应式竞品 Listing 历史详情页与字段筛选。
+- [x] 切片 2:竞品变化预警规则(含多竞品同向变化)。
+- [x] 切片 3:变化影响分析与可回溯 impactScore。
+- [x] 切片 4:竞品驱动 Listing 优化任务(幂等、workspace 隔离、状态机)。
+- [x] 切片 5:预警/影响/任务前端工作台与证据抽屉。
+- [x] 切片 6:优化前后评分验证闭环(不宣称业务收益)。
+- [x] 域函数:评分前后 delta 与 effective 分类,并接入任务 API/工作台。
+
+## Cloud Function migration (2026-09-03)
+
+> **当前状态:暂缓线上托管验收,回归本地前端 + 本地后端代理真实 Parse REST 数据开发。** 不使用静态案例数据;保留云函数实现与部署配置,暂不继续处理线上用户 provisioning 和托管执行验收。
+
+- [x] Implement the allow-listed action registry and unified response/requestId boundary.
+- [x] Implement the local action adapter over the existing authenticated domain routes.
+- [x] Implement and deploy the managed Parse `Function` record `/saas-voc-gateway`.
+- [x] Add managed authentication, workspace membership, RBAC, product scope, cursor reads, audit, idempotent writes, and truthful failure responses.
+- [x] Verify Parse Schema/data counts and unauthenticated hosted execution rejection.
+- [x] Add a Parse REST managed-task worker that resumes persisted competitor refresh and Listing score jobs, with graceful shutdown.
+- [ ] Verify authenticated hosted action execution and all browser pages with a dedicated test Session.
+- [ ] Bind the deployed Function to the platform execution registry and verify authenticated hosted action execution.
+- [ ] Complete live third-party provider contracts and hosted/background worker execution in the managed environment.

+ 855 - 0
cloud-functions/saas-voc-gateway.js

@@ -0,0 +1,855 @@
+/*
+ * Source uploaded to Parse's Function table by scripts/deploy-cloud-function.mjs.
+ * The Fmode runtime injects Parse, request and response. Keep this file free of
+ * imports so it can run in the managed standalone-function evaluator.
+ */
+const ACTIONS = new Set([
+  'context.get', 'workspace.list', 'workspace.members.list', 'workspace.member.update', 'data-source.list', 'import.list', 'audit.list',
+  'domestic.snapshot', 'domestic.products.list', 'domestic.product.get', 'domestic.reviews.list', 'domestic.relations.list',
+  'sync.enqueue', 'sync.jobs.list', 'sync.job.get', 'sync.job.events', 'sync.job.retry', 'sync.job.cancel',
+  'analysis.list', 'analysis.create', 'analysis.update', 'insight-decision.list', 'insight-decision.get', 'insight-decision.create',
+  'action.list', 'action.create', 'action.update', 'alert.list', 'alert.create', 'alert.update',
+  'knowledge.products.list', 'knowledge.product.upsert', 'knowledge.product.delete',
+  'competitor.overview', 'competitor.refresh', 'competitor.history', 'competitor.alerts', 'competitor.impacts',
+  'competitor.tasks.list', 'competitor.tasks.create', 'competitor.tasks.update', 'competitor.run.get',
+  'listing.overview', 'listing.products.list', 'listing.product.get', 'listing.product.score', 'listing.jd-voc-score.get', 'listing.jd-voc-image-review.run', 'listing.score-job.create',
+  'listing.score-job.list', 'listing.score-job.get', 'listing.score-job.items', 'listing.score-job.retry', 'listing.score-job.cancel',
+  'listing.versions.list', 'listing.version.create', 'listing.version.adopt',
+  'ai.status', 'ai.test', 'ai.prompts.list', 'ai.prompt.update', 'ai.chat',
+  'upstream.amazon', 'upstream.sorftime', 'upstream.tikhub', 'upstream.domestic'
+]);
+
+const CLASS_BY_READ_ACTION = {
+  'workspace.list': 'VocWorkspace',
+  'workspace.members.list': 'VocWorkspaceMember',
+  'data-source.list': 'VocSourceConnection',
+  'import.list': 'VocImportBatch',
+  'audit.list': 'VocAuditLog',
+  'domestic.products.list': 'VocProduct',
+  'domestic.reviews.list': 'VocReview',
+  'domestic.relations.list': 'VocProductRelation',
+  'sync.jobs.list': 'VocSyncJob',
+  'sync.job.events': 'VocSyncJobEvent',
+  'analysis.list': 'VocAnalysisRun',
+  'insight-decision.list': 'VocInsightDecision',
+  'action.list': 'VocActionItem',
+  'alert.list': 'VocAlert',
+  'knowledge.products.list': 'VocProductKnowledge',
+  'ai.prompts.list': 'VocPromptConfig',
+  'listing.products.list': 'VocListingSourceSnapshot',
+  'listing.score-job.list': 'VocListingScoreJob',
+  'listing.versions.list': 'VocListingVersion',
+  'competitor.tasks.list': 'VocCompetitorOptimizationTask',
+};
+
+const WRITE_ROLES = new Set(['owner', 'admin', 'editor']);
+const ADMIN_ROLES = new Set(['owner', 'admin']);
+const PRODUCT_SCOPE_FIELDS = {
+  VocProduct: 'productId', VocReview: 'productId', VocDailyMetric: 'productId',
+  VocProductRelation: 'ownProductId', VocProductKnowledge: 'productId',
+  VocListingSourceSnapshot: 'productId', VocListingCurrentScore: 'productId', VocListingVersion: 'productId'
+  ,VocCompetitorListingSnapshot: 'productId', VocCompetitorListingChange: 'productId', VocCompetitorOptimizationTask: 'ownProductId'
+};
+const READ_FILTER_FIELDS = {
+  VocProduct: ['platform', 'role'],
+  VocReview: ['platform', 'productId'],
+  VocProductRelation: ['platform', 'ownProductId', 'competitorProductId'],
+  VocSyncJob: ['status', 'platform'],
+  VocSyncJobEvent: ['jobId', 'eventType'],
+  VocAnalysisRun: ['status', 'analysisType', 'targetKind'],
+  VocInsightDecision: ['sourceAnalysisId', 'sourceInsightId', 'isCurrent'],
+  VocActionItem: ['status', 'actionType', 'productKey'],
+  VocAlert: ['status', 'alertType', 'productKey'],
+  VocProductKnowledge: ['productId', 'productKey', 'status'],
+  VocListingSourceSnapshot: ['platform', 'productId', 'isCurrent', 'scoreStatus', 'aiScoreStatus', 'coverageStatus'],
+  VocListingScoreJob: ['status', 'platform'],
+  VocListingVersion: ['productId', 'status'],
+  VocCompetitorListingSnapshot: ['platform', 'productId'],
+  VocCompetitorListingChange: ['platform', 'productId'],
+  VocCompetitorOptimizationTask: ['status', 'ownProductId'],
+};
+const UPSTREAM_PATHS = {
+  amazon: [/^\/test$/, /^\/orders(?:\/[A-Za-z0-9._~-]+)?(?:\/items)?$/, /^\/sales\/orderMetrics$/, /^\/catalog\/items(?:\/[A-Za-z0-9._~-]+)?$/, /^\/listings\/items\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)?$/, /^\/customerFeedback\/items\/[A-Za-z0-9._~-]+\/reviews\/(?:topics|trends|browseNode)$/, /^\/sellers\/marketplaceParticipations$/, /^\/returns$/],
+  sorftime: [/^\/api\/(?:CategoryTree|CategoryRequest|CategoryProducts|ProductQuery|ProductRequest|AsinSalesVolume|SimilarProductRealtimeRequest|SimilarProductRealtimeRequestStatusQuery|SimilarProductRealtimeRequestCollection|ProductReviewsQuery|MonitorQuery|KeywordQuery|ASINRequestKeyword|KeywordProductRanking|KeywordSearchResultTrend|ProductVariationHistory)$/],
+  tikhub: [/^\/v1\/(?:tiktok|instagram)\/[A-Za-z0-9._~/-]+$/],
+  domestic: [/^\/jd\/(?:get-item-detail|get-item-comments|search-item-list)\/v1$/],
+};
+const UPSTREAM_OPERATIONS = {
+  amazon: new Set(['get', 'post']),
+  sorftime: new Set(['get', 'post', 'forward']),
+  tikhub: new Set(['get', 'post', 'forward']),
+  domestic: new Set(['gateway.get']),
+};
+let activeRequest;
+let activeResponse;
+let functionQueue = Promise.resolve();
+
+async function handler(request, response) {
+  let release;
+  const previous = functionQueue;
+  functionQueue = new Promise((resolve) => { release = resolve; });
+  await previous;
+  try {
+    return await runHandler(request, response);
+  } finally {
+    activeRequest = null;
+    activeResponse = null;
+    release();
+  }
+}
+
+function fail(status, code, message) {
+  activeResponse.status(status).json({ success: false, code, message, requestId: requestId() });
+}
+
+function requestId() {
+  return activeRequest.headers && (activeRequest.headers['x-request-id'] || activeRequest.headers['X-Request-Id']) || 'cloud-' + Date.now().toString(36);
+}
+
+function paramsOf() {
+  const value = activeRequest.body && activeRequest.body.params;
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
+  const payload = value.payload && typeof value.payload === 'object' && !Array.isArray(value.payload) ? value.payload : {};
+  return { ...payload, action: value.action, workspaceId: value.workspaceId, platform: value.platform, idempotencyKey: value.idempotencyKey };
+}
+
+async function activeMember(workspaceId) {
+  const user = activeRequest.user;
+  if (!user) return null;
+  const query = new Parse.Query('VocWorkspaceMember');
+  query.equalTo('workspaceId', workspaceId);
+  query.equalTo('userId', user.id);
+  query.equalTo('status', 'active');
+  return query.first({ useMasterKey: true });
+}
+
+async function accessibleWorkspaces() {
+  const memberQuery = new Parse.Query('VocWorkspaceMember');
+  memberQuery.equalTo('userId', activeRequest.user.id);
+  memberQuery.equalTo('status', 'active');
+  memberQuery.limit(100);
+  const members = await memberQuery.find({ useMasterKey: true });
+  const ids = [...new Set(members.map((member) => String(member.get('workspaceId') || '')).filter(Boolean))];
+  if (!ids.length) return [];
+  const workspaceQuery = new Parse.Query('VocWorkspace');
+  workspaceQuery.containedIn('publicId', ids);
+  workspaceQuery.equalTo('status', 'active');
+  workspaceQuery.limit(100);
+  const workspaces = await workspaceQuery.find({ useMasterKey: true });
+  const roleByWorkspace = new Map(members.map((member) => [String(member.get('workspaceId') || ''), String(member.get('role') || 'viewer')]));
+  return workspaces.map((workspace) => ({ ...safeValue(workspace.toJSON(), 0), role: roleByWorkspace.get(String(workspace.get('publicId') || '')) || 'viewer' }));
+}
+
+async function authorize(action, workspaceId) {
+  if (!activeRequest.user) throw { status: 401, code: 'unauthenticated', message: '需要登录' };
+  const member = await activeMember(workspaceId);
+  if (!member) throw { status: 403, code: 'workspace_access_denied', message: '无权访问该 workspace' };
+  const role = String(member.get('role') || 'viewer');
+  if (action === 'workspace.members.list' && !ADMIN_ROLES.has(role)) throw { status: 403, code: 'forbidden', message: '权限不足' };
+  if (isWriteAction(action) && !WRITE_ROLES.has(role)) throw { status: 403, code: 'viewer_write_forbidden', message: 'Viewer 不能执行写入操作' };
+  const configuredProductIds = member.get('productIds');
+  const productIds = Array.isArray(configuredProductIds)
+    ? [...new Set(configuredProductIds.filter((value) => typeof value === 'string' && value.length <= 200))]
+    : (ADMIN_ROLES.has(role) ? null : []);
+  return { member, role, productIds };
+}
+
+function isWriteAction(action) {
+  return /\.create$|\.update$|\.upsert$|\.delete$|\.enqueue$|\.retry$|\.cancel$|\.adopt$|\.run$|^competitor\.refresh$/.test(action)
+    || action === 'ai.chat' || action === 'ai.test';
+}
+
+function boundedLimit(value, fallback) {
+  const limit = Number(value || fallback);
+  return Number.isInteger(limit) ? Math.max(1, Math.min(limit, 100)) : fallback;
+}
+
+function safeValue(value, depth) {
+  if (depth > 5 || value === null || value === undefined) return value;
+  if (value instanceof Date) return value;
+  if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
+  if (typeof value !== 'object') return value;
+  const output = {};
+  Object.keys(value).slice(0, 100).forEach((key) => {
+    if (/token|secret|password|credential|authorization|master/i.test(key)) return;
+    output[key] = safeValue(value[key], depth + 1);
+  });
+  return output;
+}
+
+async function readMany(className, workspaceId, params, productIds, maximum = 100) {
+  const query = new Parse.Query(className);
+  query.equalTo(className === 'VocWorkspace' ? 'publicId' : 'workspaceId', workspaceId);
+  applyReadFilters(query, className, params);
+  if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
+    query.containedIn(PRODUCT_SCOPE_FIELDS[className], await visibleProductIds(className, workspaceId, productIds));
+  }
+  const limit = boundedLimit(params.limit, 50, maximum);
+  if (typeof params.cursor === 'string' && params.cursor) {
+    const cursorDate = new Date(params.cursor);
+    if (Number.isNaN(cursorDate.getTime())) throw { status: 400, code: 'invalid_cursor', message: '分页游标无效' };
+    query.lessThan('createdAt', cursorDate);
+  }
+  if (Number.isInteger(params.skip) && params.skip >= 0) query.skip(Math.min(params.skip, 100000));
+  query.limit(limit);
+  query.descending('createdAt');
+  const rows = await query.find({ useMasterKey: true });
+  const last = rows[rows.length - 1];
+  const nextCursor = Number.isInteger(params.skip)
+    ? (rows.length === limit ? String(params.skip + rows.length) : null)
+    : (rows.length === limit && last && last.createdAt ? last.createdAt.toISOString() : null);
+  return { items: rows.map((row) => safeValue(row.toJSON(), 0)), nextCursor };
+}
+
+function applyReadFilters(query, className, params) {
+  const fields = READ_FILTER_FIELDS[className] || [];
+  for (const field of fields) {
+    const value = params && params[field];
+    if (value !== undefined && value !== null && value !== '') query.equalTo(field, value);
+  }
+  if (className === 'VocProduct' && typeof params?.search === 'string' && params.search.trim()) {
+    query.contains('title', params.search.trim().slice(0, 200));
+  }
+}
+
+async function readAll(className, workspaceId, params, productIds, maximum = 10000) {
+  const items = [];
+  while (items.length < maximum) {
+    const page = await readMany(className, workspaceId, { ...params, limit: Math.min(100, maximum - items.length), skip: items.length }, productIds, 100);
+    items.push(...page.items);
+    if (!page.nextCursor || !page.items.length) break;
+  }
+  return items;
+}
+
+async function readAllWhere(className, workspaceId, filters, productIds, maximum = 10000) {
+  const items = [];
+  while (items.length < maximum) {
+    const query = new Parse.Query(className);
+    query.equalTo(className === 'VocWorkspace' ? 'publicId' : 'workspaceId', workspaceId);
+    Object.entries(filters || {}).forEach(([key, value]) => {
+      if (value !== undefined && value !== null && value !== '') query.equalTo(key, value);
+    });
+    if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
+      query.containedIn(PRODUCT_SCOPE_FIELDS[className], await visibleProductIds(className, workspaceId, productIds));
+    }
+    const pageSize = Math.min(100, maximum - items.length);
+    query.skip(items.length); query.limit(pageSize); query.descending('createdAt');
+    const rows = await query.find({ useMasterKey: true });
+    items.push(...rows.map((row) => safeValue(row.toJSON(), 0)));
+    if (rows.length < pageSize) break;
+  }
+  return items;
+}
+
+async function readOne(className, workspaceId, objectId, productIds) {
+  if (!objectId || typeof objectId !== 'string' || objectId.length > 200) throw { status: 400, code: 'invalid_id', message: '标识无效' };
+  const query = new Parse.Query(className);
+  query.equalTo('workspaceId', workspaceId);
+  query.equalTo(className === 'VocProduct' ? 'productId' : 'publicId', objectId);
+  if (productIds !== null && className === 'VocProduct') query.containedIn('productId', productIds);
+  const row = await query.first({ useMasterKey: true });
+  return row ? safeValue(row.toJSON(), 0) : null;
+}
+
+function bodyData(params, workspaceId) {
+  const output = {};
+  Object.keys(params).forEach((key) => {
+    if (!['action', 'workspaceId', 'platform', 'payload', 'idempotencyKey', 'userId', 'jobId', 'productId', 'decisionId', 'analysisId', 'actionId', 'alertId', 'taskId', 'runId', 'versionId'].includes(key)) output[key] = safeValue(params[key], 0);
+  });
+  output.workspaceId = workspaceId;
+  return output;
+}
+
+function storageData(params, workspaceId) {
+  const output = {};
+  Object.keys(params).forEach((key) => {
+    if (!['action', 'workspaceId', 'userId'].includes(key)) output[key] = safeValue(params[key], 0);
+  });
+  output.workspaceId = workspaceId;
+  return output;
+}
+
+function publicId() {
+  return 'cf-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
+}
+
+async function findObject(className, workspaceId, field, value) {
+  const query = new Parse.Query(className);
+  query.equalTo('workspaceId', workspaceId);
+  query.equalTo(field, value);
+  return query.first({ useMasterKey: true });
+}
+
+async function findObjects(className, workspaceId, filters, limit, productIds) {
+  const query = new Parse.Query(className); query.equalTo('workspaceId', workspaceId);
+  Object.entries(filters || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') query.equalTo(key, value); });
+  if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) query.containedIn(PRODUCT_SCOPE_FIELDS[className], await visibleProductIds(className, workspaceId, productIds));
+  query.limit(boundedLimit(limit, 50)); query.descending('createdAt');
+  return (await query.find({ useMasterKey: true })).map((item) => safeValue(item.toJSON(), 0));
+}
+
+function presentReadItem(action, item) {
+  if (action === 'listing.score-job.list') return presentListingJob(item);
+  const output = { ...item };
+  if (!output.id) output.id = output.publicId || output.objectId || output.productId;
+  if (action === 'data-source.list') {
+    output.kind = output.kind || output.connectionKind || '';
+    output.credentialStorage = output.credentialStorage || output.metadata?.credentialStorage || 'external_secret';
+  }
+  if (action === 'import.list') output.id = output.id || output.publicId;
+  if (action === 'domestic.reviews.list') output.reviewId = output.reviewId || output.reviewKey || output.sourceReviewId || output.id;
+  if (action === 'sync.jobs.list') output.id = output.publicId || output.id;
+  if (action === 'sync.job.events') output.type = output.type || output.eventType || '';
+  if (action === 'listing.versions.list') output.id = output.publicId || output.id;
+  return output;
+}
+
+function presentListingJob(item) {
+  const payload = item && item.payload && typeof item.payload === 'object' ? item.payload : {};
+  return {
+    ...payload,
+    id: payload.id || item.publicId || item.objectId,
+    workspaceId: payload.workspaceId || item.workspaceId,
+    platform: payload.platform || item.platform || 'jd',
+    idempotencyKey: payload.idempotencyKey || item.idempotencyKey,
+    requestHash: payload.requestHash || item.requestHash,
+    status: item.status || payload.status,
+  };
+}
+
+function presentListingJobItem(item) {
+  const payload = item && item.payload && typeof item.payload === 'object' ? item.payload : {};
+  return {
+    ...payload,
+    id: payload.id || item.publicId || item.objectId,
+    jobId: payload.jobId || item.jobId,
+    workspaceId: payload.workspaceId || item.workspaceId,
+    productId: payload.productId || item.productId,
+    sourceHash: payload.sourceHash || item.sourceHash,
+    status: item.status || payload.status,
+  };
+}
+
+function listingSourcePayload(item) {
+  return item && item.payload && typeof item.payload === 'object' ? item.payload : item;
+}
+
+function listingSourceMatches(item, filter) {
+  const source = listingSourcePayload(item);
+  const normalizedSearch = String(filter.search || '').trim().toLowerCase();
+  if (normalizedSearch && !`${source.productId || ''} ${source.title || ''}`.toLowerCase().includes(normalizedSearch)) return false;
+  if (filter.categoryId && !(Array.isArray(source.categoryIds) ? source.categoryIds : []).includes(filter.categoryId)) return false;
+  if (filter.itemStatus && source.itemStatus !== filter.itemStatus) return false;
+  if (filter.coverageStatus && item.coverageStatus !== filter.coverageStatus) return false;
+  if (filter.scoreStatus && item.scoreStatus !== filter.scoreStatus) return false;
+  if (filter.aiScoreStatus && item.aiScoreStatus !== filter.aiScoreStatus) return false;
+  const score = Number(item.latestOverallScore);
+  if (filter.minScore !== undefined && (!Number.isFinite(score) || score < Number(filter.minScore))) return false;
+  if (filter.maxScore !== undefined && (!Number.isFinite(score) || score > Number(filter.maxScore))) return false;
+  return true;
+}
+
+async function resolveListingScoreSources(workspaceId, scope, productIds) {
+  if (!scope || !['selected', 'filter'].includes(scope.mode)) throw { status: 400, code: 'listing_scope_invalid', message: '评分范围无效' };
+  const allRows = await readAll('VocListingSourceSnapshot', workspaceId, {}, productIds, 10000);
+  const latestByProduct = new Map();
+  allRows.filter((row) => row.platform === 'jd' && row.isCurrent !== false).forEach((row) => {
+    const source = listingSourcePayload(row);
+    const productId = String(source.productId || row.productId || '').trim();
+    if (productId && !latestByProduct.has(productId)) latestByProduct.set(productId, row);
+  });
+  if (scope.mode === 'selected') {
+    const selectedIds = [...new Set(Array.isArray(scope.productIds) ? scope.productIds.map((value) => String(value).trim()).filter(Boolean) : [])];
+    if (!selectedIds.length || selectedIds.length > 100) throw { status: 400, code: 'listing_scope_invalid', message: '评分商品范围无效' };
+    if (productIds !== null && selectedIds.some((id) => !productIds.includes(id))) throw { status: 403, code: 'product_scope_denied', message: '评分范围无效' };
+    const selected = selectedIds.map((id) => latestByProduct.get(id)).filter(Boolean);
+    if (selected.length !== selectedIds.length) throw { status: 422, code: 'listing_source_incomplete', message: '评分商品数据不完整' };
+    return selected;
+  }
+  const filter = scope.filter && typeof scope.filter === 'object' ? scope.filter : {};
+  return [...latestByProduct.values()].filter((row) => listingSourceMatches(row, filter));
+}
+
+async function createListingScoreItems(workspaceId, jobId, sources, requestedAt) {
+  const objects = sources.map((row) => {
+    const source = listingSourcePayload(row);
+    const productId = String(source.productId || row.productId || '');
+    const sourceHash = String(source.sourceHash || row.sourceHash || '');
+    const id = publicId();
+    const payload = { id, jobId, workspaceId, productId, sourceHash, status: 'queued', attempts: 0, errorCode: null, errorDetail: null, statusReasonCodes: [], updatedAt: requestedAt };
+    const item = new Parse.Object('VocListingScoreItem');
+    item.set('publicId', id); item.set('naturalKey', `${jobId}|${productId}|${sourceHash}`); item.set('workspaceId', workspaceId);
+    item.set('jobId', jobId); item.set('productId', productId); item.set('sourceHash', sourceHash); item.set('status', 'queued'); item.set('payload', payload);
+    return item;
+  });
+  for (let index = 0; index < objects.length; index += 50) await Parse.Object.saveAll(objects.slice(index, index + 50), { useMasterKey: true });
+}
+
+async function activateListingScoreJob(workspaceId, jobId, job) {
+  const object = await findObject('VocListingScoreJob', workspaceId, 'publicId', jobId);
+  if (!object) throw { status: 500, code: 'listing_score_job_missing', message: '评分任务保存失败' };
+  const activated = { ...job, status: 'queued', updatedAt: new Date().toISOString() };
+  object.set('status', 'queued'); object.set('payload', activated);
+  await object.save(null, { useMasterKey: true });
+  return activated;
+}
+
+function presentReadPage(action, page) {
+  return { ...page, items: page.items.map((item) => presentReadItem(action, item)) };
+}
+
+async function visibleProductIds(className, workspaceId, productIds) {
+  if (!productIds || !['VocProduct', 'VocCompetitorListingSnapshot', 'VocCompetitorListingChange'].includes(className)) return productIds;
+  const relationQuery = new Parse.Query('VocProductRelation');
+  relationQuery.equalTo('workspaceId', workspaceId);
+  relationQuery.containedIn('ownProductId', productIds);
+  relationQuery.limit(10000);
+  const relations = await relationQuery.find({ useMasterKey: true });
+  const competitorIds = relations.map((relation) => String(relation.get('competitorProductId') || '')).filter(Boolean);
+  return [...new Set(productIds.concat(competitorIds))];
+}
+
+async function updateByPublicId(className, workspaceId, id, params) {
+  const object = await findObject(className, workspaceId, 'publicId', id);
+  if (!object) throw { status: 404, code: 'not_found', message: '记录不存在' };
+  Object.entries(bodyData(params, workspaceId)).forEach(([key, value]) => object.set(key, value));
+  object.set('updatedBy', activeRequest.user.id);
+  await object.save(null, { useMasterKey: true });
+  return safeValue(object.toJSON(), 0);
+}
+
+async function appendAudit(workspaceId, action, entityType, entityId, metadata) {
+  const audit = new Parse.Object('VocAuditLog');
+  audit.set('publicId', publicId()); audit.set('workspaceId', workspaceId);
+  audit.set('actorUserId', activeRequest.user.id); audit.set('action', action);
+  audit.set('entityType', entityType); audit.set('entityId', entityId || null);
+  audit.set('metadata', safeValue(metadata || {}, 0));
+  await audit.save(null, { useMasterKey: true });
+}
+
+async function createIdempotent(className, workspaceId, keyField, keyValue, params) {
+  if (keyValue) {
+    const existing = await findObject(className, workspaceId, keyField, keyValue);
+    if (existing) return { value: safeValue(existing.toJSON(), 0), idempotent: true };
+  }
+  const object = new Parse.Object(className);
+  Object.entries(storageData(params, workspaceId)).forEach(([key, value]) => object.set(key, value));
+  if (!object.get('publicId')) object.set('publicId', publicId());
+  if (keyValue) object.set(keyField, keyValue);
+  object.set('createdBy', activeRequest.user.id);
+  await object.save(null, { useMasterKey: true });
+  return { value: safeValue(object.toJSON(), 0), idempotent: false };
+}
+
+async function writeObject(className, params, workspaceId) {
+  const object = new Parse.Object(className);
+  Object.entries(storageData(params, workspaceId)).forEach(([key, value]) => object.set(key, value));
+  object.set('createdBy', activeRequest.user.id);
+  await object.save(null, { useMasterKey: true });
+  return safeValue(object.toJSON(), 0);
+}
+
+async function aiChat(params) {
+  const env = typeof process !== 'undefined' && process.env ? process.env : {};
+  const token = env.FMODE_AI_TOKEN || env.AI_API_KEY || '';
+  if (!token) throw { status: 503, code: 'ai_gateway_not_configured', message: 'AI 服务尚未配置' };
+  if (!Array.isArray(params.messages) || params.messages.length < 1 || params.messages.length > 100) throw { status: 400, code: 'invalid_ai_messages', message: '消息参数无效' };
+  const body = { messages: params.messages, model: String(params.model || env.FMODE_AI_MODEL || '').slice(0, 120), stream: false };
+  ['temperature', 'presence_penalty', 'frequency_penalty', 'max_tokens', 'response_format', 'thinking', 'websearch'].forEach((key) => { if (params[key] !== undefined) body[key] = safeValue(params[key], 0); });
+  const baseUrl = String(env.FMODE_AI_BASE_URL || '').replace(/\/+$/, '');
+  if (!/^https:\/\//i.test(baseUrl)) throw { status: 503, code: 'ai_gateway_not_configured', message: 'AI 服务地址未配置' };
+  const response = await fetch(baseUrl + '/v1/chat/completions', { method: 'POST', headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(120000) });
+  const data = await response.json().catch(() => null);
+  if (!response.ok) throw { status: 502, code: 'ai_upstream_failed', message: 'AI 上游请求失败' };
+  return data;
+}
+
+async function fixedUpstream(provider, params) {
+  const path = normalizeUpstreamPath(params.path);
+  if (!path || !UPSTREAM_PATHS[provider].some((pattern) => pattern.test(path))) throw { status: 400, code: 'upstream_path_not_allowed', message: '上游路径不在白名单中' };
+  const operation = String(params.operation || '').trim().toLowerCase();
+  if (!UPSTREAM_OPERATIONS[provider].has(operation)) throw { status: 400, code: 'upstream_operation_not_allowed', message: '上游操作不在白名单中' };
+  const env = typeof process !== 'undefined' && process.env ? process.env : {};
+  const base = String(env[provider.toUpperCase() + '_BASE_URL'] || (provider === 'domestic' ? env.FMODE_BASE_URL || '' : '')).replace(/\/+$/, '');
+  const token = String(env[provider.toUpperCase() + '_API_KEY'] || (provider === 'domestic' ? env.FMODE_API_KEY || '' : ''));
+  if (!/^https:\/\//i.test(base) || !token) throw { status: 503, code: 'upstream_not_configured', message: '上游数据源尚未配置' };
+  const method = operation === 'post' || operation === 'forward' ? 'POST' : 'GET';
+  const url = new URL(path.replace(/^\/+/, ''), base + '/');
+  const query = params.query && typeof params.query === 'object' ? params.query : params.params && typeof params.params === 'object' ? params.params : {};
+  if (method === 'GET') Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null) url.searchParams.set(key, String(value)); });
+  let response;
+  try {
+    response = await fetch(url, { method, headers: { accept: 'application/json', authorization: 'Bearer ' + token, ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) }, ...(method === 'POST' ? { body: JSON.stringify(params.body || query) } : {}), signal: AbortSignal.timeout(Number(env.UPSTREAM_TIMEOUT_MS || 30000)) });
+  } catch (error) {
+    const name = error && error.name;
+    if (name === 'TimeoutError' || name === 'AbortError') throw { status: 504, code: 'upstream_timeout', message: '上游请求超时' };
+    throw { status: 502, code: 'upstream_unreachable', message: '上游数据源不可达' };
+  }
+  const data = await response.json().catch(() => null);
+  if (response.status === 401 || response.status === 403) throw { status: 502, code: 'upstream_auth_failed', message: '上游鉴权失败' };
+  if (response.status === 429) throw { status: 503, code: 'upstream_rate_limited', message: '上游请求受限' };
+  if (!response.ok) throw { status: 502, code: 'upstream_request_failed', message: '上游请求失败' };
+  return safeValue(data, 0);
+}
+
+function normalizeUpstreamPath(value) {
+  const path = String(value || '').trim();
+  if (!path || path.length > 300 || path.includes('://') || path.includes('\\') || path.includes('?') || path.includes('#')) return '';
+  return '/' + path.replace(/^\/+/, '');
+}
+
+async function snapshot(workspaceId, params, productIds) {
+  const [products, reviews, metrics, relations, imports] = await Promise.all([
+    readAll('VocProduct', workspaceId, {}, productIds, 10000),
+    readAll('VocReview', workspaceId, {}, productIds, 10000),
+    readAll('VocDailyMetric', workspaceId, {}, productIds, 20000),
+    readAll('VocProductRelation', workspaceId, {}, productIds, 10000),
+    readMany('VocImportBatch', workspaceId, { limit: 1 }, null),
+  ]);
+  const importRow = imports.items[0] || {};
+  const dailyTotals = aggregateDailyMetrics(metrics);
+  const productById = new Map(products.map((product) => [product.productId, product]));
+  const grouped = new Map();
+  relations.forEach((relation) => {
+    const key = relation.ownProductId;
+    if (!grouped.has(key)) grouped.set(key, { ownProductId: key, ownProductKey: relation.ownProductKey, model: productById.get(key)?.model || relation.ownModel || '', category: relation.category || productById.get(key)?.category3 || '', competitors: [] });
+    grouped.get(key).competitors.push(relation);
+  });
+  const categories2 = new Set(products.map((product) => product.category2).filter(Boolean));
+  const categories3 = new Set(products.map((product) => product.category3).filter(Boolean));
+  return {
+    schemaVersion: 1,
+    generatedAt: new Date().toISOString(),
+    caseName: importRow.caseName || workspaceId,
+    platform: params.platform || 'jd',
+    source: { sourceFile: importRow.sourceFile || '', sourceHash: importRow.sourceHash || '', sheets: importRow.sheets || [], dateRange: { start: importRow.sourceDateStart || '', end: importRow.sourceDateEnd || '' } },
+    summary: { metricRows: metrics.length, metricProducts: new Set(metrics.map((metric) => metric.productId)).size, mappingRows: relations.length, relations: relations.length, uniqueCompetitorProducts: new Set(relations.map((relation) => relation.competitorProductId)).size, category2Count: categories2.size, category3Count: categories3.size, reviewCount: reviews.length },
+    dailyTotals,
+    products,
+    mappingGroups: [...grouped.values()],
+    relations,
+    reviews: reviews.map((review) => ({ productId: review.productId, reviewId: review.reviewKey || review.sourceReviewId || review.objectId, rating: Number(review.rating || 0), content: review.content || '', ...(review.reviewDate ? { reviewDate: review.reviewDate } : {}) })),
+    quality: importRow.quality || { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] },
+  };
+}
+
+function aggregateDailyMetrics(metrics) {
+  const totals = new Map();
+  const numericFields = ['gmv', 'soldUnits', 'transactionOrders', 'transactionCustomers', 'impressions', 'clicks', 'views', 'visitors', 'cartUnits', 'orderAmount', 'orderUnits', 'orderCount', 'refundAmount', 'refundUnits', 'refundOrders'];
+  metrics.forEach((metric) => {
+    const date = String(metric.metricDate || metric.date || '');
+    if (!date) return;
+    if (!totals.has(date)) totals.set(date, { date, 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 total = totals.get(date);
+    numericFields.forEach((field) => { total[field] += Number(metric[field] || 0); });
+  });
+  return [...totals.values()].sort((left, right) => left.date.localeCompare(right.date)).map((total) => ({ ...total, conversionRate: total.visitors ? total.orderCount / total.visitors : 0, clickThroughRate: total.impressions ? total.clicks / total.impressions : 0, averageUnitPrice: total.soldUnits ? total.gmv / total.soldUnits : 0, refundToGmvRate: total.gmv ? total.refundAmount / total.gmv : 0 }));
+}
+
+async function runHandler(request, response) {
+  activeRequest = request;
+  activeResponse = response;
+  const input = paramsOf();
+  const action = input.action;
+  if (!ACTIONS.has(action)) return fail(400, 'cloud_action_not_allowed', '不支持的业务操作');
+  if (!activeRequest.user) return fail(401, 'unauthenticated', '需要登录');
+  let workspaceId = String(input.workspaceId || '').trim();
+  if (!workspaceId && (action === 'context.get' || action === 'workspace.list')) {
+    const membershipQuery = new Parse.Query('VocWorkspaceMember');
+    membershipQuery.equalTo('userId', activeRequest.user && activeRequest.user.id);
+    membershipQuery.equalTo('status', 'active');
+    const membership = await membershipQuery.first({ useMasterKey: true });
+    workspaceId = membership ? String(membership.get('workspaceId') || '') : '';
+  }
+  if (!workspaceId || workspaceId.length > 200) return fail(400, 'workspace_id_invalid', 'workspaceId 无效');
+  try {
+    const auth = await authorize(action, workspaceId);
+    if (action === 'context.get') return activeResponse.json({ success: true, data: { principal: { userId: activeRequest.user.id, email: activeRequest.user.get('email') || '', displayName: activeRequest.user.get('username') || '' }, workspaces: await accessibleWorkspaces(), capabilities: {}, productScope: { all: auth.productIds === null, productIds: auth.productIds || [] } }, requestId: requestId() });
+    if (action === 'workspace.list') return activeResponse.json({ success: true, data: { items: await accessibleWorkspaces(), nextCursor: null }, requestId: requestId() });
+    if (action === 'domestic.snapshot') return activeResponse.json({ success: true, data: await snapshot(workspaceId, input, auth.productIds), requestId: requestId() });
+    if (action === 'domestic.product.get') return activeResponse.json({ success: true, data: { product: await readOne('VocProduct', workspaceId, input.productId, auth.productIds) }, requestId: requestId() });
+    if (action === 'sync.job.get') return activeResponse.json({ success: true, data: { job: await readOne('VocSyncJob', workspaceId, input.jobId, auth.productIds) }, requestId: requestId() });
+    if (action === 'insight-decision.get') return activeResponse.json({ success: true, data: { decision: await readOne('VocInsightDecision', workspaceId, input.decisionId, auth.productIds) }, requestId: requestId() });
+    if (action === 'competitor.run.get') {
+      const run = await findObject('VocCompetitorListingRefreshRun', workspaceId, 'publicId', input.runId); if (!run) throw { status: 404, code: 'competitor_run_not_found', message: '刷新任务不存在' };
+      return activeResponse.json({ success: true, data: { run: safeValue(run.toJSON(), 0) }, requestId: requestId() });
+    }
+    if (action === 'competitor.history') {
+      if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
+      const snapshots = await readAllWhere('VocCompetitorListingSnapshot', workspaceId, { productId: input.productId, platform: input.platform || 'jd' }, auth.productIds, 10000);
+      const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { productId: input.productId, platform: input.platform || 'jd' }, auth.productIds, 10000);
+      return activeResponse.json({ success: true, data: { workspaceId, platform: input.platform || 'jd', productId: input.productId, snapshots, changes, collectionRuns: [] }, requestId: requestId() });
+    }
+    if (action === 'competitor.overview') {
+      const products = await readAll('VocProduct', workspaceId, {}, auth.productIds, 10000);
+      const targets = products.filter((item) => item.role === 'competitor');
+      const snapshots = await readAllWhere('VocCompetitorListingSnapshot', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
+      const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
+      const latest = new Map(); snapshots.forEach((item) => { if (!latest.has(item.productId)) latest.set(item.productId, item); });
+      const latestChange = new Map(); changes.forEach((item) => { if (!latestChange.has(item.productId)) latestChange.set(item.productId, item); });
+      const items = targets.map((product) => ({ productId: product.productId, platform: input.platform || 'jd', title: product.title || null, brand: product.brand || null, mainImageUrl: product.detail?.imageUrl || null, category: product.category3 || product.category2 || null, relatedProducts: [], currentSnapshot: latest.get(product.productId) || null, latestChange: latestChange.get(product.productId) || null, latestCollectionResult: null, monitorStatus: latest.has(product.productId) ? (latestChange.has(product.productId) ? 'changed' : 'unchanged') : 'not_initialized' }));
+      const facets = (key) => [...new Set(items.map((item) => item[key]).filter(Boolean))].map((value) => ({ value, count: items.filter((item) => item[key] === value).length }));
+      return activeResponse.json({ success: true, data: { summary: { targetTotal: items.length, baselineTotal: latest.size, comparedTotal: 0, changedIn7d: changes.length, priceChangedIn7d: changes.filter((item) => item.changeTypes?.includes('price')).length, contentChangedIn7d: changes.filter((item) => item.changeTypes?.some((type) => type !== 'price')).length, latestFailedTotal: 0, lastRefreshAt: null, lastSuccessfulRefreshAt: null, historyStatus: latest.size ? 'baseline_only' : 'not_initialized' }, items, facets: { brands: facets('brand'), categories: facets('category') } }, requestId: requestId() });
+    }
+    if (action === 'competitor.alerts') {
+      const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
+      const alerts = changes.flatMap((change) => (change.changeTypes || []).map((field) => ({ id: change.publicId || change.objectId, workspaceId, platform: input.platform || 'jd', rule: field === 'main_image' ? 'main_image_change' : field === 'key_specifications' ? 'specification_change' : `${field}_change`, productIds: [change.productId], changeIds: [change.publicId || change.objectId], detectedAt: change.detectedAt, evidence: change.changes || [] })));
+      return activeResponse.json({ success: true, data: { alerts }, requestId: requestId() });
+    }
+    if (action === 'competitor.impacts') {
+      const changes = await readAllWhere('VocCompetitorListingChange', workspaceId, { platform: input.platform || 'jd' }, auth.productIds, 10000);
+      const relationQuery = new Parse.Query('VocProductRelation'); relationQuery.equalTo('workspaceId', workspaceId); relationQuery.limit(10000); const relations = await relationQuery.find({ useMasterKey: true });
+      const ownByCompetitor = new Map(relations.map((relation) => [String(relation.get('competitorProductId') || ''), String(relation.get('ownProductId') || '')]));
+      const impacts = changes.map((change) => ({ competitorProductId: change.productId, ownProductId: ownByCompetitor.get(change.productId) || '', changeId: change.publicId || change.objectId, currentSnapshotId: change.currentSnapshotId, previousSnapshotId: change.previousSnapshotId, weakestDimension: null, improvementPotential: null, impactScore: null, scoreSource: null, explanation: '当前变更已记录;未发现正式本品评分,暂不计算影响分数。' })).filter((item) => item.ownProductId);
+      return activeResponse.json({ success: true, data: { impacts }, requestId: requestId() });
+    }
+    if (action === 'competitor.refresh') {
+      const activeRunQuery = new Parse.Query('VocCompetitorListingRefreshRun'); activeRunQuery.equalTo('workspaceId', workspaceId); activeRunQuery.equalTo('platform', input.platform || 'jd'); activeRunQuery.containedIn('status', ['queued', 'running']);
+      if (await activeRunQuery.first({ useMasterKey: true })) throw { status: 409, code: 'competitor_listing_refresh_running', message: '竞品刷新任务正在运行' };
+      const targets = (await readAll('VocProduct', workspaceId, {}, auth.productIds, 10000)).filter((item) => item.role === 'competitor');
+      const run = await writeObject('VocCompetitorListingRefreshRun', { publicId: publicId(), workspaceId, platform: input.platform || 'jd', trigger: 'manual', status: 'queued', total: targets.length, completed: 0, baseline: 0, unchanged: 0, changed: 0, failed: 0, itemResults: [], requestedAt: new Date(), startedAt: null, completedAt: null }, workspaceId);
+      await appendAudit(workspaceId, 'competitor.refresh.requested', 'competitor_refresh_run', run.publicId, { total: targets.length });
+      return activeResponse.status(202).json({ success: true, data: { run }, requestId: requestId() });
+    }
+    if (action === 'listing.product.get') {
+      if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
+      const source = (await findObjects('VocListingSourceSnapshot', workspaceId, { productId: input.productId, isCurrent: true }, 1, auth.productIds))[0];
+      if (!source) throw { status: 404, code: 'listing_product_not_found', message: 'Listing 商品不存在' };
+      const scores = await readAllWhere('VocListingCurrentScore', workspaceId, { productId: input.productId }, auth.productIds, 100);
+      const versions = await readAllWhere('VocListingVersion', workspaceId, { productId: input.productId }, auth.productIds, 100);
+      const scorePayloads = scores.map((item) => item.payload || item);
+      const jdVocScores = scorePayloads.filter((item) => item.rubricVersion === 'jd-voc-v0.5' && (item.scoreKind === 'jd_voc_hybrid_ai' || item.scoreKind === 'jd_voc_rules'));
+      const jdVocScore = jdVocScores.find((item) => item.scoreKind === 'jd_voc_hybrid_ai' && item.aiReview && item.aiReview.status === 'completed') || jdVocScores.find((item) => item.scoreKind === 'jd_voc_rules') || null;
+      const legacyScore = scorePayloads.find((item) => item.scoreKind === 'hybrid_ai' && item.aiStatus === 'completed') || scorePayloads.find((item) => item.scoreKind === 'rules') || null;
+      const displayJdVoc = typeof process !== 'undefined' && process.env && process.env.JD_VOC_DISPLAY_DEFAULT === 'true' && jdVocScore;
+      return activeResponse.json({ success: true, data: { source: source.payload || source, coverage: displayJdVoc ? jdVocScore.coverage : legacyScore && legacyScore.coverage || null, displayScoreKind: displayJdVoc ? 'jd_voc' : 'legacy', currentScore: legacyScore, rulePrecheck: scorePayloads.find((item) => item.scoreKind === 'rules') || null, jdVocScore, versionsSummary: { items: versions, nextCursor: null } }, requestId: requestId() });
+    }
+    if (action === 'listing.product.score') {
+      const allScores = (await findObjects('VocListingCurrentScore', workspaceId, { productId: input.productId }, 100, auth.productIds)).map((item) => item.payload || item);
+      const score = typeof process !== 'undefined' && process.env && process.env.JD_VOC_DISPLAY_DEFAULT === 'true'
+        ? allScores.find((item) => item.rubricVersion === 'jd-voc-v0.5' && item.scoreKind === 'jd_voc_hybrid_ai') || allScores.find((item) => item.rubricVersion === 'jd-voc-v0.5' && item.scoreKind === 'jd_voc_rules')
+        : allScores.find((item) => item.scoreKind === 'hybrid_ai') || allScores.find((item) => item.scoreKind === 'rules');
+      if (!score) throw { status: 404, code: 'listing_score_not_found', message: 'Listing 评分不存在' };
+      return activeResponse.json({ success: true, data: { score, displayScoreKind: String(score.scoreKind || '').startsWith('jd_voc_') ? 'jd_voc' : 'legacy' }, requestId: requestId() });
+    }
+    if (action === 'listing.jd-voc-score.get') {
+      if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
+      const score = (await findObjects('VocListingCurrentScore', workspaceId, { productId: input.productId }, 100, auth.productIds)).map((item) => item.payload || item).find((item) => item.rubricVersion === 'jd-voc-v0.5' && (item.scoreKind === 'jd_voc_rules' || item.scoreKind === 'jd_voc_hybrid_ai'));
+      if (!score) throw { status: 404, code: 'jd_voc_score_not_found', message: 'JD-VOC 评分不存在' };
+      return activeResponse.json({ success: true, data: { score }, requestId: requestId() });
+    }
+    if (action === 'listing.jd-voc-image-review.run') throw { status: 409, code: 'jd_voc_image_review_not_deployed', message: '影子识图需由后端服务执行,当前托管函数未启用该开关' };
+    if (action === 'listing.overview') {
+      const sources = await readAllWhere('VocListingSourceSnapshot', workspaceId, { isCurrent: true }, auth.productIds, 10000);
+      const scores = await readAllWhere('VocListingCurrentScore', workspaceId, {}, auth.productIds, 20000);
+      const scoreByProduct = new Map(scores.map((score) => [score.productId, score]));
+      const rows = sources.map((source) => { const score = scoreByProduct.get(source.productId); const payload = source.payload || source; const scorePayload = score?.payload || score; return { productId: source.productId, title: source.title || payload.title || null, imageUrl: payload.imageUrl || null, categoryId: source.categoryIds?.[0] || null, categoryName: null, categoryPath: [], categoryIds: source.categoryIds || [], itemStatusLabel: source.itemStatus || '', scoreNature: score ? 'formal_ai' : 'unscored', scoreNatureLabel: score ? '正式评分' : '未评分', overallScore: score?.overallScore ?? null, overallRate: score?.overallScore == null ? null : Number(score.overallScore) / 100, dimensions: scorePayload?.dimensions || {}, weakestDimension: null, improvementPotential: null, scoredAt: score?.scoredAt || null, syncedAt: source.observedAt }; });
+      const scored = rows.filter((row) => row.overallScore !== null);
+      return activeResponse.json({ success: true, data: { items: rows.slice(0, boundedLimit(input.limit, 25)), nextCursor: null, summary: { sourceTotal: rows.length, matchedTotal: rows.length, scoredTotal: scored.length, simulationTotal: 0, averageScore: scored.length ? scored.reduce((sum, row) => sum + row.overallScore, 0) / scored.length : null, medianScore: null, scoreDistribution: [], dimensionStats: {}, categoryFacets: [], scoreNatureFacets: [], snapshotId: 'managed-current', generatedAt: new Date().toISOString() } }, requestId: requestId() });
+    }
+    if (action === 'listing.score-job.get') {
+      const job = await findObject('VocListingScoreJob', workspaceId, 'publicId', input.jobId); if (!job) throw { status: 404, code: 'score_job_not_found', message: '评分任务不存在' };
+      return activeResponse.json({ success: true, data: { job: presentListingJob(safeValue(job.toJSON(), 0)) }, requestId: requestId() });
+    }
+    if (action === 'listing.score-job.items') return activeResponse.json({ success: true, data: { items: (await findObjects('VocListingScoreItem', workspaceId, { jobId: input.jobId, status: input.status }, input.limit, auth.productIds)).map(presentListingJobItem), nextCursor: null }, requestId: requestId() });
+    if (action === 'listing.products.list') {
+      const page = await readMany('VocListingSourceSnapshot', workspaceId, { ...input, limit: input.limit || 25 }, auth.productIds);
+      const items = page.items.map((item) => presentReadItem(action, { ...item, ...item.payload, productId: item.productId, syncedAt: item.observedAt }));
+      return activeResponse.json({ success: true, data: { items, nextCursor: page.nextCursor, summary: { sourceTotal: items.length, eligible: items.filter((item) => item.coverageStatus === 'eligible').length, scored: items.filter((item) => item.scoreStatus === 'scored').length, partial: items.filter((item) => item.scoreStatus === 'partial').length, blocked: items.filter((item) => item.coverageStatus === 'blocked').length, failed: items.filter((item) => item.scoreStatus === 'failed').length, averageScore: null, lastCatalogSyncAt: null } }, requestId: requestId() });
+    }
+    if (action === 'competitor.tasks.list') {
+      const page = await readMany('VocCompetitorOptimizationTask', workspaceId, input, auth.productIds);
+      return activeResponse.json({ success: true, data: { tasks: page.items.map((item) => presentReadItem(action, item)) }, requestId: requestId() });
+    }
+    if (action === 'listing.versions.list') {
+      const page = await readMany('VocListingVersion', workspaceId, input, auth.productIds);
+      page.items = page.items.map((item) => ({ ...presentReadItem(action, item), ...item.payload, content: item.payload?.content || item.content || null }));
+      return activeResponse.json({ success: true, data: page, requestId: requestId() });
+    }
+    if (CLASS_BY_READ_ACTION[action]) return activeResponse.json({ success: true, data: presentReadPage(action, await readMany(CLASS_BY_READ_ACTION[action], workspaceId, input, auth.productIds)), requestId: requestId() });
+    if (action === 'ai.status') { const env = typeof process !== 'undefined' && process.env ? process.env : {}; return activeResponse.json({ success: true, data: { service: 'Fmode AI', configured: Boolean(env.FMODE_AI_TOKEN || env.AI_API_KEY), baseUrl: '', defaultModel: env.FMODE_AI_MODEL || '', proxyEndpoint: 'ai.chat' }, requestId: requestId() }); }
+    if (action === 'ai.test') { const data = await aiChat({ messages: [{ role: 'user', content: '只回复 OK' }], model: input.model }); return activeResponse.json({ success: true, data: { ok: true, model: data && data.model || input.model || '', message: data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content || 'OK' }, requestId: requestId() }); }
+    if (action === 'ai.chat') return activeResponse.json({ success: true, data: await aiChat(input), requestId: requestId() });
+    if (action === 'workspace.member.update') {
+      if (!input.userId || !['owner','admin','editor','viewer'].includes(input.role)) throw { status: 400, code: 'invalid_member', message: '成员参数无效' };
+      const naturalKey = workspaceId + ':' + input.userId;
+      const result = await createIdempotent('VocWorkspaceMember', workspaceId, 'naturalKey', naturalKey, { ...input, status: input.status || 'active' });
+      if (result.idempotent) {
+        result.value = await updateByPublicId('VocWorkspaceMember', workspaceId, result.value.publicId || result.value.objectId, input).catch(async () => {
+          const object = await findObject('VocWorkspaceMember', workspaceId, 'naturalKey', naturalKey);
+          Object.entries(bodyData(input, workspaceId)).forEach(([key, value]) => object.set(key, value));
+          await object.save(null, { useMasterKey: true }); return safeValue(object.toJSON(), 0);
+        });
+      }
+      await appendAudit(workspaceId, 'member.updated', 'workspace_member', input.userId, { role: input.role, status: input.status });
+      return activeResponse.json({ success: true, data: { member: result.value }, requestId: requestId() });
+    }
+    if (action === 'sync.enqueue') {
+      if (!Array.isArray(input.productIds) || input.productIds.length < 1 || input.productIds.length > 100) throw { status: 400, code: 'invalid_sync_scope', message: '同步商品范围无效' };
+      if (auth.productIds !== null && input.productIds.some((id) => !auth.productIds.includes(id))) throw { status: 403, code: 'product_scope_denied', message: '包含无权访问的商品' };
+      const key = String(input.idempotencyKey || '').trim();
+      if (key.length < 8) throw { status: 400, code: 'idempotency_key_required', message: '缺少幂等键' };
+      const result = await createIdempotent('VocSyncJob', workspaceId, 'naturalKey', workspaceId + ':' + key, { ...input, publicId: publicId(), status: 'pending', progress: 0, attempts: 0, maxAttempts: 3, requestedAt: new Date() });
+      await appendAudit(workspaceId, result.idempotent ? 'sync.reused' : 'sync.requested', 'sync_job', result.value.publicId, { productCount: input.productIds.length });
+      return activeResponse.json({ success: true, data: { job: result.value, idempotent: result.idempotent }, requestId: requestId() });
+    }
+    if (action === 'sync.job.retry' || action === 'sync.job.cancel') {
+      const job = await findObject('VocSyncJob', workspaceId, 'publicId', input.jobId);
+      if (!job) throw { status: 404, code: 'job_not_found', message: '任务不存在' };
+      const status = String(job.get('status') || '');
+      if (action.endsWith('retry') && !['failed','partial','cancelled'].includes(status)) throw { status: 409, code: 'sync_job_not_retryable', message: '任务不可重试' };
+      if (action.endsWith('cancel') && !['pending','processing'].includes(status)) throw { status: 409, code: 'sync_job_not_cancellable', message: '任务不可取消' };
+      job.set('status', action.endsWith('retry') ? 'pending' : 'cancelled'); job.set('progress', action.endsWith('retry') ? 0 : job.get('progress') || 0);
+      await job.save(null, { useMasterKey: true });
+      await appendAudit(workspaceId, action.endsWith('retry') ? 'sync.retried' : 'sync.cancelled', 'sync_job', input.jobId, {});
+      return activeResponse.json({ success: true, data: { job: safeValue(job.toJSON(), 0) }, requestId: requestId() });
+    }
+    if (action === 'analysis.create') {
+      const result = await createIdempotent('VocAnalysisRun', workspaceId, 'publicId', input.idempotencyKey || publicId(), { ...input, status: 'pending', requestedBy: activeRequest.user.id, requestedAt: new Date(), result: null, evidenceCount: 0 });
+      await appendAudit(workspaceId, result.idempotent ? 'analysis.reused' : 'analysis.created', 'analysis_run', result.value.publicId, {});
+      return activeResponse.json({ success: true, data: { analysis: result.value, idempotent: result.idempotent }, requestId: requestId() });
+    }
+    if (action === 'analysis.update') return activeResponse.json({ success: true, data: { analysis: await updateByPublicId('VocAnalysisRun', workspaceId, input.analysisId, input) }, requestId: requestId() });
+    if (action === 'insight-decision.create') {
+      if (!input.sourceAnalysisId || !input.sourceInsightId || !['confirmed','rejected','needs_more_evidence'].includes(input.decision)) throw { status: 400, code: 'invalid_decision', message: '决策参数无效' };
+      const currentQuery = new Parse.Query('VocInsightDecision'); currentQuery.equalTo('workspaceId', workspaceId); currentQuery.equalTo('sourceAnalysisId', input.sourceAnalysisId); currentQuery.equalTo('sourceInsightId', input.sourceInsightId); currentQuery.equalTo('isCurrent', true);
+      const current = await currentQuery.first({ useMasterKey: true });
+      const decision = await writeObject('VocInsightDecision', { ...input, publicId: publicId(), decidedBy: activeRequest.user.id, decidedAt: new Date(), version: Number(current && current.get('version') || 0) + 1, supersedesId: current && current.get('publicId') || null, isCurrent: true }, workspaceId);
+      if (current) { current.set('isCurrent', false); await current.save(null, { useMasterKey: true }); }
+      await appendAudit(workspaceId, 'decision.created', 'insight_decision', decision.publicId, { version: decision.version });
+      return activeResponse.json({ success: true, data: { decision }, requestId: requestId() });
+    }
+    if (action === 'action.create') {
+      const key = String(input.creationKey || input.idempotencyKey || '').trim();
+      if (key.length < 8) throw { status: 400, code: 'creation_key_required', message: '缺少 creationKey' };
+      const result = await createIdempotent('VocActionItem', workspaceId, 'creationKey', key, { ...input, publicId: publicId(), createdBy: activeRequest.user.id });
+      await appendAudit(workspaceId, result.idempotent ? 'action.reused' : 'action.created', 'action_item', result.value.publicId, {});
+      return activeResponse.json({ success: true, data: { action: result.value, idempotent: result.idempotent }, requestId: requestId() });
+    }
+    if (action === 'action.update') return activeResponse.json({ success: true, data: { action: await updateByPublicId('VocActionItem', workspaceId, input.actionId, input) }, requestId: requestId() });
+    if (action === 'alert.create') {
+      const alert = await writeObject('VocAlert', { ...input, publicId: publicId(), status: 'open', detectedAt: new Date() }, workspaceId);
+      await appendAudit(workspaceId, 'alert.created', 'alert', alert.publicId, {});
+      return activeResponse.json({ success: true, data: { alert }, requestId: requestId() });
+    }
+    if (action === 'alert.update') return activeResponse.json({ success: true, data: { alert: await updateByPublicId('VocAlert', workspaceId, input.alertId, input) }, requestId: requestId() });
+    if (action === 'knowledge.product.upsert') {
+      if (!input.productKey || !input.productId) throw { status: 400, code: 'invalid_knowledge_product', message: '商品知识参数无效' };
+      if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
+      const naturalKey = workspaceId + ':' + input.productKey;
+      let object = await findObject('VocProductKnowledge', workspaceId, 'naturalKey', naturalKey);
+      if (!object) { object = new Parse.Object('VocProductKnowledge'); object.set('naturalKey', naturalKey); object.set('workspaceId', workspaceId); object.set('createdBy', activeRequest.user.id); }
+      Object.entries(bodyData(input, workspaceId)).forEach(([key, value]) => object.set(key, value)); object.set('updatedBy', activeRequest.user.id); await object.save(null, { useMasterKey: true });
+      await appendAudit(workspaceId, 'knowledge.upserted', 'product_knowledge', input.productKey, {});
+      return activeResponse.json({ success: true, data: { item: safeValue(object.toJSON(), 0) }, requestId: requestId() });
+    }
+    if (action === 'knowledge.product.delete') {
+      const object = await findObject('VocProductKnowledge', workspaceId, 'productKey', input.productKey); if (!object) throw { status: 404, code: 'knowledge_not_found', message: '商品知识不存在' };
+      object.set('status', 'archived'); object.set('updatedBy', activeRequest.user.id); await object.save(null, { useMasterKey: true });
+      await appendAudit(workspaceId, 'knowledge.archived', 'product_knowledge', input.productKey, {});
+      return activeResponse.json({ success: true, data: { item: safeValue(object.toJSON(), 0) }, requestId: requestId() });
+    }
+    if (action === 'ai.prompt.update') {
+      if (!input.promptKey) throw { status: 400, code: 'prompt_key_required', message: '缺少 promptKey' };
+      let prompt = await findObject('VocPromptConfig', workspaceId, 'promptKey', input.promptKey); if (!prompt) { prompt = new Parse.Object('VocPromptConfig'); prompt.set('workspaceId', workspaceId); prompt.set('promptKey', input.promptKey); prompt.set('naturalKey', workspaceId + ':' + input.promptKey); }
+      Object.entries(bodyData(input, workspaceId)).forEach(([key, value]) => prompt.set(key, value)); prompt.set('updatedBy', activeRequest.user.id); await prompt.save(null, { useMasterKey: true });
+      await appendAudit(workspaceId, 'prompt.updated', 'prompt_config', input.promptKey, {});
+      return activeResponse.json({ success: true, data: safeValue(prompt.toJSON(), 0), requestId: requestId() });
+    }
+    if (action === 'listing.score-job.create') {
+      const key = String(input.idempotencyKey || '').trim(); if (key.length < 8) throw { status: 400, code: 'idempotency_key_required', message: '缺少幂等键' };
+      const scope = input.scope; const scoringMode = input.scoringMode === 'rules' ? 'rules' : 'ai'; const rescorePolicy = input.rescorePolicy === 'force' ? 'force' : 'reuse';
+      const jdVocEnabled = typeof process !== 'undefined' && process.env && process.env.JD_VOC_ENABLED === 'true';
+      const rubricVersion = input.rubricVersion || (jdVocEnabled ? 'jd-voc-v0.5' : scoringMode === 'ai' ? 'listing-jd-ai-v5' : 'listing-jd-v7');
+      if (rubricVersion === 'jd-voc-v0.5' && !jdVocEnabled) throw { status: 409, code: 'jd_voc_disabled', message: 'JD-VOC 评分开关未启用' };
+      const requestHash = JSON.stringify({ platform: input.platform || 'jd', scope: safeValue(scope, 0), scoringMode, rubricVersion, rescorePolicy });
+      const existing = await findObject('VocListingScoreJob', workspaceId, 'idempotencyKey', key);
+      if (existing) {
+        const value = safeValue(existing.toJSON(), 0); const job = presentListingJob(value);
+        if (job.requestHash !== requestHash) throw { status: 409, code: 'idempotency_conflict', message: '幂等键对应的评分请求不同' };
+        await appendAudit(workspaceId, 'listing.score.reused', 'listing_score_job', job.id, {});
+        return activeResponse.json({ success: true, data: { job, idempotent: true }, requestId: requestId() });
+      }
+      const sources = await resolveListingScoreSources(workspaceId, scope, auth.productIds);
+      if (scoringMode === 'ai' && sources.length > 10) throw { status: 429, code: 'listing_ai_budget_exceeded', message: 'AI 评分商品数量超过单次上限' };
+      const requestedAt = new Date().toISOString(); const jobId = publicId();
+      const job = { id: jobId, workspaceId, platform: input.platform || 'jd', idempotencyKey: key, requestHash, rubricVersion, includeAiSuggestions: scoringMode === 'ai', rescorePolicy, scope: safeValue(scope, 0), status: sources.length ? 'initializing' : 'completed', total: sources.length, processed: 0, succeeded: 0, partial: 0, blocked: 0, failed: 0, requestedBy: activeRequest.user.id, requestedAt, startedAt: null, completedAt: sources.length ? null : requestedAt, updatedAt: requestedAt };
+      const result = await createIdempotent('VocListingScoreJob', workspaceId, 'idempotencyKey', key, { publicId: jobId, naturalKey: workspaceId + '|' + key, platform: job.platform, idempotencyKey: key, requestHash, status: job.status, payload: job, requestedAt: new Date(requestedAt) });
+      if (result.idempotent) {
+        const existingJob = presentListingJob(result.value);
+        await appendAudit(workspaceId, 'listing.score.reused', 'listing_score_job', existingJob.id, {});
+        return activeResponse.json({ success: true, data: { job: existingJob, idempotent: true }, requestId: requestId() });
+      }
+      try {
+        if (sources.length) {
+          await createListingScoreItems(workspaceId, jobId, sources, requestedAt);
+          job.status = 'queued';
+          job.updatedAt = new Date().toISOString();
+          const activated = await activateListingScoreJob(workspaceId, jobId, job);
+          await appendAudit(workspaceId, 'listing.score.requested', 'listing_score_job', jobId, { total: sources.length, scoringMode });
+          return activeResponse.status(202).json({ success: true, data: { job: activated, idempotent: false }, requestId: requestId() });
+        }
+        await appendAudit(workspaceId, 'listing.score.requested', 'listing_score_job', jobId, { total: 0, scoringMode });
+        return activeResponse.status(202).json({ success: true, data: { job, idempotent: false }, requestId: requestId() });
+      } catch (error) {
+        const failed = { ...job, status: 'failed', failed: sources.length ? sources.length : 1, completedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), errorCode: 'listing_score_queue_failed' };
+        const failedObject = await findObject('VocListingScoreJob', workspaceId, jobId);
+        if (failedObject) { failedObject.set('status', 'failed'); failedObject.set('payload', failed); await failedObject.save(null, { useMasterKey: true }); }
+        throw error;
+      }
+    }
+    if (action === 'listing.score-job.retry' || action === 'listing.score-job.cancel') {
+      const job = await findObject('VocListingScoreJob', workspaceId, 'publicId', input.jobId); if (!job) throw { status: 404, code: 'score_job_not_found', message: '评分任务不存在' };
+      const status = String(job.get('status') || '');
+      if (action.endsWith('retry') && !['failed','partial'].includes(status)) throw { status: 409, code: 'score_job_not_retryable', message: '评分任务不可重试' };
+      if (action.endsWith('cancel') && !['queued','running'].includes(status)) throw { status: 409, code: 'score_job_not_cancellable', message: '评分任务不可取消' };
+      const nextStatus = action.endsWith('retry') ? 'queued' : 'cancelled'; const now = new Date().toISOString(); const payload = { ...presentListingJob(safeValue(job.toJSON(), 0)), status: nextStatus, completedAt: nextStatus === 'cancelled' ? now : null, updatedAt: now };
+      if (nextStatus === 'queued') {
+        const failedItems = await findObjects('VocListingScoreItem', workspaceId, { jobId: input.jobId, status: 'failed' }, 10000, auth.productIds);
+        for (const value of failedItems) { const item = await findObject('VocListingScoreItem', workspaceId, 'publicId', value.publicId); if (item) { const itemPayload = { ...presentListingJobItem(value), status: 'queued', errorCode: null, errorDetail: null, statusReasonCodes: [], updatedAt: now }; item.set('status', 'queued'); item.set('payload', itemPayload); await item.save(null, { useMasterKey: true }); } }
+      }
+      job.set('status', nextStatus); job.set('payload', payload); await job.save(null, { useMasterKey: true });
+      await appendAudit(workspaceId, action.endsWith('retry') ? 'listing.score.retried' : 'listing.score.cancelled', 'listing_score_job', input.jobId, {});
+      return activeResponse.json({ success: true, data: { job: payload }, requestId: requestId() });
+    }
+    if (action === 'listing.version.create') {
+      if (!input.productId || !input.baseSourceHash || !input.content) throw { status: 400, code: 'invalid_listing_version', message: 'Listing 版本参数无效' };
+      if (auth.productIds !== null && !auth.productIds.includes(input.productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
+      const versions = await findObjects('VocListingVersion', workspaceId, { productId: input.productId }, 100, auth.productIds);
+      const versionNo = versions.reduce((max, item) => Math.max(max, Number(item.versionNo || 0)), 0) + 1;
+      const version = await writeObject('VocListingVersion', { publicId: publicId(), naturalKey: workspaceId + ':' + input.productId + ':' + versionNo, productId: input.productId, versionNo, baseSourceHash: input.baseSourceHash, status: 'draft', payload: { content: safeValue(input.content, 0), createdBy: activeRequest.user.id }, versionCreatedAt: new Date() }, workspaceId);
+      await appendAudit(workspaceId, 'listing.version.created', 'listing_version', version.publicId, { productId: input.productId, versionNo });
+      return activeResponse.json({ success: true, data: { version }, requestId: requestId() });
+    }
+    if (action === 'listing.version.adopt') {
+      const version = await findObject('VocListingVersion', workspaceId, 'publicId', input.versionId); if (!version) throw { status: 404, code: 'listing_version_not_found', message: 'Listing 版本不存在' };
+      const productId = version.get('productId'); if (auth.productIds !== null && !auth.productIds.includes(productId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
+      version.set('status', 'adopted'); version.set('adoptedAt', new Date()); version.set('adoptedBy', activeRequest.user.id); await version.save(null, { useMasterKey: true });
+      await appendAudit(workspaceId, 'listing.version.adopted', 'listing_version', input.versionId, { productId });
+      return activeResponse.json({ success: true, data: { version: safeValue(version.toJSON(), 0) }, requestId: requestId() });
+    }
+    if (action === 'competitor.tasks.create') {
+      if (!input.competitorSnapshotId || !input.ownProductId || !input.dimension) throw { status: 400, code: 'invalid_competitor_task', message: '优化任务参数无效' };
+      if (auth.productIds !== null && !auth.productIds.includes(input.ownProductId)) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
+      const key = input.competitorSnapshotId + ':' + input.ownProductId + ':' + input.dimension;
+      const result = await createIdempotent('VocCompetitorOptimizationTask', workspaceId, 'creationKey', key, { publicId: publicId(), competitorSnapshotId: input.competitorSnapshotId, ownProductId: input.ownProductId, dimension: input.dimension, creationKey: key, status: 'open', impactScore: input.impactScore || null, beforeScore: null, afterScore: null, delta: null, effectiveness: 'not_measured' });
+      await appendAudit(workspaceId, result.idempotent ? 'competitor.task.reused' : 'competitor.task.created', 'competitor_optimization_task', result.value.publicId, {});
+      return activeResponse.json({ success: true, data: { task: result.value, idempotent: result.idempotent }, requestId: requestId() });
+    }
+    if (action === 'competitor.tasks.update') {
+      const task = await findObject('VocCompetitorOptimizationTask', workspaceId, 'publicId', input.taskId); if (!task) throw { status: 404, code: 'competitor_task_not_found', message: '优化任务不存在' };
+      if (auth.productIds !== null && !auth.productIds.includes(task.get('ownProductId'))) throw { status: 403, code: 'product_scope_denied', message: '无权访问该商品' };
+      ['status','beforeScore','afterScore'].forEach((field) => { if (input[field] !== undefined) task.set(field, input[field]); });
+      const before = Number(task.get('beforeScore')); const after = Number(task.get('afterScore'));
+      if (Number.isFinite(before) && Number.isFinite(after)) { const delta = after - before; task.set('delta', delta); task.set('effectiveness', delta > 5 ? 'effective' : delta > 0 ? 'partially_effective' : 'ineffective'); }
+      await task.save(null, { useMasterKey: true }); await appendAudit(workspaceId, 'competitor.task.updated', 'competitor_optimization_task', input.taskId, {});
+      return activeResponse.json({ success: true, data: { task: safeValue(task.toJSON(), 0) }, requestId: requestId() });
+    }
+    if (action.startsWith('upstream.')) return activeResponse.json({ success: true, data: await fixedUpstream(action.slice('upstream.'.length), input), requestId: requestId() });
+    return fail(501, 'cloud_action_not_implemented', '业务操作暂未配置');
+  } catch (error) {
+    const status = Number(error && error.status) || 500;
+    return fail(status, error && error.code || 'internal_error', status >= 500 ? '服务暂不可用,请稍后重试' : error.message || '请求失败');
+  }
+}

+ 54 - 0
cloud-functions/shared/audit.js

@@ -0,0 +1,54 @@
+/*
+ * Audit logging utilities for SaaS VOC Cloud Functions
+ */
+
+/**
+ * Safely serialize a value, removing sensitive fields
+ */
+function safeValue(value, depth) {
+  if (depth > 5 || value === null || value === undefined) return value;
+  if (value instanceof Date) return value;
+  if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
+  if (typeof value !== 'object') return value;
+  const output = {};
+  Object.keys(value).slice(0, 100).forEach((key) => {
+    if (/token|secret|password|credential|authorization|master/i.test(key)) return;
+    output[key] = safeValue(value[key], depth + 1);
+  });
+  return output;
+}
+
+/**
+ * Generate a unique public ID
+ */
+function publicId() {
+  return 'cf-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
+}
+
+/**
+ * Get user ID from active request
+ */
+function getUserId() {
+  return Parse?.request?.user?.id || null;
+}
+
+/**
+ * Append an audit log entry
+ */
+async function appendAudit(workspaceId, action, entityType, entityId, metadata) {
+  const audit = new Parse.Object('VocAuditLog');
+  audit.set('publicId', publicId());
+  audit.set('workspaceId', workspaceId);
+  audit.set('actorUserId', getUserId());
+  audit.set('action', action);
+  audit.set('entityType', entityType);
+  audit.set('entityId', entityId || null);
+  audit.set('metadata', safeValue(metadata || {}, 0));
+  await audit.save(null, { useMasterKey: true });
+}
+
+/*
+// module.exports = {
+//   appendAudit,
+// };
+*/

+ 239 - 0
cloud-functions/shared/auth.js

@@ -0,0 +1,239 @@
+/*
+ * Authentication and Authorization utilities for SaaS VOC Cloud Functions
+ */
+
+/**
+ * Safely serialize a value, removing sensitive fields
+ */
+function safeValue(value, depth) {
+  if (depth > 5 || value === null || value === undefined) return value;
+  if (value instanceof Date) return value;
+  if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
+  if (typeof value !== 'object') return value;
+  const output = {};
+  Object.keys(value).slice(0, 100).forEach((key) => {
+    if (/token|secret|password|credential|authorization|master/i.test(key)) return;
+    output[key] = safeValue(value[key], depth + 1);
+  });
+  return output;
+}
+
+// Roles that can perform write operations
+const WRITE_ROLES = new Set(['owner', 'admin', 'editor']);
+const ADMIN_ROLES = new Set(['owner', 'admin']);
+
+let activeRequest = null;
+let activeResponse = null;
+
+/**
+ * Set the current request/response context
+ */
+function setContext(request, response) {
+  activeRequest = request;
+  activeResponse = response;
+}
+
+/**
+ * Clear the current context
+ */
+function clearContext() {
+  activeRequest = null;
+  activeResponse = null;
+}
+
+/**
+ * Get request ID from headers or generate one
+ */
+function requestId() {
+  return activeRequest?.headers && (
+    activeRequest.headers['x-request-id'] ||
+    activeRequest.headers['X-Request-Id']
+  ) || 'cloud-' + Date.now().toString(36);
+}
+
+/**
+ * Get the current user from the request
+ */
+function getUser() {
+  return activeRequest?.user || null;
+}
+
+/**
+ * Get the current workspace ID
+ */
+function getWorkspaceId() {
+  return activeRequest?.workspaceId || '';
+}
+
+/**
+ * Respond with an error
+ */
+function fail(status, code, message) {
+  activeResponse?.status(status).json({
+    success: false,
+    code,
+    message,
+    requestId: requestId(),
+  });
+}
+
+/**
+ * Respond with success
+ */
+function success(data, statusCode = 200) {
+  const response = statusCode === 202
+    ? activeResponse?.status(202).json({ success: true, data, requestId: requestId() })
+    : activeResponse?.json({ success: true, data, requestId: requestId() });
+  return response;
+}
+
+/**
+ * Check if user is authenticated
+ */
+function requireAuth() {
+  if (!activeRequest?.user) {
+    throw { status: 401, code: 'unauthenticated', message: '需要登录' };
+  }
+}
+
+/**
+ * Get the active workspace member for the current user
+ */
+async function activeMember(workspaceId) {
+  requireAuth();
+  const user = getUser();
+  const query = new Parse.Query('VocWorkspaceMember');
+  query.equalTo('workspaceId', workspaceId);
+  query.equalTo('userId', user.id);
+  query.equalTo('status', 'active');
+  return query.first({ useMasterKey: true });
+}
+
+/**
+ * Get all workspaces accessible by the current user
+ */
+async function accessibleWorkspaces() {
+  requireAuth();
+  const user = getUser();
+  const memberQuery = new Parse.Query('VocWorkspaceMember');
+  memberQuery.equalTo('userId', user.id);
+  memberQuery.equalTo('status', 'active');
+  memberQuery.limit(100);
+  const members = await memberQuery.find({ useMasterKey: true });
+  const ids = [...new Set(
+    members
+      .map((member) => String(member.get('workspaceId') || ''))
+      .filter(Boolean)
+  )];
+  if (!ids.length) return [];
+  const workspaceQuery = new Parse.Query('VocWorkspace');
+  workspaceQuery.containedIn('publicId', ids);
+  workspaceQuery.equalTo('status', 'active');
+  workspaceQuery.limit(100);
+  const workspaces = await workspaceQuery.find({ useMasterKey: true });
+  const roleByWorkspace = new Map(
+    members.map((member) => [
+      String(member.get('workspaceId') || ''),
+      String(member.get('role') || 'viewer'),
+    ])
+  );
+  return workspaces.map((workspace) => ({
+    ...safeValue(workspace.toJSON(), 0),
+    role: roleByWorkspace.get(String(workspace.get('publicId') || '')) || 'viewer',
+  }));
+}
+
+/**
+ * Check if an action is a write action
+ */
+function isWriteAction(action) {
+  return (
+    /\.create$|\.update$|\.upsert$|\.delete$|\.enqueue$|\.retry$|\.cancel$|\.adopt$|\.run$/.test(action) ||
+    action === 'ai.chat' ||
+    action === 'ai.test' ||
+    action === 'competitor.refresh'
+  );
+}
+
+/**
+ * Get safe value utility (lazy loaded to avoid circular dependency)
+ */
+function safeValue(value, depth) {
+  if (depth > 5 || value === null || value === undefined) return value;
+  if (value instanceof Date) return value;
+  if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
+  if (typeof value !== 'object') return value;
+  const output = {};
+  Object.keys(value).slice(0, 100).forEach((key) => {
+    if (/token|secret|password|credential|authorization|master/i.test(key)) return;
+    output[key] = safeValue(value[key], depth + 1);
+  });
+  return output;
+}
+
+/**
+ * Authorize an action for the current user in a workspace
+ * @param {string} action - The action being performed
+ * @param {string} workspaceId - The workspace ID
+ * @returns {Object} Authorization result with member, role, and productIds
+ */
+async function authorize(action, workspaceId) {
+  requireAuth();
+  const member = await activeMember(workspaceId);
+  if (!member) {
+    throw { status: 403, code: 'workspace_access_denied', message: '无权访问该 workspace' };
+  }
+  const role = String(member.get('role') || 'viewer');
+
+  // Check admin-only actions
+  if (action === 'workspace.members.list' && !ADMIN_ROLES.has(role)) {
+    throw { status: 403, code: 'forbidden', message: '权限不足' };
+  }
+
+  // Check write permission
+  if (isWriteAction(action) && !WRITE_ROLES.has(role)) {
+    throw { status: 403, code: 'viewer_write_forbidden', message: 'Viewer 不能执行写入操作' };
+  }
+
+  // Get product scope
+  const configuredProductIds = member.get('productIds');
+  const productIds = Array.isArray(configuredProductIds)
+    ? [...new Set(configuredProductIds.filter((value) => typeof value === 'string' && value.length <= 200))]
+    : (ADMIN_ROLES.has(role) ? null : []);
+
+  return { member, role, productIds };
+}
+
+/**
+ * Resolve workspace ID, using membership if not provided
+ */
+async function resolveWorkspaceId(workspaceId) {
+  if (workspaceId && workspaceId.trim()) return workspaceId.trim();
+
+  requireAuth();
+  const user = getUser();
+  const membershipQuery = new Parse.Query('VocWorkspaceMember');
+  membershipQuery.equalTo('userId', user.id);
+  membershipQuery.equalTo('status', 'active');
+  const membership = await membershipQuery.first({ useMasterKey: true });
+  return membership ? String(membership.get('workspaceId') || '') : '';
+}
+
+/*
+// module.exports = {
+//   setContext,
+//   clearContext,
+//   requestId,
+//   getUser,
+//   getWorkspaceId,
+//   fail,
+//   success,
+//   requireAuth,
+//   activeMember,
+//   accessibleWorkspaces,
+//   isWriteAction,
+//   safeValue,
+//   authorize,
+//   resolveWorkspaceId,
+// };
+*/

+ 192 - 0
cloud-functions/shared/constants.js

@@ -0,0 +1,192 @@
+/*
+ * Shared constants for SaaS VOC Cloud Functions
+ * These constants are shared across all cloud functions
+ */
+
+// Allowed actions - each action maps to a specific cloud function
+const ACTIONS = new Set([
+  // Context & Workspace
+  'context.get', 'workspace.list', 'workspace.member.update',
+  // Data Source & Import
+  'data-source.list', 'import.list', 'audit.list',
+  // Domestic VOC
+  'domestic.snapshot', 'domestic.products.list', 'domestic.product.get', 'domestic.reviews.list', 'domestic.relations.list',
+  // Sync
+  'sync.enqueue', 'sync.jobs.list', 'sync.job.get', 'sync.job.events', 'sync.job.retry', 'sync.job.cancel',
+  // Analysis
+  'analysis.list', 'analysis.create', 'analysis.update',
+  'insight-decision.list', 'insight-decision.get', 'insight-decision.create',
+  // Actions & Alerts
+  'action.list', 'action.create', 'action.update',
+  'alert.list', 'alert.create', 'alert.update',
+  // Knowledge
+  'knowledge.products.list', 'knowledge.product.upsert', 'knowledge.product.delete',
+  // Competitor
+  'competitor.overview', 'competitor.refresh', 'competitor.history', 'competitor.alerts', 'competitor.impacts',
+  'competitor.tasks.list', 'competitor.tasks.create', 'competitor.tasks.update', 'competitor.run.get',
+  // Listing
+  'listing.overview', 'listing.products.list', 'listing.product.get', 'listing.product.score',
+  'listing.jd-voc-score.get', 'listing.jd-voc-image-review.run',
+  'listing.score-job.create',
+  'listing.score-job.list', 'listing.score-job.get', 'listing.score-job.items', 'listing.score-job.retry', 'listing.score-job.cancel',
+  'listing.versions.list', 'listing.version.create', 'listing.version.adopt',
+  // AI
+  'ai.status', 'ai.test', 'ai.prompts.list', 'ai.prompt.update', 'ai.chat',
+  // Upstream
+  'upstream.amazon', 'upstream.sorftime', 'upstream.tikhub', 'upstream.domestic',
+]);
+
+// Map actions to Parse class names for generic read operations
+const CLASS_BY_READ_ACTION = {
+  'workspace.list': 'VocWorkspace',
+  'workspace.members.list': 'VocWorkspaceMember',
+  'data-source.list': 'VocSourceConnection',
+  'import.list': 'VocImportBatch',
+  'audit.list': 'VocAuditLog',
+  'domestic.products.list': 'VocProduct',
+  'domestic.reviews.list': 'VocReview',
+  'domestic.relations.list': 'VocProductRelation',
+  'sync.jobs.list': 'VocSyncJob',
+  'sync.job.events': 'VocSyncJobEvent',
+  'analysis.list': 'VocAnalysisRun',
+  'insight-decision.list': 'VocInsightDecision',
+  'action.list': 'VocActionItem',
+  'alert.list': 'VocAlert',
+  'knowledge.products.list': 'VocProductKnowledge',
+  'ai.prompts.list': 'VocPromptConfig',
+  'listing.products.list': 'VocListingSourceSnapshot',
+  'listing.score-job.list': 'VocListingScoreJob',
+  'listing.versions.list': 'VocListingVersion',
+  'competitor.tasks.list': 'VocCompetitorOptimizationTask',
+};
+
+// Roles that can perform write operations
+const WRITE_ROLES = new Set(['owner', 'admin', 'editor']);
+const ADMIN_ROLES = new Set(['owner', 'admin']);
+
+// Fields used for product scope filtering
+const PRODUCT_SCOPE_FIELDS = {
+  VocProduct: 'productId',
+  VocReview: 'productId',
+  VocDailyMetric: 'productId',
+  VocProductRelation: 'ownProductId',
+  VocProductKnowledge: 'productId',
+  VocListingSourceSnapshot: 'productId',
+  VocListingCurrentScore: 'productId',
+  VocListingVersion: 'productId',
+  VocCompetitorListingSnapshot: 'productId',
+  VocCompetitorListingChange: 'productId',
+  VocCompetitorOptimizationTask: 'ownProductId',
+};
+
+// Fields used for read filtering by class
+const READ_FILTER_FIELDS = {
+  VocProduct: ['platform', 'role'],
+  VocReview: ['platform', 'productId'],
+  VocProductRelation: ['platform', 'ownProductId', 'competitorProductId'],
+  VocSyncJob: ['status', 'platform'],
+  VocSyncJobEvent: ['jobId', 'eventType'],
+  VocAnalysisRun: ['status', 'analysisType', 'targetKind'],
+  VocInsightDecision: ['sourceAnalysisId', 'sourceInsightId', 'isCurrent'],
+  VocActionItem: ['status', 'actionType', 'productKey'],
+  VocAlert: ['status', 'alertType', 'productKey'],
+  VocProductKnowledge: ['productId', 'productKey', 'status'],
+  VocListingSourceSnapshot: ['platform', 'productId', 'isCurrent', 'scoreStatus', 'aiScoreStatus', 'coverageStatus'],
+  VocListingScoreJob: ['status', 'platform'],
+  VocListingVersion: ['productId', 'status'],
+  VocCompetitorListingSnapshot: ['platform', 'productId'],
+  VocCompetitorListingChange: ['platform', 'productId'],
+  VocCompetitorOptimizationTask: ['status', 'ownProductId'],
+};
+
+// Allowed paths for upstream providers
+const UPSTREAM_PATHS = {
+  amazon: [
+    /^\/test$/,
+    /^\/orders(?:\/[A-Za-z0-9._~-]+)?(?:\/items)?$/,
+    /^\/sales\/orderMetrics$/,
+    /^\/catalog\/items(?:\/[A-Za-z0-9._~-]+)?$/,
+    /^\/listings\/items\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)?$/,
+    /^\/customerFeedback\/items\/[A-Za-z0-9._~-]+\/reviews\/(?:topics|trends|browseNode)$/,
+    /^\/sellers\/marketplaceParticipations$/,
+    /^\/returns$/,
+  ],
+  sorftime: [
+    /^\/api\/(?:CategoryTree|CategoryRequest|CategoryProducts|ProductQuery|ProductRequest|AsinSalesVolume|SimilarProductRealtimeRequest|SimilarProductRealtimeRequestStatusQuery|SimilarProductRealtimeRequestCollection|ProductReviewsQuery|MonitorQuery|KeywordQuery|ASINRequestKeyword|KeywordProductRanking|KeywordSearchResultTrend|ProductVariationHistory)$/,
+  ],
+  tikhub: [
+    /^\/v1\/(?:tiktok|instagram)\/[A-Za-z0-9._~/-]+$/,
+  ],
+  domestic: [
+    /^\/jd\/(?:get-item-detail|get-item-comments|search-item-list)\/v1$/,
+  ],
+};
+
+// Allowed operations for upstream providers
+const UPSTREAM_OPERATIONS = {
+  amazon: new Set(['get', 'post']),
+  sorftime: new Set(['get', 'post', 'forward']),
+  tikhub: new Set(['get', 'post', 'forward']),
+  domestic: new Set(['gateway.get']),
+};
+
+// Fields excluded from body data for security
+const EXCLUDED_BODY_FIELDS = [
+  'action', 'workspaceId', 'platform', 'payload', 'idempotencyKey',
+  'userId', 'jobId', 'productId', 'decisionId', 'analysisId',
+  'actionId', 'alertId', 'taskId', 'runId', 'versionId',
+];
+
+// Fields excluded from storage data
+const EXCLUDED_STORAGE_FIELDS = ['action', 'workspaceId', 'userId'];
+
+// Cloud function mapping - action prefix to function name
+const ACTION_TO_FUNCTION = {
+  // Context & Workspace
+  'context': 'vContext',
+  'workspace': 'vContext',
+  // Domestic VOC
+  'domestic': 'vDomesticVoc',
+  'data-source': 'vDomesticVoc',
+  'import': 'vDomesticVoc',
+  'audit': 'vDomesticVoc',
+  // Sync
+  'sync': 'vSync',
+  // Competitor
+  'competitor': 'vCompetitor',
+  // Listing
+  'listing.product': 'vListing',
+  'listing.overview': 'vListing',
+  'listing.products': 'vListing',
+  // Listing Job
+  'listing.score-job': 'vListingJob',
+  'listing.version': 'vListingJob',
+  // Analysis
+  'analysis': 'vAnalysis',
+  'insight-decision': 'vAnalysis',
+  // Actions
+  'action': 'vActions',
+  'alert': 'vActions',
+  // Knowledge
+  'knowledge': 'vKnowledge',
+  // AI
+  'ai': 'vAI',
+  // Upstream
+  'upstream': 'vUpstream',
+};
+
+/*
+// module.exports = {
+//   ACTIONS,
+//   CLASS_BY_READ_ACTION,
+//   WRITE_ROLES,
+//   ADMIN_ROLES,
+//   PRODUCT_SCOPE_FIELDS,
+//   READ_FILTER_FIELDS,
+//   UPSTREAM_PATHS,
+//   UPSTREAM_OPERATIONS,
+//   EXCLUDED_BODY_FIELDS,
+//   EXCLUDED_STORAGE_FIELDS,
+//   ACTION_TO_FUNCTION,
+// };
+*/

+ 392 - 0
cloud-functions/shared/repository.js

@@ -0,0 +1,392 @@
+/*
+ * Data repository utilities for SaaS VOC Cloud Functions
+ * Provides read operations for Parse objects
+ */
+
+/**
+ * Limit a value to a bounded range
+ */
+function boundedLimit(value, fallback, maximum = 100) {
+  const limit = Number(value || fallback);
+  return Number.isInteger(limit) ? Math.max(1, Math.min(limit, maximum)) : fallback;
+}
+
+/**
+ * Safely serialize a value, removing sensitive fields
+ */
+function safeValue(value, depth) {
+  if (depth > 5 || value === null || value === undefined) return value;
+  if (value instanceof Date) return value;
+  if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
+  if (typeof value !== 'object') return value;
+  const output = {};
+  Object.keys(value).slice(0, 100).forEach((key) => {
+    if (/token|secret|password|credential|authorization|master/i.test(key)) return;
+    output[key] = safeValue(value[key], depth + 1);
+  });
+  return output;
+}
+
+// Allowed actions - maps action to Parse class names for generic read operations
+const CLASS_BY_READ_ACTION = {
+  'workspace.list': 'VocWorkspace',
+  'workspace.members.list': 'VocWorkspaceMember',
+  'data-source.list': 'VocSourceConnection',
+  'import.list': 'VocImportBatch',
+  'audit.list': 'VocAuditLog',
+  'domestic.products.list': 'VocProduct',
+  'domestic.reviews.list': 'VocReview',
+  'domestic.relations.list': 'VocProductRelation',
+  'sync.jobs.list': 'VocSyncJob',
+  'sync.job.events': 'VocSyncJobEvent',
+  'analysis.list': 'VocAnalysisRun',
+  'insight-decision.list': 'VocInsightDecision',
+  'action.list': 'VocActionItem',
+  'alert.list': 'VocAlert',
+  'knowledge.products.list': 'VocProductKnowledge',
+  'ai.prompts.list': 'VocPromptConfig',
+  'listing.products.list': 'VocListingSourceSnapshot',
+  'listing.score-job.list': 'VocListingScoreJob',
+  'listing.versions.list': 'VocListingVersion',
+  'competitor.tasks.list': 'VocCompetitorOptimizationTask',
+};
+
+// Fields used for product scope filtering
+const PRODUCT_SCOPE_FIELDS = {
+  VocProduct: 'productId',
+  VocReview: 'productId',
+  VocDailyMetric: 'productId',
+  VocProductRelation: 'ownProductId',
+  VocProductKnowledge: 'productId',
+  VocListingSourceSnapshot: 'productId',
+  VocListingCurrentScore: 'productId',
+  VocListingVersion: 'productId',
+  VocCompetitorListingSnapshot: 'productId',
+  VocCompetitorListingChange: 'productId',
+  VocCompetitorOptimizationTask: 'ownProductId',
+};
+
+// Fields used for read filtering by class
+const READ_FILTER_FIELDS = {
+  VocProduct: ['platform', 'role'],
+  VocReview: ['platform', 'productId'],
+  VocProductRelation: ['platform', 'ownProductId', 'competitorProductId'],
+  VocSyncJob: ['status', 'platform'],
+  VocSyncJobEvent: ['jobId', 'eventType'],
+  VocAnalysisRun: ['status', 'analysisType', 'targetKind'],
+  VocInsightDecision: ['sourceAnalysisId', 'sourceInsightId', 'isCurrent'],
+  VocActionItem: ['status', 'actionType', 'productKey'],
+  VocAlert: ['status', 'alertType', 'productKey'],
+  VocProductKnowledge: ['productId', 'productKey', 'status'],
+  VocListingSourceSnapshot: ['platform', 'productId', 'isCurrent', 'scoreStatus', 'aiScoreStatus', 'coverageStatus'],
+  VocListingScoreJob: ['status', 'platform'],
+  VocListingVersion: ['productId', 'status'],
+  VocCompetitorListingSnapshot: ['platform', 'productId'],
+  VocCompetitorListingChange: ['platform', 'productId'],
+  VocCompetitorOptimizationTask: ['status', 'ownProductId'],
+};
+
+/**
+ * Read a paginated list of objects
+ */
+async function readMany(className, workspaceId, params = {}, productIds = null, maximum = 100) {
+  const query = new Parse.Query(className);
+  const idField = className === 'VocWorkspace' ? 'publicId' : 'workspaceId';
+  query.equalTo(idField, workspaceId);
+
+  applyReadFilters(query, className, params);
+
+  if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
+    const visibleIds = await visibleProductIds(className, workspaceId, productIds);
+    query.containedIn(PRODUCT_SCOPE_FIELDS[className], visibleIds);
+  }
+
+  const limit = boundedLimit(params.limit, 50, maximum);
+
+  // Handle cursor-based pagination
+  if (typeof params.cursor === 'string' && params.cursor) {
+    const cursorDate = new Date(params.cursor);
+    if (Number.isNaN(cursorDate.getTime())) {
+      throw { status: 400, code: 'invalid_cursor', message: '分页游标无效' };
+    }
+    query.lessThan('createdAt', cursorDate);
+  }
+
+  if (Number.isInteger(params.skip) && params.skip >= 0) {
+    query.skip(Math.min(params.skip, 100000));
+  }
+
+  query.limit(limit);
+  query.descending('createdAt');
+
+  const rows = await query.find({ useMasterKey: true });
+  const last = rows[rows.length - 1];
+
+  const nextCursor = Number.isInteger(params.skip)
+    ? (rows.length === limit ? String(params.skip + rows.length) : null)
+    : (rows.length === limit && last && last.createdAt ? last.createdAt.toISOString() : null);
+
+  return {
+    items: rows.map((row) => safeValue(row.toJSON(), 0)),
+    nextCursor,
+  };
+}
+
+/**
+ * Apply read filters to a query based on class and params
+ */
+function applyReadFilters(query, className, params) {
+  const fields = READ_FILTER_FIELDS[className] || [];
+  for (const field of fields) {
+    const value = params?.[field];
+    if (value !== undefined && value !== null && value !== '') {
+      query.equalTo(field, value);
+    }
+  }
+
+  // Special case for product search
+  if (className === 'VocProduct' && typeof params?.search === 'string' && params.search.trim()) {
+    query.contains('title', params.search.trim().slice(0, 200));
+  }
+}
+
+/**
+ * Read all objects (with pagination)
+ */
+async function readAll(className, workspaceId, params = {}, productIds = null, maximum = 10000) {
+  const items = [];
+  while (items.length < maximum) {
+    const page = await readMany(
+      className,
+      workspaceId,
+      { ...params, limit: Math.min(100, maximum - items.length), skip: items.length },
+      productIds,
+      100
+    );
+    items.push(...page.items);
+    if (!page.nextCursor || !page.items.length) break;
+  }
+  return items;
+}
+
+/**
+ * Read all objects matching specific filters
+ */
+async function readAllWhere(className, workspaceId, filters = {}, productIds = null, maximum = 10000) {
+  const items = [];
+  while (items.length < maximum) {
+    const query = new Parse.Query(className);
+    const idField = className === 'VocWorkspace' ? 'publicId' : 'workspaceId';
+    query.equalTo(idField, workspaceId);
+
+    Object.entries(filters || {}).forEach(([key, value]) => {
+      if (value !== undefined && value !== null && value !== '') {
+        query.equalTo(key, value);
+      }
+    });
+
+    if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
+      const visibleIds = await visibleProductIds(className, workspaceId, productIds);
+      query.containedIn(PRODUCT_SCOPE_FIELDS[className], visibleIds);
+    }
+
+    const pageSize = Math.min(100, maximum - items.length);
+    query.skip(items.length);
+    query.limit(pageSize);
+    query.descending('createdAt');
+
+    const rows = await query.find({ useMasterKey: true });
+    items.push(...rows.map((row) => safeValue(row.toJSON(), 0)));
+    if (rows.length < pageSize) break;
+  }
+  return items;
+}
+
+/**
+ * Read a single object by ID
+ */
+async function readOne(className, workspaceId, objectId, productIds = null) {
+  if (!objectId || typeof objectId !== 'string' || objectId.length > 200) {
+    throw { status: 400, code: 'invalid_id', message: '标识无效' };
+  }
+
+  const query = new Parse.Query(className);
+  query.equalTo('workspaceId', workspaceId);
+  const idField = className === 'VocProduct' ? 'productId' : 'publicId';
+  query.equalTo(idField, objectId);
+
+  if (productIds !== null && className === 'VocProduct') {
+    query.containedIn('productId', productIds);
+  }
+
+  const row = await query.first({ useMasterKey: true });
+  return row ? safeValue(row.toJSON(), 0) : null;
+}
+
+/**
+ * Find a single object by a specific field
+ */
+async function findObject(className, workspaceId, field, value) {
+  const query = new Parse.Query(className);
+  query.equalTo('workspaceId', workspaceId);
+  query.equalTo(field, value);
+  return query.first({ useMasterKey: true });
+}
+
+/**
+ * Find multiple objects matching filters
+ */
+async function findObjects(className, workspaceId, filters = {}, limit = 50, productIds = null) {
+  const query = new Parse.Query(className);
+  query.equalTo('workspaceId', workspaceId);
+
+  Object.entries(filters || {}).forEach(([key, value]) => {
+    if (value !== undefined && value !== null && value !== '') {
+      query.equalTo(key, value);
+    }
+  });
+
+  if (productIds !== null && PRODUCT_SCOPE_FIELDS[className]) {
+    const visibleIds = await visibleProductIds(className, workspaceId, productIds);
+    query.containedIn(PRODUCT_SCOPE_FIELDS[className], visibleIds);
+  }
+
+  query.limit(boundedLimit(limit, 50));
+  query.descending('createdAt');
+
+  const rows = await query.find({ useMasterKey: true });
+  return rows.map((row) => safeValue(row.toJSON(), 0));
+}
+
+/**
+ * Get product IDs visible for a class (including competitors)
+ */
+async function visibleProductIds(className, workspaceId, productIds) {
+  if (!productIds || !['VocProduct', 'VocCompetitorListingSnapshot', 'VocCompetitorListingChange'].includes(className)) {
+    return productIds;
+  }
+
+  const relationQuery = new Parse.Query('VocProductRelation');
+  relationQuery.equalTo('workspaceId', workspaceId);
+  relationQuery.containedIn('ownProductId', productIds);
+  relationQuery.limit(10000);
+
+  const relations = await relationQuery.find({ useMasterKey: true });
+  const competitorIds = relations
+    .map((relation) => String(relation.get('competitorProductId') || ''))
+    .filter(Boolean);
+
+  return [...new Set(productIds.concat(competitorIds))];
+}
+
+/**
+ * Present a read item with normalized fields
+ */
+function presentReadItem(action, item) {
+  if (action === 'listing.score-job.list') return presentListingJob(item);
+
+  const output = { ...item };
+  if (!output.id) {
+    output.id = output.publicId || output.objectId || output.productId;
+  }
+
+  if (action === 'data-source.list') {
+    output.kind = output.kind || output.connectionKind || '';
+    output.credentialStorage = output.credentialStorage || output.metadata?.credentialStorage || 'external_secret';
+  }
+  if (action === 'import.list') {
+    output.id = output.id || output.publicId;
+  }
+  if (action === 'domestic.reviews.list') {
+    output.reviewId = output.reviewId || output.reviewKey || output.sourceReviewId || output.id;
+  }
+  if (action === 'sync.jobs.list') {
+    output.id = output.publicId || output.id;
+  }
+  if (action === 'sync.job.events') {
+    output.type = output.type || output.eventType || '';
+  }
+  if (action === 'listing.versions.list') {
+    output.id = output.publicId || output.id;
+  }
+
+  return output;
+}
+
+/**
+ * Present a listing job item
+ */
+function presentListingJob(item) {
+  const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
+  return {
+    ...payload,
+    id: payload.id || item.publicId || item.objectId,
+    workspaceId: payload.workspaceId || item.workspaceId,
+    platform: payload.platform || item.platform || 'jd',
+    idempotencyKey: payload.idempotencyKey || item.idempotencyKey,
+    requestHash: payload.requestHash || item.requestHash,
+    status: item.status || payload.status,
+  };
+}
+
+/**
+ * Present a listing job item
+ */
+function presentListingJobItem(item) {
+  const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
+  return {
+    ...payload,
+    id: payload.id || item.publicId || item.objectId,
+    jobId: payload.jobId || item.jobId,
+    workspaceId: payload.workspaceId || item.workspaceId,
+    productId: payload.productId || item.productId,
+    sourceHash: payload.sourceHash || item.sourceHash,
+    status: item.status || payload.status,
+  };
+}
+
+/**
+ * Present a read page with normalized items
+ */
+function presentReadPage(action, page) {
+  return {
+    ...page,
+    items: page.items.map((item) => presentReadItem(action, item)),
+  };
+}
+
+/**
+ * Safe value serializer (lazy loaded to avoid circular dependency)
+ */
+function safeValue(value, depth) {
+  if (depth > 5 || value === null || value === undefined) return value;
+  if (value instanceof Date) return value;
+  if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
+  if (typeof value !== 'object') return value;
+  const output = {};
+  Object.keys(value).slice(0, 100).forEach((key) => {
+    if (/token|secret|password|credential|authorization|master/i.test(key)) return;
+    output[key] = safeValue(value[key], depth + 1);
+  });
+  return output;
+}
+
+/*
+// module.exports = {
+//   boundedLimit,
+//   readMany,
+//   applyReadFilters,
+//   readAll,
+//   readAllWhere,
+//   readOne,
+//   findObject,
+//   findObjects,
+//   visibleProductIds,
+//   presentReadItem,
+//   presentListingJob,
+//   presentListingJobItem,
+//   presentReadPage,
+//   safeValue,
+//   CLASS_BY_READ_ACTION,
+// };
+*/

+ 155 - 0
cloud-functions/shared/storage.js

@@ -0,0 +1,155 @@
+/*
+ * Data storage utilities for SaaS VOC Cloud Functions
+ * Provides write operations for Parse objects
+ */
+
+/**
+ * Safely serialize a value, removing sensitive fields
+ */
+function safeValue(value, depth) {
+  if (depth > 5 || value === null || value === undefined) return value;
+  if (value instanceof Date) return value;
+  if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
+  if (typeof value !== 'object') return value;
+  const output = {};
+  Object.keys(value).slice(0, 100).forEach((key) => {
+    if (/token|secret|password|credential|authorization|master/i.test(key)) return;
+    output[key] = safeValue(value[key], depth + 1);
+  });
+  return output;
+}
+
+// Fields excluded from body data
+const EXCLUDED_BODY_FIELDS = [
+  'action', 'workspaceId', 'platform', 'payload', 'idempotencyKey',
+  'userId', 'jobId', 'productId', 'decisionId', 'analysisId',
+  'actionId', 'alertId', 'taskId', 'runId', 'versionId',
+];
+
+// Fields excluded from storage data
+const EXCLUDED_STORAGE_FIELDS = ['action', 'workspaceId', 'userId'];
+
+/**
+ * Get user ID from active request
+ */
+function getUserId() {
+  return Parse?.request?.user?.id || null;
+}
+
+/**
+ * Extract body data from params (excluding system fields)
+ */
+function bodyData(params, workspaceId) {
+  const output = {};
+  Object.keys(params).forEach((key) => {
+    if (!EXCLUDED_BODY_FIELDS.includes(key)) {
+      output[key] = safeValue(params[key], 0);
+    }
+  });
+  output.workspaceId = workspaceId;
+  return output;
+}
+
+/**
+ * Extract storage data from params
+ */
+function storageData(params, workspaceId) {
+  const output = {};
+  Object.keys(params).forEach((key) => {
+    if (!EXCLUDED_STORAGE_FIELDS.includes(key)) {
+      output[key] = safeValue(params[key], 0);
+    }
+  });
+  output.workspaceId = workspaceId;
+  return output;
+}
+
+/**
+ * Generate a unique public ID
+ */
+function publicId() {
+  return 'cf-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
+}
+
+/**
+ * Write a new object
+ */
+async function writeObject(className, params, workspaceId) {
+  const object = new Parse.Object(className);
+  Object.entries(storageData(params, workspaceId)).forEach(([key, value]) => {
+    object.set(key, value);
+  });
+  object.set('createdBy', getUserId());
+  await object.save(null, { useMasterKey: true });
+  return safeValue(object.toJSON(), 0);
+}
+
+/**
+ * Update an existing object by public ID
+ */
+async function updateByPublicId(className, workspaceId, id, params) {
+  const object = await findObject(className, workspaceId, 'publicId', id);
+  if (!object) {
+    throw { status: 404, code: 'not_found', message: '记录不存在' };
+  }
+
+  Object.entries(bodyData(params, workspaceId)).forEach(([key, value]) => {
+    object.set(key, value);
+  });
+  object.set('updatedBy', getUserId());
+
+  await object.save(null, { useMasterKey: true });
+  return safeValue(object.toJSON(), 0);
+}
+
+/**
+ * Find an object by field value
+ */
+async function findObject(className, workspaceId, field, value) {
+  const query = new Parse.Query(className);
+  query.equalTo('workspaceId', workspaceId);
+  query.equalTo(field, value);
+  return query.first({ useMasterKey: true });
+}
+
+/**
+ * Create or return existing object (idempotent operation)
+ */
+async function createIdempotent(className, workspaceId, keyField, keyValue, params) {
+  if (keyValue) {
+    const existing = await findObject(className, workspaceId, keyField, keyValue);
+    if (existing) {
+      return { value: safeValue(existing.toJSON(), 0), idempotent: true };
+    }
+  }
+
+  const object = new Parse.Object(className);
+  Object.entries(storageData(params, workspaceId)).forEach(([key, value]) => {
+    object.set(key, value);
+  });
+
+  if (!object.get('publicId')) {
+    object.set('publicId', publicId());
+  }
+  if (keyValue) {
+    object.set(keyField, keyValue);
+  }
+  object.set('createdBy', getUserId());
+
+  await object.save(null, { useMasterKey: true });
+  return { value: safeValue(object.toJSON(), 0), idempotent: false };
+}
+
+/*
+// module.exports = {
+//   safeValue,
+//   getUserId,
+//   bodyData,
+//   storageData,
+//   publicId,
+//   writeObject,
+//   updateByPublicId,
+//   findObject,
+//   createIdempotent,
+// };
+*/

+ 42 - 0
cloud-functions/shared/utils.js

@@ -0,0 +1,42 @@
+/*
+ * Utility functions for SaaS VOC Cloud Functions
+ */
+
+/**
+ * Generate a unique public ID
+ */
+function publicId() {
+  return 'cf-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
+}
+
+/**
+ * Safely serialize a value, removing sensitive fields
+ */
+function safeValue(value, depth) {
+  if (depth > 5 || value === null || value === undefined) return value;
+  if (value instanceof Date) return value;
+  if (Array.isArray(value)) return value.slice(0, 100).map((item) => safeValue(item, depth + 1));
+  if (typeof value !== 'object') return value;
+  const output = {};
+  Object.keys(value).slice(0, 100).forEach((key) => {
+    if (/token|secret|password|credential|authorization|master/i.test(key)) return;
+    output[key] = safeValue(value[key], depth + 1);
+  });
+  return output;
+}
+
+/**
+ * Limit a value to a bounded range
+ */
+function boundedLimit(value, fallback, maximum = 100) {
+  const limit = Number(value || fallback);
+  return Number.isInteger(limit) ? Math.max(1, Math.min(limit, maximum)) : fallback;
+}
+
+/*
+// module.exports = {
+//   publicId,
+//   safeValue,
+//   boundedLimit,
+// };
+*/

+ 7 - 0
package.json

@@ -21,12 +21,17 @@
     "refresh:competitor-listings": "tsx scripts/refresh-competitor-listings.ts",
     "sync:jd-listings": "tsx scripts/sync-jd-listings.ts",
     "sync:jd-listing-reviews": "tsx scripts/sync-jd-listing-reviews.ts",
+    "backfill:voc-products": "tsx scripts/backfill-voc-products-from-listing-sources.ts",
     "enrich:listing-context": "tsx scripts/enrich-listing-context.ts",
     "score:jd-listings": "tsx scripts/score-listings.ts",
     "score:jd-listings:ai": "tsx scripts/score-listings-ai.ts",
     "score:jd-listings:codex": "tsx scripts/score-listings-codex.ts",
+    "score:jd-voc:codex": "tsx scripts/score-jd-voc-codex.ts",
     "migrate:listing-current-scores": "tsx scripts/migrate-listing-current-scores.ts",
+    "migrate:jd-voc-slots": "tsx scripts/migrate-jd-voc-score-slots.ts",
     "verify:listing-rollout": "tsx scripts/verify-listing-rollout.ts",
+    "benchmark:jd-voc-migration": "tsx scripts/benchmark-jd-voc-migration.ts",
+    "benchmark:jd-voc-images": "tsx scripts/benchmark-jd-voc-image-review.ts",
     "set:listing-cohort": "tsx scripts/set-listing-catalog-cohort.ts",
     "reset:listing-formal-ai": "tsx scripts/reset-listing-formal-ai-scores.ts",
     "publish:listing-simulation": "tsx scripts/publish-listing-simulated-scores.ts",
@@ -36,6 +41,8 @@
     "test": "tsx --test test/**/*.test.ts",
     "test:coverage": "tsx --test --experimental-test-coverage test/**/*.test.ts",
     "typecheck": "tsc -p tsconfig.json --noEmit"
+    ,"deploy:cloud-function": "node scripts/deploy-cloud-function.mjs",
+    "bootstrap:managed-user": "node scripts/bootstrap-managed-user.mjs"
   },
   "dependencies": {
     "cors": "^2.8.6",

+ 133 - 0
scripts/backfill-voc-products-from-listing-sources.ts

@@ -0,0 +1,133 @@
+import 'dotenv/config';
+import { loadConfig } from '../src/config/env.js';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+
+type Row = Record<string, unknown> & { objectId: string };
+
+const apply = process.argv.includes('--apply');
+const overwrite = process.argv.includes('--overwrite');
+const workspaceId = process.env.SAAS_DEFAULT_WORKSPACE_ID || 'demashi';
+const config = loadConfig();
+const client = new ParseRestClient({
+  serverUrl: config.parse.serverUrl,
+  appId: config.parse.appId,
+  masterKey: config.parse.masterKey,
+  timeoutMs: config.parse.timeoutMs,
+});
+
+const [products, sources] = await Promise.all([
+  client.findAll<Row>(VOC_PARSE_CLASSES.product, { workspaceId, role: 'own' }),
+  client.findAll<Row>(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, isCurrent: true }),
+]);
+
+const sourceBySku = new Map<string, { source: Row; payload: Record<string, unknown>; sku: Record<string, unknown> }>();
+for (const source of sources) {
+  const payload = record(source.payload);
+  for (const sku of list(payload.skus).map(record)) {
+    const skuId = text(sku.skuId || sku.id);
+    if (skuId) sourceBySku.set(skuId, { source, payload, sku });
+  }
+}
+
+const updates = products.flatMap((product) => {
+  if (!overwrite && product.rawPayload) return [];
+  const match = sourceBySku.get(text(product.productId));
+  if (!match) return [];
+  const { source, payload, sku } = match;
+  const imageUrls = list(payload.images).map(record).map((image) => text(image.url)).filter(Boolean);
+  const attributes = list(sku.attributes).map(record);
+  const saleAttrs = list(sku.saleAttrs).map(record);
+  const dimensions = record(payload.dimensions);
+  const marketing = record(payload.marketing);
+  const sellingPoints = list(marketing.sellingPoints).map(text).filter(Boolean);
+  const model = attributeValue(attributes, ['型号', 'model']) || text(product.model);
+  const specification = saleAttrs.map((item) => text(item.value || item.attrValues || item.attrValueAlias)).filter(Boolean).join(' / ')
+    || model;
+  const rawPayload = {
+    itemId: text(product.productId),
+    skuId: text(product.productId),
+    productId: text(product.productId),
+    jdProductId: text(payload.productId || source.productId),
+    itemName: text(sku.name || product.title || payload.title),
+    brandName: text(payload.brand || product.brand),
+    model,
+    imageurl: imageUrls[0] || '',
+    mainImages: imageUrls,
+    specName: specification,
+    color: attributeValue(attributes, ['颜色', 'color']),
+    weight: text(dimensions.weight || dimensions.weightKg),
+    length: text(dimensions.length),
+    width: text(dimensions.width),
+    height: text(dimensions.height),
+    shopId: text(payload.shopId),
+    skuStatus: text(sku.status || payload.itemStatus),
+    sellPoint: sellingPoints.join(';'),
+    price: number(sku.price),
+    stock: number(sku.stock),
+    sourceModifiedAt: text(payload.sourceModifiedAt),
+    collectedAt: text(payload.syncedAt || source.updatedAt),
+    source: 'jd-sp-api',
+  };
+  return [{
+    objectId: product.objectId,
+    body: {
+      source: 'jd-sp-api',
+      brand: text(payload.brand || product.brand),
+      title: text(sku.name || product.title || payload.title),
+      model,
+      rawPayload,
+    },
+  }];
+});
+
+console.log(JSON.stringify({
+  mode: apply ? 'apply' : 'dry-run',
+  workspaceId,
+  ownProducts: products.length,
+  listingSources: sources.length,
+  indexedSkus: sourceBySku.size,
+  matchedUpdates: updates.length,
+  unmatched: products.filter((product) => !sourceBySku.has(text(product.productId))).length,
+  skippedExisting: products.filter((product) => Boolean(product.rawPayload) && sourceBySku.has(text(product.productId))).length,
+}, null, 2));
+
+if (apply) {
+  for (let offset = 0; offset < updates.length; offset += 50) {
+    const batch = updates.slice(offset, offset + 50);
+    await writeFmodeBatch(batch.map((item) => ({
+      method: 'PUT',
+      path: `/classes/${VOC_PARSE_CLASSES.product}/${item.objectId}`,
+      body: item.body,
+    })));
+    console.log(`[voc-product-backfill] ${Math.min(offset + batch.length, updates.length)}/${updates.length}`);
+  }
+}
+
+async function writeFmodeBatch(requests: Array<{ method: 'PUT'; path: string; body: unknown }>): Promise<void> {
+  // The managed Fmode mount exposes REST under /backend/{app}/data, while its
+  // batch router expects nested paths relative to /data.
+  const results = await client.request<Array<{ error?: { code?: number; error?: string } }>>('/batch', {
+    method: 'POST',
+    body: { requests: requests.map((request) => ({ ...request, path: `/data${request.path}` })) },
+  });
+  const failure = results.find((result) => result.error)?.error;
+  if (failure) throw new Error(`voc_product_batch_${failure.code ?? 'unknown'}:${failure.error ?? 'failed'}`);
+}
+
+function record(value: unknown): Record<string, unknown> {
+  return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
+}
+function list(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }
+function text(value: unknown): string { return value === null || value === undefined ? '' : String(value).trim(); }
+function number(value: unknown): number | null { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; }
+function attributeValue(attributes: Record<string, unknown>[], names: string[]): string {
+  const expected = names.map((name) => name.toLowerCase());
+  for (const attribute of attributes) {
+    const name = text(attribute.name || attribute.attrName).toLowerCase();
+    if (!expected.some((item) => name.includes(item))) continue;
+    const values = list(attribute.values).map((value) => typeof value === 'object' ? text(record(value).attrValue || record(value).value) : text(value)).filter(Boolean);
+    if (values.length) return values.join(' / ');
+  }
+  return '';
+}

+ 31 - 0
scripts/benchmark-jd-voc-image-review.ts

@@ -0,0 +1,31 @@
+import { FmodeGeminiImageReviewProvider } from '../src/modules/listing-ai/image-review/gemini-image-review.provider.js';
+
+const baseUrl = (process.env.LAB_URL ?? 'http://127.0.0.1:4410').replace(/\/+$/, '');
+const provider = new FmodeGeminiImageReviewProvider({
+  baseUrl: process.env.FMODE_LLM_BASE_URL ?? '', token: process.env.FMODE_LLM_API_KEY ?? '', timeoutMs: 45_000,
+});
+const cases = [
+  ['10020722928820', ['去皮', '削皮', '土豆']],
+  ['10020518260748', ['开水器', '开水机', '烧水']],
+  ['10020422869649', ['消毒柜', '茶具']],
+  ['10020670145859', ['烤箱', '烤炉']],
+  ['10020770480513', ['置物架', '货架', '储物架']],
+] as const;
+const rows: Array<{ productId: string; matched: boolean; facts: number; inferences: number; latencyMs: number; error: string | null }> = [];
+for (const [productId, expected] of cases) {
+  try {
+    const response = await fetch(`${baseUrl}/lab/products/${productId}`, { signal: AbortSignal.timeout(30_000) });
+    if (!response.ok) throw new Error(`source_http_${response.status}`);
+    const payload = await response.json() as { source?: { images?: Array<{ url?: string }>; title?: string } };
+    const source = payload.source;
+    const urls = (source?.images ?? []).map((image) => image.url ?? '').filter(Boolean).slice(0, 3);
+    if (!source || !urls.length) throw new Error('image_asset_missing');
+    const result = await provider.analyze(urls, source as never);
+    const haystack = result.visibleFacts.map((fact) => `${fact.field} ${fact.value}`).join(' ');
+    rows.push({ productId, matched: expected.some((term) => haystack.includes(term)), facts: result.visibleFacts.length, inferences: result.visualInferences.length, latencyMs: result.latencyMs, error: null });
+  } catch (error) { rows.push({ productId, matched: false, facts: 0, inferences: 0, latencyMs: 0, error: error instanceof Error ? error.message.slice(0, 80) : 'image_review_failed' }); }
+}
+const matched = rows.filter((row) => row.matched).length;
+const report = { generatedAt: new Date().toISOString(), model: 'gemini-3.1-flash-image-preview', requested: rows.length, completed: rows.filter((row) => row.error === null).length, matched, accuracy: matched / rows.length, averageMs: rows.filter((row) => row.error === null).reduce((sum, row) => sum + row.latencyMs, 0) / Math.max(1, rows.filter((row) => row.error === null).length), rows: rows.map(({ productId, matched: rowMatched, facts, inferences, latencyMs, error }) => ({ productId, matched: rowMatched, facts, inferences, latencyMs, error })) };
+console.log(JSON.stringify(report, null, 2));
+if (rows.some((row) => row.error !== null)) process.exitCode = 2;

+ 51 - 0
scripts/benchmark-jd-voc-migration.ts

@@ -0,0 +1,51 @@
+import { pathToFileURL } from 'node:url';
+import { resolve } from 'node:path';
+import type { JdVocRuleContext, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
+
+const labUrl = (process.env.LAB_URL ?? 'http://127.0.0.1:4410').replace(/\/+$/, '');
+const limit = Math.max(1, Math.min(100, Number(process.env.BENCHMARK_LIMIT ?? 100)));
+const originalUrl = pathToFileURL(resolve(process.cwd(), '..', 'listing评分规则测试', 'public', 'rubric-engine.js')).href;
+const original = await import(originalUrl) as {
+  normalizeListing(input: unknown): unknown;
+  buildVocConcerns(input: unknown): unknown;
+  scoreListing(input: unknown, concerns: unknown): { observableScore: number | null; coverage: number; dimensions: Array<{ key: string; score: number | null; coverage: number }> };
+};
+
+async function json<T>(url: string): Promise<T> {
+  const response = await fetch(url, { signal: AbortSignal.timeout(90_000) });
+  if (!response.ok) throw new Error(`http_${response.status}`);
+  return response.json() as Promise<T>;
+}
+
+const catalog = await json<{ items: Array<{ productId: string }> }>(`${labUrl}/lab/catalog?limit=${limit}`);
+const rows: Array<{ productId: string; scoreDelta: number | null; coverageDelta: number; dimensionMismatches: number; error: string | null }> = [];
+for (const item of catalog.items.slice(0, limit)) {
+  try {
+    const detail = await json<{ source: ListingSourceSnapshot; reviews?: Array<{ id?: string; rating?: number; content?: string; date?: string }>; competitors?: JdVocRuleContext['competitors']; competitorBasis?: string }>(`${labUrl}/lab/products/${encodeURIComponent(item.productId)}`);
+    const listing = original.normalizeListing(detail);
+    const expected = original.scoreListing(listing, original.buildVocConcerns(listing));
+    const context: JdVocRuleContext = {
+      productReviews: (detail.reviews ?? []).map((review, index) => ({ id: review.id ?? `review-${index + 1}`, source: 'product-review', text: review.content ?? '', rating: review.rating ?? null, observedAt: review.date ?? null })),
+      categoryVocEvidence: (detail.source.vocEvidence ?? []).map((evidence) => ({ id: evidence.id, source: 'category-voc', text: evidence.text, observedAt: evidence.collectedAt })),
+      competitors: detail.competitors ?? [],
+      competitorBasis: detail.competitorBasis === 'category-inferred' ? 'category-inferred' : detail.competitors?.length ? 'formal' : 'none',
+    };
+    const actual = scoreJdVocRules(detail.source, context, { id: 'benchmark', now: '2026-09-05T00:00:00.000Z' });
+    const expectedByKey = new Map(expected.dimensions.map((dimension) => [dimension.key, dimension]));
+    const dimensionMismatches = actual.dimensions.filter((dimension) => {
+      const target = expectedByKey.get(dimension.key);
+      return !target || target.score !== dimension.score || target.coverage !== dimension.coverage;
+    }).length;
+    rows.push({ productId: item.productId, scoreDelta: expected.observableScore === null || actual.overallScore === null ? expected.observableScore === actual.overallScore ? 0 : null : Math.round((actual.overallScore - expected.observableScore) * 10) / 10, coverageDelta: Math.round((actual.coverage.percent - expected.coverage) * 10) / 10, dimensionMismatches, error: null });
+  } catch (error) { rows.push({ productId: item.productId, scoreDelta: null, coverageDelta: 0, dimensionMismatches: 6, error: error instanceof Error ? error.message.slice(0, 120) : 'benchmark_failed' }); }
+}
+const report = {
+  rubricVersion: 'jd-voc-v0.5', requested: rows.length, completed: rows.filter((row) => !row.error).length,
+  exactScoreMatches: rows.filter((row) => row.scoreDelta === 0).length,
+  exactCoverageMatches: rows.filter((row) => row.coverageDelta === 0).length,
+  rowsWithDimensionMismatch: rows.filter((row) => row.dimensionMismatches > 0).length,
+  failed: rows.filter((row) => row.error).length,
+};
+console.log(JSON.stringify(report, null, 2));
+if (report.failed || report.exactScoreMatches !== report.requested || report.exactCoverageMatches !== report.requested || report.rowsWithDimensionMismatch) process.exitCode = 2;

+ 178 - 0
scripts/bootstrap-managed-user.mjs

@@ -0,0 +1,178 @@
+import 'dotenv/config';
+import { randomUUID } from 'node:crypto';
+
+const required = ['PARSE_SERVER_URL', 'PARSE_APP_ID', 'PARSE_MASTER_KEY'];
+for (const name of required) if (!process.env[name]?.trim()) throw new Error(`${name} is required`);
+
+const serverUrl = process.env.PARSE_SERVER_URL.replace(/\/+$/, '');
+const appId = process.env.PARSE_APP_ID.trim();
+const masterKey = process.env.PARSE_MASTER_KEY.trim();
+const username = (process.env.SAAS_BOOTSTRAP_USERNAME || '').trim();
+const password = process.env.SAAS_BOOTSTRAP_PASSWORD || '';
+const workspaceId = (process.env.SAAS_DEFAULT_WORKSPACE_ID || 'demashi').trim();
+const email = (process.env.SAAS_BOOTSTRAP_USER_EMAIL || '').trim();
+const displayName = (process.env.SAAS_BOOTSTRAP_USER_NAME || username).trim();
+const role = (process.env.SAAS_BOOTSTRAP_USER_ROLE || 'owner').trim();
+
+if (username.length < 2) throw new Error('SAAS_BOOTSTRAP_USERNAME must contain at least 2 characters');
+if (password.length < 12) throw new Error('SAAS_BOOTSTRAP_PASSWORD must contain at least 12 characters');
+if (!['owner', 'admin', 'editor', 'viewer'].includes(role)) throw new Error('SAAS_BOOTSTRAP_USER_ROLE is invalid');
+
+const masterHeaders = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+
+async function jsonRequest(url, options = {}) {
+  const response = await fetch(url, options);
+  const body = await response.json().catch(() => ({}));
+  if (!response.ok || body.error) {
+    throw new Error(`${options.method || 'GET'} ${new URL(url).pathname} failed: HTTP ${response.status}, code ${body.code || 'unknown'}`);
+  }
+  return body;
+}
+
+const userQuery = new URL(`${serverUrl}/users`);
+userQuery.searchParams.set('where', JSON.stringify({ username }));
+userQuery.searchParams.set('limit', '1');
+let user = (await jsonRequest(userQuery, { headers: masterHeaders })).results?.[0];
+let userCreated = false;
+if (!user) {
+  let created;
+  try {
+    created = await jsonRequest(`${serverUrl}/users`, {
+      method: 'POST',
+      headers: masterHeaders,
+      body: JSON.stringify({ username, password, ...(email ? { email } : {}) }),
+    });
+  } catch (error) {
+    // Some managed Fmode Parse mounts return a generic error from REST user
+    // creation while Parse.User.signUp remains available inside Functions.
+    // Use a random, one-shot Function and remove it in a finally block.
+    created = await createUserThroughEphemeralFunction();
+  }
+  user = { objectId: created.objectId };
+  userCreated = true;
+}
+
+const naturalKey = `${workspaceId}:${user.objectId}`;
+const memberQuery = new URL(`${serverUrl}/classes/VocWorkspaceMember`);
+memberQuery.searchParams.set('where', JSON.stringify({ naturalKey }));
+memberQuery.searchParams.set('limit', '1');
+const existingMember = (await jsonRequest(memberQuery, { headers: masterHeaders })).results?.[0];
+const memberBody = {
+  naturalKey,
+  workspaceId,
+  userId: user.objectId,
+  ...(email ? { email } : {}),
+  displayName,
+  role,
+  status: 'active',
+};
+const memberResult = await jsonRequest(
+  existingMember
+    ? `${serverUrl}/classes/VocWorkspaceMember/${existingMember.objectId}`
+    : `${serverUrl}/classes/VocWorkspaceMember`,
+  {
+    method: existingMember ? 'PUT' : 'POST',
+    headers: masterHeaders,
+    body: JSON.stringify(memberBody),
+  },
+);
+
+const login = await jsonRequest(`${serverUrl}/login`, {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId },
+  body: JSON.stringify({ username, password }),
+});
+const functionUrl = new URL(`${serverUrl}/../api/functions`).href;
+const acceptance = await jsonRequest(functionUrl, {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+    'X-Parse-Application-Id': appId,
+    'X-Parse-Session-Token': login.sessionToken,
+  },
+  body: JSON.stringify({
+    path: '/saas-voc-gateway',
+    params: { action: 'context.get', workspaceId },
+    _ApplicationId: appId,
+    _SessionToken: login.sessionToken,
+  }),
+});
+
+if (acceptance.success !== true || !acceptance.data?.workspaces?.length) {
+  throw new Error('Managed Function authenticated acceptance failed');
+}
+
+console.log(JSON.stringify({
+  ok: true,
+  username,
+  userId: user.objectId,
+  userCreated,
+  membershipId: existingMember?.objectId || memberResult.objectId,
+  workspaceId,
+  role,
+  cloudFunctionAuthenticated: true,
+}, null, 2));
+
+async function createUserThroughEphemeralFunction() {
+  const registryServerUrl = (process.env.FUNCTION_REGISTRY_SERVER_URL || serverUrl).replace(/\/+$/, '');
+  const registryAppId = (process.env.FUNCTION_REGISTRY_APP_ID || appId).trim();
+  const registryMasterKey = (process.env.FUNCTION_REGISTRY_MASTER_KEY || masterKey).trim();
+  const registryHeaders = {
+    'Content-Type': 'application/json',
+    'X-Parse-Application-Id': registryAppId,
+    'X-Parse-Master-Key': registryMasterKey,
+  };
+  const nonce = randomUUID();
+  const path = `/saas-voc-user-bootstrap-${randomUUID()}`;
+  const code = `
+async function handler(request, response) {
+  const input = request.body && request.body.params || {};
+  if (input.nonce !== ${JSON.stringify(nonce)}) return response.status(404).json({ error: 'not_found' });
+  const query = new Parse.Query(Parse.User);
+  query.equalTo('username', input.username);
+  let user = await query.first({ useMasterKey: true });
+  if (!user) {
+    user = new Parse.User();
+    user.set('username', input.username);
+    user.set('password', input.password);
+    if (input.email) user.set('email', input.email);
+    await user.signUp(null, { useMasterKey: true });
+  }
+  return response.json({ success: true, data: { objectId: user.id } });
+}`;
+  const record = await jsonRequest(`${registryServerUrl}/classes/Function`, {
+    method: 'POST',
+    headers: registryHeaders,
+    body: JSON.stringify({
+      name: `saasVocUserBootstrap${Date.now()}`,
+      desc: 'Ephemeral first-user bootstrap; delete after one invocation',
+      type: 'standalone',
+      path,
+      paramList: [{ name: 'params', type: 'Object', required: true }],
+      code,
+      respType: 'json',
+      respJson: { success: true, data: null },
+      isDeleted: false,
+    }),
+  });
+  try {
+    const functionUrl = new URL(`${registryServerUrl}/../api/functions`).href;
+    const result = await jsonRequest(functionUrl, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': registryAppId },
+      body: JSON.stringify({
+        path,
+        params: { nonce, username, password, ...(email ? { email } : {}) },
+        _ApplicationId: registryAppId,
+      }),
+    });
+    if (result.success !== true || !result.data?.objectId) throw new Error('Ephemeral user bootstrap did not return a user');
+    return result.data;
+  } finally {
+    await fetch(`${registryServerUrl}/classes/Function/${record.objectId}`, { method: 'DELETE', headers: registryHeaders }).catch(() => undefined);
+  }
+}

+ 38 - 0
scripts/build-cloud-functions.mjs

@@ -0,0 +1,38 @@
+/**
+ * Cloud Function Builder
+ * Copies pre-inlined cloud function files from cloud-functions/ to cloud-functions/.build/
+ * The vXxx.js files already have all shared code inlined - they are ready to deploy.
+ */
+import { readFile, writeFile, mkdir } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const cloudFunctionsDir = resolve(__dirname, '..', 'cloud-functions');
+const outputDir = resolve(cloudFunctionsDir, '.build');
+
+const CLOUD_FUNCTIONS = [
+  'vContext.js', 'vDomesticVoc.js', 'vSync.js', 'vCompetitor.js',
+  'vListing.js', 'vListingJob.js', 'vAnalysis.js', 'vActions.js',
+  'vKnowledge.js', 'vAI.js', 'vUpstream.js',
+];
+
+async function main() {
+  console.log('Building self-contained cloud functions...\n');
+  await mkdir(outputDir, { recursive: true });
+
+  for (const filename of CLOUD_FUNCTIONS) {
+    const sourcePath = resolve(cloudFunctionsDir, filename);
+    const outPath = resolve(outputDir, filename);
+    const code = await readFile(sourcePath, 'utf8');
+    await writeFile(outPath, code, 'utf8');
+    console.log(`  ✓ ${filename} (${(code.length / 1024).toFixed(0)}KB)`);
+  }
+
+  console.log(`\nOutput: ${outputDir}`);
+}
+
+main().catch((error) => {
+  console.error('Build failed:', error);
+  process.exitCode = 1;
+});

+ 110 - 0
scripts/deploy-cloud-function.mjs

@@ -0,0 +1,110 @@
+import 'dotenv/config';
+import { readFile } from 'node:fs/promises';
+import { randomUUID } from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const required = ['PARSE_SERVER_URL', 'PARSE_APP_ID', 'PARSE_MASTER_KEY'];
+for (const name of required) if (!process.env[name]?.trim()) throw new Error(`${name} is required`);
+const registryBase = (process.env.FUNCTION_REGISTRY_SERVER_URL || process.env.PARSE_SERVER_URL).replace(/\/+$/, '');
+const registryAppId = process.env.FUNCTION_REGISTRY_APP_ID || process.env.PARSE_APP_ID;
+const registryMasterKey = process.env.FUNCTION_REGISTRY_MASTER_KEY || process.env.PARSE_MASTER_KEY;
+const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': registryAppId, 'X-Parse-Master-Key': registryMasterKey };
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const cloudFunctionsDir = resolve(__dirname, '..', 'cloud-functions');
+
+// Cloud function definitions
+const CLOUD_FUNCTIONS = [
+  { name: 'vContext', path: '/v-context', file: 'vContext.js', desc: 'SaaS VOC context and workspace management' },
+  { name: 'vDomesticVoc', path: '/v-domestic-voc', file: 'vDomesticVoc.js', desc: 'SaaS VOC domestic VOC data' },
+  { name: 'vSync', path: '/v-sync', file: 'vSync.js', desc: 'SaaS VOC sync job management' },
+  { name: 'vCompetitor', path: '/v-competitor', file: 'vCompetitor.js', desc: 'SaaS VOC competitor monitor' },
+  { name: 'vListing', path: '/v-listing', file: 'vListing.js', desc: 'SaaS VOC listing scores' },
+  { name: 'vListingJob', path: '/v-listing-job', file: 'vListingJob.js', desc: 'SaaS VOC listing score jobs' },
+  { name: 'vAnalysis', path: '/v-analysis', file: 'vAnalysis.js', desc: 'SaaS VOC analysis and insights' },
+  { name: 'vActions', path: '/v-actions', file: 'vActions.js', desc: 'SaaS VOC actions and alerts' },
+  { name: 'vKnowledge', path: '/v-knowledge', file: 'vKnowledge.js', desc: 'SaaS VOC product knowledge' },
+  { name: 'vAI', path: '/v-ai', file: 'vAI.js', desc: 'SaaS VOC AI gateway' },
+  { name: 'vUpstream', path: '/v-upstream', file: 'vUpstream.js', desc: 'SaaS VOC upstream API proxy' },
+];
+
+async function deployFunction(func) {
+  const filePath = resolve(cloudFunctionsDir, func.file);
+  const code = await readFile(filePath, 'utf8');
+
+  const query = new URL(`${registryBase}/classes/Function`);
+  query.searchParams.set('where', JSON.stringify({ path: func.path }));
+  const existingResponse = await fetch(query, { headers });
+  if (!existingResponse.ok) throw new Error(`Function lookup failed for ${func.name}: HTTP ${existingResponse.status}`);
+  const existing = await existingResponse.json();
+
+  const payload = {
+    name: func.name,
+    desc: func.desc,
+    type: 'standalone',
+    path: func.path,
+    paramList: [{ name: 'params', type: 'Object', required: true }],
+    code,
+    respType: 'json',
+    respJson: { success: true, data: null, requestId: randomUUID() },
+    isDeleted: false,
+  };
+
+  const target = existing.results?.[0];
+  const url = target
+    ? `${registryBase}/classes/Function/${target.objectId}`
+    : `${registryBase}/classes/Function`;
+  const method = target ? 'PUT' : 'POST';
+
+  const response = await fetch(url, {
+    method,
+    headers,
+    body: JSON.stringify(payload),
+  });
+
+  if (!response.ok) {
+    const errorText = await response.text();
+    throw new Error(`Function deployment failed for ${func.name}: HTTP ${response.status} - ${errorText}`);
+  }
+
+  const result = await response.json();
+  return {
+    ok: true,
+    name: func.name,
+    path: func.path,
+    objectId: target?.objectId || result.objectId,
+    action: target ? 'updated' : 'created',
+  };
+}
+
+async function main() {
+  console.log(`Deploying ${CLOUD_FUNCTIONS.length} cloud functions...`);
+  const results = [];
+
+  for (const func of CLOUD_FUNCTIONS) {
+    try {
+      const result = await deployFunction(func);
+      console.log(`✓ ${result.name} (${result.action}): ${result.objectId}`);
+      results.push(result);
+    } catch (error) {
+      console.error(`✗ ${func.name}: ${error.message}`);
+      results.push({ ok: false, name: func.name, error: error.message });
+    }
+  }
+
+  const failed = results.filter((r) => !r.ok);
+  if (failed.length > 0) {
+    console.error(`\n${failed.length} function(s) failed to deploy`);
+    process.exit(1);
+  }
+
+  console.log(`\nAll ${results.length} cloud functions deployed successfully!`);
+  console.log(`\nCloud function paths:`);
+  results.forEach((r) => console.log(`  ${r.name}: ${r.path}`));
+}
+
+main().catch((error) => {
+  console.error('Deployment failed:', error);
+  process.exitCode = 1;
+});

+ 73 - 0
scripts/migrate-jd-voc-score-slots.ts

@@ -0,0 +1,73 @@
+import 'dotenv/config';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { loadConfig } from '../src/config/env.js';
+import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+import { FmodeAiClient } from '../src/modules/ai-gateway/client.js';
+import { FmodeJdVocAiScoringProvider, ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js';
+import { FmodeGeminiImageReviewProvider } from '../src/modules/listing-ai/image-review/gemini-image-review.provider.js';
+import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
+
+const args = new Set(process.argv.slice(2));
+const value = (prefix: string, fallback: string) => process.argv.slice(2).find((item) => item.startsWith(`${prefix}=`))?.slice(prefix.length + 1) ?? fallback;
+const apply = args.has('--apply');
+const includeAi = args.has('--ai');
+const includeImages = args.has('--images');
+const retryFailed = args.has('--retry-failed');
+const rollbackCheck = args.has('--rollback-check');
+const limit = Math.max(1, Math.min(625, Number(value('--limit', '100')) || 100));
+const cohort = value('--cohort', 'listing-jd-v3-formal-625');
+if (cohort !== 'listing-jd-v3-formal-625') throw new Error('unsupported_jd_voc_cohort');
+
+const config = loadConfig();
+if (config.storageDriver !== 'parse_rest') throw new Error('jd_voc_migration_requires_parse_rest');
+const client = new ParseRestClient({ serverUrl: config.parse.serverUrl, appId: config.parse.appId, masterKey: config.parse.masterKey, timeoutMs: config.parse.timeoutMs });
+const repository = new ParseRestListingAiRepository(client);
+const aiClient = new FmodeAiClient({ ...config.ai, timeoutMs: Math.min(config.ai.timeoutMs, 45_000) });
+const aiProvider = includeAi ? new FmodeJdVocAiScoringProvider(aiClient, config.listingAi.jdVocAiModel) : undefined;
+const imageProvider = includeImages ? new FmodeGeminiImageReviewProvider({ baseUrl: process.env.FMODE_LLM_BASE_URL ?? config.ai.baseUrl, token: process.env.FMODE_LLM_API_KEY ?? config.ai.token, timeoutMs: 45_000 }) : undefined;
+const service = new ListingAiService(repository, undefined, () => new Date(), Math.min(config.listingAi.concurrency, 4), config.listingAi.maxAiItemsPerJob, aiProvider, imageProvider, config.listingAi.jdVocDisplayDefault, true);
+if (rollbackCheck) {
+  const [legacy,jdVoc]=await Promise.all([repository.listCurrentScores(config.auth.defaultWorkspaceId),repository.listJdVocScores(config.auth.defaultWorkspaceId)]);
+  console.log(JSON.stringify({mode:'rollback-check',workspaceId:config.auth.defaultWorkspaceId,legacyRows:legacy.length,jdVocRows:jdVoc.length,legacyReadable:legacy.length>0,jdVocRules:jdVoc.filter((item)=>item.scoreKind==='jd_voc_rules').length,jdVocHybrid:jdVoc.filter((item)=>item.scoreKind==='jd_voc_hybrid_ai').length,destructiveActions:0},null,2));
+  process.exit(legacy.length>0?0:2);
+}
+const sources = (await repository.listAllSources(config.auth.defaultWorkspaceId, 'jd')).slice(0, limit);
+const rows: Array<{ productId: string; sourceHash: string; executionKey: string | null; rules: string; ai: string; image: string; error: string | null }> = [];
+
+for (const source of sources) {
+  try {
+    const context = await repository.getJdVocRuleContext(source.workspaceId, source.platform, source.productId);
+    const preview = scoreJdVocRules(source, context, { now: new Date().toISOString() });
+    if (preview.sourceHash !== source.sourceHash) throw new Error('source_hash_mismatch');
+    if (!apply) {
+      rows.push({ productId: source.productId, sourceHash: source.sourceHash, executionKey: preview.executionKey, rules: 'dry-run', ai: includeAi ? 'dry-run' : 'skipped', image: includeImages ? 'dry-run' : 'skipped', error: null });
+      continue;
+    }
+    const rules = await service.scoreJdVocRules({ workspaceId: source.workspaceId, platform: source.platform, productId: source.productId, context, force: retryFailed });
+    let ai = 'skipped'; let image = 'skipped'; let error: string | null = null;
+    if (includeAi) {
+      const result = await service.scoreJdVocWithAi({ workspaceId: source.workspaceId, platform: source.platform, productId: source.productId, context, force: retryFailed });
+      ai = result.hybrid ? 'completed' : result.errorCode ? 'failed' : 'blocked';
+      error = result.errorCode;
+    }
+    if (includeImages) {
+      const result = await service.reviewJdVocImages({ workspaceId: source.workspaceId, platform: source.platform, productId: source.productId, force: retryFailed });
+      image = result.imageReview?.status ?? 'failed';
+    }
+    rows.push({ productId: source.productId, sourceHash: source.sourceHash, executionKey: rules.executionKey, rules: 'completed', ai, image, error });
+  } catch (error) {
+    rows.push({ productId: source.productId, sourceHash: source.sourceHash, executionKey: null, rules: 'failed', ai: 'skipped', image: 'skipped', error: error instanceof Error ? error.message.slice(0, 120) : 'migration_failed' });
+  }
+}
+
+const report = {
+  mode: apply ? 'apply' : 'dry-run', cohort, requested: limit, resolved: sources.length,
+  rulesCompleted: rows.filter((row) => row.rules === 'completed' || row.rules === 'dry-run').length,
+  aiCompleted: rows.filter((row) => row.ai === 'completed').length,
+  imageCompleted: rows.filter((row) => row.image === 'shadow_completed').length,
+  failed: rows.filter((row) => row.error).length,
+  rubricVersion: 'jd-voc-v0.5', slots: ['jd_voc_rules', 'jd_voc_hybrid_ai'],
+  samples: rows.slice(0, 5).map((row) => ({ productId: row.productId, sourceHash: row.sourceHash, executionKey: row.executionKey, rules: row.rules, ai: row.ai, image: row.image, error: row.error })),
+};
+console.log(JSON.stringify(report, null, 2));
+if (report.resolved !== report.requested || report.failed) process.exitCode = 2;

+ 71 - 0
scripts/recover-all.mjs

@@ -0,0 +1,71 @@
+/**
+ * Recovery script: deploy the original saas-voc-gateway.js to restore cloud functions.
+ * The gateway is a single consolidated function that handles all actions via routing.
+ */
+import 'dotenv/config';
+import { readFile } from 'node:fs/promises';
+import { randomUUID } from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const required = ['PARSE_SERVER_URL', 'PARSE_APP_ID', 'PARSE_MASTER_KEY'];
+for (const name of required) if (!process.env[name]?.trim()) throw new Error(`${name} is required`);
+const registryBase = (process.env.FUNCTION_REGISTRY_SERVER_URL || process.env.PARSE_SERVER_URL).replace(/\/+$/, '');
+const registryAppId = process.env.FUNCTION_REGISTRY_APP_ID || process.env.PARSE_APP_ID;
+const registryMasterKey = process.env.FUNCTION_REGISTRY_MASTER_KEY || process.env.PARSE_MASTER_KEY;
+const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': registryAppId, 'X-Parse-Master-Key': registryMasterKey };
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// Each ObjectId -> function name, path, description
+const FUNCTIONS = [
+  { objectId: 'BzgrlznLbV', name: 'vContext',        path: '/v-context',       desc: 'Context & Workspace actions' },
+  { objectId: 'Yp1RkzNmXK', name: 'vDomesticVoc',    path: '/v-domestic-voc',  desc: 'Domestic VOC, data-source, import, audit' },
+  { objectId: 'Hd4FmnOpQR', name: 'vSync',           path: '/v-sync',          desc: 'Sync enqueue, jobs, retry, cancel' },
+  { objectId: 'Tk7WvXyZAb', name: 'vCompetitor',     path: '/v-competitor',     desc: 'Competitor overview, refresh, history, alerts, tasks' },
+  { objectId: 'Qm2BnDeFgH', name: 'vListing',        path: '/v-listing',       desc: 'Listing product, overview, products' },
+  { objectId: 'Lr3CsEfGhI', name: 'vListingJob',     path: '/v-listing-job',   desc: 'Listing score-job, version' },
+  { objectId: 'Nv4DtGhIjK', name: 'vAnalysis',       path: '/v-analysis',      desc: 'Analysis, insight-decision' },
+  { objectId: 'Pw5EuHiJkL', name: 'vActions',        path: '/v-actions',       desc: 'Action, alert CRUD' },
+  { objectId: 'Qx6FvIjKlM', name: 'vKnowledge',      path: '/v-knowledge',    desc: 'Knowledge products' },
+  { objectId: 'Ry7GwJkLmN', name: 'vAI',            path: '/v-ai',           desc: 'AI status, prompts, chat' },
+  { objectId: 'Sz8HxKlMnO', name: 'vUpstream',      path: '/v-upstream',     desc: 'Upstream Amazon, Sorftime, Tikhub, Domestic' },
+];
+
+async function deployFunction(fn) {
+  const code = await readFile(resolve(__dirname, '..', 'cloud-functions', 'saas-voc-gateway.js'), 'utf8');
+  const url = `${registryBase}/classes/Function/${fn.objectId}`;
+  const payload = {
+    name: fn.name,
+    desc: fn.desc,
+    type: 'standalone',
+    path: fn.path,
+    paramList: [{ name: 'params', type: 'Object', required: true }],
+    code,
+    respType: 'json',
+    respJson: { success: true, data: null, requestId: randomUUID() },
+    isDeleted: false,
+  };
+  const response = await fetch(url, { method: 'PUT', headers, body: JSON.stringify(payload) });
+  if (!response.ok) {
+    const errorText = await response.text();
+    throw new Error(`Deploy ${fn.name} failed: HTTP ${response.status} - ${errorText}`);
+  }
+  const result = await response.json();
+  return { ok: true, objectId: result.objectId };
+}
+
+async function main() {
+  console.log('Recovering all cloud functions with original gateway...\n');
+  for (const fn of FUNCTIONS) {
+    try {
+      const result = await deployFunction(fn);
+      console.log(`  ✓ ${fn.name} (${fn.objectId})`);
+    } catch (err) {
+      console.log(`  ✗ ${fn.name}: ${err.message}`);
+    }
+  }
+  console.log('\nDone. Test with: POST to /api/functions with {"id":"<objectId>","params":{"action":"..."}}');
+}
+
+main();

+ 67 - 0
scripts/recover-gateway.mjs

@@ -0,0 +1,67 @@
+import 'dotenv/config';
+import { readFile } from 'node:fs/promises';
+import { randomUUID } from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const required = ['PARSE_SERVER_URL', 'PARSE_APP_ID', 'PARSE_MASTER_KEY'];
+for (const name of required) if (!process.env[name]?.trim()) throw new Error(`${name} is required`);
+const registryBase = (process.env.FUNCTION_REGISTRY_SERVER_URL || process.env.PARSE_SERVER_URL).replace(/\/+$/, '');
+const registryAppId = process.env.FUNCTION_REGISTRY_APP_ID || process.env.PARSE_APP_ID;
+const registryMasterKey = process.env.FUNCTION_REGISTRY_MASTER_KEY || process.env.PARSE_MASTER_KEY;
+const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': registryAppId, 'X-Parse-Master-Key': registryMasterKey };
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// Deploy only the original consolidated gateway
+const FUNCTION = {
+  name: 'vSaaSVocGateway',
+  path: '/saas-voc-gateway',
+  file: 'saas-voc-gateway.js',
+  desc: 'SaaS VOC allow-listed Cloud Function gateway',
+  objectId: 'ZLDvkbQ16D',
+};
+
+async function deployFunction() {
+  const filePath = resolve(__dirname, '..', 'cloud-functions', FUNCTION.file);
+  const code = await readFile(filePath, 'utf8');
+
+  const url = `${registryBase}/classes/Function/${FUNCTION.objectId}`;
+  const payload = {
+    name: FUNCTION.name,
+    desc: FUNCTION.desc,
+    type: 'standalone',
+    path: FUNCTION.path,
+    paramList: [{ name: 'params', type: 'Object', required: true }],
+    code,
+    respType: 'json',
+    respJson: { success: true, data: null, requestId: randomUUID() },
+    isDeleted: false,
+  };
+
+  const response = await fetch(url, {
+    method: 'PUT',
+    headers,
+    body: JSON.stringify(payload),
+  });
+
+  if (!response.ok) {
+    const errorText = await response.text();
+    throw new Error(`Function deployment failed: HTTP ${response.status} - ${errorText}`);
+  }
+
+  const result = await response.json();
+  return { ok: true, objectId: result.objectId };
+}
+
+async function main() {
+  console.log(`Deploying ${FUNCTION.name}...`);
+  const result = await deployFunction();
+  console.log(`✓ ${FUNCTION.name} (${result.objectId})`);
+  console.log(`Path: ${FUNCTION.path}`);
+}
+
+main().catch((error) => {
+  console.error('Deployment failed:', error);
+  process.exitCode = 1;
+});

+ 68 - 0
scripts/score-jd-voc-codex.ts

@@ -0,0 +1,68 @@
+import 'dotenv/config';
+import { mkdir, writeFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { loadConfig } from '../src/config/env.js';
+import { ParseRestClient } from '../src/db/parse-rest.client.js';
+import type { JdVocAiScoreOutput, } from '../src/modules/listing-ai/scoring/jd-voc-ai-rubric.js';
+import type { JdVocDimensionKey, JdVocScoreResult } from '../src/modules/listing-ai/domain.js';
+import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+import { composeJdVocHybridScore } from '../src/modules/listing-ai/scoring/jd-voc-ai-rubric.js';
+import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
+
+const EXPECTED = 625;
+const MODEL = 'codex-jd-voc-evaluator-v1';
+const SEMANTIC_DIMENSIONS: readonly Exclude<JdVocDimensionKey,'media'>[] = ['search','voc','selling','facts','competitive'];
+const LEVEL_FACTOR = { strong:1, pass:.9, weak:.55, fail:.2, unknown:0 } as const;
+const args = new Map(process.argv.slice(2).map((arg)=>{const [key,...rest]=arg.split('=');return[key!,rest.join('=')||'true'];}));
+const apply = args.get('--apply') === 'true';
+const concurrency = Math.max(1,Math.min(12,Number(args.get('--concurrency')??6)));
+
+function judge(baseline:JdVocScoreResult):JdVocAiScoreOutput{
+  const assessments = SEMANTIC_DIMENSIONS.map((key)=>{
+    const dimension=baseline.dimensions.find((item)=>item.key===key)!;
+    const available=dimension.evidence.filter((item)=>item.outcome!=='unknown'&&item.maxPoints>0);
+    const verifiedMax=available.reduce((sum,item)=>sum+item.maxPoints,0);
+    const semanticEarned=available.reduce((sum,item)=>sum+item.maxPoints*LEVEL_FACTOR[item.level??(item.outcome==='pass'?'pass':'fail')],0);
+    const score=verifiedMax?Math.round(semanticEarned/verifiedMax*dimension.maxScore*10)/10:0;
+    const evidenceIds=available.sort((a,b)=>b.maxPoints-a.maxPoints||a.ruleId.localeCompare(b.ruleId)).slice(0,3).map((item)=>item.id);
+    const counts={pass:available.filter((item)=>item.outcome==='pass').length,weak:available.filter((item)=>item.level==='weak').length,fail:available.filter((item)=>item.outcome==='fail'&&item.level!=='weak').length};
+    return{dimension:key,score:evidenceIds.length?score:0,evidenceIds,rationale:`按固定证据校准:${counts.pass}项通过、${counts.weak}项部分满足、${counts.fail}项失败;仅使用该维度规则证据。`,confidence:Math.round(dimension.coverage)/100};
+  });
+  const suggestions=baseline.actions.filter((item)=>item.dimension!=='media'&&item.evidenceIds.length).slice(0,12).map((item)=>({title:item.title,action:item.action,evidenceIds:item.evidenceIds}));
+  return{assessments,suggestions,summary:'Codex 按 jd-voc-v0.5 固定证据等级校准五个文本语义维度;media 保持规则分,未使用外部知识。',latencyMs:0};
+}
+
+async function concurrent<T>(items:T[],worker:(item:T,index:number)=>Promise<void>):Promise<void>{let cursor=0;await Promise.all(Array.from({length:Math.min(concurrency,items.length)},async()=>{while(cursor<items.length){const index=cursor++;await worker(items[index]!,index);}}));}
+
+const config=loadConfig();
+if(config.storageDriver!=='parse_rest')throw new Error('codex_jd_voc_requires_parse_rest');
+const client=new ParseRestClient({serverUrl:config.parse.serverUrl,appId:config.parse.appId,masterKey:config.parse.masterKey,timeoutMs:config.parse.timeoutMs});
+const repository=new ParseRestListingAiRepository(client);
+const sources=await repository.listAllSources(config.auth.defaultWorkspaceId,'jd');
+if(sources.length!==EXPECTED)throw new Error(`source_count_mismatch:${sources.length}:${EXPECTED}`);
+const existing=await repository.listJdVocScores(config.auth.defaultWorkspaceId);
+const existingRules=new Map(existing.filter((item)=>item.scoreKind==='jd_voc_rules').map((item)=>[item.productId,item]));
+const results:Array<{rules:JdVocScoreResult;hybrid:JdVocScoreResult}>=new Array(sources.length);
+await concurrent(sources,async(source,index)=>{
+  const context=await repository.getJdVocRuleContext(source.workspaceId,source.platform,source.productId);
+  let rules=scoreJdVocRules(source,context,{now:new Date().toISOString()});
+  const previous=existingRules.get(source.productId);
+  if(previous?.sourceHash===source.sourceHash&&previous.imageReview?.status==='shadow_completed')rules={...rules,imageReview:previous.imageReview};
+  const hybrid=composeJdVocHybridScore({baseline:rules,output:judge(rules),model:MODEL,source,now:new Date().toISOString()});
+  results[index]={rules,hybrid};
+});
+const invalid=results.filter(({hybrid})=>hybrid.dimensions.length!==6||hybrid.aiReview?.assessments.length!==5||hybrid.aiReview.assessments.some((item)=>(item.score??0)>0&&!item.evidenceIds.length));
+if(invalid.length)throw new Error(`invalid_codex_results:${invalid.length}`);
+const summary={mode:apply?'apply':'dry-run',model:MODEL,requested:EXPECTED,completed:results.length,invalid:invalid.length,rules:results.length,hybrid:results.length,blocked:results.filter(({rules})=>rules.coverage.status==='blocked').length,partial:results.filter(({hybrid})=>hybrid.coverage.status==='partial').length,averageHybrid:Math.round(results.reduce((sum,{hybrid})=>sum+(hybrid.overallScore??0),0)/results.length*10)/10};
+if(!apply){console.log(JSON.stringify(summary,null,2));process.exit();}
+const now=new Date().toISOString();
+const backupPath=resolve(args.get('--backup')??`logs/jd-voc-current-backup-${now.replace(/[:.]/g,'-')}.json`);
+await mkdir(dirname(backupPath),{recursive:true});
+await writeFile(backupPath,JSON.stringify({workspaceId:config.auth.defaultWorkspaceId,createdAt:now,scores:existing},null,2),'utf8');
+await concurrent(results,async({rules,hybrid})=>{await repository.upsertJdVocCurrentScore(rules);await repository.upsertJdVocCurrentScore(hybrid);});
+const persisted=await repository.listJdVocScores(config.auth.defaultWorkspaceId);
+const codex=persisted.filter((item)=>item.scoreKind==='jd_voc_hybrid_ai'&&item.aiReview?.model===MODEL);
+const byProduct=new Map(codex.map((item)=>[item.productId,item]));
+const mismatches=sources.filter((source)=>{const score=byProduct.get(source.productId);return!score||score.sourceHash!==source.sourceHash||score.dimensions.length!==6||score.aiReview?.assessments.length!==5;});
+console.log(JSON.stringify({...summary,backupPath,persistedRules:persisted.filter((item)=>item.scoreKind==='jd_voc_rules').length,persistedHybrid:persisted.filter((item)=>item.scoreKind==='jd_voc_hybrid_ai').length,codexVerified:codex.length,mismatches:mismatches.length},null,2));
+if(mismatches.length||codex.length!==EXPECTED)process.exitCode=2;

+ 37 - 14
scripts/sync-jd-listing-reviews.ts

@@ -11,7 +11,7 @@ import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js'
 
 type JsonRecord = Record<string, unknown>;
 interface StoredSource extends ParseObject { productId?: string; payload: ListingSourceSnapshot }
-interface StoredReview extends ParseObject { naturalKey: string; productId: string; reviewDate?: unknown }
+interface StoredReview extends ParseObject { naturalKey: string; productId: string; sourceReviewId?: string; reviewDate?: unknown }
 interface Checkpoint {
   workspaceId: string;
   cohort: string;
@@ -47,6 +47,8 @@ const args = new Map(process.argv.slice(2).map((arg) => {
   return [key!, rest.join('=') || 'true'];
 }));
 const workspaceId = args.get('--workspace') ?? process.env.SAAS_DEFAULT_WORKSPACE_ID ?? 'demashi';
+const scope = args.get('--scope') === 'own' ? 'own' : 'listing-cohort';
+const scopeName = scope === 'own' ? 'voc-own-products' : ACTIVE_COHORT;
 const checkpointPath = resolve(args.get('--checkpoint') ?? 'logs/jd-listing-review-sync-2026-06-01-to-2026-08-28.json');
 const resume = args.get('--resume') === 'true';
 const delayMs = Math.max(100, Number(args.get('--delay-ms') ?? 200));
@@ -85,16 +87,27 @@ async function main(): Promise<void> {
   const accessToken = text(authData['access_token']);
   if (!accessToken) throw new Error('jd_authorization_missing');
 
-  const currentSources = await target.findAll<StoredSource>(VOC_PARSE_CLASSES.listingSourceSnapshot, {
-    workspaceId, platform: 'jd', isCurrent: true, catalogIncluded: true, catalogCohort: ACTIVE_COHORT,
-  });
-  const cohortProductIds = new Set(currentSources.map((row) => row.payload.productId));
-  if (cohortProductIds.size !== 625) throw new Error(`listing_review_cohort_count_mismatch:${cohortProductIds.size}:625`);
-
-  const allSources = await target.findAll<StoredSource>(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, platform: 'jd' });
-  const skuToProduct = buildSkuMap(allSources, cohortProductIds);
+  let allSources: StoredSource[] = [];
+  let cohortProductIds: Set<string>;
+  let skuToProduct: Map<string, string>;
+  if (scope === 'own') {
+    const ownProducts = await target.findAll<ParseObject & { productId?: string }>(VOC_PARSE_CLASSES.product, {
+      workspaceId, platform: 'jd', role: 'own',
+    });
+    cohortProductIds = new Set(ownProducts.map((row) => text(row.productId)).filter(Boolean));
+    skuToProduct = buildOwnSkuMap(cohortProductIds);
+    if (!cohortProductIds.size) throw new Error('listing_review_own_product_scope_empty');
+  } else {
+    allSources = await target.findAll<StoredSource>(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, platform: 'jd' });
+    const currentSources = await target.findAll<StoredSource>(VOC_PARSE_CLASSES.listingSourceSnapshot, {
+      workspaceId, platform: 'jd', isCurrent: true, catalogIncluded: true, catalogCohort: ACTIVE_COHORT,
+    });
+    cohortProductIds = new Set(currentSources.map((row) => row.payload.productId));
+    if (cohortProductIds.size !== 625) throw new Error(`listing_review_cohort_count_mismatch:${cohortProductIds.size}:625`);
+    skuToProduct = buildSkuMap(allSources, cohortProductIds);
+  }
   if (!skuToProduct.size) throw new Error('listing_review_sku_map_empty');
-  const conflicts = findSkuConflicts(allSources, cohortProductIds);
+  const conflicts = scope === 'own' ? [] : findSkuConflicts(allSources, cohortProductIds);
   if (conflicts.length) throw new Error(`listing_review_sku_conflicts:${conflicts.slice(0, 5).join(',')}`);
 
   let existingReviews = await target.findAll<StoredReview>(VOC_PARSE_CLASSES.review, { workspaceId, platform: 'jd' });
@@ -112,6 +125,9 @@ async function main(): Promise<void> {
     console.log(JSON.stringify({ event: 'listing_review_prune', deleted: outsideRange.length, historyStart: historyStartArg, historyEndExclusive: historyEndArg }));
   }
   const existingByNaturalKey = new Map(existingReviews.map((row) => [row.naturalKey, row]));
+  const existingBySourceReviewId = new Map(existingReviews
+    .filter((row) => text(row.sourceReviewId))
+    .map((row) => [text(row.sourceReviewId), row]));
   const seen = new Set(existingByNaturalKey.keys());
   let checkpoint = resume ? await readCheckpoint(checkpointPath) : null;
   if (checkpoint?.status === 'completed') {
@@ -120,12 +136,12 @@ async function main(): Promise<void> {
   }
   const now = new Date().toISOString();
   checkpoint ??= {
-    workspaceId, cohort: ACTIVE_COHORT, nextPage: 1, totalItems: 0, totalPages: 0,
+    workspaceId, cohort: scopeName, nextPage: 1, totalItems: 0, totalPages: 0,
     pagesProcessed: 0, commentsScanned: 0, commentsMatched: 0, commentsCreated: 0,
     commentsUpdated: 0, commentsDuplicate: 0, commentsUnmatched: 0, matchedProducts: [],
     startedAt: now, updatedAt: now, status: 'running',
   };
-  if (checkpoint.workspaceId !== workspaceId || checkpoint.cohort !== ACTIVE_COHORT) {
+  if (checkpoint.workspaceId !== workspaceId || checkpoint.cohort !== scopeName) {
     throw new Error('listing_review_checkpoint_scope_mismatch');
   }
   if (checkpoint.phase === 'windowed'
@@ -184,7 +200,7 @@ async function main(): Promise<void> {
       const reviewDate = dateIso(comment['creationTime']);
       const reviewKey = makeReviewKey({ platform: 'jd', productId, reviewId, content, reviewDate });
       const naturalKey = [workspaceId, 'jd', reviewKey].map(encodeURIComponent).join('|');
-      const existing = existingByNaturalKey.get(naturalKey);
+      const existing = existingByNaturalKey.get(naturalKey) ?? existingBySourceReviewId.get(reviewId);
       if (!existing && seen.has(naturalKey)) { checkpoint.commentsDuplicate += 1; continue; }
       const body = {
         naturalKey, workspaceId, platform: 'jd', productId, sourceReviewId: reviewId || null,
@@ -200,6 +216,7 @@ async function main(): Promise<void> {
         checkpoint.commentsCreated += 1;
       }
       seen.add(naturalKey);
+      if (reviewId && existing) existingBySourceReviewId.set(reviewId, existing);
       matchedProducts.add(productId);
       checkpoint.commentsMatched += 1;
     }
@@ -230,7 +247,7 @@ async function main(): Promise<void> {
   checkpoint.matchedProducts = [...matchedProducts].sort();
   await persistCheckpoint(checkpointPath, checkpoint);
   console.log(JSON.stringify({
-    mode: checkpoint.status, workspaceId, cohortProducts: cohortProductIds.size, mappedSkus: skuToProduct.size,
+    mode: checkpoint.status, workspaceId, scope, cohortProducts: cohortProductIds.size, mappedSkus: skuToProduct.size,
     totalItems: checkpoint.totalItems, totalPages: checkpoint.totalPages, pagesProcessed: checkpoint.pagesProcessed,
     commentsScanned: checkpoint.commentsScanned, commentsMatched: checkpoint.commentsMatched,
     commentsCreated: checkpoint.commentsCreated, commentsUpdated: checkpoint.commentsUpdated,
@@ -281,6 +298,12 @@ function buildSkuMap(rows: StoredSource[], cohortProductIds: Set<string>): Map<s
   return output;
 }
 
+function buildOwnSkuMap(ownProductIds: Set<string>): Map<string, string> {
+  const output = new Map<string, string>();
+  for (const productId of ownProductIds) output.set(productId, productId);
+  return output;
+}
+
 function findSkuConflicts(rows: StoredSource[], cohortProductIds: Set<string>): string[] {
   const owners = new Map<string, string>(); const conflicts = new Set<string>();
   for (const row of rows) {

+ 54 - 0
scripts/verify-cloud-functions.mjs

@@ -0,0 +1,54 @@
+/*
+ * INTERNAL: Cloud Function Builder Script
+ * Generates self-contained cloud function files for Fmode Parse platform
+ * (which runs in isolated VM without require/import support)
+ */
+import { readFile, writeFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const cloudFunctionsDir = resolve(__dirname, '..', 'cloud-functions');
+
+// Extract shared helpers from vContext.js (everything after the Constants block)
+async function getSharedHelpers() {
+  const vContext = await readFile(resolve(cloudFunctionsDir, 'vContext.js'), 'utf8');
+  const helpersStart = vContext.indexOf('// ============= Utilities =============');
+  const handlerStart = vContext.indexOf('// ============= Handler =============');
+  return vContext.substring(helpersStart, handlerStart);
+}
+
+// Function definitions: file, actions, needs repository functions
+const FUNCTIONS = [
+  { file: 'vContext.js', actions: ['context.get', 'workspace.list', 'workspace.member.update'] },
+  { file: 'vDomesticVoc.js', actions: ['domestic.snapshot', 'domestic.products.list', 'domestic.product.get', 'domestic.reviews.list', 'domestic.relations.list', 'data-source.list', 'import.list', 'audit.list'] },
+  { file: 'vSync.js', actions: ['sync.enqueue', 'sync.jobs.list', 'sync.job.get', 'sync.job.events', 'sync.job.retry', 'sync.job.cancel'] },
+  { file: 'vCompetitor.js', actions: ['competitor.overview', 'competitor.refresh', 'competitor.history', 'competitor.alerts', 'competitor.impacts', 'competitor.tasks.list', 'competitor.tasks.create', 'competitor.tasks.update', 'competitor.run.get'] },
+  { file: 'vListing.js', actions: ['listing.overview', 'listing.products.list', 'listing.product.get', 'listing.product.score', 'listing.jd-voc-score.get', 'listing.jd-voc-image-review.run'] },
+  { file: 'vListingJob.js', actions: ['listing.score-job.create', 'listing.score-job.list', 'listing.score-job.get', 'listing.score-job.items', 'listing.score-job.retry', 'listing.score-job.cancel', 'listing.versions.list', 'listing.version.create', 'listing.version.adopt'] },
+  { file: 'vAnalysis.js', actions: ['analysis.list', 'analysis.create', 'analysis.update', 'insight-decision.list', 'insight-decision.get', 'insight-decision.create'] },
+  { file: 'vActions.js', actions: ['action.list', 'action.create', 'action.update', 'alert.list', 'alert.create', 'alert.update'] },
+  { file: 'vKnowledge.js', actions: ['knowledge.products.list', 'knowledge.product.upsert', 'knowledge.product.delete'] },
+  { file: 'vAI.js', actions: ['ai.status', 'ai.test', 'ai.prompts.list', 'ai.prompt.update', 'ai.chat'] },
+  { file: 'vUpstream.js', actions: ['upstream.amazon', 'upstream.sorftime', 'upstream.tikhub', 'upstream.domestic'] },
+];
+
+async function main() {
+  const shared = await getSharedHelpers();
+  console.log(`Extracted ${shared.split('\n').length} lines of shared code from vContext.js`);
+
+  // Currently all files already exist, just verify they have no require statements
+  for (const func of FUNCTIONS) {
+    const filePath = resolve(cloudFunctionsDir, func.file);
+    const content = await readFile(filePath, 'utf8');
+    const hasRequire = /require\s*\(/.test(content);
+    const hasImport = /^import\s/m.test(content);
+    const status = (!hasRequire && !hasImport) ? 'OK' : 'NEEDS FIX';
+    console.log(`  ${status} ${func.file} (${content.length} bytes, ${func.actions.length} actions)`);
+  }
+}
+
+main().catch((error) => {
+  console.error('Build failed:', error);
+  process.exitCode = 1;
+});

+ 27 - 7
scripts/verify-listing-rollout.ts

@@ -2,31 +2,41 @@ 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 { ListingDimension, ListingScoreJob, ListingScoreJobItem, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import type { JdVocScoreResult, ListingDimension, ListingScoreJob, ListingScoreJobItem, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
 import { LISTING_DIMENSION_MAX, LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js';
 import { LISTING_AI_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js';
 import { isListingV7Score } from '../src/modules/listing-ai/scoring/score-status.js';
 
 const CODEX_GROUNDED_MODEL = 'codex-grounded-evaluator-v1';
 
-interface Stored<T> { workspaceId: string; productId?: string; jobId?: string; idempotencyKey?: string; slot?: 'rule_precheck' | 'formal_ai'; model?: string; payload: T }
+interface Stored<T> { workspaceId: string; productId?: string; jobId?: string; idempotencyKey?: string; slot?: 'rule_precheck' | 'formal_ai' | 'jd_voc_rules' | 'jd_voc_hybrid_ai'; model?: 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([
+  const [sourceRows, allResultRows, itemRows, jobRows] = await Promise.all([
     client.findAll<Stored<ListingSourceSnapshot>>(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, platform: 'jd', isCurrent: true, catalogIncluded: true, catalogCohort: 'listing-jd-v3-formal-625' }),
-    client.findAll<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingCurrentScore, { workspaceId }),
+    client.findAll<Stored<ListingScoreResult | JdVocScoreResult>>(VOC_PARSE_CLASSES.listingCurrentScore, { workspaceId }),
     client.findAll<Stored<ListingScoreJobItem>>(VOC_PARSE_CLASSES.listingScoreItem, { workspaceId }),
     client.findAll<Stored<ListingScoreJob> & { idempotencyKey: string }>(VOC_PARSE_CLASSES.listingScoreJob, { workspaceId }),
   ]);
+  const resultRows = allResultRows.filter((row) => !String(row.payload.scoreKind ?? '').startsWith('jd_voc_')) as Array<Stored<ListingScoreResult> & { objectId: string; createdAt: string; updatedAt: string }>;
+  const jdVocRows = allResultRows.filter((row) => String(row.payload.scoreKind ?? '').startsWith('jd_voc_')) as Array<Stored<JdVocScoreResult> & { objectId: string; createdAt: string; updatedAt: string }>;
   const schemas = await client.schemas();
   const legacyScoreSchemaPresent = schemas.some((schema) => schema.className === 'VocListingScoreResult');
-  const slotKeys = resultRows.map((row) => `${row.workspaceId}|${row.productId ?? row.payload.productId}|${row.slot ?? (row.payload.scoreKind === 'hybrid_ai' ? 'formal_ai' : 'rule_precheck')}`);
+  const slotKeys = allResultRows.map((row) => `${row.workspaceId}|${row.productId ?? row.payload.productId}|${row.slot ?? (row.payload.scoreKind === 'hybrid_ai' ? 'formal_ai' : row.payload.scoreKind === 'jd_voc_hybrid_ai' ? 'jd_voc_hybrid_ai' : row.payload.scoreKind === 'jd_voc_rules' ? 'jd_voc_rules' : 'rule_precheck')}`);
   const duplicateCurrentSlots = slotKeys.length - new Set(slotKeys).size;
   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 jdVocInvalid = jdVocRows.filter((row) => row.payload.rubricVersion !== 'jd-voc-v0.5' || row.payload.dimensions.length !== 6 || row.payload.sourceHash.length !== 64 || !row.payload.executionKey || !row.payload.inputFingerprint).length;
+  const jdVocStale = jdVocRows.filter((row) => latestSources.get(row.payload.productId)?.sourceHash !== row.payload.sourceHash).length;
+  const jdVocAiEvidenceInvalid = jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_hybrid_ai').filter((row) => row.payload.aiReview?.assessments.some((assessment) => (assessment.score ?? 0) > 0 && !assessment.evidenceIds.length)).length;
+  const jdVocHybridRows = jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_hybrid_ai');
+  const jdVocAiLatencies = jdVocHybridRows.map((row) => row.payload.aiReview?.latencyMs).filter((value): value is number => typeof value === 'number');
+  const jdVocAiUsage = jdVocHybridRows.reduce((sum,row)=>({promptTokens:sum.promptTokens+(row.payload.aiReview?.usage?.promptTokens??0),completionTokens:sum.completionTokens+(row.payload.aiReview?.usage?.completionTokens??0),totalTokens:sum.totalTokens+(row.payload.aiReview?.usage?.totalTokens??0)}),{promptTokens:0,completionTokens:0,totalTokens:0});
+  const jdVocAiEstimatedPublicCostUsd = Math.round((jdVocAiUsage.promptTokens * 0.15 / 1_000_000 + jdVocAiUsage.completionTokens * 0.60 / 1_000_000) * 1_000_000) / 1_000_000;
+  const jdVocImageLeak = jdVocRows.filter((row) => row.payload.imageReview && /https?:\/\//i.test(JSON.stringify(row.payload.imageReview))).length;
   const ruleResults = new Map<string, ListingScoreResult>();
   const aiResults = new Map<string, ListingScoreResult>();
   for (const row of resultRows) {
@@ -82,7 +92,17 @@ async function main() {
   };
   const report = {
     workspaceId, rubricVersion: `${LISTING_RUBRIC_VERSION} / ${LISTING_AI_RUBRIC_VERSION}`, weights: LISTING_DIMENSION_MAX, sources: latestSources.size, normalizers,
-    currentScoreRows: resultRows.length, duplicateCurrentSlots, legacyScoreSchemaPresent,
+    currentScoreRows: allResultRows.length, legacyCurrentScoreRows: resultRows.length, jdVocCurrentScoreRows: jdVocRows.length,
+    jdVocRuleScores: jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_rules').length,
+    jdVocHybridScores: jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_hybrid_ai').length,
+    jdVocImageShadowScores: jdVocRows.filter((row) => row.payload.imageReview?.status === 'shadow_completed').length,
+    jdVocInvalid, jdVocStale, jdVocAiEvidenceInvalid, jdVocImageLeak,
+    jdVocAiAverageLatencyMs: jdVocAiLatencies.length ? Math.round(jdVocAiLatencies.reduce((sum,value)=>sum+value,0)/jdVocAiLatencies.length) : null,
+    jdVocAiUsage,
+    jdVocAiEstimatedPublicCostUsd,
+    jdVocAiEstimatedPublicCostCny: Math.round(jdVocAiEstimatedPublicCostUsd * Number(process.env.FMODE_USD_TO_CNY_RATE ?? 6.8) * 10_000) / 10_000,
+    jdVocAiPricingBasis: 'OpenAI public gpt-4o-mini token rates; excludes Fmode gateway markup',
+    duplicateCurrentSlots, legacyScoreSchemaPresent,
     ruleScores: ruleResults.size, rulePartialResults: results.filter((result) => result.overallScore === null).length,
     formalScores: aiResults.size, codexScores: codexScores.length, codexAverage: formalNumericScores.length ? Math.round(formalNumericScores.reduce((sum, value) => sum + value, 0) / formalNumericScores.length * 10) / 10 : null, simulatedScores: simulatedScores.length, simulatedAverage, simulatedMinimum, simulatedMaximum, simulatedMinimumCount, simulatedMaximumCount, invalidAiResults: invalidAiResults.length, invalidFormalScores: invalidFormalScores.length, staleAiResults: staleAiResults.length,
     averageKnownScore: knownScores.length ? Math.round(knownScores.reduce((sum, value) => sum + value, 0) / knownScores.length * 10) / 10 : null,
@@ -104,7 +124,7 @@ async function main() {
     jobItemStatusSemantics: { partialWithErrorCode, failedWithoutErrorCode },
   };
   console.log(JSON.stringify(report, null, 2));
-  if (latestSources.size !== 625 || normalizers['jd-listing-v5'] !== 625 || resultRows.length !== 1250 || duplicateCurrentSlots || legacyScoreSchemaPresent || ruleResults.size !== 625 || aiResults.size !== 625 || codexScores.length !== 625 || simulatedScores.length !== 0 || invalidAiResults.length || invalidFormalScores.length || staleAiResults.length || missingResults.length || orphanResults.length || staleResults.length || invalidDimensions.length || invalidWeights.length || (latestJob !== null && latestJob.processed !== latestJob.total) || partialWithErrorCode || failedWithoutErrorCode || systemicHardFailGate.titleLength > systemicHardFailGate.maximumAllowedRate || systemicHardFailGate.titleCategoryInFirst15 > systemicHardFailGate.maximumAllowedRate) process.exitCode = 2;
+  if (latestSources.size !== 625 || normalizers['jd-listing-v5'] !== 625 || resultRows.length !== 1250 || jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_rules').length < 100 || jdVocRows.filter((row) => row.payload.scoreKind === 'jd_voc_hybrid_ai').length < 5 || jdVocRows.filter((row) => row.payload.imageReview?.status === 'shadow_completed').length < 5 || jdVocInvalid || jdVocStale || jdVocAiEvidenceInvalid || jdVocImageLeak || duplicateCurrentSlots || legacyScoreSchemaPresent || ruleResults.size !== 625 || aiResults.size !== 625 || codexScores.length !== 625 || simulatedScores.length !== 0 || invalidAiResults.length || invalidFormalScores.length || staleAiResults.length || missingResults.length || orphanResults.length || staleResults.length || invalidDimensions.length || invalidWeights.length || (latestJob !== null && latestJob.processed !== latestJob.total) || partialWithErrorCode || failedWithoutErrorCode || systemicHardFailGate.titleLength > systemicHardFailGate.maximumAllowedRate || systemicHardFailGate.titleCategoryInFirst15 > systemicHardFailGate.maximumAllowedRate) process.exitCode = 2;
 }
 
 main().catch((error) => { console.error(`[verify-listing-rollout] ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });

+ 35 - 10
src/app.ts

@@ -1,5 +1,5 @@
 import cors from 'cors';
-import express, { type ErrorRequestHandler, type RequestHandler } from 'express';
+import express, { Router, type ErrorRequestHandler, type RequestHandler } from 'express';
 import type { Pool } from 'pg';
 import { ZodError } from 'zod';
 import type { AppConfig } from './config/env.js';
@@ -18,11 +18,15 @@ import type { AiPromptConfigStore } from './modules/ai-gateway/prompt-config.rep
 import { createProductKnowledgeRouter } from './modules/product-knowledge/routes.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 { FmodeJdVocAiScoringProvider, FmodeListingAiScoringProvider, ListingAiService } from './modules/listing-ai/listing-ai.service.js';
+import { FmodeGeminiImageReviewProvider } from './modules/listing-ai/image-review/gemini-image-review.provider.js';
 import { createListingAiRouter } from './modules/listing-ai/routes.js';
 import type { ListingAiRepository } from './modules/listing-ai/domain.js';
 import type { CompetitorListingMonitorService } from './modules/competitor-listing-monitor/competitor-listing-monitor.service.js';
 import { createCompetitorListingMonitorRouter } from './modules/competitor-listing-monitor/routes.js';
+import { createCloudFunctionRouter, dispatchThroughRouter } from './cloud-functions/router.js';
+import { createSpecialActionHandler } from './cloud-functions/special-actions.js';
+import { FmodeVocEcommerceClient } from './modules/domestic-voc/upstream/fmode-client.js';
 
 export function createApp(input: {
   config: AppConfig;
@@ -35,6 +39,7 @@ export function createApp(input: {
   aiPromptConfigs?: AiPromptConfigStore;
   productKnowledge?: ProductKnowledgeStore;
   listingAiRepository?: ListingAiRepository;
+  listingAiService?: ListingAiService;
   competitorListingMonitor?: CompetitorListingMonitorService;
 }) {
   const app = express();
@@ -125,14 +130,17 @@ export function createApp(input: {
   if (!input.platformRepository && !input.pool) throw new Error('Platform repository is not configured');
   const platform = input.platformRepository ?? new PostgresPlatformRepository(input.pool!);
   const access = new WorkspaceAccessService(platform);
-  app.use('/api', createAuthenticationMiddleware(createAuthenticator(input.config)));
+  const businessRouter = Router();
+  const authenticationMiddleware = createAuthenticationMiddleware(createAuthenticator(input.config));
+  app.use('/api', authenticationMiddleware);
   const aiClient = new FmodeAiClient(input.config.ai);
-  app.use('/api/ai', createAiGatewayRouter(aiClient, input.aiPromptConfigs));
+  const domesticGateway = new FmodeVocEcommerceClient(input.config.fmode);
+  businessRouter.use('/ai', createAiGatewayRouter(aiClient, input.aiPromptConfigs));
 
   const jobs = input.jobs ?? new SyncJobRepository(input.pool!);
   const sync = new SyncService(jobs);
   const snapshot = input.snapshot ?? new SnapshotService(input.pool!);
-  app.use('/api/domestic-voc', createDomesticVocRouter({
+  businessRouter.use('/domestic-voc', createDomesticVocRouter({
     jobs,
     sync,
     snapshot,
@@ -140,15 +148,19 @@ export function createApp(input: {
     access,
     defaultWorkspaceId: input.config.auth.defaultWorkspaceId,
   }));
-  app.use('/api/saas', createSaasPlatformRouter({ repository: platform, access }));
-  const listingAi = new ListingAiService(
+  businessRouter.use('/saas', createSaasPlatformRouter({ repository: platform, access }));
+  const listingAi = input.listingAiService ?? new ListingAiService(
     input.listingAiRepository ?? new InMemoryListingAiRepository(),
     new FmodeListingAiScoringProvider(aiClient, input.config.listingAi.model),
     () => new Date(),
     input.config.listingAi.concurrency,
     input.config.listingAi.maxAiItemsPerJob,
+    input.config.listingAi.jdVocAiEnabled ? new FmodeJdVocAiScoringProvider(aiClient, input.config.listingAi.jdVocAiModel) : undefined,
+    input.config.listingAi.jdVocImageShadowEnabled ? new FmodeGeminiImageReviewProvider({ baseUrl: process.env.FMODE_LLM_BASE_URL ?? input.config.ai.baseUrl, token: process.env.FMODE_LLM_API_KEY ?? input.config.ai.token, timeoutMs: 45_000 }) : undefined,
+    input.config.listingAi.jdVocDisplayDefault,
+    input.config.listingAi.jdVocEnabled,
   );
-  app.use('/api/listing-ai', createListingAiRouter({
+  businessRouter.use('/listing-ai', createListingAiRouter({
     service: listingAi,
     access,
     audit: platform,
@@ -160,7 +172,7 @@ export function createApp(input: {
     });
   });
   if (input.productKnowledge) {
-    app.use('/api/knowledge', createProductKnowledgeRouter({
+    businessRouter.use('/knowledge', createProductKnowledgeRouter({
       store: input.productKnowledge,
       repository: platform,
       access,
@@ -168,13 +180,26 @@ export function createApp(input: {
     }));
   }
   if (input.competitorListingMonitor) {
-    app.use('/api/competitor-listings', createCompetitorListingMonitorRouter({
+    businessRouter.use('/competitor-listings', createCompetitorListingMonitorRouter({
       service: input.competitorListingMonitor,
       access,
       defaultWorkspaceId: input.config.auth.defaultWorkspaceId,
     }));
   }
 
+  app.use('/api', businessRouter);
+  const localCloudFunctionRouter = createCloudFunctionRouter({
+    defaultWorkspaceId: input.config.auth.defaultWorkspaceId,
+    specialAction: createSpecialActionHandler({ ai: aiClient, domestic: domesticGateway }),
+    dispatch: (request, response, actionPath, method, body, requestId) =>
+      dispatchThroughRouter(businessRouter, request, response, actionPath, method, body, requestId),
+  });
+  app.use('/api/functions', localCloudFunctionRouter);
+  // Parse REST storage has no embedded Parse Server. This compatibility mount
+  // lets the browser's Parse SDK keep calling /parse/functions/* while the
+  // request is still dispatched through the same allow-listed Cloud actions.
+  if (!input.parseApp) app.use('/parse/functions', authenticationMiddleware, localCloudFunctionRouter);
+
   app.use((_request, response) => {
     response.status(404).json({ error: 'not_found' });
   });

+ 123 - 0
src/cloud-functions/action-registry.ts

@@ -0,0 +1,123 @@
+import { ApiError } from '../http/api-error.js';
+
+export const CLOUD_ACTIONS = [
+  'context.get', 'workspace.list', 'workspace.members.list', 'workspace.member.update',
+  'data-source.list', 'import.list', 'audit.list',
+  'domestic.snapshot', 'domestic.products.list', 'domestic.product.get',
+  'domestic.reviews.list', 'domestic.relations.list',
+  'sync.enqueue', 'sync.jobs.list', 'sync.job.get', 'sync.job.events', 'sync.job.retry', 'sync.job.cancel',
+  'analysis.list', 'analysis.create', 'analysis.update',
+  'insight-decision.list', 'insight-decision.get', 'insight-decision.create',
+  'action.list', 'action.create', 'action.update',
+  'alert.list', 'alert.create', 'alert.update',
+  'knowledge.products.list', 'knowledge.product.upsert', 'knowledge.product.delete',
+  'competitor.overview', 'competitor.refresh', 'competitor.history', 'competitor.alerts', 'competitor.impacts',
+  'competitor.tasks.list', 'competitor.tasks.create', 'competitor.tasks.update', 'competitor.run.get',
+  'listing.overview', 'listing.products.list', 'listing.product.get', 'listing.product.score', 'listing.jd-voc-score.get', 'listing.jd-voc-image-review.run',
+  'listing.score-job.create', 'listing.score-job.list', 'listing.score-job.get', 'listing.score-job.items',
+  'listing.score-job.retry', 'listing.score-job.cancel', 'listing.versions.list', 'listing.version.create',
+  'listing.version.adopt',
+  'ai.status', 'ai.test', 'ai.prompts.list', 'ai.prompt.update', 'ai.chat',
+  'upstream.amazon', 'upstream.sorftime', 'upstream.tikhub', 'upstream.domestic',
+] as const;
+
+export type CloudAction = typeof CLOUD_ACTIONS[number];
+
+const allowed = new Set<string>(CLOUD_ACTIONS);
+
+export function assertCloudAction(value: unknown): CloudAction {
+  if (typeof value !== 'string' || !allowed.has(value)) {
+    throw new ApiError(400, 'cloud_action_not_allowed');
+  }
+  return value as CloudAction;
+}
+
+export function requireWorkspaceId(value: unknown, fallback: string): string {
+  const workspaceId = typeof value === 'string' && value.trim() ? value.trim() : fallback;
+  if (workspaceId.length > 200) throw new ApiError(400, 'workspace_id_invalid');
+  return workspaceId;
+}
+
+export function encodeRouteValue(value: string): string {
+  return encodeURIComponent(value);
+}
+
+export type CloudRoute = {
+  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
+  path: (payload: Record<string, unknown>, workspaceId: string) => string;
+  query?: (payload: Record<string, unknown>, workspaceId: string) => Record<string, unknown>;
+};
+
+const route = (method: CloudRoute['method'], path: CloudRoute['path'], query?: CloudRoute['query']): CloudRoute => query ? { method, path, query } : { method, path };
+const workspacePath = (suffix: string) => (_payload: Record<string, unknown>, workspaceId: string) => `/saas/workspaces/${encodeRouteValue(workspaceId)}${suffix}`;
+const domesticQuery = (payload: Record<string, unknown>, workspaceId: string) => ({ workspaceId, platform: payload.platform ?? 'jd', ...payload });
+const listingWorkspaceQuery = (payload: Record<string, unknown>, workspaceId: string) => ({ workspaceId, platform: payload.platform ?? 'jd', ...payload });
+
+export const CLOUD_ROUTES: Readonly<Record<CloudAction, CloudRoute | null>> = {
+  'context.get': route('GET', () => '/saas/context'),
+  'workspace.list': route('GET', () => '/saas/workspaces'),
+  'workspace.members.list': route('GET', workspacePath('/members')),
+  'workspace.member.update': route('PUT', (p, w) => `${workspacePath('/members/' + encodeRouteValue(String(p.userId ?? '')))(p, w)}`),
+  'data-source.list': route('GET', workspacePath('/data-sources')),
+  'import.list': route('GET', workspacePath('/imports')),
+  'audit.list': route('GET', workspacePath('/audit')),
+  'domestic.snapshot': route('GET', () => '/domestic-voc/snapshot', domesticQuery),
+  'domestic.products.list': route('GET', () => '/domestic-voc/products', domesticQuery),
+  'domestic.product.get': route('GET', (p) => `/domestic-voc/products/${encodeRouteValue(String(p.productId ?? ''))}`, domesticQuery),
+  'domestic.reviews.list': route('GET', (p) => `/domestic-voc/products/${encodeRouteValue(String(p.productId ?? ''))}/reviews`, domesticQuery),
+  'domestic.relations.list': route('GET', () => '/domestic-voc/relations', domesticQuery),
+  'sync.enqueue': route('POST', () => '/domestic-voc/sync'),
+  'sync.jobs.list': route('GET', () => '/domestic-voc/jobs', domesticQuery),
+  'sync.job.get': route('GET', (p) => `/domestic-voc/jobs/${encodeRouteValue(String(p.jobId ?? ''))}`),
+  'sync.job.events': route('GET', (p) => `/domestic-voc/jobs/${encodeRouteValue(String(p.jobId ?? ''))}/events`, domesticQuery),
+  'sync.job.retry': route('POST', (p) => `/domestic-voc/jobs/${encodeRouteValue(String(p.jobId ?? ''))}/retry`),
+  'sync.job.cancel': route('POST', (p) => `/domestic-voc/jobs/${encodeRouteValue(String(p.jobId ?? ''))}/cancel`),
+  'analysis.list': route('GET', workspacePath('/analyses')),
+  'analysis.create': route('POST', workspacePath('/analyses')),
+  'analysis.update': route('PATCH', (p, w) => `${workspacePath('/analyses/' + encodeRouteValue(String(p.analysisId ?? '')))(p, w)}`),
+  'insight-decision.list': route('GET', workspacePath('/insight-decisions')),
+  'insight-decision.get': route('GET', (p, w) => `${workspacePath('/insight-decisions/' + encodeRouteValue(String(p.decisionId ?? '')))(p, w)}`),
+  'insight-decision.create': route('POST', workspacePath('/insight-decisions')),
+  'action.list': route('GET', workspacePath('/actions')),
+  'action.create': route('POST', workspacePath('/actions')),
+  'action.update': route('PATCH', (p, w) => `${workspacePath('/actions/' + encodeRouteValue(String(p.actionId ?? '')))(p, w)}`),
+  'alert.list': route('GET', workspacePath('/alerts')),
+  'alert.create': route('POST', workspacePath('/alerts')),
+  'alert.update': route('PATCH', (p, w) => `${workspacePath('/alerts/' + encodeRouteValue(String(p.alertId ?? '')))(p, w)}`),
+  'knowledge.products.list': route('GET', () => '/knowledge/products'),
+  'knowledge.product.upsert': route('PUT', () => '/knowledge/products'),
+  'knowledge.product.delete': route('DELETE', (p) => `/knowledge/products/${encodeRouteValue(String(p.productKey ?? ''))}`),
+  'competitor.overview': route('GET', () => '/competitor-listings/overview', listingWorkspaceQuery),
+  'competitor.refresh': route('POST', () => '/competitor-listings/refresh'),
+  'competitor.history': route('GET', (p) => `/competitor-listings/history/${encodeRouteValue(String(p.productId ?? ''))}`, listingWorkspaceQuery),
+  'competitor.alerts': route('GET', () => '/competitor-listings/alerts', listingWorkspaceQuery),
+  'competitor.impacts': route('GET', () => '/competitor-listings/impacts', listingWorkspaceQuery),
+  'competitor.tasks.list': route('GET', () => '/competitor-listings/optimization-tasks', listingWorkspaceQuery),
+  'competitor.tasks.create': route('POST', () => '/competitor-listings/optimization-tasks'),
+  'competitor.tasks.update': route('PATCH', (p) => `/competitor-listings/optimization-tasks/${encodeRouteValue(String(p.taskId ?? ''))}`, listingWorkspaceQuery),
+  'competitor.run.get': route('GET', (p) => `/competitor-listings/runs/${encodeRouteValue(String(p.runId ?? ''))}`, listingWorkspaceQuery),
+  'listing.overview': route('GET', () => '/listing-ai/overview', listingWorkspaceQuery),
+  'listing.products.list': route('GET', () => '/listing-ai/products', listingWorkspaceQuery),
+  'listing.product.get': route('GET', (p) => `/listing-ai/products/${encodeRouteValue(String(p.productId ?? ''))}`, listingWorkspaceQuery),
+  'listing.product.score': route('GET', (p) => `/listing-ai/products/${encodeRouteValue(String(p.productId ?? ''))}/score`, listingWorkspaceQuery),
+  'listing.jd-voc-score.get': route('GET', (p) => `/listing-ai/products/${encodeRouteValue(String(p.productId ?? ''))}/jd-voc-score`, listingWorkspaceQuery),
+  'listing.jd-voc-image-review.run': route('POST', (p) => `/listing-ai/products/${encodeRouteValue(String(p.productId ?? ''))}/jd-voc-image-review`, listingWorkspaceQuery),
+  'listing.score-job.create': route('POST', () => '/listing-ai/score-jobs'),
+  'listing.score-job.list': route('GET', () => '/listing-ai/score-jobs', listingWorkspaceQuery),
+  'listing.score-job.get': route('GET', (p) => `/listing-ai/score-jobs/${encodeRouteValue(String(p.jobId ?? ''))}`, listingWorkspaceQuery),
+  'listing.score-job.items': route('GET', (p) => `/listing-ai/score-jobs/${encodeRouteValue(String(p.jobId ?? ''))}/items`, listingWorkspaceQuery),
+  'listing.score-job.retry': route('POST', (p) => `/listing-ai/score-jobs/${encodeRouteValue(String(p.jobId ?? ''))}/retry`, listingWorkspaceQuery),
+  'listing.score-job.cancel': route('POST', (p) => `/listing-ai/score-jobs/${encodeRouteValue(String(p.jobId ?? ''))}/cancel`, listingWorkspaceQuery),
+  'listing.versions.list': route('GET', () => '/listing-ai/versions', listingWorkspaceQuery),
+  'listing.version.create': route('POST', (p) => `/listing-ai/products/${encodeRouteValue(String(p.productId ?? ''))}/versions`),
+  'listing.version.adopt': route('POST', (p) => `/listing-ai/versions/${encodeRouteValue(String(p.versionId ?? ''))}/adopt`, listingWorkspaceQuery),
+  'ai.status': route('GET', () => '/ai/status'),
+  'ai.test': route('POST', () => '/ai/test'),
+  'ai.prompts.list': route('GET', () => '/ai/prompts'),
+  'ai.prompt.update': route('PUT', (p) => `/ai/prompts/${encodeRouteValue(String(p.promptKey ?? ''))}`),
+  'ai.chat': null,
+  'upstream.amazon': null,
+  'upstream.sorftime': null,
+  'upstream.tikhub': null,
+  'upstream.domestic': null,
+};

+ 170 - 0
src/cloud-functions/router.ts

@@ -0,0 +1,170 @@
+import { Router, type Request, type Response } from 'express';
+import { randomUUID } from 'node:crypto';
+import { ZodError, z } from 'zod';
+import { ApiError } from '../http/api-error.js';
+import { assertCloudAction, CLOUD_ROUTES, requireWorkspaceId } from './action-registry.js';
+
+const requestSchema = z.object({
+  action: z.string().min(1).max(100),
+  workspaceId: z.string().min(1).max(200).optional(),
+  platform: z.string().min(1).max(30).optional(),
+  payload: z.record(z.string(), z.unknown()).optional().default({}),
+  idempotencyKey: z.string().min(8).max(200).optional(),
+}).strict();
+
+type Dispatcher = (request: Request, response: Response, actionPath: string, method: string, body: Record<string, unknown>, requestId: string) => Promise<void>;
+type SpecialActionHandler = (request: Request, response: Response, action: string, body: Record<string, unknown>, requestId: string) => Promise<boolean>;
+
+export function createCloudFunctionRouter(input: {
+  defaultWorkspaceId: string;
+  dispatch: Dispatcher;
+  specialAction?: SpecialActionHandler;
+}): Router {
+  const router = Router();
+
+  router.get('/test', (_request, response) => response.json({ success: true, data: { service: 'saas-voc-cloud-functions' }, requestId: randomUUID() }));
+
+  router.post('/', async (request, response) => {
+    const requestId = randomUUID();
+    try {
+      const parsed = requestSchema.parse(unwrapManagedFunctionEnvelope(request.body));
+      const action = assertCloudAction(parsed.action);
+      const payload: Record<string, unknown> = { ...(parsed.payload as Record<string, unknown>), ...(parsed.platform ? { platform: parsed.platform } : {}) };
+      const workspaceId = requireWorkspaceId(parsed.workspaceId ?? payload['workspaceId'], input.defaultWorkspaceId);
+      // The authenticated workspace is authoritative; never let a nested
+      // payload replace it after it has been validated.
+      payload['workspaceId'] = workspaceId;
+      const route = CLOUD_ROUTES[action];
+      if (!route) {
+        if (input.specialAction && await input.specialAction(request, response, action, { ...payload, workspaceId }, requestId)) return;
+        throw new ApiError(501, 'cloud_action_not_implemented');
+      }
+      const actionPath = route.path(payload, workspaceId);
+      const query = route.query?.(payload, workspaceId) ?? {};
+      const queryString = new URLSearchParams(
+        Object.entries(query).filter(([, value]) => value !== undefined && value !== null && value !== '')
+          .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value)]),
+      ).toString();
+      const fullPath = queryString ? `${actionPath}?${queryString}` : actionPath;
+      const body = { ...payload, workspaceId, ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}) };
+      await input.dispatch(request, response, fullPath, route.method, body, requestId);
+    } catch (error) {
+      sendCloudError(response, requestId, error);
+    }
+  });
+
+  return router;
+}
+
+export async function dispatchThroughRouter(
+  businessRouter: Router,
+  request: Request,
+  response: Response,
+  actionPath: string,
+  method: string,
+  body: Record<string, unknown>,
+  requestId: string,
+): Promise<void> {
+  const originalUrl = request.url;
+  const originalMethod = request.method;
+  const originalBody = request.body;
+  const originalJson = response.json.bind(response);
+  const originalSend = response.send.bind(response);
+  let settled = false;
+  request.url = actionPath;
+  request.method = method;
+  request.body = body;
+
+  await new Promise<void>((resolve, reject) => {
+    const finish = (error?: unknown) => {
+      if (settled) return;
+      settled = true;
+      if (error) reject(error);
+      else resolve();
+    };
+    response.json = ((value: unknown) => {
+      response.json = originalJson;
+      if (response.statusCode >= 400 || (value && typeof value === 'object' && 'error' in value)) {
+        finish(new ApiError(response.statusCode >= 400 ? response.statusCode : 500, extractErrorCode(value)));
+      } else {
+        const output = isCloudResponse(value) ? value : { success: true, data: value, requestId };
+        response.status(200).json(output);
+        finish();
+      }
+      return response;
+    }) as Response['json'];
+    response.send = ((value: unknown) => {
+      response.send = originalSend;
+      let parsed: unknown = value;
+      if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
+        try { parsed = JSON.parse(Buffer.from(value).toString('utf8')); } catch { parsed = { value: '[non-json response]' }; }
+      } else if (typeof value === 'string') {
+        try { parsed = JSON.parse(value); } catch { parsed = { value: '[non-json response]' }; }
+      }
+      if (response.statusCode >= 400 || (parsed && typeof parsed === 'object' && 'error' in parsed)) {
+        finish(new ApiError(response.statusCode >= 400 ? response.statusCode : 502, extractErrorCode(parsed)));
+      } else {
+        const output = isCloudResponse(parsed) ? parsed : { success: true, data: parsed, requestId };
+        response.status(200).json(output);
+        finish();
+      }
+      return response;
+    }) as Response['send'];
+    (businessRouter as unknown as { handle: (request: Request, response: Response, next: (error?: unknown) => void) => void })
+      .handle(request, response, (error?: unknown) => finish(error ?? new ApiError(404, 'not_found')));
+  }).finally(() => {
+    request.url = originalUrl;
+    request.method = originalMethod;
+    request.body = originalBody;
+    response.json = originalJson;
+    response.send = originalSend;
+  });
+}
+
+/**
+ * FmodeCloud.function posts `{ path, params, _ApplicationId, _InstallationId }`.
+ * The hosted evaluator unwraps `params` before invoking the Function, so the
+ * local adapter mirrors that transport step for the same browser client.
+ */
+function unwrapManagedFunctionEnvelope(body: unknown): unknown {
+  if (!body || typeof body !== 'object' || Array.isArray(body)) return body;
+  const value = body as Record<string, unknown>;
+  if (value['path'] !== '/saas-voc-gateway') return body;
+  const params = value['params'];
+  return params && typeof params === 'object' && !Array.isArray(params) ? params : body;
+}
+
+function extractErrorCode(value: unknown): string {
+  if (value && typeof value === 'object' && 'error' in value) {
+    const error = (value as Record<string, unknown>).error;
+    if (typeof error === 'string') return error;
+  }
+  return 'cloud_action_failed';
+}
+
+function isCloudResponse(value: unknown): value is { success: boolean; data: unknown; requestId: string } {
+  return Boolean(value && typeof value === 'object' && 'success' in value && 'requestId' in value && 'data' in value);
+}
+
+function sendCloudError(response: Response, requestId: string, error: unknown): void {
+  if (response.headersSent) return;
+  const status = error instanceof ApiError ? error.status : error instanceof ZodError ? 400 : 500;
+  const code = error instanceof ApiError
+    ? error.code
+    : error instanceof ZodError ? 'invalid_request' : 'internal_error';
+  const details = error instanceof ZodError
+    ? error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message }))
+    : undefined;
+  if (status >= 500) console.error('[cloud-function]', requestId, code, error instanceof Error ? error.message : error);
+  response.status(status).json({ success: false, code, message: publicMessage(code), requestId, ...(details ? { details } : {}) });
+}
+
+function publicMessage(code: string): string {
+  const messages: Record<string, string> = {
+    cloud_action_not_allowed: '不支持的业务操作',
+    cloud_action_not_implemented: '业务操作暂未配置',
+    invalid_request: '请求参数错误',
+    internal_error: '服务暂不可用,请稍后重试',
+  };
+  return messages[code] ?? code;
+}

+ 45 - 0
src/cloud-functions/special-actions.ts

@@ -0,0 +1,45 @@
+import type { Request, Response } from 'express';
+import { ApiError } from '../http/api-error.js';
+import type { FmodeAiClient } from '../modules/ai-gateway/client.js';
+import type { FmodeVocEcommerceClient } from '../modules/domestic-voc/upstream/fmode-client.js';
+
+const DOMESTIC_PATHS = new Set([
+  'jd/get-item-detail/v1', 'jd/get-item-comments/v1', 'jd/search-item-list/v1',
+]);
+const AMAZON_PATHS = [/^\/test$/, /^\/orders(?:\/[A-Za-z0-9._~-]+)?(?:\/items)?$/, /^\/sales\/orderMetrics$/, /^\/catalog\/items(?:\/[A-Za-z0-9._~-]+)?$/, /^\/listings\/items\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)?$/, /^\/customerFeedback\/items\/[A-Za-z0-9._~-]+\/reviews\/(?:topics|trends|browseNode)$/, /^\/sellers\/marketplaceParticipations$/, /^\/returns$/];
+const SORFTIME_PATHS = [/^\/api\/(?:CategoryTree|CategoryRequest|CategoryProducts|ProductQuery|ProductRequest|AsinSalesVolume|SimilarProductRealtimeRequest|SimilarProductRealtimeRequestStatusQuery|SimilarProductRealtimeRequestCollection|ProductReviewsQuery|MonitorQuery|KeywordQuery|ASINRequestKeyword|KeywordProductRanking|KeywordSearchResultTrend|ProductVariationHistory)$/];
+const TIKHUB_PATHS = [/^\/v1\/(?:tiktok|instagram)\/[A-Za-z0-9._~/-]+$/];
+
+export function createSpecialActionHandler(input: { ai: FmodeAiClient; domestic: FmodeVocEcommerceClient }) {
+  return async (_request: Request, response: Response, action: string, body: Record<string, unknown>, _requestId: string): Promise<boolean> => {
+    if (action === 'ai.chat') {
+      const messages = body.messages;
+      if (!Array.isArray(messages) || messages.length < 1 || messages.length > 100) throw new ApiError(400, 'invalid_ai_messages');
+      const aiBody: Record<string, unknown> = { messages, stream: false };
+      for (const key of ['model', 'temperature', 'presence_penalty', 'frequency_penalty', 'max_tokens', 'response_format', 'thinking', 'websearch']) {
+        if (body[key] !== undefined) aiBody[key] = body[key];
+      }
+      const upstream = await input.ai.createChatCompletion(aiBody);
+      const data = await upstream.json().catch(() => null);
+      if (!upstream.ok) throw new ApiError(502, 'ai_upstream_failed');
+      response.json({ success: true, data, requestId: _requestId });
+      return true;
+    }
+    if (action === 'upstream.domestic') {
+      if (body['operation'] !== 'gateway.get') throw new ApiError(501, 'upstream_domestic_operation_not_configured');
+      const path = String(body['path'] ?? '').replace(/^\/+/, '');
+      if (!DOMESTIC_PATHS.has(path)) throw new ApiError(400, 'upstream_path_not_allowed');
+      const query = body['query'] && typeof body['query'] === 'object' ? body['query'] as Record<string, unknown> : {};
+      response.json({ success: true, data: await input.domestic.request(path, { method: 'GET', params: query }), requestId: _requestId });
+      return true;
+    }
+    if (action.startsWith('upstream.')) {
+      const provider = action.slice('upstream.'.length);
+      const path = String(body.path ?? '').trim();
+      const allowed = provider === 'amazon' ? AMAZON_PATHS : provider === 'sorftime' ? SORFTIME_PATHS : provider === 'tikhub' ? TIKHUB_PATHS : [...DOMESTIC_PATHS].map((item) => new RegExp(`^/${item}$`));
+      if (!allowed.some((pattern) => typeof pattern === 'string' ? path === `/${pattern}` : pattern.test(path))) throw new ApiError(400, 'upstream_path_not_allowed');
+      throw new ApiError(503, 'upstream_not_configured');
+    }
+    return false;
+  };
+}

+ 17 - 1
src/config/env.ts

@@ -34,11 +34,17 @@ const environmentSchema = z.object({
   FMODE_RETRIES: z.coerce.number().int().min(0).max(5).default(2),
   FMODE_AI_BASE_URL: z.url().default('https://api.fmode.cn'),
   FMODE_AI_TOKEN: optionalNonEmptyString,
+  AI_API_KEY: optionalNonEmptyString,
   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),
   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),
+  JD_VOC_ENABLED: z.enum(['true', 'false']).default('false'),
+  JD_VOC_AI_ENABLED: z.enum(['true', 'false']).default('false'),
+  JD_VOC_AI_MODEL: z.string().min(1).default('gpt-4o-mini'),
+  JD_VOC_IMAGE_SHADOW_ENABLED: z.enum(['true', 'false']).default('false'),
+  JD_VOC_DISPLAY_DEFAULT: z.enum(['true', 'false']).default('false'),
   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_JOB_STALE_AFTER_MS: z.coerce.number().int().min(60_000).max(86_400_000).default(900_000),
@@ -100,6 +106,11 @@ export type AppConfig = {
     model: string;
     concurrency: number;
     maxAiItemsPerJob: number;
+    jdVocEnabled: boolean;
+    jdVocAiEnabled: boolean;
+    jdVocAiModel: string;
+    jdVocImageShadowEnabled: boolean;
+    jdVocDisplayDefault: boolean;
   };
   worker: {
     enabled: boolean;
@@ -193,7 +204,7 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
     },
     ai: {
       baseUrl: value.FMODE_AI_BASE_URL.replace(/\/+$/, ''),
-      token: value.FMODE_AI_TOKEN ?? '',
+      token: value.FMODE_AI_TOKEN ?? value.AI_API_KEY ?? '',
       defaultModel: value.FMODE_AI_MODEL,
       timeoutMs: value.FMODE_AI_TIMEOUT_MS,
     },
@@ -201,6 +212,11 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
       model: value.LISTING_AI_MODEL,
       concurrency: value.LISTING_AI_CONCURRENCY,
       maxAiItemsPerJob: value.LISTING_AI_MAX_ITEMS_PER_JOB,
+      jdVocEnabled: value.JD_VOC_ENABLED === 'true',
+      jdVocAiEnabled: value.JD_VOC_AI_ENABLED === 'true',
+      jdVocAiModel: value.JD_VOC_AI_MODEL,
+      jdVocImageShadowEnabled: value.JD_VOC_IMAGE_SHADOW_ENABLED === 'true',
+      jdVocDisplayDefault: value.JD_VOC_DISPLAY_DEFAULT === 'true',
     },
     worker: {
       enabled: value.SYNC_WORKER_ENABLED === 'true',

+ 17 - 2
src/db/parse-rest.schema.ts

@@ -37,8 +37,11 @@ export const VOC_PARSE_CLASSES = {
   competitorListingSnapshot: 'VocCompetitorListingSnapshot',
   competitorListingChange: 'VocCompetitorListingChange',
   competitorListingRefreshRun: 'VocCompetitorListingRefreshRun',
+  competitorOptimizationTask: 'VocCompetitorOptimizationTask',
 } as const;
 
+export const JD_VOC_SCORE_SLOTS = ['jd_voc_rules', 'jd_voc_hybrid_ai'] as const;
+
 export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
   {
     className: VOC_PARSE_CLASSES.workspace,
@@ -130,7 +133,7 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
     className: VOC_PARSE_CLASSES.workspaceMember,
     fields: {
       naturalKey: string(true), workspaceId: string(true), userId: string(true), email: string(),
-      displayName: string(), role: string(true), status: string(true),
+      displayName: string(), role: string(true), status: string(true), productIds: array(),
     },
     indexes: indexes('voc_member', 'naturalKey', 'workspaceId', 'userId', 'role', 'status'),
   },
@@ -285,7 +288,11 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       slot: string(true), sourceHash: string(true), rubricVersion: string(true), overallScore: number(), knownOverallScore: number(), knownOverallMaxScore: number(),
       scoreKind: string(), aiStatus: string(), complianceStatus: string(), executionKey: string(), inputFingerprint: string(), payload: object(true), scoredAt: date(true),
     },
-    indexes: indexes('voc_listing_current_score', 'publicId', 'naturalKey', 'workspaceId', 'productId', 'slot', 'sourceHash', 'executionKey', 'scoredAt'),
+    indexes: {
+      ...indexes('voc_listing_current_score', 'publicId', 'naturalKey', 'workspaceId', 'productId', 'slot', 'sourceHash', 'executionKey', 'scoredAt'),
+      voc_listing_current_score_rubric_version_idx: { workspaceId: 1, productId: 1, rubricVersion: 1 },
+      voc_listing_current_score_kind_idx: { workspaceId: 1, productId: 1, scoreKind: 1 },
+    },
   },
   {
     className: VOC_PARSE_CLASSES.listingVersion,
@@ -351,6 +358,14 @@ export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
       'requestedAt',
     ),
   },
+  {
+    className: VOC_PARSE_CLASSES.competitorOptimizationTask,
+    fields: {
+      publicId: string(true), workspaceId: string(true), competitorSnapshotId: string(true), ownProductId: string(true),
+      dimension: string(true), creationKey: string(true), status: string(true), impactScore: number(), beforeScore: number(), afterScore: number(), delta: number(), effectiveness: string(),
+    },
+    indexes: indexes('voc_competitor_optimization_task', 'publicId', 'workspaceId', 'creationKey', 'status', 'competitorSnapshotId', 'ownProductId'),
+  },
 ];
 
 export interface ParseSchemaSyncResult {

+ 21 - 7
src/local-app.ts

@@ -1,5 +1,5 @@
 import cors from 'cors';
-import express, { type ErrorRequestHandler } from 'express';
+import express, { Router, type ErrorRequestHandler } from 'express';
 import { ZodError } from 'zod';
 import type { DomesticDataset } from './types/domestic-dataset.js';
 import { LocalSnapshotService } from './modules/domestic-voc/local/local-snapshot.service.js';
@@ -20,6 +20,9 @@ import { listingSourcesFromDomesticDataset } from './modules/listing-ai/normaliz
 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';
+import { createCloudFunctionRouter, dispatchThroughRouter } from './cloud-functions/router.js';
+import { createSpecialActionHandler } from './cloud-functions/special-actions.js';
+import { FmodeVocEcommerceClient } from './modules/domestic-voc/upstream/fmode-client.js';
 
 export function createLocalDemoApp(input: {
   dataset: DomesticDataset;
@@ -29,6 +32,7 @@ export function createLocalDemoApp(input: {
   listingAiModel?: string;
   productKnowledge?: ProductKnowledgeStore;
   listingAiRepository?: ListingAiRepository;
+  listingAiService?: ListingAiService;
 }) {
   const app = express();
   app.disable('x-powered-by');
@@ -57,7 +61,9 @@ export function createLocalDemoApp(input: {
     defaultModel: 'deepseek-v4-pro',
     timeoutMs: 120_000,
   });
-  app.use('/api/ai', createAiGatewayRouter(aiClient));
+  const domesticGateway = new FmodeVocEcommerceClient({ baseUrl: 'http://127.0.0.1', apiKey: '', timeoutMs: 1_000, retries: 0 });
+  const businessRouter = Router();
+  businessRouter.use('/ai', createAiGatewayRouter(aiClient));
   const sync = new SyncService(jobs);
   const snapshot = new LocalSnapshotService(input.dataset, workspaceId);
   const productKnowledge = input.productKnowledge ?? new LocalProductKnowledgeStore(input.dataset, workspaceId);
@@ -79,7 +85,7 @@ export function createLocalDemoApp(input: {
     });
   });
 
-  app.use('/api/domestic-voc', createDomesticVocRouter({
+  businessRouter.use('/domestic-voc', createDomesticVocRouter({
     jobs,
     sync,
     snapshot,
@@ -87,24 +93,32 @@ export function createLocalDemoApp(input: {
     access,
     defaultWorkspaceId: workspaceId,
   }));
-  app.use('/api/saas', createSaasPlatformRouter({ repository: platform, access }));
+  businessRouter.use('/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({
+  const listingAi = input.listingAiService ?? new ListingAiService(listingRepository, new FmodeListingAiScoringProvider(aiClient, input.listingAiModel ?? 'deepseek-v4-flash'));
+  businessRouter.use('/listing-ai', createListingAiRouter({
     service: listingAi,
     access,
     audit: platform,
     defaultWorkspaceId: workspaceId,
   }));
-  app.use('/api/knowledge', createProductKnowledgeRouter({
+  businessRouter.use('/knowledge', createProductKnowledgeRouter({
     store: productKnowledge,
     repository: platform,
     access,
     defaultWorkspaceId: workspaceId,
   }));
 
+  app.use('/api', businessRouter);
+  app.use('/api/functions', createCloudFunctionRouter({
+    defaultWorkspaceId: workspaceId,
+    specialAction: createSpecialActionHandler({ ai: aiClient, domestic: domesticGateway }),
+    dispatch: (request, response, actionPath, method, body, requestId) =>
+      dispatchThroughRouter(businessRouter, request, response, actionPath, method, body, requestId),
+  }));
+
   app.use((_request, response) => {
     response.status(404).json({ error: 'not_found' });
   });

+ 1 - 1
src/modules/ai-gateway/routes.ts

@@ -64,7 +64,7 @@ export function createAiGatewayRouter(client: FmodeAiClient, promptConfigs?: AiP
       configured: client.configured,
       baseUrl: publicBaseUrl(client.config.baseUrl),
       defaultModel: client.config.defaultModel,
-      proxyEndpoint: '/api/ai/chat/completions',
+      proxyEndpoint: 'ai.chat',
     });
   });
 

+ 55 - 0
src/modules/competitor-listing-monitor/alerts.ts

@@ -0,0 +1,55 @@
+import { createHash } from 'node:crypto';
+import type { CompetitorListingChange } from './domain.js';
+
+export type CompetitorListingAlertRule = 'price_change' | 'availability_change' | 'title_change' | 'main_image_change' | 'specification_change' | 'multi_competitor_direction';
+export interface CompetitorListingAlert {
+  id: string;
+  workspaceId: string;
+  platform: 'jd';
+  rule: CompetitorListingAlertRule;
+  productIds: string[];
+  changeIds: string[];
+  detectedAt: string;
+  evidence: CompetitorListingChange['changes'];
+}
+
+export function buildCompetitorListingAlerts(changes: CompetitorListingChange[]): CompetitorListingAlert[] {
+  const alerts: CompetitorListingAlert[] = [];
+  for (const change of changes) {
+    const rules: CompetitorListingAlertRule[] = [];
+    if (change.changeTypes.includes('price')) rules.push('price_change');
+    if (change.changeTypes.includes('availability')) rules.push('availability_change');
+    if (change.changeTypes.includes('title')) rules.push('title_change');
+    if (change.changeTypes.includes('main_image')) rules.push('main_image_change');
+    if (change.changeTypes.includes('key_specifications')) rules.push('specification_change');
+    for (const rule of rules) alerts.push(alertFor(rule, [change]));
+  }
+  const grouped = new Map<string, CompetitorListingChange[]>();
+  for (const change of changes) {
+    for (const type of change.changeTypes) {
+      const direction = directionFor(change, type);
+      if (direction) {
+        const key = `${type}:${direction}`;
+        grouped.set(key, [...(grouped.get(key) ?? []), change]);
+      }
+    }
+  }
+  for (const group of grouped.values()) {
+    const unique = [...new Map(group.map((change) => [change.productId, change])).values()];
+    if (unique.length >= 2) alerts.push(alertFor('multi_competitor_direction', unique));
+  }
+  return alerts.sort((left, right) => right.detectedAt.localeCompare(left.detectedAt) || left.id.localeCompare(right.id));
+}
+
+function alertFor(rule: CompetitorListingAlertRule, changes: CompetitorListingChange[]): CompetitorListingAlert {
+  const first = changes[0]!;
+  const key = `${rule}:${changes.map((change) => change.id).sort().join(',')}`;
+  return { id: createHash('sha256').update(key).digest('hex').slice(0, 32), workspaceId: first.workspaceId, platform: first.platform, rule, productIds: [...new Set(changes.map((change) => change.productId))].sort(), changeIds: changes.map((change) => change.id).sort(), detectedAt: changes.reduce((latest, change) => change.detectedAt > latest ? change.detectedAt : latest, first.detectedAt), evidence: changes.flatMap((change) => change.changes) };
+}
+
+function directionFor(change: CompetitorListingChange, type: CompetitorListingChange['changeTypes'][number]): 'up' | 'down' | 'changed' | null {
+  const item = change.changes.find((candidate) => (type === 'price' ? candidate.field === 'priceCents' : type === 'title' ? candidate.field === 'title' : false));
+  if (!item) return type === 'availability' || type === 'main_image' || type === 'key_specifications' ? 'changed' : null;
+  if (typeof item.before === 'number' && typeof item.after === 'number') return item.after > item.before ? 'up' : item.after < item.before ? 'down' : null;
+  return item.before === item.after ? null : 'changed';
+}

+ 160 - 1
src/modules/competitor-listing-monitor/competitor-listing-monitor.service.ts

@@ -17,6 +17,21 @@ import type {
   CompetitorListingSnapshot,
 } from './domain.js';
 import { decideCompetitorListingSnapshot } from './snapshot-diff.js';
+import { buildCompetitorListingAlerts, type CompetitorListingAlert } from './alerts.js';
+import { analyzeCompetitorListingImpact, type CompetitorListingImpact } from './impact-analysis.js';
+import type { ListingScoreResult } from '../listing-ai/domain.js';
+import { createOptimizationTask, updateOptimizationTask, type CompetitorOptimizationTask, type CompetitorOptimizationTaskInput, type CompetitorOptimizationTaskStatus } from './optimization-task.js';
+import { validateListingOptimization } from './validation.js';
+
+export interface CompetitorListingScoreReader {
+  listCurrentScores(workspaceId: string): Promise<ListingScoreResult[]>;
+}
+export interface CompetitorOptimizationTaskRepository {
+  listOptimizationTasks(workspaceId: string): Promise<CompetitorOptimizationTask[]>;
+  createOptimizationTask(task: CompetitorOptimizationTask): Promise<CompetitorOptimizationTask>;
+  getOptimizationTask(workspaceId: string, id: string): Promise<CompetitorOptimizationTask | null>;
+  updateOptimizationTask(task: CompetitorOptimizationTask): Promise<CompetitorOptimizationTask>;
+}
 
 export interface CompetitorListingMonitorTarget {
   productId: string;
@@ -42,6 +57,25 @@ export interface CompetitorListingMonitorRepository {
   listRuns(workspaceId: string, platform: CompetitorListingPlatform): Promise<CompetitorListingRefreshRun[]>;
 }
 
+export interface CompetitorListingHistoryQuery {
+  /** Restrict returned changes to one or more observed fields. */
+  fields?: CompetitorListingChange['changeTypes'];
+}
+
+export interface CompetitorListingHistoryRun {
+  run: CompetitorListingRefreshRun;
+  result: CompetitorListingRefreshItemResult | null;
+}
+
+export interface CompetitorListingHistory {
+  workspaceId: string;
+  platform: CompetitorListingPlatform;
+  productId: string;
+  snapshots: CompetitorListingSnapshot[];
+  changes: CompetitorListingChange[];
+  collectionRuns: CompetitorListingHistoryRun[];
+}
+
 export interface CompetitorListingGateway {
   request<T>(
     path: string,
@@ -67,12 +101,15 @@ const EMPTY_SPECIFICATIONS: CompetitorListingKeySpecifications = {
 
 export class CompetitorListingMonitorService {
   private readonly activeWorkspaceKeys = new Set<string>();
+  private readonly optimizationTasks: CompetitorOptimizationTask[] = [];
 
   constructor(
     readonly repository: CompetitorListingMonitorRepository,
     private readonly gateway: CompetitorListingGateway,
     private readonly now: () => Date = () => new Date(),
     private readonly concurrency = 3,
+    private readonly scoreReader?: CompetitorListingScoreReader,
+    private readonly taskRepository?: CompetitorOptimizationTaskRepository,
   ) {}
 
   async overview(workspaceId: string, platform: CompetitorListingPlatform): Promise<CompetitorListingOverview> {
@@ -194,10 +231,124 @@ export class CompetitorListingMonitorService {
     }
   }
 
+  /**
+   * Resume one persisted refresh after a process restart or after a managed
+   * Cloud Function has enqueued it.  The persisted run is the source of truth;
+   * completed products are not collected again.
+   */
+  async resumePendingRefreshes(
+    workspaceId: string,
+    platform: CompetitorListingPlatform,
+  ): Promise<number> {
+    const workspaceKey = `${workspaceId}:${platform}`;
+    if (this.activeWorkspaceKeys.has(workspaceKey)) return 0;
+    const run = (await this.repository.listRuns(workspaceId, platform))
+      .filter((candidate) => candidate.status === 'queued' || candidate.status === 'running')
+      .toSorted((left, right) => left.requestedAt.localeCompare(right.requestedAt))[0];
+    if (!run) return 0;
+
+    this.activeWorkspaceKeys.add(workspaceKey);
+    try {
+      const targets = await this.repository.listTargets(workspaceId, platform);
+      const targetIds = new Set(targets.map((target) => target.productId));
+      const retainedResults = run.itemResults.filter((result) => targetIds.has(result.productId));
+      const completedIds = new Set(retainedResults.map((result) => result.productId));
+      const pendingTargets = targets.filter((target) => !completedIds.has(target.productId));
+      const resumableRun: CompetitorListingRefreshRun = {
+        ...run,
+        total: targets.length,
+        completed: retainedResults.length,
+        baseline: retainedResults.filter((result) => result.status === 'baseline').length,
+        unchanged: retainedResults.filter((result) => result.status === 'unchanged').length,
+        changed: retainedResults.filter((result) => result.status === 'changed').length,
+        failed: retainedResults.filter((result) => result.status === 'failed').length,
+        itemResults: retainedResults,
+      };
+      queueMicrotask(() => {
+        void this.processRefresh(resumableRun, pendingTargets, workspaceKey);
+      });
+      return 1;
+    } catch (error) {
+      this.activeWorkspaceKeys.delete(workspaceKey);
+      throw error;
+    }
+  }
+
   async getRun(workspaceId: string, runId: string): Promise<CompetitorListingRefreshRun | null> {
     return this.repository.getRun(workspaceId, runId);
   }
 
+  /** Return immutable snapshot history, field-level diffs and collection outcomes for one product. */
+  async history(
+    workspaceId: string,
+    platform: CompetitorListingPlatform,
+    productId: string,
+    query: CompetitorListingHistoryQuery = {},
+  ): Promise<CompetitorListingHistory> {
+    const normalizedProductId = productId.trim();
+    const [snapshots, changes, runs] = await Promise.all([
+      this.repository.listSnapshots(workspaceId, platform),
+      this.repository.listChanges(workspaceId, platform),
+      this.repository.listRuns(workspaceId, platform),
+    ]);
+    const fields = query.fields?.length ? new Set(query.fields) : null;
+    const productSnapshots = snapshots
+      .filter((snapshot) => snapshot.productId === normalizedProductId)
+      .toSorted(compareObservedAtDescending);
+    const productChanges = changes
+      .filter((change) => change.productId === normalizedProductId)
+      .filter((change) => !fields || change.changeTypes.some((type) => fields.has(type)))
+      .map((change) => fields
+        ? { ...change, changeTypes: change.changeTypes.filter((type) => fields.has(type)), changes: change.changes.filter((item) => fields.has(fieldToChangeType(item.field))) }
+        : change)
+      .toSorted((left, right) => right.detectedAt.localeCompare(left.detectedAt) || right.id.localeCompare(left.id));
+    const collectionRuns = runs
+      .map((run) => ({ run, result: run.itemResults.find((result) => result.productId === normalizedProductId) ?? null }))
+      .filter((entry) => entry.result !== null)
+      .toSorted((left, right) => right.run.requestedAt.localeCompare(left.run.requestedAt));
+    return { workspaceId, platform, productId: normalizedProductId, snapshots: productSnapshots, changes: productChanges, collectionRuns };
+  }
+
+  async alerts(workspaceId: string, platform: CompetitorListingPlatform): Promise<CompetitorListingAlert[]> {
+    const changes = await this.repository.listChanges(workspaceId, platform);
+    return buildCompetitorListingAlerts(changes);
+  }
+
+  async impacts(workspaceId: string, platform: CompetitorListingPlatform): Promise<CompetitorListingImpact[]> {
+    const [changes, targets, scores] = await Promise.all([
+      this.repository.listChanges(workspaceId, platform),
+      this.repository.listTargets(workspaceId, platform),
+      this.scoreReader?.listCurrentScores(workspaceId) ?? Promise.resolve([]),
+    ]);
+    const targetById = new Map(targets.map((target) => [target.productId, target]));
+    const scoreByProduct = new Map(scores.map((score) => [score.productId, score]));
+    return changes.flatMap((change) => {
+      const target = targetById.get(change.productId);
+      if (!target?.relatedProducts.length) return [];
+      return target.relatedProducts.map((related) => analyzeCompetitorListingImpact(change, related.productId, scoreByProduct.get(related.productId) ?? null));
+    });
+  }
+
+  async createOptimizationTask(input: CompetitorOptimizationTaskInput): Promise<{ task: CompetitorOptimizationTask; idempotent: boolean }> {
+    if (!this.taskRepository) return createOptimizationTask(this.optimizationTasks, input);
+    const candidate = createOptimizationTask([], input).task;
+    const existing = (await this.taskRepository.listOptimizationTasks(input.workspaceId)).find((task) => task.creationKey === candidate.creationKey);
+    if (existing) return { task: existing, idempotent: true };
+    return { task: await this.taskRepository.createOptimizationTask(candidate), idempotent: false };
+  }
+
+  async listOptimizationTasks(workspaceId: string): Promise<CompetitorOptimizationTask[]> {
+    if (this.taskRepository) return this.taskRepository.listOptimizationTasks(workspaceId);
+    return this.optimizationTasks.filter((task) => task.workspaceId === workspaceId).map((task) => structuredClone(task));
+  }
+
+  async updateOptimizationTask(workspaceId: string, id: string, status: CompetitorOptimizationTaskStatus, scores: { beforeScore?: number | null; afterScore?: number | null } = {}): Promise<CompetitorOptimizationTask | null> {
+    if (this.taskRepository) { const found = await this.taskRepository.getOptimizationTask(workspaceId, id); if (!found) return null; const next = updateOptimizationTask(found, status); if (scores.beforeScore !== undefined || scores.afterScore !== undefined) { const validation = validateListingOptimization(next.dimension as ListingScoreResult['dimensions'][number]['dimension'], scores.beforeScore ?? next.beforeScore, scores.afterScore ?? next.afterScore); Object.assign(next, { beforeScore: validation.beforeScore, afterScore: validation.afterScore, delta: validation.delta, effectiveness: validation.outcome }); } return this.taskRepository.updateOptimizationTask(next); }
+    const task = this.optimizationTasks.find((item) => item.workspaceId === workspaceId && item.id === id);
+    if (!task) return null;
+    const updated = updateOptimizationTask(task, status); Object.assign(task, updated); if (scores.beforeScore !== undefined || scores.afterScore !== undefined) { const validation = validateListingOptimization(task.dimension as ListingScoreResult['dimensions'][number]['dimension'], scores.beforeScore ?? task.beforeScore, scores.afterScore ?? task.afterScore); Object.assign(task, validation); } return structuredClone(task);
+  }
+
   private async processRefresh(
     initialRun: CompetitorListingRefreshRun,
     targets: CompetitorListingMonitorTarget[],
@@ -206,7 +357,7 @@ export class CompetitorListingMonitorService {
     let run: CompetitorListingRefreshRun = {
       ...initialRun,
       status: 'running',
-      startedAt: this.now().toISOString(),
+      startedAt: initialRun.startedAt ?? this.now().toISOString(),
     };
     let partialCollections = 0;
     try {
@@ -410,6 +561,14 @@ export class CompetitorListingMonitorService {
   }
 }
 
+function fieldToChangeType(field: string): CompetitorListingChange['changeTypes'][number] {
+  if (field === 'priceCents') return 'price';
+  if (field === 'availability') return 'availability';
+  if (field === 'title') return 'title';
+  if (field === 'mainImageUrl') return 'main_image';
+  return 'key_specifications';
+}
+
 function failure(
   workspaceId: string,
   platform: CompetitorListingPlatform,

+ 17 - 0
src/modules/competitor-listing-monitor/impact-analysis.ts

@@ -0,0 +1,17 @@
+import type { CompetitorListingChange } from './domain.js';
+import type { ListingDimension, ListingScoreResult } from '../listing-ai/domain.js';
+
+export interface CompetitorListingImpact {
+  competitorProductId: string; ownProductId: string; changeId: string; currentSnapshotId: string; previousSnapshotId: string;
+  weakestDimension: ListingDimension | null; improvementPotential: number | null; impactScore: number | null;
+  scoreSource: { scoreId: string; sourceHash: string } | null; explanation: string;
+}
+
+/** Deterministic, explainable impact score: change breadth (0..1) × listing headroom (0..1). */
+export function analyzeCompetitorListingImpact(change: CompetitorListingChange, ownProductId: string, score: ListingScoreResult | null): CompetitorListingImpact {
+  const weakest = score?.dimensions.filter((dimension) => dimension.score !== null).sort((a, b) => (a.score! / a.maxScore) - (b.score! / b.maxScore))[0]?.dimension ?? null;
+  const potential = score?.dimensions.every((dimension) => dimension.score !== null) ? Number((score.dimensions.reduce((sum, dimension) => sum + (dimension.maxScore - (dimension.score ?? 0)), 0) / score.dimensions.reduce((sum, dimension) => sum + dimension.maxScore, 0) * 100).toFixed(2)) : null;
+  const breadth = Math.min(1, change.changeTypes.length / 5);
+  const impactScore = potential === null ? null : Number((breadth * potential).toFixed(2));
+  return { competitorProductId: change.productId, ownProductId, changeId: change.id, currentSnapshotId: change.currentSnapshotId, previousSnapshotId: change.previousSnapshotId, weakestDimension: weakest, improvementPotential: potential, impactScore, scoreSource: score ? { scoreId: score.id, sourceHash: score.sourceHash } : null, explanation: score ? `变化字段 ${change.changeTypes.join('、')};本品最弱维度 ${weakest ?? '未知'},可提升空间 ${potential ?? '未知'}。` : '本品暂无正式 Listing 评分,无法计算影响分。' };
+}

+ 12 - 0
src/modules/competitor-listing-monitor/optimization-task.ts

@@ -0,0 +1,12 @@
+import { createHash, randomUUID } from 'node:crypto';
+import { ApiError } from '../../http/api-error.js';
+
+export type CompetitorOptimizationTaskStatus = 'open' | 'in_progress' | 'completed' | 'ignored';
+export interface CompetitorOptimizationTask { id: string; workspaceId: string; competitorSnapshotId: string; ownProductId: string; dimension: string; creationKey: string; status: CompetitorOptimizationTaskStatus; impactScore: number | null; beforeScore: number | null; afterScore: number | null; delta: number | null; effectiveness: 'effective'|'partially_effective'|'ineffective'|'not_measured'|null; createdAt: string; updatedAt: string; }
+export type CompetitorOptimizationTaskInput = Pick<CompetitorOptimizationTask, 'workspaceId' | 'competitorSnapshotId' | 'ownProductId' | 'dimension' | 'impactScore'> & { now?: string };
+export function optimizationTaskKey(snapshotId: string, ownProductId: string, dimension: string): string { return createHash('sha256').update(`${snapshotId}:${ownProductId}:${dimension}`).digest('hex'); }
+export function createOptimizationTask(existing: CompetitorOptimizationTask[], input: CompetitorOptimizationTaskInput): { task: CompetitorOptimizationTask; idempotent: boolean } {
+  const creationKey = optimizationTaskKey(input.competitorSnapshotId, input.ownProductId, input.dimension); const prior = existing.find((task) => task.workspaceId === input.workspaceId && task.creationKey === creationKey); if (prior) return { task: prior, idempotent: true };
+  const { now: requestedAt, ...base } = input; const now = requestedAt ?? new Date().toISOString(); const task: CompetitorOptimizationTask = { ...base, id: randomUUID(), creationKey, status: 'open', beforeScore: null, afterScore: null, delta: null, effectiveness: null, createdAt: now, updatedAt: now }; existing.push(task); return { task, idempotent: false };
+}
+export function updateOptimizationTask(task: CompetitorOptimizationTask, status: CompetitorOptimizationTaskStatus): CompetitorOptimizationTask { if (!['open','in_progress','completed','ignored'].includes(status)) throw new ApiError(400,'invalid_optimization_task_status'); return { ...task, status, updatedAt: new Date().toISOString() }; }

+ 27 - 1
src/modules/competitor-listing-monitor/repositories/parse-rest-competitor-listing-monitor.repository.ts

@@ -11,7 +11,9 @@ import type {
 import type {
   CompetitorListingMonitorRepository,
   CompetitorListingMonitorTarget,
+  CompetitorOptimizationTaskRepository,
 } from '../competitor-listing-monitor.service.js';
+import type { CompetitorOptimizationTask } from '../optimization-task.js';
 
 interface RelationObject {
   competitorProductId: string;
@@ -58,7 +60,7 @@ const EMPTY_SPECIFICATIONS: CompetitorListingKeySpecifications = {
   heightMm: null,
 };
 
-export class ParseRestCompetitorListingMonitorRepository implements CompetitorListingMonitorRepository {
+export class ParseRestCompetitorListingMonitorRepository implements CompetitorListingMonitorRepository, CompetitorOptimizationTaskRepository {
   constructor(private readonly client: ParseRestClient) {}
 
   async listTargets(
@@ -225,8 +227,32 @@ export class ParseRestCompetitorListingMonitorRepository implements CompetitorLi
     });
     return response.results.map(mapRun);
   }
+
+  async listOptimizationTasks(workspaceId: string): Promise<CompetitorOptimizationTask[]> {
+    const rows = await this.client.findAll<StoredOptimizationTask>(VOC_PARSE_CLASSES.competitorOptimizationTask, { workspaceId });
+    return rows.map(mapOptimizationTask);
+  }
+
+  async createOptimizationTask(task: CompetitorOptimizationTask): Promise<CompetitorOptimizationTask> {
+    const existing = await this.client.findOne<StoredOptimizationTask>(VOC_PARSE_CLASSES.competitorOptimizationTask, { workspaceId: task.workspaceId, creationKey: task.creationKey });
+    if (existing) return mapOptimizationTask(existing);
+    await this.client.create(VOC_PARSE_CLASSES.competitorOptimizationTask, { publicId: task.id, workspaceId: task.workspaceId, competitorSnapshotId: task.competitorSnapshotId, ownProductId: task.ownProductId, dimension: task.dimension, creationKey: task.creationKey, status: task.status, impactScore: task.impactScore, beforeScore: task.beforeScore, afterScore: task.afterScore, delta: task.delta, effectiveness: task.effectiveness });
+    return task;
+  }
+
+  async getOptimizationTask(workspaceId: string, id: string): Promise<CompetitorOptimizationTask | null> {
+    const row = await this.client.findOne<StoredOptimizationTask>(VOC_PARSE_CLASSES.competitorOptimizationTask, { workspaceId, publicId: id }); return row ? mapOptimizationTask(row) : null;
+  }
+
+  async updateOptimizationTask(task: CompetitorOptimizationTask): Promise<CompetitorOptimizationTask> {
+    const row = await this.client.findOne<StoredOptimizationTask>(VOC_PARSE_CLASSES.competitorOptimizationTask, { workspaceId: task.workspaceId, publicId: task.id }); if (!row) throw new Error('Competitor optimization task was not found');
+    await this.client.update(VOC_PARSE_CLASSES.competitorOptimizationTask, row.objectId, { status: task.status, beforeScore: task.beforeScore, afterScore: task.afterScore, delta: task.delta, effectiveness: task.effectiveness, impactScore: task.impactScore }); return task;
+  }
 }
 
+type StoredOptimizationTask = Omit<CompetitorOptimizationTask, 'createdAt'|'updatedAt'> & { publicId: string; createdAt: unknown; updatedAt: unknown };
+function mapOptimizationTask(row: StoredOptimizationTask & ParseObject): CompetitorOptimizationTask { const createdValue = row.createdAt as unknown; const fallback = createdValue && typeof createdValue === 'object' && 'iso' in createdValue && typeof (createdValue as { iso?: unknown }).iso === 'string' ? (createdValue as { iso: string }).iso : new Date().toISOString(); return { id: row.publicId || row.objectId, workspaceId: row.workspaceId, competitorSnapshotId: row.competitorSnapshotId, ownProductId: row.ownProductId, dimension: row.dimension, creationKey: row.creationKey, status: row.status, impactScore: row.impactScore ?? null, beforeScore: row.beforeScore ?? null, afterScore: row.afterScore ?? null, delta: row.delta ?? null, effectiveness: row.effectiveness ?? null, createdAt: dateIso(row.createdAt, fallback), updatedAt: dateIso(row.updatedAt, fallback) }; }
+
 function mapSnapshot(row: StoredSnapshot & ParseObject): CompetitorListingSnapshot {
   return {
     id: row.publicId || row.objectId,

+ 52 - 0
src/modules/competitor-listing-monitor/routes.ts

@@ -6,6 +6,13 @@ import {
   competitorListingOverviewResponseSchema,
   competitorListingRefreshRequestSchema,
   competitorListingRefreshResponseSchema,
+  competitorListingHistoryQuerySchema,
+  competitorListingHistoryResponseSchema,
+  competitorListingAlertsResponseSchema,
+  competitorListingImpactsResponseSchema,
+  competitorOptimizationTaskCreateSchema,
+  competitorOptimizationTaskPatchSchema,
+  competitorOptimizationTaskSchema,
   competitorListingRunIdSchema,
   competitorListingRunResponseSchema,
   competitorListingWorkspaceSchema,
@@ -45,6 +52,51 @@ export function createCompetitorListingMonitorRouter(input: {
     } catch (error) { next(error); }
   });
 
+  router.get('/history/:productId', async (request, response, next) => {
+    try {
+      const query = competitorListingHistoryQuerySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? input.defaultWorkspaceId;
+      const productId = String(request.params.productId ?? '').trim();
+      if (!productId) throw new ApiError(400, 'competitor_listing_product_required');
+      await input.access.require(request, workspaceId, 'workspace:read');
+      const selectedFields = query.field ?? query.fields;
+      const history = await input.service.history(workspaceId, query.platform, productId, selectedFields ? { fields: selectedFields } : {});
+      response.json(competitorListingHistoryResponseSchema.parse(history));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/alerts', async (request, response, next) => {
+    try {
+      const query = competitorListingWorkspaceSchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? input.defaultWorkspaceId;
+      await input.access.require(request, workspaceId, 'workspace:read');
+      const alerts = await input.service.alerts(workspaceId, query.platform);
+      response.json(competitorListingAlertsResponseSchema.parse({ alerts }));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/impacts', async (request, response, next) => {
+    try {
+      const query = competitorListingWorkspaceSchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? input.defaultWorkspaceId;
+      await input.access.require(request, workspaceId, 'workspace:read');
+      const impacts = await input.service.impacts(workspaceId, query.platform);
+      response.json(competitorListingImpactsResponseSchema.parse({ impacts }));
+    } catch (error) { next(error); }
+  });
+
+  router.get('/optimization-tasks', async (request, response, next) => {
+    try { const query = competitorListingWorkspaceSchema.parse(request.query); const workspaceId = query.workspaceId ?? input.defaultWorkspaceId; await input.access.require(request, workspaceId, 'workspace:read'); response.json({ tasks: (await input.service.listOptimizationTasks(workspaceId)).map((task) => competitorOptimizationTaskSchema.parse(task)) }); } catch (error) { next(error); }
+  });
+
+  router.post('/optimization-tasks', async (request, response, next) => {
+    try { const body = competitorOptimizationTaskCreateSchema.parse(request.body); const workspaceId = body.workspaceId ?? input.defaultWorkspaceId; await input.access.require(request, workspaceId, 'action:write'); const result = await input.service.createOptimizationTask({ workspaceId, competitorSnapshotId: body.competitorSnapshotId, ownProductId: body.ownProductId, dimension: body.dimension, impactScore: body.impactScore }); response.status(result.idempotent ? 200 : 201).json({ task: competitorOptimizationTaskSchema.parse(result.task), idempotent: result.idempotent }); } catch (error) { next(error); }
+  });
+
+  router.patch('/optimization-tasks/:id', async (request, response, next) => {
+    try { const query = competitorListingWorkspaceSchema.parse(request.query); const workspaceId = query.workspaceId ?? input.defaultWorkspaceId; await input.access.require(request, workspaceId, 'action:write'); const body = competitorOptimizationTaskPatchSchema.parse(request.body); const scores = Object.fromEntries(Object.entries({ beforeScore: body.beforeScore, afterScore: body.afterScore }).filter(([, value]) => value !== undefined)) as { beforeScore?: number | null; afterScore?: number | null }; const task = await input.service.updateOptimizationTask(workspaceId, request.params.id, body.status ?? 'in_progress', scores); if (!task) throw new ApiError(404, 'competitor_optimization_task_not_found'); response.json({ task: competitorOptimizationTaskSchema.parse(task) }); } catch (error) { next(error); }
+  });
+
   router.get('/runs/:runId', async (request, response, next) => {
     try {
       const query = competitorListingWorkspaceSchema.parse(request.query);

+ 52 - 0
src/modules/competitor-listing-monitor/schemas.ts

@@ -127,3 +127,55 @@ export const competitorListingRefreshResponseSchema = z.object({
 export const competitorListingRunResponseSchema = z.object({
   run: competitorListingRefreshRunSchema,
 });
+
+export const competitorListingHistoryQuerySchema = z.object({
+  workspaceId: z.string().trim().min(1).max(200).optional(),
+  platform: z.enum(['jd']).default('jd'),
+  // Accept both ?field=price&field=title and ?field=price,title.
+  field: z.preprocess((value) => {
+    if (Array.isArray(value)) return value.flatMap((item) => String(item).split(','));
+    if (typeof value === 'string') return value.split(',');
+    return undefined;
+  }, z.array(observedFieldSchema).optional()),
+  fields: z.preprocess((value) => {
+    if (Array.isArray(value)) return value.flatMap((item) => String(item).split(','));
+    if (typeof value === 'string') return value.split(',');
+    return undefined;
+  }, z.array(observedFieldSchema).optional()),
+});
+
+export const competitorListingHistoryResponseSchema = z.object({
+  workspaceId: z.string().min(1),
+  platform: z.literal('jd'),
+  productId: z.string().min(1),
+  snapshots: z.array(competitorListingSnapshotSchema),
+  changes: z.array(competitorListingChangeSchema),
+  collectionRuns: z.array(z.object({
+    run: competitorListingRefreshRunSchema,
+    result: competitorListingRefreshItemResultSchema.nullable(),
+  })),
+});
+
+export const competitorListingAlertsResponseSchema = z.object({
+  alerts: z.array(z.object({
+    id: z.string().min(1), workspaceId: z.string().min(1), platform: z.literal('jd'),
+    rule: z.enum(['price_change', 'availability_change', 'title_change', 'main_image_change', 'specification_change', 'multi_competitor_direction']),
+    productIds: z.array(z.string().min(1)), changeIds: z.array(z.string().min(1)), detectedAt: z.iso.datetime(),
+    evidence: z.array(z.object({ field: z.string(), before: z.unknown(), after: z.unknown() })),
+  })),
+});
+
+export const competitorListingImpactsResponseSchema = z.object({
+  impacts: z.array(z.object({
+    competitorProductId: z.string().min(1), ownProductId: z.string().min(1), changeId: z.string().min(1), currentSnapshotId: z.string().min(1), previousSnapshotId: z.string().min(1),
+    weakestDimension: z.enum(['title','selling_points','images','description','specifications']).nullable(), improvementPotential: z.number().min(0).max(100).nullable(), impactScore: z.number().min(0).max(100).nullable(),
+    scoreSource: z.object({ scoreId: z.string().min(1), sourceHash: z.string().min(1) }).nullable(), explanation: z.string(),
+  })),
+});
+
+export const competitorOptimizationTaskCreateSchema = z.object({
+  workspaceId: z.string().trim().min(1).max(200).optional(), platform: z.literal('jd').default('jd'),
+  competitorSnapshotId: z.string().trim().min(1).max(200), ownProductId: z.string().trim().min(1).max(200), dimension: z.enum(['title','selling_points','images','description','specifications']), impactScore: z.number().min(0).max(100).nullable().default(null),
+});
+export const competitorOptimizationTaskPatchSchema = z.object({ status: z.enum(['open','in_progress','completed','ignored']).optional(), beforeScore: z.number().min(0).max(100).nullable().optional(), afterScore: z.number().min(0).max(100).nullable().optional() }).refine((value) => Object.keys(value).length > 0);
+export const competitorOptimizationTaskSchema = z.object({ id:z.uuid(),workspaceId:z.string(),competitorSnapshotId:z.string(),ownProductId:z.string(),dimension:z.string(),creationKey:z.string(),status:z.enum(['open','in_progress','completed','ignored']),impactScore:z.number().nullable(),beforeScore:z.number().nullable(),afterScore:z.number().nullable(),delta:z.number().nullable(),effectiveness:z.enum(['effective','partially_effective','ineffective','not_measured']).nullable(),createdAt:z.iso.datetime(),updatedAt:z.iso.datetime() });

+ 4 - 0
src/modules/competitor-listing-monitor/validation.ts

@@ -0,0 +1,4 @@
+import type { ListingDimension } from '../listing-ai/domain.js';
+export type ListingOptimizationEffectiveness = 'effective' | 'partially_effective' | 'ineffective' | 'not_measured';
+export interface ListingOptimizationValidation { dimension: ListingDimension; beforeScore: number | null; afterScore: number | null; delta: number | null; outcome: ListingOptimizationEffectiveness; }
+export function validateListingOptimization(dimension: ListingDimension, beforeScore: number | null, afterScore: number | null): ListingOptimizationValidation { const delta = beforeScore != null && afterScore != null ? Number((afterScore - beforeScore).toFixed(2)) : null; const outcome: ListingOptimizationEffectiveness = delta === null ? 'not_measured' : delta >= 10 ? 'effective' : delta > 0 ? 'partially_effective' : 'ineffective'; return { dimension, beforeScore, afterScore, delta, outcome }; }

+ 151 - 5
src/modules/listing-ai/domain.ts

@@ -1,13 +1,143 @@
 export type ListingPlatform = 'jd';
 export type ListingCoverageStatus = 'eligible' | 'partial' | 'blocked';
 export type ListingDimension = 'title' | 'selling_points' | 'images' | 'description' | 'specifications';
+/** Versioned JD-VOC dimensions. Kept separate from the legacy five-dimension type. */
+export type JdVocDimensionKey = 'search' | 'voc' | 'selling' | 'facts' | 'competitive' | 'media';
+export type JdVocScoreKind = 'jd_voc_rules' | 'jd_voc_hybrid_ai';
 export type ListingScoreItemStatus = 'queued' | 'rules_scored' | 'ai_pending' | 'scored' | 'partial' | 'blocked' | 'failed';
 export type ListingScoreJobStatus = 'queued' | 'running' | 'completed' | 'partial' | 'failed' | 'cancelled';
 export type ListingProductScoreStatus = 'unscored' | 'scored' | 'partial' | 'blocked' | 'failed';
 export type ListingAiScoreStatus = 'not_scored' | 'completed' | 'partial' | 'failed';
 export type ListingRescorePolicy = 'reuse' | 'force';
 export type ListingComplianceStatus = 'normal' | 'warning' | 'needs_review' | 'blocked';
-export type ListingCurrentScoreSlot = 'rule_precheck' | 'formal_ai';
+export type ListingCurrentScoreSlot = 'rule_precheck' | 'formal_ai' | 'jd_voc_rules' | 'jd_voc_hybrid_ai';
+
+export const JD_VOC_RUBRIC_VERSION = 'jd-voc-v0.5' as const;
+export const JD_VOC_DIMENSION_MAX: Readonly<Record<JdVocDimensionKey, number>> = {
+  search: 25,
+  voc: 25,
+  selling: 15,
+  facts: 20,
+  competitive: 10,
+  media: 5,
+};
+
+export function jdVocScoreSlot(scoreKind: JdVocScoreKind): Extract<ListingCurrentScoreSlot, 'jd_voc_rules' | 'jd_voc_hybrid_ai'> {
+  return scoreKind === 'jd_voc_hybrid_ai' ? 'jd_voc_hybrid_ai' : 'jd_voc_rules';
+}
+
+export interface JdVocEvidence {
+  id: string;
+  ruleId: string;
+  fieldPath: string;
+  outcome: 'pass' | 'fail' | 'unknown';
+  message: string;
+  source: 'rule' | 'ai';
+  level?: 'unknown' | 'fail' | 'weak' | 'pass' | 'strong';
+  pointsAwarded: number | null;
+  maxPoints: number;
+  confidence?: number | null;
+  citations?: string[];
+}
+
+export interface JdVocDimensionScore {
+  key: JdVocDimensionKey;
+  score: number | null;
+  maxScore: number;
+  fixedEarned: number | null;
+  verifiedMax: number;
+  coverage: number;
+  status: 'scored' | 'partial' | 'blocked';
+  evidence: JdVocEvidence[];
+}
+
+export interface JdVocVocEvidence {
+  id: string;
+  source: 'product-review' | 'category-voc' | 'category-profile' | 'manual-question';
+  text: string;
+  rating?: number | null;
+  observedAt?: string | null;
+}
+
+export interface JdVocRuleContext {
+  productReviews?: JdVocVocEvidence[];
+  categoryVocEvidence?: JdVocVocEvidence[];
+  manualQuestions?: Array<{ id: string; question: string; severity: 'low' | 'medium' | 'high'; terms: string[] }>;
+  competitors?: Array<{
+    productId: string;
+    title: string;
+    brand?: string;
+    category?: string;
+    price?: number | null;
+    sellingPoints?: string[];
+    keySpecifications?: Record<string, unknown> | string[];
+    observedAt?: string | null;
+  }>;
+  competitorBasis?: 'formal' | 'category-inferred' | 'none';
+}
+
+export interface JdVocScoreResult {
+  id: string;
+  workspaceId: string;
+  platform: ListingPlatform;
+  productId: string;
+  sourceHash: string;
+  rubricVersion: typeof JD_VOC_RUBRIC_VERSION;
+  scoreKind: JdVocScoreKind;
+  overallScore: number | null;
+  dimensions: JdVocDimensionScore[];
+  coverage: ListingCoverage;
+  voc: {
+    profileId: string | null;
+    productReviewCount: number;
+    categoryVocEvidenceCount: number;
+    concerns: Array<{ id: string; question: string; severity: 'low' | 'medium' | 'high'; source: JdVocVocEvidence['source']; evidenceIds: string[] }>;
+    evidence?: JdVocVocEvidence[];
+  };
+  compliance?: {
+    status: 'normal' | 'warning' | 'blocked';
+    gate: 'PASS' | 'WARN' | 'BLOCK';
+    findings: Array<{ ruleId: string; severity: string; fieldPath: string; message: string; evidence: string[] }>;
+  };
+  competitor: {
+    basis: 'formal' | 'category-inferred' | 'none';
+    count: number;
+    comparableSurface: string[];
+    productIds: string[];
+    items?: Array<{ productId: string; title: string; brand: string; price: number | null; specifications: string[]; sellingPoints: string[]; observedAt: string | null; status: 'available' | 'partial' }>;
+  };
+  actions: Array<{
+    ruleId: string;
+    priority: 'P0' | 'P1' | 'P2' | 'DATA';
+    dimension: JdVocDimensionKey;
+    title: string;
+    action: string;
+    evidenceIds: string[];
+    confidence: number | null;
+    potentialGain: number;
+  }>;
+  aiReview?: {
+    status: 'not_requested' | 'pending' | 'completed' | 'failed';
+    model: string | null;
+    promptVersion: string | null;
+    baselineFingerprint?: string;
+    latencyMs?: number;
+    usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
+    baselineScore: number | null;
+    hybridScore: number | null;
+    assessments: Array<{ dimension: JdVocDimensionKey; score: number | null; evidenceIds: string[]; rationale: string }>;
+    suggestions: Array<{ title: string; action: string; evidenceIds: string[] }>;
+  };
+  imageReview?: {
+    status: 'not_requested' | 'shadow_completed' | 'failed';
+    model: string | null;
+    evidence: Array<{ id: string; fieldPath: string; outcome: 'pass' | 'fail' | 'unknown'; message: string; confidence: number }>;
+    suggestions: string[];
+  };
+  executionKey: string;
+  inputFingerprint: string;
+  createdAt: string;
+}
 
 export interface ListingFeature {
   key: string;
@@ -329,7 +459,8 @@ export interface ListingProductQuery extends ListingProductFilter {
 }
 
 export type ListingOverviewScoreNature = 'simulation' | 'formal_ai' | 'rule_precheck' | 'unscored';
-export type ListingOverviewSort = 'productId' | 'overallScore' | ListingDimension | 'improvementPotential' | 'scoredAt' | 'syncedAt';
+export type ListingOverviewDimensionKey = ListingDimension | JdVocDimensionKey;
+export type ListingOverviewSort = 'productId' | 'overallScore' | ListingOverviewDimensionKey | 'improvementPotential' | 'scoredAt' | 'syncedAt';
 export type ListingOverviewDirection = 'asc' | 'desc';
 
 export interface ListingOverviewQuery {
@@ -338,6 +469,11 @@ export interface ListingOverviewQuery {
   search?: string | undefined;
   categoryIds?: string[] | undefined;
   scoreNature?: ListingOverviewScoreNature | undefined;
+  rubricVersion?: string | undefined;
+  scoreKind?: 'rules' | 'hybrid_ai' | 'jd_voc_rules' | 'jd_voc_hybrid_ai' | undefined;
+  priority?: 'P0' | 'P1' | 'P2' | 'DATA' | undefined;
+  coverageMin?: number | undefined;
+  coverageMax?: number | undefined;
   minScore?: number | undefined;
   maxScore?: number | undefined;
   titleMin?: number | undefined;
@@ -350,7 +486,7 @@ export interface ListingOverviewQuery {
   descriptionMax?: number | undefined;
   specificationsMin?: number | undefined;
   specificationsMax?: number | undefined;
-  weakestDimension?: ListingDimension | undefined;
+  weakestDimension?: ListingOverviewDimensionKey | undefined;
   sort: ListingOverviewSort;
   direction: ListingOverviewDirection;
   limit: number;
@@ -378,10 +514,15 @@ export interface ListingOverviewRow {
   overallScore: number | null;
   overallRate: number | null;
   dimensions: Record<ListingDimension, ListingOverviewDimensionValue>;
-  weakestDimension: ListingDimension | null;
+  jdVocDimensions?: Record<JdVocDimensionKey, ListingOverviewDimensionValue>;
+  weakestDimension: ListingOverviewDimensionKey | null;
   improvementPotential: number | null;
   scoredAt: string | null;
   syncedAt: string;
+  rubricVersion?: string | null;
+  scoreKind?: ListingScoreResult['scoreKind'] | JdVocScoreKind | null;
+  coveragePercent?: number | null;
+  priority?: 'P0' | 'P1' | 'P2' | 'DATA' | null;
 }
 
 export interface ListingOverviewScoreDistributionBucket {
@@ -394,7 +535,7 @@ export interface ListingOverviewScoreDistributionBucket {
 }
 
 export interface ListingOverviewDimensionStat {
-  key: ListingDimension;
+  key: ListingOverviewDimensionKey;
   label: string;
   maxScore: number;
   scoredCount: number;
@@ -426,6 +567,7 @@ export interface ListingOverviewSummary {
   medianScore: number | null;
   scoreDistribution: ListingOverviewScoreDistributionBucket[];
   dimensionStats: Record<ListingDimension, ListingOverviewDimensionStat>;
+  jdVocDimensionStats?: Record<JdVocDimensionKey, ListingOverviewDimensionStat>;
   categoryFacets: ListingOverviewCategoryFacet[];
   scoreNatureFacets: ListingOverviewScoreNatureFacet[];
   snapshotId: string;
@@ -442,9 +584,13 @@ export interface ListingAiRepository {
   listProducts(query: ListingProductQuery): Promise<ListingCursorPage<ListingProductSummary>>;
   catalogSummary(workspaceId: string, platform: ListingPlatform): Promise<ListingCatalogSummary>;
   getSource(workspaceId: string, platform: ListingPlatform, productId: string): Promise<ListingSourceSnapshot | null>;
+  getJdVocRuleContext(workspaceId: string, platform: ListingPlatform, productId: string): Promise<JdVocRuleContext>;
   getCurrentScore(workspaceId: string, productId: string, slot?: ListingCurrentScoreSlot): Promise<ListingScoreResult | null>;
   getCurrentScoreByExecutionKey(workspaceId: string, executionKey: string): Promise<ListingScoreResult | null>;
+  getJdVocCurrentScore(workspaceId: string, productId: string, slot?: Extract<ListingCurrentScoreSlot, 'jd_voc_rules' | 'jd_voc_hybrid_ai'>): Promise<JdVocScoreResult | null>;
+  upsertJdVocCurrentScore(result: JdVocScoreResult): Promise<JdVocScoreResult>;
   listCurrentScores(workspaceId: string): Promise<ListingScoreResult[]>;
+  listJdVocScores(workspaceId: string): Promise<JdVocScoreResult[]>;
   upsertCurrentScore(result: ListingScoreResult): Promise<ListingScoreResult>;
   createJob(job: ListingScoreJob, items: ListingScoreJobItem[]): Promise<{ job: ListingScoreJob; created: boolean }>;
   updateJob(job: ListingScoreJob): Promise<void>;

+ 54 - 0
src/modules/listing-ai/image-review/gemini-image-review.provider.ts

@@ -0,0 +1,54 @@
+import type { ListingSourceSnapshot } from '../domain.js';
+
+export interface GeminiImageReviewOutput {
+  visibleFacts: Array<{ field: string; value: string; confidence: number }>;
+  visualInferences: Array<{ field: string; value: string; confidence: number }>;
+  unknowns: string[];
+  model: string;
+  latencyMs: number;
+}
+
+export interface GeminiImageReviewProvider {
+  analyze(imageUrls: string[], source: ListingSourceSnapshot): Promise<GeminiImageReviewOutput>;
+}
+
+const MODEL = 'gemini-3.1-flash-image-preview';
+
+function parseJson(content: string): Record<string, unknown> {
+  const clean = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
+  const start = clean.indexOf('{'); const end = clean.lastIndexOf('}');
+  if (start < 0 || end <= start) throw new Error('image_review_invalid_json');
+  const value = JSON.parse(clean.slice(start, end + 1)) as unknown;
+  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('image_review_invalid_json');
+  return value as Record<string, unknown>;
+}
+
+function text(value: unknown): string { return typeof value === 'string' ? value.trim().slice(0, 200) : ''; }
+function confidence(value: unknown): number { const number = typeof value === 'number' ? value : Number(value); return Number.isFinite(number) ? Math.min(1, Math.max(0, number > 1 ? number / 100 : number)) : 0.5; }
+
+export class FmodeGeminiImageReviewProvider implements GeminiImageReviewProvider {
+  constructor(private readonly options: { baseUrl: string; token: string; timeoutMs?: number; fetchImpl?: typeof fetch; model?: string }) {}
+
+  async analyze(imageUrls: string[], source: ListingSourceSnapshot): Promise<GeminiImageReviewOutput> {
+    if (!this.options.baseUrl || !this.options.token) throw new Error('image_review_not_configured');
+    const started = Date.now();
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 45_000);
+    try {
+      const prompt = '仅提取图片中直接可见的商品类型、规格文字、安装条件和使用场景。不要使用外部知识,不要把视觉推断当作确定事实。返回 JSON: {"visible_facts":[{"field":"","value":"","confidence":0}],"visual_inferences":[{"field":"","value":"","confidence":0}],"unknowns":[]}。';
+      const content = imageUrls.slice(0, 3).map((url) => ({ type: 'image_url', image_url: { url } }));
+      content.push({ type: 'text', text: `${prompt}\n商品标题(仅用于核对身份,不作为图片事实):${source.title ?? ''}` } as never);
+      const response = await (this.options.fetchImpl ?? fetch)(`${this.options.baseUrl.replace(/\/+$/, '')}/v1/chat/completions`, {
+        method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${this.options.token}` }, signal: controller.signal,
+        body: JSON.stringify({ model: this.options.model ?? MODEL, temperature: 0, max_tokens: 1_500, messages: [{ role: 'user', content }] }),
+      });
+      if (!response.ok) throw new Error(`image_review_http_${response.status}`);
+      const body = await response.json() as { choices?: Array<{ message?: { content?: unknown } }> };
+      const root = parseJson(String(body.choices?.[0]?.message?.content ?? ''));
+      const visibleFacts = Array.isArray(root.visible_facts) ? root.visible_facts.map((item) => ({ field: text((item as Record<string, unknown>).field), value: text((item as Record<string, unknown>).value), confidence: confidence((item as Record<string, unknown>).confidence) })).filter((item) => item.field && item.value).slice(0, 30) : [];
+      const visualInferences = Array.isArray(root.visual_inferences) ? root.visual_inferences.map((item) => ({ field: text((item as Record<string, unknown>).field), value: text((item as Record<string, unknown>).value), confidence: confidence((item as Record<string, unknown>).confidence) })).filter((item) => item.field && item.value).slice(0, 30) : [];
+      const unknowns = Array.isArray(root.unknowns) ? root.unknowns.map(text).filter(Boolean).slice(0, 30) : [];
+      return { visibleFacts, visualInferences, unknowns, model: this.options.model ?? MODEL, latencyMs: Date.now() - started };
+    } finally { clearTimeout(timer); }
+  }
+}

+ 52 - 0
src/modules/listing-ai/image-review/image-review.service.ts

@@ -0,0 +1,52 @@
+import type { ListingSourceSnapshot } from '../domain.js';
+import type { GeminiImageReviewProvider } from './gemini-image-review.provider.js';
+
+export interface ImageReviewEvidence {
+  id: string;
+  fieldPath: 'imageAssets.defaultImages' | 'imageAssets.skuImages' | 'imageAssets.whiteBackgroundImages' | 'images';
+  outcome: 'pass' | 'fail' | 'unknown';
+  message: string;
+  confidence: number;
+}
+
+export interface ImageReviewResult {
+  status: 'not_requested' | 'shadow_completed' | 'failed';
+  model: string | null;
+  evidence: ImageReviewEvidence[];
+  suggestions: string[];
+}
+
+/**
+ * Shadow-only asset review. It intentionally reports structure, never image URLs,
+ * OCR text, or semantic claims that could affect the scoring dimensions.
+ */
+export function reviewImageAssets(source: ListingSourceSnapshot, options: { enabled?: boolean; model?: string } = {}): ImageReviewResult {
+  if (!options.enabled) return { status: 'not_requested', model: null, evidence: [], suggestions: [] };
+  try {
+    const defaultCount = source.imageAssets?.defaultImages.length || source.images.length;
+    const skuCount = source.imageAssets?.skuImages.reduce((sum, group) => sum + group.images.length, 0) ?? 0;
+    const whiteCount = source.imageAssets?.whiteBackgroundImages.length ?? 0;
+    const evidence: ImageReviewEvidence[] = [
+      { id: `image-review:${source.productId}:default-count`, fieldPath: 'imageAssets.defaultImages', outcome: defaultCount > 0 ? 'pass' : 'fail', message: `默认图片资产 ${defaultCount} 张`, confidence: 1 },
+      { id: `image-review:${source.productId}:sku-count`, fieldPath: 'imageAssets.skuImages', outcome: skuCount > 0 ? 'pass' : 'unknown', message: `规格图片资产 ${skuCount} 张`, confidence: 1 },
+      { id: `image-review:${source.productId}:white-count`, fieldPath: 'imageAssets.whiteBackgroundImages', outcome: whiteCount > 0 ? 'pass' : 'unknown', message: `白底图片资产 ${whiteCount} 张`, confidence: 1 },
+    ];
+    return { status: 'shadow_completed', model: options.model ?? 'asset-structure-shadow-v1', evidence, suggestions: defaultCount ? [] : ['补充至少一张默认商品图片'] };
+  } catch {
+    return { status: 'failed', model: options.model ?? 'asset-structure-shadow-v1', evidence: [], suggestions: [] };
+  }
+}
+
+export async function reviewImageAssetsWithProvider(source: ListingSourceSnapshot, provider: GeminiImageReviewProvider): Promise<ImageReviewResult> {
+  const urls = source.images.map((image) => image.url).filter((url) => /^https:\/\//i.test(url)).slice(0, 3);
+  if (!urls.length) return { status: 'failed', model: null, evidence: [], suggestions: ['没有可用的 HTTPS 图片资产'] };
+  try {
+    const result = await provider.analyze(urls, source);
+    const evidence: ImageReviewEvidence[] = result.visibleFacts.map((fact, index) => ({
+      id: `image-review:${source.productId}:gemini:${index + 1}`, fieldPath: 'images', outcome: 'pass', message: `${fact.field}:${fact.value}`.slice(0, 240), confidence: fact.confidence,
+    }));
+    return { status: 'shadow_completed', model: result.model, evidence, suggestions: result.visualInferences.filter((item) => item.confidence >= 0.8).map((item) => `人工确认视觉提示:${item.field}`).slice(0, 5) };
+  } catch (error) {
+    return { status: 'failed', model: null, evidence: [], suggestions: [error instanceof Error ? error.message.slice(0, 80) : 'image_review_failed'] };
+  }
+}

+ 139 - 6
src/modules/listing-ai/listing-ai.service.ts

@@ -13,7 +13,9 @@ import type {
   ListingScoreScope,
   ListingSourceSnapshot,
   ListingVersion,
+  JdVocScoreResult,
 } from './domain.js';
+import { JD_VOC_RUBRIC_VERSION } from './domain.js';
 import { queryListingOverview } from './query/listing-overview.query.js';
 import { canonicalHash, LISTING_RUBRIC_VERSION, listingCoverage, scoreListing } from './scoring/rule-engine.js';
 import {
@@ -26,6 +28,10 @@ import {
   type ListingAiScoreOutput,
 } from './scoring/ai-rubric.js';
 import { selectCurrentListingScore } from './scoring/score-status.js';
+import { scoreJdVocRules, type JdVocRuleContext } from './scoring/jd-voc-rule-engine.js';
+import { reviewImageAssetsWithProvider } from './image-review/image-review.service.js';
+import type { GeminiImageReviewProvider } from './image-review/gemini-image-review.provider.js';
+import { composeJdVocHybridScore, jdVocAiEvidenceCatalog, jdVocAiPrompt, parseJdVocAiScoreOutput, JD_VOC_AI_PROMPT_VERSION, type JdVocAiScoreOutput } from './scoring/jd-voc-ai-rubric.js';
 
 export interface ListingAiScoringProvider {
   readonly configured: boolean;
@@ -33,6 +39,30 @@ export interface ListingAiScoringProvider {
   score(source: ListingSourceSnapshot, baseline: ListingScoreResult): Promise<ListingAiScoreOutput>;
 }
 
+export interface JdVocAiScoringProvider {
+  readonly configured: boolean;
+  readonly model: string;
+  score(source: ListingSourceSnapshot, baseline: JdVocScoreResult): Promise<JdVocAiScoreOutput>;
+}
+
+export class FmodeJdVocAiScoringProvider implements JdVocAiScoringProvider {
+  readonly model: string;
+  constructor(private readonly client: FmodeAiClient, model = 'gpt-4o-mini') { this.model = model.trim() || 'gpt-4o-mini'; }
+  get configured(): boolean { return this.client.configured; }
+  async score(source: ListingSourceSnapshot, baseline: JdVocScoreResult): Promise<JdVocAiScoreOutput> {
+    const startedAt = Date.now();
+    const response = await this.client.createChatCompletion({ stream: false, model: this.model, temperature: 0, max_tokens: 6_000, response_format: { type: 'json_object' }, messages: [
+      { role: 'system', content: jdVocAiPrompt() },
+      { role: 'user', content: JSON.stringify({ source: { productId: source.productId, title: source.title, brand: source.brand, features: source.features.slice(0, 40), attributes: source.attributes.slice(0, 60), marketing: source.marketing?.sellingPoints.slice(0, 20), dimensions: source.dimensions }, baseline: { score: baseline.overallScore, dimensions: baseline.dimensions, evidenceCatalog: jdVocAiEvidenceCatalog(source, baseline) } }) },
+    ] });
+    if (!response.ok) throw new Error(`jd_voc_ai_upstream_${response.status}`);
+    const body = await response.json() as { choices?: Array<{ message?: { content?: string } }>; usage?: { prompt_tokens?:number;completion_tokens?:number;total_tokens?:number } };
+    const parsed = parseJdVocAiScoreOutput(body.choices?.[0]?.message?.content ?? '');
+    if (!parsed) throw new Error('jd_voc_ai_invalid_output');
+    return { ...parsed, latencyMs: Date.now()-startedAt, ...(body.usage ? { usage: { promptTokens: Number(body.usage.prompt_tokens??0), completionTokens:Number(body.usage.completion_tokens??0), totalTokens:Number(body.usage.total_tokens??0) } } : {}) };
+  }
+}
+
 function statusReasons(result: ListingScoreResult): string[] {
   return [...new Set((result.unknownCriteria ?? []).map((item) => item.reasonCode))];
 }
@@ -107,6 +137,10 @@ export class ListingAiService {
     private readonly now: () => Date = () => new Date(),
     private readonly concurrency = 3,
     private readonly maxAiItemsPerJob = 10,
+    private readonly jdVocAiScoring?: JdVocAiScoringProvider,
+    private readonly imageReviewProvider?: GeminiImageReviewProvider,
+    readonly jdVocDisplayDefault = false,
+    readonly jdVocEnabled = true,
   ) {}
 
   async catalogSummary(workspaceId: string, platform: 'jd'): Promise<ListingCatalogSummary> {
@@ -114,11 +148,84 @@ export class ListingAiService {
   }
 
   async overview(query: ListingOverviewQuery): Promise<ListingOverviewResponse> {
-    const [sources, scores] = await Promise.all([
-      this.repository.listAllSources(query.workspaceId, query.platform),
-      this.repository.listCurrentScores(query.workspaceId),
+    const effectiveQuery = this.jdVocDisplayDefault && !query.rubricVersion && !query.scoreKind
+      ? { ...query, rubricVersion: JD_VOC_RUBRIC_VERSION }
+      : query;
+    const [sources, scores, jdVocScores] = await Promise.all([
+      this.repository.listAllSources(effectiveQuery.workspaceId, effectiveQuery.platform),
+      this.repository.listCurrentScores(effectiveQuery.workspaceId),
+      this.repository.listJdVocScores(effectiveQuery.workspaceId),
     ]);
-    return queryListingOverview({ sources, scores, query, generatedAt: this.now().toISOString() });
+    return queryListingOverview({ sources, scores, jdVocScores, query: effectiveQuery, generatedAt: this.now().toISOString() });
+  }
+
+  /** Scores and persists only the new JD-VOC rules slot. Legacy score slots are untouched. */
+  async scoreJdVocRules(input: {
+    workspaceId: string;
+    platform: 'jd';
+    productId: string;
+    context?: JdVocRuleContext;
+    imageReviewEnabled?: boolean;
+    force?: boolean;
+  }): Promise<JdVocScoreResult> {
+    if (!this.jdVocEnabled) throw new ApiError(409, 'jd_voc_disabled');
+    const source = await this.repository.getSource(input.workspaceId, input.platform, input.productId);
+    if (!source) throw new ApiError(404, 'listing_product_not_found');
+    const context = input.context ?? await this.repository.getJdVocRuleContext(input.workspaceId, input.platform, input.productId);
+    const result = scoreJdVocRules(source, context, { now: this.now().toISOString(), ...(input.imageReviewEnabled !== undefined ? { imageReviewEnabled: input.imageReviewEnabled } : {}) });
+    const cached = await this.repository.getJdVocCurrentScore(input.workspaceId, input.productId, 'jd_voc_rules');
+    if (!input.force && cached?.sourceHash === source.sourceHash && cached.inputFingerprint === result.inputFingerprint) return cached;
+    await this.repository.upsertJdVocCurrentScore(result);
+    return result;
+  }
+
+  async scoreJdVocWithAi(input: {
+    workspaceId: string;
+    platform: 'jd';
+    productId: string;
+    context?: JdVocRuleContext;
+    force?: boolean;
+  }): Promise<{ rules: JdVocScoreResult; hybrid: JdVocScoreResult | null; errorCode: string | null }> {
+    const rules = await this.scoreJdVocRules(input);
+    if (rules.coverage.status === 'blocked') return { rules, hybrid: null, errorCode: null };
+    const provider = this.jdVocAiScoring;
+    if (!provider?.configured) return { rules, hybrid: null, errorCode: 'jd_voc_ai_not_configured' };
+    const cached = await this.repository.getJdVocCurrentScore(input.workspaceId, input.productId, 'jd_voc_hybrid_ai');
+    if (!input.force && cached?.sourceHash === rules.sourceHash && cached.aiReview?.model === provider.model && cached.aiReview.promptVersion === JD_VOC_AI_PROMPT_VERSION && cached.aiReview.baselineFingerprint === rules.inputFingerprint) return { rules, hybrid: cached, errorCode: null };
+    const source = await this.repository.getSource(input.workspaceId, input.platform, input.productId);
+    if (!source) return { rules, hybrid: null, errorCode: 'listing_product_not_found' };
+    let lastError: unknown = null;
+    for (let attempt = 0; attempt < 2; attempt += 1) {
+      try {
+        let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
+        const timeout = new Promise<never>((_, reject) => { timeoutHandle = setTimeout(() => reject(new Error('jd_voc_ai_timeout')), 45_000); });
+        const output = await Promise.race([provider.score(source, rules), timeout]).finally(() => { if (timeoutHandle) clearTimeout(timeoutHandle); });
+        const hybrid = composeJdVocHybridScore({ baseline: rules, output, model: provider.model, source, now: this.now().toISOString() });
+        await this.repository.upsertJdVocCurrentScore(hybrid);
+        return { rules, hybrid, errorCode: null };
+      } catch (error) { lastError = error; }
+    }
+    return { rules, hybrid: null, errorCode: lastError instanceof Error ? lastError.message.slice(0, 80) : 'jd_voc_ai_failed' };
+  }
+
+  async reviewJdVocImages(input: { workspaceId: string; platform: 'jd'; productId: string; force?: boolean }): Promise<JdVocScoreResult> {
+    if (!this.jdVocEnabled) throw new ApiError(409, 'jd_voc_disabled');
+    if (!this.imageReviewProvider) throw new ApiError(409, 'jd_voc_image_review_disabled');
+    const source = await this.repository.getSource(input.workspaceId, input.platform, input.productId);
+    if (!source) throw new ApiError(404, 'listing_product_not_found');
+    const current = await this.repository.getJdVocCurrentScore(input.workspaceId, input.productId, 'jd_voc_rules');
+    const hybrid = await this.repository.getJdVocCurrentScore(input.workspaceId, input.productId, 'jd_voc_hybrid_ai');
+    const baseline = current?.sourceHash === source.sourceHash && !input.force ? current : scoreJdVocRules(source, await this.repository.getJdVocRuleContext(input.workspaceId, input.platform, input.productId), { now: this.now().toISOString() });
+    const imageReview = await reviewImageAssetsWithProvider(source, this.imageReviewProvider);
+    const createdAt = this.now().toISOString();
+    const rulesResult = { ...baseline, imageReview, createdAt };
+    await this.repository.upsertJdVocCurrentScore(rulesResult);
+    if (hybrid?.sourceHash === source.sourceHash) {
+      const hybridResult = { ...hybrid, imageReview, createdAt };
+      await this.repository.upsertJdVocCurrentScore(hybridResult);
+      return hybridResult;
+    }
+    return rulesResult;
   }
 
   async enqueueScoreJob(input: {
@@ -136,7 +243,9 @@ export class ListingAiService {
       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');
+    const jdVoc = rubricVersion === JD_VOC_RUBRIC_VERSION;
+    if (jdVoc && !this.jdVocEnabled) throw new ApiError(409, 'jd_voc_disabled');
+    if (input.includeAiSuggestions && rubricVersion !== LISTING_AI_RUBRIC_VERSION && !jdVoc) 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 rescorePolicy = input.rescorePolicy ?? 'reuse';
@@ -247,7 +356,8 @@ export class ListingAiService {
     const source = await this.repository.getSource(workspaceId, platform, version.productId);
     if (!source || source.sourceHash !== version.baseSourceHash) throw new ApiError(409, 'listing_source_changed');
     const scoreView = selectCurrentListingScore((await this.repository.listCurrentScores(workspaceId)).filter((score) => score.productId === version.productId), source.sourceHash);
-    if (scoreView.displayScore?.compliance?.status === 'blocked') throw new ApiError(409, 'listing_compliance_blocked');
+    const jdVocScore = await this.repository.getJdVocCurrentScore(workspaceId, version.productId);
+    if (scoreView.displayScore?.compliance?.status === 'blocked' || jdVocScore?.sourceHash === source.sourceHash && (jdVocScore.coverage.status === 'blocked' || jdVocScore.compliance?.status === 'blocked')) throw new ApiError(409, 'listing_compliance_blocked');
     const next = { ...version, status: 'adopted' as const, adoptedAt: this.now().toISOString() };
     await this.repository.updateVersion(next);
     return next;
@@ -289,6 +399,29 @@ export class ListingAiService {
         await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, errorCode: 'listing_source_changed', errorDetail: 'Source snapshot is missing or changed', statusReasonCodes: [], updatedAt: now });
         return;
       }
+      if (job.rubricVersion === JD_VOC_RUBRIC_VERSION) {
+        const rules = await this.scoreJdVocRules({ workspaceId: job.workspaceId, platform: job.platform, productId: item.productId, force: job.rescorePolicy === 'force' });
+        await this.repository.updateJobItem({ ...item, status: 'rules_scored', attempts: item.attempts, errorCode: null, errorDetail: null, statusReasonCodes: rules.coverage.missing, updatedAt: now });
+        if (job.includeAiSuggestions) await this.repository.updateJobItem({ ...item, status: 'ai_pending', attempts: item.attempts, errorCode: null, errorDetail: null, statusReasonCodes: rules.coverage.missing, updatedAt: now });
+        const result = job.includeAiSuggestions
+          ? await this.scoreJdVocWithAi({ workspaceId: job.workspaceId, platform: job.platform, productId: item.productId, force: job.rescorePolicy === 'force' })
+          : { rules, hybrid: null, errorCode: null };
+        const effective = result.hybrid ?? result.rules;
+        const status: ListingScoreJobItem['status'] = result.errorCode
+          ? 'failed'
+          : effective.coverage.status === 'blocked'
+            ? 'blocked'
+            : effective.overallScore === null || effective.coverage.status === 'partial'
+              ? 'partial'
+              : 'scored';
+        await this.repository.updateJobItem({
+          ...item, status, attempts: item.attempts + 1,
+          errorCode: result.errorCode,
+          errorDetail: result.errorCode ? 'JD-VOC AI 复核失败,规则结果已保留' : null,
+          statusReasonCodes: status === 'partial' || status === 'blocked' ? effective.coverage.missing : [], updatedAt: now,
+        });
+        return;
+      }
       const alreadyExecuted = await this.repository.getCurrentScoreByExecutionKey(job.workspaceId, executionKey);
       if (alreadyExecuted) {
         const status: ListingScoreJobItem['status'] = alreadyExecuted.aiStatus === 'failed' || alreadyExecuted.aiStatus === 'budget_exceeded' ? 'failed' : alreadyExecuted.coverage.status === 'blocked' ? 'blocked' : alreadyExecuted.overallScore === null ? 'partial' : 'scored';

+ 9 - 0
src/modules/listing-ai/presentation/jd-voc-score.presenter.ts

@@ -0,0 +1,9 @@
+import type { JdVocScoreResult, ListingSourceSnapshot } from '../domain.js';
+
+/** JD-VOC is already a public versioned DTO; validate identity and clone it. */
+export function presentJdVocScore(score: JdVocScoreResult, source?: Pick<ListingSourceSnapshot, 'sourceHash' | 'productId'>): JdVocScoreResult {
+  if (source && (source.productId !== score.productId || source.sourceHash !== score.sourceHash)) {
+    throw new Error('jd_voc_source_mismatch');
+  }
+  return structuredClone(score);
+}

+ 64 - 12
src/modules/listing-ai/query/listing-overview.query.ts

@@ -1,5 +1,7 @@
 import type {
+  JdVocDimensionKey,
   ListingDimension,
+  JdVocScoreResult,
   ListingOverviewCategoryFacet,
   ListingOverviewDimensionStat,
   ListingOverviewDimensionValue,
@@ -10,11 +12,13 @@ import type {
   ListingScoreResult,
   ListingSourceSnapshot,
 } from '../domain.js';
+import { JD_VOC_DIMENSION_MAX } from '../domain.js';
 import { LISTING_DIMENSION_MAX } from '../scoring/rule-engine.js';
 import { selectCurrentListingScore } from '../scoring/score-status.js';
 import { stableListingHash, stableListingPage, type StableListingSortValue } from './stable-listing-page.js';
 
 export const LISTING_OVERVIEW_DIMENSIONS: readonly ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
+export const JD_VOC_OVERVIEW_DIMENSIONS: readonly JdVocDimensionKey[] = ['search', 'voc', 'selling', 'facts', 'competitive', 'media'];
 export const LISTING_SIMULATION_MODEL = 'listing-v7-demo-simulation';
 
 const DIMENSION_LABELS: Readonly<Record<ListingDimension, string>> = {
@@ -24,6 +28,9 @@ const DIMENSION_LABELS: Readonly<Record<ListingDimension, string>> = {
   description: '商品详情',
   specifications: '规格与履约',
 };
+const JD_VOC_DIMENSION_LABELS: Readonly<Record<JdVocDimensionKey, string>> = {
+  search: '标题与搜索', voc: 'VOC需求响应', selling: '卖点说服力', facts: '事实与信息', competitive: '竞品差异化', media: '媒体资产',
+};
 
 const SCORE_NATURE_LABELS: Readonly<Record<ListingOverviewScoreNature, string>> = {
   simulation: '模拟评分',
@@ -71,6 +78,15 @@ function dimensionValues(score: ListingScoreResult | null): Record<ListingDimens
   })) as Record<ListingDimension, ListingOverviewDimensionValue>;
 }
 
+function jdVocDimensionValues(score: JdVocScoreResult | undefined): Record<JdVocDimensionKey, ListingOverviewDimensionValue> | undefined {
+  if (!score) return undefined;
+  return Object.fromEntries(JD_VOC_OVERVIEW_DIMENSIONS.map((key) => {
+    const maximum = JD_VOC_DIMENSION_MAX[key];
+    const value = score.dimensions.find((dimension) => dimension.key === key)?.score ?? null;
+    return [key, { score: value, maxScore: maximum, rate: value === null ? null : round(value / maximum, 4), gap: value === null ? null : round(maximum - value) }];
+  })) as Record<JdVocDimensionKey, ListingOverviewDimensionValue>;
+}
+
 function category(source: ListingSourceSnapshot): { id: string | null; name: string | null; path: string[] } {
   const path = (source.categoryContext?.pathNames?.length ? source.categoryContext.pathNames : source.categoryContext?.names ?? [])
     .map((value) => value.trim()).filter(Boolean);
@@ -79,14 +95,26 @@ function category(source: ListingSourceSnapshot): { id: string | null; name: str
   return { id, name, path };
 }
 
-function overviewRow(source: ListingSourceSnapshot, scores: ListingScoreResult[]): ListingOverviewRow {
-  const score = selectCurrentListingScore(scores, source.sourceHash).displayScore;
+function overviewRow(source: ListingSourceSnapshot, scores: ListingScoreResult[], jdVocScores: JdVocScoreResult[], query: ListingOverviewQuery): ListingOverviewRow {
+  const jdVoc = jdVocScores.filter((score) => score.productId === source.productId && score.sourceHash === source.sourceHash && (!query.scoreKind || score.scoreKind === query.scoreKind) && (!query.rubricVersion || score.rubricVersion === query.rubricVersion)).sort((a, b) => {
+    const priority = (score: JdVocScoreResult) => score.scoreKind === 'jd_voc_hybrid_ai' && score.aiReview?.status === 'completed' ? 0 : 1;
+    return priority(a) - priority(b) || b.createdAt.localeCompare(a.createdAt);
+  })[0];
+  const useJdVoc = Boolean(query.rubricVersion?.startsWith('jd-voc-') || query.scoreKind?.startsWith('jd_voc_'));
+  const score = useJdVoc ? null : selectCurrentListingScore(scores, source.sourceHash).displayScore;
+  const overall = useJdVoc ? jdVoc?.overallScore ?? null : score?.overallScore ?? null;
   const dimensions = dimensionValues(score);
-  const knownDimensions = LISTING_OVERVIEW_DIMENSIONS.filter((key) => dimensions[key].rate !== null);
-  const weakestDimension = knownDimensions.sort((left, right) => dimensions[left].rate! - dimensions[right].rate!)[0] ?? null;
-  const gaps = LISTING_OVERVIEW_DIMENSIONS.map((key) => dimensions[key].gap);
-  const improvementPotential = gaps.every((gap): gap is number => gap !== null) ? round(gaps.reduce((sum, gap) => sum + gap, 0)) : null;
-  const nature = scoreNature(score);
+  const jdVocDimensions = useJdVoc ? jdVocDimensionValues(jdVoc) : undefined;
+  const legacyKnown = LISTING_OVERVIEW_DIMENSIONS.filter((key) => dimensions[key].rate !== null);
+  const jdKnown = jdVocDimensions ? JD_VOC_OVERVIEW_DIMENSIONS.filter((key) => jdVocDimensions[key].rate !== null) : [];
+  const weakestDimension = useJdVoc
+    ? jdKnown.sort((left, right) => jdVocDimensions![left].rate! - jdVocDimensions![right].rate!)[0] ?? null
+    : legacyKnown.sort((left, right) => dimensions[left].rate! - dimensions[right].rate!)[0] ?? null;
+  const legacyGaps = LISTING_OVERVIEW_DIMENSIONS.map((key) => dimensions[key].gap);
+  const improvementPotential = useJdVoc
+    ? jdVoc ? Math.min(100, round(jdVoc.actions.reduce((sum, action) => sum + action.potentialGain, 0))) : null
+    : legacyGaps.every((gap): gap is number => gap !== null) ? round(legacyGaps.reduce((sum, gap) => sum + gap, 0)) : null;
+  const nature = useJdVoc ? (jdVoc?.scoreKind === 'jd_voc_hybrid_ai' ? 'formal_ai' : jdVoc ? 'rule_precheck' : 'unscored') : scoreNature(score);
   const sourceCategory = category(source);
   return {
     productId: source.productId,
@@ -99,13 +127,18 @@ function overviewRow(source: ListingSourceSnapshot, scores: ListingScoreResult[]
     itemStatusLabel: source.itemStatus ? '商品状态已获取' : '商品状态未知',
     scoreNature: nature,
     scoreNatureLabel: SCORE_NATURE_LABELS[nature],
-    overallScore: score?.overallScore ?? null,
-    overallRate: score?.overallScore === null || score?.overallScore === undefined ? null : round(score.overallScore / 100, 4),
+    overallScore: useJdVoc ? jdVoc?.overallScore ?? null : score?.overallScore ?? null,
+    overallRate: overall === null ? null : round(overall / 100, 4),
     dimensions,
+    ...(jdVocDimensions ? { jdVocDimensions } : {}),
     weakestDimension,
     improvementPotential,
-    scoredAt: score?.createdAt ?? null,
+    scoredAt: useJdVoc ? jdVoc?.createdAt ?? null : score?.createdAt ?? null,
     syncedAt: source.syncedAt,
+    rubricVersion: useJdVoc ? jdVoc?.rubricVersion ?? null : score?.rubricVersion ?? null,
+    scoreKind: useJdVoc ? jdVoc?.scoreKind ?? null : score?.scoreKind ?? null,
+    coveragePercent: useJdVoc ? jdVoc?.coverage.percent ?? null : score?.coverage.percent ?? null,
+    priority: jdVoc?.actions.slice().sort((a, b) => ({ P0: 0, P1: 1, DATA: 2, P2: 3 })[a.priority] - ({ P0: 0, P1: 1, DATA: 2, P2: 3 })[b.priority])[0]?.priority ?? null,
   };
 }
 
@@ -119,6 +152,10 @@ function matches(row: ListingOverviewRow, query: ListingOverviewQuery, normalize
   if (normalizedSearch && !`${row.productId} ${row.title ?? ''}`.toLocaleLowerCase().includes(normalizedSearch)) return false;
   if (query.categoryIds?.length && !query.categoryIds.some((categoryId) => row.categoryId === categoryId || row.categoryIds.includes(categoryId))) return false;
   if (query.scoreNature && row.scoreNature !== query.scoreNature) return false;
+  if (query.rubricVersion && row.rubricVersion !== query.rubricVersion) return false;
+  if (query.scoreKind && row.scoreKind !== query.scoreKind) return false;
+  if (query.priority && row.priority !== query.priority) return false;
+  if (!matchesRange(row.coveragePercent ?? null, query.coverageMin, query.coverageMax)) return false;
   if (!matchesRange(row.overallScore, query.minScore, query.maxScore)) return false;
   if (!matchesRange(row.dimensions.title.score, query.titleMin, query.titleMax)) return false;
   if (!matchesRange(row.dimensions.selling_points.score, query.sellingPointsMin, query.sellingPointsMax)) return false;
@@ -160,18 +197,27 @@ function dimensionStats(rows: ListingOverviewRow[]): Record<ListingDimension, Li
   })) as Record<ListingDimension, ListingOverviewDimensionStat>;
 }
 
+function jdVocDimensionStats(rows: ListingOverviewRow[]): Record<JdVocDimensionKey, ListingOverviewDimensionStat> {
+  return Object.fromEntries(JD_VOC_OVERVIEW_DIMENSIONS.map((key) => {
+    const values = rows.map((row) => row.jdVocDimensions?.[key]).filter((value): value is ListingOverviewDimensionValue & { score: number; rate: number; gap: number } => Boolean(value) && value!.score !== null && value!.rate !== null && value!.gap !== null);
+    return [key, { key, label: JD_VOC_DIMENSION_LABELS[key], maxScore: JD_VOC_DIMENSION_MAX[key], scoredCount: values.length, averageScore: values.length ? round(values.reduce((sum, value) => sum + value.score, 0) / values.length) : null, averageRate: values.length ? round(values.reduce((sum, value) => sum + value.rate, 0) / values.length, 4) : null, medianScore: median(values.map((value) => value.score)), totalGap: values.length ? round(values.reduce((sum, value) => sum + value.gap, 0)) : null }];
+  })) as Record<JdVocDimensionKey, ListingOverviewDimensionStat>;
+}
+
 function sortValue(row: ListingOverviewRow, sort: ListingOverviewQuery['sort']): StableListingSortValue {
   if (sort === 'productId') return row.productId;
   if (sort === 'overallScore') return row.overallScore;
   if (sort === 'improvementPotential') return row.improvementPotential;
   if (sort === 'scoredAt') return row.scoredAt;
   if (sort === 'syncedAt') return row.syncedAt;
-  return row.dimensions[sort].score;
+  if ((JD_VOC_OVERVIEW_DIMENSIONS as readonly string[]).includes(sort)) return row.jdVocDimensions?.[sort as JdVocDimensionKey].score ?? null;
+  return row.dimensions[sort as ListingDimension].score;
 }
 
 export function queryListingOverview(input: {
   sources: ListingSourceSnapshot[];
   scores: ListingScoreResult[];
+  jdVocScores?: JdVocScoreResult[];
   query: ListingOverviewQuery;
   generatedAt: string;
 }): ListingOverviewResponse {
@@ -184,7 +230,7 @@ export function queryListingOverview(input: {
   }
   const sourceRows = input.sources
     .filter((source) => source.workspaceId === input.query.workspaceId && source.platform === input.query.platform)
-    .map((source) => overviewRow(source, scoresByProduct.get(source.productId) ?? []));
+    .map((source) => overviewRow(source, scoresByProduct.get(source.productId) ?? [], (input.jdVocScores ?? []).filter((score) => score.productId === source.productId), input.query));
   const snapshotId = stableListingHash([...sourceRows].sort((left, right) => left.productId.localeCompare(right.productId)));
   const normalizedSearch = input.query.search?.trim().toLocaleLowerCase() ?? '';
   const matchedRows = sourceRows.filter((row) => matches(row, input.query, normalizedSearch));
@@ -194,6 +240,11 @@ export function queryListingOverview(input: {
     search: normalizedSearch,
     categoryIds: [...new Set(input.query.categoryIds ?? [])].sort(),
     scoreNature: input.query.scoreNature,
+    rubricVersion: input.query.rubricVersion,
+    scoreKind: input.query.scoreKind,
+    priority: input.query.priority,
+    coverageMin: input.query.coverageMin,
+    coverageMax: input.query.coverageMax,
     minScore: input.query.minScore,
     maxScore: input.query.maxScore,
     titleMin: input.query.titleMin,
@@ -241,6 +292,7 @@ export function queryListingOverview(input: {
         count: numericScores.filter((score) => score >= bucket.minScore && (bucket.maxScoreExclusive ? score < bucket.maxScore : score <= bucket.maxScore)).length,
       })),
       dimensionStats: dimensionStats(matchedRows),
+      jdVocDimensionStats: jdVocDimensionStats(matchedRows),
       categoryFacets: categoryFacets(sourceRows),
       scoreNatureFacets: natureFacets,
       snapshotId,

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

@@ -4,6 +4,8 @@ import type {
   ListingCursorPage,
   ListingCatalogSummary,
   ListingCurrentScoreSlot,
+  JdVocScoreResult,
+  JdVocRuleContext,
   ListingProductQuery,
   ListingProductSummary,
   ListingScoreJob,
@@ -49,6 +51,7 @@ function scoreKey(workspaceId: string, productId: string, slot: ListingCurrentSc
 export class InMemoryListingAiRepository implements ListingAiRepository {
   private readonly sources = new Map<string, ListingSourceSnapshot>();
   private readonly scores = new Map<string, ListingScoreResult>();
+  private readonly jdVocScores = new Map<string, JdVocScoreResult>();
   private readonly jobs = new Map<string, ListingScoreJob>();
   private readonly jobItems = new Map<string, ListingScoreJobItem[]>();
   private readonly versions = new Map<string, ListingVersion>();
@@ -155,6 +158,14 @@ export class InMemoryListingAiRepository implements ListingAiRepository {
     return source ? structuredClone(source) : null;
   }
 
+  async getJdVocRuleContext(workspaceId: string, platform: 'jd', productId: string): Promise<JdVocRuleContext> {
+    const source = await this.getSource(workspaceId, platform, productId);
+    return {
+      categoryVocEvidence: (source?.vocEvidence ?? []).map((item) => ({ id: item.id, source: 'category-voc', text: item.text, observedAt: item.collectedAt })),
+      productReviews: [], competitors: [], competitorBasis: 'none',
+    };
+  }
+
   async getCurrentScore(workspaceId: string, productId: string, slot?: ListingCurrentScoreSlot): Promise<ListingScoreResult | null> {
     const score = slot ? this.scores.get(scoreKey(workspaceId, productId, slot))
       : [this.scores.get(scoreKey(workspaceId, productId, 'formal_ai')),this.scores.get(scoreKey(workspaceId, productId, 'rule_precheck'))].find((item):item is ListingScoreResult=>Boolean(item&&isListingV7Score(item)));
@@ -166,9 +177,23 @@ export class InMemoryListingAiRepository implements ListingAiRepository {
     return score && isListingV7Score(score) ? structuredClone(score) : null;
   }
 
+  async getJdVocCurrentScore(workspaceId: string, productId: string, slot?: Extract<ListingCurrentScoreSlot, 'jd_voc_rules' | 'jd_voc_hybrid_ai'>): Promise<JdVocScoreResult | null> {
+    const candidates = [...this.jdVocScores.values()].filter((score) => score.workspaceId === workspaceId && score.productId === productId && (!slot || (score.scoreKind === 'jd_voc_hybrid_ai' ? 'jd_voc_hybrid_ai' : 'jd_voc_rules') === slot));
+    return candidates.sort((a, b) => Number(b.scoreKind === 'jd_voc_hybrid_ai' && b.aiReview?.status === 'completed') - Number(a.scoreKind === 'jd_voc_hybrid_ai' && a.aiReview?.status === 'completed') || b.createdAt.localeCompare(a.createdAt))[0] ?? null;
+  }
+
+  async upsertJdVocCurrentScore(result: JdVocScoreResult): Promise<JdVocScoreResult> {
+    const slot = result.scoreKind === 'jd_voc_hybrid_ai' ? 'jd_voc_hybrid_ai' : 'jd_voc_rules';
+    this.jdVocScores.set(scoreKey(result.workspaceId, result.productId, slot), structuredClone(result));
+    return structuredClone(result);
+  }
+
   async listCurrentScores(workspaceId: string): Promise<ListingScoreResult[]> {
     return [...this.scores.values()].filter((score) => score.workspaceId === workspaceId && isListingV7Score(score)).map((score) => structuredClone(score));
   }
+  async listJdVocScores(workspaceId: string): Promise<JdVocScoreResult[]> {
+    return [...this.jdVocScores.values()].filter((score) => score.workspaceId === workspaceId).map((score) => structuredClone(score));
+  }
 
   async upsertCurrentScore(result: ListingScoreResult): Promise<ListingScoreResult> {
     this.scores.set(scoreKey(result.workspaceId, result.productId, scoreSlot(result)), structuredClone(result));

File diff suppressed because it is too large
+ 30 - 1
src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.ts


+ 35 - 1
src/modules/listing-ai/repositories/postgres-listing-ai.repository.ts

@@ -2,7 +2,7 @@ import type { Pool, PoolClient } from 'pg';
 import { ApiError } from '../../../http/api-error.js';
 import type {
   ListingAiRepository, ListingCurrentScoreSlot, ListingCursorPage, ListingProductQuery, ListingScoreJob, ListingScoreJobItem,
-  ListingScoreResult, ListingSourceSnapshot, ListingVersion,
+  JdVocRuleContext, JdVocScoreResult, ListingScoreResult, ListingSourceSnapshot, ListingVersion,
 } from '../domain.js';
 import { InMemoryListingAiRepository } from './in-memory-listing-ai.repository.js';
 import { isListingV7Score } from '../scoring/score-status.js';
@@ -118,6 +118,18 @@ export class PostgresListingAiRepository implements ListingAiRepository {
     return result.rows[0]?.payload ?? null;
   }
 
+  async getJdVocRuleContext(workspaceId: string, platform: 'jd', productId: string): Promise<JdVocRuleContext> {
+    const source = await this.getSource(workspaceId, platform, productId);
+    const reviews = await this.pool.query<{ id: string; rating: number | null; content: string; review_date: Date | string | null }>(`SELECT review.review_key AS id, review.rating, review.content, review.review_date
+      FROM voc.review review JOIN voc.workspace workspace ON workspace.id=review.workspace_id JOIN voc.product product ON product.id=review.product_id
+      WHERE workspace.public_id=$1 AND review.platform=$2 AND product.product_id=$3 ORDER BY review.review_date DESC NULLS LAST LIMIT 100`, [workspaceId, platform, productId]);
+    return {
+      productReviews: reviews.rows.map((row) => ({ id: row.id, source: 'product-review', text: row.content, rating: row.rating === null ? null : Number(row.rating), observedAt: iso(row.review_date) })),
+      categoryVocEvidence: (source?.vocEvidence ?? []).map((item) => ({ id: item.id, source: 'category-voc', text: item.text, observedAt: item.collectedAt })),
+      competitors: [], competitorBasis: 'none',
+    };
+  }
+
   async getCurrentScore(workspaceId: string, productId: string, slot?: ListingCurrentScoreSlot): Promise<ListingScoreResult | null> {
     const result = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT r.result AS payload FROM voc.listing_current_score 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.slot=$3) ORDER BY CASE WHEN r.slot='formal_ai' THEN 0 ELSE 1 END`, [workspaceId, productId, slot ?? null]);
@@ -130,11 +142,33 @@ export class PostgresListingAiRepository implements ListingAiRepository {
     const score=result.rows[0]?.payload;return score&&isListingV7Score(score)?score:null;
   }
 
+  async getJdVocCurrentScore(workspaceId: string, productId: string, slot?: Extract<ListingCurrentScoreSlot, 'jd_voc_rules' | 'jd_voc_hybrid_ai'>): Promise<JdVocScoreResult | null> {
+    const result = await this.pool.query<JsonRow<JdVocScoreResult>>(`SELECT r.result AS payload
+      FROM voc.listing_current_score 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.slot=$3)
+      ORDER BY CASE WHEN r.slot='jd_voc_hybrid_ai' THEN 0 ELSE 1 END,r.updated_at DESC LIMIT 1`, [workspaceId, productId, slot ?? null]);
+    return result.rows[0]?.payload ?? null;
+  }
+
+  async upsertJdVocCurrentScore(score: JdVocScoreResult): Promise<JdVocScoreResult> {
+    const slot = score.scoreKind === 'jd_voc_hybrid_ai' ? 'jd_voc_hybrid_ai' : 'jd_voc_rules';
+    await this.pool.query(`INSERT INTO voc.listing_current_score
+      (public_id,workspace_id,product_id,slot,source_hash,rubric_version,overall_score,score_kind,ai_status,compliance_status,result,execution_key,input_fingerprint,updated_at)
+      SELECT $1,w.id,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14 FROM voc.workspace w WHERE w.public_id=$2
+      ON CONFLICT (workspace_id,product_id,slot) DO UPDATE SET public_id=EXCLUDED.public_id,source_hash=EXCLUDED.source_hash,rubric_version=EXCLUDED.rubric_version,overall_score=EXCLUDED.overall_score,score_kind=EXCLUDED.score_kind,result=EXCLUDED.result,execution_key=EXCLUDED.execution_key,input_fingerprint=EXCLUDED.input_fingerprint,updated_at=EXCLUDED.updated_at`,
+    [score.id, score.workspaceId, score.productId, slot, score.sourceHash, score.rubricVersion, score.overallScore, score.scoreKind, 'not_requested', 'normal', JSON.stringify(score), score.executionKey, score.inputFingerprint, score.createdAt]);
+    return score;
+  }
+
   async listCurrentScores(workspaceId: string): Promise<ListingScoreResult[]> {
     const result = await this.pool.query<JsonRow<ListingScoreResult>>(`SELECT r.result AS payload
       FROM voc.listing_current_score r JOIN voc.workspace w ON w.id=r.workspace_id WHERE w.public_id=$1`, [workspaceId]);
     return result.rows.map((row) => row.payload).filter(isListingV7Score);
   }
+  async listJdVocScores(workspaceId: string): Promise<JdVocScoreResult[]> {
+    const result = await this.pool.query<JsonRow<JdVocScoreResult>>(`SELECT r.result AS payload FROM voc.listing_current_score r JOIN voc.workspace w ON w.id=r.workspace_id WHERE w.public_id=$1 AND r.rubric_version='jd-voc-v0.5'`, [workspaceId]);
+    return result.rows.map((row) => row.payload);
+  }
 
   async upsertCurrentScore(score: ListingScoreResult): Promise<ListingScoreResult> {
     const slot: ListingCurrentScoreSlot = score.scoreKind === 'hybrid_ai' ? 'formal_ai' : 'rule_precheck';

+ 56 - 8
src/modules/listing-ai/routes.ts

@@ -8,8 +8,10 @@ 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 { JD_VOC_RUBRIC_VERSION } from './domain.js';
 import { selectCurrentListingScore } from './scoring/score-status.js';
 import { presentListingJob, presentListingJobItem, presentListingProductSummary, presentListingScore } from './presentation/listing-score.presenter.js';
+import { presentJdVocScore } from './presentation/jd-voc-score.presenter.js';
 import {
   createVersionRequestSchema,
   listingProductPresentationSchema,
@@ -19,6 +21,7 @@ import {
   listingOverviewResponseSchema,
   listingProductQuerySchema,
   listingWorkspaceQuerySchema,
+  jdVocScorePayloadSchema,
   scoreJobRequestSchema,
 } from './schemas.js';
 
@@ -29,7 +32,7 @@ export function createListingAiRouter(dependencies: {
   defaultWorkspaceId: string;
 }): Router {
   const router = Router();
-  const isV7Job = (rubricVersion: string) => rubricVersion === LISTING_RUBRIC_VERSION || rubricVersion === LISTING_AI_RUBRIC_VERSION;
+  const isSupportedJob = (rubricVersion: string) => rubricVersion === LISTING_RUBRIC_VERSION || rubricVersion === LISTING_AI_RUBRIC_VERSION || rubricVersion === JD_VOC_RUBRIC_VERSION;
 
   router.get('/overview', async (request, response, next) => {
     try {
@@ -64,6 +67,7 @@ export function createListingAiRouter(dependencies: {
       if (!source) throw new ApiError(404, 'listing_product_not_found');
       const currentScores = (await dependencies.service.repository.listCurrentScores(workspaceId)).filter((score) => score.productId === productId);
       const scoreView = selectCurrentListingScore(currentScores, source.sourceHash);
+      const jdVocScore = await dependencies.service.repository.getJdVocCurrentScore(workspaceId, productId);
       const latestScore = scoreView.displayScore;
       const versions = await dependencies.service.repository.listVersions(workspaceId, productId, 10, null);
       response.json({
@@ -74,14 +78,53 @@ export function createListingAiRouter(dependencies: {
             mobileHtml: sanitizeListingHtml(source.descriptions.mobileHtml),
           },
         },
-        coverage: latestScore?.coverage ?? listingCoverage(source),
+        coverage: dependencies.service.jdVocDisplayDefault && jdVocScore ? jdVocScore.coverage : latestScore?.coverage ?? listingCoverage(source),
+        displayScoreKind: dependencies.service.jdVocDisplayDefault && jdVocScore ? 'jd_voc' : 'legacy',
         currentScore: listingScorePresentationSchema.nullable().parse(presentListingScore(latestScore, source)),
         rulePrecheck: listingScorePresentationSchema.nullable().parse(presentListingScore(scoreView.latestEffectiveRuleScore, source)),
+        jdVocScore: jdVocScore ? jdVocScorePayloadSchema.parse(presentJdVocScore(jdVocScore, source)) : null,
         versionsSummary: versions,
       });
     } catch (error) { next(error); }
   });
 
+  router.get('/products/:productId/jd-voc-score', 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);
+      const score = await dependencies.service.repository.getJdVocCurrentScore(workspaceId, productId);
+      if (!source || !score || score.sourceHash !== source.sourceHash) throw new ApiError(404, 'jd_voc_score_not_found');
+      response.json({ score: jdVocScorePayloadSchema.parse(presentJdVocScore(score, source)) });
+    } catch (error) { next(error); }
+  });
+
+  router.post('/products/:productId/jd-voc-score', 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);
+      const body = z.object({ force: z.boolean().optional() }).parse(request.body ?? {});
+      await dependencies.access.require(request, workspaceId, 'analysis:run');
+      const result = await dependencies.service.scoreJdVocWithAi({ workspaceId, platform: query.platform, productId, ...(body.force !== undefined ? { force: body.force } : {}) });
+      response.status(202).json({ rules: jdVocScorePayloadSchema.parse(presentJdVocScore(result.rules)), hybrid: result.hybrid ? jdVocScorePayloadSchema.parse(presentJdVocScore(result.hybrid)) : null, errorCode: result.errorCode });
+    } catch (error) { next(error); }
+  });
+
+  router.post('/products/:productId/jd-voc-image-review', 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);
+      const body = z.object({ force: z.boolean().optional() }).parse(request.body ?? {});
+      await dependencies.access.require(request, workspaceId, 'analysis:run');
+      const result = await dependencies.service.reviewJdVocImages({ workspaceId, platform: query.platform, productId, ...(body.force !== undefined ? { force: body.force } : {}) });
+      response.status(202).json({ score: jdVocScorePayloadSchema.parse(presentJdVocScore(result)) });
+    } catch (error) { next(error); }
+  });
+
   router.get('/products/:productId/score', async (request, response, next) => {
     try {
       const query = listingWorkspaceQuerySchema.parse(request.query);
@@ -89,6 +132,11 @@ export function createListingAiRouter(dependencies: {
       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 jdVocScore = source && dependencies.service.jdVocDisplayDefault ? await dependencies.service.repository.getJdVocCurrentScore(workspaceId, productId) : null;
+      if (source && jdVocScore?.sourceHash === source.sourceHash) {
+        response.json({ score: jdVocScorePayloadSchema.parse(presentJdVocScore(jdVocScore, source)), displayScoreKind: 'jd_voc' });
+        return;
+      }
       const score = source ? selectCurrentListingScore((await dependencies.service.repository.listCurrentScores(workspaceId)).filter((item) => item.productId === productId), source.sourceHash).displayScore : null;
       if (!source || !score || score.sourceHash !== source.sourceHash) throw new ApiError(404, 'listing_score_not_found');
       response.json({ score: listingScorePresentationSchema.parse(presentListingScore(score, source)) });
@@ -106,7 +154,7 @@ export function createListingAiRouter(dependencies: {
       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),
+        rubricVersion: input.rubricVersion ?? (input.scoringMode && dependencies.service.jdVocEnabled ? JD_VOC_RUBRIC_VERSION : includeAiScoring ? LISTING_AI_RUBRIC_VERSION : LISTING_RUBRIC_VERSION),
         includeAiSuggestions: includeAiScoring,
         rescorePolicy: input.rescorePolicy,
         idempotencyKey, requestedBy: getPrincipal(request).userId,
@@ -126,7 +174,7 @@ export function createListingAiRouter(dependencies: {
       const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
       await dependencies.access.require(request, workspaceId, 'workspace:read');
       const page = await dependencies.service.repository.listJobs(workspaceId, query.status, query.limit, query.cursor ?? null);
-      response.json({ ...page, items: page.items.filter((job) => isV7Job(job.rubricVersion)).map(presentListingJob) });
+      response.json({ ...page, items: page.items.filter((job) => isSupportedJob(job.rubricVersion)).map(presentListingJob) });
     } catch (error) { next(error); }
   });
 
@@ -137,7 +185,7 @@ export function createListingAiRouter(dependencies: {
       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 || !isV7Job(job.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
+      if (!job || !isSupportedJob(job.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
       response.json({ job: presentListingJob(job) });
     } catch (error) { next(error); }
   });
@@ -149,7 +197,7 @@ export function createListingAiRouter(dependencies: {
       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 || !isV7Job(job.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
+      if (!job || !isSupportedJob(job.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
       const page = await dependencies.service.repository.listJobItems(workspaceId, jobId, query.status, query.limit, query.cursor ?? null);
       response.json({ ...page, items: page.items.map(presentListingJobItem) });
     } catch (error) { next(error); }
@@ -162,7 +210,7 @@ export function createListingAiRouter(dependencies: {
       const jobId = z.uuid().parse(request.params.jobId);
       await dependencies.access.require(request, workspaceId, 'analysis:run');
       const currentJob = await dependencies.service.repository.getJob(workspaceId, jobId);
-      if (!currentJob || !isV7Job(currentJob.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
+      if (!currentJob || !isSupportedJob(currentJob.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
       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: presentListingJob(job) });
@@ -176,7 +224,7 @@ export function createListingAiRouter(dependencies: {
       const jobId = z.uuid().parse(request.params.jobId);
       await dependencies.access.require(request, workspaceId, 'analysis:run');
       const currentJob = await dependencies.service.repository.getJob(workspaceId, jobId);
-      if (!currentJob || !isV7Job(currentJob.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
+      if (!currentJob || !isSupportedJob(currentJob.rubricVersion)) throw new ApiError(404, 'score_job_not_found');
       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: presentListingJob(job) });

+ 127 - 4
src/modules/listing-ai/schemas.ts

@@ -1,8 +1,115 @@
 import { z } from 'zod';
+import { JD_VOC_DIMENSION_MAX, JD_VOC_RUBRIC_VERSION } from './domain.js';
 
 const listingDimensionSchema = z.enum(['title', 'selling_points', 'images', 'description', 'specifications']);
+const listingOverviewDimensionKeySchema = z.enum(['title', 'selling_points', 'images', 'description', 'specifications', 'search', 'voc', 'selling', 'facts', 'competitive', 'media']);
 const listingOverviewScoreNatureSchema = z.enum(['simulation', 'formal_ai', 'rule_precheck', 'unscored']);
 
+const jdVocDimensionKeySchema = z.enum(['search', 'voc', 'selling', 'facts', 'competitive', 'media']);
+const jdVocScoreKindSchema = z.enum(['jd_voc_rules', 'jd_voc_hybrid_ai']);
+const jdVocSourceHashSchema = z.string().regex(/^[a-f0-9]{64}$/, 'sourceHash_must_be_64_lowercase_hex');
+const jdVocCoverageSchema = z.object({
+  percent: z.number().min(0).max(100),
+  missing: z.array(z.string().min(1)),
+  status: z.enum(['eligible', 'partial', 'blocked']),
+});
+const jdVocEvidenceSchema = z.object({
+  id: z.string().min(1),
+  ruleId: z.string().min(1),
+  fieldPath: z.string().min(1),
+  outcome: z.enum(['pass', 'fail', 'unknown']),
+  message: z.string(),
+  source: z.enum(['rule', 'ai']),
+  level: z.enum(['unknown', 'fail', 'weak', 'pass', 'strong']).optional(),
+  pointsAwarded: z.number().min(0).nullable(),
+  maxPoints: z.number().positive(),
+  confidence: z.number().min(0).max(1).nullable().optional(),
+  citations: z.array(z.string()).optional(),
+});
+const jdVocDimensionSchema = z.object({
+  key: jdVocDimensionKeySchema,
+  score: z.number().min(0).nullable(),
+  maxScore: z.number().positive(),
+  fixedEarned: z.number().min(0).nullable(),
+  verifiedMax: z.number().min(0),
+  coverage: z.number().min(0).max(100),
+  status: z.enum(['scored', 'partial', 'blocked']),
+  evidence: z.array(jdVocEvidenceSchema),
+});
+const jdVocActionSchema = z.object({
+  ruleId: z.string().min(1),
+  priority: z.enum(['P0', 'P1', 'P2', 'DATA']),
+  dimension: jdVocDimensionKeySchema,
+  title: z.string().min(1),
+  action: z.string().min(1),
+  evidenceIds: z.array(z.string()),
+  confidence: z.number().min(0).max(1).nullable(),
+  potentialGain: z.number().min(0),
+});
+
+export const jdVocScorePayloadSchema = z.object({
+  id: z.string().min(1),
+  workspaceId: z.string().min(1),
+  platform: z.literal('jd'),
+  productId: z.string().min(1),
+  sourceHash: jdVocSourceHashSchema,
+  rubricVersion: z.literal(JD_VOC_RUBRIC_VERSION),
+  scoreKind: jdVocScoreKindSchema,
+  overallScore: z.number().min(0).max(100).nullable(),
+  dimensions: z.array(jdVocDimensionSchema).length(6),
+  coverage: jdVocCoverageSchema,
+  voc: z.object({
+    profileId: z.string().nullable(),
+    productReviewCount: z.number().int().nonnegative(),
+    categoryVocEvidenceCount: z.number().int().nonnegative(),
+    concerns: z.array(z.object({
+      id: z.string().min(1), question: z.string().min(1), severity: z.enum(['low', 'medium', 'high']),
+      source: z.enum(['product-review', 'category-voc', 'category-profile', 'manual-question']), evidenceIds: z.array(z.string()),
+    })),
+    evidence: z.array(z.object({
+      id: z.string().min(1), source: z.enum(['product-review', 'category-voc', 'category-profile', 'manual-question']),
+      text: z.string(), rating: z.number().min(0).max(5).nullable().optional(), observedAt: z.string().nullable().optional(),
+    })).optional(),
+  }),
+  compliance: z.object({
+    status: z.enum(['normal', 'warning', 'blocked']), gate: z.enum(['PASS', 'WARN', 'BLOCK']),
+    findings: z.array(z.object({ ruleId: z.string(), severity: z.string(), fieldPath: z.string(), message: z.string(), evidence: z.array(z.string()) })),
+  }).optional(),
+  competitor: z.object({
+    basis: z.enum(['formal', 'category-inferred', 'none']), count: z.number().int().nonnegative(),
+    comparableSurface: z.array(z.string()), productIds: z.array(z.string()), items: z.array(z.object({ productId:z.string(),title:z.string(),brand:z.string(),price:z.number().nullable(),specifications:z.array(z.string()),sellingPoints:z.array(z.string()),observedAt:z.string().nullable(),status:z.enum(['available','partial']) })).optional(),
+  }),
+  actions: z.array(jdVocActionSchema),
+  aiReview: z.object({
+    status: z.enum(['not_requested', 'pending', 'completed', 'failed']), model: z.string().nullable(), promptVersion: z.string().nullable(), baselineFingerprint: z.string().optional(), latencyMs:z.number().nonnegative().optional(), usage:z.object({promptTokens:z.number().int().nonnegative(),completionTokens:z.number().int().nonnegative(),totalTokens:z.number().int().nonnegative()}).optional(),
+    baselineScore: z.number().min(0).max(100).nullable(), hybridScore: z.number().min(0).max(100).nullable(),
+    assessments: z.array(z.object({ dimension: jdVocDimensionKeySchema, score: z.number().min(0).nullable(), evidenceIds: z.array(z.string()), rationale: z.string() })),
+    suggestions: z.array(z.object({ title: z.string().min(1), action: z.string().min(1), evidenceIds: z.array(z.string()) })),
+  }).optional(),
+  imageReview: z.object({
+    status: z.enum(['not_requested', 'shadow_completed', 'failed']), model: z.string().nullable(), evidence: z.array(z.object({ id: z.string().min(1), fieldPath: z.string().min(1), outcome: z.enum(['pass', 'fail', 'unknown']), message: z.string(), confidence: z.number().min(0).max(1) })), suggestions: z.array(z.string()),
+  }).optional(),
+  executionKey: z.string().min(1),
+  inputFingerprint: z.string().min(1),
+  createdAt: z.string().datetime(),
+}).superRefine((payload, context) => {
+  const seen = new Set<string>();
+  for (const [index, dimension] of payload.dimensions.entries()) {
+    if (seen.has(dimension.key)) context.addIssue({ code: 'custom', path: ['dimensions', index, 'key'], message: 'duplicate_dimension' });
+    seen.add(dimension.key);
+    const expectedMax = JD_VOC_DIMENSION_MAX[dimension.key];
+    if (dimension.maxScore !== expectedMax) context.addIssue({ code: 'custom', path: ['dimensions', index, 'maxScore'], message: 'dimension_max_score_mismatch' });
+    if (dimension.score !== null && dimension.score > dimension.maxScore) context.addIssue({ code: 'custom', path: ['dimensions', index, 'score'], message: 'score_exceeds_max' });
+    if (dimension.fixedEarned !== null && dimension.fixedEarned > dimension.maxScore) context.addIssue({ code: 'custom', path: ['dimensions', index, 'fixedEarned'], message: 'fixed_earned_exceeds_max' });
+    if (dimension.verifiedMax > dimension.maxScore) context.addIssue({ code: 'custom', path: ['dimensions', index, 'verifiedMax'], message: 'verified_max_exceeds_max' });
+    if (dimension.score === null && dimension.status === 'scored') context.addIssue({ code: 'custom', path: ['dimensions', index, 'status'], message: 'null_score_requires_partial_or_blocked' });
+  }
+});
+
+// Contract name used by route/presenter code; keep payload alias for callers that
+// validate persisted Parse payloads directly.
+export const jdVocScoreResultSchema = jdVocScorePayloadSchema;
+
 export const listingWorkspaceQuerySchema = z.object({
   workspaceId: z.string().min(1).optional(),
   platform: z.literal('jd').default('jd'),
@@ -38,6 +145,11 @@ export const listingOverviewQuerySchema = listingPageQuerySchema.extend({
   search: z.string().max(200).optional(),
   categoryIds: categoryIdsSchema.optional(),
   scoreNature: listingOverviewScoreNatureSchema.optional(),
+  rubricVersion: z.string().min(1).max(100).optional(),
+  scoreKind: z.enum(['rules', 'hybrid_ai', 'jd_voc_rules', 'jd_voc_hybrid_ai']).optional(),
+  priority: z.enum(['P0', 'P1', 'P2', 'DATA']).optional(),
+  coverageMin: z.coerce.number().min(0).max(100).optional(),
+  coverageMax: z.coerce.number().min(0).max(100).optional(),
   minScore: z.coerce.number().min(0).max(100).optional(),
   maxScore: z.coerce.number().min(0).max(100).optional(),
   titleMin: z.coerce.number().min(0).max(30).optional(),
@@ -50,8 +162,8 @@ export const listingOverviewQuerySchema = listingPageQuerySchema.extend({
   descriptionMax: z.coerce.number().min(0).max(15).optional(),
   specificationsMin: z.coerce.number().min(0).max(10).optional(),
   specificationsMax: z.coerce.number().min(0).max(10).optional(),
-  weakestDimension: listingDimensionSchema.optional(),
-  sort: z.enum(['productId', 'overallScore', 'title', 'selling_points', 'images', 'description', 'specifications', 'improvementPotential', 'scoredAt', 'syncedAt']).default('improvementPotential'),
+  weakestDimension: listingOverviewDimensionKeySchema.optional(),
+  sort: z.enum(['productId', 'overallScore', 'title', 'selling_points', 'images', 'description', 'specifications', 'search', 'voc', 'selling', 'facts', 'competitive', 'media', 'improvementPotential', 'scoredAt', 'syncedAt']).default('improvementPotential'),
   direction: z.enum(['asc', 'desc']).default('desc'),
 }).superRefine((query, context) => {
   const ranges: Array<[number | undefined, number | undefined, string]> = [
@@ -61,6 +173,7 @@ export const listingOverviewQuerySchema = listingPageQuerySchema.extend({
     [query.imagesMin, query.imagesMax, 'imagesMin'],
     [query.descriptionMin, query.descriptionMax, 'descriptionMin'],
     [query.specificationsMin, query.specificationsMax, 'specificationsMin'],
+    [query.coverageMin, query.coverageMax, 'coverageMin'],
   ];
   for (const [minimum, maximum, path] of ranges) {
     if (minimum !== undefined && maximum !== undefined && minimum > maximum) {
@@ -130,6 +243,10 @@ const listingOverviewDimensionsSchema = z.object({
   description: listingOverviewDimensionValueSchema,
   specifications: listingOverviewDimensionValueSchema,
 });
+const jdVocOverviewDimensionsSchema = z.object({
+  search: listingOverviewDimensionValueSchema, voc: listingOverviewDimensionValueSchema, selling: listingOverviewDimensionValueSchema,
+  facts: listingOverviewDimensionValueSchema, competitive: listingOverviewDimensionValueSchema, media: listingOverviewDimensionValueSchema,
+});
 
 export const listingOverviewRowSchema = z.object({
   productId: z.string(),
@@ -145,14 +262,19 @@ export const listingOverviewRowSchema = z.object({
   overallScore: z.number().min(0).max(100).nullable(),
   overallRate: z.number().min(0).max(1).nullable(),
   dimensions: listingOverviewDimensionsSchema,
-  weakestDimension: listingDimensionSchema.nullable(),
+  jdVocDimensions: jdVocOverviewDimensionsSchema.optional(),
+  weakestDimension: listingOverviewDimensionKeySchema.nullable(),
   improvementPotential: z.number().min(0).max(100).nullable(),
   scoredAt: z.string().nullable(),
   syncedAt: z.string(),
+  rubricVersion: z.string().nullable().optional(),
+  scoreKind: z.enum(['rules', 'hybrid_ai', 'jd_voc_rules', 'jd_voc_hybrid_ai']).nullable().optional(),
+  coveragePercent: z.number().min(0).max(100).nullable().optional(),
+  priority: z.enum(['P0', 'P1', 'P2', 'DATA']).nullable().optional(),
 });
 
 const listingOverviewDimensionStatSchema = z.object({
-  key: listingDimensionSchema,
+  key: listingOverviewDimensionKeySchema,
   label: z.string(),
   maxScore: z.number().positive(),
   scoredCount: z.number().int().nonnegative(),
@@ -182,6 +304,7 @@ export const listingOverviewResponseSchema = z.object({
       description: listingOverviewDimensionStatSchema,
       specifications: listingOverviewDimensionStatSchema,
     }),
+    jdVocDimensionStats: z.object({ search: listingOverviewDimensionStatSchema, voc: listingOverviewDimensionStatSchema, selling: listingOverviewDimensionStatSchema, facts: listingOverviewDimensionStatSchema, competitive: listingOverviewDimensionStatSchema, media: listingOverviewDimensionStatSchema }).optional(),
     categoryFacets: z.array(z.object({ categoryId: z.string(), categoryName: z.string(), categoryPath: z.array(z.string()), count: z.number().int().nonnegative() })),
     scoreNatureFacets: z.array(z.object({ value: listingOverviewScoreNatureSchema, label: z.string(), count: z.number().int().nonnegative() })),
     snapshotId: z.string(),

+ 138 - 0
src/modules/listing-ai/scoring/jd-voc-ai-rubric.ts

@@ -0,0 +1,138 @@
+import { z } from 'zod';
+import type { JdVocDimensionKey, JdVocScoreResult, ListingSourceSnapshot } from '../domain.js';
+import { JD_VOC_DIMENSION_MAX } from '../domain.js';
+import { canonicalHash } from './rule-engine.js';
+
+export const JD_VOC_AI_PROMPT_VERSION = 'jd-voc-ai-prompt-v1';
+export const JD_VOC_AI_MODEL_DEFAULT = 'gpt-4o-mini';
+const dimensions = ['search', 'voc', 'selling', 'facts', 'competitive'] as const;
+const dimensionSchema = z.enum(dimensions);
+
+const assessmentSchema = z.object({
+  dimension: dimensionSchema,
+  score: z.number().min(0),
+  evidenceIds: z.array(z.string().min(1)).max(8),
+  rationale: z.string().min(1).max(2_000),
+  confidence: z.number().min(0).max(1),
+}).strict();
+const suggestionSchema = z.object({
+  title: z.string().min(1).max(300), action: z.string().min(1).max(1_000), evidenceIds: z.array(z.string().min(1)).min(1).max(8),
+}).strict();
+export const jdVocAiScoreOutputSchema = z.object({
+  assessments: z.array(assessmentSchema).length(dimensions.length),
+  suggestions: z.array(suggestionSchema).max(12),
+  summary: z.string().min(1).max(2_000),
+}).strict().superRefine((value, context) => {
+  const seen = new Set<string>();
+  value.assessments.forEach((item, index) => {
+    if (seen.has(item.dimension)) context.addIssue({ code: 'custom', path: ['assessments', index, 'dimension'], message: 'duplicate_dimension' });
+    seen.add(item.dimension);
+    if (item.score > JD_VOC_DIMENSION_MAX[item.dimension]) context.addIssue({ code: 'custom', path: ['assessments', index, 'score'], message: 'score_exceeds_dimension_max' });
+    if (item.score > 0 && item.evidenceIds.length === 0) context.addIssue({ code: 'custom', path: ['assessments', index, 'evidenceIds'], message: 'positive_score_requires_evidence' });
+  });
+});
+export type JdVocAiScoreOutput = z.infer<typeof jdVocAiScoreOutputSchema> & {
+  latencyMs?: number;
+  usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
+};
+
+export interface JdVocAiEvidenceEntry { id: string; path: string; value: string; }
+
+export function jdVocAiEvidenceCatalog(source: ListingSourceSnapshot, baseline: JdVocScoreResult): JdVocAiEvidenceEntry[] {
+  const entries: JdVocAiEvidenceEntry[] = [];
+  const add = (path: string, value: unknown) => {
+    if (value === null || value === undefined || !String(value).trim()) return;
+    entries.push({ id: path, path, value: String(value).replace(/\s+/g, ' ').trim().slice(0, 500) });
+  };
+  add('source.title', source.title); add('source.brand', source.brand.name);
+  source.features.slice(0, 40).forEach((item, index) => add(`source.features[${index}].${item.key}`, item.value));
+  source.attributes.slice(0, 60).forEach((item, index) => add(`source.attributes[${index}].${item.name}`, item.values.join('、')));
+  source.marketing?.sellingPoints.slice(0, 20).forEach((item, index) => add(`source.marketing.sellingPoints[${index}]`, item.value));
+  Object.entries(source.dimensions).forEach(([key, value]) => add(`source.dimensions.${key}`, value));
+  baseline.dimensions.flatMap((item) => item.evidence).slice(0, 100).forEach((item) => add(item.id, item.message));
+  return [...new Map(entries.map((entry) => [entry.id, entry])).values()];
+}
+
+export function jdVocAiPrompt(): string {
+  return [
+    '你是 JD-VOC 六维语义复核器,只能依据输入 JSON 中的结构化商品字段和规则证据。',
+    '规则已经负责数值字段、覆盖率、图片边界和合规门禁;你只能给每个维度 0 到其 maxScore 的语义分,不得解除 BLOCK。',
+    '不得读取或引用图片视觉内容、详情图片文字、外部知识、销量、CTR、CVR、价格表现或用户评论正文来证明 Listing 回应。',
+    '每个正分必须引用 evidenceCatalog 中存在的 evidenceId;建议中的数字和单位必须逐字存在于结构化事实证据中,否则建议会被拒绝。',
+    `必须返回五个唯一语义维度 ${dimensions.join(', ')}。media 图片维度由规则独占,禁止语义评分。`,
+    '只返回 JSON,不要 Markdown:{"assessments":[{"dimension":"search","score":0,"evidenceIds":["source.title"],"rationale":"理由","confidence":0.8}],"suggestions":[{"title":"标题","action":"动作","evidenceIds":["source.title"]}],"summary":"总结"}。',
+  ].join('\n');
+}
+
+export function parseJdVocAiScoreOutput(content: string): JdVocAiScoreOutput | 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;
+  try {
+    const parsed = JSON.parse(trimmed.slice(start, end + 1)) as Record<string, unknown>;
+    const candidate = parsed.result && typeof parsed.result === 'object' ? parsed.result as Record<string, unknown> : parsed;
+    const rawAssessments = Array.isArray(candidate.assessments) ? candidate.assessments : candidate.assessments && typeof candidate.assessments === 'object'
+      ? Object.entries(candidate.assessments as Record<string, unknown>).map(([dimension, value]) => ({ dimension, ...(value && typeof value === 'object' ? value as Record<string, unknown> : {}) })) : [];
+    const assessments = rawAssessments.map((raw) => {
+      const item = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {};
+      const dimension = String(item.dimension ?? item.key ?? item.id ?? '');
+      const rawEvidence = item.evidenceIds ?? item.evidence_ids;
+      const evidenceIds = (typeof rawEvidence === 'string' ? [rawEvidence] : Array.isArray(rawEvidence) ? rawEvidence : []).filter((value): value is string => typeof value === 'string' && Boolean(value.trim())).map((value) => value.trim()).slice(0, 8);
+      const numeric = Number(item.score ?? item.points ?? 0);
+      const rawConfidence = Number(item.confidence ?? 0);
+      return { dimension, score: evidenceIds.length && Number.isFinite(numeric) ? numeric : 0, evidenceIds, rationale: String(item.rationale ?? item.reason ?? '模型未提供理由').slice(0, 2_000), confidence: Number.isFinite(rawConfidence) ? Math.min(1, Math.max(0, rawConfidence > 1 ? rawConfidence / 100 : rawConfidence)) : 0 };
+    });
+    const rawSuggestions = Array.isArray(candidate.suggestions) ? candidate.suggestions : [];
+    const suggestions = rawSuggestions.flatMap((raw) => {
+      if (!raw || typeof raw !== 'object') return [];
+      const item = raw as Record<string, unknown>;
+      const ids = item.evidenceIds ?? item.evidence_ids;
+      const evidenceIds = (typeof ids === 'string' ? [ids] : Array.isArray(ids) ? ids : []).filter((value): value is string => typeof value === 'string' && Boolean(value.trim())).map((value) => value.trim()).slice(0, 8);
+      if (!evidenceIds.length) return [];
+      return [{ title: String(item.title ?? item.action ?? '').slice(0, 300), action: String(item.action ?? item.description ?? '').slice(0, 1_000), evidenceIds }];
+    });
+    const normalized = { assessments, suggestions, summary: String(candidate.summary ?? 'AI 已完成语义复核').slice(0, 2_000) };
+    const result = jdVocAiScoreOutputSchema.safeParse(normalized);
+    return result.success ? result.data : null;
+  } catch { return null; }
+}
+
+function containsObservedNumber(text: string, facts: string[]): boolean {
+  const numbers = text.match(/\d+(?:\.\d+)?\s*(?:万|千|kg|克|g|L|毫升|ml|W|kW|V|cm|mm|英寸|人|层|盘|%)?/gi) ?? [];
+  return numbers.every((number) => facts.some((fact) => fact.toLocaleLowerCase().includes(number.toLocaleLowerCase())));
+}
+
+export function validateJdVocAiOutput(output: JdVocAiScoreOutput, baseline: JdVocScoreResult, catalog: JdVocAiEvidenceEntry[], source: ListingSourceSnapshot): JdVocAiScoreOutput {
+  const allowed = new Set(catalog.map((entry) => entry.id));
+  for (const assessment of output.assessments) {
+    if (assessment.evidenceIds.some((id) => !allowed.has(id))) throw new Error('jd_voc_ai_evidence_invalid');
+  }
+  const facts = [source.title ?? '', ...(source.marketing?.sellingPoints ?? []).map((item) => item.value), ...source.features.map((item) => item.value), ...source.attributes.flatMap((item) => item.values), ...Object.values(source.dimensions).filter((item) => item !== null).map(String)];
+  const suggestions = output.suggestions.filter((suggestion) => suggestion.evidenceIds.every((id) => allowed.has(id)) && containsObservedNumber(`${suggestion.title} ${suggestion.action}`, facts));
+  return { ...output, suggestions };
+}
+
+export function composeJdVocHybridScore(input: { baseline: JdVocScoreResult; output: JdVocAiScoreOutput; model: string; source: ListingSourceSnapshot; now: string }): JdVocScoreResult {
+  const catalog = jdVocAiEvidenceCatalog(input.source, input.baseline);
+  const validated = validateJdVocAiOutput(input.output, input.baseline, catalog, input.source);
+  const byDimension = new Map(validated.assessments.map((item) => [item.dimension, item]));
+  const dimensions = input.baseline.dimensions.map((dimension) => {
+    if (dimension.key === 'media') return dimension;
+    const assessment = byDimension.get(dimension.key);
+    if (!assessment || dimension.score === null) return dimension;
+    const score = Math.round((dimension.score * 0.65 + assessment.score * 0.35) * 10) / 10;
+    return { ...dimension, score, evidence: dimension.evidence };
+  });
+  const blocked = input.baseline.coverage.status === 'blocked' || input.baseline.dimensions.some((item) => item.status === 'blocked');
+  const observed = dimensions.filter((item) => item.score !== null);
+  const overallScore = blocked || !observed.length ? null : Math.round(observed.reduce((sum, item) => sum + (item.score ?? 0), 0) / observed.reduce((sum, item) => sum + item.maxScore, 0) * 1000) / 10;
+  const confidence = validated.assessments.length ? Math.round(validated.assessments.reduce((sum, item) => sum + item.confidence, 0) / validated.assessments.length * 1000) / 1000 : null;
+  const inputFingerprint = canonicalHash({ baselineFingerprint: input.baseline.inputFingerprint, model: input.model, promptVersion: JD_VOC_AI_PROMPT_VERSION });
+  return {
+    ...input.baseline, scoreKind: 'jd_voc_hybrid_ai', overallScore, dimensions, createdAt: input.now, inputFingerprint, executionKey: `jd-voc-v0.5:ai:${inputFingerprint}`,
+    aiReview: { status: 'completed', model: input.model, promptVersion: JD_VOC_AI_PROMPT_VERSION, baselineFingerprint: input.baseline.inputFingerprint, baselineScore: input.baseline.overallScore, hybridScore: overallScore,
+      ...(input.output.latencyMs !== undefined ? { latencyMs: input.output.latencyMs } : {}), ...(input.output.usage ? { usage: input.output.usage } : {}),
+      assessments: validated.assessments.map((item) => ({ dimension: item.dimension, score: item.score, evidenceIds: item.evidenceIds, rationale: item.rationale })),
+      suggestions: validated.suggestions.map((item) => ({ title: item.title, action: item.action, evidenceIds: item.evidenceIds })) },
+  };
+}

+ 218 - 0
src/modules/listing-ai/scoring/jd-voc-lab/rubric-config.js

@@ -0,0 +1,218 @@
+export const RUBRIC_VERSION = 'JD-VOC v0.5-evidence-calibrated';
+
+export const DIMENSIONS = [
+  { key: 'search', label: '标题与搜索表达', short: '搜得到 · 看得懂', max: 25 },
+  { key: 'voc', label: 'VOC需求响应', short: '顾虑 · 决策', max: 25 },
+  { key: 'selling', label: '卖点说服力', short: '具体 · 有益', max: 15 },
+  { key: 'facts', label: '事实与信息完整', short: '字段 · 一致', max: 20 },
+  { key: 'competitive', label: '竞品差异化', short: '区隔 · 优势', max: 10 },
+  { key: 'media', label: '图片资产数量', short: '只计数量', max: 5 },
+];
+
+export const SEVERITY_WEIGHT = { high: 3, medium: 2, low: 1 };
+
+const common = [
+  { id: 'fit', severity: 'high', question: '是否适合目标经营场景与使用人数', terms: ['商用', '饭店', '餐厅', '食堂', '学校', '工厂', '酒店', '人数', '人用'] },
+  { id: 'spec', severity: 'high', question: '关键规格是否足以判断能否使用', terms: ['容量', '尺寸', '功率', '电压', '升', 'L', 'W', 'kW', 'V'] },
+  { id: 'safety', severity: 'medium', question: '安全与稳定性是否有明确说明', terms: ['安全', '防干烧', '断电', '保护', '防水', '防漏', '过热', '稳定'] },
+  { id: 'service', severity: 'medium', question: '安装、售后和使用成本是否清楚', terms: ['安装', '保修', '质保', '售后', '上门', '能耗', '省电'] },
+];
+
+export const CATEGORY_PROFILES = [
+  {
+    id: 'water', label: '商用开水与净饮设备', match: ['开水器', '饮水机', '直饮机', '净水'], anchors: ['开水器', '饮水机', '直饮机'],
+    requiredGroups: [['L/H', '升/小时', '出水量'], ['220V', '380V', '电压'], ['kW', '功率'], ['供', '人用', '人数']],
+    concerns: [
+      { id: 'output', severity: 'high', question: '出水量能否满足高峰期人数', terms: ['L/H', '升/小时', '出水量', '供', '人用'] },
+      { id: 'water-quality', severity: 'high', question: '过滤和水质方案是否说明清楚', terms: ['过滤', '滤芯', '净化', '直饮', '水质'] },
+      { id: 'power', severity: 'medium', question: '电压功率与安装条件是否匹配', terms: ['220V', '380V', 'kW', '功率', '安装'] },
+      { id: 'temperature', severity: 'medium', question: '加热速度、保温和温控是否明确', terms: ['加热', '保温', '温控', '数显', '温度'] },
+    ],
+  },
+  {
+    id: 'fryer', label: '商用电炸设备', match: ['电炸炉', '炸锅', '油炸机'], anchors: ['电炸炉', '炸锅', '油炸机'],
+    requiredGroups: [['L', '升', '容量'], ['W', 'kW', '功率'], ['220V', '380V'], ['控温', '温度']],
+    concerns: [
+      { id: 'capacity', severity: 'high', question: '容量和出餐效率是否满足经营规模', terms: ['L', '升', '容量', '双缸', '单缸', '出餐'] },
+      { id: 'temperature', severity: 'high', question: '升温、控温和火力是否稳定', terms: ['功率', 'W', 'kW', '控温', '温度', '升温'] },
+      { id: 'clean', severity: 'medium', question: '排油和清洁维护是否方便', terms: ['排油', '清洁', '可拆', '不锈钢', '接油'] },
+      { id: 'safety', severity: 'medium', question: '高温作业安全保护是否明确', terms: ['断电', '过热', '保护', '防烫', '安全'] },
+    ],
+  },
+  {
+    id: 'griddle', label: '商用电饼铛', match: ['电饼铛', '电饼炉', '烙饼机'], anchors: ['电饼铛', '电饼炉', '烙饼机'],
+    requiredGroups: [['cm', 'CM', '直径', '盘'], ['W', 'kW', '功率'], ['220V', '380V'], ['双面', '上下盘']],
+    concerns: [
+      { id: 'size', severity: 'high', question: '烤盘尺寸与单次产能是否明确', terms: ['cm', 'CM', '直径', '烤盘', '产能'] },
+      { id: 'heating', severity: 'high', question: '双面加热和温控效果是否清楚', terms: ['双面', '上下盘', '控温', '温度', '加热'] },
+      { id: 'power', severity: 'medium', question: '功率电压是否适配经营场地', terms: ['W', 'kW', '220V', '380V', '功率'] },
+      { id: 'use', severity: 'medium', question: '适用品类和经营场景是否具体', terms: ['烙饼', '千层饼', '酱香饼', '早餐店', '食堂'] },
+    ],
+  },
+  {
+    id: 'disinfection', label: '商用消毒设备', match: ['消毒柜', '消毒机'], anchors: ['消毒柜', '消毒机'],
+    requiredGroups: [['L', '升', '容量', '层'], ['高温', '紫外线', '臭氧', '消毒方式'], ['尺寸', 'mm', 'cm'], ['W', '功率']],
+    concerns: [
+      { id: 'method', severity: 'high', question: '消毒方式和适用餐具是否明确', terms: ['高温', '紫外线', '臭氧', '热风', '红外线', '餐具', '杯子'] },
+      { id: 'capacity', severity: 'high', question: '容量层数能否满足使用规模', terms: ['L', '升', '层', '容量', '大容量'] },
+      { id: 'placement', severity: 'medium', question: '尺寸和摆放场景是否清楚', terms: ['尺寸', '小型', '迷你', '立式', '壁挂', '办公室', '茶室'] },
+      { id: 'safety', severity: 'medium', question: '安全防护和消毒等级是否充分', terms: ['安全', '保护', '认证', '童锁', '防烫', '开门断电', '消毒星级', '一星级', '二星级'] },
+    ],
+  },
+  {
+    id: 'ice', label: '商用制冰机', match: ['制冰机'], anchors: ['制冰机'],
+    requiredGroups: [['kg', 'KG', '日产'], ['冰格', '冰型'], ['W', '功率'], ['进水', '接水']],
+    concerns: [
+      { id: 'output', severity: 'high', question: '日产冰量与高峰杯量是否明确', terms: ['日产', 'KG', 'kg', '杯', '冰量'] },
+      { id: 'ice-type', severity: 'high', question: '冰型、冰格与制冰速度是否清楚', terms: ['冰格', '方块冰', '制冰速度', '分钟'] },
+      { id: 'installation', severity: 'medium', question: '进排水和安装条件是否明确', terms: ['进水', '排水', '接水', '安装', '水压'] },
+      { id: 'scene', severity: 'medium', question: '是否明确适配门店类型与峰值需求', terms: ['奶茶店', '酒吧', '餐饮', '火锅店', 'KTV'] },
+    ],
+  },
+  {
+    id: 'blender', label: '商用豆浆与破壁设备', match: ['豆浆机', '破壁机', '沙冰机', '磨浆机'], anchors: ['豆浆机', '破壁机', '沙冰机', '磨浆机'],
+    requiredGroups: [['L', '升', '容量'], ['W', '功率', '转速'], ['浆渣', '破壁', '研磨'], ['降噪', '隔音', '噪音']],
+    concerns: [
+      { id: 'capacity', severity: 'high', question: '容量和连续出杯能力是否明确', terms: ['L', '升', '容量', '出杯', '连续'] },
+      { id: 'result', severity: 'high', question: '研磨效果和成品质地是否清楚', terms: ['浆渣分离', '细腻', '破壁', '研磨', '免滤'] },
+      { id: 'noise', severity: 'medium', question: '商用噪音控制是否有说明', terms: ['降噪', '隔音', '低音', '噪音'] },
+      { id: 'clean', severity: 'medium', question: '清洗、防水和维护是否方便', terms: ['清洗', '自清洁', '防水', '可拆', '维护'] },
+    ],
+  },
+  {
+    id: 'steamer', label: '商用蒸饭设备', match: ['蒸饭柜', '蒸饭车', '蒸饭箱', '蒸箱'], anchors: ['蒸饭柜', '蒸饭车', '蒸饭箱', '蒸箱'],
+    requiredGroups: [['盘', '层', '容量'], ['人', '人数', '供餐'], ['kW', '功率'], ['220V', '380V'], ['304', '不锈钢']],
+    concerns: [
+      { id: 'capacity', severity: 'high', question: '盘数和单次供餐人数是否明确', terms: ['盘', '层', '人', '供餐', '容量'] },
+      { id: 'steam', severity: 'high', question: '蒸制速度和受热均匀性是否说明', terms: ['蒸汽', '快速', '均匀', '定时', '温控'] },
+      { id: 'safety', severity: 'medium', question: '缺水、防干烧和泄压保护是否明确', terms: ['防干烧', '缺水', '泄压', '安全', '保护'] },
+      { id: 'material', severity: 'medium', question: '内胆材质和清洁维护是否清楚', terms: ['304', '不锈钢', '内胆', '清洁', '排水'] },
+    ],
+  },
+  {
+    id: 'oven', label: '商用烘焙设备', match: ['烤箱', '烘焙炉', '披萨炉'], anchors: ['烤箱', '烘焙炉', '披萨炉'],
+    requiredGroups: [['层', '盘', '容量'], ['温度', '控温'], ['kW', '功率'], ['220V', '380V'], ['尺寸', 'mm', 'cm']],
+    concerns: [
+      { id: 'capacity', severity: 'high', question: '层盘容量和单批产量是否明确', terms: ['层', '盘', '容量', '产量'] },
+      { id: 'temperature', severity: 'high', question: '温度范围与控温均匀性是否清楚', terms: ['温度', '控温', '均匀', '上下火'] },
+      { id: 'power', severity: 'medium', question: '功率电压与安装条件是否匹配', terms: ['kW', '功率', '220V', '380V', '安装'] },
+      { id: 'use', severity: 'medium', question: '适用烘焙品类是否具体', terms: ['蛋糕', '月饼', '面包', '披萨', '烤红薯'] },
+    ],
+  },
+  {
+    id: 'dishwasher', label: '商用洗碗设备', match: ['洗碗机', '洗杯机'], anchors: ['洗碗机', '洗杯机'],
+    requiredGroups: [['筐', '件/小时', '清洗量', '餐位'], ['kW', '功率'], ['220V', '380V'], ['尺寸', 'mm'], ['进水', '排水']],
+    concerns: [
+      { id: 'throughput', severity: 'high', question: '每小时清洗量是否满足餐位规模', terms: ['件/小时', '筐/小时', '餐位', '清洗量', '效率'] },
+      { id: 'clean-result', severity: 'high', question: '清洗、除菌和烘干效果是否明确', terms: ['清洗', '除菌', '烘干', '高温', '喷淋'] },
+      { id: 'installation', severity: 'medium', question: '水电与安装空间要求是否清楚', terms: ['220V', '380V', '进水', '排水', '安装', '尺寸'] },
+      { id: 'cost', severity: 'medium', question: '耗水耗电和人工节省是否说明', terms: ['耗水', '耗电', '节能', '省人工', '成本'] },
+    ],
+  },
+  {
+    id: 'refrigeration', label: '商用制冷设备', match: ['冰箱', '冷柜', '保鲜柜', '冷藏工作台', '雪柜'], anchors: ['冰箱', '冷柜', '保鲜柜', '冷藏工作台', '雪柜'],
+    requiredGroups: [['L', '升', '容量', '门'], ['冷藏', '冷冻', '双温'], ['温度', '控温'], ['尺寸', 'mm'], ['铜管', '制冷']],
+    concerns: [
+      { id: 'capacity', severity: 'high', question: '容量门数与后厨储存规模是否明确', terms: ['L', '升', '容量', '门', '储存'] },
+      { id: 'temperature', severity: 'high', question: '冷藏冷冻温区和控温范围是否清楚', terms: ['冷藏', '冷冻', '双温', '温区', '控温'] },
+      { id: 'cooling', severity: 'medium', question: '制冷方式和降温稳定性是否说明', terms: ['风冷', '直冷', '铜管', '制冷', '降温'] },
+      { id: 'energy', severity: 'medium', question: '能耗、噪音和维护成本是否清楚', terms: ['能效', '节能', '省电', '噪音', '维护'] },
+    ],
+  },
+  {
+    id: 'rice-cooker', label: '商用电饭设备', match: ['电饭煲', '电饭锅'], anchors: ['电饭煲', '电饭锅'],
+    requiredGroups: [['L', '升', '容量'], ['人', '人用'], ['W', '功率'], ['保温', '定时']],
+    concerns: [
+      { id: 'capacity', severity: 'high', question: '容量和适用人数是否明确', terms: ['L', '升', '容量', '人用', '人数'] },
+      { id: 'result', severity: 'high', question: '受热与米饭成品效果是否说明', terms: ['均匀', '不粘', '焖饭', '受热', '口感'] },
+      { id: 'speed', severity: 'medium', question: '煮饭速度和保温能力是否清楚', terms: ['快速', '分钟', '保温', '定时'] },
+      { id: 'safety', severity: 'medium', question: '防干烧和操作安全是否明确', terms: ['防干烧', '断电', '安全', '保护'] },
+    ],
+  },
+  {
+    id: 'induction', label: '商用电磁加热设备', match: ['电磁炉', '电炒炉', '电磁灶'], anchors: ['电磁炉', '电炒炉', '电磁灶'],
+    requiredGroups: [['W', 'kW', '功率'], ['220V', '380V'], ['档', '火力'], ['锅径', 'cm', '尺寸']],
+    concerns: [
+      { id: 'power', severity: 'high', question: '功率火力能否满足商用爆炒需求', terms: ['W', 'kW', '功率', '火力', '爆炒'] },
+      { id: 'compatibility', severity: 'high', question: '电压、锅径和场地适配是否明确', terms: ['220V', '380V', '锅径', '尺寸', '安装'] },
+      { id: 'control', severity: 'medium', question: '火力调节和操控是否清楚', terms: ['档', '火力调节', '旋钮', '触控', '数显'] },
+      { id: 'safety', severity: 'medium', question: '散热与过热保护是否明确', terms: ['散热', '过热', '防干烧', '保护', '安全'] },
+    ],
+  },
+  {
+    id: 'peeler', label: '商用果蔬去皮设备', match: ['去皮机', '磨皮机', '剥皮机'], anchors: ['去皮机', '磨皮机', '剥皮机'],
+    requiredGroups: [['kg/h', '公斤/小时', '处理量', '产量'], ['去皮率', '残留', '破损', '均匀'], ['土豆', '马铃薯', '地瓜', '生姜'], ['W', 'kW', '功率']],
+    concerns: [
+      { id: 'throughput', severity: 'high', question: '每小时处理量是否匹配经营规模', terms: ['kg/h', '公斤/小时', '处理量', '产量', '效率', '每小时'] },
+      { id: 'peel-result', severity: 'high', question: '去皮效果、残留和食材损耗是否明确', terms: ['去皮率', '残留', '破损', '均匀', '削皮'] },
+      { id: 'food-fit', severity: 'medium', question: '适配食材和规格范围是否清楚', terms: ['土豆', '马铃薯', '地瓜', '生姜', '食材', '规格'] },
+      { id: 'clean-safety', severity: 'medium', question: '清洗维护和操作防护是否明确', terms: ['清洗', '可拆', '防护', '急停', '过载', '安全', '保护'] },
+    ],
+  },
+  {
+    id: 'food-prep', label: '商用食品加工设备', match: ['切菜机', '去皮机', '绞肉机', '切肉机', '切片机', '锯骨机', '和面机', '搅拌机', '压面机', '馒头机', '包子机'], anchors: ['切菜机', '去皮机', '绞肉机', '切肉机', '切片机', '锯骨机', '和面机', '搅拌机', '压面机', '馒头机', '包子机'],
+    requiredGroups: [['kg/h', '公斤/小时', '个/小时', '台/小时', '产量', '效率'], ['W', 'kW', '功率'], ['220V', '380V'], ['尺寸', 'mm'], ['刀', '模具', '规格']],
+    concerns: [
+      { id: 'throughput', severity: 'high', question: '每小时处理量是否匹配经营规模', terms: ['kg/h', '公斤/小时', '个/小时', '台/小时', '产量', '效率', '每小时'] },
+      { id: 'result', severity: 'high', question: '成品规格和加工效果是否明确', terms: ['粗细', '厚薄', '规格', '成型', '均匀'] },
+      { id: 'clean', severity: 'medium', question: '拆洗与食品接触材质是否清楚', terms: ['可拆', '清洗', '304', '不锈钢', '食品接触'] },
+      { id: 'safety', severity: 'medium', question: '操作防护与过载保护是否明确', terms: ['防护', '急停', '过载', '安全', '保护'] },
+    ],
+  },
+  {
+    id: 'storage', label: '商用不锈钢储物与操作设备', match: ['工作台', '操作台', '置物架', '货架', '米面架', '平板车', '推车'], anchors: ['工作台', '操作台', '置物架', '货架', '米面架', '平板车', '推车'],
+    requiredGroups: [['尺寸', 'mm', 'cm'], ['层', '门', '容量'], ['承重', 'kg'], ['201', '304', '不锈钢']],
+    concerns: [
+      { id: 'size', severity: 'high', question: '外形尺寸是否适配后厨空间', terms: ['尺寸', 'mm', 'cm', '长', '宽', '高'] },
+      { id: 'load', severity: 'high', question: '层数与承重能力是否明确', terms: ['层', '承重', 'kg', '加厚'] },
+      { id: 'material', severity: 'medium', question: '不锈钢材质和厚度是否清楚', terms: ['201', '304', '不锈钢', '厚度', '加厚'] },
+      { id: 'clean', severity: 'medium', question: '清洁、移动和安装是否方便', terms: ['清洁', '脚轮', '移动', '安装', '可调'] },
+    ],
+  },
+  {
+    id: 'exhaust', label: '商用排烟净化设备', match: ['油烟机', '排烟', '风柜', '净化器'], anchors: ['油烟机', '排烟机', '风柜', '油烟净化器'],
+    requiredGroups: [['风量', 'm³/h'], ['W', 'kW', '功率'], ['尺寸', 'mm'], ['噪音', 'dB']],
+    concerns: [
+      { id: 'airflow', severity: 'high', question: '风量是否匹配灶头和后厨面积', terms: ['风量', 'm³/h', '灶头', '面积'] },
+      { id: 'effect', severity: 'high', question: '排烟净化效果是否明确', terms: ['净化率', '排烟', '油烟', '吸力'] },
+      { id: 'noise', severity: 'medium', question: '噪音和震动控制是否说明', terms: ['噪音', 'dB', '低噪', '减震'] },
+      { id: 'install', severity: 'medium', question: '尺寸、管径和安装条件是否清楚', terms: ['尺寸', '管径', '安装', '电压'] },
+    ],
+  },
+];
+
+export const GENERIC_PROFILE = {
+  id: 'commercial-equipment', label: '通用商用设备', match: [], anchors: [],
+  requiredGroups: [['容量', 'L', '升', 'kg', 'KG'], ['功率', 'W', 'kW'], ['220V', '380V', '电压'], ['尺寸', 'mm', 'cm', '型号']],
+  concerns: common,
+};
+
+export const COMPLIANCE_RULES = [
+  { id: 'contact', severity: 'critical', pattern: /(微信|加微|二维码|手机号|联系电话|QQ)/gi, message: '标题或可见文案包含站外联系/导流信息' },
+  { id: 'medical', severity: 'critical', pattern: /(治疗|治愈|根治|疗效保证|100%有效|永不复发)/gi, message: '包含高风险医疗或绝对功效宣称' },
+  { id: 'absolute', severity: 'high', pattern: /(国家级|世界级|行业第一|销量第一|顶级|最好|最佳|绝对|永久)/gi, message: '包含绝对化或无法直接证明的宣传表达' },
+  { id: 'promotion', severity: 'medium', pattern: /(今日特价|仅限今日|限时秒杀|买一送一|最后一天)/gi, message: '标题包含时效促销信息,可能快速失效' },
+];
+
+export const BENEFIT_TERMS = ['省时', '高效', '稳定', '安全', '省心', '方便', '易清洁', '节能', '省电', '耐用', '防干烧', '降噪', '保温', '快速', '均匀', '省人工', '提效', '出餐快', '控温', '一键', '易操作', '不粘', '卫生', '细腻', '防糊', '免过滤', '免沥水', '可调节', '大出水量', '防烫', '过热保护'];
+export const BENEFIT_SIGNAL_GROUPS = [
+  { id: 'efficiency', label: '效率/省人工', terms: ['全自动', '双面加热', '快速', '高效', '省时', '省人工', '提效', '出餐快', '连续'] },
+  { id: 'safety', label: '安全/稳定', terms: ['防干烧', '过热保护', '安全', '防烫', '断电', '稳定', '泄压'] },
+  { id: 'cost', label: '节能/成本', terms: ['节能', '省电', '低耗', '耗材少', '性价比'] },
+  { id: 'cleaning', label: '清洁/耐用', terms: ['304不锈钢', '不锈钢', '易清洁', '可拆', '防糊', '不粘', '免过滤', '免沥水', '耐用'] },
+  { id: 'operation', label: '操作便利', terms: ['智能', '数显', '触控', '一键', '定时', '可调', '可调节', '操作简单', '易操作'] },
+  { id: 'result', label: '产能/效果', terms: ['大容量', '大出水量', '细腻', '均匀', '保温', '控温', '降噪', '隔音', '浆渣分离', '热风循环'] },
+];
+export const SCENE_TERMS = ['商用', '饭店', '餐厅', '食堂', '学校', '工厂', '酒店', '后厨', '奶茶店', '早餐店', '办公室', '茶室', '便利店'];
+export const SELECTION_TERMS = ['智能', '全自动', '半自动', '不锈钢', '304', '201', '大容量', '双面加热', '过滤', '反渗透', '超滤', '保温', '防干烧', '数显', '触控', '机械式', '风冷', '直冷', '双温', '铜管', '隔音', '降噪', '浆渣分离', '磨煮一体', '可调节', '定时', '热风循环', '紫外线', '臭氧'];
+
+export const REVIEW_SIGNAL_TOPICS = [
+  { id: 'review-reliability', question: '连续工作稳定性和故障保障是否说明', evidenceTerms: ['发烫', '过热', '停机', '故障', '卡机', '死机', '漏水', '漏电', '坏了', '不工作'], responseTerms: ['稳定', '耐用', '连续工作', '过热保护', '防干烧', '自动断电', '质保', '保修', '上门维修'] },
+  { id: 'review-noise', question: '运行噪音是否会影响经营环境', evidenceTerms: ['噪音', '声音大', '太响', '吵', '震动'], responseTerms: ['低噪', '降噪', '隔音', '静音', 'dB', '分贝', '减震'] },
+  { id: 'review-cleaning', question: '使用后的清洁维护是否方便', evidenceTerms: ['难清洗', '清洗', '清洁', '残留', '油污', '水垢', '难拆'], responseTerms: ['易清洁', '清洗', '可拆', '可拆洗', '排水', '排污', '免拆洗', '维护'] },
+  { id: 'review-service', question: '安装和售后处理是否及时可靠', evidenceTerms: ['售后', '客服', '安装', '维修', '处理慢', '响应慢', '不上门', '退换'], responseTerms: ['免费安装', '包安装', '上门安装', '上门维修', '全国联保', '质保', '保修', '售后'] },
+  { id: 'review-performance', question: '实际加工或使用效果是否达到预期', evidenceTerms: ['效果', '冰渣', '不均匀', '不干净', '不彻底', '达不到', '效果差'], responseTerms: ['细腻', '均匀', '净化率', '温度', '控温', '产量', '制冰量', '清洗率'] },
+  { id: 'review-efficiency', question: '实际工作效率是否满足高峰需求', evidenceTerms: ['速度', '太慢', '效率', '出单', '等待', '产量', '耽误'], responseTerms: ['kg/h', '公斤/小时', 'L/h', '升/小时', '件/小时', '筐/小时', '产量', '效率', '快速', '分钟', '连续', '出餐'] },
+  { id: 'review-operation', question: '操作学习和日常使用是否简单', evidenceTerms: ['操作', '上手', '难用', '不会用', '说明书', '按键', '复杂'], responseTerms: ['一键', '全自动', '操作简单', '易操作', '数显', '触控', '旋钮', '定时', '说明书'] },
+  { id: 'review-value', question: '价格与后续使用成本是否可接受', evidenceTerms: ['贵', '价格', '不值', '性价比', '耗电', '耗材', '滤芯', '成本'], responseTerms: ['节能', '省电', '低耗', '能效', '耗水', '耗电', '免耗材', '滤芯寿命', '成本', '质保'] },
+];

+ 458 - 0
src/modules/listing-ai/scoring/jd-voc-lab/rubric-engine.js

@@ -0,0 +1,458 @@
+import {
+  BENEFIT_SIGNAL_GROUPS, BENEFIT_TERMS, CATEGORY_PROFILES, COMPLIANCE_RULES, DIMENSIONS, GENERIC_PROFILE,
+  REVIEW_SIGNAL_TOPICS, RUBRIC_VERSION, SCENE_TERMS, SELECTION_TERMS, SEVERITY_WEIGHT,
+} from './rubric-config.js';
+
+const text = (value) => String(value ?? '').trim();
+const unique = (values) => [...new Set(values.map(text).filter(Boolean))];
+const uniqueFold = (values) => {
+  const seen = new Set();
+  return values.map(text).filter(Boolean).filter((value) => {
+    const key = value.toLocaleLowerCase();
+    if (seen.has(key)) return false;
+    seen.add(key);
+    return true;
+  });
+};
+const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value));
+const round1 = (value) => Math.round(value * 10) / 10;
+const contains = (value, term) => text(value).toLocaleLowerCase().includes(text(term).toLocaleLowerCase());
+const escapeRegExp = (value) => text(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+const containsTerm = (value, term) => {
+  const target = text(term);
+  if (!target) return false;
+  if (/^[A-Za-z]$/.test(target)) return new RegExp(`\\d+(?:\\.\\d+)?\\s*${escapeRegExp(target)}(?:\\b|(?=[^A-Za-z]))`, 'i').test(text(value));
+  if (/^[\\u4e00-\\u9fff]$/.test(target)) return text(value).includes(target);
+  return target.length > 1 && contains(value, target);
+};
+const hasAny = (value, terms) => terms.some((term) => containsTerm(value, term));
+
+function stripHtml(value) {
+  return text(value).replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;|&#160;/gi, ' ').replace(/&[a-z]+;/gi, ' ').replace(/\s+/g, ' ').trim();
+}
+
+function normalizeReviewText(value) {
+  return stripHtml(value).replace(/&ldquo;|&rdquo;/gi, '').replace(/(.)\1{4,}/g, '$1$1').trim();
+}
+
+function meaningfulReview(value) {
+  const content = normalizeReviewText(value);
+  if (content.length < 4) return false;
+  if (/(?:此用户)?未(?:及时)?填写评价内容|系统默认好评|默认评价/.test(content)) return false;
+  if (/^(?:好评|很好|不错|满意|挺好|可以|推荐|好用|质量好|非常好)[!!。.,,\s]*$/.test(content)) return false;
+  return true;
+}
+
+function attrValues(item) {
+  return unique([item?.value, ...(Array.isArray(item?.values) ? item.values : [])]);
+}
+
+function meaningfulFeature(item) {
+  const key = text(item?.key).toLocaleLowerCase();
+  const value = text(item?.value);
+  if (!value || /^(?:0|1|2|3|true|false|null|undefined)$/i.test(value)) return false;
+  if (/^\d+(?:\|\d+)*$/.test(value)) return false;
+  if (/^(?:id|is|has|use|flag|type|status|time|date|source|salemodel|fylx|wlzyc|xnzysp|popsfkc|zyyfmb)/i.test(key)) return false;
+  return /[\u4e00-\u9fff]/.test(key) || ['model', 'namewithoutbrand', 'sellpoint', 'shorttitle'].includes(key);
+}
+
+function titleTokens(value) {
+  return unique(text(value).match(/[A-Za-z]+(?:-[A-Za-z0-9]+)*|\d+(?:\.\d+)?(?:L\/H|KG|CM|MM|KW|W|V|L|升|人)?|[\u4e00-\u9fff]{2,8}/gi) || []);
+}
+
+function numericFacts(value) {
+  return unique(text(value).match(/\d+(?:\.\d+)?\s*(?:KG\/H|公斤\/小时|个\/小时|台\/小时|L\/H|升\/小时|KG|公斤|CM|厘米|MM|毫米|KW|W|V|伏|L|升|层|人用|人|格|杯|门)/gi) || []);
+}
+
+function profileFor(source) {
+  const value = `${source.title} ${source.categoryPath.join(' ')}`;
+  return CATEGORY_PROFILES.find((profile) => hasAny(value, profile.match)) || GENERIC_PROFILE;
+}
+
+function stringList(value) {
+  if (Array.isArray(value)) return unique(value.flatMap((item) => typeof item === 'object' && item !== null ? Object.values(item) : item));
+  if (value && typeof value === 'object') return unique(Object.entries(value).flatMap(([key, item]) => [key, item]));
+  return value === null || value === undefined ? [] : [text(value)].filter(Boolean);
+}
+
+function normalizeCompetitor(item) {
+  return {
+    productId: text(item?.productId || item?.id), title: text(item?.title || item?.name), brand: text(item?.brand),
+    category: text(item?.category), price: Number(item?.price) || null,
+    sellingPoints: stringList(item?.sellingPoints), specifications: stringList(item?.keySpecifications),
+  };
+}
+
+export function normalizeListing(input = {}) {
+  const source = input.source || input;
+  const domestic = input.domestic || {};
+  const skus = Array.isArray(source.skus) ? source.skus : [];
+  const rawAttributes = [
+    ...(Array.isArray(source.attributes) ? source.attributes : []),
+    ...skus.flatMap((sku) => [...(Array.isArray(sku.attributes) ? sku.attributes : []), ...(Array.isArray(sku.saleAttributes) ? sku.saleAttributes : [])]),
+  ];
+  const attributeMap = new Map();
+  for (const item of rawAttributes) {
+    const name = text(item?.name || item?.key || item?.id);
+    if (!name) continue;
+    attributeMap.set(name, { name, values: unique([...(attributeMap.get(name)?.values || []), ...attrValues(item)]) });
+  }
+  const attributes = [...attributeMap.values()];
+  const rawFeatures = [...(Array.isArray(source.features) ? source.features : []), ...skus.flatMap((sku) => Array.isArray(sku.features) ? sku.features : [])];
+  const features = rawFeatures.map((item) => ({ name: text(item?.key), value: text(item?.value), meaningful: meaningfulFeature(item) }));
+  const meaningfulFeatures = features.filter((item) => item.meaningful);
+  const skuNames = unique(skus.map((sku) => sku?.name));
+  const sellingPoints = unique([
+    ...(Array.isArray(source.marketing?.sellingPoints) ? source.marketing.sellingPoints.map((item) => item?.value || item) : []),
+    source.marketing?.adword,
+  ]);
+  const categoryPath = unique([
+    ...(source.categoryContext?.pathNames || []), ...(source.categoryContext?.names || []), ...(source.categoryIds || []),
+    domestic.category1, domestic.category2, domestic.category3,
+  ]);
+  const desktop = stripHtml(source.descriptions?.desktopHtml);
+  const mobile = stripHtml(source.descriptions?.mobileHtml);
+  const detailText = unique([desktop, mobile]).join(' ');
+  const imageCount = unique([
+    ...(source.images || []).map((item) => item?.url),
+    ...(source.imageAssets?.defaultImages || []).map((item) => item?.url),
+  ]).length;
+  const imageObserved = imageCount > 0 || source.detailStatus === 'available' || source.imageCount !== undefined;
+  const normalizeEvidence = (items, provenance) => {
+    const seen = new Set();
+    return (Array.isArray(items) ? items : []).map((item) => ({
+      id: text(item?.id), rating: Number(item?.rating || 0), content: normalizeReviewText(item?.content || item?.text || item),
+      date: item?.date || item?.collectedAt || null, provenance,
+    })).filter((item) => {
+      if (!meaningfulReview(item.content)) return false;
+      const key = item.content.replace(/[\s,。!?、,.!?]/g, '').toLocaleLowerCase();
+      if (seen.has(key)) return false;
+      seen.add(key);
+      return true;
+    });
+  };
+  const reviews = normalizeEvidence(input.reviews, 'product-review');
+  const categoryVocEvidence = normalizeEvidence(source.vocEvidence, 'category-low-rating-review');
+  const normalized = {
+    raw: source, productId: text(source.productId || domestic.productId), title: text(source.title || domestic.title),
+    brand: text(source.brand?.name || source.titleBrandName || domestic.brand), categoryPath,
+    category: text(source.categoryContext?.displayName || categoryPath.at(-1)),
+    attributes, features, sellingPoints, detailText, descriptionStructure: source.descriptionStructure || null,
+    images: source.images || [], imageCount, imageObserved, skus, skuNames, skuText: skuNames.join(' '), dimensions: source.dimensions || {},
+    logistics: source.logistics || {}, afterService: source.afterService || {}, reviews, categoryVocEvidence,
+    competitors: (input.competitors || source.competitors || []).map(normalizeCompetitor).filter((item) => item.title),
+    competitorBasis: input.competitorBasis || source.competitorBasis || 'none',
+    sourceVia: input.sourceVia || 'unknown', performance: domestic.summary || input.performance || null,
+  };
+  normalized.profile = profileFor(normalized);
+  const accessoryTitle = /(?:污碟台|水槽|配套用|配件|底座|支架|滤芯|耗材|清洁机|清洗机|适配)/.test(normalized.title)
+    && !/(?:洗碗机|电炸炉|电饼铛|制冰机|开水器|蒸饭柜|烤箱|冰箱|电磁炉)\s*(?:整机|主机)/.test(normalized.title);
+  const categoryTitleConflict = normalized.profile.id === 'induction' && /(?:燃气|天然气|液化气)/.test(normalized.title);
+  if (accessoryTitle || categoryTitleConflict) {
+    normalized.profileWarning = accessoryTitle ? '标题疑似配套品/耗材或非标准设备,未强套设备VOC Profile' : '标题与后台类目存在燃气/电磁冲突,未强套电磁VOC Profile';
+    normalized.profile = GENERIC_PROFILE;
+  }
+  if (!normalized.category || /^\d+$/.test(normalized.category)) normalized.category = normalized.profile.anchors[0] || '类目未验证';
+  if (!normalized.categoryPath.some((item) => item === normalized.category)) normalized.categoryPath.push(normalized.category);
+  normalized.meaningfulFeatures = meaningfulFeatures;
+  normalized.attributeText = unique(attributes.flatMap((item) => [item.name, ...item.values]).concat(meaningfulFeatures.flatMap((item) => [item.name, item.value]))).join(' ');
+  normalized.contentText = unique([normalized.title, ...sellingPoints, normalized.attributeText, normalized.skuText, detailText]).join(' ');
+  return normalized;
+}
+
+function reviewSignals(evidence, concern) {
+  return evidence.filter((review) => hasAny(review.content, concern.evidenceTerms || concern.terms));
+}
+
+export function buildVocConcerns(input, explicit = null) {
+  const listing = input.profile ? input : normalizeListing(input);
+  if (Array.isArray(explicit)) return explicit.map(normalizeConcern).filter((item) => item.question);
+  const baseline = listing.profile.id === GENERIC_PROFILE.id ? [] : listing.profile.concerns;
+  const profileConcerns = baseline.map((concern) => {
+    const productEvidence = reviewSignals(listing.reviews, concern);
+    const categoryEvidence = listing.profileWarning ? [] : reviewSignals(listing.categoryVocEvidence, concern);
+    const negative = productEvidence.filter((item) => item.rating > 0 && item.rating <= 3).length;
+    return {
+      ...concern,
+      terms: uniqueFold(concern.terms || []),
+      severity: negative >= 2 || productEvidence.length >= 5 ? 'high' : concern.severity,
+      evidenceCount: productEvidence.length + categoryEvidence.length,
+      evidence: [...productEvidence, ...categoryEvidence].slice(0, 3).map((item) => item.content),
+      evidenceBreakdown: { productReview: productEvidence.length, categoryVoc: categoryEvidence.length },
+      source: productEvidence.length ? 'product-review' : categoryEvidence.length ? 'category-voc' : 'category-profile',
+    };
+  });
+  const negativeTerms = /(发烫|过热|自动停机|故障|卡机|死机|坏了|售后差|处理慢|响应慢|难用|不值|太贵|噪音大|声音大|不干净|不彻底|效果差|达不到|漏水|漏油|无法|不能使用|退货)/;
+  const existingTerms = new Set(profileConcerns.flatMap((item) => [...item.terms, ...(item.evidenceTerms || [])].map((term) => term.toLocaleLowerCase())));
+  const derived = REVIEW_SIGNAL_TOPICS.map((topic) => {
+    const productEvidence = reviewSignals(listing.reviews, topic);
+    const categoryEvidence = listing.profileWarning ? [] : reviewSignals(listing.categoryVocEvidence, topic);
+    const negativeEvidence = productEvidence.filter((item) => item.rating > 0 && item.rating <= 3 || negativeTerms.test(item.content));
+    const overlap = [...topic.responseTerms, ...topic.evidenceTerms].some((term) => existingTerms.has(term.toLocaleLowerCase()));
+    return { ...topic, productEvidence, categoryEvidence, negativeEvidence, overlap };
+  }).filter((item) => !item.overlap && (item.negativeEvidence.length >= 1 || item.productEvidence.length >= 2 || item.categoryEvidence.length >= 1))
+    .sort((left, right) => Number(Boolean(right.productEvidence.length)) - Number(Boolean(left.productEvidence.length)) || right.negativeEvidence.length - left.negativeEvidence.length || right.categoryEvidence.length - left.categoryEvidence.length)
+    .slice(0, 2).map((item) => ({
+    id: item.id, question: item.question, terms: uniqueFold(item.responseTerms), evidenceTerms: uniqueFold(item.evidenceTerms),
+    severity: item.negativeEvidence.length ? 'high' : 'medium',
+    evidenceCount: item.productEvidence.length + item.categoryEvidence.length,
+    evidence: [...item.productEvidence, ...item.categoryEvidence].slice(0, 3).map((review) => review.content),
+    evidenceBreakdown: { productReview: item.productEvidence.length, categoryVoc: item.categoryEvidence.length },
+    source: item.productEvidence.length ? 'product-review-derived' : 'category-voc-derived',
+  }));
+  return [...profileConcerns, ...derived];
+}
+
+function normalizeConcern(item) {
+  const severity = ['high', 'medium', 'low'].includes(item?.severity) ? item.severity : 'medium';
+  return { id: text(item?.id), question: text(item?.question), terms: uniqueFold(item?.terms || []), evidenceTerms: uniqueFold(item?.evidenceTerms || item?.terms || []), severity, evidenceCount: Number(item?.evidenceCount || 0), evidence: item?.evidence || [], evidenceBreakdown: item?.evidenceBreakdown || { productReview: 0, categoryVoc: 0 }, source: item?.source || 'manual' };
+}
+
+export function parseVocInput(value) {
+  return text(value).split(/\r?\n/).map((line, index) => {
+    const matched = line.trim().match(/^\s*(?:\[(高|中|低)\]|(高|中|低)[::])?\s*([^||]+?)(?:\s*[||]\s*(.*))?$/);
+    if (!matched) return null;
+    const severity = { 高: 'high', 中: 'medium', 低: 'low' }[matched[1] || matched[2]] || 'medium';
+    return normalizeConcern({ id: `manual-${index + 1}`, severity, question: matched[3], terms: text(matched[4]).split(/[,,、]/), source: 'manual' });
+  }).filter(Boolean).filter((item) => item.question);
+}
+
+export function formatVocInput(concerns) {
+  const label = { high: '高', medium: '中', low: '低' };
+  return concerns.map((item) => `[${label[item.severity] || '中'}] ${item.question}${item.terms?.length ? ` | ${item.terms.join(',')}` : ''}`).join('\n');
+}
+
+function criterion(input) {
+  const available = input.available !== false;
+  const score = available ? round1(clamp(Number(input.score || 0), 0, input.max)) : null;
+  const status = !available ? 'unverified' : score >= input.max * .9 ? 'pass' : score >= input.max * .45 ? 'partial' : 'fail';
+  return { ...input, score, available, status, priority: input.priority || (status === 'fail' ? 'P0' : status === 'partial' ? 'P1' : 'P2') };
+}
+
+function scoreSearch(listing) {
+  const { title, brand, profile } = listing;
+  const anchors = unique([...profile.anchors, ...listing.category.split(/[\\/、|]/), listing.category])
+    .filter((item) => item.length >= 2 && item !== '类目未验证' && !/^\d+$/.test(item));
+  const anchor = anchors.find((item) => item.length >= 2 && contains(title, item));
+  const anchorPosition = anchor ? title.indexOf(anchor) : -1;
+  const facts = numericFacts(title);
+  const selectionSignals = unique([
+    ...facts,
+    ...profile.requiredGroups.flat().filter((term) => containsTerm(title, term)),
+    ...BENEFIT_TERMS.filter((term) => containsTerm(title, term)),
+    ...SELECTION_TERMS.filter((term) => containsTerm(title, term)),
+  ]);
+  const scenes = SCENE_TERMS.filter((item) => contains(title, item));
+  const repeated = titleTokens(title).filter((token, index, items) => token.length >= 3 && items.indexOf(token) !== index);
+  const repeatedAnchors = profile.anchors.filter((term) => title.split(term).length - 1 > 1);
+  const lengthScore = title.length >= 24 && title.length <= 100 ? 3 : title.length >= 15 && title.length <= 130 ? 2 : title.length > 0 ? 1 : 0;
+  return [
+    criterion({ id: 'search.subject', label: '商品主体明确且靠前', max: 8, score: !anchor ? 0 : anchorPosition <= 30 ? 8 : 4, available: anchors.length > 0, evidence: anchor ? `「${anchor}」位于第 ${anchorPosition + 1} 字符` : anchors.length ? '标题未命中已识别的商品主体词' : '源数据不足以确定商品主体词', action: anchors.length ? '将准确的核心商品词放在标题前部' : '补齐有效类目后再判断标题主体' }),
+    criterion({ id: 'search.brand', label: '品牌身份一致', max: 4, score: brand && contains(title, brand) ? 4 : brand ? 1 : 0, available: Boolean(brand), evidence: brand || '品牌字段未验证', action: '品牌字段与标题首段保持一致' }),
+    criterion({ id: 'search.selection', label: '关键选型信息前置', max: 4, score: selectionSignals.length >= 4 ? 4 : selectionSignals.length >= 2 ? 3 : selectionSignals.length === 1 ? 2 : 0, evidence: selectionSignals.join('、') || '未发现规格、功能、材质或用户收益信号', action: '补充最影响选型的规格、功能或明确收益' }),
+    criterion({ id: 'search.scene', label: '经营场景与对象', max: 4, score: scenes.length >= 2 ? 4 : scenes.length === 1 ? 2 : 0, evidence: scenes.join('、') || '未发现明确经营场景', action: '说明适用门店、机构或人数规模' }),
+    criterion({ id: 'search.readability', label: '长度、去重与可读性', max: 5, score: lengthScore + (repeated.length || repeatedAnchors.length ? 0 : 2), evidence: `${title.length}字符${repeated.length || repeatedAnchors.length ? `;重复:${unique([...repeatedAnchors, ...repeated]).join('、')}` : ';未发现明显重复片段'}`, action: '删除同义堆砌,保留主体、规格与核心差异' }),
+  ];
+}
+
+function concernLocationScore(listing, concern) {
+  const sources = [
+    { name: '标题', value: listing.title, weight: 1 },
+    { name: '卖点', value: listing.sellingPoints.join(' '), weight: .95 },
+    { name: 'SKU', value: listing.skuText, weight: .9 },
+    { name: '属性', value: listing.attributeText, weight: .85 },
+    { name: '详情', value: listing.detailText, weight: .75 },
+  ];
+  const matchedTerms = concern.terms.filter((term) => hasAny(listing.contentText, [term]));
+  const locations = sources.filter((source) => hasAny(source.value, concern.terms));
+  const termStrength = matchedTerms.length >= 2 ? 1 : matchedTerms.length === 1 ? .7 : 0;
+  const locationFactor = locations.length ? Math.max(...locations.map((item) => item.weight)) : 0;
+  return { ratio: clamp(termStrength * .85 + locationFactor * .15, 0, 1), matchedTerms, locations: locations.map((item) => item.name) };
+}
+
+function scoreVoc(listing, concerns) {
+  if (!concerns.length) return [criterion({ id: 'voc.baseline', label: 'VOC基准', max: 25, score: 0, available: false, evidence: '没有VOC问题清单', action: '加载品类或商品评论VOC' })];
+  const concernMax = 25;
+  const weights = concerns.map((item) => SEVERITY_WEIGHT[item.severity] || 2);
+  const totalWeight = weights.reduce((sum, value) => sum + value, 0);
+  const maxima = weights.map((weight) => round1(concernMax * weight / totalWeight));
+  maxima[maxima.length - 1] = round1(maxima.at(-1) + concernMax - maxima.reduce((sum, value) => sum + value, 0));
+  const rows = concerns.map((concern, index) => {
+    const max = maxima[index];
+    const match = concernLocationScore(listing, concern);
+    const scope = concern.source === 'product-review-derived' ? 'review' : concern.source === 'category-voc-derived' ? 'category-review' : listing.profile.id;
+    return criterion({
+      id: `voc.${scope}.${concern.id || index}`, label: concern.question, max, score: max * match.ratio,
+      evidence: match.matchedTerms.length ? `${match.locations.join('/')}命中:${match.matchedTerms.join('、')}` : `未回应:${concern.terms.join('、')}`,
+      action: match.ratio < .45 ? `在标题/卖点中用可核验事实回应「${concern.question}」` : match.ratio < .9 ? '补足缺失的决策词或量化证据' : null,
+      priority: concern.severity === 'high' && match.ratio < .45 ? 'P0' : match.ratio < .9 ? 'P1' : 'P2',
+    });
+  });
+  return rows;
+}
+
+function scoreSelling(listing) {
+  const textValue = unique([...listing.sellingPoints, listing.attributeText, listing.detailText]).join(' ');
+  const observed = listing.sellingPoints.length > 0 || listing.detailText.length > 0 || listing.raw.detailStatus === 'available';
+  const facts = numericFacts(`${listing.title} ${textValue}`);
+  const benefitGroups = BENEFIT_SIGNAL_GROUPS.map((group) => ({ ...group, matched: group.terms.filter((term) => containsTerm(textValue, term)) })).filter((group) => group.matched.length);
+  const pointTexts = listing.sellingPoints.filter(Boolean);
+  const distinct = unique(pointTexts.map((item) => item.replace(/\d+/g, '#').slice(0, 30))).length;
+  return [
+    criterion({ id: 'selling.presence', label: '独立卖点字段', max: 4, score: pointTexts.length >= 3 ? 4 : pointTexts.length ? 2 : 0, available: observed, evidence: observed ? `${pointTexts.length}条独立卖点` : '卖点/详情字段未获取', action: '提供3–5条独立、可验证的购买理由' }),
+    criterion({ id: 'selling.specificity', label: '卖点具体可验证', max: 4, score: facts.length >= 3 ? 4 : facts.length ? 2 : 0, available: observed, evidence: facts.join('、') || '卖点中缺少量化事实', action: '用容量、效率、温度、功率或人数替代空泛形容词' }),
+    criterion({ id: 'selling.benefit', label: '属性转用户收益', max: 4, score: benefitGroups.length >= 3 ? 4 : benefitGroups.length === 2 ? 3 : benefitGroups.length === 1 ? 2 : 0, available: observed, evidence: benefitGroups.map((group) => `${group.label}(${group.matched.slice(0, 3).join('/')})`).join('、') || '未识别用户收益表达', action: '把参数解释成效率、安全、成本、清洁、操作或产能收益' }),
+    criterion({ id: 'selling.nonrepeat', label: '卖点不重复', max: 3, score: pointTexts.length && distinct === pointTexts.length ? 3 : pointTexts.length ? 1 : 0, available: observed, evidence: pointTexts.length ? `${distinct}/${pointTexts.length}条表达相互独立` : '未获取卖点字段', action: '每条卖点只解决一个不同的购买问题' }),
+  ];
+}
+
+function groupCoverage(value, groups) {
+  const matched = groups.filter((group) => hasAny(value, group));
+  return { matched, rate: groups.length ? matched.length / groups.length : 0 };
+}
+
+function scoreFacts(listing) {
+  const profile = listing.profile;
+  const specs = groupCoverage(listing.contentText, profile.requiredGroups);
+  const profileObserved = profile.id !== GENERIC_PROFILE.id;
+  const usableAttributes = listing.attributes.filter((attribute) => attribute.name && attribute.values.some(meaningfulAttributeValue));
+  const attributeScore = usableAttributes.length >= 8 ? 7 : usableAttributes.length >= 6 ? 6 : usableAttributes.length >= 4 ? 5 : usableAttributes.length >= 2 ? 3 : usableAttributes.length === 1 ? 1 : 0;
+  const readableDetail = listing.detailText.length >= 50 || (listing.descriptionStructure?.headingCount || 0) > 0 || (listing.descriptionStructure?.faqCandidateCount || 0) > 0;
+  const readableSignals = Number(listing.detailText.length >= 500) + Number((listing.descriptionStructure?.headingCount || 0) >= 3) + Number((listing.descriptionStructure?.faqCandidateCount || 0) >= 3);
+  return [
+    criterion({ id: 'facts.specs', label: '关键选型参数完整', max: 10, score: specs.rate * 10, available: profileObserved, evidence: profileObserved ? `${specs.matched.length}/${profile.requiredGroups.length}组:${specs.matched.map((group) => group[0]).join('、') || '无'}` : '未识别细分品类,无法选择对应参数清单', action: profileObserved && specs.rate < .75 ? '补充该品类的容量、功率、电压、尺寸/产能等关键参数' : '补齐或校正类目后再判断关键参数' }),
+    criterion({ id: 'facts.attributes', label: '结构化属性可用于选型', max: 7, score: attributeScore, available: Array.isArray(listing.raw.attributes), evidence: `${usableAttributes.length}项有意义的结构化属性:${usableAttributes.slice(0, 6).map((item) => item.name).join('、') || '无'}`, action: usableAttributes.length < 4 ? '补齐型号、材质、尺寸、功率、容量或产能等结构化属性' : null }),
+    criterion({ id: 'facts.detail-content', label: '详情文本可读结构', max: 3, score: readableSignals >= 2 ? 3 : readableSignals === 1 ? 1.5 : 0, available: readableDetail, evidence: readableDetail ? `可提取文本${listing.detailText.length}字符;标题块${listing.descriptionStructure?.headingCount || 0};FAQ候选${listing.descriptionStructure?.faqCandidateCount || 0}` : '详情主要为图片且当前不评图片内容,无法判断详情文案', action: '补充可提取的用途、步骤、安装条件、FAQ与售后文本' }),
+  ];
+}
+
+function informativeTokens(value) {
+  const stop = new Set(['德玛仕', 'DEMASHI', '商用', '家用', '大容量', '全自动', '多功能', '大型', '小型', '机器', '设备', '热销', '第一', '免费', '包安装', '旗舰', '推荐', '爆款', '企业采购']);
+  return titleTokens(value).filter((item) => item.length >= 2 && ![...stop].some((term) => item.includes(term)) && !/^\d+$/.test(item));
+}
+
+function meaningfulAttributeValue(value) {
+  const normalized = text(value);
+  if (normalized.length < 2 || normalized.length > 24) return false;
+  if (/^(其他|无|未知|通用|默认|标准|标准款|机械式|电子式|国产|中国大陆|是|否|支持|不支持)$/.test(normalized)) return false;
+  return true;
+}
+
+function collapseSignalFamilies(signals) {
+  const seen = new Set();
+  const output = [];
+  for (const signal of signals) {
+    const numeric = text(signal).match(/\d+(?:\.\d+)?\s*(KG\/H|公斤\/小时|个\/小时|台\/小时|L\/H|升\/小时|KG|公斤|CM|厘米|MM|毫米|KW|W|V|伏|L|升|层|人用|人|格|杯|门)/i);
+    const family = numeric ? `unit:${numeric[1].toLocaleUpperCase()}` : `text:${text(signal).toLocaleLowerCase()}`;
+    if (seen.has(family)) continue;
+    seen.add(family);
+    output.push(signal);
+  }
+  return output;
+}
+
+function scoreCompetitive(listing, concerns) {
+  const competitors = listing.competitors;
+  if (!competitors.length) return [
+    criterion({ id: 'competitive.benchmark', label: '竞品基准可用', max: 2, score: 0, available: false, evidence: '当前商品没有关联竞品快照', action: '建立本品与同类京东竞品的关系' }),
+    criterion({ id: 'competitive.difference', label: '可识别差异点', max: 4, score: 0, available: false, evidence: '缺少竞品标题/卖点', action: '加载至少3个同品类竞品' }),
+    criterion({ id: 'competitive.voc-gap', label: 'VOC缺口优势', max: 4, score: 0, available: false, evidence: '缺少竞品内容,无法比较VOC响应', action: '对比竞品对高优先级VOC问题的覆盖' }),
+  ];
+  const richCompetitors = competitors.filter((item) => item.sellingPoints.length || item.specifications.length);
+  const comparableRichFields = richCompetitors.length >= Math.ceil(competitors.length / 2);
+  const comparisonSurface = comparableRichFields ? '标题+卖点/规格' : '双方标题';
+  const ownText = comparableRichFields ? `${listing.title} ${listing.sellingPoints.join(' ')} ${listing.attributeText}` : listing.title;
+  const ownTokens = collapseSignalFamilies(unique([
+    ...numericFacts(ownText),
+    ...BENEFIT_TERMS.filter((term) => containsTerm(ownText, term)),
+  ]).filter((item) => informativeTokens(item).length || numericFacts(item).length));
+  const competitorText = competitors.map((item) => `${item.title} ${item.sellingPoints.join(' ')} ${item.specifications.join(' ')}`).join(' ');
+  const uniqueTokens = ownTokens.filter((item) => !contains(competitorText, item));
+  if (comparableRichFields) {
+    for (const attribute of listing.attributes) {
+      const values = attribute.values.filter((value) => value !== attribute.name).filter(meaningfulAttributeValue);
+      const sameFieldComparable = containsTerm(competitorText, attribute.name);
+      if (sameFieldComparable && values.length && !values.some((value) => containsTerm(competitorText, value))) uniqueTokens.push(`${attribute.name}:${values.join('/')}`);
+    }
+  }
+  const ownConcernRate = concerns.length ? concerns.filter((item) => hasAny(ownText, item.terms)).length / concerns.length : 0;
+  const competitorRates = competitors.map((competitor) => {
+    const value = comparableRichFields ? `${competitor.title} ${competitor.sellingPoints.join(' ')} ${competitor.specifications.join(' ')}` : competitor.title;
+    return concerns.length ? concerns.filter((item) => hasAny(value, item.terms)).length / concerns.length : 0;
+  });
+  const median = competitorRates.sort((a, b) => a - b)[Math.floor(competitorRates.length / 2)] || 0;
+  const advantage = ownConcernRate - median;
+  const inferred = listing.competitorBasis === 'category-inferred';
+  return [
+    criterion({ id: 'competitive.benchmark', label: '竞品基准可用', max: 2, score: inferred ? 1 : competitors.length >= 3 ? 2 : 1, evidence: `${competitors.length}个${inferred ? '按品类推断的同类候选' : '显式关联竞品'}`, action: inferred ? '建立明确本品-竞品关系后升级为正式竞品基准' : competitors.length < 3 ? '补至至少3个同类竞品' : null }),
+    criterion({ id: 'competitive.difference', label: '可识别差异点', max: 4, score: uniqueTokens.length >= 5 ? 4 : uniqueTokens.length >= 2 ? 2 : uniqueTokens.length === 1 ? 1 : 0, evidence: `${comparisonSurface}口径:${uniqueTokens.slice(0, 8).join('、') || '未识别独有的规格或利益信号'}${comparableRichFields ? ';结构化属性仅比较双方同名字段' : ';当前竞品快照没有卖点/规格,不拿本品额外字段占优'}`, action: '突出竞品没有说明且本品可证明的规格或利益' }),
+    criterion({ id: 'competitive.voc-gap', label: 'VOC缺口优势', max: 4, score: concerns.length ? advantage >= .25 ? 4 : advantage > 0 ? 2 : 0 : 0, available: concerns.length > 0, evidence: concerns.length ? `${comparisonSurface}口径:本品VOC覆盖${Math.round(ownConcernRate * 100)}%,竞品中位数${Math.round(median * 100)}%` : '没有可用VOC问题,无法进行同口径比较', action: concerns.length && advantage <= 0 ? '优先回应竞品普遍遗漏的高严重度顾虑' : null }),
+  ];
+}
+
+function scoreMedia(listing) {
+  const count = listing.imageCount;
+  return [criterion({
+    id: 'media.count', label: '图片数量(不评价内容)', max: 5,
+    score: count >= 5 ? 5 : count >= 3 ? 3 : count > 0 ? 1 : 0, available: listing.imageObserved,
+    evidence: listing.imageObserved ? `${count}张图片;未检查白底、构图、清晰度和图中文字` : '图片数量字段未验证,不能按0张扣分',
+    action: listing.imageObserved && count < 5 ? '补足图片数量;内容质量待后续版本单独评估' : null,
+  })];
+}
+
+export function complianceGate(input) {
+  const listing = input.profile ? input : normalizeListing(input);
+  const locations = [
+    { name: 'title', label: '标题', value: listing.title }, { name: 'selling', label: '卖点', value: listing.sellingPoints.join(' ') },
+    { name: 'attributes', label: '属性/特征', value: listing.attributeText }, { name: 'detail', label: '详情', value: listing.detailText },
+  ];
+  const findings = [];
+  for (const rule of COMPLIANCE_RULES) {
+    for (const location of locations) {
+      const pattern = new RegExp(rule.pattern.source, rule.pattern.flags);
+      const matches = unique(location.value.match(pattern) || []);
+      for (const term of matches) findings.push({ id: rule.id, severity: rule.severity, location: location.name, locationLabel: location.label, term, message: rule.message });
+    }
+  }
+  const status = findings.some((item) => item.severity === 'critical') ? 'BLOCK' : findings.length ? 'WARN' : 'PASS';
+  return { status, findings };
+}
+
+function priorityRank(value) { return value === 'P0' ? 0 : value === 'P1' ? 1 : 2; }
+
+export function scoreListing(input, explicitConcerns = null) {
+  const listing = input.profile ? input : normalizeListing(input);
+  const concerns = buildVocConcerns(listing, explicitConcerns);
+  const groups = {
+    search: scoreSearch(listing), voc: scoreVoc(listing, concerns), selling: scoreSelling(listing),
+    facts: scoreFacts(listing), competitive: scoreCompetitive(listing, concerns), media: scoreMedia(listing),
+  };
+  const dimensions = DIMENSIONS.map((definition) => {
+    const criteria = groups[definition.key] || [];
+    const verifiedMax = round1(criteria.reduce((sum, item) => sum + (item.available ? item.max : 0), 0));
+    const earned = round1(criteria.reduce((sum, item) => sum + (item.available ? item.score : 0), 0));
+    const normalizedScore = verifiedMax ? round1(earned / verifiedMax * definition.max) : null;
+    return { ...definition, score: normalizedScore, earned, verifiedMax, coverage: round1(verifiedMax / definition.max * 100), criteria };
+  });
+  const all = dimensions.flatMap((item) => item.criteria);
+  const verifiedMax = round1(all.reduce((sum, item) => sum + (item.available ? item.max : 0), 0));
+  const earned = round1(all.reduce((sum, item) => sum + (item.available ? item.score : 0), 0));
+  const observableScore = verifiedMax ? round1(earned / verifiedMax * 100) : null;
+  const coverage = round1(verifiedMax);
+  const compliance = complianceGate(listing);
+  const grade = compliance.status === 'BLOCK' ? 'F' : coverage < 60 ? '待补数据' : observableScore >= 90 && coverage >= 80 ? 'A' : observableScore >= 80 ? 'B' : observableScore >= 65 ? 'C' : 'D';
+  const productReviewCount = listing.reviews.length;
+  const categoryVocCount = listing.categoryVocEvidence.length;
+  const evidenceGrade = productReviewCount >= 10 ? 'A' : productReviewCount >= 3 ? 'B' : productReviewCount > 0 || categoryVocCount >= 2 ? 'C' : concerns.length ? 'D' : '—';
+  const confidence = coverage >= 85 && concerns.length && evidenceGrade !== 'D' && listing.competitors.length >= 3 && !['category-inferred', 'none'].includes(listing.competitorBasis) ? '高' : coverage >= 65 ? '中' : '低';
+  const actions = all.filter((item) => item.available && item.status !== 'pass' && item.action).sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || (a.score / a.max) - (b.score / b.max));
+  const dataGaps = all.filter((item) => !item.available).map((item) => ({ id: item.id, label: item.label, action: item.action, evidence: item.evidence }));
+  return {
+    rubricVersion: RUBRIC_VERSION, listing, concerns, observableScore, fixedEarnedScore: earned,
+    verifiedMax, coverage, grade, confidence, compliance, dimensions, actions, dataGaps,
+    evidenceGrade,
+    vocEvidenceSummary: { productReviewCount, categoryVocCount, profileBasedCount: concerns.filter((item) => item.source === 'category-profile').length, profileWarning: listing.profileWarning || null },
+  };
+}
+
+export { DIMENSIONS, RUBRIC_VERSION };

+ 167 - 0
src/modules/listing-ai/scoring/jd-voc-rule-engine.ts

@@ -0,0 +1,167 @@
+import { randomUUID } from 'node:crypto';
+import type {
+  JdVocDimensionKey,
+  JdVocEvidence,
+  JdVocRuleContext,
+  JdVocScoreResult,
+  JdVocVocEvidence,
+  ListingSourceSnapshot,
+} from '../domain.js';
+import { JD_VOC_RUBRIC_VERSION } from '../domain.js';
+import { reviewImageAssets } from '../image-review/image-review.service.js';
+import { canonicalHash } from './rule-engine.js';
+import { buildVocConcerns, normalizeListing, scoreListing as scoreCalibratedListing } from './jd-voc-lab/rubric-engine.js';
+
+type LabCriterion = {
+  id: string; label: string; max: number; score: number | null; available: boolean;
+  status: 'pass' | 'partial' | 'fail' | 'unverified'; priority: 'P0' | 'P1' | 'P2';
+  evidence?: string; action?: string | null;
+};
+type LabDimension = {
+  key: JdVocDimensionKey; score: number | null; max: number; earned: number;
+  verifiedMax: number; coverage: number; criteria: LabCriterion[];
+};
+type LabReport = {
+  observableScore: number | null; fixedEarnedScore: number; verifiedMax: number; coverage: number;
+  grade: string; confidence: string;
+  listing: { profile: { id: string }; reviews: unknown[]; categoryVocEvidence: unknown[]; competitorBasis: string; competitors: Array<{ productId: string }> };
+  concerns: Array<{ id: string; question: string; severity: 'low' | 'medium' | 'high'; source: string; evidence?: string[] }>;
+  compliance: { status: 'PASS' | 'WARN' | 'BLOCK'; findings: Array<{ id: string; severity: string; location: string; message: string; term: string }> };
+  dimensions: LabDimension[];
+  actions: LabCriterion[];
+  dataGaps: Array<{ id: string; label: string; action?: string | null; evidence?: string }>;
+  vocEvidenceSummary?: { productReviewCount?: number; categoryVocCount?: number };
+};
+
+function normalizeSourceEvidence(source: ListingSourceSnapshot, context: JdVocRuleContext): ListingSourceSnapshot {
+  if (!context.categoryVocEvidence) return source;
+  return {
+    ...source,
+    vocEvidence: context.categoryVocEvidence.map((item) => ({
+      id: item.id, text: item.text, sourceVersion: item.source, collectedAt: item.observedAt ?? source.syncedAt,
+    })),
+  };
+}
+
+function labInput(source: ListingSourceSnapshot, context: JdVocRuleContext): Record<string, unknown> {
+  return {
+    source: normalizeSourceEvidence(source, context),
+    sourceVia: 'saas-voc-server',
+    reviews: (context.productReviews ?? []).map((item) => ({ id: item.id, rating: item.rating ?? 0, content: item.text, date: item.observedAt ?? null })),
+    competitors: context.competitors ?? [],
+    competitorBasis: context.competitorBasis === 'formal' ? 'direct-relation' : context.competitorBasis ?? 'none',
+  };
+}
+
+function evidenceId(productId: string, ruleId: string): string {
+  return `jd-voc:${productId}:${ruleId}`;
+}
+
+function mapEvidence(productId: string, criterion: LabCriterion): JdVocEvidence {
+  return {
+    id: evidenceId(productId, criterion.id), ruleId: criterion.id, fieldPath: criterion.id.split('.')[0] ?? criterion.id,
+    outcome: !criterion.available ? 'unknown' : criterion.status === 'pass' ? 'pass' : 'fail',
+    message: criterion.evidence ?? '', source: 'rule',
+    level: !criterion.available || criterion.status === 'unverified' ? 'unknown' : criterion.status === 'partial' ? 'weak' : criterion.status,
+    pointsAwarded: criterion.available ? criterion.score : null, maxPoints: criterion.max, confidence: null,
+  };
+}
+
+function normalizeConcernSource(value: string): JdVocVocEvidence['source'] {
+  if (value.startsWith('product-review')) return 'product-review';
+  if (value.startsWith('category-voc')) return 'category-voc';
+  if (value === 'manual') return 'manual-question';
+  return 'category-profile';
+}
+
+function normalizedStatus(dimension: LabDimension, blocked: boolean): 'scored' | 'partial' | 'blocked' {
+  if (blocked) return 'blocked';
+  if (dimension.verifiedMax === dimension.max && dimension.score !== null) return 'scored';
+  return 'partial';
+}
+
+export function scoreJdVocRules(
+  source: ListingSourceSnapshot,
+  context: JdVocRuleContext = {},
+  options: { id?: string; now?: string; executionKey?: string; imageReviewEnabled?: boolean; imageReviewModel?: string } = {},
+): JdVocScoreResult {
+  const input = labInput(source, context);
+  const listing = normalizeListing(input);
+  const concernBuilder = buildVocConcerns as (value: unknown, explicit?: unknown) => unknown;
+  const concerns = context.manualQuestions?.length ? concernBuilder(listing, context.manualQuestions) : concernBuilder(listing);
+  const calibratedScorer = scoreCalibratedListing as (value: unknown, explicit?: unknown) => unknown;
+  const report = calibratedScorer(listing, concerns) as LabReport;
+  const blocked = report.compliance.status === 'BLOCK' || source.detailStatus !== 'available';
+  const dimensions = report.dimensions.map((dimension) => ({
+    key: dimension.key, score: dimension.score, maxScore: dimension.max, fixedEarned: dimension.earned,
+    verifiedMax: dimension.verifiedMax, coverage: dimension.coverage, status: normalizedStatus(dimension, blocked),
+    evidence: dimension.criteria.map((criterion) => mapEvidence(source.productId, criterion)),
+  }));
+  const dataActions = report.dataGaps.map((gap) => ({
+    ruleId: gap.id, priority: 'DATA' as const, dimension: (gap.id.split('.')[0] || 'facts') as JdVocDimensionKey,
+    title: gap.label, action: gap.action || '补充可验证数据', evidenceIds: [evidenceId(source.productId, gap.id)],
+    confidence: null, potentialGain: dimensions.find((item) => item.key === gap.id.split('.')[0])?.evidence.find((item) => item.ruleId === gap.id)?.maxPoints ?? 0,
+  }));
+  const scoredActions = report.actions.map((action) => ({
+    ruleId: action.id, priority: action.priority, dimension: (action.id.split('.')[0] || 'facts') as JdVocDimensionKey,
+    title: action.label, action: action.action || '补充可验证信息', evidenceIds: [evidenceId(source.productId, action.id)],
+    confidence: null, potentialGain: Math.max(0, Math.round((action.max - (action.score ?? 0)) * 10) / 10),
+  }));
+  const evidence: JdVocVocEvidence[] = [
+    ...(context.productReviews ?? []),
+    ...(context.categoryVocEvidence ?? (source.vocEvidence ?? []).map((item) => ({ id: item.id, source: 'category-voc' as const, text: item.text, observedAt: item.collectedAt }))),
+    ...(context.manualQuestions ?? []).map((item) => ({ id: item.id, source: 'manual-question' as const, text: item.question })),
+  ];
+  const fingerprint = canonicalHash({
+    sourceHash: source.sourceHash, rubricVersion: JD_VOC_RUBRIC_VERSION,
+    productReviewIds: (context.productReviews ?? []).map((item) => item.id).sort(),
+    categoryEvidenceIds: evidence.filter((item) => item.source === 'category-voc').map((item) => item.id).sort(),
+    manualQuestions: context.manualQuestions ?? [], competitorBasis: context.competitorBasis ?? 'none',
+    competitorIds: (context.competitors ?? []).map((item) => item.productId).sort(),
+  });
+  return {
+    id: options.id ?? randomUUID(), workspaceId: source.workspaceId, platform: source.platform, productId: source.productId,
+    sourceHash: source.sourceHash, rubricVersion: JD_VOC_RUBRIC_VERSION, scoreKind: 'jd_voc_rules',
+    overallScore: report.observableScore, dimensions,
+    coverage: {
+      percent: report.coverage,
+      missing: report.dataGaps.map((item) => item.id),
+      status: blocked ? 'blocked' : report.coverage < 100 ? 'partial' : 'eligible',
+    },
+    voc: {
+      profileId: report.listing.profile.id,
+      productReviewCount: report.vocEvidenceSummary?.productReviewCount ?? context.productReviews?.length ?? 0,
+      categoryVocEvidenceCount: report.vocEvidenceSummary?.categoryVocCount ?? evidence.filter((item) => item.source === 'category-voc').length,
+      concerns: report.concerns.map((item) => ({
+        id: item.id, question: item.question, severity: item.severity, source: normalizeConcernSource(item.source),
+        evidenceIds: evidence.filter((entry) => item.evidence?.includes(entry.text)).map((entry) => entry.id),
+      })), evidence,
+    },
+    competitor: {
+      basis: context.competitorBasis ?? (report.listing.competitorBasis === 'category-inferred' ? 'category-inferred' : report.listing.competitors.length ? 'formal' : 'none'),
+      count: report.listing.competitors.length,
+      comparableSurface: report.listing.competitors.length ? ['title', 'sellingPoints', 'specifications'] : [],
+      productIds: report.listing.competitors.map((item) => item.productId),
+      items: (context.competitors ?? []).map((item) => ({
+        productId: item.productId, title: item.title, brand: item.brand ?? '', price: item.price ?? null,
+        specifications: Array.isArray(item.keySpecifications) ? item.keySpecifications.map(String) : Object.entries(item.keySpecifications ?? {}).map(([key,value]) => `${key}:${String(value)}`),
+        sellingPoints: item.sellingPoints ?? [], observedAt: item.observedAt ?? null,
+        status: item.title && (item.sellingPoints?.length || Object.keys(item.keySpecifications ?? {}).length) ? 'available' : 'partial',
+      })),
+    },
+    compliance: {
+      status: blocked ? 'blocked' : report.compliance.status === 'WARN' ? 'warning' : 'normal', gate: report.compliance.status,
+      findings: report.compliance.findings.map((item) => ({ ruleId: item.id, severity: item.severity, fieldPath: item.location, message: item.message, evidence: [item.term] })),
+    },
+    actions: [...scoredActions, ...dataActions],
+    imageReview: reviewImageAssets(source, {
+      ...(options.imageReviewEnabled !== undefined ? { enabled: options.imageReviewEnabled } : {}),
+      ...(options.imageReviewModel !== undefined ? { model: options.imageReviewModel } : {}),
+    }),
+    createdAt: options.now ?? new Date().toISOString(), inputFingerprint: fingerprint,
+    executionKey: options.executionKey ?? `${JD_VOC_RUBRIC_VERSION}:${fingerprint}`,
+  };
+}
+
+export const JD_VOC_DIMENSIONS: readonly JdVocDimensionKey[] = ['search', 'voc', 'selling', 'facts', 'competitive', 'media'];
+export type { JdVocRuleContext } from '../domain.js';

+ 84 - 0
src/modules/managed-tasks/parse-rest-managed-task-worker.ts

@@ -0,0 +1,84 @@
+import type { ParseRestClient } from '../../db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../../db/parse-rest.schema.js';
+
+interface PendingWorkspaceRow {
+  workspaceId?: string;
+}
+
+export interface ManagedTaskProcessor {
+  resumePendingRefreshes(workspaceId: string, platform: 'jd'): Promise<number>;
+  resumePendingJobs(workspaceId: string): Promise<number>;
+}
+
+export class ParseRestManagedTaskQueue {
+  constructor(private readonly client: ParseRestClient) {}
+
+  async listPendingWorkspaceIds(): Promise<string[]> {
+    const options = {
+      where: { status: { $in: ['queued', 'running'] } },
+      limit: 1_000,
+      keys: ['workspaceId'],
+    };
+    const [refreshRuns, scoreJobs] = await Promise.all([
+      this.client.find<PendingWorkspaceRow>(VOC_PARSE_CLASSES.competitorListingRefreshRun, options),
+      this.client.find<PendingWorkspaceRow>(VOC_PARSE_CLASSES.listingScoreJob, options),
+    ]);
+    return [...new Set([...refreshRuns.results, ...scoreJobs.results]
+      .map((row) => row.workspaceId?.trim())
+      .filter((workspaceId): workspaceId is string => Boolean(workspaceId)))]
+      .toSorted();
+  }
+}
+
+export function startParseRestManagedTaskWorker(input: {
+  queue: { listPendingWorkspaceIds(): Promise<string[]> };
+  processor: ManagedTaskProcessor;
+  pollMs: number;
+  logger?: Pick<Console, 'error' | 'log'>;
+}): { stop(): Promise<void> } {
+  const logger = input.logger ?? console;
+  let stopped = false;
+  let running = false;
+  let timer: NodeJS.Timeout | undefined;
+  let activeIteration: Promise<void> | null = null;
+
+  const schedule = () => {
+    if (stopped) return;
+    timer = setTimeout(() => void tick(), input.pollMs);
+    timer.unref();
+  };
+  const tick = async () => {
+    if (running || stopped) return;
+    running = true;
+    activeIteration = (async () => {
+      try {
+        const workspaceIds = await input.queue.listPendingWorkspaceIds();
+        for (const workspaceId of workspaceIds) {
+          const [refreshes, scoreJobs] = await Promise.all([
+            input.processor.resumePendingRefreshes(workspaceId, 'jd'),
+            input.processor.resumePendingJobs(workspaceId),
+          ]);
+          if (refreshes || scoreJobs) {
+            logger.log(`[managed-task-worker] resumed workspace=${workspaceId} refreshes=${refreshes} scoreJobs=${scoreJobs}`);
+          }
+        }
+      } catch (error) {
+        logger.error('[managed-task-worker] Parse REST worker iteration failed', error);
+      } finally {
+        running = false;
+        activeIteration = null;
+        schedule();
+      }
+    })();
+    await activeIteration;
+  };
+
+  void tick();
+  return {
+    async stop() {
+      stopped = true;
+      if (timer) clearTimeout(timer);
+      if (activeIteration) await activeIteration;
+    },
+  };
+}

+ 42 - 12
src/server.ts

@@ -21,6 +21,10 @@ import { ParseRestListingAiRepository } from './modules/listing-ai/repositories/
 import { PostgresListingAiRepository } from './modules/listing-ai/repositories/postgres-listing-ai.repository.js';
 import { CompetitorListingMonitorService } from './modules/competitor-listing-monitor/competitor-listing-monitor.service.js';
 import { ParseRestCompetitorListingMonitorRepository } from './modules/competitor-listing-monitor/repositories/parse-rest-competitor-listing-monitor.repository.js';
+import { FmodeAiClient } from './modules/ai-gateway/client.js';
+import { FmodeJdVocAiScoringProvider, FmodeListingAiScoringProvider, ListingAiService } from './modules/listing-ai/listing-ai.service.js';
+import { FmodeGeminiImageReviewProvider } from './modules/listing-ai/image-review/gemini-image-review.provider.js';
+import { ParseRestManagedTaskQueue, startParseRestManagedTaskWorker } from './modules/managed-tasks/parse-rest-managed-task-worker.js';
 
 async function main(): Promise<void> {
   const config = loadConfig();
@@ -28,7 +32,7 @@ async function main(): Promise<void> {
     || (config.auth.mode === 'disabled' ? config.auth.localUserId : '');
   const gateway = new FmodeVocEcommerceClient(config.fmode);
   let app: ReturnType<typeof createApp>;
-  let worker: { stop(): Promise<void> } | null = null;
+  const workers: Array<{ stop(): Promise<void> }> = [];
   let closeStorage = async () => undefined;
 
   if (config.storageDriver === 'parse_rest') {
@@ -52,9 +56,26 @@ async function main(): Promise<void> {
     const repository = new ParseRestVocRepository(client);
     const promptConfigs = new ParseRestAiPromptConfigStore(client, config.auth.defaultWorkspaceId);
     const productKnowledge = new ParseRestProductKnowledgeStore(client);
+    const listingAiRepository = new ParseRestListingAiRepository(client);
+    const competitorRepository = new ParseRestCompetitorListingMonitorRepository(client);
     const competitorListingMonitor = new CompetitorListingMonitorService(
-      new ParseRestCompetitorListingMonitorRepository(client),
+      competitorRepository,
       gateway,
+      () => new Date(),
+      3,
+      listingAiRepository,
+      competitorRepository,
+    );
+    const listingAi = new ListingAiService(
+      listingAiRepository,
+      new FmodeListingAiScoringProvider(new FmodeAiClient(config.ai), config.listingAi.model),
+      () => new Date(),
+      config.listingAi.concurrency,
+      config.listingAi.maxAiItemsPerJob,
+      config.listingAi.jdVocAiEnabled ? new FmodeJdVocAiScoringProvider(new FmodeAiClient(config.ai), config.listingAi.jdVocAiModel) : undefined,
+      config.listingAi.jdVocImageShadowEnabled ? new FmodeGeminiImageReviewProvider({ baseUrl: process.env.FMODE_LLM_BASE_URL ?? config.ai.baseUrl, token: process.env.FMODE_LLM_API_KEY ?? config.ai.token, timeoutMs: 45_000 }) : undefined,
+      config.listingAi.jdVocDisplayDefault,
+      config.listingAi.jdVocEnabled,
     );
     try { if (!skipParseStartupReconciliation) await promptConfigs.ensureDefaults(DEFAULT_DOMESTIC_AI_PROMPT_CONFIGS); }
     catch (error) { if (!(error instanceof ParseRestError && error.status === 404)) throw error; console.warn('[server] skipped prompt reconciliation because Parse gateway is unavailable'); }
@@ -78,18 +99,27 @@ async function main(): Promise<void> {
       },
       aiPromptConfigs: promptConfigs,
       productKnowledge,
-      listingAiRepository: new ParseRestListingAiRepository(client),
+      listingAiRepository,
+      listingAiService: listingAi,
       competitorListingMonitor,
     });
     const processor = new JdSyncService(gateway, repository, config.worker.reviewMaxPages);
-    worker = config.worker.enabled
-      ? startParseRestSyncWorker({
+    if (config.worker.enabled) {
+      workers.push(startParseRestSyncWorker({
         queue: repository,
         processor,
         pollMs: config.worker.pollMs,
         staleAfterMs: config.worker.staleAfterMs,
-      })
-      : null;
+      }));
+      workers.push(startParseRestManagedTaskWorker({
+        queue: new ParseRestManagedTaskQueue(client),
+        processor: {
+          resumePendingRefreshes: (workspaceId, platform) => competitorListingMonitor.resumePendingRefreshes(workspaceId, platform),
+          resumePendingJobs: (workspaceId) => listingAi.resumePendingJobs(workspaceId),
+        },
+        pollMs: config.worker.pollMs,
+      }));
+    }
   } else {
     const pool = createDatabasePool(config);
     const parseServer = await createParseServer(config);
@@ -112,14 +142,14 @@ async function main(): Promise<void> {
     });
     const ingestion = new VocIngestionRepository(pool);
     const processor = new JdSyncService(gateway, ingestion, config.worker.reviewMaxPages);
-    worker = config.worker.enabled
-      ? startSyncWorker({
+    if (config.worker.enabled) {
+      workers.push(startSyncWorker({
         pool,
         processor,
         pollMs: config.worker.pollMs,
         staleAfterMs: config.worker.staleAfterMs,
-      })
-      : null;
+      }));
+    }
     closeStorage = async () => {
       await parseServer.handleShutdown();
       await pool.end();
@@ -134,7 +164,7 @@ async function main(): Promise<void> {
 
   const shutdown = async (signal: string) => {
     console.log(`[server] received ${signal}; shutting down`);
-    await worker?.stop();
+    await Promise.all(workers.map((worker) => worker.stop()));
     server.close(async () => {
       await closeStorage();
       process.exit(0);

+ 1 - 1
test/ai-gateway.test.ts

@@ -38,7 +38,7 @@ test('AI gateway reports only public configuration and keeps the token server-si
       configured: true,
       baseUrl: 'https://api.example.test',
       defaultModel: 'deepseek-v4-pro',
-      proxyEndpoint: '/api/ai/chat/completions',
+      proxyEndpoint: 'ai.chat',
     });
     assert.equal(JSON.stringify(status).includes(config.token), false);
   } finally {

+ 110 - 0
test/cloud-function-source.test.ts

@@ -0,0 +1,110 @@
+import { readFile } from 'node:fs/promises';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+const sourceUrl = new URL('../cloud-functions/saas-voc-gateway.js', import.meta.url);
+
+test('managed cloud source defines the required handler and an explicit action allowlist', async () => {
+  const source = await readFile(sourceUrl, 'utf8');
+  assert.match(source, /async function handler\(request, response\)/);
+  assert.match(source, /const ACTIONS = new Set/);
+  assert.match(source, /cloud_action_not_allowed/);
+  assert.doesNotMatch(source, /new Parse\.Query\(input\.|new Parse\.Query\(params\./);
+});
+
+test('managed cloud source enforces authentication, workspace membership, roles and product scope before master-key access', async () => {
+  const source = await readFile(sourceUrl, 'utf8');
+  assert.match(source, /if \(!activeRequest\.user\)/);
+  assert.match(source, /VocWorkspaceMember/);
+  assert.match(source, /viewer_write_forbidden/);
+  assert.match(source, /productIds/);
+  assert.match(source, /product_scope_denied/);
+  assert.match(source, /useMasterKey: true/);
+});
+
+test('managed domestic snapshot preserves the frontend dataset contract and computes empty-safe totals', async () => {
+  const source = await readFile(sourceUrl, 'utf8');
+  assert.match(source, /dailyTotals/);
+  assert.match(source, /mappingGroups/);
+  assert.match(source, /quality:/);
+  assert.match(source, /conversionRate: total\.visitors \? /);
+  assert.match(source, /reviewCount: reviews\.length/);
+});
+
+test('managed reads normalize Parse identifiers into frontend business identifiers', async () => {
+  const source = await readFile(sourceUrl, 'utf8');
+  assert.match(source, /function presentReadItem\(action, item\)/);
+  assert.match(source, /output\.id = output\.publicId \|\| output\.objectId/);
+  assert.match(source, /output\.reviewId = output\.reviewId \|\| output\.reviewKey/);
+  assert.match(source, /listing\.products\.list/);
+});
+
+test('managed handler serializes shared request context and clears it after execution', async () => {
+  const source = await readFile(sourceUrl, 'utf8');
+  assert.match(source, /let functionQueue = Promise\.resolve\(\)/);
+  assert.match(source, /await previous/);
+  assert.match(source, /activeRequest = null/);
+  assert.match(source, /activeResponse = null/);
+});
+
+test('managed cloud source rejects arbitrary upstream forwarding and redacts secret-shaped fields', async () => {
+  const source = await readFile(sourceUrl, 'utf8');
+  assert.doesNotMatch(source, /fetch\s*\(\s*input\./);
+  assert.match(source, /token\|secret\|password\|credential\|authorization\|master/i);
+  assert.doesNotMatch(source, /PARSE_MASTER_KEY\s*=\s*['"][^'"]+['"]/);
+  assert.match(source, /const UPSTREAM_PATHS = \{/);
+  assert.match(source, /upstream_path_not_allowed/);
+  assert.match(source, /new URL\(path\.replace/);
+  assert.match(source, /const UPSTREAM_OPERATIONS = \{/);
+  assert.match(source, /upstream_operation_not_allowed/);
+  assert.match(source, /upstream_timeout/);
+  assert.match(source, /function normalizeUpstreamPath\(value\)/);
+  assert.ok(source.includes("if (!/^https:\\/\\//i.test(base)"));
+});
+
+test('managed score jobs persist the worker contract and create queue items before publishing the job', async () => {
+  const source = await readFile(sourceUrl, 'utf8');
+  assert.match(source, /async function resolveListingScoreSources/);
+  assert.match(source, /async function createListingScoreItems/);
+  assert.match(source, /VocListingScoreItem/);
+  assert.match(source, /const rubricVersion = input\.rubricVersion \|\| \(jdVocEnabled \? 'jd-voc-v0\.5'/);
+  assert.match(source, /rubricVersion, includeAiSuggestions: scoringMode === 'ai'/);
+  assert.match(source, /jd_voc_disabled/);
+  assert.match(source, /\.run\$/);
+  assert.match(source, /processed: 0, succeeded: 0, partial: 0, blocked: 0, failed: 0/);
+  assert.match(source, /status: sources\.length \? 'initializing' : 'completed'/);
+  assert.match(source, /await createIdempotent\('VocListingScoreJob'[\s\S]*await createListingScoreItems/);
+  assert.match(source, /activateListingScoreJob/);
+  assert.match(source, /function presentListingJob\(/);
+  assert.match(source, /function presentListingJobItem\(/);
+});
+
+test('managed task mutations preserve persisted payloads and reject duplicate active competitor refreshes', async () => {
+  const source = await readFile(sourceUrl, 'utf8');
+  assert.match(source, /function storageData\(params, workspaceId\)/);
+  assert.match(source, /storageData\(params, workspaceId\)/);
+  assert.match(source, /activeRunQuery\.containedIn\('status', \['queued', 'running'\]\)/);
+  assert.match(source, /competitor_listing_refresh_running/);
+});
+
+test('managed aggregate reads expand linked competitor scope and paginate beyond one Parse page', async () => {
+  const source = await readFile(sourceUrl, 'utf8');
+  assert.match(source, /\['VocProduct', 'VocCompetitorListingSnapshot', 'VocCompetitorListingChange'\]/);
+  assert.match(source, /const competitorIds = relations\.map/);
+  assert.match(source, /const products = await readAll\('VocProduct'/);
+  assert.match(source, /const sources = await readAllWhere\('VocListingSourceSnapshot'/);
+  assert.match(source, /const scores = await readAllWhere\('VocListingCurrentScore'/);
+  assert.match(source, /const changes = await readAllWhere\('VocCompetitorListingChange'/);
+  assert.match(source, /async function readAllWhere\(/);
+  assert.match(source, /readAllWhere\('VocListingSourceSnapshot'/);
+  assert.match(source, /const READ_FILTER_FIELDS = \{/);
+  assert.match(source, /function applyReadFilters\(query, className, params\)/);
+  assert.match(source, /query\.contains\('title', params\.search/);
+});
+
+test('deployment documentation exposes a separate Function registry credential boundary', async () => {
+  const deployment = await readFile(new URL('../docs/cloud-functions-deployment.md', import.meta.url), 'utf8');
+  assert.match(deployment, /FUNCTION_REGISTRY_SERVER_URL/);
+  assert.match(deployment, /FUNCTION_REGISTRY_APP_ID/);
+  assert.match(deployment, /FUNCTION_REGISTRY_MASTER_KEY/);
+});

+ 62 - 0
test/cloud-functions.router.test.ts

@@ -0,0 +1,62 @@
+import { createServer } from 'node:http';
+import express from 'express';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createCloudFunctionRouter } from '../src/cloud-functions/router.js';
+
+async function withRouter(dispatch: Parameters<typeof createCloudFunctionRouter>[0]['dispatch'], actionBody: unknown): Promise<{ status: number; body: any }> {
+  const app = express();
+  app.use(express.json());
+  app.use('/api/functions', createCloudFunctionRouter({ defaultWorkspaceId: 'demashi', dispatch }));
+  const server = createServer(app);
+  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
+  const address = server.address();
+  const port = typeof address === 'object' && address ? address.port : 0;
+  try {
+    const response = await fetch(`http://127.0.0.1:${port}/api/functions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(actionBody) });
+    return { status: response.status, body: await response.json() };
+  } finally {
+    await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
+  }
+}
+
+test('cloud function router rejects actions outside the allowlist', async () => {
+  const result = await withRouter(async () => undefined, { action: 'parse.query', workspaceId: 'demashi', payload: {} });
+  assert.equal(result.status, 400);
+  assert.equal(result.body.code, 'cloud_action_not_allowed');
+  assert.match(result.body.requestId, /.+/);
+});
+
+test('cloud function router normalizes action payloads and forwards a workspace-scoped route', async () => {
+  let captured: { path: string; method: string; body: Record<string, unknown> } | undefined;
+  const result = await withRouter(async (_request, response, path, method, body, requestId) => {
+    captured = { path, method, body };
+    response.json({ success: true, data: { ok: true }, requestId });
+  }, { action: 'domestic.products.list', workspaceId: 'workspace/alpha', platform: 'jd', payload: { limit: 20, cursor: 'next' } });
+  assert.equal(result.status, 200);
+  assert.deepEqual(captured, {
+    path: '/domestic-voc/products?workspaceId=workspace%2Falpha&platform=jd&limit=20&cursor=next',
+    method: 'GET',
+    body: { limit: 20, cursor: 'next', platform: 'jd', workspaceId: 'workspace/alpha' },
+  });
+});
+
+test('cloud function router accepts the managed Function browser envelope', async () => {
+  let captured: { path: string; method: string; body: Record<string, unknown> } | undefined;
+  const result = await withRouter(async (_request, response, path, method, body, requestId) => {
+    captured = { path, method, body };
+    response.json({ success: true, data: { ok: true }, requestId });
+  }, {
+    path: '/saas-voc-gateway',
+    params: { action: 'context.get', workspaceId: 'demashi' },
+    _ApplicationId: 'saas-voc-local',
+    _InstallationId: 'test-installation',
+  });
+
+  assert.equal(result.status, 200);
+  assert.deepEqual(captured, {
+    path: '/saas/context',
+    method: 'GET',
+    body: { workspaceId: 'demashi' },
+  });
+});

+ 26 - 0
test/cloud-functions.special-actions.test.ts

@@ -0,0 +1,26 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createSpecialActionHandler } from '../src/cloud-functions/special-actions.js';
+
+test('ai cloud action forwards only the allow-listed completion fields', async () => {
+  let captured: Record<string, unknown> | undefined;
+  const handler = createSpecialActionHandler({
+    ai: { createChatCompletion: async (body: Record<string, unknown>) => { captured = body; return new Response(JSON.stringify({ choices: [] }), { status: 200 }); } } as any,
+    domestic: {} as any,
+  });
+  const response = { json(value: unknown) { return value; } } as any;
+  const handled = await handler({} as any, response, 'ai.chat', {
+    workspaceId: 'workspace-a', action: 'ai.chat', messages: [{ role: 'user', content: 'hello' }], model: 'test-model', secret: 'must-not-forward', stream: true,
+  }, 'request-1');
+  assert.equal(handled, true);
+  assert.deepEqual(captured, { messages: [{ role: 'user', content: 'hello' }], model: 'test-model', stream: false });
+});
+
+test('local upstream adapter rejects arbitrary URLs before configuration lookup', async () => {
+  const handler = createSpecialActionHandler({ ai: {} as any, domestic: {} as any });
+  const response = {} as any;
+  await assert.rejects(
+    handler({} as any, response, 'upstream.amazon', { path: 'https://attacker.invalid/relay', operation: 'get' }, 'request-2'),
+    (error: any) => error?.code === 'upstream_path_not_allowed',
+  );
+});

+ 11 - 0
test/competitor-listing-alerts.test.ts

@@ -0,0 +1,11 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { buildCompetitorListingAlerts } from '../src/modules/competitor-listing-monitor/alerts.js';
+import type { CompetitorListingChange } from '../src/modules/competitor-listing-monitor/domain.js';
+const change = (id: string, productId: string, before: number, after: number): CompetitorListingChange => ({ id, naturalKey: id, workspaceId: 'ws', platform: 'jd', productId, previousSnapshotId: 'p', currentSnapshotId: 'c', detectedAt: '2026-01-01T00:00:00.000Z', changeTypes: ['price'], changes: [{ field: 'priceCents', before, after }] });
+test('alerts emit field rules and a deterministic multi-competitor direction warning', () => {
+  const alerts = buildCompetitorListingAlerts([change('a','c1',100,90), change('b','c2',200,180)]);
+  assert.ok(alerts.some((alert) => alert.rule === 'price_change' && alert.productIds[0] === 'c1'));
+  assert.ok(alerts.some((alert) => alert.rule === 'multi_competitor_direction' && alert.productIds.length === 2));
+  assert.equal(buildCompetitorListingAlerts([change('a','c1',100,90)])[0]!.id.length, 32);
+});

+ 18 - 0
test/competitor-listing-history.test.ts

@@ -0,0 +1,18 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { CompetitorListingMonitorService, type CompetitorListingMonitorRepository } from '../src/modules/competitor-listing-monitor/competitor-listing-monitor.service.js';
+import type { CompetitorListingChange, CompetitorListingRefreshRun, CompetitorListingSnapshot } from '../src/modules/competitor-listing-monitor/domain.js';
+
+const specs = { model: null, color: null, specification: null, origin: null, weightKg: null, lengthMm: null, widthMm: null, heightMm: null };
+class Repo implements CompetitorListingMonitorRepository {
+  snapshots: CompetitorListingSnapshot[] = []; changes: CompetitorListingChange[] = []; runs: CompetitorListingRefreshRun[] = [];
+  async listTargets() { return []; } async getLatestSnapshot() { return null; } async listSnapshots() { return this.snapshots; } async saveSnapshot(v: CompetitorListingSnapshot) { return v; } async listChanges() { return this.changes; } async saveChange(v: CompetitorListingChange) { return v; } async findActiveRun() { return null; } async createRun(v: CompetitorListingRefreshRun) { return v; } async updateRun(v: CompetitorListingRefreshRun) { return v; } async getRun() { return null; } async listRuns() { return this.runs; }
+}
+const snap = (id: string, prev: string|null, title: string, priceCents: number): CompetitorListingSnapshot => ({ id, naturalKey: id, workspaceId: 'ws', platform: 'jd', productId: 'c1', previousSnapshotId: prev, contentHash: id.padEnd(64, '0'), observedAt: `2026-01-0${id.slice(-1)}T00:00:00.000Z`, title, priceCents, currency: 'CNY', availability: 'online', mainImageUrl: null, keySpecifications: specs, observedFields: ['title', 'price', 'availability'], collectionStatus: 'succeeded' });
+test('history returns field-filtered diffs and failed runs', async () => {
+  const repository = new Repo(); repository.snapshots = [snap('s1', null, 'A', 1000), snap('s2', 's1', 'B', 900)];
+  repository.changes = [{ id:'ch', naturalKey:'s1:s2', workspaceId:'ws', platform:'jd', productId:'c1', previousSnapshotId:'s1', currentSnapshotId:'s2', detectedAt:'2026-01-02T00:00:00.000Z', changeTypes:['title','price'], changes:[{field:'title',before:'A',after:'B'},{field:'priceCents',before:1000,after:900}] }];
+  repository.runs = [{ id:'r', workspaceId:'ws', platform:'jd', trigger:'manual', status:'partial', total:1, completed:1, baseline:0, unchanged:0, changed:0, failed:1, itemResults:[{productId:'c1',status:'failed',errorCode:'timeout',observedAt:'2026-01-02T00:00:00.000Z'}], requestedAt:'2026-01-02T00:00:00.000Z',startedAt:null,completedAt:null }];
+  const history = await new CompetitorListingMonitorService(repository, { request: async <T>() => ({} as T) }).history('ws','jd','c1',{ fields:['price'] });
+  assert.equal(history.snapshots.length, 2); assert.deepEqual(history.changes[0]!.changeTypes, ['price']); assert.equal(history.collectionRuns[0]!.result?.status, 'failed');
+});

+ 3 - 0
test/competitor-listing-impact.test.ts

@@ -0,0 +1,3 @@
+import assert from 'node:assert/strict'; import test from 'node:test'; import { analyzeCompetitorListingImpact } from '../src/modules/competitor-listing-monitor/impact-analysis.js'; import type { CompetitorListingChange } from '../src/modules/competitor-listing-monitor/domain.js';
+const change: CompetitorListingChange = { id:'ch',naturalKey:'k',workspaceId:'ws',platform:'jd',productId:'c1',previousSnapshotId:'p',currentSnapshotId:'c',detectedAt:'2026-01-01T00:00:00.000Z',changeTypes:['price','title'],changes:[] };
+test('impact analysis is traceable and fails closed without formal score',()=>{ const noScore=analyzeCompetitorListingImpact(change,'own',null); assert.equal(noScore.impactScore,null); assert.equal(noScore.scoreSource,null); const score:any={id:'score',sourceHash:'hash',dimensions:[{dimension:'title',score:50,maxScore:100},{dimension:'images',score:90,maxScore:100}]}; const result=analyzeCompetitorListingImpact(change,'own',score); assert.equal(result.changeId,'ch'); assert.equal(result.currentSnapshotId,'c'); assert.equal(result.weakestDimension,'title'); assert.ok((result.impactScore??0)>0); });

+ 35 - 0
test/competitor-listing-monitor.service.test.ts

@@ -239,6 +239,41 @@ test('refresh establishes baselines and identical data does not duplicate snapsh
   assert.equal(overview.items[0]?.relatedProducts.length, 1);
 });
 
+test('resumePendingRefreshes continues persisted work without recollecting completed targets', async () => {
+  const repository = new FakeRepository([target('c1'), target('c2')]);
+  const gateway = new FakeGateway();
+  gateway.products.set('c2', { title: '商品 c2', price: 1000, skuStatus: '1' });
+  repository.runs.push({
+    id: 'persisted-run',
+    workspaceId: 'demashi',
+    platform: 'jd',
+    trigger: 'manual',
+    status: 'running',
+    total: 2,
+    completed: 1,
+    baseline: 1,
+    unchanged: 0,
+    changed: 0,
+    failed: 0,
+    itemResults: [{ productId: 'c1', status: 'baseline', observedAt: '2026-08-26T00:00:00.000Z' }],
+    requestedAt: '2026-08-26T00:00:00.000Z',
+    startedAt: '2026-08-26T00:00:01.000Z',
+    completedAt: null,
+  });
+  const service = new CompetitorListingMonitorService(repository, gateway, clock(), 1);
+
+  assert.equal(await service.resumePendingRefreshes('demashi', 'jd'), 1);
+  assert.equal(await service.resumePendingRefreshes('demashi', 'jd'), 0);
+  const completed = await waitForTerminal(repository, 'persisted-run');
+
+  assert.equal(completed.status, 'completed');
+  assert.equal(completed.completed, 2);
+  assert.equal(completed.baseline, 2);
+  assert.deepEqual(completed.itemResults.map((item) => item.productId), ['c1', 'c2']);
+  assert.equal(completed.startedAt, '2026-08-26T00:00:01.000Z');
+  assert.equal(await service.resumePendingRefreshes('demashi', 'jd'), 0);
+});
+
 test('a scheduled run processes all 37 mapped targets with bounded concurrency and complete statistics', async () => {
   const targets = Array.from({ length: 37 }, (_, index) => target(`c${index + 1}`));
   const repository = new FakeRepository(targets);

+ 2 - 0
test/competitor-listing-optimization-task.test.ts

@@ -0,0 +1,2 @@
+import assert from 'node:assert/strict'; import test from 'node:test'; import { createOptimizationTask, updateOptimizationTask, type CompetitorOptimizationTask } from '../src/modules/competitor-listing-monitor/optimization-task.js';
+test('optimization task creation is workspace-scoped and idempotent',()=>{ const all: CompetitorOptimizationTask[]=[]; const input={workspaceId:'ws',competitorSnapshotId:'snap',ownProductId:'own',dimension:'title',impactScore:40,now:'2026-01-01T00:00:00.000Z'}; const first=createOptimizationTask(all,input); const second=createOptimizationTask(all,{...input,now:'2026-01-02T00:00:00.000Z'}); assert.equal(first.idempotent,false); assert.equal(second.idempotent,true); assert.equal(all.length,1); assert.equal(updateOptimizationTask(first.task,'in_progress').status,'in_progress'); });

+ 2 - 0
test/competitor-listing-validation.test.ts

@@ -0,0 +1,2 @@
+import assert from 'node:assert/strict'; import test from 'node:test'; import { validateListingOptimization } from '../src/modules/competitor-listing-monitor/validation.js';
+test('validation reports score deltas without claiming business outcomes',()=>{ assert.equal(validateListingOptimization('title',50,55).outcome,'partially_effective'); assert.equal(validateListingOptimization('title',50,60).outcome,'effective'); assert.equal(validateListingOptimization('title',null,60).outcome,'not_measured'); assert.equal(validateListingOptimization('title',70,60).outcome,'ineffective'); });

+ 13 - 0
test/env.test.ts

@@ -55,9 +55,22 @@ test('loadConfig normalizes URLs and CORS origins', () => {
   assert.equal(config.worker.reviewMaxPages, 1);
   assert.equal(config.auth.mode, 'disabled');
   assert.equal(config.auth.defaultWorkspaceId, 'demashi');
+  assert.equal(config.listingAi.jdVocEnabled, false);
+  assert.equal(config.listingAi.jdVocAiEnabled, false);
+  assert.equal(config.listingAi.jdVocImageShadowEnabled, false);
+  assert.equal(config.listingAi.jdVocDisplayDefault, false);
+  assert.equal(config.listingAi.jdVocAiModel, 'gpt-4o-mini');
   assert.deepEqual(config.corsOrigins, ['http://127.0.0.1:4300', 'http://localhost:4200']);
 });
 
+test('loadConfig enables each approved JD-VOC rollout switch independently', () => {
+  const config = loadConfig({ ...validEnvironment, JD_VOC_ENABLED:'true', JD_VOC_AI_ENABLED:'true', JD_VOC_IMAGE_SHADOW_ENABLED:'true', JD_VOC_DISPLAY_DEFAULT:'true' });
+  assert.equal(config.listingAi.jdVocEnabled, true);
+  assert.equal(config.listingAi.jdVocAiEnabled, true);
+  assert.equal(config.listingAi.jdVocImageShadowEnabled, true);
+  assert.equal(config.listingAi.jdVocDisplayDefault, true);
+});
+
 test('loadConfig treats blank optional environment values as unset', () => {
   const config = loadConfig({
     ...validEnvironment,

+ 26 - 0
test/fixtures/jd-voc-score-result.json

@@ -0,0 +1,26 @@
+{
+  "id": "jd-voc-result-1",
+  "workspaceId": "demashi",
+  "platform": "jd",
+  "productId": "10020722928820",
+  "sourceHash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+  "rubricVersion": "jd-voc-v0.5",
+  "scoreKind": "jd_voc_rules",
+  "overallScore": 64.9,
+  "dimensions": [
+    { "key": "search", "score": 20, "maxScore": 25, "fixedEarned": 20, "verifiedMax": 25, "coverage": 100, "status": "scored", "evidence": [] },
+    { "key": "voc", "score": 15, "maxScore": 25, "fixedEarned": 15, "verifiedMax": 25, "coverage": 90, "status": "scored", "evidence": [] },
+    { "key": "selling", "score": 10, "maxScore": 15, "fixedEarned": 10, "verifiedMax": 15, "coverage": 100, "status": "scored", "evidence": [] },
+    { "key": "facts", "score": null, "maxScore": 20, "fixedEarned": null, "verifiedMax": 0, "coverage": 0, "status": "partial", "evidence": [] },
+    { "key": "competitive", "score": null, "maxScore": 10, "fixedEarned": null, "verifiedMax": 0, "coverage": 0, "status": "blocked", "evidence": [] },
+    { "key": "media", "score": 5, "maxScore": 5, "fixedEarned": 5, "verifiedMax": 5, "coverage": 100, "status": "scored", "evidence": [] }
+  ],
+  "coverage": { "percent": 78, "missing": ["facts.detail-content", "competitive.benchmark"], "status": "partial" },
+  "voc": { "profileId": "peeler", "productReviewCount": 2, "categoryVocEvidenceCount": 3, "concerns": [], "evidence": [] },
+  "competitor": { "basis": "category-inferred", "count": 2, "comparableSurface": ["price", "specifications"], "productIds": ["1001", "1002"] },
+  "actions": [],
+  "imageReview": { "status": "not_requested", "model": null, "evidence": [], "suggestions": [] },
+  "executionKey": "jd-voc-v0.5:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+  "inputFingerprint": "sha256:fixture",
+  "createdAt": "2026-09-04T08:00:00.000Z"
+}

+ 56 - 0
test/image-review-shadow.test.ts

@@ -0,0 +1,56 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { reviewImageAssets } from '../src/modules/listing-ai/image-review/image-review.service.js';
+import { FmodeGeminiImageReviewProvider } from '../src/modules/listing-ai/image-review/gemini-image-review.provider.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';
+
+const source = (images: number): ListingSourceSnapshot => ({
+  id: 'image-source', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: 'image-1', sourceHash: 'c'.repeat(64),
+  title: '测试商品', titleBrandName: null, brand: { id: null, name: null }, categoryIds: [], itemStatus: null, price: { jd: null, cost: null },
+  descriptions: { desktopHtml: null, mobileHtml: null }, features: [], attributes: [],
+  images: Array.from({ length: images }, (_, index) => ({ url: `https://private.example/${index}.jpg`, order: index + 1, isPrimary: index === 0, gptFlag: null })),
+  imageAssets: { defaultImages: [], skuImages: [], whiteBackgroundImages: [] }, skus: [], dimensions: { length: null, width: null, height: null, weight: null }, logistics: {}, afterService: {}, sourceModifiedAt: null, syncedAt: '2026-09-04T00:00:00.000Z', detailStatus: 'available',
+});
+
+test('image review is closed by default and never exposes image URLs', () => {
+  assert.deepEqual(reviewImageAssets(source(2)), { status: 'not_requested', model: null, evidence: [], suggestions: [] });
+  const result = reviewImageAssets(source(2), { enabled: true });
+  assert.equal(result.status, 'shadow_completed');
+  assert.equal(JSON.stringify(result).includes('private.example'), false);
+  assert.equal(result.evidence.every((item) => !item.message.includes('http')), true);
+});
+
+test('shadow benchmark classifies empty and non-empty asset structure without changing score inputs', () => {
+  const cases = Array.from({ length: 10 }, (_, index) => ({ source: source(index % 2), expected: index % 2 ? 'pass' : 'fail' }));
+  const correct = cases.filter((item) => reviewImageAssets(item.source, { enabled: true }).evidence[0]?.outcome === item.expected).length;
+  assert.equal(correct / cases.length, 1);
+});
+
+test('Gemini provider sends bounded image inputs with server authorization and normalizes JSON', async () => {
+  let requestBody = '';
+  let authorization = '';
+  const provider = new FmodeGeminiImageReviewProvider({
+    baseUrl: 'https://vision.example.test', token: 'server-token', fetchImpl: (async (_input, init) => {
+      requestBody = String(init?.body ?? ''); authorization = new Headers(init?.headers).get('authorization') ?? '';
+      return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ visible_facts: [{ field: 'category', value: '削皮机', confidence: 0.9 }], visual_inferences: [], unknowns: [] }) } }] }), { status: 200 });
+    }) as typeof fetch,
+  });
+  const result = await provider.analyze(['https://img.test/a.jpg', 'https://img.test/b.jpg', 'https://img.test/c.jpg', 'https://img.test/d.jpg'], source(2));
+  assert.equal(result.visibleFacts[0]?.value, '削皮机');
+  assert.equal(authorization, 'Bearer server-token');
+  assert.equal((JSON.parse(requestBody) as { messages: Array<{ content: unknown[] }> }).messages[0]?.content.length, 4);
+});
+
+test('service persists shadow evidence without changing any JD-VOC main score', async () => {
+  const input = source(2);
+  const repository = new InMemoryListingAiRepository([input]);
+  const provider = { analyze: async () => ({ visibleFacts: [{ field: 'category', value: '测试商品', confidence: 0.9 }], visualInferences: [], unknowns: [], model: 'gemini-test', latencyMs: 1 }) };
+  const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'), 1, 10, undefined, provider);
+  const before = await service.scoreJdVocRules({ workspaceId: input.workspaceId, platform: input.platform, productId: input.productId });
+  const after = await service.reviewJdVocImages({ workspaceId: input.workspaceId, platform: input.platform, productId: input.productId });
+  assert.equal(after.imageReview?.status, 'shadow_completed');
+  assert.equal(after.overallScore, before.overallScore);
+  assert.deepEqual(after.dimensions, before.dimensions);
+});

+ 83 - 0
test/jd-voc-ai-rubric.test.ts

@@ -0,0 +1,83 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
+import { composeJdVocHybridScore, jdVocAiEvidenceCatalog, jdVocAiPrompt, parseJdVocAiScoreOutput, validateJdVocAiOutput } from '../src/modules/listing-ai/scoring/jd-voc-ai-rubric.js';
+import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
+import { ListingAiService, type JdVocAiScoringProvider } from '../src/modules/listing-ai/listing-ai.service.js';
+
+const source = {
+  id: 'source-ai', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: 'ai-jd-1', sourceHash: 'b'.repeat(64),
+  title: '德玛仕 商用削皮机 500W', titleBrandName: '德玛仕', brand: { id: '1', name: '德玛仕' }, categoryIds: ['food'],
+  categoryContext: { names: ['削皮机'], coreTerms: ['削皮机'], requiredSpecificationNames: [], qualificationNames: [], ruleVersion: 'peeler-v1' }, itemStatus: 'on_shelf',
+  price: { jd: 1000, cost: null }, descriptions: { desktopHtml: '<p>商用</p>', mobileHtml: '<p>商用</p>' }, descriptionStructure: { observed: true, imageCount: 1, videoCount: 0, headingCount: 0, faqCandidateCount: 0 },
+  features: [{ key: 'power', value: '500W' }], attributes: [{ id: '1', name: '功率', values: ['500W'] }], images: [{ url: 'https://img.test/1.jpg', order: 1, isPrimary: true, gptFlag: false }], imageAssets: { defaultImages: [], skuImages: [], whiteBackgroundImages: [] }, skus: [], dimensions: { length: 1, width: 1, height: 1, weight: 1 }, logistics: {}, afterService: {}, marketing: { adword: '500W', skuShortTitles: [], sellingPoints: [{ value: '500W', source: 'product_adword', fieldPath: 'adword', skuId: null }] }, sourceModifiedAt: null, syncedAt: '2026-09-04T08:00:00.000Z', detailStatus: 'available',
+} as ListingSourceSnapshot;
+
+const baseline = scoreJdVocRules(source, { productReviews: [{ id: 'review-1', source: 'product-review', text: '削皮速度稳定' }] }, { id: 'baseline', now: '2026-09-04T08:00:00.000Z' });
+const validOutput = () => ({
+  assessments: (['search', 'voc', 'selling', 'facts', 'competitive'] as const).map((dimension) => ({ dimension, score: ({ search: 20, voc: 20, selling: 12, facts: 15, competitive: 5 } as const)[dimension], evidenceIds: ['source.title'], rationale: '基于字段证据', confidence: 0.8 })),
+  suggestions: [{ title: '补充功率说明', action: '在卖点中说明已观测功率', evidenceIds: ['source.title'] }], summary: '完成语义复核',
+});
+
+test('JD-VOC AI parser requires five unique semantic dimensions and bounded scores', () => {
+  const parsed = parseJdVocAiScoreOutput(JSON.stringify(validOutput()));
+  assert.equal(parsed?.assessments.length, 5);
+  assert.match(jdVocAiPrompt(), /search, voc, selling, facts, competitive/);
+  assert.match(jdVocAiPrompt(), /media 图片维度由规则独占/);
+  const duplicate = { ...validOutput(), assessments: validOutput().assessments.map((item, index) => index === 4 ? { ...item, dimension: 'search' as const } : item) };
+  assert.equal(parseJdVocAiScoreOutput(JSON.stringify(duplicate)), null);
+});
+
+test('JD-VOC AI validation rejects unknown evidence and filters fabricated numeric suggestions', () => {
+  const output = validOutput();
+  const catalog = jdVocAiEvidenceCatalog(source, baseline);
+  assert.throws(() => validateJdVocAiOutput({ ...output, assessments: output.assessments.map((item) => ({ ...item, evidenceIds: ['does-not-exist'] })) }, baseline, catalog, source), /evidence_invalid/);
+  const filtered = validateJdVocAiOutput({ ...output, suggestions: [{ title: '补充 9999W', action: '宣称 9999W', evidenceIds: ['source.title'] }] }, baseline, catalog, source);
+  assert.equal(filtered.suggestions.length, 0);
+});
+
+test('JD-VOC hybrid composition uses 65/35 and preserves blocked rule results', () => {
+  const result = composeJdVocHybridScore({ baseline, output: validOutput(), source, model: 'gpt-4o-mini', now: '2026-09-04T08:01:00.000Z' });
+  const search = result.dimensions.find((item) => item.key === 'search');
+  const ruleSearch = baseline.dimensions.find((item) => item.key === 'search')?.score;
+  assert.equal(search?.score, ruleSearch === null || ruleSearch === undefined ? null : Math.round((ruleSearch * 0.65 + 20 * 0.35) * 10) / 10);
+  assert.equal(result.scoreKind, 'jd_voc_hybrid_ai');
+  assert.equal(result.dimensions.find((item) => item.key === 'media')?.score, baseline.dimensions.find((item) => item.key === 'media')?.score);
+  const blocked = scoreJdVocRules({ ...source, detailStatus: 'empty', title: null, features: [], attributes: [], images: [] });
+  const blockedSource = { ...source, detailStatus: 'empty' as const, title: null, features: [], attributes: [], images: [] };
+  const blockedOutput = { ...validOutput(), assessments: validOutput().assessments.map((item) => ({ ...item, evidenceIds: ['source.brand'] })) };
+  assert.equal(composeJdVocHybridScore({ baseline: blocked, output: blockedOutput, source: blockedSource, model: 'gpt-4o-mini', now: '2026-09-04T08:01:00.000Z' }).overallScore, null);
+});
+
+test('JD-VOC score jobs persist AI slots, reuse cache, and keep rules on provider failure', async () => {
+  class Provider implements JdVocAiScoringProvider {
+    configured = true;
+    model = 'gpt-4o-mini';
+    calls = 0;
+    fail = false;
+    async score() { this.calls += 1; if (this.fail) throw new Error('test_ai_failure'); return validOutput(); }
+  }
+  class TrackingRepository extends InMemoryListingAiRepository {
+    statuses:string[]=[];
+    override async updateJobItem(item:Parameters<InMemoryListingAiRepository['updateJobItem']>[0]){this.statuses.push(item.status);return super.updateJobItem(item);}
+  }
+  const repository = new TrackingRepository([source]);
+  const provider = new Provider();
+  const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'), 1, 10, provider);
+  const run = async (key: string, policy: 'reuse' | 'force' = 'reuse') => {
+    const job = await service.enqueueScoreJob({ workspaceId: source.workspaceId, platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, rubricVersion: 'jd-voc-v0.5', includeAiSuggestions: true, rescorePolicy: policy, idempotencyKey: key, requestedBy: 'test' });
+    for (let index = 0; index < 100; index += 1) { const current = await repository.getJob(source.workspaceId, job.id); if (current && ['completed', 'partial', 'failed'].includes(current.status)) return current; await new Promise((resolve) => setTimeout(resolve, 2)); }
+    assert.fail('JD-VOC job did not finish');
+  };
+  await run('jd-voc-ai-job-1');
+  assert.deepEqual(repository.statuses.slice(0,3), ['rules_scored','ai_pending','partial']);
+  assert.equal((await repository.getJdVocCurrentScore(source.workspaceId, source.productId, 'jd_voc_hybrid_ai'))?.aiReview?.status, 'completed');
+  assert.equal(provider.calls, 1);
+  await run('jd-voc-ai-job-2');
+  assert.equal(provider.calls, 1, 'same source/model/prompt/context reuses the hybrid slot');
+  provider.fail = true;
+  const failed = await run('jd-voc-ai-job-3', 'force');
+  assert.equal(failed.status, 'failed');
+  assert.equal((await repository.getJdVocCurrentScore(source.workspaceId, source.productId, 'jd_voc_rules'))?.scoreKind, 'jd_voc_rules');
+});

+ 38 - 0
test/jd-voc-contract.test.ts

@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+import { jdVocScorePayloadSchema } from '../src/modules/listing-ai/schemas.js';
+
+const fixture = JSON.parse(await readFile(new URL('./fixtures/jd-voc-score-result.json', import.meta.url), 'utf8')) as Record<string, unknown>;
+
+test('jd-voc payload accepts the versioned six-dimension fixture', () => {
+  const result = jdVocScorePayloadSchema.safeParse(fixture);
+  assert.equal(result.success, true, result.success ? '' : result.error.message);
+  if (result.success) assert.equal(result.data.dimensions.length, 6);
+});
+
+test('jd-voc payload rejects legacy rubric versions and unknown statuses', () => {
+  const wrongVersion = { ...fixture, rubricVersion: 'JD-VOC v0.5-evidence-calibrated' };
+  const wrongStatus = {
+    ...fixture,
+    dimensions: (fixture.dimensions as Array<Record<string, unknown>>).map((item, index) => index === 0 ? { ...item, status: 'unknown' } : item),
+  };
+  assert.equal(jdVocScorePayloadSchema.safeParse(wrongVersion).success, false);
+  assert.equal(jdVocScorePayloadSchema.safeParse(wrongStatus).success, false);
+});
+
+test('jd-voc payload rejects null dimensions encoded as zero and out-of-range scores', () => {
+  const dimensions = fixture.dimensions as Array<Record<string, unknown>>;
+  const nullAsScored = { ...fixture, dimensions: dimensions.map((item, index) => index === 3 ? { ...item, score: null, status: 'scored' } : item) };
+  const overMax = { ...fixture, dimensions: dimensions.map((item, index) => index === 0 ? { ...item, score: 26 } : item) };
+  const badHash = { ...fixture, sourceHash: 'not-a-source-hash' };
+  assert.equal(jdVocScorePayloadSchema.safeParse(nullAsScored).success, false);
+  assert.equal(jdVocScorePayloadSchema.safeParse(overMax).success, false);
+  assert.equal(jdVocScorePayloadSchema.safeParse(badHash).success, false);
+});
+
+test('jd-voc payload remains strict about the six unique dimension keys', () => {
+  const dimensions = fixture.dimensions as Array<Record<string, unknown>>;
+  const duplicate = { ...fixture, dimensions: dimensions.map((item, index) => index === 5 ? { ...item, key: 'search' } : item) };
+  assert.equal(jdVocScorePayloadSchema.safeParse(duplicate).success, false);
+});

+ 61 - 0
test/jd-voc-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 { 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 { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
+
+function source(overrides: Partial<ListingSourceSnapshot> = {}): ListingSourceSnapshot {
+  return {
+    id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop-1', productId: '10020722928820', sourceHash: 'a'.repeat(64),
+    title: '德玛仕 商用削皮机 304不锈钢 自动削皮', titleBrandName: '德玛仕', brand: { id: '1', name: '德玛仕' }, categoryIds: ['food'],
+    categoryContext: { names: ['削皮机'], coreTerms: ['削皮机'], requiredSpecificationNames: ['功率'], qualificationNames: [], ruleVersion: 'peeler-v1' }, itemStatus: 'on_shelf',
+    price: { jd: 1000, cost: 800 }, descriptions: { desktopHtml: '<p>适用于食堂</p>', mobileHtml: '<p>适用于食堂</p>' },
+    descriptionStructure: { observed: true, imageCount: 1, videoCount: 0, headingCount: 0, faqCandidateCount: 0 },
+    features: [{ key: 'power', value: '500W' }], attributes: [{ id: '1', name: '功率', values: ['500W'] }],
+    images: [{ url: 'https://img.test/1.jpg', order: 1, isPrimary: true, gptFlag: false }], imageAssets: { defaultImages: [], skuImages: [], whiteBackgroundImages: [] },
+    skus: [], dimensions: { length: 10, width: 10, height: 10, weight: 5 }, logistics: {}, afterService: {},
+    marketing: { adword: '自动削皮', skuShortTitles: [], sellingPoints: [{ value: '自动削皮', source: 'product_adword', fieldPath: 'adword', skuId: null }] },
+    sourceModifiedAt: null, syncedAt: '2026-09-04T08:00:00.000Z', detailStatus: 'available', ...overrides,
+  };
+}
+
+test('jd-voc rule engine emits six dimensions, stable execution identity, and separated VOC sources', () => {
+  const input = source();
+  const first = scoreJdVocRules(input, { productReviews: [{ id: 'r1', source: 'product-review', text: '削皮速度可以' }] }, { id: 'fixed', now: '2026-09-04T08:00:00.000Z' });
+  const second = scoreJdVocRules(input, { productReviews: [{ id: 'r1', source: 'product-review', text: '削皮速度可以' }] }, { id: 'fixed', now: '2026-09-04T08:00:00.000Z' });
+  assert.deepEqual(first, second);
+  assert.equal(first.rubricVersion, 'jd-voc-v0.5');
+  assert.equal(first.scoreKind, 'jd_voc_rules');
+  assert.deepEqual(first.dimensions.map((item) => item.key), ['search', 'voc', 'selling', 'facts', 'competitive', 'media']);
+  assert.equal(first.voc.productReviewCount, 1);
+  assert.equal(first.voc.categoryVocEvidenceCount, 0);
+  assert.equal(first.executionKey, 'jd-voc-v0.5:' + first.inputFingerprint);
+});
+
+test('missing VOC and competitors remain unknown/partial instead of zero', () => {
+  const result = scoreJdVocRules(source({ detailStatus: 'empty', title: null, features: [], attributes: [], images: [] }));
+  assert.equal(result.dimensions.find((item) => item.key === 'voc')?.score, null);
+  assert.equal(result.dimensions.find((item) => item.key === 'competitive')?.score, null);
+  assert.equal(result.coverage.status, 'blocked');
+});
+
+test('service persists JD-VOC result idempotently in its dedicated slot', async () => {
+  const repository = new InMemoryListingAiRepository([source()]);
+  const service = new ListingAiService(repository, undefined, () => new Date('2026-09-04T08:00:00.000Z'));
+  const first = await service.scoreJdVocRules({ workspaceId: 'demashi', platform: 'jd', productId: '10020722928820' });
+  const second = await service.scoreJdVocRules({ workspaceId: 'demashi', platform: 'jd', productId: '10020722928820' });
+  assert.deepEqual(second, first);
+  assert.equal((await repository.getJdVocCurrentScore('demashi', '10020722928820', 'jd_voc_rules'))?.executionKey, first.executionKey);
+  assert.equal((await repository.getCurrentScore('demashi', '10020722928820')), null);
+});
+
+test('JD_VOC_ENABLED=false blocks new score writes while persisted reads remain available', async () => {
+  const repository = new InMemoryListingAiRepository([source()]);
+  const existing = scoreJdVocRules(source(), {}, { id:'existing', now:'2026-09-05T00:00:00.000Z' });
+  await repository.upsertJdVocCurrentScore(existing);
+  const service = new ListingAiService(repository, undefined, () => new Date(), 1, 10, undefined, undefined, false, false);
+  assert.equal((await repository.getJdVocCurrentScore('demashi', '10020722928820'))?.id, 'existing');
+  await assert.rejects(service.scoreJdVocRules({ workspaceId:'demashi', platform:'jd', productId:'10020722928820' }), (error:unknown)=>error instanceof ApiError&&error.code==='jd_voc_disabled');
+});

+ 12 - 0
test/listing-ai.overview-query.test.ts

@@ -7,6 +7,7 @@ import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositor
 import { listingOverviewQuerySchema, listingOverviewResponseSchema } from '../src/modules/listing-ai/schemas.js';
 import { LISTING_AI_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js';
 import { LISTING_DIMENSION_MAX, LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js';
+import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
 
 const WORKSPACE_ID = 'overview-test';
 const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
@@ -129,6 +130,17 @@ test('overview query schema normalizes category IDs, applies defaults, and rejec
   assert.throws(() => listingOverviewQuerySchema.parse({ titleMax: 31 }));
 });
 
+test('overview isolates JD-VOC scores by rubricVersion and scoreKind', () => {
+  const jdVoc = scoreJdVocRules(sources[1]!, {}, { id: 'jd-voc-overview', now: '2026-08-26T12:00:00.000Z' });
+  const result = queryListingOverview({ sources, scores, jdVocScores: [jdVoc], query: query({ rubricVersion: 'jd-voc-v0.5', scoreKind: 'jd_voc_rules' }), generatedAt: '2026-08-26T12:00:00.000Z' });
+  assert.deepEqual(result.items.map((item) => item.productId), ['2']);
+  assert.equal(result.items[0]?.rubricVersion, 'jd-voc-v0.5');
+  assert.equal(result.items[0]?.scoreKind, 'jd_voc_rules');
+  assert.equal(result.items[0]?.jdVocDimensions?.search.maxScore, 25);
+  assert.ok(result.items[0]?.weakestDimension);
+  assert.equal(result.summary.jdVocDimensionStats?.media.maxScore, 5);
+});
+
 test('overview service performs one source read and one current-score read without per-product queries', async () => {
   class CountingRepository extends InMemoryListingAiRepository {
     sourceReads = 0;

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

@@ -7,6 +7,8 @@ import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.j
 import { ApiError } from '../src/http/api-error.js';
 import type { ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
 import { LISTING_AI_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js';
+import { scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
+import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.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 };
@@ -94,6 +96,60 @@ test('listing API scores a frozen source, sanitizes HTML, and adopts an internal
   }
 });
 
+test('score-job scoringMode uses JD-VOC v0.5 and the approved display selector keeps legacy results queryable', async () => {
+  const repository = new InMemoryListingAiRepository([listingSource]);
+  const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'), 1, 10, undefined, undefined, true);
+  const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository, listingAiService: service });
+  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 response = await fetch(`${base}/score-jobs`, {
+      method: 'POST', headers: { 'content-type': 'application/json', 'idempotency-key': 'jd-voc-route-job-1' },
+      body: JSON.stringify({ scope: { mode: 'selected', productIds: [listingSource.productId] }, scoringMode: 'rules', rescorePolicy: 'reuse' }),
+    });
+    assert.equal(response.status, 202);
+    const job = (await response.json() as { job: { id: string; rubricVersion: string } }).job;
+    assert.equal(job.rubricVersion, 'jd-voc-v0.5');
+    for (let index = 0; index < 50; index += 1) {
+      const current = await repository.getJob(listingSource.workspaceId, job.id);
+      if (current && ['completed', 'partial', 'failed'].includes(current.status)) break;
+      await new Promise((resolve) => setTimeout(resolve, 5));
+    }
+    const jdVoc = await repository.getJdVocCurrentScore(listingSource.workspaceId, listingSource.productId, 'jd_voc_rules');
+    assert.equal(jdVoc?.rubricVersion, 'jd-voc-v0.5');
+    assert.equal(jdVoc?.dimensions.length, 6);
+    const detail = await fetch(`${base}/products/${listingSource.productId}`);
+    const detailBody = await detail.json() as { displayScoreKind: string; jdVocScore: { sourceHash: string; inputFingerprint: string }; currentScore: unknown };
+    assert.equal(detail.status, 200);
+    assert.equal(detailBody.displayScoreKind, 'jd_voc');
+    assert.equal(detailBody.jdVocScore.sourceHash, listingSource.sourceHash);
+    assert.ok(detailBody.jdVocScore.inputFingerprint);
+    const defaultScore = await fetch(`${base}/products/${listingSource.productId}/score`);
+    const defaultBody = await defaultScore.json() as { displayScoreKind: string; score: { rubricVersion: string } };
+    assert.equal(defaultBody.displayScoreKind, 'jd_voc');
+    assert.equal(defaultBody.score.rubricVersion, 'jd-voc-v0.5');
+  } finally { await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); }
+});
+
+test('JD_VOC_DISPLAY_DEFAULT=false immediately restores the legacy selector without deleting JD-VOC slots', async () => {
+  const repository = new InMemoryListingAiRepository([listingSource]);
+  await repository.upsertCurrentScore(scoreListing(listingSource, { now:'2026-09-05T00:00:00.000Z' }));
+  await repository.upsertJdVocCurrentScore(scoreJdVocRules(listingSource, {}, { now:'2026-09-05T00:00:00.000Z' }));
+  const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'), 1, 10, undefined, undefined, false, true);
+  const app = createLocalDemoApp({ dataset, corsOrigins:['http://localhost:4200'], listingAiRepository:repository, listingAiService:service });
+  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/products/${listingSource.productId}`;
+    const detail=await (await fetch(base)).json() as {displayScoreKind:string;jdVocScore:unknown;currentScore:unknown};
+    assert.equal(detail.displayScoreKind,'legacy');
+    assert.ok(detail.currentScore);
+    assert.ok(detail.jdVocScore,'new slot remains queryable during rollback');
+    const score=await (await fetch(`${base}/score`)).json() as {displayScoreKind?:string;score:{standardLabel?:string}};
+    assert.equal(score.displayScoreKind,undefined);
+    assert.equal(score.score.standardLabel,'京东五维评分 V7');
+  }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';
@@ -147,6 +203,16 @@ test('critical compliance findings prevent adopting an internal version', async
   await assert.rejects(service.adoptVersion(blockedSource.workspaceId, 'jd', version.id), (error: unknown) => error instanceof ApiError && error.code === 'listing_compliance_blocked');
 });
 
+test('JD-VOC compliance BLOCK prevents adoption even when the legacy selector has no block', async () => {
+  const blockedSource = { ...listingSource, productId: 'jd-blocked-1', sourceHash: '8'.repeat(64), title: '星星 商用冷藏展示柜 加微信购买' };
+  const repository = new InMemoryListingAiRepository([blockedSource]);
+  const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'));
+  const score = await service.scoreJdVocRules({ workspaceId: blockedSource.workspaceId, platform:'jd', productId:blockedSource.productId });
+  assert.equal(score.compliance?.gate, 'BLOCK');
+  const version = await service.createVersion({ workspaceId:blockedSource.workspaceId,platform:'jd',productId:blockedSource.productId,baseSourceHash:blockedSource.sourceHash,content:{title:blockedSource.title,sellingPoints:[],descriptionHtml:null,specifications:blockedSource.attributes,imageUrls:blockedSource.images.map((item)=>item.url)},createdBy:'test' });
+  await assert.rejects(service.adoptVersion(blockedSource.workspaceId,'jd',version.id),(error:unknown)=>error instanceof ApiError&&error.code==='listing_compliance_blocked');
+});
+
 test('score history routes are physically removed', async () => {
   const repository = new InMemoryListingAiRepository([listingSource]);
   const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository });

+ 61 - 0
test/parse-rest-managed-task-worker.test.ts

@@ -0,0 +1,61 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { ParseRestClient } from '../src/db/parse-rest.client.js';
+import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
+import {
+  ParseRestManagedTaskQueue,
+  startParseRestManagedTaskWorker,
+} from '../src/modules/managed-tasks/parse-rest-managed-task-worker.js';
+
+test('managed task queue discovers and deduplicates workspaces across both queue classes', async () => {
+  const client = {
+    async find(className: string) {
+      assert.ok([
+        VOC_PARSE_CLASSES.competitorListingRefreshRun,
+        VOC_PARSE_CLASSES.listingScoreJob,
+      ].includes(className as never));
+      return className === VOC_PARSE_CLASSES.competitorListingRefreshRun
+        ? { results: [{ workspaceId: 'workspace-b' }, { workspaceId: 'workspace-a' }] }
+        : { results: [{ workspaceId: 'workspace-a' }, { workspaceId: '' }, {}] };
+    },
+  } as unknown as ParseRestClient;
+
+  assert.deepEqual(
+    await new ParseRestManagedTaskQueue(client).listPendingWorkspaceIds(),
+    ['workspace-a', 'workspace-b'],
+  );
+});
+
+test('managed task worker resumes competitor and listing jobs and stops cleanly', async () => {
+  let queueCalls = 0;
+  const refreshCalls: string[] = [];
+  const scoreCalls: string[] = [];
+  const worker = startParseRestManagedTaskWorker({
+    queue: {
+      async listPendingWorkspaceIds() {
+        queueCalls += 1;
+        return queueCalls === 1 ? ['workspace-a'] : [];
+      },
+    },
+    processor: {
+      async resumePendingRefreshes(workspaceId, platform) {
+        refreshCalls.push(`${workspaceId}:${platform}`);
+        return 1;
+      },
+      async resumePendingJobs(workspaceId) {
+        scoreCalls.push(workspaceId);
+        return 1;
+      },
+    },
+    pollMs: 5,
+    logger: { log() {}, error() {} },
+  });
+
+  for (let attempt = 0; attempt < 50 && queueCalls === 0; attempt += 1) {
+    await new Promise((resolve) => setTimeout(resolve, 2));
+  }
+  await worker.stop();
+
+  assert.deepEqual(refreshCalls, ['workspace-a:jd']);
+  assert.deepEqual(scoreCalls, ['workspace-a']);
+});

+ 2 - 1
tsconfig.json

@@ -14,9 +14,10 @@
     "sourceMap": true,
     "declaration": false,
     "resolveJsonModule": true,
+    "allowJs": true,
     "types": ["node"]
   },
-  "include": ["src/**/*.ts", "scripts/**/*.ts", "test/**/*.ts"],
+  "include": ["src/**/*.ts", "src/**/*.js", "scripts/**/*.ts", "test/**/*.ts"],
   "exclude": ["dist", "node_modules"]
 }
 

Some files were not shown because too many files changed in this diff