产品全量档案以图搜图改用壹点图搜接口并增加本地向量双层检索

- A 层:调壹点图搜上游(ownProductMatches 我司 SKU 命中),上游调用收口为共享 client,供应商推荐搜索市场同款同步复用
- B 层:本地 DINOv2 向量索引覆盖全量档案图(宽松排序、同图哈希置顶、模型未就绪自动降级)
- 上游编码与档案条码/SKU 大小写不敏感匹配,两路结果按条码去重
- 弹窗分「我司 SKU 命中」「全量档案相似」两区展示,本地索引通过 scripts/index-product-archive-images.ts 增量维护
This commit is contained in:
WuYongJie 2026-09-29 17:54:54 +08:00
parent 1dcaa48baf
commit b9dc0bddb1
11 changed files with 830 additions and 401 deletions

View File

@ -1,9 +1,13 @@
import { NextRequest } from 'next/server'
import { IMAGE_SEARCH_UPSTREAM_LIMITS } from '@/lib/image-search-upstream'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { productArchiveImageSearchService } from '@/server/services/product-archive-image-search.service'
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
// 图搜上游同步等待可能长达数分钟(对接文档建议 180-600s)
export const maxDuration = 300
const MAX_IMAGE_BYTES = IMAGE_SEARCH_UPSTREAM_LIMITS.MAX_IMAGE_BYTES
export async function POST(request: NextRequest) {
return handleRequest(
@ -30,7 +34,7 @@ export async function POST(request: NextRequest) {
if (file.size <= 0 || file.size > MAX_IMAGE_BYTES) {
return jsonError(
ErrorCode.FILE_TOO_LARGE,
'图片大小需在 1B 到 10MB 之间',
'图片大小需在 1B 到 12MB 之间',
400,
ctx.requestId,
)

View File

@ -4,9 +4,19 @@ import Image from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react'
import { LoaderCircle, Search, Upload, X } from 'lucide-react'
import type { ApiResponse } from '@/server/core/types'
import type { ProductArchiveImageSearchResponse } from '@/lib/product-archive-image-search'
import type {
ProductArchiveImageSearchResponse,
ProductArchiveLocalSearchResult,
ProductArchiveImageSearchResult,
} from '@/lib/product-archive-image-search'
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
const MAX_IMAGE_BYTES = 12 * 1024 * 1024
function formatScore(score: number | null) {
if (score === null) return '-'
if (score > 1) return `${Math.round(score)}%`
return `${Math.round(score * 100)}%`
}
export function ProductArchiveImageSearchDialog({
onClose,
@ -42,7 +52,7 @@ export function ProductArchiveImageSearchDialog({
return
}
if (nextFile.size <= 0 || nextFile.size > MAX_IMAGE_BYTES) {
setError('图片大小不能超过 10MB')
setError('图片大小不能超过 12MB')
return
}
setFile(nextFile)
@ -87,6 +97,27 @@ export function ProductArchiveImageSearchDialog({
[acceptFile],
)
const hasAnyResult = result && (result.results.length > 0 || result.archiveResults.length > 0)
const renderRow = (item: ProductArchiveImageSearchResult | ProductArchiveLocalSearchResult) => (
<div className="image-search-result-row" key={item.barcode}>
{item.image ? (
<Image unoptimized src={item.image} alt={item.productName || item.sku} width={56} height={56} className="image-search-result-image" />
) : (
<span className="image-search-result-image image-search-result-image-empty" aria-hidden="true" />
)}
<div className="image-search-result-fields">
<strong>{item.productName || item.sku || item.barcode}</strong>
<span>
条码 {item.barcode} · SPU {item.spu || '-'} · SKU {item.sku || '-'}
{'colorName' in item && item.colorName ? ` · ${item.colorName}` : ''}
</span>
</div>
<span className="image-search-score">相似度 {formatScore(item.score)}</span>
<button type="button" className="image-search-locate" onClick={() => { onLocateBarcode(item.barcode); onClose() }}>定位</button>
</div>
)
return (
<div className="image-search-backdrop" role="presentation" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
@ -95,7 +126,7 @@ export function ProductArchiveImageSearchDialog({
<header className="image-search-header">
<div>
<h2 id="image-search-title">以图搜图</h2>
<p>按家具主体造型严格匹配;无法确认是同款时不返回结果。</p>
<p>上传图片搜索产品全量档案,结果按相似度排序;搜索可能需要 1~3 分钟,请耐心等待。</p>
</div>
<button type="button" className="image-search-icon-button" onClick={onClose} aria-label="关闭">
<X size={18} />
@ -114,9 +145,7 @@ export function ProductArchiveImageSearchDialog({
acceptFile(event.dataTransfer.files[0] ?? null)
}}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') inputRef.current?.click()
}}
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') inputRef.current?.click() }}
>
{previewUrl ? (
<Image unoptimized src={previewUrl} alt="待搜索图片" width={132} height={132} className="image-search-preview" />
@ -124,7 +153,7 @@ export function ProductArchiveImageSearchDialog({
<>
<Upload size={24} strokeWidth={1.7} />
<strong>选择、拖入或粘贴图片</strong>
<span>支持 JPG、PNG、WEBP,单张不超过 10MB</span>
<span>支持 JPG、PNG、WEBP,单张不超过 12MB</span>
</>
)}
<input ref={inputRef} type="file" accept="image/*" hidden onChange={(event) => acceptFile(event.target.files?.[0] ?? null)} />
@ -140,33 +169,49 @@ export function ProductArchiveImageSearchDialog({
<div className="image-search-actions">
<span className="image-search-index-status">
{result ? `当前可检索 ${result.indexedCount.toLocaleString()} 条档案图片` : '仅搜索产品全量档案图片'}
{loading
? '正在搜索,可能需要 1~3 分钟…'
: result
? (result.ownProductIndex.enabled && result.ownProductIndex.points !== null
? `我司 SKU 索引 ${result.ownProductIndex.points.toLocaleString()} 条`
: '我司 SKU 索引') +
(result.localIndex.ready
? ` · 本地档案索引 ${result.localIndex.indexedCount.toLocaleString()} 条`
: '')
: '两层检索:我司 SKU 索引 + 全量档案本地索引'}
</span>
<button type="button" className="image-search-submit" onClick={() => void search()} disabled={loading || !file}>
{loading ? <LoaderCircle size={15} className="image-search-spin" /> : <Search size={15} />}
开始搜索
{loading ? '搜索中…' : '开始搜索'}
</button>
</div>
{result?.notice && <p className="image-search-notice">{result.notice}</p>}
{result && !result.notice && (
<div className="image-search-results">
<div className="image-search-results-heading">同款结果({result.results.length})</div>
{result.results.length === 0 ? (
<div className="image-search-empty">没有找到可确认的同款家具</div>
) : (
result.results.map((item) => (
<div className="image-search-result-row" key={item.id}>
<Image unoptimized src={item.image} alt={item.productName || item.sku} width={56} height={56} className="image-search-result-image" />
<div className="image-search-result-fields">
<strong>{item.productName || item.spu || item.sku || '未命名商品'}</strong>
<span>SPU {item.spu || '-'} · SKU {item.sku || '-'} · 条码 {item.barcode || '-'}</span>
</div>
<span className="image-search-score">高置信同款</span>
<button type="button" className="image-search-locate" onClick={() => { onLocateBarcode(item.barcode); onClose() }}>定位</button>
</div>
))
{result && result.localIndex.ready === false && (
<p className="image-search-notice">本地图片模型尚未就绪,本次仅返回我司 SKU 命中结果;请联系管理员准备本地模型。</p>
)}
{result && hasAnyResult && (
<div className="image-search-results">
{result.results.length > 0 && (
<>
<div className="image-search-results-heading">
我司 SKU 命中({result.results.length})
{result.unmatchedCount > 0 ? `,另有 ${result.unmatchedCount} 条命中未对应到档案` : ''}
</div>
{result.results.map(renderRow)}
</>
)}
{result.archiveResults.length > 0 && (
<>
<div className="image-search-results-heading">全量档案相似({result.archiveResults.length})</div>
{result.archiveResults.map(renderRow)}
</>
)}
</div>
)}
{result && !hasAnyResult && !result.notice && result.localIndex.ready && (
<div className="image-search-results">
<div className="image-search-empty">没有找到相似产品</div>
</div>
)}
</section>
@ -198,10 +243,11 @@ export function ProductArchiveImageSearchDialog({
.image-search-empty { padding: 28px 22px 30px; color: #a1a1aa; font-size: 13px; text-align: center; }
.image-search-result-row { display: flex; align-items: center; gap: 12px; padding: 10px 22px; border-top: 1px solid #f4f4f5; }
.image-search-result-image { flex: 0 0 auto; width: 56px; height: 56px; border: 1px solid #e4e4e7; object-fit: cover; }
.image-search-result-image-empty { background: #f4f4f5; }
.image-search-result-fields { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 5px; }
.image-search-result-fields strong { overflow: hidden; color: #3f3f46; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.image-search-result-fields span { overflow: hidden; color: #71717a; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.image-search-score { flex: 0 0 76px; color: #166534; font-size: 12px; font-weight: 650; text-align: right; }
.image-search-score { flex: 0 0 92px; color: #166534; font-size: 12px; font-weight: 650; text-align: right; }
.image-search-locate { flex: 0 0 auto; height: 28px; padding: 0 10px; border-color: #d4d4d8; background: #fff; color: #3f3f46; font-size: 12px; }
.image-search-locate:hover { background: #f4f4f5; }
.image-search-spin { animation: image-search-spin 1s linear infinite; }

View File

@ -0,0 +1,132 @@
// ============ 壹点相似市场款图搜 OpenAPI 共享契约 ============
// 供应商新品推荐「搜索市场同款」与产品全量档案「以图搜图」共用同一上游服务,
// 上游类型与限制统一收口在这里,业务侧 lib 只保留各自的裁剪与展示结构。
export const IMAGE_SEARCH_UPSTREAM_LIMITS = {
// 图搜接口限制:上传图片最大 12MB(见对接文档 §5)
MAX_IMAGE_BYTES: 12 * 1024 * 1024,
} as const
export type ImageSearchUpstreamSku = {
skuRecordId?: number
itemId?: string
skuId?: string
skuKey?: string
skuName?: string
skuSize?: string
imageUrl?: string
}
export type ImageSearchUpstreamProduct = {
itemId?: string
channel?: string
title?: string
detailLink?: string
mainImageUrl?: string
shopName?: string
price?: string
priceRange?: string
sales?: string
marketCategoryName?: string
marketCommonCategoryName?: string
standardProductType?: string
coreMaterialName?: string
productMaterial?: string
}
export type ImageSearchUpstreamMarketMatch = {
rank?: number
score?: number
itemId?: string
sku?: ImageSearchUpstreamSku
product?: ImageSearchUpstreamProduct
}
export type ImageSearchUpstreamSameStyleProduct = {
itemId?: string
title?: string
detailLink?: string
mainImageUrl?: string
shopName?: string
price?: string
priceRange?: string
sales?: string
score?: number
matchedSku?: ImageSearchUpstreamSku
}
export type ImageSearchUpstreamSpuGroup = {
rank?: number
spuKey?: string
confidence?: number
matchCount?: number
sameStyleProductCount?: number
appearanceCode?: string
marketCategoryName?: string
marketCommonCategoryName?: string
standardProductType?: string
coreMaterialName?: string
representativeProduct?: {
itemId?: string
title?: string
detailLink?: string
mainImageUrl?: string
shopName?: string
price?: string
priceRange?: string
sales?: string
}
sameStyleProducts?: ImageSearchUpstreamSameStyleProduct[]
}
export type ImageSearchUpstreamOwnMatch = {
rank?: number
score?: number
source?: string
sourceTable?: string
sourceRecordId?: number
maySkuCode?: string
productCode?: string
skuName?: string
skuSize?: string
skuImageUrl?: string
colorName?: string
category?: string
brand?: string
saleStatus?: string
}
export type ImageSearchUpstreamData = {
summary?: {
rawMatchCount?: number
marketMatchCount?: number
matchedSpuCount?: number
marketProductLinkCount?: number
ownProductMatchCount?: number
}
marketMatches?: ImageSearchUpstreamMarketMatch[]
matchedSpus?: ImageSearchUpstreamSpuGroup[]
marketProductLinks?: Array<{
itemId?: string
sourceItemId?: string
title?: string
detailLink?: string
mainImageUrl?: string
score?: number
}>
ownProductMatches?: ImageSearchUpstreamOwnMatch[]
ownProductIndex?: {
enabled?: boolean
sourceTable?: string
collection?: string
points?: number
}
}
export type ImageSearchUpstreamResponse = {
success?: boolean
request_id?: string
data?: ImageSearchUpstreamData
error?: string
message?: string
}

View File

@ -1,19 +1,59 @@
// ============ 产品全量档案「以图搜图」共享类型 ============
// 双层混合检索:A 层调壹点图搜上游(ownProductMatches 我司 SKU 命中,质量优先),
// B 层本地 DINOv2 向量索引(全量档案图覆盖优先),前端分两区展示,分数口径不混排。
// 本地向量层专用:与 ProductArchiveImageEmbedding 表中已有向量一致,改动即需全量重建索引
export const PRODUCT_ARCHIVE_IMAGE_SEARCH_MODEL_VERSION = 'dinov2-small-shape-v1'
export const PRODUCT_ARCHIVE_IMAGE_SEARCH_DIMENSIONS = 768
export type ProductArchiveImageSearchResult = {
id: string
image: string
rank: number
score: number | null
barcode: string
spu: string
sku: string
barcode: string
productName: string
confidence: 'HIGH'
image: string
maySkuCode: string
skuName: string
skuSize: string
colorName: string
category: string
brand: string
saleStatus: string
}
export type ProductArchiveLocalSearchResult = {
rank: number
score: number | null
barcode: string
spu: string
sku: string
productName: string
image: string
}
export type ProductArchiveImageSearchResponse = {
requestId: string | null
/** A 层:我司 SKU 命中(上游索引,按上游相似度排序) */
results: ProductArchiveImageSearchResult[]
/** B 层:全量档案相似(本地索引,已去掉 A 层出现过的条码) */
archiveResults: ProductArchiveLocalSearchResult[]
/** 上游「我司 SKU 命中」总条数(裁剪上限内) */
ownMatchCount: number
/** A 层成功对应到产品全量档案的条数 */
matchedCount: number
/** A 层未能对应到产品全量档案的条数 */
unmatchedCount: number
ownProductIndex: {
enabled: boolean
sourceTable: string
points: number | null
}
/** B 层本地索引状态 */
localIndex: {
ready: boolean
indexedCount: number
modelReady: boolean
notice?: string | null
}
notice: string | null
}

View File

@ -1,10 +1,21 @@
import { z } from 'zod'
import {
IMAGE_SEARCH_UPSTREAM_LIMITS,
type ImageSearchUpstreamData,
type ImageSearchUpstreamOwnMatch,
type ImageSearchUpstreamResponse,
type ImageSearchUpstreamSameStyleProduct,
type ImageSearchUpstreamSku,
type ImageSearchUpstreamSpuGroup,
type ImageSearchUpstreamProduct,
type ImageSearchUpstreamMarketMatch,
} from '@/lib/image-search-upstream'
// ============ 以图搜图(壹点相似市场款 OpenAPI)限制与默认值 ============
export const SUPPLIER_RECOMMENDED_IMAGE_SEARCH_LIMITS = {
// 图搜接口限制:上传图片最大 12MB(见对接文档 §5)
MAX_IMAGE_BYTES: 12 * 1024 * 1024,
MAX_IMAGE_BYTES: IMAGE_SEARCH_UPSTREAM_LIMITS.MAX_IMAGE_BYTES,
// 返回给前端的裁剪上限,避免整包响应过大
MAX_SAME_STYLE_PRODUCTS_PER_SPU: 6,
MAX_OWN_PRODUCT_MATCHES: 20,
@ -17,130 +28,18 @@ export const supplierRecommendedImageSearchInputSchema = z.object({
export type SupplierRecommendedImageSearchInput = z.infer<typeof supplierRecommendedImageSearchInputSchema>
// ============ 上游响应(只声明实际用到的字段,见对接文档 §6/§7) ============
// ============ 上游响应:共享契约见 @/lib/image-search-upstream,这里保留历史导出名 ============
export type ImageSearchUpstreamSku = {
skuRecordId?: number
itemId?: string
skuId?: string
skuKey?: string
skuName?: string
skuSize?: string
imageUrl?: string
}
export type ImageSearchUpstreamProduct = {
itemId?: string
channel?: string
title?: string
detailLink?: string
mainImageUrl?: string
shopName?: string
price?: string
priceRange?: string
sales?: string
marketCategoryName?: string
marketCommonCategoryName?: string
standardProductType?: string
coreMaterialName?: string
productMaterial?: string
}
export type ImageSearchUpstreamMarketMatch = {
rank?: number
score?: number
itemId?: string
sku?: ImageSearchUpstreamSku
product?: ImageSearchUpstreamProduct
}
export type ImageSearchUpstreamSameStyleProduct = {
itemId?: string
title?: string
detailLink?: string
mainImageUrl?: string
shopName?: string
price?: string
priceRange?: string
sales?: string
score?: number
matchedSku?: ImageSearchUpstreamSku
}
export type ImageSearchUpstreamSpuGroup = {
rank?: number
spuKey?: string
confidence?: number
matchCount?: number
sameStyleProductCount?: number
appearanceCode?: string
marketCategoryName?: string
marketCommonCategoryName?: string
standardProductType?: string
coreMaterialName?: string
representativeProduct?: {
itemId?: string
title?: string
detailLink?: string
mainImageUrl?: string
shopName?: string
price?: string
priceRange?: string
sales?: string
}
sameStyleProducts?: ImageSearchUpstreamSameStyleProduct[]
}
export type ImageSearchUpstreamOwnMatch = {
rank?: number
score?: number
source?: string
sourceTable?: string
sourceRecordId?: number
maySkuCode?: string
productCode?: string
skuName?: string
skuSize?: string
skuImageUrl?: string
colorName?: string
category?: string
brand?: string
saleStatus?: string
}
export type ImageSearchUpstreamData = {
summary?: {
rawMatchCount?: number
marketMatchCount?: number
matchedSpuCount?: number
marketProductLinkCount?: number
ownProductMatchCount?: number
}
marketMatches?: ImageSearchUpstreamMarketMatch[]
matchedSpus?: ImageSearchUpstreamSpuGroup[]
marketProductLinks?: Array<{
itemId?: string
sourceItemId?: string
title?: string
detailLink?: string
mainImageUrl?: string
score?: number
}>
ownProductMatches?: ImageSearchUpstreamOwnMatch[]
ownProductIndex?: {
enabled?: boolean
sourceTable?: string
collection?: string
points?: number
}
}
export type ImageSearchUpstreamResponse = {
success?: boolean
request_id?: string
data?: ImageSearchUpstreamData
error?: string
message?: string
export type {
IMAGE_SEARCH_UPSTREAM_LIMITS,
ImageSearchUpstreamData,
ImageSearchUpstreamMarketMatch,
ImageSearchUpstreamOwnMatch,
ImageSearchUpstreamProduct,
ImageSearchUpstreamResponse,
ImageSearchUpstreamSameStyleProduct,
ImageSearchUpstreamSku,
ImageSearchUpstreamSpuGroup,
}
// ============ 返回给前端的结果(裁剪后) ============

View File

@ -36,10 +36,17 @@ export type ProductArchiveImageEmbeddingRecord =
}>
export interface ProductArchiveImageSearchRepository {
/** 索引脚本分页遍历带图档案 */
listCandidates(params: {
cursor?: string
take: number
}): Promise<ProductArchiveImageCandidate[]>
/** A 层:按上游命中的编码集合批量查档案候选(条码精确 > SKU 精确) */
findCandidatesByCodes(params: {
barcodes: string[]
skus: string[]
}): Promise<ProductArchiveImageCandidate[]>
/** B 层与索引脚本:本地向量表读写 */
upsertEmbedding(params: {
archiveId: string
imageUrl: string
@ -72,6 +79,28 @@ class ProductArchiveImageSearchRepositoryClass
})
}
async findCandidatesByCodes(params: { barcodes: string[]; skus: string[] }) {
// 上游返回的编码可能是小写(如 szy000212N337),档案条码是大写,统一大小写不敏感匹配
const barcodes = [...new Set(params.barcodes.map((code) => code.trim()).filter(Boolean))]
const skus = [...new Set(params.skus.map((code) => code.trim()).filter(Boolean))]
if (barcodes.length === 0 && skus.length === 0) return []
return prisma.supplyChainProductArchive.findMany({
where: {
...eligibleArchiveWhere,
OR: [
...(barcodes.length > 0
? [{ barcode: { in: barcodes, mode: 'insensitive' as const } }]
: []),
...(skus.length > 0
? [{ sku: { in: skus, mode: 'insensitive' as const } }]
: []),
],
},
select: candidateSelect,
})
}
async upsertEmbedding(params: {
archiveId: string
imageUrl: string

View File

@ -1,104 +1,223 @@
import { rankProductArchiveImageEmbeddings } from '../product-archive-image-shape'
const mockFindCandidatesByCodes = jest.fn()
const mockListReadyEmbeddings = jest.fn()
const mockGetCandidatesByIds = jest.fn()
const mockSearchUpstream = jest.fn()
const mockIsModelReady = jest.fn()
const mockCreateEmbedding = jest.fn()
const mockHashImage = jest.fn()
const mockRankEmbeddings = jest.fn()
function vector(values: number[]) {
const normalized = Float32Array.from(values)
return new Uint8Array(normalized.buffer)
jest.mock('@/server/repositories/product-archive-image-search.repository', () => ({
productArchiveImageSearchRepository: {
findCandidatesByCodes: (...args: unknown[]) => mockFindCandidatesByCodes(...args),
listReadyEmbeddings: (...args: unknown[]) => mockListReadyEmbeddings(...args),
getCandidatesByIds: (...args: unknown[]) => mockGetCandidatesByIds(...args),
},
}))
jest.mock('@/server/services/image-search-upstream-client', () => ({
searchSimilarProductsByImageBase64: (...args: unknown[]) => mockSearchUpstream(...args),
}))
jest.mock('@/server/services/product-archive-image-shape', () => ({
isProductArchiveImageSearchModelReady: () => mockIsModelReady(),
createProductArchiveImageEmbedding: (...args: unknown[]) => mockCreateEmbedding(...args),
hashProductArchiveImage: (...args: unknown[]) => mockHashImage(...args),
rankProductArchiveImageEmbeddings: (...args: unknown[]) => mockRankEmbeddings(...args),
}))
import { ProductArchiveImageSearchService } from '@/server/services/product-archive-image-search.service'
import type { ImageSearchUpstreamResponse } from '@/lib/image-search-upstream'
function candidate(overrides: Partial<{ id: string; barcode: string; spu: string; sku: string; productName: string; image: string }> = {}) {
return {
id: overrides.id ?? 'arch-1',
barcode: overrides.barcode ?? '6901234500018',
spu: overrides.spu ?? 'ZSJ0081',
sku: overrides.sku ?? 'ZSJ008103N958V01',
productName: overrides.productName ?? '示例家具',
image: overrides.image ?? '/api/storage/object?key=archive%2Fa.jpg',
}
}
describe('product archive image search scoring', () => {
it('returns only the best high-confidence furniture image', () => {
const results = rankProductArchiveImageEmbeddings({
query: Float32Array.from([1, 0]),
records: [
{
archiveId: 'different',
imageHash: 'different-image',
embedding: vector([0.6, 0.8]),
dimensions: 2,
},
{
archiveId: 'same',
imageHash: 'same-image',
embedding: vector([1, 0]),
dimensions: 2,
function upstreamBody(overrides: {
ownProductMatches?: Array<Record<string, unknown>>
ownProductIndex?: Record<string, unknown>
}): ImageSearchUpstreamResponse {
return {
success: true,
request_id: 'req-1',
data: {
ownProductMatches: overrides.ownProductMatches ?? [],
ownProductIndex: overrides.ownProductIndex ?? { enabled: true, sourceTable: 'jst_sku', points: 20006 },
},
}
}
const READY_EMBEDDINGS = [
{ archiveId: 'arch-1', imageUrl: 'u1', imageHash: 'h1', embedding: new Uint8Array(16), dimensions: 4, modelVersion: 'dinov2-small-shape-v1' },
{ archiveId: 'arch-2', imageUrl: 'u2', imageHash: 'h2', embedding: new Uint8Array(16), dimensions: 4, modelVersion: 'dinov2-small-shape-v1' },
]
describe('ProductArchiveImageSearchService(双层混合检索)', () => {
beforeEach(() => {
jest.clearAllMocks()
mockIsModelReady.mockReturnValue(true)
mockListReadyEmbeddings.mockResolvedValue(READY_EMBEDDINGS)
mockCreateEmbedding.mockResolvedValue({ vector: new Float32Array(4), buffer: Buffer.alloc(0), dimensions: 4 })
mockHashImage.mockResolvedValue('hash-query')
mockRankEmbeddings.mockReturnValue([
{ archiveId: 'arch-1', imageHash: 'h1', cosine: 0.92 },
{ archiveId: 'arch-2', imageHash: 'h2', cosine: 0.71 },
])
mockGetCandidatesByIds.mockImplementation(async (ids: string[]) => {
const all = [candidate(), candidate({ id: 'arch-2', barcode: '6909999999999', spu: 'CBZ0002', sku: 'CBZ000229', productName: '本地相似款' })]
return all.filter((c) => ids.includes(c.id))
})
})
it('双路正常:A 层命中 + B 层全量相似,重复条码去重', async () => {
mockSearchUpstream.mockResolvedValue(upstreamBody({
ownProductMatches: [
{ rank: 1, score: 0.98, maySkuCode: 'ZSJ008103N958V01', productCode: 'ZSJ0081', skuName: '示例公司SKU', colorName: '原木色' },
],
limit: 20,
}))
mockFindCandidatesByCodes.mockResolvedValue([candidate()])
const service = new ProductArchiveImageSearchService()
const result = await service.search({ buffer: Buffer.from('img'), contentType: 'image/png' })
expect(result.results).toHaveLength(1)
expect(result.results[0]).toMatchObject({ rank: 1, score: 0.98, barcode: '6901234500018', colorName: '原木色' })
// 本地 2 条中 arch-1 与 A 层同条码被去掉,只剩 arch-2
expect(result.archiveResults).toHaveLength(1)
expect(result.archiveResults[0]).toMatchObject({ barcode: '6909999999999', sku: 'CBZ000229', score: 0.71 })
expect(result.localIndex).toEqual({ ready: true, indexedCount: 2 })
expect(result.ownProductIndex).toEqual({ enabled: true, sourceTable: 'jst_sku', points: 20006 })
expect(result.notice).toBeNull()
})
expect(results.map((result) => result.archiveId)).toEqual(['same'])
it('本地模型未就绪时降级:只返回 A 层,localIndex.ready=false', async () => {
mockIsModelReady.mockReturnValue(false)
mockSearchUpstream.mockResolvedValue(upstreamBody({
ownProductMatches: [{ rank: 1, score: 0.9, maySkuCode: 'ZSJ008103N958V01' }],
}))
mockFindCandidatesByCodes.mockResolvedValue([candidate()])
const service = new ProductArchiveImageSearchService()
const result = await service.search({ buffer: Buffer.from('img'), contentType: 'image/png' })
expect(result.results).toHaveLength(1)
expect(result.archiveResults).toHaveLength(0)
expect(result.localIndex).toEqual({ ready: false, indexedCount: 0 })
expect(mockListReadyEmbeddings).not.toHaveBeenCalled()
})
it('returns no result when the best furniture shape is ambiguous', () => {
const results = rankProductArchiveImageEmbeddings({
query: Float32Array.from([1, 0]),
records: [
{
archiveId: 'first',
imageHash: 'first-image',
embedding: vector([0.9, 0.4359]),
dimensions: 2,
},
{
archiveId: 'second',
imageHash: 'second-image',
embedding: vector([0.86, 0.5103]),
dimensions: 2,
},
],
it('上游失败本地成功:不抛错,返回本地结果并给出提示', async () => {
mockSearchUpstream.mockRejectedValue(new Error('图搜服务不可达,请确认部署环境与图搜服务的网络连通'))
const service = new ProductArchiveImageSearchService()
const result = await service.search({ buffer: Buffer.from('img'), contentType: 'image/png' })
expect(result.results).toHaveLength(0)
expect(result.archiveResults).toHaveLength(2)
expect(result.requestId).toBeNull()
expect(result.notice).toContain('我司 SKU 命中层调用失败')
expect(result.notice).toContain('图搜服务不可达')
})
expect(results).toEqual([])
it('双路都失败时抛错', async () => {
mockIsModelReady.mockReturnValue(false)
mockSearchUpstream.mockRejectedValue(new Error('图搜服务不可达'))
const service = new ProductArchiveImageSearchService()
await expect(service.search({ buffer: Buffer.from('img'), contentType: 'image/png' }))
.rejects.toThrow('图搜服务不可达')
})
it('returns all archive rows that reuse the winning image', () => {
const results = rankProductArchiveImageEmbeddings({
query: Float32Array.from([1, 0]),
records: [
{
archiveId: 'same-a',
imageHash: 'same-image',
embedding: vector([1, 0]),
dimensions: 2,
},
{
archiveId: 'same-b',
imageHash: 'same-image',
embedding: vector([1, 0]),
dimensions: 2,
},
{
archiveId: 'different',
imageHash: 'different-image',
embedding: vector([0.6, 0.8]),
dimensions: 2,
},
],
it('上游无我司命中:提示参考本地结果', async () => {
mockSearchUpstream.mockResolvedValue(upstreamBody({ ownProductMatches: [] }))
const service = new ProductArchiveImageSearchService()
const result = await service.search({ buffer: Buffer.from('img'), contentType: 'image/png' })
expect(result.results).toHaveLength(0)
expect(result.notice).toBe('我司 SKU 命中没有结果,可参考下方全量档案相似结果')
expect(result.archiveResults).toHaveLength(2)
})
expect(results.map((result) => result.archiveId)).toEqual(['same-a', 'same-b'])
it('上游命中都对不上档案:给出编码口径提示,本地结果照常', async () => {
mockSearchUpstream.mockResolvedValue(upstreamBody({
ownProductMatches: [{ rank: 1, score: 0.9, maySkuCode: 'NOPE' }],
}))
mockFindCandidatesByCodes.mockResolvedValue([])
const service = new ProductArchiveImageSearchService()
const result = await service.search({ buffer: Buffer.from('img'), contentType: 'image/png' })
expect(result.results).toHaveLength(0)
expect(result.notice).toContain('未能对应到产品全量档案')
expect(result.archiveResults).toHaveLength(2)
})
it('returns exact image matches even when a near-duplicate image is ambiguous', () => {
const results = rankProductArchiveImageEmbeddings({
query: Float32Array.from([1, 0]),
queryImageHash: 'exact-image',
records: [
{
archiveId: 'exact',
imageHash: 'exact-image',
embedding: vector([1, 0]),
dimensions: 2,
},
{
archiveId: 'near-duplicate',
imageHash: 'near-duplicate-image',
embedding: vector([0.99, 0.1411]),
dimensions: 2,
},
],
it('上游返回小写编码时仍能命中大写档案条码', async () => {
mockSearchUpstream.mockResolvedValue(upstreamBody({
ownProductMatches: [{ rank: 1, score: 0.95, maySkuCode: 'zsj008103n958v01' }],
}))
mockFindCandidatesByCodes.mockResolvedValue([candidate()])
const service = new ProductArchiveImageSearchService()
const result = await service.search({ buffer: Buffer.from('img'), contentType: 'image/png' })
expect(result.results).toHaveLength(1)
expect(result.results[0].barcode).toBe('6901234500018')
})
expect(results.map((result) => result.archiveId)).toEqual(['exact'])
it('我司产品索引未启用时明确提示', async () => {
mockSearchUpstream.mockResolvedValue(upstreamBody({
ownProductMatches: [],
ownProductIndex: { enabled: false, sourceTable: '', points: null },
}))
const service = new ProductArchiveImageSearchService()
const result = await service.search({ buffer: Buffer.from('img'), contentType: 'image/png' })
expect(result.notice).toContain('未启用我司产品索引')
expect(result.ownProductIndex.enabled).toBe(false)
})
it('本地索引为空时 B 层返回空且视为就绪', async () => {
// service 内向量缓存 60s,跨用例共享;把时钟拨到缓存过期后隔离本用例
const realNow = Date.now
Date.now = () => realNow() + 120_000
mockListReadyEmbeddings.mockResolvedValue([])
mockSearchUpstream.mockResolvedValue(upstreamBody({ ownProductMatches: [] }))
try {
const service = new ProductArchiveImageSearchService()
const result = await service.search({ buffer: Buffer.from('img'), contentType: 'image/png' })
expect(result.archiveResults).toHaveLength(0)
expect(result.localIndex).toEqual({ ready: true, indexedCount: 0 })
expect(mockCreateEmbedding).not.toHaveBeenCalled()
} finally {
Date.now = realNow
}
})
it('空图片直接校验失败,不调任何一路', async () => {
const service = new ProductArchiveImageSearchService()
await expect(service.search({ buffer: Buffer.alloc(0), contentType: 'image/png' }))
.rejects.toThrow('图片内容为空')
expect(mockSearchUpstream).not.toHaveBeenCalled()
expect(mockListReadyEmbeddings).not.toHaveBeenCalled()
})
it('超过上游 12MB 限制时报文件过大', async () => {
const service = new ProductArchiveImageSearchService()
const big = Buffer.alloc(12 * 1024 * 1024 + 1)
await expect(service.search({ buffer: big, contentType: 'image/png' }))
.rejects.toThrow('12MB')
expect(mockSearchUpstream).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,68 @@
import {
IMAGE_SEARCH_UPSTREAM_LIMITS,
type ImageSearchUpstreamResponse,
} from '@/lib/image-search-upstream'
import { AppError, ErrorCode } from '@/server/core/errors'
// 壹点相似市场款图搜 OpenAPI 的共享调用入口;
// 供应商新品推荐与产品全量档案的以图搜图都从这里发请求,配置与环境变量只有这一份解释。
const UPSTREAM_PATH = '/open-api/image-search/similar-products'
const DEFAULT_API_BASE = 'http://192.168.42.134:7503'
const DEFAULT_UPSTREAM_TIMEOUT_MS = 180_000
export const IMAGE_SEARCH_UPSTREAM_MAX_IMAGE_BYTES = IMAGE_SEARCH_UPSTREAM_LIMITS.MAX_IMAGE_BYTES
export function resolveImageSearchApiBase() {
const base = (process.env.IMAGE_SEARCH_API_BASE ?? '').trim()
return base || DEFAULT_API_BASE
}
export function resolveImageSearchApiKey() {
return (process.env.IMAGE_SEARCH_API_KEY ?? '').trim()
}
export function resolveImageSearchUpstreamTimeoutMs() {
const value = Number(process.env.IMAGE_SEARCH_TIMEOUT_MS)
return Number.isFinite(value) && value >= 30_000 ? value : DEFAULT_UPSTREAM_TIMEOUT_MS
}
export async function searchSimilarProductsByImageBase64(
imageBase64: string,
): Promise<ImageSearchUpstreamResponse> {
const apiKey = resolveImageSearchApiKey()
if (!apiKey) {
throw new AppError(
ErrorCode.SYSTEM_INTERNAL,
'图搜服务未配置(缺少 IMAGE_SEARCH_API_KEY),请联系管理员在部署环境补齐配置',
)
}
let response: Response
try {
response = await fetch(`${resolveImageSearchApiBase()}${UPSTREAM_PATH}`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ image_base64: imageBase64 }),
signal: AbortSignal.timeout(resolveImageSearchUpstreamTimeoutMs()),
})
} catch (error) {
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
throw new AppError(ErrorCode.SYSTEM_INTERNAL, '图搜服务响应超时,请稍后重试')
}
throw new AppError(
ErrorCode.SYSTEM_INTERNAL,
`图搜服务不可达,请确认部署环境与图搜服务(${DEFAULT_API_BASE})的网络连通`,
)
}
const payload = (await response.json().catch(() => null)) as ImageSearchUpstreamResponse | null
if (!response.ok || !payload || payload.success !== true || !payload.data) {
const upstreamMessage = payload?.message || payload?.error || `HTTP ${response.status}`
const requestIdSuffix = payload?.request_id ? `(request_id: ${payload.request_id})` : ''
throw new AppError(
ErrorCode.SYSTEM_INTERNAL,
`图搜接口调用失败:${upstreamMessage}${requestIdSuffix}`,
)
}
return payload
}

View File

@ -1,42 +1,237 @@
import sharp from 'sharp'
import {
IMAGE_SEARCH_UPSTREAM_LIMITS,
type ImageSearchUpstreamOwnMatch,
} from '@/lib/image-search-upstream'
import {
PRODUCT_ARCHIVE_IMAGE_SEARCH_DIMENSIONS,
PRODUCT_ARCHIVE_IMAGE_SEARCH_MODEL_VERSION,
type ProductArchiveImageSearchResponse,
type ProductArchiveImageSearchResult,
type ProductArchiveLocalSearchResult,
} from '@/lib/product-archive-image-search'
import {
productArchiveImageSearchRepository,
type ProductArchiveImageCandidate,
type ProductArchiveImageSearchRepository,
} from '@/server/repositories/product-archive-image-search.repository'
import { searchSimilarProductsByImageBase64 } from '@/server/services/image-search-upstream-client'
import {
createProductArchiveImageEmbedding,
hashProductArchiveImage,
isProductArchiveImageSearchModelReady,
rankProductArchiveImageEmbeddings,
} from './product-archive-image-shape'
} from '@/server/services/product-archive-image-shape'
import { AppError, ErrorCode } from '@/server/core/errors'
const MAX_RESULTS = 20
const MAX_ARCHIVE_RESULTS = 20
const EMBEDDING_CACHE_TTL_MS = 60_000
export { isProductArchiveImageSearchModelReady }
let embeddingCache: {
records: Awaited<ReturnType<ProductArchiveImageSearchRepository['listReadyEmbeddings']>>
expiresAt: number
} | null = null
export { isProductArchiveImageSearchModelReady }
function buildLookup(
candidates: ProductArchiveImageCandidate[],
keyOf: (candidate: ProductArchiveImageCandidate) => string,
) {
const lookup = new Map<string, ProductArchiveImageCandidate>()
for (const candidate of candidates) {
const key = keyOf(candidate)
if (key && !lookup.has(key)) lookup.set(key, candidate)
}
return lookup
}
function mapCandidate(candidate: ProductArchiveImageCandidate) {
return {
id: candidate.id,
image: candidate.image?.trim() ?? '',
barcode: candidate.barcode.trim(),
spu: candidate.spu.trim(),
sku: candidate.sku.trim(),
barcode: candidate.barcode.trim(),
productName: candidate.productName.trim(),
confidence: 'HIGH' as const,
image: candidate.image?.trim() ?? '',
}
}
export class ProductArchiveImageSearchService {
constructor(
private readonly repository: ProductArchiveImageSearchRepository = productArchiveImageSearchRepository,
) {}
async search(params: {
buffer: Buffer
contentType: string
limit?: number
}): Promise<ProductArchiveImageSearchResponse> {
if (params.buffer.byteLength === 0) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '图片内容为空')
}
if (params.buffer.byteLength > IMAGE_SEARCH_UPSTREAM_LIMITS.MAX_IMAGE_BYTES) {
throw new AppError(ErrorCode.FILE_TOO_LARGE, '图片超过图搜接口 12MB 限制,请先更换更小的图片')
}
const limit = Math.min(Math.max(1, params.limit ?? MAX_RESULTS), MAX_RESULTS)
// A 层(上游我司 SKU 索引)与 B 层(本地全量档案向量)并行检索,任一路异常降级为提示,不阻断另一路
const [upstreamOutcome, localOutcome] = await Promise.allSettled([
this.searchUpstream(params),
this.searchLocal(params, limit),
])
const upstreamFailed = upstreamOutcome.status === 'rejected'
const local = localOutcome.status === 'fulfilled' ? localOutcome.value : null
// 上游失败时只有本地层真正可用(模型就绪且拿到索引)才降级;否则整体失败必须抛错
if (upstreamFailed && local?.status !== 'ok') {
throw upstreamOutcome.reason instanceof Error
? upstreamOutcome.reason
: new AppError(ErrorCode.SYSTEM_INTERNAL, '图片搜索失败')
}
const upstream = upstreamFailed ? null : upstreamOutcome.value
const upstreamBarcodes = new Set((upstream?.results ?? []).map((item) => item.barcode))
const archiveResults = (local?.results ?? [])
.filter((item) => !upstreamBarcodes.has(item.barcode))
.slice(0, MAX_ARCHIVE_RESULTS)
let notice: string | null = null
if (upstreamFailed) {
notice = `我司 SKU 命中层调用失败(${upstreamOutcome.reason instanceof Error ? upstreamOutcome.reason.message : '未知错误'}),已返回本地索引结果`
} else if (upstream && upstream.ownProductIndex.enabled === false) {
notice = '图搜服务未启用我司产品索引,请联系管理员确认图搜服务配置'
} else if (upstream && upstream.ownMatches.length === 0) {
notice = '我司 SKU 命中没有结果,可参考下方全量档案相似结果'
} else if (upstream && upstream.results.length === 0 && upstream.ownMatches.length > 0) {
notice = `图搜命中 ${upstream.ownMatches.length} 条我司 SKU,但都未能对应到产品全量档案,请联系管理员核对编码口径`
}
return {
requestId: upstream?.requestId ?? null,
results: upstream?.results ?? [],
archiveResults,
ownMatchCount: upstream?.ownMatches.length ?? 0,
matchedCount: upstream?.results.length ?? 0,
unmatchedCount: upstream?.unmatchedCount ?? 0,
ownProductIndex: upstream?.ownProductIndex ?? { enabled: true, sourceTable: '', points: null },
localIndex: {
ready: local?.status === 'ok',
indexedCount: local?.indexedCount ?? 0,
},
notice,
}
}
/** A 层:调上游图搜,取 ownProductMatches 换算为档案条码 */
private async searchUpstream(params: { buffer: Buffer }) {
const upstream = await searchSimilarProductsByImageBase64(params.buffer.toString('base64'))
const data = upstream.data ?? {}
const ownMatches = (data.ownProductMatches ?? []).slice(0, MAX_RESULTS)
const codes = ownMatches.flatMap((match) => [match.maySkuCode, match.productCode])
.map((code) => (code ?? '').trim())
.filter(Boolean)
const candidates = codes.length > 0
? await this.repository.findCandidatesByCodes({ barcodes: codes, skus: codes })
: []
const byBarcode = buildLookup(candidates, (candidate) => candidate.barcode.trim().toUpperCase())
const bySku = buildLookup(candidates, (candidate) => candidate.sku.trim().toUpperCase())
const usedBarcodes = new Set<string>()
const results: ProductArchiveImageSearchResult[] = []
let unmatchedCount = 0
ownMatches.forEach((match) => {
// 上游命中编码优先按条码精确定位,条码没有命中再按 SKU 码定位,避免误挂到同 SPU 的其他变体;
// 上游编码可能小写、档案编码大写,统一按大写比较
const matchCodes = [match.maySkuCode, match.productCode]
.map((code) => (code ?? '').trim().toUpperCase())
.filter(Boolean)
const candidate =
matchCodes.map((code) => byBarcode.get(code)).find(Boolean) ??
matchCodes.map((code) => bySku.get(code)).find(Boolean)
if (!candidate) {
unmatchedCount += 1
return
}
if (usedBarcodes.has(candidate.barcode)) return
usedBarcodes.add(candidate.barcode)
results.push(toUpstreamResult(match, candidate, results.length))
})
const ownProductIndex = data.ownProductIndex ?? {}
return {
requestId: upstream.request_id ?? null,
results,
ownMatches,
unmatchedCount,
ownProductIndex: {
enabled: ownProductIndex.enabled === true,
sourceTable: ownProductIndex.sourceTable ?? '',
points: typeof ownProductIndex.points === 'number' ? ownProductIndex.points : null,
},
}
}
/** B 层:本地 DINOv2 向量索引,覆盖上游索引之外的档案图;模型未就绪或出错时降级 */
private async searchLocal(params: {
buffer: Buffer
contentType: string
}, limit: number): Promise<{
status: 'ok' | 'skipped'
results: ProductArchiveLocalSearchResult[]
indexedCount: number
}> {
if (!isProductArchiveImageSearchModelReady()) {
return { status: 'skipped', results: [], indexedCount: 0 }
}
const records = await this.getReadyEmbeddings()
if (records.length === 0) {
return { status: 'ok', results: [], indexedCount: 0 }
}
const [embedding, queryImageHash] = await Promise.all([
createProductArchiveImageEmbedding(params),
hashProductArchiveImage(params.buffer),
])
const ranked = rankProductArchiveImageEmbeddings({
query: embedding.vector,
queryImageHash,
records,
limit,
})
const candidates = await this.repository.getCandidatesByIds(
ranked.map((item) => item.archiveId),
)
const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]))
const results: ProductArchiveLocalSearchResult[] = []
for (const item of ranked) {
const candidate = byId.get(item.archiveId)
if (!candidate) continue
results.push({
rank: results.length + 1,
score: item.cosine,
...mapCandidate(candidate),
})
}
return { status: 'ok', results, indexedCount: records.length }
}
private async getReadyEmbeddings() {
if (embeddingCache && embeddingCache.expiresAt > Date.now()) return embeddingCache.records
const records = await this.repository.listReadyEmbeddings(
PRODUCT_ARCHIVE_IMAGE_SEARCH_MODEL_VERSION,
)
embeddingCache = { records, expiresAt: Date.now() + EMBEDDING_CACHE_TTL_MS }
return records
}
}
/** 索引脚本用:为单条档案图片计算/更新本地向量(图未变化时跳过) */
export async function indexProductArchiveImageCandidate(
candidate: ProductArchiveImageCandidate,
repository: ProductArchiveImageSearchRepository = productArchiveImageSearchRepository,
@ -101,74 +296,22 @@ export async function indexProductArchiveImageCandidate(
}
}
async function getReadyEmbeddings(repository: ProductArchiveImageSearchRepository) {
if (embeddingCache && embeddingCache.expiresAt > Date.now()) return embeddingCache.records
const records = await repository.listReadyEmbeddings(
PRODUCT_ARCHIVE_IMAGE_SEARCH_MODEL_VERSION,
)
embeddingCache = { records, expiresAt: Date.now() + EMBEDDING_CACHE_TTL_MS }
return records
}
export class ProductArchiveImageSearchService {
constructor(
private readonly repository: ProductArchiveImageSearchRepository = productArchiveImageSearchRepository,
) {}
async search(params: {
buffer: Buffer
contentType: string
limit?: number
}): Promise<ProductArchiveImageSearchResponse> {
const mirroredBuffer = await sharp(params.buffer).rotate().flop().toBuffer()
const [embedding, mirroredEmbedding, queryImageHash] = await Promise.all([
createProductArchiveImageEmbedding(params),
createProductArchiveImageEmbedding({
buffer: mirroredBuffer,
contentType: params.contentType,
}),
hashProductArchiveImage(params.buffer),
])
const records = await getReadyEmbeddings(this.repository)
const scored = [embedding.vector, mirroredEmbedding.vector]
.map((query, index) =>
rankProductArchiveImageEmbeddings({
query,
queryImageHash: index === 0 ? queryImageHash : undefined,
records,
limit: params.limit,
}),
)
.reduce((best, current) =>
(current[0]?.cosine ?? -1) > (best[0]?.cosine ?? -1) ? current : best,
)
const candidates = await this.repository.getCandidatesByIds(
scored.map((item) => item.archiveId),
)
const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]))
const results = scored
.map((item) => {
const candidate = byId.get(item.archiveId)
return candidate ? mapCandidate(candidate) : null
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
function toUpstreamResult(
match: ImageSearchUpstreamOwnMatch,
candidate: ProductArchiveImageCandidate,
rank: number,
): ProductArchiveImageSearchResult {
return {
results,
indexedCount: records.length,
modelReady: true,
notice: records.length === 0 ? '索引还没有数据,请先运行图片增量索引脚本' : null,
}
}
async getStatus() {
return {
modelReady: isProductArchiveImageSearchModelReady(),
indexedCount: await this.repository.countReadyEmbeddings(
PRODUCT_ARCHIVE_IMAGE_SEARCH_MODEL_VERSION,
),
}
rank: rank + 1,
score: typeof match.score === 'number' ? match.score : null,
...mapCandidate(candidate),
maySkuCode: match.maySkuCode ?? '',
skuName: match.skuName ?? '',
skuSize: match.skuSize ?? '',
colorName: match.colorName ?? '',
category: match.category ?? '',
brand: match.brand ?? '',
saleStatus: match.saleStatus ?? '',
}
}

View File

@ -7,9 +7,10 @@ import {
} from '@/lib/product-archive-image-search'
import { AppError, ErrorCode } from '@/server/core/errors'
const MAX_QUERY_IMAGE_BYTES = 10 * 1024 * 1024
const DEFAULT_MIN_SHAPE_COSINE = 0.78
const DEFAULT_MIN_SHAPE_MARGIN = 0.08
// 本地图片向量层:DINOv2-small ONNX 推理 + 余弦排序,作为上游图搜(我司 SKU 索引)之外的全量档案覆盖层。
// 排序是宽松模式:尽力返回最相似的 top-N,只在极低相似度时过滤噪声,不再做严格同款判定。
const MAX_IMAGE_BYTES = 12 * 1024 * 1024
const DEFAULT_MIN_LOCAL_COSINE = 0.5
type ImageFeaturePipeline = (input: Blob, options: { pool: false }) => Promise<{
data: Float32Array | Float64Array | number[]
@ -45,7 +46,7 @@ async function getImageFeaturePipeline() {
if (!isProductArchiveImageSearchModelReady()) {
throw new AppError(
ErrorCode.SYSTEM_INTERNAL,
'以图搜图模型尚未就绪,请先运行图片模型准备脚本',
'本地图片模型尚未就绪,请先运行图片模型准备脚本',
)
}
@ -93,8 +94,8 @@ export async function createProductArchiveImageEmbedding(params: {
buffer: Buffer
contentType: string
}) {
if (params.buffer.byteLength > MAX_QUERY_IMAGE_BYTES) {
throw new AppError(ErrorCode.FILE_TOO_LARGE, '图片不能超过 10MB')
if (params.buffer.byteLength > MAX_IMAGE_BYTES) {
throw new AppError(ErrorCode.FILE_TOO_LARGE, '图片不能超过 12MB')
}
const extractor = await getImageFeaturePipeline()
@ -155,11 +156,11 @@ function cosineSimilarity(left: Float32Array, right: Uint8Array, dimensions: num
return dot / Math.sqrt(leftNorm * rightNorm)
}
function readShapeThreshold(name: string, fallback: number) {
const configured = Number(process.env[name])
function readLocalMinCosine() {
const configured = Number(process.env.IMAGE_SEARCH_MIN_LOCAL_COSINE)
return Number.isFinite(configured) && configured > 0 && configured < 1
? configured
: fallback
: DEFAULT_MIN_LOCAL_COSINE
}
export function rankProductArchiveImageEmbeddings(params: {
@ -173,37 +174,31 @@ export function rankProductArchiveImageEmbeddings(params: {
}>
limit?: number
}) {
const exactImageMatches = params.queryImageHash
const limit = Math.min(Math.max(1, params.limit ?? 20), 50)
// 查询图与索引图完全一致(归一化后哈希相同)时直接置顶,视为相似度 1
const exactMatches = params.queryImageHash
? params.records.filter((record) => record.imageHash === params.queryImageHash)
: []
if (exactImageMatches.length > 0) {
return exactImageMatches.slice(0, Math.min(20, Math.max(1, params.limit ?? 20))).map(
(record) => ({
archiveId: record.archiveId,
imageHash: record.imageHash,
cosine: 1,
}),
)
}
const exactIds = new Set(exactMatches.map((record) => record.archiveId))
const minCosine = readLocalMinCosine()
const scored = params.records
.filter((record) => !exactIds.has(record.archiveId))
.map((record) => ({
archiveId: record.archiveId,
imageHash: record.imageHash,
cosine: cosineSimilarity(params.query, record.embedding, record.dimensions),
}))
.sort((left, right) => right.cosine - left.cosine)
.filter((item) => item.cosine >= minCosine)
const best = scored[0]
if (!best) return []
const runnerUp = scored.find((item) => item.imageHash !== best.imageHash)
const minCosine = readShapeThreshold('IMAGE_SEARCH_MIN_SHAPE_COSINE', DEFAULT_MIN_SHAPE_COSINE)
const minMargin = readShapeThreshold('IMAGE_SEARCH_MIN_SHAPE_MARGIN', DEFAULT_MIN_SHAPE_MARGIN)
if (best.cosine < minCosine || (runnerUp && best.cosine - runnerUp.cosine < minMargin)) {
return []
}
return scored
.filter((item) => item.imageHash === best.imageHash)
.slice(0, Math.min(20, Math.max(1, params.limit ?? 20)))
return [
...exactMatches.slice(0, limit).map((record) => ({
archiveId: record.archiveId,
imageHash: record.imageHash,
cosine: 1,
})),
...scored.slice(0, Math.max(0, limit - Math.min(exactMatches.length, limit))),
]
}

View File

@ -7,26 +7,10 @@ import { resolveObjectStorageUrlForServerAccess } from '@/lib/storage'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import { supplierRecommendedProductsRepository, type SupplierRecommendedProductsRepository } from '@/server/repositories/supplier-recommended-products.repository'
import { searchSimilarProductsByImageBase64 } from '@/server/services/image-search-upstream-client'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
const UPSTREAM_PATH = '/open-api/image-search/similar-products'
const DEFAULT_API_BASE = 'http://192.168.42.134:7503'
const IMAGE_FETCH_TIMEOUT_MS = 30_000
const DEFAULT_UPSTREAM_TIMEOUT_MS = 180_000
function resolveApiBase() {
const base = (process.env.IMAGE_SEARCH_API_BASE ?? '').trim()
return base || DEFAULT_API_BASE
}
function resolveApiKey() {
return (process.env.IMAGE_SEARCH_API_KEY ?? '').trim()
}
function resolveUpstreamTimeoutMs() {
const value = Number(process.env.IMAGE_SEARCH_TIMEOUT_MS)
return Number.isFinite(value) && value >= 30_000 ? value : DEFAULT_UPSTREAM_TIMEOUT_MS
}
function toClientResult(upstream: ImageSearchUpstreamResponse, requestId: string | null): SupplierRecommendedImageSearchClientResult {
const data = upstream.data ?? {}
@ -108,7 +92,7 @@ export class SupplierRecommendedImageSearchService {
if (!imageUrl) throw new AppError(ErrorCode.VALIDATION_FAILED, '该记录没有图片,无法以图搜图')
const base64 = await this.fetchImageAsBase64(imageUrl)
const upstream = await this.callUpstream(base64)
const upstream = await searchSimilarProductsByImageBase64(base64)
return toClientResult(upstream, null)
}
@ -137,36 +121,6 @@ export class SupplierRecommendedImageSearchService {
}
return buffer.toString('base64')
}
private async callUpstream(imageBase64: string): Promise<ImageSearchUpstreamResponse> {
const apiKey = resolveApiKey()
if (!apiKey) {
throw new AppError(ErrorCode.SYSTEM_INTERNAL, '图搜服务未配置(缺少 IMAGE_SEARCH_API_KEY),请联系管理员在部署环境补齐配置')
}
let response: Response
try {
response = await fetch(`${resolveApiBase()}${UPSTREAM_PATH}`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ image_base64: imageBase64 }),
signal: AbortSignal.timeout(resolveUpstreamTimeoutMs()),
})
} catch (error) {
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
throw new AppError(ErrorCode.SYSTEM_INTERNAL, '图搜服务响应超时,请稍后重试')
}
throw new AppError(ErrorCode.SYSTEM_INTERNAL, '图搜服务不可达,请确认部署环境与图搜服务(192.168.42.134:7503)的网络连通')
}
const payload = (await response.json().catch(() => null)) as ImageSearchUpstreamResponse | null
if (!response.ok || !payload || payload.success !== true || !payload.data) {
const upstreamMessage = payload?.message || payload?.error || `HTTP ${response.status}`
const requestIdSuffix = payload?.request_id ? `(request_id: ${payload.request_id})` : ''
throw new AppError(ErrorCode.SYSTEM_INTERNAL, `图搜接口调用失败:${upstreamMessage}${requestIdSuffix}`)
}
return payload
}
}
export const supplierRecommendedImageSearchService = new SupplierRecommendedImageSearchService()