merge: 发布 BOM 商品抠图与图片分类

This commit is contained in:
WuYongJie 2026-09-03 17:05:49 +08:00
commit 30ab1b4b35
14 changed files with 582 additions and 40 deletions

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ConfigForm } from './AiRenderConfigClient'
import { ConfigForm, TAB_META } from './AiRenderConfigClient'
describe('AI 服务配置模型选择', () => {
const originalFetch = global.fetch
@ -9,6 +9,13 @@ describe('AI 服务配置模型选择', () => {
jest.clearAllMocks()
})
it('提供独立的商品抠图服务配置入口', () => {
expect(TAB_META['image-background-removal']).toMatchObject({
label: '商品抠图',
serviceType: 'image-background-removal',
})
})
it('连接成功后只能从 API Key 可用模型列表中选择', async () => {
const fetchMock = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({

View File

@ -22,6 +22,7 @@ import {
Loader2,
Eye,
EyeOff,
ScanLine,
Plug,
Save,
X,
@ -39,14 +40,20 @@ interface ProviderConfig {
createdAt: string
}
type ActiveTab = 'ai-render' | 'prompt-optimizer'
type ActiveTab = 'ai-render' | 'prompt-optimizer' | 'image-background-removal'
interface TestResult { status: 'idle' | 'testing' | 'success' | 'error'; message: string }
interface ModelOption { value: string; label: string }
const TAB_META: Record<ActiveTab, { label: string; icon: typeof Sparkles; desc: string; serviceType: string }> = {
export const TAB_META: Record<ActiveTab, { label: string; icon: typeof Sparkles; desc: string; serviceType: string }> = {
'ai-render': { label: '生图服务', icon: Sparkles, desc: '辅助装配 AI 效果图的外部生图接口', serviceType: 'ai-render' },
'prompt-optimizer': { label: '提示词优化', icon: Wand2, desc: '用 AI 优化用户的粗糙描述为高质量生图 prompt', serviceType: 'prompt-optimizer' },
'image-background-removal': {
label: '商品抠图',
icon: ScanLine,
desc: '为商品以图搜图生成去除促销文字和场景背景的纯白底主体图',
serviceType: 'image-background-removal',
},
}
function ConfigCard({ config, onActivate, onEdit, onDelete }: {
@ -322,7 +329,7 @@ export default function AiRenderConfigClient() {
}
rail={
<ObsidianRailGroup title="服务">
{(Object.entries(TAB_META) as Array<[ActiveTab, typeof TAB_META['ai-render']]>).map(([key, meta]) => (
{(Object.entries(TAB_META) as Array<[ActiveTab, typeof TAB_META[ActiveTab]]>).map(([key, meta]) => (
<ObsidianRailButton key={key} active={activeTab === key} onClick={() => { setActiveTab(key); setShowForm(false); setEditing(null) }} icon={meta.icon} label={meta.label} />
))}
</ObsidianRailGroup>

View File

@ -4,8 +4,8 @@ import { RequireAuth } from '@/components/auth/RequireAuth'
import AiRenderConfigClient from './AiRenderConfigClient'
export const metadata: Metadata = {
title: 'AI 生图配置 | 后台设置',
description: '配置 AI 生图和提示词优化服务',
title: 'AI 服务配置 | 后台设置',
description: '配置 AI 生图、提示词优化和商品抠图服务',
}
export default function AiRenderConfigPage() {

View File

@ -6,7 +6,7 @@ import { providerModelCatalogService } from '@/server/services'
const schema = z.object({
baseUrl: z.string().url('地址格式不正确'),
serviceType: z.enum(['ai-render', 'prompt-optimizer']),
serviceType: z.enum(['ai-render', 'prompt-optimizer', 'image-background-removal']),
})
export async function POST(request: NextRequest) {

View File

@ -34,10 +34,11 @@ function detail({ costBreakdownAvailable = false, status = 'READY' }: {
statusMessage: status === 'READY' ? '预估完成' : '正在生成预估',
sourceCacheHit: false,
sourceSnapshot: {
status: 'READY', skuCount: 1, storedImageCount: 2, failedImageCount: 0,
status: 'READY', skuCount: 1, storedImageCount: 3, failedImageCount: 0,
assets: [
{ id: 'asset-main', kind: 'MAIN', status: 'STORED', storedUrl: 'https://images.example.com/main.jpg', sortOrder: 0, externalSkuIds: [] },
{ id: 'asset-detail', kind: 'DETAIL', status: 'STORED', storedUrl: 'https://images.example.com/detail.jpg', sortOrder: 1, externalSkuIds: [] },
{ id: 'asset-sku', kind: 'SKU', status: 'STORED', storedUrl: 'https://images.example.com/sku.jpg', sortOrder: 1, externalSkuIds: ['EXT-1'] },
{ id: 'asset-detail', kind: 'DETAIL', status: 'STORED', storedUrl: 'https://images.example.com/detail.jpg', sortOrder: 2, externalSkuIds: [] },
],
},
requiresRecalculation: false, hasFailedRecalculation: false, needsPriceReview: false,
@ -102,6 +103,23 @@ describe('BomCostingWorkbenchClient', () => {
expect(screen.queryByText('成本构成')).not.toBeInTheDocument()
})
it('左侧图片按主图、当前 SKU 图和详情图分组切换', async () => {
mockCaseResponse(detail())
render(<BomCostingWorkbenchClient initialCapabilities={capabilities} initialCases={[summary()]} />)
await screen.findByText('约 ¥1,350.00')
expect(screen.getByRole('tab', { name: '主图 1' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '当前 SKU 图 1' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', { name: '详情图 1' })).toBeInTheDocument()
expect(screen.getByRole('img', { name: '黑胡桃色 120cm SKU 图' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '主图 1' }))
expect(screen.getByRole('img', { name: '商品主图' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '详情图 1' }))
expect(screen.getByRole('img', { name: '商品详情图 1' })).toBeInTheDocument()
})
it('整件临时价的详情只展示结构事实,不展示另一套结构金额', async () => {
mockCaseResponse(detail())
render(<BomCostingWorkbenchClient initialCapabilities={capabilities} initialCases={[summary()]} />)

View File

@ -28,6 +28,7 @@ import type {
BomCostingCaseDetail,
BomCostingCaseSummary,
BomCostingSkuDetail,
BomCostingSourceAssetDetail,
} from '@/lib/bom-costing/client-types'
import { getBomCostingCurrentTaskEvents } from './BomCostingAssetWorkspace'
import {
@ -51,6 +52,15 @@ type RecalculationMode =
| 'REANALYZE_SELECTED'
| 'REANALYZE_ALL'
type ProductImageKind = BomCostingSourceAssetDetail['kind']
const PRODUCT_IMAGE_KINDS: ProductImageKind[] = ['MAIN', 'SKU', 'DETAIL']
const PRODUCT_IMAGE_KIND_LABELS: Record<ProductImageKind, string> = {
MAIN: '主图',
SKU: '当前 SKU 图',
DETAIL: '详情图',
}
interface RecalculationRequest {
mode: RecalculationMode
selectedAssetIds: string[]
@ -460,14 +470,24 @@ function ResultCanvas({
const skuAssets = sourceAssets.filter((asset) => (
asset.kind === 'SKU' && asset.externalSkuIds.includes(selectedSku.externalSkuId)
))
const galleryAssets = [
...skuAssets,
...sourceAssets.filter((asset) => asset.kind === 'MAIN'),
]
const mainAssets = sourceAssets.filter((asset) => asset.kind === 'MAIN')
const detailAssets = sourceAssets.filter((asset) => asset.kind === 'DETAIL')
const imageGroups: Record<ProductImageKind, BomCostingSourceAssetDetail[]> = {
MAIN: mainAssets,
SKU: skuAssets,
DETAIL: detailAssets,
}
const [preferredImageKind, setPreferredImageKind] = useState<ProductImageKind>('SKU')
const activeImageKind = imageGroups[preferredImageKind].length > 0
? preferredImageKind
: PRODUCT_IMAGE_KINDS.find((kind) => imageGroups[kind].length > 0) ?? preferredImageKind
const galleryAssets = imageGroups[activeImageKind]
const [selectedAssetId, setSelectedAssetId] = useState('')
const selectedAsset = galleryAssets.find((asset) => asset.id === selectedAssetId) ??
galleryAssets[0] ?? null
const selectedAssetIndex = selectedAsset
? galleryAssets.findIndex((asset) => asset.id === selectedAsset.id)
: -1
const [detailsOpen, setDetailsOpen] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [recalculationMode, setRecalculationMode] = useState<RecalculationMode>('REUSE_FACTS')
@ -554,11 +574,58 @@ function ResultCanvas({
<div className="grid flex-1 items-center gap-8 lg:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)] lg:gap-12">
<section id="bom-product-gallery" className="min-w-0">
<div className="relative aspect-[4/3] min-h-[280px] overflow-hidden bg-[#f2f4f3] sm:min-h-[420px]">
<div className="mb-3 flex items-end justify-between gap-4 border-b border-black/[0.08]">
<div className="flex min-w-0 gap-4 overflow-x-auto" role="tablist" aria-label="商品图片分类">
{PRODUCT_IMAGE_KINDS.map((kind) => {
const assets = imageGroups[kind]
const active = activeImageKind === kind
return (
<button
key={kind}
type="button"
role="tab"
aria-selected={active}
aria-controls="bom-product-gallery-panel"
disabled={assets.length === 0}
onClick={() => {
setPreferredImageKind(kind)
setSelectedAssetId(assets[0]?.id ?? '')
}}
className={cn(
'flex h-9 shrink-0 items-center gap-1.5 border-b-2 px-0 text-[11px] font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#526a70] focus-visible:ring-offset-2',
active
? 'border-[#526a70] text-[#2f3d40]'
: 'border-transparent text-[#6f797b] hover:text-[#3f4d50]',
assets.length === 0 && 'cursor-not-allowed text-[#a3aaac] hover:text-[#a3aaac]',
)}
>
<span>{PRODUCT_IMAGE_KIND_LABELS[kind]}</span>
<span className="tabular-nums text-[10px] text-current/70">{assets.length}</span>
</button>
)
})}
</div>
{galleryAssets.length > 0 ? (
<span className="mb-2.5 shrink-0 text-[10px] tabular-nums text-[#7a8486]">
{selectedAssetIndex + 1} / {galleryAssets.length}
</span>
) : null}
</div>
<div
id="bom-product-gallery-panel"
role="tabpanel"
aria-label={PRODUCT_IMAGE_KIND_LABELS[activeImageKind]}
className="relative aspect-[4/3] min-h-[280px] overflow-hidden bg-[#f2f4f3] sm:min-h-[420px]"
>
{selectedAsset?.storedUrl ? (
<Image
src={selectedAsset.storedUrl}
alt={selectedAsset.kind === 'MAIN' ? '商品主图' : `${selectedSku.name} 图片`}
alt={selectedAsset.kind === 'MAIN'
? '商品主图'
: selectedAsset.kind === 'SKU'
? `${selectedSku.name} SKU 图`
: `商品详情图 ${selectedAssetIndex + 1}`}
fill
sizes="(min-width: 1024px) 62vw, 100vw"
priority
@ -570,19 +637,20 @@ function ResultCanvas({
)}
</div>
{galleryAssets.length > 1 ? (
<div className="mt-3 flex gap-2 overflow-x-auto pb-1" aria-label="商品图片">
{galleryAssets.map((asset) => (
<div className="mt-3 flex gap-2 overflow-x-auto pb-1" aria-label={`${PRODUCT_IMAGE_KIND_LABELS[activeImageKind]}缩略图`}>
{galleryAssets.map((asset, index) => (
<button
key={asset.id}
type="button"
onClick={() => setSelectedAssetId(asset.id)}
aria-label={`查看商品图片 ${asset.sortOrder + 1}`}
aria-label={`查看${PRODUCT_IMAGE_KIND_LABELS[activeImageKind]} ${index + 1}`}
aria-pressed={selectedAsset?.id === asset.id}
className={cn(
'relative h-14 w-14 shrink-0 overflow-hidden border bg-[#f2f4f3] outline-none focus-visible:ring-2 focus-visible:ring-[#526a70]',
selectedAsset?.id === asset.id ? 'border-[#526a70]' : 'border-black/[0.08]',
)}
>
<Image src={asset.storedUrl!} alt="" fill sizes="56px" unoptimized className="object-cover" />
<Image src={asset.storedUrl!} alt="" fill sizes="56px" unoptimized className="object-contain" />
</button>
))}
</div>

View File

@ -123,6 +123,56 @@ describe("callExternalImageGenerateApi", () => {
expect(requestInit.body).toBeInstanceOf(FormData);
});
it("uses the independent background-removal config and sends its selected model", async () => {
mockedGetActiveProviderConfig.mockResolvedValue({
enabled: true,
baseUrl: "https://cutout.example.com",
apiKey: "cutout-key",
model: "gpt-image-2",
} as never);
mockedFetch.mockResolvedValue({
ok: true,
json: async () => ({
data: {
requestId: "req-cutout",
model: "gpt-image-2",
images: [{ originalUrl: "https://cdn.example.com/cutout.png" }],
},
}),
} as never);
await callExternalImageGenerateApi({
serviceType: "image-background-removal",
prompt: "remove background",
imageCount: 1,
aspectRatio: "1:1",
imageSize: "1K",
maxAttempts: 1,
timeoutMs: 60_000,
referenceImage: {
buffer: Buffer.from([1, 2, 3]),
contentType: "image/png",
fileName: "source.png",
},
});
expect(mockedGetActiveProviderConfig).toHaveBeenCalledWith(
"image-background-removal",
);
const [endpoint, requestInit] = mockedFetch.mock.calls[0] as [
string,
RequestInit,
];
expect(endpoint).toBe(
"https://cutout.example.com/api/external/image/generate",
);
expect(requestInit.headers).toMatchObject({
Authorization: "Bearer cutout-key",
});
const formData = requestInit.body as FormData;
expect(formData.get("model")).toBe("gpt-image-2");
});
it("sends file uploads and image URLs together in multipart mode", async () => {
mockedFetch.mockResolvedValue({
ok: true,

View File

@ -139,6 +139,31 @@ describe('ProviderModelCatalogService', () => {
)
})
it('商品抠图使用图片网关公开 modelId,不把内部配置 UUID 当成模型名', async () => {
const fetchMock = jest.fn().mockResolvedValue(createResponse(200, {
data: {
models: [{
id: 'internal-config-uuid',
modelId: 'gpt-image-2',
displayName: 'GPT Image 2',
}],
},
}))
global.fetch = fetchMock as typeof fetch
const result = await service.fetchCatalog({
serviceType: 'image-background-removal',
baseUrl: 'https://image.example.com',
apiKey: 'secret',
})
expect(result).toMatchObject({
ok: true,
endpointPath: '/api/external/image/models',
models: [{ value: 'gpt-image-2', label: 'GPT Image 2' }],
})
})
it('密钥无权限时返回明确错误且不继续尝试其他地址', async () => {
const fetchMock = jest.fn().mockResolvedValue(createResponse(401, {
error: { message: 'unauthorized' },

View File

@ -234,6 +234,48 @@ describe('BomCosting1688ImageSearchService', () => {
)
})
it('uses the configured product cutout for 1688 search and keeps the original source URL for audit', async () => {
const imageResponse = {
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'image/jpeg' }),
arrayBuffer: async () => Buffer.from('poster-image'),
} as unknown as Response
const apiResponse = {
ok: true,
status: 200,
text: async () => JSON.stringify({ success: true, model: { count: 0, data: [] } }),
} as unknown as Response
const fetch = jest.fn()
.mockResolvedValueOnce(imageResponse)
.mockResolvedValueOnce(apiResponse)
const prepareImageForSearch = jest.fn().mockResolvedValue({
buffer: Buffer.from('white-background-product'),
contentType: 'image/jpeg',
})
const service = new BomCosting1688ImageSearchService({
getConfig: () => config,
fetch,
now: () => new Date('2026-08-16T10:00:00.000Z'),
randomBytes: () => Buffer.from([1, 2, 3, 4]),
prepareImageForSearch,
})
const quote = await service.findRealtimeQuote({
imageUrl: 'https://images.example.com/sku-poster.jpg',
})
expect(prepareImageForSearch).toHaveBeenCalledWith({
buffer: Buffer.from('poster-image'),
contentType: 'image/jpeg',
})
const requestBody = JSON.parse(String(fetch.mock.calls[1]?.[1]?.body))
expect(requestBody.imgBase64).toBe(
Buffer.from('white-background-product').toString('base64'),
)
expect(quote.sourceImageUrl).toBe('https://images.example.com/sku-poster.jpg')
})
it('degrades without a hidden browser fallback when the service is not configured', async () => {
const fetch = jest.fn()
const service = new BomCosting1688ImageSearchService({

View File

@ -8,6 +8,7 @@ import type {
DynamicBomRealtimeMarketOffer,
DynamicBomRealtimeMarketQuote,
} from '@/lib/bom-costing/dynamic-bom-cost-domain'
import { bomCostingImageBackgroundRemovalService } from './bom-costing-image-background-removal.service'
const OFFICIAL_IMAGE_SEARCH_ENDPOINT = 'https://skills-gateway.1688.com/api/find_product/1.0.0'
const IMAGE_SEARCH_PATH = '/api/find_product/1.0.0'
@ -71,6 +72,10 @@ export interface BomCosting1688ImageSearchGateway {
now(): Date
randomBytes(size: number): Buffer
createStoredObjectReadUrl?: (key: string) => string
prepareImageForSearch?: (input: {
buffer: Buffer
contentType: string
}) => Promise<{ buffer: Buffer; contentType: string } | null>
}
function boundedPositiveInteger(
@ -193,6 +198,7 @@ const defaultGateway: BomCosting1688ImageSearchGateway = {
now: () => new Date(),
randomBytes: (size) => randomBytes(size),
createStoredObjectReadUrl: createObjectReadUrl,
prepareImageForSearch: (input) => bomCostingImageBackgroundRemovalService.prepare(input),
}
interface Alibaba1688AkKeys {
@ -538,8 +544,17 @@ export class BomCosting1688ImageSearchService {
const contentType = imageResponse.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
if (!contentType.startsWith('image/')) throw new Error('SKU 主图不是可检索图片')
const image = await readBoundedImage(imageResponse, config.maxImageBytes)
let searchImage: Uint8Array = image
if (this.gateway.prepareImageForSearch) {
try {
const prepared = await this.gateway.prepareImageForSearch({ buffer: image, contentType })
if (prepared?.buffer.length) searchImage = prepared.buffer
} catch {
console.warn('[BOM costing] product cutout unavailable; using original search image')
}
}
const body = JSON.stringify({
imgBase64: image.toString('base64'),
imgBase64: Buffer.from(searchImage).toString('base64'),
pageSize: config.pageSize,
purchaseAmount: config.purchaseAmount,
sortType: 'yx_desc',

View File

@ -0,0 +1,95 @@
import sharp from 'sharp'
import {
BOM_COSTING_BACKGROUND_REMOVAL_PROMPT,
BomCostingImageBackgroundRemovalService,
} from './bom-costing-image-background-removal.service'
function activeConfig() {
return {
id: 'cutout-config-1',
serviceType: 'image-background-removal',
name: '商品抠图',
baseUrl: 'https://ai.example.com',
apiKey: 'secret',
model: 'gpt-image-2',
extra: null,
isActive: true,
sortOrder: 0,
createdAt: '2026-09-03T00:00:00.000Z',
updatedAt: '2026-09-03T00:00:00.000Z',
}
}
describe('BomCostingImageBackgroundRemovalService', () => {
it('returns null without an active dedicated cutout model', async () => {
const generate = jest.fn()
const service = new BomCostingImageBackgroundRemovalService({
getActiveConfig: jest.fn().mockResolvedValue(null),
generate,
fetch: jest.fn(),
})
await expect(service.prepare({
buffer: Buffer.from('source'),
contentType: 'image/jpeg',
})).resolves.toBeNull()
expect(generate).not.toHaveBeenCalled()
})
it('uses the selected image-edit model, normalizes a white canvas, and caches the result', async () => {
const generatedPng = await sharp({
create: {
width: 32,
height: 48,
channels: 4,
background: { r: 120, g: 80, b: 40, alpha: 0.8 },
},
}).png().toBuffer()
const generate = jest.fn().mockResolvedValue({
requestId: 'request-1',
model: 'gpt-image-2',
images: [{ originalUrl: 'https://cdn.example.com/cutout.png' }],
generatedImageIds: [],
generatedAssetIds: [],
attempts: [],
})
const fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'image/png' }),
arrayBuffer: async () => generatedPng,
} as unknown as Response)
const service = new BomCostingImageBackgroundRemovalService({
getActiveConfig: jest.fn().mockResolvedValue(activeConfig()),
generate,
fetch,
})
const source = Buffer.from('same-source-image')
const first = await service.prepare({ buffer: source, contentType: 'image/jpeg' })
const second = await service.prepare({ buffer: source, contentType: 'image/jpeg' })
expect(generate).toHaveBeenCalledWith(expect.objectContaining({
serviceType: 'image-background-removal',
prompt: BOM_COSTING_BACKGROUND_REMOVAL_PROMPT,
aspectRatio: '1:1',
imageSize: '1K',
maxAttempts: 1,
timeoutMs: 60_000,
}))
expect(fetch).toHaveBeenCalledWith(
new URL('https://cdn.example.com/cutout.png'),
expect.objectContaining({ redirect: 'error' }),
)
expect(first).toMatchObject({
contentType: 'image/jpeg',
model: 'gpt-image-2',
cached: false,
})
expect(second).toMatchObject({ cached: true })
expect(generate).toHaveBeenCalledTimes(1)
expect(fetch).toHaveBeenCalledTimes(1)
const metadata = await sharp(first!.buffer).metadata()
expect(metadata).toMatchObject({ width: 1024, height: 1024, format: 'jpeg' })
})
})

View File

@ -0,0 +1,163 @@
import 'server-only'
import { createHash } from 'node:crypto'
import sharp from 'sharp'
import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety'
import { providerConfigService } from '@/server/services/provider-config.service'
import { callExternalImageGenerateApi } from '@/server/services/external-image-generate.service'
const BACKGROUND_REMOVAL_SERVICE_TYPE = 'image-background-removal'
const BACKGROUND_REMOVAL_TIMEOUT_MS = 60_000
const BACKGROUND_REMOVAL_RESULT_TIMEOUT_MS = 45_000
const BACKGROUND_REMOVAL_MAX_RESULT_BYTES = 10 * 1024 * 1024
const BACKGROUND_REMOVAL_CACHE_LIMIT = 24
export const BOM_COSTING_BACKGROUND_REMOVAL_PROMPT = [
'Create a faithful product cutout from the reference image.',
'Keep exactly one complete furniture product and remove all promotional text, prices, badges, borders, people, props, room scenery, floor, and decorative background.',
'Preserve the original silhouette, proportions, perspective, panel and door count, openings, shelves, hardware, materials, colors, and every visible structural detail.',
'Do not add, remove, rebuild, beautify, recolor, repair, or reinterpret any product part.',
'Center the unchanged product on a pure white #FFFFFF square canvas with a small even margin and no text, watermark, badge, floor, reflection, or decorative shadow.',
].join(' ')
type ActiveBackgroundRemovalConfig = Awaited<
ReturnType<typeof providerConfigService.getActive>
>
export interface BomCostingBackgroundRemovalResult {
buffer: Buffer
contentType: 'image/jpeg'
model: string
cached: boolean
}
interface BomCostingBackgroundRemovalDependencies {
getActiveConfig(): Promise<ActiveBackgroundRemovalConfig>
generate: typeof callExternalImageGenerateApi
fetch: typeof fetch
}
const defaultDependencies: BomCostingBackgroundRemovalDependencies = {
getActiveConfig: () => providerConfigService.getActive(BACKGROUND_REMOVAL_SERVICE_TYPE),
generate: callExternalImageGenerateApi,
fetch: (input, init) => fetch(input, init),
}
function sourceFileName(contentType: string) {
if (contentType === 'image/jpeg') return 'bom-search-source.jpg'
if (contentType === 'image/webp') return 'bom-search-source.webp'
if (contentType === 'image/avif') return 'bom-search-source.avif'
return 'bom-search-source.png'
}
async function readBoundedImage(response: Response) {
const declaredLength = Number(response.headers.get('content-length') || '0')
if (Number.isFinite(declaredLength) && declaredLength > BACKGROUND_REMOVAL_MAX_RESULT_BYTES) {
throw new Error('抠图结果超过大小限制')
}
const bytes = Buffer.from(await response.arrayBuffer())
if (bytes.length === 0 || bytes.length > BACKGROUND_REMOVAL_MAX_RESULT_BYTES) {
throw new Error('抠图结果为空或超过大小限制')
}
return bytes
}
function buildCacheKey(
source: Buffer,
config: NonNullable<ActiveBackgroundRemovalConfig>,
) {
return createHash('sha256')
.update('bom-costing-background-removal/v1\0')
.update(config.id)
.update('\0')
.update(config.updatedAt)
.update('\0')
.update(config.model ?? '')
.update('\0')
.update(source)
.digest('hex')
}
export class BomCostingImageBackgroundRemovalService {
private readonly cache = new Map<string, Buffer>()
constructor(
private readonly dependencies: BomCostingBackgroundRemovalDependencies = defaultDependencies,
) {}
async prepare(input: {
buffer: Buffer
contentType: string
}): Promise<BomCostingBackgroundRemovalResult | null> {
const config = await this.dependencies.getActiveConfig()
if (!config?.baseUrl || !config.apiKey || !config.model) return null
const cacheKey = buildCacheKey(input.buffer, config)
const cached = this.cache.get(cacheKey)
if (cached) {
this.cache.delete(cacheKey)
this.cache.set(cacheKey, cached)
return {
buffer: cached,
contentType: 'image/jpeg',
model: config.model,
cached: true,
}
}
const generated = await this.dependencies.generate({
serviceType: BACKGROUND_REMOVAL_SERVICE_TYPE,
prompt: BOM_COSTING_BACKGROUND_REMOVAL_PROMPT,
imageCount: 1,
aspectRatio: '1:1',
imageSize: '1K',
timeoutMs: BACKGROUND_REMOVAL_TIMEOUT_MS,
maxAttempts: 1,
referenceImage: {
buffer: input.buffer,
contentType: input.contentType,
fileName: sourceFileName(input.contentType),
},
disabledMessage: '商品抠图服务未启用或未配置',
})
const generatedUrl = generated.images.find((image) => image.originalUrl.trim())?.originalUrl
if (!generatedUrl) throw new Error('商品抠图服务没有返回可用图片')
const safeUrl = parseSafeCompetitorUrl(generatedUrl)
const response = await this.dependencies.fetch(safeUrl, {
method: 'GET',
headers: { Accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8' },
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.timeout(BACKGROUND_REMOVAL_RESULT_TIMEOUT_MS),
})
if (!response.ok) throw new Error(`商品抠图结果读取失败(${response.status})`)
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
if (!contentType.startsWith('image/')) throw new Error('商品抠图结果不是图片')
const generatedBuffer = await readBoundedImage(response)
const normalized = await sharp(generatedBuffer)
.rotate()
.flatten({ background: '#ffffff' })
.resize({ width: 1024, height: 1024, fit: 'contain', background: '#ffffff' })
.jpeg({ quality: 92, mozjpeg: true })
.toBuffer()
this.cache.set(cacheKey, normalized)
while (this.cache.size > BACKGROUND_REMOVAL_CACHE_LIMIT) {
const oldestKey = this.cache.keys().next().value
if (!oldestKey) break
this.cache.delete(oldestKey)
}
return {
buffer: normalized,
contentType: 'image/jpeg',
model: config.model,
cached: false,
}
}
}
export const bomCostingImageBackgroundRemovalService =
new BomCostingImageBackgroundRemovalService()

View File

@ -10,8 +10,13 @@ export interface ExternalImageApiConfig {
enabled: boolean
baseUrl: string | null
apiKey: string | null
model: string | null
}
export type ExternalImageProviderServiceType =
| 'ai-render'
| 'image-background-removal'
export interface ExternalImageGenerateImageDTO {
id?: string | number
originalUrl: string
@ -35,6 +40,7 @@ type ExternalImageGenerateReferenceImage = {
}
export type CallExternalImageGenerateApiInput = {
serviceType?: ExternalImageProviderServiceType
prompt: string
negativePrompt?: string | null
imageCount: number
@ -47,6 +53,8 @@ export type CallExternalImageGenerateApiInput = {
/** 参考图 URL 数组 */
referenceImageUrls?: string[]
disabledMessage?: string
timeoutMs?: number
maxAttempts?: number
}
const CURL_HTTP_STATUS_MARKER = '__CODEX_HTTP_STATUS__'
@ -57,9 +65,6 @@ const EXTERNAL_IMAGE_GENERATE_FETCH_TIMEOUT_MS = readPositiveIntegerEnv(
DEFAULT_EXTERNAL_IMAGE_GENERATE_TIMEOUT_MS,
)
const EXTERNAL_IMAGE_GENERATE_CURL_CONNECT_TIMEOUT_SECONDS = 10
const EXTERNAL_IMAGE_GENERATE_CURL_MAX_TIME_SECONDS = Math.ceil(
EXTERNAL_IMAGE_GENERATE_FETCH_TIMEOUT_MS / 1000,
)
function readPositiveIntegerEnv(name: string, fallback: number): number {
const rawValue = process.env[name]?.trim()
@ -310,8 +315,10 @@ async function runCurl(args: string[]) {
async function requestExternalImageGenerateViaCurl(params: {
endpoint: string
apiKey: string
model: string | null
input: CallExternalImageGenerateApiInput
normalizedReferenceImageUrls: string[]
timeoutMs: number
}) {
const tempDirectory = await mkdtemp(
path.join(os.tmpdir(), 'external-image-generate-'),
@ -324,7 +331,7 @@ async function requestExternalImageGenerateViaCurl(params: {
'--connect-timeout',
String(EXTERNAL_IMAGE_GENERATE_CURL_CONNECT_TIMEOUT_SECONDS),
'--max-time',
String(EXTERNAL_IMAGE_GENERATE_CURL_MAX_TIME_SECONDS),
String(Math.max(1, Math.ceil(params.timeoutMs / 1000))),
'-X',
'POST',
params.endpoint,
@ -343,6 +350,7 @@ async function requestExternalImageGenerateViaCurl(params: {
await writeFile(
bodyPath,
JSON.stringify({
...(params.model ? { model: params.model } : {}),
prompt: params.input.prompt,
...(trimToNull(params.input.negativePrompt)
? { negativePrompt: trimToNull(params.input.negativePrompt)! }
@ -367,6 +375,7 @@ async function requestExternalImageGenerateViaCurl(params: {
}
args.push(
...(params.model ? ['-F', `model=${params.model}`] : []),
'-F',
`prompt=${params.input.prompt}`,
)
@ -439,10 +448,12 @@ async function requestExternalImageGenerateViaCurl(params: {
}
}
export async function getExternalImageApiConfig(): Promise<ExternalImageApiConfig> {
export async function getExternalImageApiConfig(
serviceType: ExternalImageProviderServiceType = 'ai-render',
): Promise<ExternalImageApiConfig> {
let active: Awaited<ReturnType<typeof providerConfigService.getActive>> = null
try {
active = await providerConfigService.getActive('ai-render')
active = await providerConfigService.getActive(serviceType)
} catch (error) {
if (!isMissingPrismaTableError(error, 'ProviderConfig')) {
throw error
@ -454,9 +465,14 @@ export async function getExternalImageApiConfig(): Promise<ExternalImageApiConfi
enabled: true,
baseUrl: trimToNull(active.baseUrl),
apiKey: trimToNull(active.apiKey),
model: trimToNull(active.model),
}
}
if (serviceType !== 'ai-render') {
return { enabled: false, baseUrl: null, apiKey: null, model: null }
}
let dbConfig: {
enabled?: boolean
baseUrl?: string
@ -479,6 +495,7 @@ export async function getExternalImageApiConfig(): Promise<ExternalImageApiConfi
enabled: true,
baseUrl: trimToNull(dbConfig.baseUrl ?? null),
apiKey: trimToNull(dbConfig.apiKey ?? null),
model: null,
}
}
@ -488,14 +505,19 @@ export async function getExternalImageApiConfig(): Promise<ExternalImageApiConfi
),
baseUrl: trimToNull(process.env.ASSISTED_ASSEMBLY_IMAGE_API_BASE_URL),
apiKey: trimToNull(process.env.ASSISTED_ASSEMBLY_IMAGE_API_KEY),
model: null,
}
}
export async function callExternalImageGenerateApi(
input: CallExternalImageGenerateApiInput,
): Promise<ExternalImageGenerateResult> {
const config = await getExternalImageApiConfig()
if (!config.enabled || !config.baseUrl || !config.apiKey) {
const serviceType = input.serviceType ?? 'ai-render'
const config = await getExternalImageApiConfig(serviceType)
if (
!config.enabled || !config.baseUrl || !config.apiKey ||
(serviceType === 'image-background-removal' && !config.model)
) {
throw new AppError(
ErrorCode.SYSTEM_INTERNAL,
input.disabledMessage ?? 'AI 生图服务未启用或未配置',
@ -530,6 +552,7 @@ export async function callExternalImageGenerateApi(
'Content-Type': 'application/json',
},
body: JSON.stringify({
...(config.model ? { model: config.model } : {}),
prompt: input.prompt,
...(trimToNull(input.negativePrompt)
? { negativePrompt: trimToNull(input.negativePrompt)! }
@ -553,6 +576,7 @@ export async function callExternalImageGenerateApi(
const formData = new FormData()
if (config.model) formData.append('model', config.model)
formData.append('prompt', input.prompt)
if (trimToNull(input.negativePrompt)) {
formData.append('negativePrompt', trimToNull(input.negativePrompt)!)
@ -602,17 +626,23 @@ export async function callExternalImageGenerateApi(
})()
let lastError: unknown = null
const timeoutMs = Number.isInteger(input.timeoutMs) && input.timeoutMs! >= 5_000
? Math.min(input.timeoutMs!, EXTERNAL_IMAGE_GENERATE_FETCH_TIMEOUT_MS)
: EXTERNAL_IMAGE_GENERATE_FETCH_TIMEOUT_MS
const maxAttempts = Number.isInteger(input.maxAttempts) && input.maxAttempts! >= 1
? Math.min(input.maxAttempts!, EXTERNAL_IMAGE_GENERATE_MAX_ATTEMPTS)
: EXTERNAL_IMAGE_GENERATE_MAX_ATTEMPTS
for (
let attempt = 1;
attempt <= EXTERNAL_IMAGE_GENERATE_MAX_ATTEMPTS;
attempt <= maxAttempts;
attempt += 1
) {
try {
const response = await fetchExternalImageGenerateWithTimeout(
endpoint,
requestInit,
EXTERNAL_IMAGE_GENERATE_FETCH_TIMEOUT_MS,
timeoutMs,
)
const payload = await response.json().catch(() => null)
@ -629,7 +659,7 @@ export async function callExternalImageGenerateApi(
if (!isNetworkFetchFailure(error)) {
if (
attempt < EXTERNAL_IMAGE_GENERATE_MAX_ATTEMPTS &&
attempt < maxAttempts &&
isRetryableExternalImageError(error)
) {
await sleep(1500 * attempt)
@ -641,8 +671,10 @@ export async function callExternalImageGenerateApi(
const curlResponse = await requestExternalImageGenerateViaCurl({
endpoint,
apiKey: config.apiKey,
model: config.model,
input,
normalizedReferenceImageUrls,
timeoutMs,
}).catch((curlError) => {
const details =
curlError instanceof Error && curlError.message.trim()
@ -670,7 +702,7 @@ export async function callExternalImageGenerateApi(
lastError = curlError
if (
attempt < EXTERNAL_IMAGE_GENERATE_MAX_ATTEMPTS &&
attempt < maxAttempts &&
isRetryableExternalImageError(curlError)
) {
await sleep(1500 * attempt)

View File

@ -1,6 +1,9 @@
import { AppError, ErrorCode } from '@/server/core/errors'
export type ProviderModelServiceType = 'ai-render' | 'prompt-optimizer'
export type ProviderModelServiceType =
| 'ai-render'
| 'prompt-optimizer'
| 'image-background-removal'
export interface ProviderModelOption {
value: string
@ -64,7 +67,10 @@ function readStringField(
return null
}
function normalizeModelEntry(value: unknown): ProviderModelOption | null {
function normalizeModelEntry(
value: unknown,
preferExternalImageModelId = false,
): ProviderModelOption | null {
if (typeof value === 'string') {
const normalized = value.trim()
return normalized ? { value: normalized, label: normalized } : null
@ -72,7 +78,12 @@ function normalizeModelEntry(value: unknown): ProviderModelOption | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as Record<string, unknown>
const modelId = readStringField(record, MODEL_ID_FIELDS)
const modelId = readStringField(
record,
preferExternalImageModelId
? ['modelId', 'model', 'value', 'name', 'id']
: MODEL_ID_FIELDS,
)
if (!modelId) return null
return {
@ -92,12 +103,15 @@ function collectModelCollections(value: unknown, depth = 0): unknown[][] {
)
}
export function extractProviderModelOptions(payload: unknown): ProviderModelOption[] {
export function extractProviderModelOptions(
payload: unknown,
options?: { preferExternalImageModelId?: boolean },
): ProviderModelOption[] {
const deduplicated = new Map<string, ProviderModelOption>()
for (const collection of collectModelCollections(payload)) {
for (const entry of collection) {
const model = normalizeModelEntry(entry)
const model = normalizeModelEntry(entry, options?.preferExternalImageModelId)
if (model && !deduplicated.has(model.value)) {
deduplicated.set(model.value, model)
}
@ -115,7 +129,7 @@ function buildEndpointCandidates(
baseUrl: string,
serviceType: ProviderModelServiceType,
): Array<{ url: string; path: string }> {
if (serviceType === 'ai-render') {
if (serviceType === 'ai-render' || serviceType === 'image-background-removal') {
return [{
url: joinUrl(baseUrl, '/api/external/image/models'),
path: '/api/external/image/models',
@ -151,6 +165,7 @@ function extractErrorMessage(payload: unknown): string | null {
async function requestCatalog(
endpoint: { url: string; path: string },
apiKey: string,
serviceType: ProviderModelServiceType,
): Promise<CatalogAttempt> {
const startedAt = Date.now()
try {
@ -174,7 +189,12 @@ async function requestCatalog(
}
}
const models = response.ok ? extractProviderModelOptions(payload) : []
const models = response.ok
? extractProviderModelOptions(payload, {
preferExternalImageModelId:
serviceType === 'ai-render' || serviceType === 'image-background-removal',
})
: []
return {
status: response.status,
latency,
@ -207,7 +227,7 @@ export class ProviderModelCatalogService {
let reachedProvider = false
for (const candidate of candidates) {
const attempt = await requestCatalog(candidate, apiKey)
const attempt = await requestCatalog(candidate, apiKey, input.serviceType)
lastAttempt = attempt
reachedProvider ||= attempt.status >= 200 && attempt.status < 500