Ver Fonte

feat(listing-ai): add stable overview APIs

Yi Jiarui há 3 semanas atrás
pai
commit
a1a5bb0bcb

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

@@ -328,6 +328,114 @@ export interface ListingProductQuery extends ListingProductFilter {
   sort?: 'productId' | 'score_asc' | 'score_desc' | 'updated_desc' | undefined;
 }
 
+export type ListingOverviewScoreNature = 'simulation' | 'formal_ai' | 'rule_precheck' | 'unscored';
+export type ListingOverviewSort = 'productId' | 'overallScore' | ListingDimension | 'improvementPotential' | 'scoredAt' | 'syncedAt';
+export type ListingOverviewDirection = 'asc' | 'desc';
+
+export interface ListingOverviewQuery {
+  workspaceId: string;
+  platform: ListingPlatform;
+  search?: string | undefined;
+  categoryIds?: string[] | undefined;
+  scoreNature?: ListingOverviewScoreNature | undefined;
+  minScore?: number | undefined;
+  maxScore?: number | undefined;
+  titleMin?: number | undefined;
+  titleMax?: number | undefined;
+  sellingPointsMin?: number | undefined;
+  sellingPointsMax?: number | undefined;
+  imagesMin?: number | undefined;
+  imagesMax?: number | undefined;
+  descriptionMin?: number | undefined;
+  descriptionMax?: number | undefined;
+  specificationsMin?: number | undefined;
+  specificationsMax?: number | undefined;
+  weakestDimension?: ListingDimension | undefined;
+  sort: ListingOverviewSort;
+  direction: ListingOverviewDirection;
+  limit: number;
+  cursor: string | null;
+}
+
+export interface ListingOverviewDimensionValue {
+  score: number | null;
+  maxScore: number;
+  rate: number | null;
+  gap: number | null;
+}
+
+export interface ListingOverviewRow {
+  productId: string;
+  title: string | null;
+  imageUrl: string | null;
+  categoryId: string | null;
+  categoryName: string | null;
+  categoryPath: string[];
+  categoryIds: string[];
+  itemStatusLabel: string;
+  scoreNature: ListingOverviewScoreNature;
+  scoreNatureLabel: string;
+  overallScore: number | null;
+  overallRate: number | null;
+  dimensions: Record<ListingDimension, ListingOverviewDimensionValue>;
+  weakestDimension: ListingDimension | null;
+  improvementPotential: number | null;
+  scoredAt: string | null;
+  syncedAt: string;
+}
+
+export interface ListingOverviewScoreDistributionBucket {
+  key: string;
+  label: string;
+  minScore: number;
+  maxScore: number;
+  maxScoreExclusive: boolean;
+  count: number;
+}
+
+export interface ListingOverviewDimensionStat {
+  key: ListingDimension;
+  label: string;
+  maxScore: number;
+  scoredCount: number;
+  averageScore: number | null;
+  averageRate: number | null;
+  medianScore: number | null;
+  totalGap: number | null;
+}
+
+export interface ListingOverviewCategoryFacet {
+  categoryId: string;
+  categoryName: string;
+  categoryPath: string[];
+  count: number;
+}
+
+export interface ListingOverviewScoreNatureFacet {
+  value: ListingOverviewScoreNature;
+  label: string;
+  count: number;
+}
+
+export interface ListingOverviewSummary {
+  sourceTotal: number;
+  matchedTotal: number;
+  scoredTotal: number;
+  simulationTotal: number;
+  averageScore: number | null;
+  medianScore: number | null;
+  scoreDistribution: ListingOverviewScoreDistributionBucket[];
+  dimensionStats: Record<ListingDimension, ListingOverviewDimensionStat>;
+  categoryFacets: ListingOverviewCategoryFacet[];
+  scoreNatureFacets: ListingOverviewScoreNatureFacet[];
+  snapshotId: string;
+  generatedAt: string;
+}
+
+export interface ListingOverviewResponse extends ListingCursorPage<ListingOverviewRow> {
+  summary: ListingOverviewSummary;
+}
+
 export interface ListingAiRepository {
   upsertSources(sources: ListingSourceSnapshot[]): Promise<void>;
   listAllSources(workspaceId: string, platform: ListingPlatform): Promise<ListingSourceSnapshot[]>;

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

@@ -4,6 +4,8 @@ import type { FmodeAiClient } from '../ai-gateway/client.js';
 import type {
   ListingAiRepository,
   ListingCatalogSummary,
+  ListingOverviewQuery,
+  ListingOverviewResponse,
   ListingProductFilter,
   ListingScoreJob,
   ListingScoreJobItem,
@@ -12,6 +14,7 @@ import type {
   ListingSourceSnapshot,
   ListingVersion,
 } from './domain.js';
+import { queryListingOverview } from './query/listing-overview.query.js';
 import { canonicalHash, LISTING_RUBRIC_VERSION, listingCoverage, scoreListing } from './scoring/rule-engine.js';
 import {
   composeListingAiScore,
@@ -110,6 +113,14 @@ export class ListingAiService {
     return this.repository.catalogSummary(workspaceId, platform);
   }
 
+  async overview(query: ListingOverviewQuery): Promise<ListingOverviewResponse> {
+    const [sources, scores] = await Promise.all([
+      this.repository.listAllSources(query.workspaceId, query.platform),
+      this.repository.listCurrentScores(query.workspaceId),
+    ]);
+    return queryListingOverview({ sources, scores, query, generatedAt: this.now().toISOString() });
+  }
+
   async enqueueScoreJob(input: {
     workspaceId: string;
     platform: 'jd';

+ 250 - 0
src/modules/listing-ai/query/listing-overview.query.ts

@@ -0,0 +1,250 @@
+import type {
+  ListingDimension,
+  ListingOverviewCategoryFacet,
+  ListingOverviewDimensionStat,
+  ListingOverviewDimensionValue,
+  ListingOverviewQuery,
+  ListingOverviewResponse,
+  ListingOverviewRow,
+  ListingOverviewScoreNature,
+  ListingScoreResult,
+  ListingSourceSnapshot,
+} 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 LISTING_SIMULATION_MODEL = 'listing-v7-demo-simulation';
+
+const DIMENSION_LABELS: Readonly<Record<ListingDimension, string>> = {
+  title: '商品标题',
+  selling_points: '核心卖点',
+  images: '图片资产',
+  description: '商品详情',
+  specifications: '规格与履约',
+};
+
+const SCORE_NATURE_LABELS: Readonly<Record<ListingOverviewScoreNature, string>> = {
+  simulation: '模拟评分',
+  formal_ai: '正式评分',
+  rule_precheck: '自动检查',
+  unscored: '未评分',
+};
+
+const SCORE_BUCKETS = [
+  { key: 'below_60', label: '60 分以下', minScore: 0, maxScore: 60, maxScoreExclusive: true },
+  { key: '60_69', label: '60–69 分', minScore: 60, maxScore: 70, maxScoreExclusive: true },
+  { key: '70_79', label: '70–79 分', minScore: 70, maxScore: 80, maxScoreExclusive: true },
+  { key: '80_89', label: '80–89 分', minScore: 80, maxScore: 90, maxScoreExclusive: true },
+  { key: '90_100', label: '90–100 分', minScore: 90, maxScore: 100, maxScoreExclusive: false },
+] as const;
+
+function round(value: number, digits = 2): number {
+  const multiplier = 10 ** digits;
+  return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
+}
+
+function median(values: number[]): number | null {
+  if (!values.length) return null;
+  const sorted = [...values].sort((left, right) => left - right);
+  const middle = Math.floor(sorted.length / 2);
+  return sorted.length % 2 ? sorted[middle]! : round((sorted[middle - 1]! + sorted[middle]!) / 2);
+}
+
+function scoreNature(score: ListingScoreResult | null): ListingOverviewScoreNature {
+  if (!score) return 'unscored';
+  if (score.model === LISTING_SIMULATION_MODEL) return 'simulation';
+  return score.scoreKind === 'hybrid_ai' ? 'formal_ai' : 'rule_precheck';
+}
+
+function dimensionValues(score: ListingScoreResult | null): Record<ListingDimension, ListingOverviewDimensionValue> {
+  return Object.fromEntries(LISTING_OVERVIEW_DIMENSIONS.map((key) => {
+    const maximum = LISTING_DIMENSION_MAX[key];
+    const value = score?.dimensions.find((dimension) => dimension.dimension === 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<ListingDimension, 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);
+  const id = source.categoryContext?.categoryId?.trim() || source.categoryIds.at(-1)?.trim() || null;
+  const name = source.categoryContext?.displayName?.trim() || path.at(-1) || source.categoryContext?.names.at(-1)?.trim() || id;
+  return { id, name, path };
+}
+
+function overviewRow(source: ListingSourceSnapshot, scores: ListingScoreResult[]): ListingOverviewRow {
+  const score = selectCurrentListingScore(scores, source.sourceHash).displayScore;
+  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 sourceCategory = category(source);
+  return {
+    productId: source.productId,
+    title: source.title,
+    imageUrl: source.images.find((image) => image.isPrimary)?.url ?? source.images[0]?.url ?? null,
+    categoryId: sourceCategory.id,
+    categoryName: sourceCategory.name,
+    categoryPath: sourceCategory.path,
+    categoryIds: [...source.categoryIds],
+    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),
+    dimensions,
+    weakestDimension,
+    improvementPotential,
+    scoredAt: score?.createdAt ?? null,
+    syncedAt: source.syncedAt,
+  };
+}
+
+function matchesRange(value: number | null, minimum: number | undefined, maximum: number | undefined): boolean {
+  if (minimum === undefined && maximum === undefined) return true;
+  if (value === null) return false;
+  return (minimum === undefined || value >= minimum) && (maximum === undefined || value <= maximum);
+}
+
+function matches(row: ListingOverviewRow, query: ListingOverviewQuery, normalizedSearch: string): boolean {
+  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 (!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;
+  if (!matchesRange(row.dimensions.images.score, query.imagesMin, query.imagesMax)) return false;
+  if (!matchesRange(row.dimensions.description.score, query.descriptionMin, query.descriptionMax)) return false;
+  if (!matchesRange(row.dimensions.specifications.score, query.specificationsMin, query.specificationsMax)) return false;
+  return !query.weakestDimension || row.weakestDimension === query.weakestDimension;
+}
+
+function categoryFacets(rows: ListingOverviewRow[]): ListingOverviewCategoryFacet[] {
+  const facets = new Map<string, ListingOverviewCategoryFacet>();
+  for (const row of rows) {
+    if (!row.categoryId) continue;
+    const existing = facets.get(row.categoryId);
+    if (existing) existing.count += 1;
+    else facets.set(row.categoryId, {
+      categoryId: row.categoryId,
+      categoryName: row.categoryName ?? row.categoryId,
+      categoryPath: row.categoryPath,
+      count: 1,
+    });
+  }
+  return [...facets.values()].sort((left, right) => right.count - left.count || left.categoryId.localeCompare(right.categoryId));
+}
+
+function dimensionStats(rows: ListingOverviewRow[]): Record<ListingDimension, ListingOverviewDimensionStat> {
+  return Object.fromEntries(LISTING_OVERVIEW_DIMENSIONS.map((key) => {
+    const values = rows.map((row) => row.dimensions[key]).filter((value): value is ListingOverviewDimensionValue & { score: number; rate: number; gap: number } => value.score !== null && value.rate !== null && value.gap !== null);
+    return [key, {
+      key,
+      label: DIMENSION_LABELS[key],
+      maxScore: LISTING_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<ListingDimension, 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;
+}
+
+export function queryListingOverview(input: {
+  sources: ListingSourceSnapshot[];
+  scores: ListingScoreResult[];
+  query: ListingOverviewQuery;
+  generatedAt: string;
+}): ListingOverviewResponse {
+  const scoresByProduct = new Map<string, ListingScoreResult[]>();
+  for (const score of input.scores) {
+    if (score.workspaceId !== input.query.workspaceId) continue;
+    const current = scoresByProduct.get(score.productId) ?? [];
+    current.push(score);
+    scoresByProduct.set(score.productId, current);
+  }
+  const sourceRows = input.sources
+    .filter((source) => source.workspaceId === input.query.workspaceId && source.platform === input.query.platform)
+    .map((source) => overviewRow(source, scoresByProduct.get(source.productId) ?? []));
+  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));
+  const queryHash = stableListingHash({
+    workspaceId: input.query.workspaceId,
+    platform: input.query.platform,
+    search: normalizedSearch,
+    categoryIds: [...new Set(input.query.categoryIds ?? [])].sort(),
+    scoreNature: input.query.scoreNature,
+    minScore: input.query.minScore,
+    maxScore: input.query.maxScore,
+    titleMin: input.query.titleMin,
+    titleMax: input.query.titleMax,
+    sellingPointsMin: input.query.sellingPointsMin,
+    sellingPointsMax: input.query.sellingPointsMax,
+    imagesMin: input.query.imagesMin,
+    imagesMax: input.query.imagesMax,
+    descriptionMin: input.query.descriptionMin,
+    descriptionMax: input.query.descriptionMax,
+    specificationsMin: input.query.specificationsMin,
+    specificationsMax: input.query.specificationsMax,
+    weakestDimension: input.query.weakestDimension,
+    sort: input.query.sort,
+    direction: input.query.direction,
+  });
+  const page = stableListingPage({
+    items: matchedRows,
+    limit: input.query.limit,
+    cursor: input.query.cursor,
+    snapshotId,
+    queryHash,
+    sortKey: input.query.sort,
+    direction: input.query.direction,
+    productId: (row) => row.productId,
+    sortValue: (row) => sortValue(row, input.query.sort),
+  });
+  const numericScores = matchedRows.map((row) => row.overallScore).filter((value): value is number => value !== null);
+  const natureFacets = (Object.keys(SCORE_NATURE_LABELS) as ListingOverviewScoreNature[]).map((value) => ({
+    value,
+    label: SCORE_NATURE_LABELS[value],
+    count: sourceRows.filter((row) => row.scoreNature === value).length,
+  }));
+  return {
+    ...page,
+    summary: {
+      sourceTotal: sourceRows.length,
+      matchedTotal: matchedRows.length,
+      scoredTotal: numericScores.length,
+      simulationTotal: matchedRows.filter((row) => row.scoreNature === 'simulation').length,
+      averageScore: numericScores.length ? round(numericScores.reduce((sum, value) => sum + value, 0) / numericScores.length) : null,
+      medianScore: median(numericScores),
+      scoreDistribution: SCORE_BUCKETS.map((bucket) => ({
+        ...bucket,
+        count: numericScores.filter((score) => score >= bucket.minScore && (bucket.maxScoreExclusive ? score < bucket.maxScore : score <= bucket.maxScore)).length,
+      })),
+      dimensionStats: dimensionStats(matchedRows),
+      categoryFacets: categoryFacets(sourceRows),
+      scoreNatureFacets: natureFacets,
+      snapshotId,
+      generatedAt: input.generatedAt,
+    },
+  };
+}

+ 114 - 0
src/modules/listing-ai/query/stable-listing-page.ts

@@ -0,0 +1,114 @@
+import { createHash } from 'node:crypto';
+import { ApiError } from '../../../http/api-error.js';
+import type { ListingCursorPage } from '../domain.js';
+
+export type StableListingSortDirection = 'asc' | 'desc';
+export type StableListingSortValue = number | string | null;
+
+interface StableListingCursor {
+  version: 1;
+  snapshotId: string;
+  queryHash: string;
+  sortKey: string;
+  direction: StableListingSortDirection;
+  sortValue: StableListingSortValue;
+  productId: string;
+}
+
+function canonicalize(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(canonicalize);
+  if (value && typeof value === 'object') {
+    return Object.fromEntries(
+      Object.entries(value)
+        .filter(([, child]) => child !== undefined)
+        .sort(([left], [right]) => left.localeCompare(right))
+        .map(([key, child]) => [key, canonicalize(child)]),
+    );
+  }
+  return value;
+}
+
+export function stableListingHash(value: unknown): string {
+  return createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('base64url');
+}
+
+function encodeCursor(cursor: StableListingCursor): string {
+  return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
+}
+
+function decodeCursor(value: string): StableListingCursor {
+  try {
+    if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error('invalid base64url');
+    const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Partial<StableListingCursor>;
+    const sortValueValid = decoded.sortValue === null || typeof decoded.sortValue === 'string' || (typeof decoded.sortValue === 'number' && Number.isFinite(decoded.sortValue));
+    if (decoded.version !== 1 || typeof decoded.snapshotId !== 'string' || typeof decoded.queryHash !== 'string'
+      || typeof decoded.sortKey !== 'string' || !['asc', 'desc'].includes(decoded.direction ?? '')
+      || !sortValueValid || typeof decoded.productId !== 'string') {
+      throw new Error('invalid cursor shape');
+    }
+    return decoded as StableListingCursor;
+  } catch {
+    throw new ApiError(400, 'invalid_cursor');
+  }
+}
+
+function compareValues(left: StableListingSortValue, right: StableListingSortValue, direction: StableListingSortDirection): number {
+  if (left === null && right === null) return 0;
+  if (left === null) return 1;
+  if (right === null) return -1;
+  const compared = typeof left === 'number' && typeof right === 'number'
+    ? left - right
+    : String(left) < String(right) ? -1 : String(left) > String(right) ? 1 : 0;
+  return direction === 'asc' ? compared : -compared;
+}
+
+function sameValue(left: StableListingSortValue, right: StableListingSortValue): boolean {
+  return left === right;
+}
+
+export function stableListingPage<T>(input: {
+  items: T[];
+  limit: number;
+  cursor: string | null;
+  snapshotId: string;
+  queryHash: string;
+  sortKey: string;
+  direction: StableListingSortDirection;
+  productId: (item: T) => string;
+  sortValue: (item: T) => StableListingSortValue;
+}): ListingCursorPage<T> {
+  const sorted = [...input.items].sort((left, right) => (
+    compareValues(input.sortValue(left), input.sortValue(right), input.direction)
+      || (input.productId(left) < input.productId(right) ? -1 : input.productId(left) > input.productId(right) ? 1 : 0)
+  ));
+
+  let start = 0;
+  if (input.cursor) {
+    const cursor = decodeCursor(input.cursor);
+    if (cursor.snapshotId !== input.snapshotId || cursor.queryHash !== input.queryHash
+      || cursor.sortKey !== input.sortKey || cursor.direction !== input.direction) {
+      throw new ApiError(409, 'listing_overview_cursor_stale');
+    }
+    const index = sorted.findIndex((item) => input.productId(item) === cursor.productId);
+    if (index < 0 || !sameValue(input.sortValue(sorted[index]!), cursor.sortValue)) {
+      throw new ApiError(409, 'listing_overview_cursor_stale');
+    }
+    start = index + 1;
+  }
+
+  const items = sorted.slice(start, start + input.limit);
+  const hasMore = start + input.limit < sorted.length;
+  const last = items.at(-1);
+  return {
+    items,
+    nextCursor: hasMore && last ? encodeCursor({
+      version: 1,
+      snapshotId: input.snapshotId,
+      queryHash: input.queryHash,
+      sortKey: input.sortKey,
+      direction: input.direction,
+      sortValue: input.sortValue(last),
+      productId: input.productId(last),
+    }) : null,
+  };
+}

+ 44 - 6
src/modules/listing-ai/repositories/in-memory-listing-ai.repository.ts

@@ -14,6 +14,7 @@ import type {
 } from '../domain.js';
 import { listingCoverage } from '../scoring/rule-engine.js';
 import { isListingV7Score, listingAiScoreStatus, listingProductScoreStatus, selectCurrentListingScore } from '../scoring/score-status.js';
+import { stableListingHash, stableListingPage, type StableListingSortDirection, type StableListingSortValue } from '../query/stable-listing-page.js';
 
 function cursorEncode(id: string): string {
   return Buffer.from(JSON.stringify({ id }), 'utf8').toString('base64url');
@@ -76,6 +77,20 @@ export class InMemoryListingAiRepository implements ListingAiRepository {
 
   async listProducts(query: ListingProductQuery): Promise<ListingCursorPage<ListingProductSummary>> {
     let items = (await this.listAllSources(query.workspaceId, query.platform)).map((source) => this.summary(source));
+    const snapshotId = stableListingHash(items.map((item) => ({
+      productId: item.productId,
+      sourceHash: item.sourceHash,
+      title: item.title,
+      categoryIds: item.categoryIds,
+      itemStatus: item.itemStatus,
+      coverageStatus: item.coverage.status,
+      scoreStatus: item.scoreStatus,
+      aiScoreStatus: item.aiScoreStatus,
+      overallScore: item.latestScore?.overallScore ?? null,
+      scoreId: item.latestScore?.id ?? null,
+      scoreCreatedAt: item.latestScore?.createdAt ?? null,
+      syncedAt: item.syncedAt,
+    })).sort((left, right) => left.productId < right.productId ? -1 : left.productId > right.productId ? 1 : 0));
     const search = query.search?.trim().toLocaleLowerCase() ?? '';
     if (search) items = items.filter((item) => `${item.productId} ${item.title ?? ''}`.toLocaleLowerCase().includes(search));
     if (query.categoryId) items = items.filter((item) => item.categoryIds.includes(query.categoryId!));
@@ -87,13 +102,36 @@ export class InMemoryListingAiRepository implements ListingAiRepository {
     if (query.aiScoreStatus) items = items.filter((item) => item.aiScoreStatus === query.aiScoreStatus);
     if (query.minScore !== undefined) items = items.filter((item) => (item.latestScore?.overallScore ?? -1) >= query.minScore!);
     if (query.maxScore !== undefined) items = items.filter((item) => (item.latestScore?.overallScore ?? 101) <= query.maxScore!);
-    items.sort((left, right) => {
-      if (query.sort === 'score_asc') return (left.latestScore?.overallScore ?? 101) - (right.latestScore?.overallScore ?? 101) || left.productId.localeCompare(right.productId);
-      if (query.sort === 'score_desc') return (right.latestScore?.overallScore ?? -1) - (left.latestScore?.overallScore ?? -1) || left.productId.localeCompare(right.productId);
-      if (query.sort === 'updated_desc') return right.syncedAt.localeCompare(left.syncedAt) || left.productId.localeCompare(right.productId);
-      return left.productId.localeCompare(right.productId);
+    const sort = query.sort ?? 'productId';
+    const sortKey = sort.startsWith('score_') ? 'overallScore' : sort === 'updated_desc' ? 'syncedAt' : 'productId';
+    const direction: StableListingSortDirection = sort === 'score_desc' || sort === 'updated_desc' ? 'desc' : 'asc';
+    const sortValue = (item: ListingProductSummary): StableListingSortValue => sortKey === 'overallScore'
+      ? item.latestScore?.overallScore ?? null
+      : sortKey === 'syncedAt' ? item.syncedAt : item.productId;
+    const queryHash = stableListingHash({
+      workspaceId: query.workspaceId,
+      platform: query.platform,
+      search,
+      categoryId: query.categoryId,
+      itemStatus: query.itemStatus,
+      scoreStatus: query.scoreStatus,
+      aiScoreStatus: query.aiScoreStatus,
+      coverageStatus: query.coverageStatus,
+      minScore: query.minScore,
+      maxScore: query.maxScore,
+      sort,
+    });
+    return stableListingPage({
+      items,
+      limit: query.limit,
+      cursor: query.cursor,
+      snapshotId,
+      queryHash,
+      sortKey,
+      direction,
+      productId: (item) => item.productId,
+      sortValue,
     });
-    return page(items, query.limit, query.cursor, (item) => item.productId);
   }
 
   async catalogSummary(workspaceId: string, platform: 'jd'): Promise<ListingCatalogSummary> {

+ 2 - 10
src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.ts

@@ -45,17 +45,9 @@ export class ParseRestListingAiRepository implements ListingAiRepository {
   }
   async listAllSources(workspaceId:string,platform:'jd'):Promise<ListingSourceSnapshot[]>{const cohort=await this.catalogCohortActive(workspaceId,platform);const where=cohort?{workspaceId,platform,isCurrent:true,catalogIncluded:true,catalogCohort:ACTIVE_CATALOG_COHORT}:{workspaceId,platform};const rows=await this.client.findAll<Stored<ListingSourceSnapshot>&{platform:string}>(VOC_PARSE_CLASSES.listingSourceSnapshot,where);const latest=new Map<string,(typeof rows)[number]>();for(const row of rows){const current=latest.get(row.payload.productId);if(!current||row.payload.syncedAt>current.payload.syncedAt)latest.set(row.payload.productId,row);}return[...latest.values()].map((row)=>row.payload);}
   async listProducts(query:ListingProductQuery){
-    const cohort=await this.catalogCohortActive(query.workspaceId,query.platform);const cursorProductId=decode(query.cursor);
-    const where:Record<string,unknown>={workspaceId:query.workspaceId,platform:query.platform,isCurrent:true,...(cohort?{catalogIncluded:true,catalogCohort:ACTIVE_CATALOG_COHORT}:{})};
-    if(cursorProductId)where.productId={$gt:cursorProductId};if(query.categoryId)where.categoryIds=query.categoryId;if(query.itemStatus)where.itemStatus=query.itemStatus;if(query.coverageStatus)where.coverageStatus=query.coverageStatus;
-    if(query.search){const pattern=query.search.trim().replace(/[.*+?^${}()|[\]\\]/g,'\\$&');where.$or=[{productId:{$regex:pattern,$options:'i'}},{title:{$regex:pattern,$options:'i'}}];}
-    const fetchLimit=Math.min(100,Math.max(query.limit+1,query.limit*4));const rows=await this.client.find<Stored<ListingSourceSnapshot>>(VOC_PARSE_CLASSES.listingSourceSnapshot,{where,order:'productId',limit:fetchLimit});
-    const memory=new InMemoryListingAiRepository(rows.results.map((row)=>row.payload));const productIds=rows.results.map((row)=>row.payload.productId);
-    if(productIds.length){const scores=await this.client.findAll<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingCurrentScore,{workspaceId:query.workspaceId,productId:{$in:productIds}});for(const row of scores){if(isListingV7Score(row.payload))await memory.upsertCurrentScore(row.payload);}}
-    const result=await memory.listProducts({...query,cursor:null,limit:query.limit});const last=result.items.at(-1)?.productId??rows.results.slice(0,query.limit).at(-1)?.payload.productId;
-    return{items:result.items,nextCursor:rows.results.length>query.limit&&last?encode(last):null};
+    return this.listProductsInMemory(query);
   }
-  private async listProductsInMemory(query:ListingProductQuery){const memory=new InMemoryListingAiRepository(await this.listAllSources(query.workspaceId,query.platform));const scores=await this.client.findAll<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingCurrentScore,{workspaceId:query.workspaceId});for(const row of scores)await memory.upsertCurrentScore(row.payload);return memory.listProducts(query);}
+  private async listProductsInMemory(query:ListingProductQuery){const memory=new InMemoryListingAiRepository(await this.listAllSources(query.workspaceId,query.platform));const scores=await this.client.findAll<Stored<ListingScoreResult>>(VOC_PARSE_CLASSES.listingCurrentScore,{workspaceId:query.workspaceId});for(const row of scores){if(isListingV7Score(row.payload))await memory.upsertCurrentScore(row.payload);}return memory.listProducts(query);}
   async catalogSummary(workspaceId:string,platform:'jd'):Promise<ListingCatalogSummary>{
     const cohort=await this.catalogCohortActive(workspaceId,platform);const where={workspaceId,platform,isCurrent:true,...(cohort?{catalogIncluded:true,catalogCohort:ACTIVE_CATALOG_COHORT}:{})};
     type SourceProjection=ParseObject&{productId:string;sourceHash:string;coverageStatus?:string;observedAt?:unknown};

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

@@ -15,6 +15,8 @@ import {
   listingProductPresentationSchema,
   listingScorePresentationSchema,
   listingPageQuerySchema,
+  listingOverviewQuerySchema,
+  listingOverviewResponseSchema,
   listingProductQuerySchema,
   listingWorkspaceQuerySchema,
   scoreJobRequestSchema,
@@ -29,6 +31,16 @@ export function createListingAiRouter(dependencies: {
   const router = Router();
   const isV7Job = (rubricVersion: string) => rubricVersion === LISTING_RUBRIC_VERSION || rubricVersion === LISTING_AI_RUBRIC_VERSION;
 
+  router.get('/overview', async (request, response, next) => {
+    try {
+      const query = listingOverviewQuerySchema.parse(request.query);
+      const workspaceId = query.workspaceId ?? dependencies.defaultWorkspaceId;
+      await dependencies.access.require(request, workspaceId, 'workspace:read');
+      const overview = await dependencies.service.overview({ ...query, workspaceId, cursor: query.cursor ?? null });
+      response.json(listingOverviewResponseSchema.parse(overview));
+    } catch (error) { next(error); }
+  });
+
   router.get('/products', async (request, response, next) => {
     try {
       const query = listingProductQuerySchema.parse(request.query);

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

@@ -1,5 +1,8 @@
 import { z } from 'zod';
 
+const listingDimensionSchema = z.enum(['title', 'selling_points', 'images', 'description', 'specifications']);
+const listingOverviewScoreNatureSchema = z.enum(['simulation', 'formal_ai', 'rule_precheck', 'unscored']);
+
 export const listingWorkspaceQuerySchema = z.object({
   workspaceId: z.string().min(1).optional(),
   platform: z.literal('jd').default('jd'),
@@ -26,6 +29,46 @@ export const listingProductQuerySchema = listingPageQuerySchema.extend({
   sort: z.enum(['productId', 'score_asc', 'score_desc', 'updated_desc']).default('productId'),
 });
 
+const categoryIdsSchema = z.preprocess((value) => {
+  const values = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : value;
+  return Array.isArray(values) ? values.flatMap((item) => typeof item === 'string' ? item.split(',') : item) : values;
+}, z.array(z.string().trim().min(1).max(100)).max(50).transform((values) => [...new Set(values)]));
+
+export const listingOverviewQuerySchema = listingPageQuerySchema.extend({
+  search: z.string().max(200).optional(),
+  categoryIds: categoryIdsSchema.optional(),
+  scoreNature: listingOverviewScoreNatureSchema.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(),
+  titleMax: z.coerce.number().min(0).max(30).optional(),
+  sellingPointsMin: z.coerce.number().min(0).max(25).optional(),
+  sellingPointsMax: z.coerce.number().min(0).max(25).optional(),
+  imagesMin: z.coerce.number().min(0).max(20).optional(),
+  imagesMax: z.coerce.number().min(0).max(20).optional(),
+  descriptionMin: z.coerce.number().min(0).max(15).optional(),
+  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'),
+  direction: z.enum(['asc', 'desc']).default('desc'),
+}).superRefine((query, context) => {
+  const ranges: Array<[number | undefined, number | undefined, string]> = [
+    [query.minScore, query.maxScore, 'minScore'],
+    [query.titleMin, query.titleMax, 'titleMin'],
+    [query.sellingPointsMin, query.sellingPointsMax, 'sellingPointsMin'],
+    [query.imagesMin, query.imagesMax, 'imagesMin'],
+    [query.descriptionMin, query.descriptionMax, 'descriptionMin'],
+    [query.specificationsMin, query.specificationsMax, 'specificationsMin'],
+  ];
+  for (const [minimum, maximum, path] of ranges) {
+    if (minimum !== undefined && maximum !== undefined && minimum > maximum) {
+      context.addIssue({ code: 'custom', path: [path], message: 'minimum_must_not_exceed_maximum' });
+    }
+  }
+});
+
 export const scoreJobRequestSchema = z.object({
   workspaceId: z.string().min(1).optional(),
   platform: z.literal('jd').default('jd'),
@@ -69,3 +112,76 @@ export const listingProductPresentationSchema = z.object({
   specificationCount: z.number().nullable(), dataStatusLabel: z.string(), scoreStatusLabel: z.string(), aiStatusLabel: z.string(), score: z.number().nullable(),
   scoreText: z.string(), methodLabel: z.string(), syncedAt: z.string(),
 });
+
+const listingOverviewDimensionValueSchema = z.object({
+  score: z.number().nullable(),
+  maxScore: z.number().positive(),
+  rate: z.number().min(0).max(1).nullable(),
+  gap: z.number().min(0).nullable(),
+});
+
+const listingOverviewDimensionsSchema = z.object({
+  title: listingOverviewDimensionValueSchema,
+  selling_points: listingOverviewDimensionValueSchema,
+  images: listingOverviewDimensionValueSchema,
+  description: listingOverviewDimensionValueSchema,
+  specifications: listingOverviewDimensionValueSchema,
+});
+
+export const listingOverviewRowSchema = z.object({
+  productId: z.string(),
+  title: z.string().nullable(),
+  imageUrl: z.string().nullable(),
+  categoryId: z.string().nullable(),
+  categoryName: z.string().nullable(),
+  categoryPath: z.array(z.string()),
+  categoryIds: z.array(z.string()),
+  itemStatusLabel: z.string(),
+  scoreNature: listingOverviewScoreNatureSchema,
+  scoreNatureLabel: z.string(),
+  overallScore: z.number().min(0).max(100).nullable(),
+  overallRate: z.number().min(0).max(1).nullable(),
+  dimensions: listingOverviewDimensionsSchema,
+  weakestDimension: listingDimensionSchema.nullable(),
+  improvementPotential: z.number().min(0).max(100).nullable(),
+  scoredAt: z.string().nullable(),
+  syncedAt: z.string(),
+});
+
+const listingOverviewDimensionStatSchema = z.object({
+  key: listingDimensionSchema,
+  label: z.string(),
+  maxScore: z.number().positive(),
+  scoredCount: z.number().int().nonnegative(),
+  averageScore: z.number().nullable(),
+  averageRate: z.number().min(0).max(1).nullable(),
+  medianScore: z.number().nullable(),
+  totalGap: z.number().nonnegative().nullable(),
+});
+
+export const listingOverviewResponseSchema = z.object({
+  items: z.array(listingOverviewRowSchema),
+  nextCursor: z.string().nullable(),
+  summary: z.object({
+    sourceTotal: z.number().int().nonnegative(),
+    matchedTotal: z.number().int().nonnegative(),
+    scoredTotal: z.number().int().nonnegative(),
+    simulationTotal: z.number().int().nonnegative(),
+    averageScore: z.number().min(0).max(100).nullable(),
+    medianScore: z.number().min(0).max(100).nullable(),
+    scoreDistribution: z.array(z.object({
+      key: z.string(), label: z.string(), minScore: z.number(), maxScore: z.number(), maxScoreExclusive: z.boolean(), count: z.number().int().nonnegative(),
+    })),
+    dimensionStats: z.object({
+      title: listingOverviewDimensionStatSchema,
+      selling_points: listingOverviewDimensionStatSchema,
+      images: listingOverviewDimensionStatSchema,
+      description: listingOverviewDimensionStatSchema,
+      specifications: listingOverviewDimensionStatSchema,
+    }),
+    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(),
+    generatedAt: z.string(),
+  }),
+});

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

@@ -0,0 +1,152 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { ListingDimension, ListingOverviewQuery, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js';
+import { LISTING_SIMULATION_MODEL, queryListingOverview } from '../src/modules/listing-ai/query/listing-overview.query.js';
+import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
+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';
+
+const WORKSPACE_ID = 'overview-test';
+const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
+
+function source(productId: string, categoryId: string, syncedAt = '2026-08-26T00:00:00.000Z'): ListingSourceSnapshot {
+  return {
+    id: `source-${productId}`, workspaceId: WORKSPACE_ID, platform: 'jd', shopId: 'shop', productId,
+    sourceHash: productId.padStart(64, '0'), title: `测试商品 ${productId}`, titleBrandName: '测试品牌', brand: { id: 'brand', name: '测试品牌' },
+    categoryIds: ['root', categoryId], categoryContext: { names: ['商用设备', `类目 ${categoryId}`], categoryId, pathNames: ['商用设备', `类目 ${categoryId}`], displayName: `类目 ${categoryId}`, coreTerms: [], requiredSpecificationNames: [], qualificationNames: [], ruleVersion: 'test' },
+    itemStatus: '1', price: { jd: 100, cost: null }, descriptions: { desktopHtml: '<p>详情</p>', mobileHtml: null }, features: [], attributes: [],
+    images: [{ url: `https://img.test/${productId}.jpg`, order: 1, isPrimary: true, gptFlag: null }], skus: [],
+    dimensions: { length: null, width: null, height: null, weight: null }, logistics: {}, afterService: {}, sourceModifiedAt: null, syncedAt, detailStatus: 'available',
+  };
+}
+
+function score(input: {
+  source: ListingSourceSnapshot;
+  overall: number;
+  values: Record<ListingDimension, number>;
+  nature: 'simulation' | 'formal_ai' | 'rule_precheck';
+}): ListingScoreResult {
+  const hybrid = input.nature !== 'rule_precheck';
+  return {
+    id: `score-${input.source.productId}-${input.nature}`, workspaceId: WORKSPACE_ID, productId: input.source.productId, sourceHash: input.source.sourceHash,
+    rubricVersion: hybrid ? LISTING_AI_RUBRIC_VERSION : LISTING_RUBRIC_VERSION, overallScore: input.overall,
+    coverage: { percent: 100, missing: [], status: 'eligible' },
+    dimensions: DIMENSIONS.map((dimension) => ({ dimension, score: input.values[dimension], maxScore: LISTING_DIMENSION_MAX[dimension], coverage: 100, status: 'scored', evidence: [], suggestions: [] })),
+    aiStatus: hybrid ? 'completed' : 'not_requested', aiSuggestions: [], aiCandidate: null,
+    model: input.nature === 'simulation' ? LISTING_SIMULATION_MODEL : input.nature === 'formal_ai' ? 'production-model' : null,
+    promptVersion: hybrid ? 'test-prompt' : null, scoreKind: hybrid ? 'hybrid_ai' : 'rules', createdAt: `2026-08-26T0${input.source.productId}:00:00.000Z`,
+  };
+}
+
+const sources = [source('1', 'cat-a'), source('2', 'cat-a'), source('3', 'cat-b'), source('4', 'cat-c')];
+const scores = [
+  score({ source: sources[0]!, overall: 74, nature: 'simulation', values: { title: 24, selling_points: 20, images: 10, description: 12, specifications: 8 } }),
+  score({ source: sources[1]!, overall: 90, nature: 'formal_ai', values: { title: 27, selling_points: 22.5, images: 18, description: 13.5, specifications: 9 } }),
+  score({ source: sources[2]!, overall: 60, nature: 'rule_precheck', values: { title: 18, selling_points: 15, images: 12, description: 9, specifications: 6 } }),
+];
+
+function query(patch: Partial<ListingOverviewQuery> = {}): ListingOverviewQuery {
+  return { workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'improvementPotential', direction: 'desc', limit: 25, cursor: null, ...patch };
+}
+
+test('overview rows calculate normalized dimensions, weakest dimension, improvement potential, and score nature', () => {
+  const result = queryListingOverview({ sources, scores, query: query(), generatedAt: '2026-08-26T12:00:00.000Z' });
+  const simulation = result.items.find((item) => item.productId === '1')!;
+  assert.equal(simulation.scoreNature, 'simulation');
+  assert.equal(simulation.scoreNatureLabel, '模拟评分');
+  assert.equal(simulation.overallRate, 0.74);
+  assert.deepEqual(simulation.dimensions.images, { score: 10, maxScore: 20, rate: 0.5, gap: 10 });
+  assert.equal(simulation.weakestDimension, 'images', 'weakest dimension uses rate instead of raw points');
+  assert.equal(simulation.improvementPotential, 26);
+  assert.equal(simulation.categoryId, 'cat-a');
+  assert.deepEqual(simulation.categoryPath, ['商用设备', '类目 cat-a']);
+
+  const unscored = result.items.find((item) => item.productId === '4')!;
+  assert.equal(unscored.scoreNature, 'unscored');
+  assert.equal(unscored.overallScore, null);
+  assert.equal(unscored.dimensions.title.score, null);
+  assert.equal(unscored.weakestDimension, null);
+  assert.equal(unscored.improvementPotential, null, 'missing dimensions are not treated as zero points');
+  const unscoredOnly = queryListingOverview({ sources, scores, query: query({ scoreNature: 'unscored' }), generatedAt: '2026-08-26T12:00:00.000Z' });
+  assert.equal(unscoredOnly.summary.dimensionStats.title.totalGap, null);
+});
+
+test('overview aggregates the complete filtered set before pagination and returns distribution, health, and facets', () => {
+  const result = queryListingOverview({ sources, scores, query: query({ limit: 2 }), generatedAt: '2026-08-26T12:00:00.000Z' });
+  assert.equal(result.items.length, 2);
+  assert.ok(result.nextCursor);
+  assert.equal(result.summary.sourceTotal, 4);
+  assert.equal(result.summary.matchedTotal, 4);
+  assert.equal(result.summary.scoredTotal, 3);
+  assert.equal(result.summary.simulationTotal, 1);
+  assert.equal(result.summary.averageScore, 74.67);
+  assert.equal(result.summary.medianScore, 74);
+  assert.deepEqual(result.summary.scoreDistribution.map((bucket) => bucket.count), [0, 1, 1, 0, 1]);
+  assert.deepEqual(result.summary.dimensionStats.title, {
+    key: 'title', label: '商品标题', maxScore: 30, scoredCount: 3, averageScore: 23, averageRate: 0.7667, medianScore: 24, totalGap: 21,
+  });
+  assert.deepEqual(result.summary.categoryFacets.map((facet) => [facet.categoryId, facet.count]), [['cat-a', 2], ['cat-b', 1], ['cat-c', 1]]);
+  assert.deepEqual(result.summary.scoreNatureFacets.map((facet) => [facet.value, facet.count]), [['simulation', 1], ['formal_ai', 1], ['rule_precheck', 1], ['unscored', 1]]);
+  assert.equal(result.summary.generatedAt, '2026-08-26T12:00:00.000Z');
+  assert.ok(result.summary.snapshotId);
+  listingOverviewResponseSchema.parse(result);
+});
+
+test('overview applies category, nature, total, dimension, and weakest-dimension filters globally', () => {
+  const result = queryListingOverview({
+    sources,
+    scores,
+    query: query({ categoryIds: ['cat-a', 'missing'], scoreNature: 'simulation', minScore: 70, maxScore: 80, imagesMin: 9, imagesMax: 11, weakestDimension: 'images' }),
+    generatedAt: '2026-08-26T12:00:00.000Z',
+  });
+  assert.equal(result.summary.sourceTotal, 4);
+  assert.equal(result.summary.matchedTotal, 1);
+  assert.deepEqual(result.items.map((item) => item.productId), ['1']);
+  assert.equal(result.summary.averageScore, 74);
+  assert.equal(result.summary.dimensionStats.images.totalGap, 10);
+});
+
+test('overview reuses stable cursors and rejects a changed snapshot', () => {
+  const first = queryListingOverview({ sources, scores, query: query({ sort: 'overallScore', direction: 'desc', limit: 1 }), generatedAt: '2026-08-26T12:00:00.000Z' });
+  assert.deepEqual(first.items.map((item) => item.productId), ['2']);
+  const second = queryListingOverview({ sources, scores, query: query({ sort: 'overallScore', direction: 'desc', limit: 1, cursor: first.nextCursor }), generatedAt: '2026-08-26T12:00:01.000Z' });
+  assert.deepEqual(second.items.map((item) => item.productId), ['1']);
+  assert.throws(
+    () => queryListingOverview({ sources: [{ ...sources[0]!, syncedAt: '2026-08-26T13:00:00.000Z' }, ...sources.slice(1)], scores, query: query({ sort: 'overallScore', direction: 'desc', limit: 1, cursor: first.nextCursor }), generatedAt: '2026-08-26T12:00:01.000Z' }),
+    (error: unknown) => error instanceof Error && 'code' in error && error.code === 'listing_overview_cursor_stale',
+  );
+});
+
+test('overview query schema normalizes category IDs, applies defaults, and rejects inverted ranges', () => {
+  const parsed = listingOverviewQuerySchema.parse({ categoryIds: ['cat-a,cat-b', 'cat-a'] });
+  assert.deepEqual(parsed.categoryIds, ['cat-a', 'cat-b']);
+  assert.equal(parsed.sort, 'improvementPotential');
+  assert.equal(parsed.direction, 'desc');
+  assert.equal(parsed.limit, 25);
+  assert.throws(() => listingOverviewQuerySchema.parse({ minScore: 90, maxScore: 80 }));
+  assert.throws(() => listingOverviewQuerySchema.parse({ titleMax: 31 }));
+});
+
+test('overview service performs one source read and one current-score read without per-product queries', async () => {
+  class CountingRepository extends InMemoryListingAiRepository {
+    sourceReads = 0;
+    scoreReads = 0;
+    override async listAllSources(workspaceId: string, platform: 'jd') {
+      this.sourceReads += 1;
+      return super.listAllSources(workspaceId, platform);
+    }
+    override async listCurrentScores(workspaceId: string) {
+      this.scoreReads += 1;
+      return super.listCurrentScores(workspaceId);
+    }
+  }
+  const repository = new CountingRepository(sources);
+  for (const currentScore of scores) await repository.upsertCurrentScore(currentScore);
+  const service = new ListingAiService(repository, undefined, () => new Date('2026-08-26T12:00:00.000Z'));
+  const result = await service.overview(query());
+  assert.equal(result.summary.sourceTotal, 4);
+  assert.equal(repository.sourceReads, 1);
+  assert.equal(repository.scoreReads, 1);
+});

+ 184 - 0
test/listing-ai.pagination.test.ts

@@ -0,0 +1,184 @@
+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 { ApiError } from '../src/http/api-error.js';
+import type { ListingAiRepository, ListingProductQuery, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
+import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
+import { LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js';
+
+const WORKSPACE_ID = 'listing-pagination-625';
+
+function productId(index: number): string {
+  return `JD${String(index).padStart(4, '0')}`;
+}
+
+function sourceAt(index: number): ListingSourceSnapshot {
+  const id = productId(index);
+  const day = String(index % 25 + 1).padStart(2, '0');
+  return {
+    id: `source-${id}`,
+    workspaceId: WORKSPACE_ID,
+    platform: 'jd',
+    shopId: 'shop-1',
+    productId: id,
+    sourceHash: index.toString(16).padStart(64, '0'),
+    title: `商品 ${id}`,
+    titleBrandName: '测试品牌',
+    brand: { id: 'brand-1', name: '测试品牌' },
+    categoryIds: [`category-${index % 5}`],
+    itemStatus: '1',
+    price: { jd: 100 + index, cost: null },
+    descriptions: { desktopHtml: '<p>完整详情</p>', mobileHtml: '<p>完整详情</p>' },
+    features: [{ key: 'feature', value: '可靠卖点' }],
+    attributes: [{ id: 'attribute-1', name: '规格', values: ['标准'] }],
+    images: [{ url: `https://img.test/${id}.jpg`, order: 1, isPrimary: true, gptFlag: null }],
+    skus: [{ skuId: `sku-${id}`, name: '标准', price: 100 + index, stock: 10, status: '1', attributes: [] }],
+    dimensions: { length: 1, width: 1, height: 1, weight: 1 },
+    logistics: {},
+    afterService: {},
+    sourceModifiedAt: null,
+    syncedAt: `2026-08-${day}T12:00:00.000Z`,
+    detailStatus: 'available',
+  };
+}
+
+function scoreAt(source: ListingSourceSnapshot, index: number): ListingScoreResult {
+  return {
+    id: `score-${source.productId}`,
+    workspaceId: source.workspaceId,
+    productId: source.productId,
+    sourceHash: source.sourceHash,
+    rubricVersion: LISTING_RUBRIC_VERSION,
+    overallScore: index * 37 % 101,
+    coverage: { percent: 100, missing: [], status: 'eligible' },
+    dimensions: [],
+    aiStatus: 'not_requested',
+    aiSuggestions: [],
+    aiCandidate: null,
+    model: null,
+    promptVersion: null,
+    scoreKind: 'rules',
+    createdAt: `2026-08-26T${String(index % 24).padStart(2, '0')}:00:00.000Z`,
+  };
+}
+
+const sources = Array.from({ length: 625 }, (_, offset) => sourceAt(offset + 1)).reverse();
+const scores = sources.map((source) => scoreAt(source, Number(source.productId.slice(2))));
+
+async function seededMemory(): Promise<InMemoryListingAiRepository> {
+  const repository = new InMemoryListingAiRepository(sources);
+  for (const score of scores) await repository.upsertCurrentScore(score);
+  return repository;
+}
+
+async function traverse(repository: ListingAiRepository, sort: NonNullable<ListingProductQuery['sort']>, limit: number) {
+  const items = [];
+  let cursor: string | null = null;
+  let pages = 0;
+  do {
+    const page = await repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort, limit, cursor });
+    assert.ok(page.items.length <= limit);
+    items.push(...page.items);
+    cursor = page.nextCursor;
+    pages += 1;
+    assert.ok(pages <= 30, 'cursor traversal must terminate');
+  } while (cursor);
+  return { items, pages };
+}
+
+function expectedIds(sort: NonNullable<ListingProductQuery['sort']>): string[] {
+  return sources.map((source) => ({
+    productId: source.productId,
+    score: Number(source.productId.slice(2)) * 37 % 101,
+    syncedAt: source.syncedAt,
+  })).sort((left, right) => {
+    if (sort === 'score_asc') return left.score - right.score || left.productId.localeCompare(right.productId);
+    if (sort === 'score_desc') return right.score - left.score || left.productId.localeCompare(right.productId);
+    if (sort === 'updated_desc') return right.syncedAt.localeCompare(left.syncedAt) || left.productId.localeCompare(right.productId);
+    return left.productId.localeCompare(right.productId);
+  }).map((item) => item.productId);
+}
+
+test('625 listings traverse every existing sort without duplicates or omissions at 25 and 100 item boundaries', async () => {
+  const repository = await seededMemory();
+  for (const sort of ['productId', 'score_asc', 'score_desc', 'updated_desc'] as const) {
+    for (const limit of [25, 100]) {
+      const result = await traverse(repository, sort, limit);
+      const ids = result.items.map((item) => item.productId);
+      assert.equal(ids.length, 625, `${sort}/${limit} returns the complete cohort`);
+      assert.equal(new Set(ids).size, 625, `${sort}/${limit} has no duplicates`);
+      assert.deepEqual(ids, expectedIds(sort), `${sort}/${limit} is globally sorted`);
+      assert.equal(result.pages, limit === 25 ? 25 : 7);
+      assert.equal(result.items.length % limit, limit === 25 ? 0 : 25);
+    }
+  }
+});
+
+test('Parse REST product traversal loads the complete cohort before score sorting', async () => {
+  let boundedFindCalled = false;
+  const sourceRows = sources.map((payload) => ({
+    objectId: payload.id,
+    naturalKey: payload.id,
+    workspaceId: payload.workspaceId,
+    productId: payload.productId,
+    platform: payload.platform,
+    payload,
+  }));
+  const scoreRows = scores.map((payload) => ({
+    objectId: payload.id,
+    naturalKey: payload.id,
+    workspaceId: payload.workspaceId,
+    productId: payload.productId,
+    payload,
+  }));
+  const client = {
+    count: async (className: string) => className === VOC_PARSE_CLASSES.listingSourceSnapshot ? 625 : 0,
+    find: async () => { boundedFindCalled = true; throw new Error('bounded Parse query must not be used for product pagination'); },
+    findAll: async (className: string) => className === VOC_PARSE_CLASSES.listingSourceSnapshot ? sourceRows : scoreRows,
+  } as unknown as ParseRestClient;
+  const repository = new ParseRestListingAiRepository(client);
+
+  const result = await traverse(repository, 'score_desc', 100);
+  assert.equal(boundedFindCalled, false);
+  assert.equal(result.pages, 7);
+  assert.deepEqual(result.items.map((item) => item.productId), expectedIds('score_desc'));
+});
+
+test('stable product cursors reject query changes and changed snapshots instead of mixing pages', async () => {
+  const repository = await seededMemory();
+  const first = await repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'score_desc', limit: 25, cursor: null });
+  assert.ok(first.nextCursor);
+  const cursorPayload = JSON.parse(Buffer.from(first.nextCursor, 'base64url').toString('utf8')) as Record<string, unknown>;
+  assert.deepEqual(Object.keys(cursorPayload).sort(), ['direction', 'productId', 'queryHash', 'snapshotId', 'sortKey', 'sortValue', 'version']);
+  assert.equal(cursorPayload['sortKey'], 'overallScore');
+  assert.equal(cursorPayload['direction'], 'desc');
+  assert.equal(typeof cursorPayload['sortValue'], 'number');
+  assert.equal(typeof cursorPayload['productId'], 'string');
+
+  await assert.rejects(
+    repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'score_asc', limit: 25, cursor: first.nextCursor }),
+    (error: unknown) => error instanceof ApiError && error.status === 409 && error.code === 'listing_overview_cursor_stale',
+  );
+
+  const changed = { ...sources[0]!, syncedAt: '2026-08-26T13:00:00.000Z' };
+  await repository.upsertSources([changed]);
+  await assert.rejects(
+    repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'score_desc', limit: 25, cursor: first.nextCursor }),
+    (error: unknown) => error instanceof ApiError && error.status === 409 && error.code === 'listing_overview_cursor_stale',
+  );
+});
+
+test('null scores sort last in both directions with productId as the stable tie-breaker', async () => {
+  const selectedSources = [sourceAt(1), sourceAt(2), sourceAt(3), sourceAt(4)];
+  const repository = new InMemoryListingAiRepository(selectedSources);
+  await repository.upsertCurrentScore(scoreAt(selectedSources[0]!, 1));
+  await repository.upsertCurrentScore(scoreAt(selectedSources[2]!, 1));
+
+  for (const sort of ['score_asc', 'score_desc'] as const) {
+    const page = await repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort, limit: 25, cursor: null });
+    assert.deepEqual(page.items.map((item) => item.productId), [productId(1), productId(3), productId(2), productId(4)]);
+    assert.equal(page.nextCursor, null);
+  }
+});

+ 55 - 1
test/listing-ai.routes.test.ts

@@ -5,7 +5,8 @@ import { createLocalDemoApp } from '../src/local-app.js';
 import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
 import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js';
 import { ApiError } from '../src/http/api-error.js';
-import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import type { ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
+import { LISTING_AI_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.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 };
@@ -156,3 +157,56 @@ test('score history routes are physically removed', async () => {
     assert.equal((await fetch(`${base}/scores/latest`)).status, 404);
   } finally { await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); }
 });
+
+test('overview route returns the current five-dimension read model, aggregates, facets, and validates ranges', async () => {
+  const repository = new InMemoryListingAiRepository([listingSource]);
+  const overviewScore: ListingScoreResult = {
+    id: 'overview-simulation-score', workspaceId: listingSource.workspaceId, productId: listingSource.productId, sourceHash: listingSource.sourceHash,
+    rubricVersion: LISTING_AI_RUBRIC_VERSION, overallScore: 82,
+    coverage: { percent: 100, missing: [], status: 'eligible' },
+    dimensions: [
+      { dimension: 'title', score: 24, maxScore: 30, coverage: 100, status: 'scored', evidence: [], suggestions: [] },
+      { dimension: 'selling_points', score: 21, maxScore: 25, coverage: 100, status: 'scored', evidence: [], suggestions: [] },
+      { dimension: 'images', score: 15, maxScore: 20, coverage: 100, status: 'scored', evidence: [], suggestions: [] },
+      { dimension: 'description', score: 13, maxScore: 15, coverage: 100, status: 'scored', evidence: [], suggestions: [] },
+      { dimension: 'specifications', score: 9, maxScore: 10, coverage: 100, status: 'scored', evidence: [], suggestions: [] },
+    ],
+    aiStatus: 'completed', aiSuggestions: [], aiCandidate: null, model: 'listing-v7-demo-simulation', promptVersion: 'test', scoreKind: 'hybrid_ai', createdAt: '2026-08-26T12:00:00.000Z',
+  };
+  await repository.upsertCurrentScore(overviewScore);
+  const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository });
+  const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
+  try {
+    const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai`;
+    const response = await fetch(`${base}/overview?categoryIds=20&sort=images&direction=asc&limit=25`);
+    assert.equal(response.status, 200);
+    const body = await response.json() as {
+      items: Array<Record<string, unknown> & { productId: string; scoreNature: string; overallScore: number; weakestDimension: string; dimensions: { images: { rate: number; gap: number } } }>;
+      nextCursor: string | null;
+      summary: { sourceTotal: number; matchedTotal: number; scoredTotal: number; simulationTotal: number; snapshotId: string; scoreDistribution: unknown[]; dimensionStats: Record<string, unknown>; categoryFacets: unknown[]; scoreNatureFacets: unknown[] };
+    };
+    assert.equal(body.items[0]?.productId, listingSource.productId);
+    assert.equal(body.items[0]?.scoreNature, 'simulation');
+    assert.equal(body.items[0]?.overallScore, 82);
+    assert.equal(body.items[0]?.weakestDimension, 'images');
+    assert.deepEqual(body.items[0]?.dimensions.images, { score: 15, maxScore: 20, rate: 0.75, gap: 5 });
+    assert.equal(body.items[0]?.['sourceHash'], undefined);
+    assert.equal(body.items[0]?.['model'], undefined);
+    assert.equal(body.summary.sourceTotal, 1);
+    assert.equal(body.summary.matchedTotal, 1);
+    assert.equal(body.summary.scoredTotal, 1);
+    assert.equal(body.summary.simulationTotal, 1);
+    assert.ok(body.summary.snapshotId);
+    assert.equal(body.summary.scoreDistribution.length, 5);
+    assert.ok(body.summary.dimensionStats['images']);
+    assert.equal(body.summary.categoryFacets.length, 1);
+    assert.equal(body.summary.scoreNatureFacets.length, 4);
+    assert.equal(body.nextCursor, null);
+
+    const invalid = await fetch(`${base}/overview?minScore=90&maxScore=80`);
+    assert.equal(invalid.status, 400);
+    assert.equal((await invalid.json() as { error: string }).error, 'invalid_request');
+  } finally {
+    await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
+  }
+});