fix(supplier-recommended): use upstream image search defaults
This commit is contained in:
parent
a2dab2cd49
commit
da3a49f03f
@ -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 })
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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('当前账号没有供应商新品推荐权限')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user