From 6b3760c6e731aec2e50f15b260dbd981a75c5ef7 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 3 Sep 2026 18:27:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(bom-costing):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8F=AF=E5=88=87=E6=8D=A2=E7=9A=84=E6=8A=A0=E5=9B=BE=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-render/AiRenderConfigClient.test.tsx | 55 ++++++++ .../admin/ai-render/AiRenderConfigClient.tsx | 73 +++++++++- .../app/api/admin/test-connection/route.ts | 2 + .../provider-model-catalog.service.test.ts | 24 ++++ ...g-image-background-removal.service.test.ts | 51 +++++++ ...osting-image-background-removal.service.ts | 126 +++++++++++++----- .../provider-model-catalog.service.ts | 13 +- 7 files changed, 307 insertions(+), 37 deletions(-) diff --git a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx index 1c4a0178..d25b619a 100644 --- a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx +++ b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx @@ -87,4 +87,59 @@ describe('AI 服务配置模型选择', () => { model: 'gpt-5.6', }) }) + + it('商品抠图配置可选择接口协议并随配置保存', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + success: true, + data: { + ok: true, + status: 200, + latency: 120, + models: [{ value: 'image-edit-v3', label: 'Image Edit V3' }], + endpointPath: '/models', + error: null, + }, + }), + }) + global.fetch = fetchMock as typeof fetch + const onSave = jest.fn() + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('如:生产环境、测试 Key'), { target: { value: '通用抠图' } }) + fireEvent.change(screen.getByPlaceholderText('https://ai.yydd.ai'), { target: { value: 'https://api.example.com/v1' } }) + fireEvent.change(screen.getByPlaceholderText('extimg_xxx... / sk-...'), { target: { value: 'secret' } }) + fireEvent.change(screen.getByRole('combobox', { name: '接口协议' }), { + target: { value: 'openai-compatible-image-edit' }, + }) + fireEvent.click(screen.getByRole('button', { name: '测试并获取模型' })) + + const modelSelect = screen.getByRole('combobox', { name: '模型' }) + await waitFor(() => expect(modelSelect).toBeEnabled()) + fireEvent.change(modelSelect, { target: { value: 'image-edit-v3' } }) + fireEvent.click(screen.getByRole('button', { name: '保存' })) + + expect(fetchMock).toHaveBeenCalledWith('/api/admin/test-connection', expect.objectContaining({ + body: JSON.stringify({ + baseUrl: 'https://api.example.com/v1', + serviceType: 'image-background-removal', + apiMode: 'openai-compatible-image-edit', + }), + })) + expect(onSave).toHaveBeenCalledWith({ + name: '通用抠图', + baseUrl: 'https://api.example.com/v1', + apiKey: 'secret', + model: 'image-edit-v3', + extra: { apiMode: 'openai-compatible-image-edit' }, + }) + }) }) diff --git a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx index 6f7bcc50..7d1a5687 100644 --- a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx +++ b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx @@ -36,6 +36,7 @@ interface ProviderConfig { baseUrl: string apiKey: string model: string | null + extra: Record | null isActive: boolean createdAt: string } @@ -45,6 +46,27 @@ type ActiveTab = 'ai-render' | 'prompt-optimizer' | 'image-background-removal' interface TestResult { status: 'idle' | 'testing' | 'success' | 'error'; message: string } interface ModelOption { value: string; label: string } +type BackgroundRemovalApiMode = 'external-image-gateway' | 'openai-compatible-image-edit' + +const BACKGROUND_REMOVAL_API_MODE_META: Record = { + 'openai-compatible-image-edit': { + label: 'OpenAI 兼容图片编辑', + desc: '使用配置地址下的 /images/edits,适合兼容 OpenAI 图片编辑协议的服务。', + }, + 'external-image-gateway': { + label: '图片生成网关', + desc: '使用 /api/external/image/generate,兼容当前图片网关。', + }, +} + +function readBackgroundRemovalApiMode(config?: ProviderConfig | null): BackgroundRemovalApiMode { + if (!config) return 'openai-compatible-image-edit' + const value = config?.extra?.apiMode + return value === 'openai-compatible-image-edit' || value === 'external-image-gateway' + ? value + : 'external-image-gateway' +} + export const TAB_META: Record = { 'ai-render': { label: '生图服务', icon: Sparkles, desc: '辅助装配 AI 效果图的外部生图接口', serviceType: 'ai-render' }, 'prompt-optimizer': { label: '提示词优化', icon: Wand2, desc: '用 AI 优化用户的粗糙描述为高质量生图 prompt', serviceType: 'prompt-optimizer' }, @@ -77,6 +99,11 @@ function ConfigCard({ config, onActivate, onEdit, onDelete }: {

{config.baseUrl}

{config.model &&

模型:{config.model}

} + {config.serviceType === 'image-background-removal' && ( +

+ 协议:{BACKGROUND_REMOVAL_API_MODE_META[readBackgroundRemovalApiMode(config)].label} +

+ )}
{!config.isActive && ( @@ -101,7 +128,7 @@ function ConfigCard({ config, onActivate, onEdit, onDelete }: { export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: { config?: ProviderConfig | null serviceType: string - onSave: (data: { name: string; baseUrl: string; apiKey: string; model: string }) => void + onSave: (data: { name: string; baseUrl: string; apiKey: string; model: string; extra?: Record }) => void onCancel: () => void saving: boolean }) { @@ -114,6 +141,9 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: { const [testResult, setTestResult] = useState({ status: 'idle', message: '' }) const [models, setModels] = useState([]) const [modelsLoaded, setModelsLoaded] = useState(false) + const [backgroundRemovalApiMode, setBackgroundRemovalApiMode] = useState( + readBackgroundRemovalApiMode(config), + ) async function handleTest() { setTesting(true) @@ -124,7 +154,11 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: { const res = await fetch('/api/admin/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-test-api-key': apiKey }, - body: JSON.stringify({ baseUrl, serviceType }), + body: JSON.stringify({ + baseUrl, + serviceType, + ...(serviceType === 'image-background-removal' ? { apiMode: backgroundRemovalApiMode } : {}), + }), }) const data = await res.json() if (data.success && data.data?.ok) { @@ -166,6 +200,29 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: { setName(e.target.value)} placeholder="如:生产环境、测试 Key" className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] placeholder:text-[#4a5a5d] focus:ring-[#47646b]/40" />
+ {serviceType === 'image-background-removal' && ( +
+ + +

{BACKGROUND_REMOVAL_API_MODE_META[backgroundRemovalApiMode].desc}

+
+ )}
{ setBaseUrl(e.target.value); setModel(''); setModelsLoaded(false); setModels([]) }} placeholder="https://ai.yydd.ai" className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] placeholder:text-[#4a5a5d] focus:ring-[#47646b]/40" /> @@ -221,7 +278,15 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
-
@@ -258,7 +323,7 @@ export default function AiRenderConfigClient() { if (canManage) fetchConfigs() }, [canManage, fetchConfigs]) - async function handleSave(formData: { name: string; baseUrl: string; apiKey: string; model: string }) { + async function handleSave(formData: { name: string; baseUrl: string; apiKey: string; model: string; extra?: Record }) { setSaving(true) try { const url = editing ? `/api/admin/provider-configs/${editing.id}` : '/api/admin/provider-configs' diff --git a/furniture-3d-system/src/app/api/admin/test-connection/route.ts b/furniture-3d-system/src/app/api/admin/test-connection/route.ts index 84718d3e..e4e81dc2 100644 --- a/furniture-3d-system/src/app/api/admin/test-connection/route.ts +++ b/furniture-3d-system/src/app/api/admin/test-connection/route.ts @@ -7,6 +7,7 @@ import { providerModelCatalogService } from '@/server/services' const schema = z.object({ baseUrl: z.string().url('地址格式不正确'), serviceType: z.enum(['ai-render', 'prompt-optimizer', 'image-background-removal']), + apiMode: z.enum(['external-image-gateway', 'openai-compatible-image-edit']).optional(), }) export async function POST(request: NextRequest) { @@ -28,6 +29,7 @@ export async function POST(request: NextRequest) { baseUrl: parsed.data.baseUrl, serviceType: parsed.data.serviceType, apiKey, + apiMode: parsed.data.apiMode, }) return jsonSuccess(result as unknown as Record, 200, ctx.requestId) }, diff --git a/furniture-3d-system/src/server/services/__tests__/provider-model-catalog.service.test.ts b/furniture-3d-system/src/server/services/__tests__/provider-model-catalog.service.test.ts index 2e1268a3..ad38bee3 100644 --- a/furniture-3d-system/src/server/services/__tests__/provider-model-catalog.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/provider-model-catalog.service.test.ts @@ -164,6 +164,30 @@ describe('ProviderModelCatalogService', () => { }) }) + it('商品抠图可切换为 OpenAI 兼容模型目录', async () => { + const fetchMock = jest.fn().mockResolvedValue(createResponse(200, { + data: [{ id: 'image-edit-v3' }], + })) + global.fetch = fetchMock as typeof fetch + + const result = await service.fetchCatalog({ + serviceType: 'image-background-removal', + apiMode: 'openai-compatible-image-edit', + baseUrl: 'https://api.example.com/v1', + apiKey: 'secret', + }) + + expect(result).toMatchObject({ + ok: true, + endpointPath: '/models', + models: [{ value: 'image-edit-v3', label: 'image-edit-v3' }], + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/v1/models', + expect.any(Object), + ) + }) + it('密钥无权限时返回明确错误且不继续尝试其他地址', async () => { const fetchMock = jest.fn().mockResolvedValue(createResponse(401, { error: { message: 'unauthorized' }, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-background-removal.service.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-background-removal.service.test.ts index 26fbc321..2e6b6837 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-background-removal.service.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-background-removal.service.test.ts @@ -92,4 +92,55 @@ describe('BomCostingImageBackgroundRemovalService', () => { const metadata = await sharp(first!.buffer).metadata() expect(metadata).toMatchObject({ width: 1024, height: 1024, format: 'jpeg' }) }) + + it('uses the active OpenAI-compatible image-edit config without the image gateway', async () => { + const generatedPng = await sharp({ + create: { + width: 24, + height: 36, + channels: 4, + background: { r: 90, g: 60, b: 30, alpha: 1 }, + }, + }).png().toBuffer() + const generate = jest.fn() + const fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: [{ b64_json: generatedPng.toString('base64') }], + }), + text: async () => '', + } as unknown as Response) + const config = { + ...activeConfig(), + baseUrl: 'https://api.example.com/v1', + model: 'image-edit-v3', + extra: { apiMode: 'openai-compatible-image-edit' }, + } + const service = new BomCostingImageBackgroundRemovalService({ + getActiveConfig: jest.fn().mockResolvedValue(config), + generate, + fetch, + }) + + const result = await service.prepare({ + buffer: Buffer.from('openai-compatible-source'), + contentType: 'image/png', + }) + + expect(generate).not.toHaveBeenCalled() + expect(fetch).toHaveBeenCalledTimes(1) + const [endpoint, requestInit] = fetch.mock.calls[0] as [string, RequestInit] + expect(endpoint).toBe('https://api.example.com/v1/images/edits') + expect(requestInit.headers).toMatchObject({ Authorization: 'Bearer secret' }) + const formData = requestInit.body as FormData + expect(formData.get('model')).toBe('image-edit-v3') + expect(formData.get('response_format')).toBe('b64_json') + expect(result).toMatchObject({ model: 'image-edit-v3', contentType: 'image/jpeg' }) + await expect(sharp(result!.buffer).metadata()).resolves.toMatchObject({ + width: 1024, + height: 1024, + format: 'jpeg', + }) + }) }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-background-removal.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-background-removal.service.ts index 35c34cd4..9804f8df 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-background-removal.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-background-removal.service.ts @@ -12,6 +12,10 @@ const BACKGROUND_REMOVAL_RESULT_TIMEOUT_MS = 45_000 const BACKGROUND_REMOVAL_MAX_RESULT_BYTES = 10 * 1024 * 1024 const BACKGROUND_REMOVAL_CACHE_LIMIT = 24 +type BackgroundRemovalApiMode = + | 'external-image-gateway' + | 'openai-compatible-image-edit' + 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.', @@ -78,6 +82,24 @@ function buildCacheKey( .digest('hex') } +function getApiMode(config: NonNullable): BackgroundRemovalApiMode { + return config.extra?.apiMode === 'openai-compatible-image-edit' + ? 'openai-compatible-image-edit' + : 'external-image-gateway' +} + +function decodeBase64Image(value: string): Buffer { + const normalized = value.replace(/^data:image\/[a-zA-Z0-9.+-]+;base64,/, '').trim() + if (!normalized || normalized.length > Math.ceil(BACKGROUND_REMOVAL_MAX_RESULT_BYTES * 4 / 3) + 8) { + throw new Error('商品抠图结果为空或超过大小限制') + } + const buffer = Buffer.from(normalized, 'base64') + if (buffer.length === 0 || buffer.length > BACKGROUND_REMOVAL_MAX_RESULT_BYTES) { + throw new Error('商品抠图结果为空或超过大小限制') + } + return buffer +} + export class BomCostingImageBackgroundRemovalService { private readonly cache = new Map() @@ -85,6 +107,57 @@ export class BomCostingImageBackgroundRemovalService { private readonly dependencies: BomCostingBackgroundRemovalDependencies = defaultDependencies, ) {} + private async downloadGeneratedImage(url: string): Promise { + const safeUrl = parseSafeCompetitorUrl(url) + 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('商品抠图结果不是图片') + return readBoundedImage(response) + } + + private async editWithOpenAiCompatibleApi( + config: NonNullable, + input: { buffer: Buffer; contentType: string }, + ): Promise { + const formData = new FormData() + formData.append('model', config.model!) + formData.append('prompt', BOM_COSTING_BACKGROUND_REMOVAL_PROMPT) + formData.append('size', '1024x1024') + formData.append('response_format', 'b64_json') + formData.append( + 'image', + new Blob([new Uint8Array(input.buffer)], { type: input.contentType }), + sourceFileName(input.contentType), + ) + + const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/images/edits` + const response = await this.dependencies.fetch(endpoint, { + method: 'POST', + headers: { Authorization: `Bearer ${config.apiKey}` }, + body: formData, + cache: 'no-store', + signal: AbortSignal.timeout(BACKGROUND_REMOVAL_TIMEOUT_MS), + }) + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(`商品抠图服务返回 ${response.status}: ${body.slice(0, 200)}`) + } + const payload = await response.json() as { + data?: Array<{ b64_json?: string; url?: string }> + } + const image = payload.data?.[0] + if (image?.b64_json) return decodeBase64Image(image.b64_json) + if (image?.url) return this.downloadGeneratedImage(image.url) + throw new Error('商品抠图服务没有返回可用图片') + } + async prepare(input: { buffer: Buffer contentType: string @@ -105,37 +178,28 @@ export class BomCostingImageBackgroundRemovalService { } } - 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 generatedBuffer = getApiMode(config) === 'openai-compatible-image-edit' + ? await this.editWithOpenAiCompatibleApi(config, input) + : await (async () => { + 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('商品抠图服务没有返回可用图片') + return this.downloadGeneratedImage(generatedUrl) + })() const normalized = await sharp(generatedBuffer) .rotate() .flatten({ background: '#ffffff' }) diff --git a/furniture-3d-system/src/server/services/provider-model-catalog.service.ts b/furniture-3d-system/src/server/services/provider-model-catalog.service.ts index 7fb177f8..dc568923 100644 --- a/furniture-3d-system/src/server/services/provider-model-catalog.service.ts +++ b/furniture-3d-system/src/server/services/provider-model-catalog.service.ts @@ -5,6 +5,10 @@ export type ProviderModelServiceType = | 'prompt-optimizer' | 'image-background-removal' +export type ImageBackgroundRemovalApiMode = + | 'external-image-gateway' + | 'openai-compatible-image-edit' + export interface ProviderModelOption { value: string label: string @@ -23,6 +27,7 @@ interface FetchProviderModelCatalogInput { serviceType: ProviderModelServiceType baseUrl: string apiKey: string + apiMode?: ImageBackgroundRemovalApiMode } interface CatalogAttempt { @@ -128,8 +133,12 @@ function joinUrl(baseUrl: string, path: string): string { function buildEndpointCandidates( baseUrl: string, serviceType: ProviderModelServiceType, + apiMode?: ImageBackgroundRemovalApiMode, ): Array<{ url: string; path: string }> { - if (serviceType === 'ai-render' || serviceType === 'image-background-removal') { + if ( + serviceType === 'ai-render' || + (serviceType === 'image-background-removal' && apiMode !== 'openai-compatible-image-edit') + ) { return [{ url: joinUrl(baseUrl, '/api/external/image/models'), path: '/api/external/image/models', @@ -221,7 +230,7 @@ export class ProviderModelCatalogService { async fetchCatalog(input: FetchProviderModelCatalogInput): Promise { const baseUrl = normalizeBaseUrl(input.baseUrl) const apiKey = normalizeRequiredText(input.apiKey, 'API 密钥') - const candidates = buildEndpointCandidates(baseUrl, input.serviceType) + const candidates = buildEndpointCandidates(baseUrl, input.serviceType, input.apiMode) const startedAt = Date.now() let lastAttempt: CatalogAttempt | null = null let reachedProvider = false