listing-ai.routes.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import assert from 'node:assert/strict';
  2. import type { AddressInfo } from 'node:net';
  3. import test from 'node:test';
  4. import { createLocalDemoApp } from '../src/local-app.js';
  5. import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
  6. import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js';
  7. import { ApiError } from '../src/http/api-error.js';
  8. import type { ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  9. import { LISTING_AI_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js';
  10. import { scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
  11. import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
  12. import type { DomesticDataset, DomesticMetricSummary } from '../src/types/domestic-dataset.js';
  13. 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 };
  14. const dataset: DomesticDataset = {
  15. schemaVersion: 1, generatedAt: '2026-08-21T00:00:00.000Z', caseName: 'Listing test', platform: 'jd',
  16. source: { sourceFile: 'test.json', sourceHash: 'test', dateRange: { start: '2026-08-21', end: '2026-08-21' } },
  17. summary: { metricRows: 0, metricProducts: 1, mappingRows: 0, relations: 0, uniqueCompetitorProducts: 0, category2Count: 0, category3Count: 0, reviewCount: 0 },
  18. dailyTotals: [], products: [{ platform: 'jd', productId: '1001', productKey: 'jd:1001', asin: '1001', role: 'own', brand: '星星', title: '测试商品', model: '', category1: '', category2: '', category3: '', source: 'test', relationCount: 0, summary: metrics, trend: [] }],
  19. mappingGroups: [], relations: [], reviews: [], quality: { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] },
  20. };
  21. const listingSource: ListingSourceSnapshot = {
  22. id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: '1001', sourceHash: 'b'.repeat(64), title: '星星 商用冷藏展示柜 299L 一级能效风冷无霜', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['20'], categoryContext: { names: ['商用冷藏展示柜'], coreTerms: ['商用冷藏展示柜'], requiredSpecificationNames: ['容量'], qualificationNames: [], ruleVersion: 'test-category-v1' }, itemStatus: '1', price: { jd: 1049, cost: null },
  23. descriptions: { desktopHtml: '<script>alert(1)</script><p onclick="bad()">安全详情</p>', mobileHtml: '<p>移动详情</p>' },
  24. descriptionStructure: { observed: true, imageCount: 5, videoCount: 0, headingCount: 1, faqCandidateCount: 0 },
  25. features: [{ key: 'one', value: '299L 大容量' }, { key: 'two', value: '一级能效' }, { key: 'three', value: '风冷无霜' }],
  26. attributes: [{ id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '能效', values: ['一级'] }, { id: '3', name: '制冷', values: ['风冷'] }],
  27. images: Array.from({ length: 5 }, (_, i) => ({ url: `https://img.test/${i}.jpg`, order: i + 1, isPrimary: i === 0, gptFlag: null })),
  28. skus: [{ skuId: 'sku1', name: '299L', price: 1049, stock: 1, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
  29. dimensions: { length: 1, width: 1, height: 1, weight: 1 }, logistics: {}, afterService: { return7Days: true }, marketing: { adword: '299L 大容量 一级能效', skuShortTitles: [{ skuId: 'sku1', value: '299L 高效冷藏' }], sellingPoints: [{ value: '299L 大容量 一级能效', source: 'product_adword', fieldPath: 'productInfo.adword', skuId: null }, { value: '299L 高效冷藏', source: 'sku_short_title', fieldPath: 'skuList[].features[key=shortTitle]', skuId: 'sku1' }] }, sourceModifiedAt: null, syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available',
  30. };
  31. test('listing API scores a frozen source, sanitizes HTML, and adopts an internal version', async () => {
  32. const repository = new InMemoryListingAiRepository([listingSource]);
  33. const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository });
  34. const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
  35. try {
  36. const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai`;
  37. const products = await fetch(`${base}/products`);
  38. assert.equal(products.status, 200);
  39. const catalog = await products.json() as { items: Array<Record<string, unknown> & { productId: string }>; summary: { sourceTotal: number } };
  40. assert.equal(catalog.summary.sourceTotal, 1);
  41. assert.equal(catalog.items[0]?.productId, '1001');
  42. assert.equal(catalog.items[0]?.['sourceHash'], undefined);
  43. assert.equal(catalog.items[0]?.['rubricVersion'], undefined);
  44. assert.equal(catalog.items[0]?.['latestScore'], undefined);
  45. assert.equal(catalog.items[0]?.['scoreText'], '等待智能评分');
  46. const detail = await fetch(`${base}/products/1001`);
  47. const detailBody = await detail.json() as { source: { descriptions: { desktopHtml: string } } };
  48. assert.equal(detail.status, 200);
  49. assert.doesNotMatch(detailBody.source.descriptions.desktopHtml, /script|onclick/i);
  50. const jobResponse = await fetch(`${base}/score-jobs`, {
  51. method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'listing-route-test-1' },
  52. body: JSON.stringify({ scope: { mode: 'filter', filter: {} }, includeAiSuggestions: false }),
  53. });
  54. assert.equal(jobResponse.status, 202);
  55. const createdJob = (await jobResponse.json() as { job: { id: string; statusLabel: string; rubricVersion?: string } }).job;
  56. const jobId = createdJob.id;
  57. assert.equal(createdJob.statusLabel, '等待处理');
  58. let status = '';
  59. for (let index = 0; index < 30; index += 1) {
  60. const response = await fetch(`${base}/score-jobs/${jobId}`);
  61. status = (await response.json() as { job: { status: string } }).job.status;
  62. if (['completed', 'partial', 'failed'].includes(status)) break;
  63. await new Promise((resolve) => setTimeout(resolve, 10));
  64. }
  65. assert.equal(status, 'partial');
  66. const scored = await fetch(`${base}/products/1001`);
  67. const scoredBody = await scored.json() as { currentScore: { score: number | null; scoreText: string; standardLabel: string } };
  68. assert.equal(scoredBody.currentScore.score, null);
  69. assert.equal(scoredBody.currentScore.standardLabel, '京东五维评分 V7');
  70. const versionResponse = await fetch(`${base}/products/1001/versions`, {
  71. method: 'POST', headers: { 'Content-Type': 'application/json' },
  72. body: JSON.stringify({
  73. baseSourceHash: listingSource.sourceHash,
  74. content: {
  75. title: `${listingSource.title} 优化稿`,
  76. sellingPoints: ['299L 大容量', '一级能效', '风冷无霜'],
  77. descriptionHtml: '<p>优化详情</p>',
  78. specifications: listingSource.attributes,
  79. imageUrls: listingSource.images.map((item) => item.url),
  80. },
  81. }),
  82. });
  83. assert.equal(versionResponse.status, 201);
  84. const versionId = (await versionResponse.json() as { version: { id: string } }).version.id;
  85. const adopted = await fetch(`${base}/versions/${versionId}/adopt`, { method: 'POST' });
  86. assert.equal(adopted.status, 200);
  87. assert.equal((await adopted.json() as { version: { status: string } }).version.status, 'adopted');
  88. } finally {
  89. await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  90. }
  91. });
  92. test('score-job scoringMode uses JD-VOC v0.5 and the approved display selector keeps legacy results queryable', async () => {
  93. const repository = new InMemoryListingAiRepository([listingSource]);
  94. const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'), 1, 10, undefined, undefined, true);
  95. const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository, listingAiService: service });
  96. const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
  97. try {
  98. const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai`;
  99. const response = await fetch(`${base}/score-jobs`, {
  100. method: 'POST', headers: { 'content-type': 'application/json', 'idempotency-key': 'jd-voc-route-job-1' },
  101. body: JSON.stringify({ scope: { mode: 'selected', productIds: [listingSource.productId] }, scoringMode: 'rules', rescorePolicy: 'reuse' }),
  102. });
  103. assert.equal(response.status, 202);
  104. const job = (await response.json() as { job: { id: string; rubricVersion: string } }).job;
  105. assert.equal(job.rubricVersion, 'jd-voc-v0.5');
  106. for (let index = 0; index < 50; index += 1) {
  107. const current = await repository.getJob(listingSource.workspaceId, job.id);
  108. if (current && ['completed', 'partial', 'failed'].includes(current.status)) break;
  109. await new Promise((resolve) => setTimeout(resolve, 5));
  110. }
  111. const jdVoc = await repository.getJdVocCurrentScore(listingSource.workspaceId, listingSource.productId, 'jd_voc_rules');
  112. assert.equal(jdVoc?.rubricVersion, 'jd-voc-v0.5');
  113. assert.equal(jdVoc?.dimensions.length, 6);
  114. const detail = await fetch(`${base}/products/${listingSource.productId}`);
  115. const detailBody = await detail.json() as { displayScoreKind: string; jdVocScore: { sourceHash: string; inputFingerprint: string }; currentScore: unknown };
  116. assert.equal(detail.status, 200);
  117. assert.equal(detailBody.displayScoreKind, 'jd_voc');
  118. assert.equal(detailBody.jdVocScore.sourceHash, listingSource.sourceHash);
  119. assert.ok(detailBody.jdVocScore.inputFingerprint);
  120. const defaultScore = await fetch(`${base}/products/${listingSource.productId}/score`);
  121. const defaultBody = await defaultScore.json() as { displayScoreKind: string; score: { rubricVersion: string } };
  122. assert.equal(defaultBody.displayScoreKind, 'jd_voc');
  123. assert.equal(defaultBody.score.rubricVersion, 'jd-voc-v0.5');
  124. } finally { await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); }
  125. });
  126. test('JD_VOC_DISPLAY_DEFAULT=false immediately restores the legacy selector without deleting JD-VOC slots', async () => {
  127. const repository = new InMemoryListingAiRepository([listingSource]);
  128. await repository.upsertCurrentScore(scoreListing(listingSource, { now:'2026-09-05T00:00:00.000Z' }));
  129. await repository.upsertJdVocCurrentScore(scoreJdVocRules(listingSource, {}, { now:'2026-09-05T00:00:00.000Z' }));
  130. const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'), 1, 10, undefined, undefined, false, true);
  131. const app = createLocalDemoApp({ dataset, corsOrigins:['http://localhost:4200'], listingAiRepository:repository, listingAiService:service });
  132. const server = await new Promise<ReturnType<typeof app.listen>>((resolve)=>{const listener=app.listen(0,'127.0.0.1',()=>resolve(listener));});
  133. try{
  134. const base=`http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai/products/${listingSource.productId}`;
  135. const detail=await (await fetch(base)).json() as {displayScoreKind:string;jdVocScore:unknown;currentScore:unknown};
  136. assert.equal(detail.displayScoreKind,'legacy');
  137. assert.ok(detail.currentScore);
  138. assert.ok(detail.jdVocScore,'new slot remains queryable during rollback');
  139. const score=await (await fetch(`${base}/score`)).json() as {displayScoreKind?:string;score:{standardLabel?:string}};
  140. assert.equal(score.displayScoreKind,undefined);
  141. assert.equal(score.score.standardLabel,'京东五维评分 V7');
  142. }finally{await new Promise<void>((resolve,reject)=>server.close((error)=>error?reject(error):resolve()));}
  143. });
  144. test('a changed source hash hides stale scores and marks prior versions stale', async () => {
  145. const repository = new InMemoryListingAiRepository([listingSource]);
  146. const now = '2026-08-21T01:00:00.000Z';
  147. await repository.upsertCurrentScore({
  148. id: 'score-old', workspaceId: listingSource.workspaceId, productId: listingSource.productId,
  149. sourceHash: listingSource.sourceHash, rubricVersion: 'listing-jd-v1', overallScore: 88,
  150. coverage: { percent: 100, missing: [], status: 'eligible' }, dimensions: [],
  151. aiStatus: 'not_requested', aiSuggestions: [], aiCandidate: null, model: null, promptVersion: null, createdAt: now,
  152. });
  153. const version = await repository.createVersion({
  154. id: 'version-old', workspaceId: listingSource.workspaceId, productId: listingSource.productId, versionNo: 0,
  155. baseSourceHash: listingSource.sourceHash,
  156. content: { title: listingSource.title, sellingPoints: [], descriptionHtml: null, specifications: [], imageUrls: [] },
  157. status: 'draft', createdBy: 'test', createdAt: now, adoptedAt: null,
  158. });
  159. const changed = { ...listingSource, id: 'source-2', sourceHash: 'c'.repeat(64), syncedAt: '2026-08-21T02:00:00.000Z' };
  160. await repository.upsertSources([changed]);
  161. assert.equal((await repository.listProducts({ workspaceId: changed.workspaceId, platform: 'jd', limit: 10, cursor: null })).items[0]?.latestScore, null);
  162. assert.equal((await repository.getVersion(changed.workspaceId, version.id))?.status, 'stale');
  163. });
  164. test('AI jobs fail closed before enqueueing beyond the configured item budget', async () => {
  165. const second = { ...listingSource, id: 'source-budget-2', productId: '1002', sourceHash: 'd'.repeat(64) };
  166. const service = new ListingAiService(
  167. new InMemoryListingAiRepository([listingSource, second]),
  168. undefined,
  169. () => new Date('2026-08-21T03:00:00.000Z'),
  170. 1,
  171. 1,
  172. );
  173. await assert.rejects(
  174. service.enqueueScoreJob({
  175. workspaceId: listingSource.workspaceId, platform: 'jd', scope: { mode: 'filter', filter: {} },
  176. includeAiSuggestions: true, idempotencyKey: 'budget-guard-test', requestedBy: 'test',
  177. }),
  178. (error: unknown) => error instanceof ApiError && error.status === 429 && error.code === 'listing_ai_budget_exceeded',
  179. );
  180. });
  181. test('critical compliance findings prevent adopting an internal version', async () => {
  182. const blockedSource = { ...listingSource, productId: 'blocked-1', sourceHash: '9'.repeat(64), title: '星星 商用冷藏展示柜 联系电话:13800138000' };
  183. const repository = new InMemoryListingAiRepository([blockedSource]);
  184. const service = new ListingAiService(repository, undefined, () => new Date('2026-08-24T02:00:00.000Z'));
  185. const job = await service.enqueueScoreJob({ workspaceId: blockedSource.workspaceId, platform: 'jd', scope: { mode: 'selected', productIds: [blockedSource.productId] }, includeAiSuggestions: false, idempotencyKey: 'blocked-compliance-score', requestedBy: 'test' });
  186. for (let index = 0; index < 30; index += 1) {
  187. if (['completed', 'partial', 'failed'].includes((await repository.getJob(blockedSource.workspaceId, job.id))?.status ?? '')) break;
  188. await new Promise((resolve) => setTimeout(resolve, 5));
  189. }
  190. const version = await service.createVersion({ workspaceId: blockedSource.workspaceId, productId: blockedSource.productId, platform: 'jd', baseSourceHash: blockedSource.sourceHash, content: { title: blockedSource.title, sellingPoints: [], descriptionHtml: null, specifications: blockedSource.attributes, imageUrls: blockedSource.images.map((item) => item.url) }, createdBy: 'test' });
  191. await assert.rejects(service.adoptVersion(blockedSource.workspaceId, 'jd', version.id), (error: unknown) => error instanceof ApiError && error.code === 'listing_compliance_blocked');
  192. });
  193. test('JD-VOC compliance BLOCK prevents adoption even when the legacy selector has no block', async () => {
  194. const blockedSource = { ...listingSource, productId: 'jd-blocked-1', sourceHash: '8'.repeat(64), title: '星星 商用冷藏展示柜 加微信购买' };
  195. const repository = new InMemoryListingAiRepository([blockedSource]);
  196. const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'));
  197. const score = await service.scoreJdVocRules({ workspaceId: blockedSource.workspaceId, platform:'jd', productId:blockedSource.productId });
  198. assert.equal(score.compliance?.gate, 'BLOCK');
  199. 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' });
  200. await assert.rejects(service.adoptVersion(blockedSource.workspaceId,'jd',version.id),(error:unknown)=>error instanceof ApiError&&error.code==='listing_compliance_blocked');
  201. });
  202. test('score history routes are physically removed', async () => {
  203. const repository = new InMemoryListingAiRepository([listingSource]);
  204. const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository });
  205. const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
  206. try {
  207. const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai/products/1001`;
  208. assert.equal((await fetch(`${base}/scores`)).status, 404);
  209. assert.equal((await fetch(`${base}/scores/latest`)).status, 404);
  210. } finally { await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); }
  211. });
  212. test('overview route returns the current five-dimension read model, aggregates, facets, and validates ranges', async () => {
  213. const repository = new InMemoryListingAiRepository([listingSource]);
  214. const overviewScore: ListingScoreResult = {
  215. id: 'overview-simulation-score', workspaceId: listingSource.workspaceId, productId: listingSource.productId, sourceHash: listingSource.sourceHash,
  216. rubricVersion: LISTING_AI_RUBRIC_VERSION, overallScore: 82,
  217. coverage: { percent: 100, missing: [], status: 'eligible' },
  218. dimensions: [
  219. { dimension: 'title', score: 24, maxScore: 30, coverage: 100, status: 'scored', evidence: [], suggestions: [] },
  220. { dimension: 'selling_points', score: 21, maxScore: 25, coverage: 100, status: 'scored', evidence: [], suggestions: [] },
  221. { dimension: 'images', score: 15, maxScore: 20, coverage: 100, status: 'scored', evidence: [], suggestions: [] },
  222. { dimension: 'description', score: 13, maxScore: 15, coverage: 100, status: 'scored', evidence: [], suggestions: [] },
  223. { dimension: 'specifications', score: 9, maxScore: 10, coverage: 100, status: 'scored', evidence: [], suggestions: [] },
  224. ],
  225. aiStatus: 'completed', aiSuggestions: [], aiCandidate: null, model: 'listing-v7-demo-simulation', promptVersion: 'test', scoreKind: 'hybrid_ai', createdAt: '2026-08-26T12:00:00.000Z',
  226. };
  227. await repository.upsertCurrentScore(overviewScore);
  228. const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository });
  229. const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
  230. try {
  231. const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai`;
  232. const response = await fetch(`${base}/overview?categoryIds=20&sort=images&direction=asc&limit=25`);
  233. assert.equal(response.status, 200);
  234. const body = await response.json() as {
  235. items: Array<Record<string, unknown> & { productId: string; scoreNature: string; overallScore: number; weakestDimension: string; dimensions: { images: { rate: number; gap: number } } }>;
  236. nextCursor: string | null;
  237. summary: { sourceTotal: number; matchedTotal: number; scoredTotal: number; simulationTotal: number; snapshotId: string; scoreDistribution: unknown[]; dimensionStats: Record<string, unknown>; categoryFacets: unknown[]; scoreNatureFacets: unknown[] };
  238. };
  239. assert.equal(body.items[0]?.productId, listingSource.productId);
  240. assert.equal(body.items[0]?.scoreNature, 'simulation');
  241. assert.equal(body.items[0]?.overallScore, 82);
  242. assert.equal(body.items[0]?.weakestDimension, 'images');
  243. assert.deepEqual(body.items[0]?.dimensions.images, { score: 15, maxScore: 20, rate: 0.75, gap: 5 });
  244. assert.equal(body.items[0]?.['sourceHash'], undefined);
  245. assert.equal(body.items[0]?.['model'], undefined);
  246. assert.equal(body.summary.sourceTotal, 1);
  247. assert.equal(body.summary.matchedTotal, 1);
  248. assert.equal(body.summary.scoredTotal, 1);
  249. assert.equal(body.summary.simulationTotal, 1);
  250. assert.ok(body.summary.snapshotId);
  251. assert.equal(body.summary.scoreDistribution.length, 5);
  252. assert.ok(body.summary.dimensionStats['images']);
  253. assert.equal(body.summary.categoryFacets.length, 1);
  254. assert.equal(body.summary.scoreNatureFacets.length, 4);
  255. assert.equal(body.nextCursor, null);
  256. const invalid = await fetch(`${base}/overview?minScore=90&maxScore=80`);
  257. assert.equal(invalid.status, 400);
  258. assert.equal((await invalid.json() as { error: string }).error, 'invalid_request');
  259. } finally {
  260. await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  261. }
  262. });