From da3a49f03f7cf2ca0993bc2f2a7aceb6883fd0ca Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 28 Sep 2026 14:09:50 +0800 Subject: [PATCH] fix(supplier-recommended): use upstream image search defaults --- .../image-search/route.ts | 6 +-- .../lib/supplier-recommended-image-search.ts | 10 ----- ...r-recommended-image-search.service.test.ts | 26 +++++++------ ...pplier-recommended-image-search.service.ts | 37 ++++--------------- 4 files changed, 23 insertions(+), 56 deletions(-) diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/image-search/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/image-search/route.ts index bef38267..9f90a86d 100644 --- a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/image-search/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/image-search/route.ts @@ -14,11 +14,7 @@ export async function POST(request: NextRequest) { if (!parsed.success) { return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, parsed.error.issues[0]?.message ?? '以图搜图参数不正确', 400, ctx.requestId) } - const result = await supplierRecommendedImageSearchService.searchByRecord( - parsed.data.recordId, - { topK: parsed.data.topK, scoreThreshold: parsed.data.scoreThreshold }, - ctx.user, - ) + const result = await supplierRecommendedImageSearchService.searchByRecord(parsed.data.recordId, ctx.user) return jsonSuccess(result, 200, ctx.requestId) }, { requireAuth: true }) } diff --git a/furniture-3d-system/src/lib/supplier-recommended-image-search.ts b/furniture-3d-system/src/lib/supplier-recommended-image-search.ts index dba369b3..fdc72402 100644 --- a/furniture-3d-system/src/lib/supplier-recommended-image-search.ts +++ b/furniture-3d-system/src/lib/supplier-recommended-image-search.ts @@ -5,10 +5,6 @@ import { z } from 'zod' export const SUPPLIER_RECOMMENDED_IMAGE_SEARCH_LIMITS = { // 图搜接口限制:上传图片最大 12MB(见对接文档 §5) MAX_IMAGE_BYTES: 12 * 1024 * 1024, - DEFAULT_TOP_K: 20, - MAX_TOP_K: 100, - DEFAULT_SCORE_THRESHOLD: 0.85, - MIN_SCORE_THRESHOLD: 0.8, // 返回给前端的裁剪上限,避免整包响应过大 MAX_SAME_STYLE_PRODUCTS_PER_SPU: 6, MAX_OWN_PRODUCT_MATCHES: 20, @@ -17,12 +13,6 @@ export const SUPPLIER_RECOMMENDED_IMAGE_SEARCH_LIMITS = { export const supplierRecommendedImageSearchInputSchema = z.object({ recordId: z.string().trim().min(1, '缺少推荐记录 ID').max(64), - topK: z.coerce.number().int().min(1).max(SUPPLIER_RECOMMENDED_IMAGE_SEARCH_LIMITS.MAX_TOP_K).optional(), - scoreThreshold: z.coerce - .number() - .min(SUPPLIER_RECOMMENDED_IMAGE_SEARCH_LIMITS.MIN_SCORE_THRESHOLD, '相似度阈值不能低于 0.8') - .max(0.99) - .optional(), }).strict() export type SupplierRecommendedImageSearchInput = z.infer diff --git a/furniture-3d-system/src/server/services/__tests__/supplier-recommended-image-search.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supplier-recommended-image-search.service.test.ts index 45e78045..ada178e1 100644 --- a/furniture-3d-system/src/server/services/__tests__/supplier-recommended-image-search.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supplier-recommended-image-search.service.test.ts @@ -19,6 +19,7 @@ jest.mock('@/lib/storage', () => ({ })) import { SupplierRecommendedImageSearchService } from '@/server/services/supplier-recommended-image-search.service' +import { supplierRecommendedImageSearchInputSchema } from '@/lib/supplier-recommended-image-search' const USER = { id: 'user-1', name: '采购员', role: 'USER' as const } @@ -67,6 +68,12 @@ const UPSTREAM_BODY = { describe('SupplierRecommendedImageSearchService', () => { let originalEnv: NodeJS.ProcessEnv + it('只接受记录 ID,不接受本地检索参数', () => { + expect(supplierRecommendedImageSearchInputSchema.safeParse({ recordId: 'rec-1' }).success).toBe(true) + expect(supplierRecommendedImageSearchInputSchema.safeParse({ recordId: 'rec-1', topK: 20 }).success).toBe(false) + expect(supplierRecommendedImageSearchInputSchema.safeParse({ recordId: 'rec-1', scoreThreshold: 0.85 }).success).toBe(false) + }) + beforeEach(() => { jest.clearAllMocks() originalEnv = { ...process.env } @@ -88,7 +95,7 @@ describe('SupplierRecommendedImageSearchService', () => { it('正常链路:取图→调上游→裁剪结构正确', async () => { const service = new SupplierRecommendedImageSearchService() - const result = await service.searchByRecord('rec-1', {}, USER) + const result = await service.searchByRecord('rec-1', USER) expect(result.requestId).toBe('req-123') expect(result.summary.matchedSpuCount).toBe(1) @@ -103,30 +110,27 @@ describe('SupplierRecommendedImageSearchService', () => { expect(upstreamCall).toBeDefined() const [, init] = upstreamCall! as [string, RequestInit] const body = JSON.parse(String(init.body)) - expect(body.search_mode).toBe('appearance') - expect(body.top_k).toBe(20) - expect(body.score_threshold).toBe(0.85) - expect(typeof body.image_base64).toBe('string') + expect(body).toEqual({ image_base64: expect.any(String) }) expect(init.headers).toMatchObject({ Authorization: 'Bearer test-key' }) }) it('记录不存在时报错', async () => { mockFindById.mockResolvedValue(null) const service = new SupplierRecommendedImageSearchService() - await expect(service.searchByRecord('missing', {}, USER)).rejects.toThrow('供应商新品推荐记录不存在') + await expect(service.searchByRecord('missing', USER)).rejects.toThrow('供应商新品推荐记录不存在') }) it('记录没有图片时报错且不调上游', async () => { mockFindById.mockResolvedValue({ id: 'rec-1', imageUrl: null }) const service = new SupplierRecommendedImageSearchService() - await expect(service.searchByRecord('rec-1', {}, USER)).rejects.toThrow('该记录没有图片,无法以图搜图') + await expect(service.searchByRecord('rec-1', USER)).rejects.toThrow('该记录没有图片,无法以图搜图') expect(fetchMock).not.toHaveBeenCalled() }) it('未配置 Key 时给出明确提示', async () => { delete process.env.IMAGE_SEARCH_API_KEY const service = new SupplierRecommendedImageSearchService() - await expect(service.searchByRecord('rec-1', {}, USER)).rejects.toThrow('图搜服务未配置') + await expect(service.searchByRecord('rec-1', USER)).rejects.toThrow('图搜服务未配置') }) it('上游网络不可达时提示网络连通问题', async () => { @@ -135,7 +139,7 @@ describe('SupplierRecommendedImageSearchService', () => { return imageResponse() }) const service = new SupplierRecommendedImageSearchService() - await expect(service.searchByRecord('rec-1', {}, USER)).rejects.toThrow('图搜服务不可达') + await expect(service.searchByRecord('rec-1', USER)).rejects.toThrow('图搜服务不可达') }) it('上游业务失败时透出 message 与 request_id', async () => { @@ -144,12 +148,12 @@ describe('SupplierRecommendedImageSearchService', () => { return imageResponse() }) const service = new SupplierRecommendedImageSearchService() - await expect(service.searchByRecord('rec-1', {}, USER)).rejects.toThrow('图搜服务返回异常(request_id: req-404)') + await expect(service.searchByRecord('rec-1', USER)).rejects.toThrow('图搜服务返回异常(request_id: req-404)') }) it('无 view 权限拒绝', async () => { mockGetUserAccess.mockResolvedValue({ view: false, download: false, edit: false }) const service = new SupplierRecommendedImageSearchService() - await expect(service.searchByRecord('rec-1', {}, USER)).rejects.toThrow('当前账号没有供应商新品推荐权限') + await expect(service.searchByRecord('rec-1', USER)).rejects.toThrow('当前账号没有供应商新品推荐权限') }) }) diff --git a/furniture-3d-system/src/server/services/supplier-recommended-image-search.service.ts b/furniture-3d-system/src/server/services/supplier-recommended-image-search.service.ts index 3678176a..c1ca4862 100644 --- a/furniture-3d-system/src/server/services/supplier-recommended-image-search.service.ts +++ b/furniture-3d-system/src/server/services/supplier-recommended-image-search.service.ts @@ -14,8 +14,6 @@ const DEFAULT_API_BASE = 'http://192.168.42.134:7503' const IMAGE_FETCH_TIMEOUT_MS = 30_000 const DEFAULT_UPSTREAM_TIMEOUT_MS = 180_000 -type UpstreamQuery = { imageBase64: string; filename: string; topK: number; scoreThreshold: number } - function resolveApiBase() { const base = (process.env.IMAGE_SEARCH_API_BASE ?? '').trim() return base || DEFAULT_API_BASE @@ -30,16 +28,6 @@ function resolveUpstreamTimeoutMs() { return Number.isFinite(value) && value >= 30_000 ? value : DEFAULT_UPSTREAM_TIMEOUT_MS } -function guessFilenameFromUrl(url: string) { - try { - const path = new URL(url).pathname - const last = path.split('/').filter(Boolean).pop() ?? '' - return /\.[a-z0-9]{2,5}$/i.test(last) ? decodeURIComponent(last) : 'image.jpg' - } catch { - return 'image.jpg' - } -} - function toClientResult(upstream: ImageSearchUpstreamResponse, requestId: string | null): SupplierRecommendedImageSearchClientResult { const data = upstream.data ?? {} const summary = { @@ -109,7 +97,7 @@ function toClientResult(upstream: ImageSearchUpstreamResponse, requestId: string export class SupplierRecommendedImageSearchService { constructor(private readonly repository: SupplierRecommendedProductsRepository = supplierRecommendedProductsRepository) {} - async searchByRecord(recordId: string, options: { topK?: number; scoreThreshold?: number }, user: AuthUser): Promise { + async searchByRecord(recordId: string, user: AuthUser): Promise { const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'purchaseSupplierRecommendedProducts') if (!access.view) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有供应商新品推荐权限') @@ -119,19 +107,14 @@ export class SupplierRecommendedImageSearchService { const imageUrl = (record.imageUrl ?? '').trim() if (!imageUrl) throw new AppError(ErrorCode.VALIDATION_FAILED, '该记录没有图片,无法以图搜图') - const { base64, filename } = await this.fetchImageAsBase64(imageUrl) - const upstream = await this.callUpstream({ - imageBase64: base64, - filename, - topK: options.topK ?? SUPPLIER_RECOMMENDED_IMAGE_SEARCH_LIMITS.DEFAULT_TOP_K, - scoreThreshold: options.scoreThreshold ?? SUPPLIER_RECOMMENDED_IMAGE_SEARCH_LIMITS.DEFAULT_SCORE_THRESHOLD, - }) + const base64 = await this.fetchImageAsBase64(imageUrl) + const upstream = await this.callUpstream(base64) return toClientResult(upstream, null) } // 记录图片可能是对象存储稳定 URL(/api/storage/object?key=...,图搜侧不可访问)或外链; // 统一在后端取成 buffer 再转 base64,规避图搜接口「image_url 禁止内网地址」的限制。 - private async fetchImageAsBase64(imageUrl: string): Promise<{ base64: string; filename: string }> { + private async fetchImageAsBase64(imageUrl: string): Promise { const serverUrl = resolveObjectStorageUrlForServerAccess(imageUrl) let response: Response try { @@ -152,10 +135,10 @@ export class SupplierRecommendedImageSearchService { if (buffer.length > SUPPLIER_RECOMMENDED_IMAGE_SEARCH_LIMITS.MAX_IMAGE_BYTES) { throw new AppError(ErrorCode.FILE_TOO_LARGE, '图片超过图搜接口 12MB 限制,请先更换更小的图片') } - return { base64: buffer.toString('base64'), filename: guessFilenameFromUrl(serverUrl) } + return buffer.toString('base64') } - private async callUpstream(query: UpstreamQuery): Promise { + private async callUpstream(imageBase64: string): Promise { const apiKey = resolveApiKey() if (!apiKey) { throw new AppError(ErrorCode.SYSTEM_INTERNAL, '图搜服务未配置(缺少 IMAGE_SEARCH_API_KEY),请联系管理员在部署环境补齐配置') @@ -166,13 +149,7 @@ export class SupplierRecommendedImageSearchService { response = await fetch(`${resolveApiBase()}${UPSTREAM_PATH}`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - image_base64: query.imageBase64, - filename: query.filename, - top_k: query.topK, - score_threshold: query.scoreThreshold, - search_mode: 'appearance', - }), + body: JSON.stringify({ image_base64: imageBase64 }), signal: AbortSignal.timeout(resolveUpstreamTimeoutMs()), }) } catch (error) {