产品全量档案以图搜图改用壹点图搜接口并增加本地向量双层检索
- A 层:调壹点图搜上游(ownProductMatches 我司 SKU 命中),上游调用收口为共享 client,供应商推荐搜索市场同款同步复用 - B 层:本地 DINOv2 向量索引覆盖全量档案图(宽松排序、同图哈希置顶、模型未就绪自动降级) - 上游编码与档案条码/SKU 大小写不敏感匹配,两路结果按条码去重 - 弹窗分「我司 SKU 命中」「全量档案相似」两区展示,本地索引通过 scripts/index-product-archive-images.ts 增量维护
This commit is contained in:
parent
1dcaa48baf
commit
b9dc0bddb1
@ -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,
|
||||
)
|
||||
|
||||
@ -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; }
|
||||
|
||||
132
furniture-3d-system/src/lib/image-search-upstream.ts
Normal file
132
furniture-3d-system/src/lib/image-search-upstream.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
// ============ 返回给前端的结果(裁剪后) ============
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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 ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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))),
|
||||
]
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user