diff --git a/furniture-3d-system/src/lib/bom-costing/contracts.test.ts b/furniture-3d-system/src/lib/bom-costing/contracts.test.ts index fb8e980c..57c56023 100644 --- a/furniture-3d-system/src/lib/bom-costing/contracts.test.ts +++ b/furniture-3d-system/src/lib/bom-costing/contracts.test.ts @@ -54,6 +54,32 @@ describe('CompetitorAnalysisResultV1', () => { expect(invalid.success).toBe(false) }) + it('SKU 没有尺寸证据时规范化为空尺寸对象', () => { + const result = competitorAnalysisResultV1Schema.parse({ + schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + sourceUrl: 'https://example.com/product/1', + product: { title: '竞品', imageUrls: [] }, + skus: [{ + externalSkuId: 'sku-1', + name: 'SKU', + attributes: {}, + bomLines: [{ + externalLineId: 'line-1', + category: 'PROCESSING', + partName: '加工', + quantity: 1, + unit: '套', + estimatedUnitPrice: 1, + evidence: [], + confidence: 0.8, + }], + }], + analyzedAt: new Date().toISOString(), + }) + + expect(result.skus[0].dimensions).toEqual({}) + }) + it('允许归档后的站内对象地址,但拒绝任意相对路径', () => { const base = { schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, diff --git a/furniture-3d-system/src/lib/bom-costing/contracts.ts b/furniture-3d-system/src/lib/bom-costing/contracts.ts index 1a4bdbac..629404eb 100644 --- a/furniture-3d-system/src/lib/bom-costing/contracts.ts +++ b/furniture-3d-system/src/lib/bom-costing/contracts.ts @@ -65,7 +65,7 @@ export const competitorSkuSchema = z.object({ lengthMm: optionalPositiveNumberSchema, widthMm: optionalPositiveNumberSchema, heightMm: optionalPositiveNumberSchema, - }), + }).default({}), weightKg: optionalPositiveNumberSchema, packaging: z.object({ lengthMm: optionalPositiveNumberSchema, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts index 8a9ee004..22e1a04d 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts @@ -188,6 +188,66 @@ describe('BOM 多模态分析器', () => { }) }) + it('模型省略无证据的 SKU 尺寸对象时仍生成规范结果', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + product: { category: '书桌' }, + structureGroups: [analyzedStructureGroup()], + skus: [ + { + externalSkuId: 'sku-white', + structureGroupKey: 'desk-120', + attributes: {}, + }, + { + externalSkuId: 'sku-black', + structureGroupKey: 'desk-120', + attributes: {}, + }, + ], + }), + }, + }], + }), { status: 200 }), + ) + + const result = await new BomCostingMultimodalAnalyzer().analyze({ + sourceUrl: snapshot.detailUrl, + snapshot, + }) + + expect(result.skus.map((sku) => sku.dimensions)).toEqual([{}, {}]) + }) + + it('模型返回其他不完整 SKU 信息时不暴露内部校验细节', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + product: { category: '书桌' }, + structureGroups: [analyzedStructureGroup()], + skus: [ + { externalSkuId: 'sku-white' }, + analyzedSku('sku-black'), + ], + }), + }, + }], + }), { status: 200 }), + ) + + await expect( + new BomCostingMultimodalAnalyzer().analyze({ + sourceUrl: snapshot.detailUrl, + snapshot, + }), + ).rejects.toThrow('多模态分析返回的 SKU 信息不完整,请重新处理') + }) + it('模型漏掉 SKU 时直接失败,不补造结果', async () => { jest.spyOn(global, 'fetch').mockResolvedValue( new Response(JSON.stringify({ diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts index 072bd195..c43e93a6 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts @@ -23,7 +23,7 @@ const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务 3. 不得把商品售价当作成本,不得根据售价倒推材料价格。 4. 材料价格由产品平台资料库匹配;BOARD、STEEL、ACCESSORY、PACKAGING 不填写 estimatedUnitPrice。 5. estimatedUnitPrice 只允许用于 PROCESSING 和 LABOR,单位为人民币,并按证据强弱降低 confidence。 -6. 尺寸统一使用毫米,重量统一使用千克;没有证据的可选字段直接省略,不得编造精确数值。 +6. 尺寸统一使用毫米,重量统一使用千克;每条 SKU 必须返回 dimensions 对象,没有尺寸证据时返回空对象 {},对象内没有证据的可选字段直接省略,不得编造精确数值。 7. 同结构 SKU 使用相同 structureGroupKey;每个结构组只在 structureGroups 中返回一次 bomLines,SKU 记录只绑定结构组,不得重复输出 BOM。 8. BOM 分类只能是 BOARD、STEEL、ACCESSORY、PACKAGING、PROCESSING、LABOR。 9. evidence 必须说明依据;引用图片时使用输入中真实的图片 URL。 @@ -321,6 +321,18 @@ function describeMultimodalHttpError(status: number): string { return `多模态分析服务返回 ${status}` } +function describeAnalysisPayloadError(error: unknown): string { + if (error instanceof z.ZodError) { + const hasSkuIssue = error.issues.some((issue) => issue.path[0] === 'skus') + return hasSkuIssue + ? '多模态分析返回的 SKU 信息不完整,请重新处理' + : '多模态分析返回的产品结构信息不完整,请重新处理' + } + return error instanceof Error + ? error.message + : '多模态分析返回的信息不完整,请重新处理' +} + function assertExactSkuCoverage( sourceSkus: MarketSkuSnapshot[], analyzedSkus: z.infer[], @@ -552,10 +564,9 @@ export class BomCostingMultimodalAnalyzer { }, } } catch (error) { - const details = error instanceof Error ? error.message : '结构校验失败' throw new AppError( ErrorCode.BOM_COSTING_INVALID_PROVIDER, - `多模态分析结果不完整:${details}`, + describeAnalysisPayloadError(error), ) } }