fix(supplier-recommended): use upstream image search defaults

This commit is contained in:
WuYongJie 2026-09-28 14:09:50 +08:00
parent a2dab2cd49
commit da3a49f03f
4 changed files with 23 additions and 56 deletions

View File

@ -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 })
}

View File

@ -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<typeof supplierRecommendedImageSearchInputSchema>

View File

@ -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('当前账号没有供应商新品推荐权限')
})
})

View File

@ -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<SupplierRecommendedImageSearchClientResult> {
async searchByRecord(recordId: string, user: AuthUser): Promise<SupplierRecommendedImageSearchClientResult> {
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<string> {
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<ImageSearchUpstreamResponse> {
private async callUpstream(imageBase64: string): Promise<ImageSearchUpstreamResponse> {
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) {