feat(ai): use durable Relay native image tasks

Persist image submissions with fixed idempotency keys, leased execution, provider
identity, reference snapshots and staged results. Integrate DIY, assisted assembly
and non-integrated white-background sessions without holding generation in a
browser request. Recover ambiguous submissions through owner-scoped reads and
stop a new eight-view batch when restoring an earlier request.

Add the NativeImageGenerationJob migration and a dedicated worker to both release
entry points. Protect job inputs/results from storage GC. Existing production
configuration and business data are not changed by this commit.

Validation: 256 focused Jest tests, isolated PostgreSQL migration/lease/transaction
checks (8), deployment guard tests (9), type-check, production build and desktop/
mobile browser component scenarios. Live native 2K reference-image request worked;
reusing its idempotency key returned the same task. Build uses local placeholders
for otherwise absent Drawing System configuration. Three unrelated material tests
also fail with the original mainline test file; they remain outside this change.

Architecture/visual/root-cause review: passed with warnings. Independent review
completed for the integration and shared input/storage paths; worker additionally
reviewed by the primary agent. Production merge/deployment requires authorization.
This commit is contained in:
WuYongJie 2026-10-15 14:55:46 +08:00
parent dc2c86275e
commit 928a7b37a9
71 changed files with 4205 additions and 2071 deletions

View File

@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"worker:ai-image": "node scripts/run-ai-image-worker.mjs",
"predev": "npm run prisma:check-client-sync",
"dev": "next dev --webpack",
"build": "prisma generate && next build",

View File

@ -0,0 +1,25 @@
CREATE TABLE "NativeImageGenerationJob" (
"id" TEXT NOT NULL,
"createdBy" TEXT NOT NULL,
"providerConfigId" TEXT NOT NULL,
"providerFingerprint" TEXT NOT NULL,
"requestHash" TEXT NOT NULL,
"request" JSONB NOT NULL,
"completion" JSONB NOT NULL,
"progress" JSONB NOT NULL DEFAULT '[]',
"status" TEXT NOT NULL DEFAULT 'QUEUED',
"retryCount" INTEGER NOT NULL DEFAULT 0,
"nextRunAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"leaseOwner" TEXT,
"leaseUntil" TIMESTAMP(3),
"errorMessage" TEXT,
"finishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NativeImageGenerationJob_pkey" PRIMARY KEY ("id"),
CONSTRAINT "NativeImageGenerationJob_status_valid" CHECK ("status" IN ('QUEUED', 'RUNNING', 'FINALIZING', 'SUCCEEDED', 'FAILED', 'BLOCKED')),
CONSTRAINT "NativeImageGenerationJob_retry_valid" CHECK ("retryCount" >= 0),
CONSTRAINT "NativeImageGenerationJob_identity_valid" CHECK (length(btrim("id")) > 0 AND length(btrim("createdBy")) > 0)
);
CREATE INDEX "NativeImageGenerationJob_status_nextRunAt_leaseUntil_idx" ON "NativeImageGenerationJob"("status", "nextRunAt", "leaseUntil");
CREATE INDEX "NativeImageGenerationJob_createdBy_createdAt_idx" ON "NativeImageGenerationJob"("createdBy", "createdAt");

View File

@ -2740,6 +2740,30 @@ model AssistedAssemblyAiRender {
@@index([status, createdAt])
}
/// Durable execution state for image generation. Business results remain in their owning module.
model NativeImageGenerationJob {
id String @id
createdBy String
providerConfigId String
providerFingerprint String
requestHash String
request Json
completion Json
progress Json @default("[]")
status String @default("QUEUED")
retryCount Int @default(0)
nextRunAt DateTime @default(now())
leaseOwner String?
leaseUntil DateTime?
errorMessage String? @db.Text
finishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, nextRunAt, leaseUntil])
@@index([createdBy, createdAt])
}
model DiyScheme {
id String @id @default(cuid())
code String @unique

View File

@ -0,0 +1,23 @@
// Dedicated process: it resumes persisted jobs after application/host restarts.
const secret = process.env.AI_IMAGE_WORKER_SECRET?.trim()
const port = Number(process.env.PORT ?? process.env.APP_PORT ?? 3001)
if (!secret || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('AI image worker requires AI_IMAGE_WORKER_SECRET and a valid local app port')
}
let stopping = false
process.on('SIGINT', () => { stopping = true })
process.on('SIGTERM', () => { stopping = true })
while (!stopping) {
let idle = false
try {
const response = await fetch(`http://127.0.0.1:${port}/api/internal/ai-image/worker/run`, {
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(120_000),
headers: { 'x-ai-image-worker-secret': secret },
})
if (!response.ok) throw new Error('worker turn failed')
const payload = await response.json()
idle = payload?.data?.worker?.status === 'idle'
if (payload?.data?.worker?.status === 'blocked') console.error('[AI image worker] a task requires review')
} catch { console.error('[AI image worker] turn unavailable; persisted tasks will resume') }
if (!stopping) await new Promise(resolve => setTimeout(resolve, idle ? 5000 : 1000))
}

View File

@ -211,6 +211,9 @@ const schemaJsonFieldClassifications = new Map(
['AssistedAssemblyAiRender.workspaceSnapshot', ['process-snapshot', '辅助装配工作区快照。']],
['AssistedAssemblyAiRender.assetSnapshot', ['process-snapshot', '辅助装配资产快照。']],
['AssistedAssemblyAiRender.attempts', ['process-snapshot', '辅助装配 AI 生图尝试记录。']],
['NativeImageGenerationJob.request', ['process-snapshot', '原生 AI 任务冻结请求及参考图引用,不含访问密钥。']],
['NativeImageGenerationJob.completion', ['process-snapshot', '原生 AI 任务业务归档目标,不作为产品主数据。']],
['NativeImageGenerationJob.progress', ['process-snapshot', '原生 AI 任务幂等编号、远端任务号与待归档结果。']],
['DiyScheme.snapshot', ['process-snapshot', 'DIY 组合方案配置与几何恢复快照;不创建正式产品、SKU 或条码。']],
['DiyCabinetStyle.panels', ['geometry-json', 'DIY 参数化柜体板件几何;身份与尺寸使用结构化列。']],
['DiyCabinetStyle.cells', ['geometry-json', 'DIY 格口与柜门几何;柜门目录和文件引用使用结构化表。']],

View File

@ -184,7 +184,7 @@ export default function AiRenderWorkbenchClient({
const {
selectedRenderTask,
generating,
generating: localGenerating,
startRenderTask,
setMessageForSku,
settleRenderTask,
@ -351,6 +351,7 @@ export default function AiRenderWorkbenchClient({
const {
selectedSkuRecords,
hasPendingSelectedSku,
appendServerRenderRecords,
handleDeleteRecord,
handleRefreshRecords,
@ -360,6 +361,8 @@ export default function AiRenderWorkbenchClient({
setMessage,
})
const generating = localGenerating || hasPendingSelectedSku
const handleCreateSpu = useCallback(async () => {
if (await createSpu()) {
resetPreviewSelection()

View File

@ -0,0 +1,51 @@
/** @jest-environment node */
import { runEightViewGeneration } from './eightViewGeneration'
jest.mock('./referenceGeometry', () => ({
buildLocalStructureReference: () => null,
buildRotatedCameraView: (view: unknown) => view,
cloneCameraView: (view: unknown) => view,
createReferenceCaptureView: (_controller: unknown, view: unknown) => view,
getEightViewReferenceIndexes: () => [],
}))
jest.mock('./referenceImageUtils', () => ({ buildReadableStructureReferenceFromImage: async () => null }))
jest.mock('./promptBuilders', () => ({ buildEightViewPrompt: () => 'fixture prompt' }))
jest.mock('./utils', () => ({
buildAiRenderAssetSnapshot: () => ({}), buildAiRenderWorkspaceSnapshot: () => ({}), createId: () => 'new-batch',
}))
jest.mock('./workbenchHelpers', () => ({ buildTaskStartedMessage: () => 'started', mergeNegativePrompt: () => '' }))
test('reload at original view 3 recovers that request and stops before submitting a replacement eight-view batch', async () => {
const values = new Map([[
'native-image-submission:v1:/api/assisted-assembly/ai-renders:sku-1:sku-1:eight-view:old-view-3',
JSON.stringify({ requestId: 'old-batch-view-3', fingerprint: 'old-snapshot' }),
]])
Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: {
get length() { return values.size }, key: (index: number) => [...values.keys()][index] ?? null,
getItem: (key: string) => values.get(key) ?? null, removeItem: (key: string) => values.delete(key),
setItem: (key: string, value: string) => values.set(key, value),
} })
const originalFetch = global.fetch
const originalRender = { id: 'old-view-3-render', status: 'SUCCEEDED', viewIndex: 3, params: { batchId: 'old-batch' }, resultImages: [] }
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, data: { render: originalRender } }) })
const append = jest.fn().mockReturnValue(0), notify = jest.fn(), settle = jest.fn()
try {
await runEightViewGeneration({
canGenerate: true, generating: false, selectedSku: { id: 'sku-1', modelUrl: '/model.glb' }, selectedSpu: null,
prompt: '', negativePrompt: '', aspectRatio: '1:1', imageSize: '2K', modelNodeNames: [], canCapturePreview: true,
getController: () => ({ saveView: () => ({ position: [1, 1, 1], target: [0, 0, 0] }), restoreView: async () => {} }),
captureReferenceAtView: async () => 'data:image/png;base64,AQID', buildProjectedDetailReferenceFromImage: async () => null,
startRenderTask: () => ({ id: 'new-task' }), updateRunningRenderTask: jest.fn(), appendServerRenderRecords: append,
setMessageForSku: notify, settleRenderTask: settle, setMessage: jest.fn(),
} as unknown as Parameters<typeof runEightViewGeneration>[0])
expect(fetch).toHaveBeenCalledTimes(1)
expect(jest.mocked(fetch).mock.calls[0][0]).toContain('requestId=old-batch-view-3')
expect(jest.mocked(fetch).mock.calls[0][1]?.method).toBeUndefined()
expect(append).toHaveBeenCalledWith(expect.objectContaining({ id: 'old-view-3-render', viewIndex: 3, submissionRecovery: expect.any(Object) }), expect.any(Object))
expect(notify).toHaveBeenCalledWith('sku-1', expect.stringContaining('没有继续新建'))
expect(settle).toHaveBeenCalledWith('new-task', 'succeeded', expect.stringContaining('未新建'))
} finally {
global.fetch = originalFetch
delete (globalThis as { sessionStorage?: Storage }).sessionStorage
}
})

View File

@ -3,6 +3,7 @@
import type { CameraController, CameraViewSnapshot } from '@/lib/3d/camera-controller'
import {
createAssistedAssemblyAiRender,
waitForAssistedAssemblyAiRender,
type CreateAssistedAssemblyAiRenderPayload,
} from '@/lib/open-product/platform-api'
import type { AssistedAssemblyAiRenderDTO } from '@/server/services'
@ -189,6 +190,8 @@ async function captureEightViewBatchReferences(params: {
}
async function createEightViewRender(params: {
requestId: string
onUncertain?: (message: string) => void
batchId: string
preset: EightViewPreset
viewIndex: number
@ -231,7 +234,8 @@ async function createEightViewRender(params: {
structureReferenceLabels,
})
return createAssistedAssemblyAiRender({
const pending = await createAssistedAssemblyAiRender({
requestId: params.requestId,
prompt: lockedPrompt,
negativePrompt: buildEightViewNegativePrompt(taskNegativePrompt),
imageCount: 1,
@ -261,7 +265,8 @@ async function createEightViewRender(params: {
sku: taskSku,
nodeNames: taskNodeNames,
}),
})
}, { onUncertain: params.onUncertain })
return pending
}
function resolveEightViewPreset(record: LocalRenderRecord): {
@ -412,6 +417,7 @@ export async function runEightViewGeneration(
await controller.restoreView(baseView, { transition: 'instant', duration: 0 })
const submitted: Awaited<ReturnType<typeof createEightViewRender>>[] = []
for (let itemIndex = 0; itemIndex < viewReferences.length; itemIndex += 1) {
const item = viewReferences[itemIndex]!
const structureReferences = [
@ -433,6 +439,8 @@ export async function runEightViewGeneration(
)
const render = await createEightViewRender({
requestId: `${task.id}-${item.index}`,
onUncertain: (message) => setMessageForSku(taskSku.id, message),
batchId,
preset: item.preset,
viewIndex: item.index,
@ -448,16 +456,25 @@ export async function runEightViewGeneration(
referenceImageDataUrl: item.referenceImageDataUrl,
structureReferences,
})
savedCount += appendServerRenderRecords(render, taskSku)
appendServerRenderRecords(render, taskSku)
if (render.submissionRecovery) {
setMessageForSku(taskSku.id, '已找回上次八视图任务,请先核对原批次。本次没有继续新建八视图任务。')
settleRenderTask(task.id, 'succeeded', '已恢复原批次,未新建任务')
return
}
submitted.push(render)
}
// All captured views are durable before waiting for generation, so navigation cannot lose later views.
const completed = await Promise.all(submitted.map((render) => waitForAssistedAssemblyAiRender(render)))
for (const render of completed) savedCount += appendServerRenderRecords(render, taskSku)
if (savedCount > 0) {
setMessageForSku(taskSku.id, `八视图已生成,共 ${savedCount} 张。`)
setMessageForSku(taskSku.id, savedCount === viewReferences.length ? `八视图已生成,共 ${savedCount} 张。` : `八视图已生成 ${savedCount}/${viewReferences.length} 张,其他视角未完成,请在记录中核对。`)
settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`)
return
}
setMessageForSku(taskSku.id, 'AI 请求已返回,但没有可用的八视图。')
setMessageForSku(taskSku.id, completed.find((render) => render.errorMessage)?.errorMessage || 'AI 请求已返回,但没有可用的八视图。')
settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片')
} catch (error) {
await controller.restoreView(baseView, { transition: 'instant', duration: 0 }).catch(() => undefined)
@ -552,7 +569,9 @@ export async function runEightViewRegeneration(
})
updateRunningRenderTask(task.id, 58, `正在重生成:${preset.label}`)
const render = await createEightViewRender({
const pending = await createEightViewRender({
requestId: task.id,
onUncertain: (message) => setMessageForSku(taskSku.id, message),
batchId,
preset,
viewIndex: presetIndex,
@ -568,6 +587,13 @@ export async function runEightViewRegeneration(
referenceImageDataUrl,
structureReferences,
})
appendServerRenderRecords(pending, taskSku)
if (pending.submissionRecovery) {
setMessageForSku(taskSku.id, '已找回上次提交的任务,请先核对原记录。本次没有新建生成任务。')
settleRenderTask(task.id, 'succeeded', '已恢复原任务,未新建任务')
return
}
const render = await waitForAssistedAssemblyAiRender(pending)
const savedCount = appendServerRenderRecords(render, taskSku)
await controller.restoreView(currentView, { transition: 'instant', duration: 0 })

View File

@ -3,6 +3,7 @@
import type { CameraController } from '@/lib/3d/camera-controller'
import {
createAssistedAssemblyAiRender,
waitForAssistedAssemblyAiRender,
type CreateAssistedAssemblyAiRenderPayload,
} from '@/lib/open-product/platform-api'
import {
@ -131,7 +132,7 @@ export async function runSceneGeneration(params: SceneGenerationParams): Promise
setMessage(buildTaskStartedMessage(task))
try {
const render = await createAssistedAssemblyAiRender({
const pending = await createAssistedAssemblyAiRender({
prompt: lockedScenePrompt,
negativePrompt: mergeNegativePrompt(
taskNegativePrompt,
@ -157,19 +158,26 @@ export async function runSceneGeneration(params: SceneGenerationParams): Promise
sku: taskSku,
nodeNames: taskNodeNames,
}),
})
}, { onUncertain: (message) => setMessageForSku(taskSku.id, message) })
appendServerRenderRecords(pending, taskSku)
if (pending.submissionRecovery) {
setMessageForSku(taskSku.id, '已找回上次提交的任务,请先核对原记录。本次没有新建生成任务。')
settleRenderTask(task.id, 'succeeded', '已恢复原任务,未新建任务')
return
}
const render = await waitForAssistedAssemblyAiRender(pending)
const savedCount = appendServerRenderRecords(render, taskSku)
if (render.status === 'SUCCEEDED' && savedCount > 0) {
setMessageForSku(
taskSku.id,
savedCount > 1 ? `场景效果图已生成,共 ${savedCount} 张。` : '场景效果图已生成。',
render.errorMessage || (savedCount > 1 ? `场景效果图已生成,共 ${savedCount} 张。` : '场景效果图已生成。'),
)
settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`)
return
}
setMessageForSku(taskSku.id, render.errorMessage || 'AI 生图请求已返回,但没有可用结果图。')
settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片')
settleRenderTask(task.id, 'failed', render.status === 'PENDING' ? '任务待核对,请勿重复提交' : '生成结束,但没有返回可用图片')
} catch (error) {
setMessageForSku(taskSku.id, error instanceof Error ? error.message : 'AI 生图失败')
settleRenderTask(task.id, 'failed', '生成失败,请稍后重试')

View File

@ -0,0 +1,44 @@
import { act, renderHook } from '@testing-library/react'
import { useRenderRecordsController } from './useRenderRecordsController'
import { listAiRenderWorkbenchSkuRenders } from '@/lib/open-product/platform-api'
import type { AiRenderSku } from './types'
jest.mock('@/lib/open-product/platform-api', () => ({ listAiRenderWorkbenchSkuRenders: jest.fn(), deleteAssistedAssemblyAiRender: jest.fn() }))
jest.mock('./workbenchHelpers', () => ({ mapServerRenderToLocalRecords: (row: { id: string; status: string }) => row.status === 'SUCCEEDED' ? [{ id: `${row.id}:0`, serverRenderId: row.id, skuId: 'sku-1' }] : [] }))
const sku = { id: 'sku-1' } as AiRenderSku
const setMessage = jest.fn()
beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks() })
afterEach(() => { jest.useRealTimers() })
test('a refreshed page discovers pending work, polls it and loads the completed images', async () => {
jest.mocked(listAiRenderWorkbenchSkuRenders).mockResolvedValueOnce([{ id: 'render-1', status: 'PENDING' }] as never)
.mockResolvedValueOnce([{ id: 'render-1', status: 'SUCCEEDED' }] as never)
const { result, unmount } = renderHook(() => useRenderRecordsController({ canDeleteImages: true, selectedSku: sku, setMessage }))
await act(async () => undefined)
expect(result.current.hasPendingSelectedSku).toBe(true)
await act(async () => { await jest.advanceTimersByTimeAsync(3000) })
expect(result.current.hasPendingSelectedSku).toBe(false)
expect(result.current.selectedSkuRecords).toHaveLength(1)
expect(jest.getTimerCount()).toBe(0)
unmount()
})
test('pending tasks requiring review show their message without endless polling', async () => {
jest.mocked(listAiRenderWorkbenchSkuRenders).mockResolvedValue([{ id: 'render-1', status: 'PENDING', errorMessage: '任务待核对,请勿重复提交' }] as never)
const { result, unmount } = renderHook(() => useRenderRecordsController({ canDeleteImages: true, selectedSku: sku, setMessage }))
await act(async () => undefined)
expect(result.current.hasPendingSelectedSku).toBe(true)
expect(setMessage).toHaveBeenCalledWith('任务待核对,请勿重复提交')
await act(async () => { await jest.advanceTimersByTimeAsync(6000) })
expect(listAiRenderWorkbenchSkuRenders).toHaveBeenCalledTimes(1)
unmount()
})
test('leaving the workbench clears its scheduled status read', async () => {
jest.mocked(listAiRenderWorkbenchSkuRenders).mockResolvedValue([{ id: 'render-1', status: 'PENDING' }] as never)
const { unmount } = renderHook(() => useRenderRecordsController({ canDeleteImages: true, selectedSku: sku, setMessage }))
await act(async () => undefined)
unmount()
await act(async () => { await jest.advanceTimersByTimeAsync(6000) })
expect(listAiRenderWorkbenchSkuRenders).toHaveBeenCalledTimes(1)
})

View File

@ -17,6 +17,8 @@ interface UseRenderRecordsControllerParams {
export function useRenderRecordsController(params: UseRenderRecordsControllerParams) {
const { canDeleteImages, selectedSku, setMessage } = params
const [pendingSkuIds, setPendingSkuIds] = useState<string[]>([])
const [refreshRevision, setRefreshRevision] = useState(0)
const [records, setRecords] = useState<LocalRenderRecord[]>([])
const selectedSkuRecords = useMemo(() => {
@ -26,6 +28,12 @@ export function useRenderRecordsController(params: UseRenderRecordsControllerPar
const appendServerRenderRecords = useCallback(
(render: AssistedAssemblyAiRenderDTO, skuFallback?: AiRenderSku | null) => {
if (render.status === 'PENDING') {
const skuId = render.intakeSkuItemId ?? render.workbenchSkuId ?? skuFallback?.id
if (skuId) setPendingSkuIds((ids) => ids.includes(skuId) ? ids : [...ids, skuId])
setRefreshRevision((revision) => revision + 1)
return 0
}
const nextRecords = mapServerRenderToLocalRecords(render, skuFallback)
if (nextRecords.length === 0) return 0
@ -33,7 +41,7 @@ export function useRenderRecordsController(params: UseRenderRecordsControllerPar
setRecords((current) => {
const sameSkuRecords = [
...nextRecords,
...current.filter((record) => record.skuId === skuId),
...current.filter((record) => record.skuId === skuId && record.serverRenderId !== render.id),
].slice(0, 120)
const otherSkuRecords = current.filter((record) => record.skuId !== skuId)
return [...sameSkuRecords, ...otherSkuRecords]
@ -49,10 +57,22 @@ export function useRenderRecordsController(params: UseRenderRecordsControllerPar
}
let active = true
void (async () => {
let timer: ReturnType<typeof setTimeout> | undefined
let trackedPendingIds: string[] = []
const refresh = async () => {
try {
const renders = await listAiRenderWorkbenchSkuRenders(selectedSku.id, 36)
if (!active) return
const pending = renders.filter((render) => render.status === 'PENDING')
setPendingSkuIds((ids) => [...ids.filter((id) => id !== selectedSku.id), ...(pending.length ? [selectedSku.id] : [])])
const blocked = pending.find((render) => render.errorMessage)
if (blocked) setMessage(blocked.errorMessage)
else if (pending.length) setMessage(`当前 SKU 有 ${pending.length} 个任务正在生成,结果会自动更新`)
else if (trackedPendingIds.length) {
const completed = renders.filter((render) => trackedPendingIds.includes(render.id))
setMessage(completed.find((render) => render.errorMessage)?.errorMessage || '后台生图任务已完成,结果已更新')
}
trackedPendingIds = pending.map((render) => render.id)
const nextRecords = renders.flatMap((render) =>
mapServerRenderToLocalRecords(render, selectedSku),
)
@ -60,17 +80,21 @@ export function useRenderRecordsController(params: UseRenderRecordsControllerPar
...nextRecords,
...current.filter((record) => record.skuId !== selectedSku.id),
])
if (pending.some((render) => !render.errorMessage)) timer = setTimeout(refresh, 3000)
} catch (error) {
if (active) {
setMessage(error instanceof Error ? error.message : '当前 SKU 生图记录加载失败')
timer = setTimeout(refresh, 5000)
}
}
})()
}
void refresh()
return () => {
active = false
clearTimeout(timer)
}
}, [selectedSku, setMessage])
}, [selectedSku, setMessage, refreshRevision])
const handleDeleteRecord = useCallback(
async (recordId: string) => {
@ -95,20 +119,13 @@ export function useRenderRecordsController(params: UseRenderRecordsControllerPar
const handleRefreshRecords = useCallback(async () => {
if (!selectedSku?.id) return
try {
const renders = await listAiRenderWorkbenchSkuRenders(selectedSku.id, 36)
setRecords((current) => [
...renders.flatMap((render) => mapServerRenderToLocalRecords(render, selectedSku)),
...current.filter((record) => record.skuId !== selectedSku.id),
])
setMessage('当前 SKU 生图记录已刷新')
} catch (error) {
setMessage(error instanceof Error ? error.message : '生图记录刷新失败')
}
setRefreshRevision((revision) => revision + 1)
setMessage('正在刷新当前 SKU 生图记录')
}, [selectedSku, setMessage])
return {
records,
hasPendingSelectedSku: Boolean(selectedSku && pendingSkuIds.includes(selectedSku.id)),
selectedSkuRecords,
appendServerRenderRecords,
handleDeleteRecord,

View File

@ -3,6 +3,7 @@
import type { CameraController, CameraViewSnapshot } from '@/lib/3d/camera-controller'
import {
createAssistedAssemblyAiRender,
waitForAssistedAssemblyAiRender,
type CreateAssistedAssemblyAiRenderPayload,
} from '@/lib/open-product/platform-api'
import {
@ -254,7 +255,7 @@ export async function runWhiteBackgroundGeneration(
materialPartLocatorGuides,
})
const render = await createAssistedAssemblyAiRender({
const pending = await createAssistedAssemblyAiRender({
prompt: lockedPrompt,
negativePrompt: mergeNegativePrompt(
taskNegativePrompt,
@ -295,15 +296,22 @@ export async function runWhiteBackgroundGeneration(
sku: taskSku,
nodeNames: taskNodeNames,
}),
})
}, { onUncertain: (message) => setMessageForSku(taskSku.id, message) })
appendServerRenderRecords(pending, taskSku)
if (pending.submissionRecovery) {
setMessageForSku(taskSku.id, '已找回上次提交的任务,请先核对原记录。本次没有新建生成任务。')
settleRenderTask(task.id, 'succeeded', '已恢复原任务,未新建任务')
return
}
const render = await waitForAssistedAssemblyAiRender(pending)
const savedCount = appendServerRenderRecords(render, taskSku)
if (render.status === 'SUCCEEDED' && savedCount > 0) {
setMessageForSku(
taskSku.id,
savedCount > 1
render.errorMessage || (savedCount > 1
? `白底图已生成,共 ${savedCount} 张,并按当前模型参考增强了低分段圆弧的视觉细节。`
: '白底图已生成,并按当前模型参考增强了低分段圆弧的视觉细节。',
: '白底图已生成,并按当前模型参考增强了低分段圆弧的视觉细节。'),
)
settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`)
return
@ -313,7 +321,7 @@ export async function runWhiteBackgroundGeneration(
taskSku.id,
render.errorMessage || 'AI 白底图请求已返回,但没有可用结果图。',
)
settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片')
settleRenderTask(task.id, 'failed', render.status === 'PENDING' ? '任务待核对,请勿重复提交' : '生成结束,但没有返回可用图片')
} catch (error) {
setMessageForSku(
taskSku.id,

View File

@ -7,6 +7,7 @@ const mockJsonError: any = jest.fn()
const mockJsonSuccess: any = jest.fn()
const mockListForUser: any = jest.fn()
const mockCreate: any = jest.fn()
const mockFindForRequest: any = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
@ -17,6 +18,7 @@ jest.mock('@/server/core/response', () => ({
jest.mock('@/server/services', () => ({
assistedAssemblyAiRenderService: {
listForUser: (...args: unknown[]) => mockListForUser(...args),
findForRequest: (...args: unknown[]) => mockFindForRequest(...args),
create: (...args: unknown[]) => mockCreate(...args),
},
}))
@ -134,4 +136,20 @@ describe('GET/POST /api/assisted-assembly/ai-renders', () => {
}),
)
})
it('POST returns an accepted pending record and preserves the caller request id', async () => {
mockCreate.mockResolvedValue({ id: 'render-pending', status: 'PENDING' })
const body = { requestId: 'retry-safe-123', prompt: 'white background', referenceImageDataUrl: 'data:image/png;base64,AAAA', workspaceSnapshot: {}, assetSnapshot: {} }
const response = await POST(createRequest('http://localhost/api/assisted-assembly/ai-renders', body))
expect(response).toMatchObject({ status: 202, data: { render: { status: 'PENDING' } } })
expect(mockCreate).toHaveBeenCalledWith(body, expect.objectContaining({ id: 'user-1' }))
})
})
it('request recovery passes the authenticated owner and returns a nullable original record', async () => {
mockFindForRequest.mockResolvedValue(null)
const result = await GET(createRequest('http://localhost/api/assisted-assembly/ai-renders?requestId=original-request'))
expect(mockFindForRequest).toHaveBeenCalledWith('original-request', expect.objectContaining({ id: 'user-1' }))
expect(result.data).toEqual({ render: null })
})

View File

@ -1,4 +1,4 @@
import { NextRequest } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { canOperateDesignWorkspace } from '@/lib/role-scope'
import { AppError, ErrorCode } from '@/server/core/errors'
@ -10,6 +10,7 @@ const AI_RENDER_NEGATIVE_PROMPT_MAX_LENGTH = 1200
const AI_RENDER_STRUCTURE_REFERENCE_IMAGE_MAX_COUNT = 4
const createAiRenderSchema = z.object({
requestId: z.string().regex(/^[a-zA-Z0-9_-]{8,120}$/, 'AI 请求标识无效').optional(),
prompt: z
.string()
.trim()
@ -63,7 +64,9 @@ function readLimit(request: NextRequest) {
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
async (ctx): Promise<NextResponse> => {
const requestId = request.nextUrl.searchParams.get('requestId')
if (requestId) return jsonSuccess({ render: await assistedAssemblyAiRenderService.findForRequest(requestId, ctx.user) }, 200, ctx.requestId)
const workbenchSkuId = request.nextUrl.searchParams.get('workbenchSkuId')?.trim()
const intakeSkuItemId = request.nextUrl.searchParams.get('intakeSkuItemId')?.trim()
if (intakeSkuItemId) {
@ -118,7 +121,7 @@ export async function POST(request: NextRequest) {
try {
const render = await assistedAssemblyAiRenderService.create(parsed.data, ctx.user)
return jsonSuccess({ render }, 201, ctx.requestId)
return jsonSuccess({ render }, render.status === 'PENDING' ? 202 : 201, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)

View File

@ -1,12 +1,16 @@
import { NextRequest } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { diyAiRenderService } from '@/server/services/diy-ai-render.service'
export const maxDuration = 300
export async function GET(request: NextRequest) {
return handleRequest(request, async ctx => jsonSuccess(await diyAiRenderService.list(ctx.user,
request.nextUrl.searchParams.get('compositionKey') || undefined, request.nextUrl.searchParams.get('schemeCode') || undefined), 200, ctx.requestId), { requireAuth: true })
return handleRequest(request, async (ctx): Promise<NextResponse> => {
const requestId = request.nextUrl.searchParams.get('requestId')
if (requestId) return jsonSuccess(await diyAiRenderService.findForRequest(requestId, ctx.user), 200, ctx.requestId)
return jsonSuccess(await diyAiRenderService.list(ctx.user,
request.nextUrl.searchParams.get('compositionKey') || undefined, request.nextUrl.searchParams.get('schemeCode') || undefined), 200, ctx.requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async ctx => {
@ -14,6 +18,7 @@ export async function POST(request: NextRequest) {
if (Buffer.byteLength(text) > 35 * 1024 * 1024) return jsonError(ErrorCode.FILE_TOO_LARGE, '出图参考不能超过35MB', 413, ctx.requestId)
let input: unknown
try { input = JSON.parse(text) } catch { return jsonError(ErrorCode.VALIDATION_FAILED, '出图参数格式无效', 400, ctx.requestId) }
return jsonSuccess(await diyAiRenderService.create(input, ctx.user), 201, ctx.requestId)
const render = await diyAiRenderService.create(input, ctx.user)
return jsonSuccess(render, render.status === 'PENDING' ? 202 : 201, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -136,7 +136,7 @@ describe("POST /api/intake/non-integrated/[id]/ai-white-background/*", () => {
expect(response).toEqual(
expect.objectContaining({
success: true,
status: 200,
status: 202,
data: expect.objectContaining({
session: expect.objectContaining({ id: "session-1" }),
}),

View File

@ -22,7 +22,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
ctx.user,
);
return jsonSuccess(result, 200, ctx.requestId);
return jsonSuccess(result, 202, ctx.requestId);
} catch (error) {
if (error instanceof AppError) {
return jsonError(

View File

@ -0,0 +1,38 @@
/** @jest-environment node */
import { POST } from './route'
import { nativeImageGenerationWorker } from '@/server/services/native-image-generation.worker'
import { jsonError, jsonSuccess } from '@/server/core/response'
jest.mock('@/server/services/native-image-generation.worker', () => ({ nativeImageGenerationWorker: { runNext: jest.fn() } }))
jest.mock('@/server/core/response', () => ({
jsonError: jest.fn((code, message, status) => ({ code, message, status })),
jsonSuccess: jest.fn(data => ({ success: true, data })),
}))
const originalSecret = process.env.AI_IMAGE_WORKER_SECRET
const request = (secret?: string) => ({ headers: new Headers(secret ? { 'x-ai-image-worker-secret': secret } : {}) }) as never
beforeEach(() => { jest.clearAllMocks(); process.env.AI_IMAGE_WORKER_SECRET = 'worker-secret' })
afterAll(() => {
if (originalSecret === undefined) delete process.env.AI_IMAGE_WORKER_SECRET
else process.env.AI_IMAGE_WORKER_SECRET = originalSecret
})
test.each([undefined, 'wrong', 'same-length-x'])('rejects unauthorized access without executing work (%s)', async secret => {
await POST(request(secret))
expect(jsonError).toHaveBeenCalledWith(expect.anything(), expect.any(String), 403)
expect(nativeImageGenerationWorker.runNext).not.toHaveBeenCalled()
})
test('missing server secret never allows an anonymous worker request', async () => {
delete process.env.AI_IMAGE_WORKER_SECRET
await POST(request())
expect(nativeImageGenerationWorker.runNext).not.toHaveBeenCalled()
})
test('executes one turn only after matching the internal secret', async () => {
jest.mocked(nativeImageGenerationWorker.runNext).mockResolvedValue({ status: 'idle' })
await POST(request('worker-secret'))
expect(nativeImageGenerationWorker.runNext).toHaveBeenCalledTimes(1)
expect(jsonSuccess).toHaveBeenCalledWith({ worker: { status: 'idle' } })
})
test('worker errors do not disclose upstream or database credentials', async () => {
jest.mocked(nativeImageGenerationWorker.runNext).mockRejectedValue(new Error('https://user:password@example.test'))
await POST(request('worker-secret'))
expect(jsonError).toHaveBeenCalledWith(expect.anything(), 'AI 出图后台处理暂时失败', 500)
})

View File

@ -0,0 +1,18 @@
import { timingSafeEqual } from 'node:crypto'
import type { NextRequest } from 'next/server'
import { ErrorCode } from '@/server/core/errors'
import { jsonError, jsonSuccess } from '@/server/core/response'
import { nativeImageGenerationWorker } from '@/server/services/native-image-generation.worker'
export const dynamic = 'force-dynamic'
export const maxDuration = 120
export async function POST(request: NextRequest) {
const expected = Buffer.from(process.env.AI_IMAGE_WORKER_SECRET?.trim() ?? '')
const received = Buffer.from(request.headers.get('x-ai-image-worker-secret')?.trim() ?? '')
if (!expected.length || expected.length !== received.length || !timingSafeEqual(expected, received)) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '内部任务身份验证失败', 403)
}
try { return jsonSuccess({ worker: await nativeImageGenerationWorker.runNext() }) }
catch { return jsonError(ErrorCode.SYSTEM_INTERNAL, 'AI 出图后台处理暂时失败', 500) }
}

View File

@ -667,6 +667,7 @@ function AiRenderHistoryList({
<button
type="button"
onClick={() => onDelete(render.id)}
disabled={render.status === 'PENDING'}
className="text-[#5a6062]/25 transition hover:text-[#b75b5b]"
title="删除"
>
@ -676,7 +677,7 @@ function AiRenderHistoryList({
<p className="mt-1 line-clamp-1 text-[10px] font-medium text-[#2d3335]/70">
{render.prompt}
</p>
{render.errorMessage && render.status === 'FAILED' && (
{render.errorMessage && (
<p className="mt-0.5 line-clamp-1 text-[9px] text-[#9f4f4f]/70">
{render.errorMessage}
</p>
@ -984,6 +985,22 @@ export default function AssistedAssemblyClient({
void loadAiRenders()
}, [loadAiRenders])
const hasPendingAiRenders = aiRenders.some((render) => render.status === 'PENDING' && !render.errorMessage)
useEffect(() => {
if (!hasPendingAiRenders) return
let active = true
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
try {
const renders = await listAssistedAssemblyAiRenders(24)
if (active) setAiRenders(renders)
} catch { /* Keep the saved task visible during a temporary connection failure. */ }
if (active) timer = setTimeout(poll, 3000)
}
timer = setTimeout(poll, 3000)
return () => { active = false; clearTimeout(timer) }
}, [hasPendingAiRenders])
const handleDeleteAiRender = useCallback(async (id: string) => {
try {
await deleteAssistedAssemblyAiRender(id)
@ -1099,9 +1116,10 @@ export default function AssistedAssemblyClient({
families: materialFamilies,
variants: materialVariants,
}),
})
}, { onUncertain: setAiError })
setAiRenders((current) => [render, ...current.filter((item) => item.id !== render.id)].slice(0, 24))
setAiSuccess(render.status === 'SUCCEEDED' ? '图片已生成' : '请求已记录,但生图服务返回失败')
if (render.status === 'FAILED') setAiError(render.errorMessage || 'AI 生图失败')
else setAiSuccess(render.submissionRecovery ? '已找回上次提交的任务,本次没有新建任务,请先核对出图记录。' : render.status === 'SUCCEEDED' ? '图片已生成' : '任务已提交,生成结果会自动更新')
} catch (error) {
setAiError(error instanceof Error ? error.message : 'AI 生图失败')
} finally {

View File

@ -22,8 +22,8 @@ export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[
<span>{new Date(record.createdAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}</span>
</div>
<p className="text-[10px] text-[#5a6062]/60">{record.snapshot.items.length} 柜体 · {Object.keys(record.snapshot.doors).length} 柜门{record.schemeCode ? ' · 已保存方案' : ' · 生成时方案'}</p>
{record.status==='PENDING' && <p role="status" className="text-[11px] text-zinc-500">正在生成…</p>}
{record.status==='FAILED' && <p className="text-[11px] text-red-600">{record.errorMessage || '生成失败'}</p>}
{record.status==='PENDING' && !record.errorMessage && <p role="status" className="text-[11px] text-zinc-500">正在生成…</p>}
{(record.status==='FAILED' || record.errorMessage) && <p className="text-[11px] text-red-600">{record.errorMessage || '生成失败'}</p>}
<div className="grid grid-cols-1 gap-2.5">
{record.images.map((image,index) => {
const filename = `${record.name}-${record.mode==='scene'?'效果图':'白底图'}-${index+1}.png`

View File

@ -56,7 +56,7 @@ export function useDiyAiRender(controller: DiyWorkbenchController, captureRef: D
}, [open,scope,currentComposition,controller.scheme?.code,controller.dirty,refresh,invalidateLists])
// A request continues when the user switches panels. Pending records refresh while the AI panel is open.
useEffect(() => {
if (!open || !records.some(record => record.status === 'PENDING')) return
if (!open || !records.some(record => record.status === 'PENDING' && !record.errorMessage)) return
const timer = setInterval(() => void refresh(), 5000)
return () => clearInterval(timer)
}, [open,records,refresh])
@ -79,6 +79,7 @@ export function useDiyAiRender(controller: DiyWorkbenchController, captureRef: D
}
const generate = async () => {
if (lock.current || !access.canGenerate) return
if (records.some(record => record.status === 'PENDING' && diyCompositionText(record.snapshot) === currentComposition)) { setError('当前方案已有任务正在生成或待核对,请先查看出图记录。'); return }
if (mode === 'scene' && !prompt.trim()) { setError('请填写场景提示词,或选择场景快捷词'); return }
lock.current = true; setGenerating(true); setError(''); setNotice(''); setStage('正在准备模型与木纹贴图…')
const settings = { mode, userPrompt: prompt, negativePrompt, aspectRatio, imageCount, imageSize }
@ -88,14 +89,14 @@ export function useDiyAiRender(controller: DiyWorkbenchController, captureRef: D
setStage('AI 正在生成,可切回配色与柜门继续编辑')
const record = await generateDiyAiRender({ requestId: crypto.randomUUID(), name:reference.name, schemeCode:reference.schemeCode,
catalogHash:reference.catalogHash, snapshot:reference.snapshot, cameraView:reference.cameraView, ...settings, ...builtPrompts,
referenceImageDataUrl: reference.dataUrl })
referenceImageDataUrl: reference.dataUrl }, setStage)
setRecords(previous => [record,...previous.filter(item => item.id!==record.id)].slice(0,60))
if(diyCompositionText(record.snapshot)!==diyCompositionText(live.current.controller.saveSnapshot())) {
live.current.scope='all';setScope('all')
}
await refresh()
if (record.status === 'FAILED') throw new Error(record.errorMessage || 'AI 出图失败')
setNotice(record.status === 'PENDING' ? '任务正在生成,结果会显示在出图记录中'
setNotice(record.submissionRecovery ? '已找回上次提交的任务,本次没有新建任务,请先核对出图记录。' : record.status === 'PENDING' ? '任务正在生成,结果会显示在出图记录中'
: `已生成 ${record.images.length} 张${record.mode === 'scene' ? '效果图' : '白底图'},已保存到出图记录`)
} catch (err) { setError(message(err)); await refresh() }
finally { lock.current = false; setGenerating(false); setStage('') }

View File

@ -1,3 +1,4 @@
import { submitRecoverableImageRequest } from '@/lib/recoverable-image-submission'
import type { DiyAiRenderDTO, DiyAiRenderInput, DiyAiRenderList, DiyAiRenderMode } from '@/lib/diy/ai-render'
async function request<T>(path: string, init?: RequestInit): Promise<T> {
@ -12,9 +13,10 @@ export function listDiyAiRenders(compositionKey?: string, schemeCode?: string) {
if (schemeCode) query.set('schemeCode', schemeCode)
return request<DiyAiRenderList>(`/api/diy/ai-renders?${query}`)
}
export const generateDiyAiRender = (input: DiyAiRenderInput) => request<DiyAiRenderDTO>('/api/diy/ai-renders', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
})
export const generateDiyAiRender = (input: DiyAiRenderInput, onUncertain?: (message: string) => void) =>
submitRecoverableImageRequest<DiyAiRenderDTO, DiyAiRenderDTO | null>({
url: '/api/diy/ai-renders', scope: 'diy', input, readResult: (data) => data ?? null, onUncertain,
})
export const optimizeDiyAiPrompt = (prompt: string, mode: DiyAiRenderMode, referenceImageDataUrl: string) =>
request<{ fullText: string; prompt: string }>('/api/diy/ai-renders/optimize-prompt', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, mode, referenceImageDataUrl }),

View File

@ -1802,32 +1802,40 @@ describe("NonIntegratedDetailClient media workspaces", () => {
});
});
it("generates uploaded product-photo source views sequentially in bulk mode", async () => {
it("submits uploaded product-photo source views together as durable background jobs", async () => {
const actor = userEvent.setup();
(
globalThis as NonIntegratedRemoteAssetStorageTestGlobal
).__NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__ = true;
const product = createProductRecord({
id: "DNZ0001-ai-white-background-generate-bulk",
skuItems: [createSkuItem({ barcode: "BAR-001" })],
skuItems: [createSkuItem({
barcode: "BAR-001",
aiWhiteBackgroundPartGroups: [{
groupId: "body", label: "主体", nodeNames: ["body"],
sourceType: "text_fallback", textFallback: "保持当前产品材质和颜色",
}],
})],
uploadFiles: [
{
id: "source-positive-1",
name: "front-source.png",
name: "正视图源图.png",
category: "产品实拍图",
dataUrl: "data:image/png;base64,source-positive-1",
mimeType: "image/png",
photoGroup: "positive",
productPhotoStyleKey: "K01",
productPhotoBarcode: "BAR-001",
},
{
id: "source-back-1",
name: "back-source.png",
name: "后视图源图.png",
category: "产品实拍图",
dataUrl: "data:image/png;base64,source-back-1",
mimeType: "image/png",
photoGroup: "back",
productPhotoStyleKey: "K01",
productPhotoBarcode: "BAR-001",
},
],
});
@ -1891,23 +1899,23 @@ describe("NonIntegratedDetailClient media workspaces", () => {
const controlCard = await screen.findByTestId(
"ai-white-background-control-card",
);
await actor.click(within(controlCard).getAllByRole("button")[0]!);
const generateButton = within(controlCard).getByRole("button", { name: "补白底图" });
await waitFor(() => expect(generateButton).toBeEnabled());
await actor.click(generateButton);
await waitFor(() => {
const requestedViewCalls =
mockedGenerateSharedNonIntegratedAiWhiteBackgroundSession.mock.calls
.map((call) => call[1]?.requestedViewKeys ?? [])
.filter((requestedViewKeys) => requestedViewKeys.length === 1)
.map((requestedViewKeys) => requestedViewKeys[0]);
expect(requestedViewCalls.length).toBeGreaterThanOrEqual(2);
expect(requestedViewCalls).toEqual(
.map((call) => call[1]?.requestedViewKeys ?? []);
expect(requestedViewCalls).toHaveLength(1);
expect(requestedViewCalls[0]).toEqual(
expect.arrayContaining(["front", "back"]),
);
expect(
mockedGenerateSharedNonIntegratedAiWhiteBackgroundSession.mock.calls.some(
(call) => (call[1]?.requestedViewKeys?.length ?? 0) > 1,
),
).toBe(false);
).toBe(true);
});
});
});

View File

@ -110,6 +110,14 @@ describe('nonIntegratedApi uploads', () => {
)
})
it.each([400, 403, 500, 504])('marks only explicit 4xx white-background rejections as correctable (%s)', async (status) => {
setFetch(jest.fn(async () => jsonResponse({ success: false, error: { message: 'submission failed' } }, status)) as typeof fetch)
const error = await generateSharedNonIntegratedAiWhiteBackgroundSession('product-1', { requestId: 'request-1', styleKey: 'K01' }).catch((value: unknown) => value)
expect(error).toBeInstanceOf(Error)
if (status < 500) expect(error).toMatchObject({ whiteBackgroundSubmissionRejected: true })
else expect(error).not.toHaveProperty('whiteBackgroundSubmissionRejected')
})
it('reconciles one virtual inventory row with an explicit confirmation', async () => {
const fetchMock = jest.fn(async () =>
jsonResponse({

View File

@ -112,6 +112,7 @@ export type OaBoardProjectLookupItem = {
};
export type GenerateNonIntegratedAiWhiteBackgroundSessionPayload = {
requestId?: string;
sessionId?: string;
styleKey: string;
targetBarcode?: string;
@ -589,12 +590,18 @@ export async function generateSharedNonIntegratedAiWhiteBackgroundSession(
},
body: JSON.stringify(payload),
},
NON_INTEGRATED_AI_WHITE_BACKGROUND_TIMEOUT_MS,
90_000,
);
return readApiData<GenerateNonIntegratedAiWhiteBackgroundSessionResponse>(
response,
);
try {
return await readApiData<GenerateNonIntegratedAiWhiteBackgroundSessionResponse>(response);
} catch (error) {
// A structured 4xx is an explicit rejection; a lost response or 5xx may follow a commit.
if (error instanceof Error && response.status >= 400 && response.status < 500) {
Object.assign(error, { whiteBackgroundSubmissionRejected: true });
}
throw error;
}
}
export async function fetchSharedNonIntegratedAiWhiteBackgroundCapabilities(

View File

@ -19,6 +19,7 @@ import type {
import {
fetchSharedNonIntegratedAiWhiteBackgroundCapabilities,
generateSharedNonIntegratedAiWhiteBackgroundSession,
getSharedNonIntegratedProductById,
uploadSharedNonIntegratedAiWhiteBackgroundSession,
} from "../../nonIntegratedApi";
import { NonIntegratedAiWhiteBackgroundWorkspace } from "./NonIntegratedAiWhiteBackgroundWorkspace";
@ -28,6 +29,7 @@ let latestPanelProps: Record<string, unknown> | null = null;
jest.mock("../../nonIntegratedApi", () => ({
fetchSharedNonIntegratedAiWhiteBackgroundCapabilities: jest.fn(),
generateSharedNonIntegratedAiWhiteBackgroundSession: jest.fn(),
getSharedNonIntegratedProductById: jest.fn(),
uploadSharedNonIntegratedAiWhiteBackgroundSession: jest.fn(),
cleanupSharedNonIntegratedAiWhiteBackgroundManifest: jest.fn(),
}));
@ -74,6 +76,7 @@ const mockedFetchCapabilities = jest.mocked(
const mockedGenerateSession = jest.mocked(
generateSharedNonIntegratedAiWhiteBackgroundSession,
);
const mockedGetProduct = jest.mocked(getSharedNonIntegratedProductById);
const mockedUploadSession = jest.mocked(
uploadSharedNonIntegratedAiWhiteBackgroundSession,
);
@ -208,9 +211,11 @@ describe("NonIntegratedAiWhiteBackgroundWorkspace", () => {
beforeEach(() => {
latestPanelProps = null;
window.localStorage.clear();
window.sessionStorage.clear();
mockedFetchCapabilities.mockReset();
mockedGenerateSession.mockReset();
mockedUploadSession.mockReset();
mockedGetProduct.mockReset();
mockedFetchCapabilities.mockResolvedValue({
canUploadToMaterialLibrary: true,
uploadDisabledReason: null,
@ -344,7 +349,7 @@ describe("NonIntegratedAiWhiteBackgroundWorkspace", () => {
fireEvent.click(screen.getByRole("button", { name: "generate-bulk" }));
await waitFor(() => {
expect(mockedGenerateSession).toHaveBeenCalledTimes(2);
expect(mockedGenerateSession).toHaveBeenCalledTimes(1);
});
mockedGenerateSession.mock.calls.forEach((call) => {
@ -358,7 +363,7 @@ describe("NonIntegratedAiWhiteBackgroundWorkspace", () => {
}),
);
});
expect(onProductSynced).toHaveBeenCalledTimes(2);
expect(onProductSynced).toHaveBeenCalledTimes(1);
});
it("syncs the latest product and shows a global success notice after upload", async () => {
@ -750,462 +755,160 @@ describe("NonIntegratedAiWhiteBackgroundWorkspace", () => {
);
});
it("runs bulk generation one view at a time to avoid duplicate AI calls from save conflicts", async () => {
const pendingRequests: Array<{
viewKey: AiWhiteBackgroundViewKey;
resolve: (value: ReturnType<typeof createGenerateResponse>) => void;
}> = [];
mockedGenerateSession.mockImplementation((_productId, payload) => {
const viewKey = payload
.requestedViewKeys?.[0] as AiWhiteBackgroundViewKey;
return new Promise((resolve) => {
pendingRequests.push({ viewKey, resolve });
});
function renderNativeWorkspace(metadata?: NonIntegratedProductRecord["metadata"], onProductSynced = jest.fn()) {
const source = createBarcodeEntry({
barcode: "BAR-001", secondaryLabel: "胡桃木色", hasOriginalImages: true,
aiSourceSlotFiles: createAiSourceSlotFiles({ positive: createUploadFile("positive"), back: createUploadFile("back"), left: createUploadFile("left") }),
availableAiSourceSlotCount: 3,
});
return render(<NonIntegratedAiWhiteBackgroundWorkspace
productId="product-1" isReadOnly={false} canUseAiWhiteBackground
currentProductPhotoStyleKey="K01" currentProductPhotoStyleLabel="款式一"
currentStyleBarcodeEntries={[source]} currentTargetBarcodeEntry={source}
selectedTargetBarcode="BAR-001" displayedSkuItems={[createSkuItem()]} skuItems={[createSkuItem()]}
styleDetails={{}} materialLibraryFinishes={[]} uploadFiles={[]}
productMetadata={metadata} onProductSynced={onProductSynced} onNotify={() => {}}
/>);
}
render(
<NonIntegratedAiWhiteBackgroundWorkspace
productId="product-1"
isReadOnly={false}
canUseAiWhiteBackground
currentProductPhotoStyleKey="K01"
currentProductPhotoStyleLabel="娆惧紡涓€"
currentStyleBarcodeEntries={[
createBarcodeEntry({
skuItemId: "sku-source-1",
barcode: "BAR-001",
label: "BAR-001 / 鎵规1",
secondaryLabel: "鑳℃鏈ㄨ壊",
hasOriginalImages: true,
aiSourceSlotFiles: createAiSourceSlotFiles({
positive: createUploadFile("positive", {
name: "front-source.png",
}),
back: createUploadFile("back", { name: "back-source.png" }),
left: createUploadFile("left", { name: "left-source.png" }),
right: createUploadFile("right", { name: "right-source.png" }),
}),
availableAiSourceSlotCount: 4,
}),
createBarcodeEntry({
skuItemId: "sku-target-1",
barcode: "BAR-002",
label: "BAR-002 / 鎵规1",
secondaryLabel: "娴呮鏈ㄨ壊",
hasOriginalImages: false,
aiSourceSlotFiles: createAiSourceSlotFiles({}),
availableAiSourceSlotCount: 0,
}),
]}
currentTargetBarcodeEntry={createBarcodeEntry({
skuItemId: "sku-target-1",
barcode: "BAR-002",
label: "BAR-002 / 鎵规1",
secondaryLabel: "娴呮鏈ㄨ壊",
hasOriginalImages: false,
aiSourceSlotFiles: createAiSourceSlotFiles({}),
availableAiSourceSlotCount: 0,
})}
selectedTargetBarcode="BAR-002"
displayedSkuItems={[createSkuItem({ barcode: "BAR-001" })]}
skuItems={[createSkuItem({ barcode: "BAR-001" })]}
materialLibraryFinishes={[]}
uploadFiles={[]}
productMetadata={undefined}
onProductSynced={() => {}}
onNotify={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "select-source" }));
await waitFor(() => {
expect(latestPanelProps?.generateDisabledReason).toBeNull();
});
function pendingMetadata(): NonIntegratedProductRecord["metadata"] {
return { aiWhiteBackground: { sessions: [{
id: "durable-session", styleKey: "K01", targetBarcode: "BAR-001", sourceBarcode: "BAR-001", skuBatchNo: 1,
status: "generating", sourceFileIdsBySlot: {}, generatedFileIdsByView: {},
generationTasksByView: { front: { taskId: "durable-task", requestId: "request-1", status: "pending", updatedAt: "2026-10-15T00:00:00Z" } },
createdAt: "2026-10-15T00:00:00Z", updatedAt: "2026-10-15T00:00:00Z",
}], uploadManifests: [], derivedSkuLinks: [] } };
}
it("submits the selected views once as a durable batch rather than scheduling generation in the browser", async () => {
renderNativeWorkspace();
await waitFor(() => expect(latestPanelProps?.generateDisabledReason).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "generate-bulk" }));
await waitFor(() => expect(mockedGenerateSession).toHaveBeenCalledTimes(1));
expect(mockedGenerateSession.mock.calls[0][1]).toMatchObject({
requestId: expect.any(String), requestedViewKeys: ["front", "back", "left", "right", "leftFront45", "rightFront45", "leftBack45", "rightBack45"],
});
});
await waitFor(() => {
it("restores pending views after refresh by polling the saved product without another generation request", async () => {
const metadata = pendingMetadata();
const sync = jest.fn();
mockedGetProduct.mockResolvedValue({ id: "product-1", metadata } as NonIntegratedProductRecord);
const view = renderNativeWorkspace(metadata, sync);
await waitFor(() => expect(mockedGetProduct).toHaveBeenCalledWith("product-1"));
expect(latestPanelProps?.generatingViewKeys).toEqual(["front"]);
fireEvent.click(screen.getByRole("button", { name: "generate-front" }));
await act(async () => {});
expect(mockedGenerateSession).not.toHaveBeenCalled();
expect(sync).toHaveBeenCalled();
view.unmount();
});
it("does not automatically replay a legacy browser queue during migration", async () => {
window.localStorage.setItem("nonIntegratedAiWhiteBackgroundBulkTasks:v1", JSON.stringify({
task: { taskKey: "task", productId: "product-1", sessionId: "legacy", styleKey: "K01", targetBarcode: "BAR-001", sourceBarcode: "BAR-001", requestedViewKeys: ["front"], completedViewKeys: [], status: "running", updatedAt: Date.now() },
}));
renderNativeWorkspace();
await act(async () => {});
expect(mockedGenerateSession).not.toHaveBeenCalled();
});
it("refreshes persisted state after a lost submit response and does not automatically retry the paid request", async () => {
mockedGenerateSession.mockRejectedValue(new Error("network disconnected"));
mockedGetProduct.mockResolvedValue({ id: "product-1", metadata: pendingMetadata() } as NonIntegratedProductRecord);
const sync = jest.fn();
renderNativeWorkspace(undefined, sync);
await waitFor(() => expect(latestPanelProps?.generateDisabledReason).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "generate-front" }));
await waitFor(() => expect(mockedGetProduct).toHaveBeenCalledWith("product-1"));
expect(mockedGenerateSession).toHaveBeenCalledTimes(1);
});
expect(pendingRequests.map((request) => request.viewKey)).toEqual([
"front",
]);
await act(async () => {
pendingRequests[0]!.resolve(createGenerateResponse("front"));
expect(sync).toHaveBeenCalled();
});
await waitFor(() => {
it("reuses the original request and full snapshot when submission and reconciliation responses are lost", async () => {
mockedGenerateSession.mockRejectedValueOnce(new Error("response lost after commit"))
.mockResolvedValueOnce(createGenerateResponse("front"));
mockedGetProduct.mockRejectedValue(new Error("status read offline"));
renderNativeWorkspace();
await waitFor(() => expect(latestPanelProps?.generateDisabledReason).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "generate-front" }));
await waitFor(() => expect(latestPanelProps?.statusText).toContain("正在核对上次提交"));
const originalPayload = mockedGenerateSession.mock.calls[0][1];
// The original task may already be complete. A manual retry must address that same task.
fireEvent.click(screen.getByRole("button", { name: "generate-bulk" }));
await waitFor(() => expect(mockedGenerateSession).toHaveBeenCalledTimes(2));
expect(mockedGenerateSession.mock.calls[1][1]).toEqual(originalPayload);
await waitFor(() => expect(window.sessionStorage.getItem("native-white-background-submission:product-1")).toBeNull());
});
it("recovers a completed original request by reading without another POST", async () => {
mockedGenerateSession.mockRejectedValue(new Error("response lost after commit"));
mockedGetProduct.mockRejectedValue(new Error("status read offline"));
const sync = jest.fn();
renderNativeWorkspace(undefined, sync);
await waitFor(() => expect(latestPanelProps?.generateDisabledReason).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "generate-front" }));
await waitFor(() => expect(latestPanelProps?.statusText).toContain("正在核对上次提交"));
const original = mockedGenerateSession.mock.calls[0][1];
const metadata = pendingMetadata()!;
const session = metadata.aiWhiteBackground!.sessions[0];
session.id = original.sessionId!;
session.generationTasksByView!.front = { taskId: "finished-task", requestId: original.requestId!, status: "succeeded", updatedAt: "2026-10-15T00:00:01Z" };
mockedGetProduct.mockResolvedValue({ id: "product-1", metadata } as NonIntegratedProductRecord);
fireEvent.click(screen.getByRole("button", { name: "generate-front" }));
await waitFor(() => expect(window.sessionStorage.getItem("native-white-background-submission:product-1")).toBeNull());
expect(mockedGenerateSession).toHaveBeenCalledTimes(1);
expect(sync).toHaveBeenCalledWith(expect.objectContaining({ metadata }));
});
it("retains only an intent pointer across refresh and refuses a new key while its outcome is unknown", async () => {
mockedGenerateSession.mockRejectedValue(new Error("response lost after commit"));
mockedGetProduct.mockRejectedValue(new Error("status read offline"));
const view = renderNativeWorkspace();
await waitFor(() => expect(latestPanelProps?.generateDisabledReason).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "generate-front" }));
await waitFor(() => expect(latestPanelProps?.statusText).toContain("正在核对上次提交"));
const saved = JSON.parse(window.sessionStorage.getItem("native-white-background-submission:product-1")!);
expect(saved).toEqual(expect.objectContaining({ requestId: expect.any(String), sessionId: expect.any(String), fingerprint: expect.any(String) }));
expect(saved.partGroups).toBeUndefined();
expect(saved.referenceImageDataUrl).toBeUndefined();
view.unmount();
renderNativeWorkspace();
await waitFor(() => expect(latestPanelProps?.statusText).toContain("正在核对上次提交"));
fireEvent.click(screen.getByRole("button", { name: "generate-front" }));
await act(async () => {});
expect(mockedGenerateSession).toHaveBeenCalledTimes(1);
expect(JSON.parse(window.sessionStorage.getItem("native-white-background-submission:product-1")!).requestId).toBe(saved.requestId);
});
it("allows correcting a rejected request once a read confirms no view was accepted, while retaining its key", async () => {
mockedGenerateSession.mockRejectedValueOnce(Object.assign(new Error("参数不正确"), { whiteBackgroundSubmissionRejected: true }))
.mockResolvedValueOnce(createGenerateResponse("front"));
mockedGetProduct.mockResolvedValue({ id: "product-1", metadata: {} } as NonIntegratedProductRecord);
renderNativeWorkspace();
await waitFor(() => expect(latestPanelProps?.generateDisabledReason).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "generate-front" }));
await waitFor(() => expect(latestPanelProps?.statusText).toContain("上次提交被拒绝"));
const original = mockedGenerateSession.mock.calls[0][1];
act(() => (latestPanelProps?.onInlineTargetColorChange as (value: string) => void)("修正后的颜色"));
fireEvent.click(screen.getByRole("button", { name: "generate-front" }));
await waitFor(() => expect(mockedGenerateSession).toHaveBeenCalledTimes(2));
expect(mockedGenerateSession.mock.calls[1][1]).toMatchObject({ requestId: original.requestId, sessionId: original.sessionId, targetColor: "修正后的颜色" });
});
it("cannot add another billable view when retrying an uncommitted unknown request with changed view controls", async () => {
mockedGenerateSession.mockRejectedValueOnce(new Error("response lost while original request is preparing references"))
.mockResolvedValueOnce(createGenerateResponse("front"));
mockedGetProduct.mockResolvedValue({ id: "product-1", metadata: {} } as NonIntegratedProductRecord);
renderNativeWorkspace();
await waitFor(() => expect(latestPanelProps?.generateDisabledReason).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "generate-front" }));
await waitFor(() => expect(latestPanelProps?.statusText).toContain("正在核对上次提交"));
const original = mockedGenerateSession.mock.calls[0][1];
await act(async () => (latestPanelProps?.onGenerate as (keys: AiWhiteBackgroundViewKey[]) => Promise<void>)(["left"]));
expect(mockedGenerateSession).toHaveBeenCalledTimes(2);
});
expect(pendingRequests.map((request) => request.viewKey)).toEqual([
"front",
"back",
]);
await act(async () => {
pendingRequests.slice(1).forEach((request) => {
request.resolve(createGenerateResponse(request.viewKey));
});
});
});
it("regenerates the full batch when bulk generation resumes an existing session", async () => {
mockedGenerateSession.mockImplementation((_productId, payload) =>
Promise.resolve(
createGenerateResponse(
payload.requestedViewKeys?.[0] as AiWhiteBackgroundViewKey,
),
),
);
const generatedFrontFile = createUploadFile("positive", {
id: "generated-front",
name: "front-white.png",
category: "AI 白底图",
photoGroup: "ai-white-background",
productPhotoStyleKey: "K01",
productPhotoBarcode: "BAR-002",
aiWhiteBackgroundSessionId: "session-existing",
aiWhiteBackgroundViewKey: "front",
});
const generatedBackFile = createUploadFile("back", {
id: "generated-back",
name: "back-white.png",
category: "AI 白底图",
photoGroup: "ai-white-background",
productPhotoStyleKey: "K01",
productPhotoBarcode: "BAR-002",
aiWhiteBackgroundSessionId: "session-existing",
aiWhiteBackgroundViewKey: "back",
});
render(
<NonIntegratedAiWhiteBackgroundWorkspace
productId="product-1"
isReadOnly={false}
canUseAiWhiteBackground
currentProductPhotoStyleKey="K01"
currentProductPhotoStyleLabel="款式一"
currentStyleBarcodeEntries={[
createBarcodeEntry({
skuItemId: "sku-source-1",
barcode: "BAR-001",
label: "BAR-001 / 源条码",
secondaryLabel: "原木色",
hasOriginalImages: true,
aiSourceSlotFiles: createAiSourceSlotFiles({
positive: createUploadFile("positive", {
name: "front-source.png",
}),
back: createUploadFile("back", { name: "back-source.png" }),
left: createUploadFile("left", { name: "left-source.png" }),
}),
availableAiSourceSlotCount: 3,
}),
createBarcodeEntry({
skuItemId: "sku-target-1",
barcode: "BAR-002",
label: "BAR-002 / 目标条码",
secondaryLabel: "白色",
hasOriginalImages: false,
aiSourceSlotFiles: createAiSourceSlotFiles({}),
availableAiSourceSlotCount: 0,
}),
]}
currentTargetBarcodeEntry={createBarcodeEntry({
skuItemId: "sku-target-1",
barcode: "BAR-002",
label: "BAR-002 / 目标条码",
secondaryLabel: "白色",
hasOriginalImages: false,
aiSourceSlotFiles: createAiSourceSlotFiles({}),
availableAiSourceSlotCount: 0,
})}
selectedTargetBarcode="BAR-002"
displayedSkuItems={[
createSkuItem({ barcode: "BAR-001" }),
createSkuItem({ barcode: "BAR-002", color: "白色" }),
]}
skuItems={[
createSkuItem({ barcode: "BAR-001" }),
createSkuItem({ barcode: "BAR-002", color: "白色" }),
]}
materialLibraryFinishes={[]}
uploadFiles={[generatedFrontFile, generatedBackFile]}
productMetadata={{
aiWhiteBackground: {
sessions: [
{
id: "session-existing",
styleKey: "K01",
targetBarcode: "BAR-002",
sourceBarcode: "BAR-001",
barcode: "BAR-002",
skuBatchNo: 1,
targetColor: "白色",
status: "generated",
sourceFileIdsBySlot: {
positive: "file-positive",
back: "file-back",
},
generatedFileIdsByView: {
front: "generated-front",
back: "generated-back",
},
createdAt: "2026-06-18T02:00:00.000Z",
updatedAt: "2026-06-18T02:01:00.000Z",
},
],
uploadManifests: [],
derivedSkuLinks: [],
},
}}
onProductSynced={() => {}}
onNotify={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "select-source" }));
await waitFor(() => {
expect(latestPanelProps?.generateDisabledReason).toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "generate-bulk" }));
await waitFor(() => {
expect(mockedGenerateSession).toHaveBeenCalledTimes(1);
});
expect(mockedGenerateSession.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
requestedViewKeys: ["front"],
}),
);
await waitFor(() => {
expect(mockedGenerateSession).toHaveBeenCalledTimes(8);
});
expect(
mockedGenerateSession.mock.calls.map(
(call) => call[1].requestedViewKeys?.[0],
),
).toEqual([
"front",
"back",
"left",
"right",
"leftFront45",
"rightFront45",
"leftBack45",
"rightBack45",
]);
});
it("resumes an interrupted persisted bulk task without regenerating completed views", async () => {
mockedGenerateSession.mockImplementation((_productId, payload) =>
Promise.resolve(
createGenerateResponse(
payload.requestedViewKeys?.[0] as AiWhiteBackgroundViewKey,
),
),
);
window.localStorage.setItem(
"nonIntegratedAiWhiteBackgroundBulkTasks:v1",
JSON.stringify({
"product-1::K01::BAR-002::BAR-001": {
taskKey: "product-1::K01::BAR-002::BAR-001",
productId: "product-1",
styleKey: "K01",
targetBarcode: "BAR-002",
sourceBarcode: "BAR-001",
sessionId: "session-resume",
requestedViewKeys: ["front", "back", "left"],
completedViewKeys: ["front"],
status: "running",
updatedAt: Date.now(),
},
}),
);
render(
<NonIntegratedAiWhiteBackgroundWorkspace
productId="product-1"
isReadOnly={false}
canUseAiWhiteBackground
currentProductPhotoStyleKey="K01"
currentProductPhotoStyleLabel="Style One"
currentStyleBarcodeEntries={[
createBarcodeEntry({
skuItemId: "sku-source-1",
barcode: "BAR-001",
label: "BAR-001 / Source",
secondaryLabel: "Wood",
hasOriginalImages: true,
aiSourceSlotFiles: createAiSourceSlotFiles({
positive: createUploadFile("positive", {
name: "front-source.png",
}),
back: createUploadFile("back", { name: "back-source.png" }),
left: createUploadFile("left", { name: "left-source.png" }),
}),
availableAiSourceSlotCount: 3,
}),
createBarcodeEntry({
skuItemId: "sku-target-1",
barcode: "BAR-002",
label: "BAR-002 / Target",
secondaryLabel: "White",
hasOriginalImages: false,
aiSourceSlotFiles: createAiSourceSlotFiles({}),
availableAiSourceSlotCount: 0,
}),
]}
currentTargetBarcodeEntry={createBarcodeEntry({
skuItemId: "sku-target-1",
barcode: "BAR-002",
label: "BAR-002 / Target",
secondaryLabel: "White",
hasOriginalImages: false,
aiSourceSlotFiles: createAiSourceSlotFiles({}),
availableAiSourceSlotCount: 0,
})}
selectedTargetBarcode="BAR-002"
displayedSkuItems={[
createSkuItem({ barcode: "BAR-001" }),
createSkuItem({ barcode: "BAR-002", color: "White" }),
]}
skuItems={[
createSkuItem({ barcode: "BAR-001" }),
createSkuItem({ barcode: "BAR-002", color: "White" }),
]}
materialLibraryFinishes={[]}
uploadFiles={[]}
productMetadata={undefined}
onProductSynced={() => {}}
onNotify={() => {}}
/>,
);
await waitFor(() => {
expect(latestPanelProps?.selectedSourceBarcode).toBe("BAR-001");
expect(mockedGenerateSession).toHaveBeenCalledTimes(1);
});
expect(mockedGenerateSession.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
sessionId: "session-resume",
requestedViewKeys: ["back"],
}),
);
await waitFor(() => {
expect(mockedGenerateSession).toHaveBeenCalledTimes(2);
});
expect(
mockedGenerateSession.mock.calls.map(
(call) => call[1].requestedViewKeys?.[0],
),
).toEqual(["back", "left"]);
expect(
window.localStorage.getItem("nonIntegratedAiWhiteBackgroundBulkTasks:v1"),
).toBe("{}");
});
it("stops bulk generation after the first failed view without retrying", async () => {
const pendingRequests: Array<{
viewKey: AiWhiteBackgroundViewKey;
resolve: (value: ReturnType<typeof createGenerateResponse>) => void;
reject: (error: Error) => void;
}> = [];
const notify = jest.fn();
mockedGenerateSession.mockImplementation((_productId, payload) => {
const viewKey = payload
.requestedViewKeys?.[0] as AiWhiteBackgroundViewKey;
return new Promise((resolve, reject) => {
pendingRequests.push({ viewKey, resolve, reject });
});
});
render(
<NonIntegratedAiWhiteBackgroundWorkspace
productId="product-1"
isReadOnly={false}
canUseAiWhiteBackground
currentProductPhotoStyleKey="K01"
currentProductPhotoStyleLabel="款式一"
currentStyleBarcodeEntries={[
createBarcodeEntry({
skuItemId: "sku-source-1",
barcode: "BAR-001",
label: "BAR-001 / 源条码",
secondaryLabel: "原木色",
hasOriginalImages: true,
aiSourceSlotFiles: createAiSourceSlotFiles({
positive: createUploadFile("positive", {
name: "front-source.png",
}),
back: createUploadFile("back", { name: "back-source.png" }),
left: createUploadFile("left", { name: "left-source.png" }),
}),
availableAiSourceSlotCount: 3,
}),
createBarcodeEntry({
skuItemId: "sku-target-1",
barcode: "BAR-002",
label: "BAR-002 / 目标条码",
secondaryLabel: "白色",
hasOriginalImages: false,
aiSourceSlotFiles: createAiSourceSlotFiles({}),
availableAiSourceSlotCount: 0,
}),
]}
currentTargetBarcodeEntry={createBarcodeEntry({
skuItemId: "sku-target-1",
barcode: "BAR-002",
label: "BAR-002 / 目标条码",
secondaryLabel: "白色",
hasOriginalImages: false,
aiSourceSlotFiles: createAiSourceSlotFiles({}),
availableAiSourceSlotCount: 0,
})}
selectedTargetBarcode="BAR-002"
displayedSkuItems={[
createSkuItem({ barcode: "BAR-001" }),
createSkuItem({ barcode: "BAR-002", color: "白色" }),
]}
skuItems={[
createSkuItem({ barcode: "BAR-001" }),
createSkuItem({ barcode: "BAR-002", color: "白色" }),
]}
materialLibraryFinishes={[]}
uploadFiles={[]}
productMetadata={undefined}
onProductSynced={() => {}}
onNotify={notify}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "select-source" }));
await waitFor(() => {
expect(latestPanelProps?.generateDisabledReason).toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "generate-bulk" }));
await waitFor(() => {
expect(mockedGenerateSession).toHaveBeenCalledTimes(1);
});
await act(async () => {
pendingRequests[0]!.reject(new Error("temporary save conflict"));
});
await waitFor(() => {
expect(notify).toHaveBeenCalledWith(
"temporary save conflict",
"error",
);
});
expect(mockedGenerateSession).toHaveBeenCalledTimes(1);
expect(pendingRequests).toHaveLength(1);
expect(mockedGenerateSession.mock.calls[1]).toEqual(["product-1", expect.objectContaining({
requestId: original.requestId, sessionId: original.sessionId, requestedViewKeys: ["front"],
})]);
});
});

View File

@ -22,14 +22,15 @@ import {
cleanupSharedNonIntegratedAiWhiteBackgroundManifest,
fetchSharedNonIntegratedAiWhiteBackgroundCapabilities,
generateSharedNonIntegratedAiWhiteBackgroundSession,
getSharedNonIntegratedProductById,
uploadSharedNonIntegratedAiWhiteBackgroundSession,
type GenerateNonIntegratedAiWhiteBackgroundSessionPayload,
} from "../../nonIntegratedApi";
import {
createAiWhiteBackgroundMaterialRecipeSignature,
isAiWhiteBackgroundPartGroupComplete,
type AiWhiteBackgroundPartGroup,
type AiWhiteBackgroundPartGroupingSource,
type AiWhiteBackgroundTemporaryTextureOption,
} from "../../core/aiWhiteBackgroundMaterialRecipe";
import { buildAiWhiteBackgroundPartGroupSuggestion } from "../../core/aiWhiteBackgroundPartGroups";
import NonIntegratedAiWhiteBackgroundPanel from "./NonIntegratedAiWhiteBackgroundPanel";
@ -65,31 +66,6 @@ export type NonIntegratedAiWhiteBackgroundWorkspaceProps = {
onNotify: (message: string, type: "success" | "error") => void;
};
type AiWhiteBackgroundBulkGenerateProgress = {
total: number;
completed: number;
currentViewKey: AiWhiteBackgroundViewKey | null;
};
type AiWhiteBackgroundPersistedBulkTask = {
taskKey: string;
productId: string;
styleKey: string;
targetBarcode: string;
sourceBarcode: string;
sessionId: string;
requestedViewKeys: AiWhiteBackgroundViewKey[];
completedViewKeys: AiWhiteBackgroundViewKey[];
status: "running" | "failed" | "completed";
failedViewKey?: AiWhiteBackgroundViewKey;
errorMessage?: string;
updatedAt: number;
};
const AI_WHITE_BACKGROUND_BULK_TASK_STORAGE_KEY =
"nonIntegratedAiWhiteBackgroundBulkTasks:v1";
const AI_WHITE_BACKGROUND_BULK_TASK_TTL_MS = 24 * 60 * 60 * 1000;
function buildClientAiWhiteBackgroundSessionId() {
const suffix =
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
@ -98,6 +74,32 @@ function buildClientAiWhiteBackgroundSessionId() {
return `ai-wbg-session-${Date.now()}-${suffix}`;
}
type UnconfirmedWhiteBackgroundSubmission = {
productId: string;
requestId: string;
sessionId: string;
requestedViewKeys: AiWhiteBackgroundViewKey[];
fingerprint: string;
rejected?: boolean;
};
const submissionStorageKey = (productId: string) => `native-white-background-submission:${productId}`;
function readUnconfirmedSubmission(productId: string | null): UnconfirmedWhiteBackgroundSubmission | null {
if (!productId || typeof window === "undefined") return null;
try {
const value = JSON.parse(window.sessionStorage.getItem(submissionStorageKey(productId)) || "null");
return value?.productId === productId && typeof value.requestId === "string" &&
typeof value.sessionId === "string" && typeof value.fingerprint === "string" &&
Array.isArray(value.requestedViewKeys) && value.requestedViewKeys.length &&
value.requestedViewKeys.every((key: AiWhiteBackgroundViewKey) => AI_WHITE_BACKGROUND_VIEW_KEYS.includes(key)) ? value : null;
} catch { return null; }
}
function submissionFingerprint(payload: GenerateNonIntegratedAiWhiteBackgroundSessionPayload): string {
// Compact diagnostic only; the server validates the authoritative SHA-256 input signature.
let hash = 2166136261;
for (const character of JSON.stringify(payload)) hash = Math.imul(hash ^ character.charCodeAt(0), 16777619);
return (hash >>> 0).toString(16).padStart(8, "0");
}
function createEmptyGeneratedFileMap() {
return Object.fromEntries(
AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => [viewKey, null]),
@ -147,177 +149,6 @@ function normalizeBarcode(value: string | null | undefined) {
return value?.trim().toUpperCase() ?? "";
}
function isAiWhiteBackgroundViewKey(
value: unknown,
): value is AiWhiteBackgroundViewKey {
return (
typeof value === "string" &&
(AI_WHITE_BACKGROUND_VIEW_KEYS as readonly string[]).includes(value)
);
}
function buildBulkGenerateTaskKey({
productId,
styleKey,
targetBarcode,
sourceBarcode,
}: {
productId: string;
styleKey: string;
targetBarcode: string;
sourceBarcode: string;
}) {
return [
productId,
styleKey.trim().toUpperCase(),
normalizeBarcode(targetBarcode),
normalizeBarcode(sourceBarcode),
].join("::");
}
function readPersistedBulkTasks(): Record<
string,
AiWhiteBackgroundPersistedBulkTask
> {
if (typeof window === "undefined") {
return {};
}
try {
const raw = window.localStorage.getItem(
AI_WHITE_BACKGROUND_BULK_TASK_STORAGE_KEY,
);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as Record<
string,
Partial<AiWhiteBackgroundPersistedBulkTask>
>;
return Object.fromEntries(
Object.entries(parsed)
.map(([taskKey, task]) => {
if (
!task ||
task.taskKey !== taskKey ||
!task.productId ||
!task.styleKey ||
!task.targetBarcode ||
!task.sourceBarcode ||
!task.sessionId ||
!Array.isArray(task.requestedViewKeys) ||
!Array.isArray(task.completedViewKeys) ||
(task.status !== "running" &&
task.status !== "failed" &&
task.status !== "completed")
) {
return null;
}
return [
taskKey,
{
taskKey,
productId: task.productId,
styleKey: task.styleKey,
targetBarcode: task.targetBarcode,
sourceBarcode: task.sourceBarcode,
sessionId: task.sessionId,
requestedViewKeys: task.requestedViewKeys.filter(
isAiWhiteBackgroundViewKey,
),
completedViewKeys: task.completedViewKeys.filter(
isAiWhiteBackgroundViewKey,
),
status: task.status,
failedViewKey: isAiWhiteBackgroundViewKey(task.failedViewKey)
? task.failedViewKey
: undefined,
errorMessage:
typeof task.errorMessage === "string"
? task.errorMessage
: undefined,
updatedAt:
typeof task.updatedAt === "number" ? task.updatedAt : 0,
} satisfies AiWhiteBackgroundPersistedBulkTask,
];
})
.filter(Boolean) as Array<
[string, AiWhiteBackgroundPersistedBulkTask]
>,
);
} catch {
return {};
}
}
function writePersistedBulkTasks(
tasks: Record<string, AiWhiteBackgroundPersistedBulkTask>,
) {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(
AI_WHITE_BACKGROUND_BULK_TASK_STORAGE_KEY,
JSON.stringify(tasks),
);
}
function savePersistedBulkTask(task: AiWhiteBackgroundPersistedBulkTask) {
writePersistedBulkTasks({
...readPersistedBulkTasks(),
[task.taskKey]: task,
});
}
function removePersistedBulkTask(taskKey: string) {
const tasks = readPersistedBulkTasks();
if (!tasks[taskKey]) {
return;
}
delete tasks[taskKey];
writePersistedBulkTasks(tasks);
}
function isPersistedBulkTaskFresh(task: AiWhiteBackgroundPersistedBulkTask) {
return Date.now() - task.updatedAt <= AI_WHITE_BACKGROUND_BULK_TASK_TTL_MS;
}
function findResumableBulkTask({
productId,
styleKey,
targetBarcode,
sourceBarcodeOptions,
}: {
productId: string;
styleKey: string;
targetBarcode: string;
sourceBarcodeOptions: ProductPhotoBarcodeEntry[];
}) {
const normalizedStyleKey = styleKey.trim().toUpperCase();
const normalizedTargetBarcode = normalizeBarcode(targetBarcode);
const sourceBarcodeSet = new Set(
sourceBarcodeOptions.map((entry) => normalizeBarcode(entry.barcode)),
);
return (
Object.values(readPersistedBulkTasks())
.filter(
(task) =>
task.status === "running" &&
task.productId === productId &&
task.styleKey === normalizedStyleKey &&
normalizeBarcode(task.targetBarcode) === normalizedTargetBarcode &&
sourceBarcodeSet.has(normalizeBarcode(task.sourceBarcode)) &&
isPersistedBulkTaskFresh(task),
)
.sort((left, right) => right.updatedAt - left.updatedAt)[0] ?? null
);
}
function clonePartGroups(partGroups: AiWhiteBackgroundPartGroup[]) {
return partGroups.map((group) => ({
...group,
@ -399,7 +230,8 @@ function findRestorableSourceBarcode({
normalizeBarcode(session.targetBarcode) === normalizedTargetBarcode &&
availableSourceBarcodes.has(sessionSourceBarcode) &&
sessionSourceBarcode !== normalizedTargetBarcode &&
countSessionGeneratedViews(session) > 0
(countSessionGeneratedViews(session) > 0 ||
Object.values(session.generationTasksByView ?? {}).some((task) => task?.status === "pending"))
);
})
.sort((left, right) => {
@ -476,13 +308,39 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
// 用户在花色缺失时手动输入的本次生图颜色。优先级高于 SKU 兜底占位符,避免"未配置花色"被当真实色传给 AI。
const [inlineTargetColor, setInlineTargetColor] = useState("");
const [generatingViewKeys, setGeneratingViewKeys] = useState<
AiWhiteBackgroundViewKey[]
>([]);
const [isBulkGenerating, setIsBulkGenerating] = useState(false);
const [bulkGenerateProgress, setBulkGenerateProgress] =
useState<AiWhiteBackgroundBulkGenerateProgress | null>(null);
const resumeAttemptKeyRef = useRef<string | null>(null);
const [submittingViewKeys, setSubmittingViewKeys] = useState<AiWhiteBackgroundViewKey[]>([]);
const [unconfirmedSubmission, setUnconfirmedSubmission] = useState(() => readUnconfirmedSubmission(productId));
const unconfirmedSubmissionRef = useRef(unconfirmedSubmission);
const submissionSnapshotRef = useRef<GenerateNonIntegratedAiWhiteBackgroundSessionPayload | null>(null);
const submissionInFlightRef = useRef(false);
const syncRef = useRef(onProductSynced);
syncRef.current = onProductSynced;
const notifyRef = useRef(onNotify);
notifyRef.current = onNotify;
const clearUnconfirmedSubmission = useCallback((requestId: string) => {
const current = unconfirmedSubmissionRef.current;
if (!current || current.requestId !== requestId) return;
try { window.sessionStorage.removeItem(submissionStorageKey(current.productId)); } catch { /* Retaining a pointer is safe: a later read can confirm it again. */ }
unconfirmedSubmissionRef.current = null;
submissionSnapshotRef.current = null;
setUnconfirmedSubmission(null);
}, []);
const acceptRecoveredProduct = useCallback((product: NonIntegratedProductRecord) => {
syncRef.current(product);
const current = unconfirmedSubmissionRef.current;
if (!current || product.id !== current.productId) return false;
const session = readNonIntegratedAiWhiteBackgroundMetadata(product.metadata).sessions.find((item) => item.id === current.sessionId);
if (!current.requestedViewKeys.every((key) => session?.generationTasksByView?.[key]?.requestId === current.requestId)) return false;
clearUnconfirmedSubmission(current.requestId);
return true;
}, [clearUnconfirmedSubmission]);
useEffect(() => {
const saved = readUnconfirmedSubmission(productId);
unconfirmedSubmissionRef.current = saved;
submissionSnapshotRef.current = null;
setUnconfirmedSubmission(saved);
}, [productId]);
const observedPendingTasks = useRef(new Set<string>());
const [isUploading, setIsUploading] = useState(false);
const [cleaningManifestId, setCleaningManifestId] = useState<string | null>(
null,
@ -554,18 +412,6 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
const effectiveSourceBarcode = isBorrowSourceMode
? scopedSelectedSourceBarcode
: selectedTargetBarcode;
const bulkGenerateTaskKey = useMemo(() => {
if (!productId || !selectedTargetBarcode || !effectiveSourceBarcode) {
return null;
}
return buildBulkGenerateTaskKey({
productId,
styleKey,
targetBarcode: selectedTargetBarcode,
sourceBarcode: effectiveSourceBarcode,
});
}, [effectiveSourceBarcode, productId, selectedTargetBarcode, styleKey]);
const targetBatchNo = currentTargetBarcodeEntry?.batchNo || 1;
const targetSkuItem = useMemo(
() => findSkuItemByBarcode(skuItems, selectedTargetBarcode, targetBatchNo),
@ -679,45 +525,19 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
targetColor,
]);
useEffect(() => {
if (
!isBorrowSourceMode ||
!productId ||
!selectedTargetBarcode ||
scopedSelectedSourceBarcode
) {
return;
}
const resumableTask = findResumableBulkTask({
productId,
styleKey,
targetBarcode: selectedTargetBarcode,
sourceBarcodeOptions,
});
if (!resumableTask) {
return;
}
setSelectedSourceBarcode(resumableTask.sourceBarcode);
setSelectedSourceScopeKey(currentSourceScopeKey);
}, [
currentSourceScopeKey,
isBorrowSourceMode,
productId,
scopedSelectedSourceBarcode,
selectedTargetBarcode,
sourceBarcodeOptions,
styleKey,
]);
const currentSession = useMemo(() => {
if (!selectedTargetBarcode || !effectiveSourceBarcode) {
return null;
}
return getLatestAiWhiteBackgroundSession(productMetadata, {
// A running view remains visible even if its manually entered color or
// material controls have not been restored yet after a browser refresh.
const pendingSession = readNonIntegratedAiWhiteBackgroundMetadata(productMetadata).sessions
.filter((session) => session.styleKey === styleKey &&
session.targetBarcode === selectedTargetBarcode && session.sourceBarcode === effectiveSourceBarcode &&
Object.values(session.generationTasksByView ?? {}).some((task) => task?.status === "pending"))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
return pendingSession ?? getLatestAiWhiteBackgroundSession(productMetadata, {
styleKey,
targetBarcode: selectedTargetBarcode,
sourceBarcode: effectiveSourceBarcode,
@ -733,6 +553,58 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
targetColor,
]);
const pendingViewKeys = useMemo(() => AI_WHITE_BACKGROUND_VIEW_KEYS.filter(
(viewKey) => currentSession?.generationTasksByView?.[viewKey]?.status === "pending",
), [currentSession]);
const generatingViewKeys = useMemo(() => Array.from(new Set([...pendingViewKeys, ...submittingViewKeys])), [pendingViewKeys, submittingViewKeys]);
const isBulkGenerating = generatingViewKeys.length > 1;
const bulkGenerateProgress = useMemo(() => {
const tasks = Object.values(currentSession?.generationTasksByView ?? {});
return {
total: tasks.length || generatingViewKeys.length,
completed: tasks.filter((task) => task?.status === "succeeded").length,
currentViewKey: generatingViewKeys[0] ?? null,
};
}, [currentSession, generatingViewKeys]);
const hasPendingTasks = aiWhiteBackgroundMetadata.sessions.some((session) =>
Object.values(session.generationTasksByView ?? {}).some((task) => task?.status === "pending"),
);
useEffect(() => {
if (!isActive || !productId || (!hasPendingTasks && !unconfirmedSubmission)) return;
let disposed = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let reportedError = false;
const poll = async () => {
try {
const product = await getSharedNonIntegratedProductById(productId);
if (!disposed && product) {
acceptRecoveredProduct(product);
reportedError = false;
}
} catch {
if (!disposed && !reportedError) {
notifyRef.current("任务仍在后台执行,暂时无法刷新进度,稍后会自动重试。", "error");
reportedError = true;
}
} finally {
if (!disposed) timer = setTimeout(poll, 3000);
}
};
void poll();
return () => { disposed = true; if (timer) clearTimeout(timer); };
}, [acceptRecoveredProduct, hasPendingTasks, isActive, productId, unconfirmedSubmission]);
useEffect(() => {
for (const task of Object.values(currentSession?.generationTasksByView ?? {})) {
if (!task) continue;
if (task.status === "pending") observedPendingTasks.current.add(task.taskId);
else if (observedPendingTasks.current.delete(task.taskId)) {
notifyRef.current(task.status === "succeeded" ? "白底图已更新。" : task.errorMessage || "白底图生成失败。", task.status === "succeeded" ? "success" : "error");
}
}
}, [currentSession]);
const generatedFiles = useMemo(
() =>
currentSession
@ -838,10 +710,7 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
useEffect(() => {
setPendingSessionId(null);
setGeneratingViewKeys([]);
setIsBulkGenerating(false);
setBulkGenerateProgress(null);
resumeAttemptKeyRef.current = null;
setSubmittingViewKeys([]);
}, [
currentPartGroupSignature,
effectiveSourceBarcode,
@ -915,11 +784,15 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
const canUpload =
canUseAiWhiteBackground &&
generatingViewKeys.length === 0 &&
Boolean(currentSession) &&
hasAnyAiWhiteBackgroundGeneratedView(currentSession) &&
materialLibraryCapabilities.canUploadToMaterialLibrary;
const statusText = useMemo(() => {
if (unconfirmedSubmission) return unconfirmedSubmission.rejected
? "上次提交被拒绝;修正参数后可重试,将先核对原请求,不会重复创建已接收的任务。"
: "正在核对上次提交;再次点击将核对或重试原任务,不会新建生图请求。";
if (!canUseAiWhiteBackground) {
return "当前环境未启用 AI 白底图能力。";
}
@ -934,6 +807,9 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
const partSummary =
partGroups.length <= 1 ? "单材质方案" : `${partGroups.length} 个部位组`;
const taskError = Object.values(currentSession?.generationTasksByView ?? {}).find((task) => task?.errorMessage)?.errorMessage;
if (taskError) return taskError;
if (isBulkGenerating && bulkGenerateProgress?.currentViewKey) {
return `目标条码 ${selectedTargetBarcode} · 源条码 ${sourceLabel} · ${partSummary} · 正在生成 ${bulkGenerateProgress.completed + 1}/${bulkGenerateProgress.total}`;
}
@ -946,12 +822,14 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
bulkGenerateProgress,
canUseAiWhiteBackground,
currentTargetBarcodeEntry,
currentSession,
effectiveSourceBarcode,
generatedCount,
generatingViewKeys,
isBulkGenerating,
partGroups.length,
selectedTargetBarcode,
unconfirmedSubmission,
]);
function handlePartGroupSourceTypeChange(
@ -1028,312 +906,79 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({
);
}
const runGenerate = useCallback(async function runGenerate(
const runGenerate = useCallback(async (
requestedViewKeys?: AiWhiteBackgroundViewKey[],
options: {
refinementPrompt?: string | null;
resumeTask?: AiWhiteBackgroundPersistedBulkTask | null;
sessionIdOverride?: string | null;
} = {},
) {
if (
!productId ||
!selectedTargetBarcode ||
!effectiveSourceBarcode ||
generateDisabledReason ||
isBulkGenerating
) {
return;
}
const nextViewKeys =
requestedViewKeys && requestedViewKeys.length > 0
? requestedViewKeys.filter(
(viewKey) =>
sourceReadyByView[viewKey] || Boolean(generatedFiles[viewKey]),
)
: availableSourceViewKeys;
if (nextViewKeys.length === 0) {
return;
}
const sessionId =
options.sessionIdOverride ??
currentSession?.id ??
pendingSessionId ??
buildClientAiWhiteBackgroundSessionId();
if (!currentSession?.id && !pendingSessionId) {
setPendingSessionId(sessionId);
}
const runSingleView = async (
viewKey: AiWhiteBackgroundViewKey,
singleViewOptions: {
manageGeneratingState?: boolean;
refinementPrompt?: string | null;
} = {},
options: { refinementPrompt?: string | null } = {},
) => {
if (singleViewOptions.manageGeneratingState !== false) {
setGeneratingViewKeys([viewKey]);
if (!productId || isReadOnly || !canUseAiWhiteBackground || submissionInFlightRef.current) return;
const unresolved = unconfirmedSubmissionRef.current;
let canUseCorrectedSnapshot = false;
if (unresolved) {
// A manual retry first reconciles the original intent, even when it already completed.
submissionInFlightRef.current = true;
try {
const product = await getSharedNonIntegratedProductById(unresolved.productId);
if (acceptRecoveredProduct(product)) return;
const session = readNonIntegratedAiWhiteBackgroundMetadata(product.metadata).sessions.find((item) => item.id === unresolved.sessionId);
const hasAcceptedViews = Object.values(session?.generationTasksByView ?? {}).some((task) => task?.requestId === unresolved.requestId);
// No accepted view was found. A corrected payload keeps the same key so an
// in-flight old commit is still protected by the server's unique ID and input hash.
canUseCorrectedSnapshot = !hasAcceptedViews;
} catch { /* Only an explicit retry with the original in-memory snapshot may submit below. */ }
finally { submissionInFlightRef.current = false; }
if (!submissionSnapshotRef.current && !canUseCorrectedSnapshot) {
onNotify("上次提交结果仍待核对,正在自动刷新。请勿重复提交或清除浏览器记录。", "error");
return;
}
const result = await generateSharedNonIntegratedAiWhiteBackgroundSession(
productId,
{
sessionId,
styleKey,
targetBarcode: selectedTargetBarcode,
sourceBarcode: effectiveSourceBarcode,
targetColor,
partGroups: clonePartGroups(partGroups),
confirmedPartGroupingSource,
requestedViewKeys: [viewKey],
refinementPrompt: singleViewOptions.refinementPrompt,
},
);
}
if ((!unresolved || canUseCorrectedSnapshot) && (!selectedTargetBarcode || !effectiveSourceBarcode ||
generateDisabledReason || submittingViewKeys.length || pendingViewKeys.length)) return;
const nextViewKeys = unresolved?.requestedViewKeys ?? (requestedViewKeys?.length
? requestedViewKeys.filter((viewKey) => sourceReadyByView[viewKey] || Boolean(generatedFiles[viewKey]))
: availableSourceViewKeys);
if (!unresolved && !nextViewKeys.length) return;
const sessionId = unresolved?.sessionId ?? currentSession?.id ?? pendingSessionId ?? buildClientAiWhiteBackgroundSessionId();
const payload = (!canUseCorrectedSnapshot ? submissionSnapshotRef.current : null) ?? {
requestId: unresolved?.requestId ?? buildClientAiWhiteBackgroundSessionId(),
sessionId, styleKey, targetBarcode: selectedTargetBarcode,
sourceBarcode: effectiveSourceBarcode, targetColor, partGroups: clonePartGroups(partGroups),
confirmedPartGroupingSource, requestedViewKeys: nextViewKeys,
refinementPrompt: options.refinementPrompt,
};
const intent = {
productId: unresolved?.productId ?? productId, requestId: payload.requestId!, sessionId, requestedViewKeys: payload.requestedViewKeys!,
fingerprint: submissionFingerprint(payload),
};
setPendingSessionId(sessionId);
submissionInFlightRef.current = true;
setSubmittingViewKeys(intent.requestedViewKeys);
try {
// Persist only IDs and a small fingerprint; image/material snapshots stay in memory.
window.sessionStorage.setItem(submissionStorageKey(productId), JSON.stringify(intent));
unconfirmedSubmissionRef.current = intent;
submissionSnapshotRef.current = payload;
const result = await generateSharedNonIntegratedAiWhiteBackgroundSession(intent.productId, payload);
clearUnconfirmedSubmission(intent.requestId);
onProductSynced(result.product);
setPendingSessionId(result.session.id);
};
const allTaskViewKeys =
options.resumeTask?.requestedViewKeys?.filter((viewKey) =>
nextViewKeys.includes(viewKey),
) ?? nextViewKeys;
const completedViewKeySet = new Set(
(options.resumeTask?.completedViewKeys ?? []).filter((viewKey) =>
allTaskViewKeys.includes(viewKey),
),
);
const pendingViewKeys = allTaskViewKeys.filter(
(viewKey) => !completedViewKeySet.has(viewKey),
);
if (pendingViewKeys.length === 0) {
if (options.resumeTask?.taskKey) {
removePersistedBulkTask(options.resumeTask.taskKey);
}
return;
}
const isBulkRun = allTaskViewKeys.length > 1;
const shouldPersistBulkTask =
isBulkRun && bulkGenerateTaskKey && !options.refinementPrompt;
let persistedBulkTask: AiWhiteBackgroundPersistedBulkTask | null =
shouldPersistBulkTask
? {
taskKey: bulkGenerateTaskKey,
productId,
styleKey,
targetBarcode: selectedTargetBarcode,
sourceBarcode: effectiveSourceBarcode,
sessionId,
requestedViewKeys: allTaskViewKeys,
completedViewKeys: [...completedViewKeySet],
status: "running",
updatedAt: Date.now(),
}
: null;
if (persistedBulkTask) {
savePersistedBulkTask(persistedBulkTask);
}
if (!isBulkRun) {
try {
await runSingleView(pendingViewKeys[0]!, {
refinementPrompt: options.refinementPrompt,
});
onNotify("白底图已更新。", "success");
onNotify("白底图任务已提交,可离开页面,稍后返回查看结果。", "success");
} catch (error) {
onNotify(
error instanceof Error
? error.message
: "AI 白底图生成失败,请稍后重试。",
"error",
);
onNotify(error instanceof Error ? error.message : "AI 白底图任务提交失败。", "error");
if (error instanceof Error && "whiteBackgroundSubmissionRejected" in error && unconfirmedSubmissionRef.current) {
unconfirmedSubmissionRef.current = { ...unconfirmedSubmissionRef.current, rejected: true };
try { window.sessionStorage.setItem(submissionStorageKey(intent.productId), JSON.stringify(unconfirmedSubmissionRef.current)); } catch { /* The original pointer remains valid. */ }
}
// A response may be lost after commit. Refresh the persisted intent; never resubmit automatically.
try { acceptRecoveredProduct(await getSharedNonIntegratedProductById(intent.productId)); } catch { /* retain the original request ID and snapshot */ }
setUnconfirmedSubmission(unconfirmedSubmissionRef.current);
} finally {
setGeneratingViewKeys([]);
submissionInFlightRef.current = false;
setSubmittingViewKeys([]);
}
return;
}
setIsBulkGenerating(true);
setBulkGenerateProgress({
total: allTaskViewKeys.length,
completed: completedViewKeySet.size,
currentViewKey: pendingViewKeys[0] ?? null,
});
const failedViews: string[] = [];
let successCount = 0;
let completedCount = completedViewKeySet.size;
let lastErrorMessage: string | null = null;
const activeViewKeys = new Set<AiWhiteBackgroundViewKey>();
try {
for (const viewKey of pendingViewKeys) {
activeViewKeys.add(viewKey);
setGeneratingViewKeys([...activeViewKeys]);
setBulkGenerateProgress({
total: allTaskViewKeys.length,
completed: completedCount,
currentViewKey: viewKey,
});
try {
await runSingleView(viewKey, { manageGeneratingState: false });
successCount += 1;
completedViewKeySet.add(viewKey);
if (persistedBulkTask) {
persistedBulkTask = {
...persistedBulkTask,
completedViewKeys: [...completedViewKeySet],
status: "running",
updatedAt: Date.now(),
};
savePersistedBulkTask(persistedBulkTask);
}
} catch (error) {
lastErrorMessage =
error instanceof Error
? error.message
: "AI 白底图生成失败,请稍后重试。";
failedViews.push(viewKey);
if (persistedBulkTask) {
savePersistedBulkTask({
...persistedBulkTask,
completedViewKeys: [...completedViewKeySet],
status: "failed",
failedViewKey: viewKey,
errorMessage: lastErrorMessage,
updatedAt: Date.now(),
});
}
break;
} finally {
completedCount += 1;
activeViewKeys.delete(viewKey);
setGeneratingViewKeys([...activeViewKeys]);
setBulkGenerateProgress({
total: allTaskViewKeys.length,
completed: completedCount,
currentViewKey: null,
});
}
}
if (successCount > 0 && failedViews.length === 0) {
if (persistedBulkTask) {
removePersistedBulkTask(persistedBulkTask.taskKey);
}
onNotify(
"已生成 " + completedViewKeySet.size + " 个视角白底图。",
"success",
);
return;
}
if (successCount > 0) {
onNotify(
"已生成 " +
completedViewKeySet.size +
"/" +
allTaskViewKeys.length +
"," +
failedViews[0] +
" 生成失败:" +
(lastErrorMessage ?? "请稍后重试。"),
"error",
);
return;
}
onNotify(lastErrorMessage ?? "AI 白底图生成失败,请稍后重试。", "error");
} finally {
setGeneratingViewKeys([]);
setIsBulkGenerating(false);
setBulkGenerateProgress(null);
}
}, [
availableSourceViewKeys,
bulkGenerateTaskKey,
confirmedPartGroupingSource,
currentSession?.id,
effectiveSourceBarcode,
generateDisabledReason,
generatedFiles,
isBulkGenerating,
onNotify,
onProductSynced,
partGroups,
pendingSessionId,
productId,
selectedTargetBarcode,
sourceReadyByView,
styleKey,
targetColor,
]);
useEffect(() => {
if (
!isActive ||
!bulkGenerateTaskKey ||
!productId ||
!selectedTargetBarcode ||
!effectiveSourceBarcode ||
isBulkGenerating ||
generateDisabledReason
) {
return;
}
const task = readPersistedBulkTasks()[bulkGenerateTaskKey] ?? null;
if (!task) {
return;
}
if (task.status !== "running") {
return;
}
if (!isPersistedBulkTaskFresh(task)) {
removePersistedBulkTask(task.taskKey);
return;
}
const remainingViewKeys = task.requestedViewKeys.filter(
(viewKey) => !task.completedViewKeys.includes(viewKey),
);
if (remainingViewKeys.length === 0) {
removePersistedBulkTask(task.taskKey);
return;
}
const resumeAttemptKey = [
task.taskKey,
task.sessionId,
task.updatedAt,
remainingViewKeys.join(","),
].join("::");
if (resumeAttemptKeyRef.current === resumeAttemptKey) {
return;
}
resumeAttemptKeyRef.current = resumeAttemptKey;
setPendingSessionId(task.sessionId);
void runGenerate(task.requestedViewKeys, {
resumeTask: task,
sessionIdOverride: task.sessionId,
});
}, [
bulkGenerateTaskKey,
effectiveSourceBarcode,
generateDisabledReason,
isActive,
isBulkGenerating,
productId,
runGenerate,
selectedTargetBarcode,
]);
}, [availableSourceViewKeys, confirmedPartGroupingSource, currentSession?.id, effectiveSourceBarcode,
generateDisabledReason, generatedFiles, onNotify, onProductSynced, partGroups, pendingSessionId,
productId, selectedTargetBarcode, sourceReadyByView, styleKey, targetColor, submittingViewKeys.length,
pendingViewKeys.length, acceptRecoveredProduct, clearUnconfirmedSubmission, isReadOnly, canUseAiWhiteBackground]);
async function handleUpload() {
if (

View File

@ -14,6 +14,18 @@ import {
import type { NonIntegratedUploadFile } from "../../nonIntegratedStorage";
describe("nonIntegratedAiWhiteBackground", () => {
it("preserves persisted generation state when serializing product metadata", () => {
const metadata = readNonIntegratedAiWhiteBackgroundMetadata({ aiWhiteBackground: {
sessions: [{ id: "pending-session", styleKey: "K01", targetBarcode: "BAR-001", sourceBarcode: "BAR-001", skuBatchNo: 1,
status: "generating", sourceFileIdsBySlot: {}, generatedFileIdsByView: {},
generationTasksByView: { front: { taskId: "job-1", requestId: "request-1", requestSignature: "signature", status: "pending", errorMessage: "任务状态待核对", updatedAt: "2026-10-15T00:00:00Z" } },
createdAt: "2026-10-15T00:00:00Z", updatedAt: "2026-10-15T00:00:00Z" }],
uploadManifests: [], derivedSkuLinks: [],
} });
const restored = readNonIntegratedAiWhiteBackgroundMetadata(writeNonIntegratedAiWhiteBackgroundMetadata(undefined, metadata));
expect(restored.sessions[0].status).toBe("generating");
expect(restored.sessions[0].generationTasksByView?.front).toMatchObject({ taskId: "job-1", requestSignature: "signature", status: "pending", errorMessage: "任务状态待核对" });
});
it("keeps the fixed 8-view mapping stable", () => {
expect(AI_WHITE_BACKGROUND_SOURCE_SLOT_TO_VIEW_KEY).toEqual({
positive: "front",

View File

@ -40,6 +40,8 @@ export type AiWhiteBackgroundViewKey =
export type AiWhiteBackgroundSessionStatus =
| "draft"
| "generating"
| "generation_failed"
| "generated"
| "uploaded"
| "upload_failed";
@ -71,6 +73,14 @@ export type AiWhiteBackgroundSession = {
Record<AiWhiteBackgroundViewKey, string[]>
>;
refinementPromptsByView?: Partial<Record<AiWhiteBackgroundViewKey, string>>;
generationTasksByView?: Partial<Record<AiWhiteBackgroundViewKey, {
taskId: string;
requestId: string;
requestSignature?: string;
status: "pending" | "succeeded" | "failed";
errorMessage?: string;
updatedAt: string;
}>>;
lastManifestId?: string;
createdAt: string;
updatedAt: string;
@ -374,13 +384,31 @@ function normalizeRefinementPromptsByView(value: unknown) {
function normalizeSessionStatus(
value: unknown,
): AiWhiteBackgroundSessionStatus {
return value === "generated" ||
return value === "generating" || value === "generation_failed" ||
value === "generated" ||
value === "uploaded" ||
value === "upload_failed"
? value
: "draft";
}
function normalizeGenerationTasks(value: unknown): AiWhiteBackgroundSession["generationTasksByView"] {
if (!isRecord(value)) return {};
return Object.fromEntries(Object.entries(value).flatMap(([viewKey, task]) => {
if (!isAiWhiteBackgroundViewKey(viewKey) || !isRecord(task) ||
!normalizeString(task.taskId) || !normalizeString(task.requestId) ||
!["pending", "succeeded", "failed"].includes(String(task.status))) return [];
return [[viewKey, {
taskId: normalizeString(task.taskId),
requestId: normalizeString(task.requestId),
requestSignature: normalizeString(task.requestSignature) || undefined,
status: task.status,
errorMessage: normalizeString(task.errorMessage) || undefined,
updatedAt: normalizeString(task.updatedAt),
}]];
})) as AiWhiteBackgroundSession["generationTasksByView"];
}
function normalizeManifestStatus(
value: unknown,
): AiWhiteBackgroundManifestStatus {
@ -466,6 +494,7 @@ function normalizeSession(value: unknown): AiWhiteBackgroundSession | null {
refinementPromptsByView: normalizeRefinementPromptsByView(
value.refinementPromptsByView,
),
generationTasksByView: normalizeGenerationTasks(value.generationTasksByView),
lastManifestId: normalizeString(value.lastManifestId) || undefined,
createdAt: normalizeString(value.createdAt) || new Date().toISOString(),
updatedAt: normalizeString(value.updatedAt) || new Date().toISOString(),

View File

@ -0,0 +1,48 @@
/** @jest-environment node */
import { createAssistedAssemblyAiRender, waitForAssistedAssemblyAiRender } from './platform-api'
import type { AssistedAssemblyAiRenderDTO } from '@/server/services'
const pending = { id: 'render-1', status: 'PENDING' } as AssistedAssemblyAiRenderDTO
const succeeded = { ...pending, status: 'SUCCEEDED' } as AssistedAssemblyAiRenderDTO
const originalFetch = global.fetch
const response = (render: AssistedAssemblyAiRenderDTO) => ({ ok: true, json: async () => ({ success: true, data: { render } }) }) as Response
beforeEach(() => { jest.useFakeTimers(); global.fetch = jest.fn() })
afterEach(() => { jest.useRealTimers(); global.fetch = originalFetch })
test('creation returns pending immediately and preserves the explicit idempotency key', async () => {
jest.mocked(fetch).mockResolvedValue(response(pending))
const result = await createAssistedAssemblyAiRender({ requestId: 'request-123', prompt: 'a chair', referenceImageDataUrl: 'data:image/png;base64,AA==', workspaceSnapshot: {}, assetSnapshot: {} })
expect(result.status).toBe('PENDING')
expect(fetch).toHaveBeenCalledTimes(1)
expect(JSON.parse(String(jest.mocked(fetch).mock.calls[0][1]?.body)).requestId).toBe('request-123')
})
test('polling gets the same persisted record without repeating a billable POST', async () => {
jest.mocked(fetch).mockResolvedValueOnce(response(pending)).mockResolvedValueOnce(response(succeeded))
const result = waitForAssistedAssemblyAiRender(pending, { pollIntervalMs: 100 })
await jest.advanceTimersByTimeAsync(200)
await expect(result).resolves.toEqual(succeeded)
expect(fetch).toHaveBeenCalledTimes(2)
for (const [url, options] of jest.mocked(fetch).mock.calls) {
expect(url).toBe('/api/assisted-assembly/ai-renders/render-1')
expect(options?.method).toBeUndefined()
}
})
test('a caller can stop polling without cancelling or submitting the server task', async () => {
const controller = new AbortController()
const result = waitForAssistedAssemblyAiRender(pending, { signal: controller.signal })
const rejection = expect(result).rejects.toBeDefined()
controller.abort()
await rejection
expect(fetch).not.toHaveBeenCalled()
expect(jest.getTimerCount()).toBe(0)
})
test('temporary status-read failures retry reads and never resubmit generation', async () => {
jest.mocked(fetch).mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(response(succeeded))
const result = waitForAssistedAssemblyAiRender(pending, { pollIntervalMs: 100 })
await jest.advanceTimersByTimeAsync(200)
await expect(result).resolves.toEqual(succeeded)
expect(fetch).toHaveBeenCalledTimes(2)
})

View File

@ -1,5 +1,7 @@
'use client'
import { submitRecoverableImageRequest, type ImageSubmissionResult } from '@/lib/recoverable-image-submission'
import type {
AssetFamilyDTO,
AssetCadImportResult,
@ -937,6 +939,7 @@ export async function unbindPreSkuFromLocation(
}
export interface CreateAssistedAssemblyAiRenderPayload {
requestId?: string
prompt: string
negativePrompt?: string | null
imageCount?: number | null
@ -1200,19 +1203,55 @@ export async function listAiRenderWorkbenchSkuRenders(
return data.renders
}
export async function createAssistedAssemblyAiRender(
input: CreateAssistedAssemblyAiRenderPayload,
): Promise<AssistedAssemblyAiRenderDTO> {
export async function getAssistedAssemblyAiRender(id: string): Promise<AssistedAssemblyAiRenderDTO> {
const data = await requestPlatform<{ render: AssistedAssemblyAiRenderDTO }>(
'/api/assisted-assembly/ai-renders',
{
method: 'POST',
body: JSON.stringify(input),
},
`/api/assisted-assembly/ai-renders/${encodeURIComponent(id)}`,
)
return data.render
}
export async function createAssistedAssemblyAiRender(
input: CreateAssistedAssemblyAiRenderPayload,
options: { onUncertain?: (message: string) => void } = {},
): Promise<ImageSubmissionResult<AssistedAssemblyAiRenderDTO>> {
return submitRecoverableImageRequest<AssistedAssemblyAiRenderDTO, { render: AssistedAssemblyAiRenderDTO | null }>({
url: '/api/assisted-assembly/ai-renders',
scope: `${input.intakeSkuItemId ?? input.workbenchSkuId ?? 'assisted'}:${input.mode ?? ''}:${input.viewName ?? input.viewIndex ?? ''}`,
group: input.intakeSkuItemId ?? input.workbenchSkuId ?? 'assisted',
input,
readResult: (data) => data?.render ?? null,
onUncertain: options.onUncertain,
})
}
/** Polling never resubmits generation; the server owns execution even when this page closes. */
export async function waitForAssistedAssemblyAiRender(
initial: AssistedAssemblyAiRenderDTO,
options: { signal?: AbortSignal; pollIntervalMs?: number } = {},
): Promise<AssistedAssemblyAiRenderDTO> {
let render = initial
let consecutiveReadErrors = 0
const startedAt = Date.now()
while (render.status === 'PENDING' && !render.errorMessage) {
options.signal?.throwIfAborted()
await new Promise<void>((resolve, reject) => {
const abort = () => { clearTimeout(timer); reject(options.signal?.reason ?? new Error('查询已停止')) }
const timer = setTimeout(() => { options.signal?.removeEventListener('abort', abort); resolve() }, options.pollIntervalMs ?? 3000)
options.signal?.addEventListener('abort', abort, { once: true })
})
options.signal?.throwIfAborted()
try {
render = await getAssistedAssemblyAiRender(initial.id)
consecutiveReadErrors = 0
} catch {
consecutiveReadErrors += 1
if (consecutiveReadErrors >= 3) return { ...render, errorMessage: '暂时无法查询任务进度,任务仍保留在后台。请刷新记录核对,请勿重复提交' }
}
if (render.status === 'PENDING' && Date.now() - startedAt > 30 * 60_000) return { ...render, errorMessage: '任务仍在后台处理,请稍后刷新记录核对,请勿重复提交' }
}
return render
}
export async function deleteAssistedAssemblyAiRender(
id: string,
options?: { imageIndex?: number | null },

View File

@ -0,0 +1,71 @@
/** @jest-environment node */
const endpoint = '/api/fixture/ai-renders'
const pending = { id: 'server-render', status: 'PENDING' }
const success = { ...pending, status: 'SUCCEEDED' }
const response = (render: unknown) => ({ ok: true, status: 200, json: async () => ({ success: true, data: { render } }) }) as Response
let values: Map<string, string>
let submit: typeof import('./recoverable-image-submission').submitRecoverableImageRequest
const input = { requestId: 'original-request', prompt: 'chair', referenceImageDataUrl: 'large-private-reference' }
const args = () => ({ url: endpoint, scope: 'sku:white', group: 'sku', input, readResult: (data: any) => data?.render ?? null })
beforeEach(() => {
jest.resetModules()
jest.useFakeTimers()
values = new Map()
Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: {
get length() { return values.size }, key: (index: number) => [...values.keys()][index] ?? null,
getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
} })
jest.spyOn(crypto.subtle, 'digest').mockResolvedValue(new Uint8Array(32).buffer)
global.fetch = jest.fn()
submit = require('./recoverable-image-submission').submitRecoverableImageRequest
})
afterEach(() => { jest.restoreAllMocks(); jest.useRealTimers(); delete (globalThis as any).sessionStorage })
test('lost first POST response retries the identical key and body after server commit', async () => {
jest.mocked(fetch).mockRejectedValueOnce(new TypeError('response lost')).mockResolvedValueOnce(response(pending))
const result = submit(args())
await jest.advanceTimersByTimeAsync(1100)
await expect(result).resolves.toEqual(pending)
expect(fetch).toHaveBeenCalledTimes(2)
expect(jest.mocked(fetch).mock.calls[0][1]?.body).toBe(jest.mocked(fetch).mock.calls[1][1]?.body)
expect(values.size).toBe(0)
})
test('three unknown POSTs switch to only reads and recover an already completed original task', async () => {
jest.mocked(fetch).mockRejectedValueOnce(new TypeError('lost')).mockRejectedValueOnce(new TypeError('lost')).mockRejectedValueOnce(new TypeError('lost'))
.mockRejectedValueOnce(new TypeError('read failed')).mockResolvedValueOnce(response(null)).mockResolvedValueOnce(response(success))
const result = submit(args())
let settled = false; void result.then(() => { settled = true })
await jest.advanceTimersByTimeAsync(2100)
expect(settled).toBe(false)
expect([...values.values()].join()).not.toContain('large-private-reference')
await jest.advanceTimersByTimeAsync(10000)
await expect(result).resolves.toEqual(success)
expect(jest.mocked(fetch).mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(3)
expect(jest.mocked(fetch).mock.calls[3][0]).toContain('requestId=original-request')
})
test('reload recovers persisted original ID through GET even after mode and input changed', async () => {
values.set(`native-image-submission:v1:${endpoint}:sku:sku:white`, JSON.stringify({ requestId: 'before-reload', fingerprint: 'hash-only' }))
jest.mocked(fetch).mockResolvedValue(response(success))
await expect(submit({ ...args(), scope: 'sku:scene', input: { ...input, requestId: 'new-key', prompt: 'changed' } })).resolves.toMatchObject({ ...success, submissionRecovery: { requestId: 'before-reload', scope: 'sku:white' } })
expect(fetch).toHaveBeenCalledTimes(1)
expect(jest.mocked(fetch).mock.calls[0][0]).toContain('requestId=before-reload')
expect(jest.mocked(fetch).mock.calls[0][1]?.method).toBeUndefined()
})
test('a confirmed result permits a later intentional generation with a fresh ID', async () => {
jest.mocked(fetch).mockResolvedValue(response(success))
await submit(args())
await submit({ ...args(), input: { ...input, requestId: 'intentional-next-request' } })
expect(fetch).toHaveBeenCalledTimes(2)
expect(JSON.parse(String(jest.mocked(fetch).mock.calls[1][1]?.body)).requestId).toBe('intentional-next-request')
})
test('browser storage failure prevents a paid POST before it becomes untrackable', async () => {
jest.spyOn(sessionStorage, 'setItem').mockImplementation(() => { throw new Error('quota') })
await expect(submit(args())).rejects.toThrow('quota')
expect(fetch).not.toHaveBeenCalled()
})

View File

@ -0,0 +1,102 @@
'use client'
type Marker = { requestId: string; fingerprint: string }
export type ImageSubmissionResult<T> = T & { submissionRecovery?: { requestId: string; scope: string } }
const storagePrefix = 'native-image-submission:v1:'
const active = new Map<string, Promise<unknown>>()
const memory = new Map<string, Marker>()
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
export const IMAGE_SUBMISSION_UNCERTAIN = '提交结果暂时无法确认,正在核对原任务,请勿重复提交。刷新后会继续核对。'
/** Keep only the request identity in browser storage, never reference images or credentials. */
export function submitRecoverableImageRequest<T, D = unknown>(options: {
url: string
scope: string
group?: string
input: { requestId?: string } & object
readResult: (data: D) => T | null
onUncertain?: (message: string) => void
}): Promise<ImageSubmissionResult<T>> {
const groupPrefix = storagePrefix + options.url + ':' + (options.group ?? options.scope) + ':'
let key = groupPrefix + options.scope
// A restored unknown request also blocks another mode/view for this product.
// Read the original identity first, even if controls changed after reload.
const previousKey = [...active.keys(), ...memory.keys()].find((entry) => entry.startsWith(groupPrefix))
if (previousKey) key = previousKey
else if (typeof sessionStorage !== 'undefined') {
for (let index = 0; index < sessionStorage.length; index += 1) {
const storedKey = sessionStorage.key(index)
if (storedKey?.startsWith(groupPrefix)) { key = storedKey; break }
}
}
const running = active.get(key)
if (running) return running.then((result) => ({ ...(result as T), submissionRecovery: { requestId: memory.get(key)?.requestId ?? '', scope: key.slice(groupPrefix.length) } }))
const execute = async () => {
let saved = memory.get(key)
if (!saved && typeof sessionStorage !== 'undefined') {
const raw = sessionStorage.getItem(key)
if (raw) {
const parsed = JSON.parse(raw) as Marker
if (!parsed.requestId || !parsed.fingerprint) throw new Error('原出图请求记录异常,请先核对出图记录。')
saved = parsed
}
}
const restored = Boolean(saved)
const requestId = saved?.requestId ?? options.input.requestId ?? crypto.randomUUID()
const body = JSON.stringify({ ...options.input, requestId })
if (!saved) {
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body))
saved = { requestId, fingerprint: Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, '0')).join('') }
// Fail before submitting if persistence is unavailable; an unknown paid request
// must remain identifiable after a refresh.
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, JSON.stringify(saved))
memory.set(key, saved)
}
const clear = () => {
memory.delete(key)
if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(key)
}
let uncertain = restored
if (!restored) {
for (let attempt = 0; attempt < 3; attempt += 1) {
if (attempt) await pause(1000)
let response: Response, payload: { success?: boolean; data?: D; error?: { message?: string } }
try {
response = await fetch(options.url, { method: 'POST', cache: 'no-store', headers: { 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(90_000) })
payload = await response.json()
} catch { uncertain = true; options.onUncertain?.(IMAGE_SUBMISSION_UNCERTAIN); continue }
if (response.ok && payload?.success && payload.data !== undefined) {
const result = options.readResult(payload.data)
if (result) { clear(); return result }
}
if (!uncertain && response.status >= 400 && response.status < 500 && payload?.success === false) {
clear()
throw new Error(payload?.error?.message || '出图请求被拒绝,请检查输入。')
}
uncertain = true
options.onUncertain?.(IMAGE_SUBMISSION_UNCERTAIN)
}
}
options.onUncertain?.(IMAGE_SUBMISSION_UNCERTAIN)
// POST retries are bounded. An unknown outcome is then recovered only through
// owner-scoped reads, including after reload; never invent a replacement key.
for (;;) {
try {
const response = await fetch(`${options.url}?requestId=${encodeURIComponent(requestId)}`, { cache: 'no-store', signal: AbortSignal.timeout(20_000) })
const payload = await response.json() as { success?: boolean; data?: D }
if (response.ok && payload?.success && payload.data !== undefined) {
const result = options.readResult(payload.data)
if (result) {
clear()
return restored ? { ...result, submissionRecovery: { requestId, scope: key.slice(groupPrefix.length) } } : result
}
}
} catch { /* Keep the identity and pending UI while the outcome is unknown. */ }
await pause(5000)
}
}
const promise = execute().finally(() => active.delete(key))
active.set(key, promise)
return promise as Promise<ImageSubmissionResult<T>>
}

View File

@ -108,6 +108,10 @@ type JsonReferenceConfig = {
}
const RESERVED_OBJECT_KEY_PREFIXES = [
// Uploads precede the atomic job/business transaction. Protect that short preparation window.
'native-ai-inputs/',
// Results are uploaded before their stable URLs are committed to job progress.
'native-ai-results/',
'.system/',
'.trash/',
'object-storage-config-check/',
@ -130,7 +134,7 @@ function readAdditionalReservedObjectKeyPrefixes(): string[] {
function getReservedObjectKeyPrefixes(): string[] {
return [
...RESERVED_OBJECT_KEY_PREFIXES,
...RESERVED_OBJECT_KEY_PREFIXES.map((prefix) => normalizeObjectStorageKey(prefix)),
...readAdditionalReservedObjectKeyPrefixes(),
]
}
@ -244,6 +248,8 @@ const JSON_REFERENCE_CONFIGS: JsonReferenceConfig[] = [
{ recordType: 'AssetSkuSyncEvent', delegate: 'assetSkuSyncEvent', fieldName: 'payload', select: { id: true, payload: true }, orderBy: { createdAt: 'desc' } },
{ recordType: 'AssetSkuSyncEvent', delegate: 'assetSkuSyncEvent', fieldName: 'progress', select: { id: true, progress: true }, orderBy: { createdAt: 'desc' } },
{ recordType: 'AssetVariant', delegate: 'assetVariant', fieldName: 'businessData', select: { id: true, businessData: true }, orderBy: { updatedAt: 'desc' } },
{ recordType: 'NativeImageGenerationJob', delegate: 'nativeImageGenerationJob', fieldName: 'request', select: { id: true, request: true }, orderBy: { updatedAt: 'desc' } },
{ recordType: 'NativeImageGenerationJob', delegate: 'nativeImageGenerationJob', fieldName: 'progress', select: { id: true, progress: true }, orderBy: { updatedAt: 'desc' } },
{ recordType: 'AssistedAssemblyAiRender', delegate: 'assistedAssemblyAiRender', fieldName: 'resultImages', select: { id: true, resultImages: true }, orderBy: { updatedAt: 'desc' } },
{ recordType: 'AssistedAssemblyAiRender', delegate: 'assistedAssemblyAiRender', fieldName: 'assetSnapshot', select: { id: true, assetSnapshot: true }, orderBy: { updatedAt: 'desc' } },
{ recordType: 'AssistedAssemblyAiRender', delegate: 'assistedAssemblyAiRender', fieldName: 'workspaceSnapshot', select: { id: true, workspaceSnapshot: true }, orderBy: { updatedAt: 'desc' } },

View File

@ -452,6 +452,9 @@ describe('storage object provider', () => {
'http://tevepd6od.hd-bkt.clouddn.com/prod-assets/models/table.glb?e=',
)
expect(signedReadUrl).toContain('?e=1700007200&token=')
expect(storage.createObjectReadUrl('models/table.glb', 72 * 3600)).toContain('?e=1700259200&token=')
expect(() => storage.createObjectReadUrl('models/table.glb', 8 * 86400)).toThrow('7 天')
expect(() => storage.createObjectReadUrl('models/table.glb', 0)).toThrow('7 天')
expect(
storage.resolveObjectStorageUrlForServerAccess(
'/api/storage/object?key=prod-assets%2Fmodels%2Ftable.glb',

View File

@ -44,7 +44,7 @@ interface ObjectStorageAdapter {
uploadStream(stream: Readable, key: string, contentType: string): Promise<string>
uploadFile(filePath: string, key: string, contentType: string): Promise<string>
buildPublicUrl(key: string): string
buildDirectReadUrl(key: string): string
buildDirectReadUrl(key: string, expiresInSeconds?: number): string
objectExists(key: string): Promise<boolean>
deleteObject(key: string): Promise<ObjectStorageDeleteResult>
}
@ -728,14 +728,14 @@ function createQiniuStorageAdapter(): ObjectStorageAdapter {
return buildQiniuPublicDownloadUrl(publicEndpoint, objectKey)
},
buildDirectReadUrl(key) {
buildDirectReadUrl(key, expiresInSeconds) {
const objectKey = normalizeObjectKey(key)
if (!privateBucket) {
return buildQiniuPublicDownloadUrl(publicEndpoint, objectKey)
}
const deadline =
Math.floor(Date.now() / 1000) + privateUrlExpiresSeconds
Math.floor(Date.now() / 1000) + (expiresInSeconds ?? privateUrlExpiresSeconds)
return buildQiniuPrivateDownloadUrl({
accessKey,
secretKey,
@ -876,8 +876,11 @@ export function buildPublicObjectUrl(key: string): string {
return getStorageAdapter().buildPublicUrl(key)
}
export function createObjectReadUrl(key: string): string {
return getStorageAdapter().buildDirectReadUrl(key)
export function createObjectReadUrl(key: string, expiresInSeconds?: number): string {
if (expiresInSeconds !== undefined && (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 7 * 86400)) {
throw new Error('[Storage] 读取链接有效期必须为 1 秒至 7 天。')
}
return getStorageAdapter().buildDirectReadUrl(key, expiresInSeconds)
}
function sanitizeObjectKeyWithoutConfiguredPrefix(key: string): string {

View File

@ -0,0 +1,46 @@
import type { NonIntegratedProductRecord } from '@/app/intake/non-integrated/nonIntegratedStorage'
import { saveWhiteBackgroundIntentWithJob } from '../non-integrated-ai-white-background.repository'
jest.mock('@/lib/prisma', () => ({ prisma: { $transaction: jest.fn() } }))
jest.mock('../intake-non-integrated-product.repository', () => ({
intakeNonIntegratedProductRepository: { findByIdWithPayloadMutationLockInTransaction: jest.fn() },
}))
const prisma = jest.requireMock('@/lib/prisma').prisma
const readLocked = jest.requireMock('../intake-non-integrated-product.repository')
.intakeNonIntegratedProductRepository.findByIdWithPayloadMutationLockInTransaction as jest.Mock
const updatedAt = '2026-10-15T00:00:00.000Z'
const product = { id: 'product-1', metadata: { aiWhiteBackground: { sessions: [] } } } as unknown as NonIntegratedProductRecord
describe('saveWhiteBackgroundIntentWithJob', () => {
let tx: { intakeNonIntegratedProduct: { update: jest.Mock } }
beforeEach(() => {
jest.clearAllMocks()
tx = { intakeNonIntegratedProduct: { update: jest.fn().mockResolvedValue({}) } }
prisma.$transaction.mockImplementation(async (work: (tx: unknown) => Promise<unknown>) => work(tx))
readLocked.mockResolvedValue({ id: product.id, deletedAt: null, updatedAt: new Date(updatedAt) })
})
it('writes the intent and job in the same transaction after taking the existing product lock', async () => {
const persistJob = jest.fn().mockResolvedValue(undefined)
expect(await saveWhiteBackgroundIntentWithJob(product, updatedAt, 'user-1', persistJob)).toBe(true)
expect(readLocked).toHaveBeenCalledWith(tx, 'product-1')
expect(persistJob).toHaveBeenCalledWith(tx)
expect(tx.intakeNonIntegratedProduct.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'product-1' }, data: expect.objectContaining({ payload: product, updatedBy: 'user-1' }),
}))
})
it('does not insert a job or overwrite a product whose version changed', async () => {
readLocked.mockResolvedValue({ deletedAt: null, updatedAt: new Date('2026-10-15T00:01:00Z') })
const persistJob = jest.fn()
expect(await saveWhiteBackgroundIntentWithJob(product, updatedAt, 'user-1', persistJob)).toBe(false)
expect(tx.intakeNonIntegratedProduct.update).not.toHaveBeenCalled()
expect(persistJob).not.toHaveBeenCalled()
})
it('propagates insertion failure to roll back the enclosing transaction', async () => {
const persistJob = jest.fn().mockRejectedValue(new Error('job insert failed'))
await expect(saveWhiteBackgroundIntentWithJob(product, updatedAt, 'user-1', persistJob)).rejects.toThrow('job insert failed')
})
})

View File

@ -0,0 +1,41 @@
import { prisma } from '@/lib/prisma'
import type { NativeImageGenerationJob, Prisma } from '@prisma/client'
const runnable = ['QUEUED', 'RUNNING', 'FINALIZING']
export const nativeImageGenerationRepository = {
findById(id: string) {
return prisma.nativeImageGenerationJob.findUnique({ where: { id } })
},
async insert(data: Prisma.NativeImageGenerationJobUncheckedCreateInput, tx: Prisma.TransactionClient = prisma) {
const row = await tx.nativeImageGenerationJob.upsert({ where: { id: data.id }, create: data, update: {} })
if (row.createdBy !== data.createdBy || row.requestHash !== data.requestHash) {
throw new Error('同一出图请求编号不能用于不同内容')
}
return row
},
async claimNext(owner: string, now: Date, leaseUntil: Date): Promise<NativeImageGenerationJob | null> {
const where: Prisma.NativeImageGenerationJobWhereInput = {
status: { in: runnable }, nextRunAt: { lte: now },
OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }],
}
const candidate = await prisma.nativeImageGenerationJob.findFirst({ where, orderBy: [{ nextRunAt: 'asc' }, { createdAt: 'asc' }] })
if (!candidate) return null
const claimed = await prisma.nativeImageGenerationJob.updateMany({
where: { ...where, id: candidate.id }, data: { leaseOwner: owner, leaseUntil },
})
return claimed.count === 1
? prisma.nativeImageGenerationJob.findFirst({ where: { id: candidate.id, leaseOwner: owner } })
: null
},
async saveClaim(id: string, owner: string, data: Prisma.NativeImageGenerationJobUpdateManyMutationInput) {
const result = await prisma.nativeImageGenerationJob.updateMany({ where: { id, leaseOwner: owner }, data })
if (result.count !== 1) throw new Error('出图后台任务处理权已转移')
},
listForOwner(createdBy: string, ids: string[]) {
return prisma.nativeImageGenerationJob.findMany({
where: { createdBy, id: { in: ids.slice(0, 100) } },
select: { id: true, status: true, errorMessage: true, updatedAt: true },
})
},
}

View File

@ -0,0 +1,23 @@
import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
/** The business render remains the single result record for a durable generation job. */
export const nativeRenderRepository = {
find(id: string) {
return prisma.assistedAssemblyAiRender.findUnique({ where: { id } })
},
create(data: Prisma.AssistedAssemblyAiRenderUncheckedCreateInput) {
return prisma.assistedAssemblyAiRender.create({ data })
},
createWithJob(data: Prisma.AssistedAssemblyAiRenderUncheckedCreateInput, persistJob: (tx: Prisma.TransactionClient) => Promise<void>) {
return prisma.$transaction(async (tx) => {
// A duplicate key rolls back the whole transaction. The queue verifies the already
// committed job before accepting a retry; never attach a new job to a terminal row.
await tx.assistedAssemblyAiRender.create({ data })
await persistJob(tx)
})
},
updatePending(id: string, data: Prisma.AssistedAssemblyAiRenderUpdateManyMutationInput) {
return prisma.assistedAssemblyAiRender.updateMany({ where: { id, status: 'PENDING' }, data })
},
}

View File

@ -0,0 +1,30 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import type { NonIntegratedProductRecord } from '@/app/intake/non-integrated/nonIntegratedStorage'
import { intakeNonIntegratedProductRepository } from './intake-non-integrated-product.repository'
/** Commit a generation intent and its durable job under the product's existing mutation lock. */
export async function saveWhiteBackgroundIntentWithJob(
product: NonIntegratedProductRecord,
expectedUpdatedAt: string,
createdBy: string,
persistJob: (tx: Prisma.TransactionClient) => Promise<void>,
) {
return prisma.$transaction(async (tx) => {
const current = await intakeNonIntegratedProductRepository
.findByIdWithPayloadMutationLockInTransaction(tx, product.id)
if (!current || current.deletedAt ||
current.updatedAt.getTime() !== new Date(expectedUpdatedAt).getTime()) {
return false
}
await tx.intakeNonIntegratedProduct.update({
where: { id: product.id },
data: {
payload: JSON.parse(JSON.stringify(product)) as Prisma.InputJsonValue,
updatedBy: createdBy,
},
})
await persistJob(tx)
return true
}, { maxWait: 10_000, timeout: 30_000 })
}

View File

@ -0,0 +1,68 @@
/** @jest-environment node */
import { createAssistedAssemblyAiRender } from '../assisted-assembly-ai-render.create'
import { nativeRenderRepository } from '@/server/repositories/native-render.repository'
import { enqueueNativeImageGeneration } from '../native-image-generation.service'
import { uploadTemporaryWorkbenchReferenceImage } from '../assisted-assembly-ai-render.temp-storage'
import { getExternalImageApiConfig } from '../external-image-generate.service'
import type { AuthUser } from '@/server/core/types'
jest.mock('@/server/repositories/native-render.repository', () => ({ nativeRenderRepository: { find: jest.fn(), create: jest.fn(), createWithJob: jest.fn() } }))
jest.mock('../native-image-generation.service', () => ({ enqueueNativeImageGeneration: jest.fn() }))
jest.mock('../external-image-generate.service', () => ({ getExternalImageApiConfig: jest.fn() }))
jest.mock('../ai-render-workbench.service', () => ({ aiRenderWorkbenchService: { getSkuForRender: jest.fn() } }))
jest.mock('../intake-ai-asset.service', () => ({ intakeAiAssetService: { getSkuForRender: jest.fn() } }))
jest.mock('../assisted-assembly-ai-render.temp-storage', () => ({
getTemporaryRenderExpiresAt: jest.fn(() => '2026-11-01T00:00:00.000Z'), uploadTemporaryWorkbenchReferenceImage: jest.fn(),
normalizeTemporaryReferenceImages: jest.fn(() => []), readImageStorageKey: jest.fn(() => null), isTemporaryWorkbenchRenderKey: jest.fn(() => false),
}))
jest.mock('@/lib/storage', () => ({ createObjectReadUrl: jest.fn(), uploadBufferToObjectStorageWithMetadata: jest.fn() }))
const user = { id: 'owner', name: 'Owner' } as AuthUser
const input = { requestId: 'request-12345678', prompt: 'A wooden chair', referenceImageDataUrl: 'data:image/png;base64,AQID', workspaceSnapshot: {}, assetSnapshot: {} }
let row: Record<string, unknown> | null
beforeEach(() => {
jest.clearAllMocks()
row = null
jest.mocked(nativeRenderRepository.find).mockImplementation(async () => row as never)
jest.mocked(nativeRenderRepository.createWithJob).mockImplementation(async (data, persist) => {
await persist({} as never)
row = { ...data, createdAt: new Date(), updatedAt: new Date(), resultImages: [], errorMessage: null }
})
jest.mocked(enqueueNativeImageGeneration).mockImplementation(async (_options, commit) => { await commit?.(jest.fn()) })
jest.mocked(uploadTemporaryWorkbenchReferenceImage).mockResolvedValue({ url: '/reference.png', storageKey: 'reference' })
jest.mocked(getExternalImageApiConfig).mockResolvedValue({ enabled: true, baseUrl: 'https://relay.invalid', apiKey: 'test', model: 'image-model' })
})
test('queues a persistent pending record and returns without waiting for a model image', async () => {
const render = await createAssistedAssemblyAiRender(input, user)
expect(render.status).toBe('PENDING')
expect(render.resultImages).toEqual([])
expect(nativeRenderRepository.create).not.toHaveBeenCalled()
expect(nativeRenderRepository.createWithJob).toHaveBeenCalledTimes(1)
expect(enqueueNativeImageGeneration).toHaveBeenCalledWith(expect.objectContaining({
id: `render:${render.id}`, createdBy: 'owner', completion: { kind: 'render', renderId: render.id, source: 'assisted' },
input: expect.objectContaining({ prompt: 'A wooden chair', referenceImage: expect.objectContaining({ buffer: Buffer.from([1, 2, 3]) }) }),
}), expect.any(Function))
})
test('a retry with the same authenticated owner and request id returns the original row without re-enqueueing', async () => {
const first = await createAssistedAssemblyAiRender(input, user)
const second = await createAssistedAssemblyAiRender(input, user)
expect(second.id).toBe(first.id)
expect(enqueueNativeImageGeneration).toHaveBeenCalledTimes(1)
expect(uploadTemporaryWorkbenchReferenceImage).toHaveBeenCalledTimes(1)
})
test('the same client request id is scoped to its authenticated owner', async () => {
const first = await createAssistedAssemblyAiRender(input, user)
row = null
const other = await createAssistedAssemblyAiRender(input, { ...user, id: 'other-owner' })
expect(other.id).not.toBe(first.id)
})
test('the original request id rejects a changed prompt or SKU instead of returning an unrelated result', async () => {
await createAssistedAssemblyAiRender(input, user)
await expect(createAssistedAssemblyAiRender({ ...input, prompt: 'Different content' }, user)).rejects.toThrow('同一出图请求编号')
expect(enqueueNativeImageGeneration).toHaveBeenCalledTimes(1)
})

View File

@ -17,6 +17,8 @@ const mockReadObjectStorageKeyFromUrl = jest.fn()
const mockCreateObjectReadUrl = jest.fn()
const mockGetExternalImageApiConfig = jest.fn()
const mockCallExternalImageGenerateApi = jest.fn()
const mockEnqueue = jest.fn()
const storedRows = new Map<string, Record<string, unknown>>()
const mockGetSkuForRender = jest.fn()
const mockAssertCanDeleteAiRenderWorkbenchImage = jest.fn()
@ -198,7 +200,7 @@ async function createLargeWorkbenchRenderPngBuffer(): Promise<Buffer> {
function createMockRecord(data: Record<string, unknown>) {
return {
id: 'render-1',
id: data.id ?? 'render-1',
createdBy: data.createdBy ?? 'user-1',
workbenchSpuId: data.workbenchSpuId ?? null,
workbenchSkuId: data.workbenchSkuId ?? null,
@ -219,9 +221,16 @@ function createMockRecord(data: Record<string, unknown>) {
}
}
async function loadService() {
async function loadService(complete = false) {
jest.doMock('../native-image-generation.service', () => ({ enqueueNativeImageGeneration: mockEnqueue }))
const renderDelegate = {
create: mockAssistedAssemblyAiRenderCreate,
findUnique: mockAssistedAssemblyAiRenderFindUnique,
upsert: jest.fn(async (args: { create: Record<string, unknown> }) => mockAssistedAssemblyAiRenderCreate({ data: args.create })),
}
jest.doMock('@/lib/prisma', () => ({
prisma: {
$transaction: async (callback: (tx: unknown) => Promise<unknown>) => callback({ assistedAssemblyAiRender: renderDelegate }),
assistedAssemblyAiRender: {
create: mockAssistedAssemblyAiRenderCreate,
findMany: mockAssistedAssemblyAiRenderFindMany,
@ -254,7 +263,21 @@ async function loadService() {
mockAssertCanDeleteAiRenderWorkbenchImage(...args),
}))
return import('../assisted-assembly-ai-render.service')
const module = await import('../assisted-assembly-ai-render.service')
// Existing image-cleaning cases now exercise the separate durable completion stage.
const service = module.assistedAssemblyAiRenderService
const wrapper = Object.create(service)
wrapper.create = async (input: unknown, user: unknown) => {
const pending = await service.create(input as never, user as never)
if (pending.status === 'FAILED') throw new Error(pending.errorMessage || 'failed')
if (!complete) return pending
const { completeNativeRenderGeneration } = await import('../native-render-completion.service')
const result = await mockCallExternalImageGenerateApi((mockEnqueue.mock.calls.at(-1)?.[0] as { input: unknown }).input)
await completeNativeRenderGeneration({ kind: 'render', renderId: pending.id, source: 'assisted' }, result as never)
return service.getForUser(pending.id, user as never)
}
return { ...module, assistedAssemblyAiRenderService: wrapper }
}
describe('assistedAssemblyAiRenderService.create', () => {
@ -262,12 +285,21 @@ describe('assistedAssemblyAiRenderService.create', () => {
jest.resetModules()
jest.clearAllMocks()
global.fetch = originalFetch
mockAssistedAssemblyAiRenderCreate.mockImplementation(async (args: { data: Record<string, unknown> }) =>
createMockRecord(args.data),
)
storedRows.clear()
mockAssistedAssemblyAiRenderCreate.mockImplementation(async (args: { data: Record<string, unknown> }) => {
const row = createMockRecord(args.data)
storedRows.set(row.id as string, row)
return row
})
mockEnqueue.mockImplementation(async (_options: unknown, commit: (persist: (tx: unknown) => Promise<void>) => Promise<void>) => commit(async () => undefined))
mockAssistedAssemblyAiRenderFindMany.mockResolvedValue([] as never)
mockAssistedAssemblyAiRenderFindUnique.mockResolvedValue(null as never)
mockAssistedAssemblyAiRenderUpdateMany.mockResolvedValue({ count: 0 } as never)
mockAssistedAssemblyAiRenderFindUnique.mockImplementation(async (args: { where: { id: string } }) => storedRows.get(args.where.id) ?? null)
mockAssistedAssemblyAiRenderUpdateMany.mockImplementation(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
const row = storedRows.get(args.where.id)
if (!row) return { count: 0 }
Object.assign(row, args.data)
return { count: 1 }
})
mockAssistedAssemblyAiRenderDeleteMany.mockResolvedValue({ count: 0 } as never)
mockUploadBufferToObjectStorageWithMetadata.mockResolvedValue({
url: '/api/storage/object?key=ai-reference.png',
@ -351,14 +383,14 @@ describe('assistedAssemblyAiRenderService.create', () => {
{ id: 'user-1' } as never,
)
expect(result.status).toBe(AssistedAssemblyAiRenderStatus.SUCCEEDED)
expect(result.status).toBe(AssistedAssemblyAiRenderStatus.PENDING)
expect(mockCreateObjectReadUrl).toHaveBeenCalledTimes(1)
expect(mockCreateObjectReadUrl).toHaveBeenCalledWith(
'prod-assets/materials/textures/material-library/oak.webp',
)
expect(fetchMock).not.toHaveBeenCalled()
const externalInput = mockCallExternalImageGenerateApi.mock.calls[0]?.[0] as {
const externalInput = (mockEnqueue.mock.calls[0]?.[0] as { input: unknown })?.input as {
prompt: string
referenceImageUrls?: string[]
additionalReferenceImages?: unknown[]
@ -373,6 +405,8 @@ describe('assistedAssemblyAiRenderService.create', () => {
data: { params: unknown }
}
expect(createArgs.data.params).toEqual({
requestId: null,
requestFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
imageCount: 1,
aspectRatio: '1:1',
imageSize: '2K',
@ -440,7 +474,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
{ id: 'user-1' } as never,
)
const externalInput = mockCallExternalImageGenerateApi.mock.calls[0]?.[0] as {
const externalInput = (mockEnqueue.mock.calls[0]?.[0] as { input: unknown })?.input as {
referenceImageUrls?: string[]
additionalReferenceImages?: Array<{ fileName?: string; contentType?: string }>
}
@ -453,6 +487,8 @@ describe('assistedAssemblyAiRenderService.create', () => {
data: { params: unknown }
}
expect(createArgs.data.params).toEqual({
requestId: null,
requestFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
imageCount: 1,
aspectRatio: '1:1',
imageSize: '2K',
@ -512,7 +548,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
expect.anything(),
)
const externalInput = mockCallExternalImageGenerateApi.mock.calls[0]?.[0] as {
const externalInput = (mockEnqueue.mock.calls[0]?.[0] as { input: unknown })?.input as {
prompt: string
referenceImageUrls?: string[]
additionalReferenceImages?: unknown[]
@ -528,6 +564,8 @@ describe('assistedAssemblyAiRenderService.create', () => {
data: { params: unknown }
}
expect(createArgs.data.params).toEqual({
requestId: null,
requestFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
mode: 'white-background',
imageCount: 1,
aspectRatio: '1:1',
@ -570,7 +608,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
{ id: 'user-1' } as never,
)
const externalInput = mockCallExternalImageGenerateApi.mock.calls.at(-1)?.[0] as {
const externalInput = (mockEnqueue.mock.calls.at(-1)?.[0] as { input: unknown })?.input as {
imageSize: string
}
expect(externalInput.imageSize).toBe('2K')
@ -617,7 +655,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
}) as unknown as typeof fetch
try {
const { assistedAssemblyAiRenderService } = await loadService()
const { assistedAssemblyAiRenderService } = await loadService(true)
const result = await assistedAssemblyAiRenderService.create(
{
@ -636,7 +674,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
expect(result.status).toBe(AssistedAssemblyAiRenderStatus.SUCCEEDED)
const createArgs = mockAssistedAssemblyAiRenderCreate.mock.calls.at(-1)?.[0] as {
const createArgs = mockAssistedAssemblyAiRenderUpdateMany.mock.calls.at(-1)?.[0] as {
data: { resultImages: Array<{ originalUrl: string }> }
}
const storedUrl = createArgs.data.resultImages[0]?.originalUrl
@ -697,7 +735,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
}) as unknown as typeof fetch
try {
const { assistedAssemblyAiRenderService } = await loadService()
const { assistedAssemblyAiRenderService } = await loadService(true)
const result = await assistedAssemblyAiRenderService.create(
{
@ -716,7 +754,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
expect(result.status).toBe(AssistedAssemblyAiRenderStatus.SUCCEEDED)
const createArgs = mockAssistedAssemblyAiRenderCreate.mock.calls.at(-1)?.[0] as {
const createArgs = mockAssistedAssemblyAiRenderUpdateMany.mock.calls.at(-1)?.[0] as {
data: { resultImages: Array<{ originalUrl: string }> }
}
const storedUrl = createArgs.data.resultImages[0]?.originalUrl
@ -775,7 +813,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
}) as unknown as typeof fetch
try {
const { assistedAssemblyAiRenderService } = await loadService()
const { assistedAssemblyAiRenderService } = await loadService(true)
const result = await assistedAssemblyAiRenderService.create(
{
@ -794,7 +832,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
expect(result.status).toBe(AssistedAssemblyAiRenderStatus.SUCCEEDED)
const createArgs = mockAssistedAssemblyAiRenderCreate.mock.calls.at(-1)?.[0] as {
const createArgs = mockAssistedAssemblyAiRenderUpdateMany.mock.calls.at(-1)?.[0] as {
data: { resultImages: Array<{ originalUrl: string }> }
}
const storedUrl = createArgs.data.resultImages[0]?.originalUrl
@ -858,7 +896,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
}) as unknown as typeof fetch
try {
const { assistedAssemblyAiRenderService } = await loadService()
const { assistedAssemblyAiRenderService } = await loadService(true)
const result = await assistedAssemblyAiRenderService.create(
{
@ -877,7 +915,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
expect(result.status).toBe(AssistedAssemblyAiRenderStatus.SUCCEEDED)
const createArgs = mockAssistedAssemblyAiRenderCreate.mock.calls.at(-1)?.[0] as {
const createArgs = mockAssistedAssemblyAiRenderUpdateMany.mock.calls.at(-1)?.[0] as {
data: { resultImages: Array<{ originalUrl: string; imageSize?: string | null }> }
}
const storedUrl = createArgs.data.resultImages[0]?.originalUrl
@ -937,7 +975,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
{ id: 'user-1' } as never,
)
const externalInput = mockCallExternalImageGenerateApi.mock.calls[0]?.[0] as {
const externalInput = (mockEnqueue.mock.calls[0]?.[0] as { input: unknown })?.input as {
additionalReferenceImages?: Array<{ fileName?: string }>
}
expect(externalInput.additionalReferenceImages).toHaveLength(3)
@ -985,6 +1023,14 @@ describe('assistedAssemblyAiRenderService.deleteForUser', () => {
global.fetch = originalFetch
})
it('does not delete pending jobs or their references while the worker may still finish', async () => {
mockAssistedAssemblyAiRenderFindUnique.mockResolvedValue(createMockRecord({ status: 'PENDING', createdBy: 'user-1', workbenchSkuId: 'sku-1', params: {}, referenceImageUrl: '/reference.png', resultImages: [] }) as never)
const { assistedAssemblyAiRenderService } = await loadService()
await expect(assistedAssemblyAiRenderService.deleteForUser('render-1', { id: 'user-1' } as never)).rejects.toThrow('图片仍在生成')
expect(mockAssistedAssemblyAiRenderDeleteMany).not.toHaveBeenCalled()
expect(mockDeleteObjectWithResult).not.toHaveBeenCalled()
})
it('treats a concurrent single-image workbench deletion as already handled', async () => {
const { assistedAssemblyAiRenderService } = await loadService()

View File

@ -4,8 +4,10 @@ import { diyAiRenderRepository } from '@/server/repositories/diy-ai-render.repos
import { diySchemeRepository } from '@/server/repositories/diy-scheme.repository'
import { diyCatalogService } from '../diy-catalog.service'
import { intakeNodePermissionSettingsService } from '../intake-node-permission-settings.service'
import { callExternalImageGenerateApi, getExternalImageApiConfig } from '../external-image-generate.service'
import { uploadDiyAiReference, storeDiyAiResults, discardDiyAiUploads } from '../diy-ai-render.storage'
import { getExternalImageApiConfig } from '../external-image-generate.service'
import { uploadDiyAiReference, discardDiyAiUploads } from '../diy-ai-render.storage'
import { enqueueNativeImageGeneration } from '../native-image-generation.service'
import { nativeRenderRepository } from '@/server/repositories/native-render.repository'
import { catalogFixture } from '@/lib/diy/__tests__/catalog.fixture'
import type { AuthUser } from '@/server/core/types'
import type { DiyAiRenderInput } from '@/lib/diy/ai-render'
@ -14,7 +16,9 @@ jest.mock('@/server/repositories/diy-ai-render.repository', () => ({ diyAiRender
jest.mock('@/server/repositories/diy-scheme.repository', () => ({ diySchemeRepository: { findOwned: jest.fn() } }))
jest.mock('../diy-catalog.service', () => ({ diyCatalogService: { getWorkbenchCatalog: jest.fn() } }))
jest.mock('../intake-node-permission-settings.service', () => ({ intakeNodePermissionSettingsService: { getSettings: jest.fn() } }))
jest.mock('../external-image-generate.service', () => ({ callExternalImageGenerateApi: jest.fn(), getExternalImageApiConfig: jest.fn() }))
jest.mock('../external-image-generate.service', () => ({ getExternalImageApiConfig: jest.fn() }))
jest.mock('../native-image-generation.service', () => ({ enqueueNativeImageGeneration: jest.fn() }))
jest.mock('@/server/repositories/native-render.repository', () => ({ nativeRenderRepository: { createWithJob: jest.fn() } }))
jest.mock('../diy-ai-render.storage', () => ({ uploadDiyAiReference: jest.fn(), storeDiyAiResults: jest.fn(), discardDiyAiUploads: jest.fn() }))
jest.mock('../prompt-optimizer.service', () => ({ promptOptimizerService: { optimize: jest.fn() } }))
const repo = jest.mocked(diyAiRenderRepository)
@ -33,18 +37,18 @@ beforeEach(() => {
repo.create.mockImplementation(async data => { Object.assign(row,data); return row as never })
repo.update.mockImplementation(async (_id,data) => { Object.assign(row,data); return row as never })
jest.mocked(getExternalImageApiConfig).mockResolvedValue({enabled:true,baseUrl:'https://provider.invalid',apiKey:'test',model:'test-model'})
jest.mocked(callExternalImageGenerateApi).mockResolvedValue({ requestId:'provider-id', model:'test-model', images:[{originalUrl:'https://provider.invalid/result.png'}], attempts:[], generatedImageIds:[], generatedAssetIds:[] })
jest.mocked(enqueueNativeImageGeneration).mockImplementation(async (_options, commit) => { await commit?.(jest.fn()) })
jest.mocked(nativeRenderRepository.createWithJob).mockImplementation(async (data, persist) => { Object.assign(row, data); repo.findOwned.mockResolvedValue(row as never); await persist({} as never) })
jest.mocked(uploadDiyAiReference).mockResolvedValue({url:'/api/storage/object?key=reference',storageKey:'reference'})
jest.mocked(storeDiyAiResults).mockImplementation(async (_images,_user,_id,keys) => { keys.push('result'); return [{originalUrl:'/api/storage/object?key=result',thumbnailUrl:'/api/storage/object?key=result',storageKey:'result',width:1024,height:1024}] })
})
test('supplier access is denied before catalog, persistence and provider calls', async () => {
await expect(diyAiRenderService.create(input,{...user,businessRoles:['SUPPLIER']})).rejects.toMatchObject({statusCode:403})
expect(repo.create).not.toHaveBeenCalled(); expect(callExternalImageGenerateApi).not.toHaveBeenCalled()
expect(repo.create).not.toHaveBeenCalled(); expect(enqueueNativeImageGeneration).not.toHaveBeenCalled()
})
test('stale catalog or invalid snapshot cannot incur an AI request', async () => {
await expect(diyAiRenderService.create({...input,catalogHash:'old'},user)).rejects.toMatchObject({statusCode:422})
await expect(diyAiRenderService.create({...input,snapshot:{...input.snapshot,items:[]}},user)).rejects.toMatchObject({statusCode:422})
expect(repo.create).not.toHaveBeenCalled(); expect(callExternalImageGenerateApi).not.toHaveBeenCalled()
expect(repo.create).not.toHaveBeenCalled(); expect(enqueueNativeImageGeneration).not.toHaveBeenCalled()
})
test('scheme ownership is checked independently of snapshot validity', async () => {
jest.mocked(diySchemeRepository.findOwned).mockResolvedValue(null)
@ -53,36 +57,44 @@ test('scheme ownership is checked independently of snapshot validity', async ()
})
test('primary image, same-view structure, exact camera and original colors reach durable DIY records', async () => {
const result = await diyAiRenderService.create(input,user)
expect(result.status).toBe('SUCCEEDED'); expect(result.snapshot).toEqual(input.snapshot); expect(result.cameraView).toEqual(input.cameraView)
const request = jest.mocked(callExternalImageGenerateApi).mock.calls[0][0]
expect(result.status).toBe('PENDING'); expect(result.snapshot).toEqual(input.snapshot); expect(result.cameraView).toEqual(input.cameraView)
const request = jest.mocked(enqueueNativeImageGeneration).mock.calls[0][0].input
expect(request.referenceImage?.buffer).toEqual(Buffer.from([1,2,3])); expect(request.additionalReferenceImages?.[0].buffer).toEqual(Buffer.from([4,5,6]))
expect(request.prompt).toContain('exactly 1 cabinets and 0 installed door panels')
expect(repo.create.mock.calls[0][0]).toMatchObject({createdBy:'designer',storageClass:'PERMANENT',params:{source:'diy'},workspaceSnapshot:{snapshot:input.snapshot}})
expect(repo.create.mock.calls[0][0]).not.toHaveProperty('intakeSkuItemId')
expect(jest.mocked(nativeRenderRepository.createWithJob).mock.calls[0][0]).toMatchObject({createdBy:'designer',storageClass:'PERMANENT',params:{source:'diy'},workspaceSnapshot:{snapshot:input.snapshot}})
expect(jest.mocked(nativeRenderRepository.createWithJob).mock.calls[0][0]).not.toHaveProperty('intakeSkuItemId')
expect(discardDiyAiUploads).not.toHaveBeenCalled()
})
test('retrying the same owned request returns its record without generating again', async () => {
const first = await diyAiRenderService.create(input,user)
repo.findOwned.mockResolvedValue(row as never)
const retry = await diyAiRenderService.create(input,user)
expect(first.id).toBe(retry.id); expect(callExternalImageGenerateApi).toHaveBeenCalledTimes(1)
expect(first.id).toBe(retry.id); expect(enqueueNativeImageGeneration).toHaveBeenCalledTimes(1)
expect(repo.findOwned).toHaveBeenLastCalledWith(input.requestId,'designer')
})
test('scene generation requires user text and discards white structure references', async () => {
await expect(diyAiRenderService.create({...input,mode:'scene'},user)).rejects.toMatchObject({statusCode:422})
await diyAiRenderService.create({...input,mode:'scene',userPrompt:'Modern living room'},user)
expect(jest.mocked(callExternalImageGenerateApi).mock.calls[0][0].additionalReferenceImages).toEqual([])
expect(repo.create.mock.calls[0][0].outputMode).toBe('SCENE')
expect(jest.mocked(enqueueNativeImageGeneration).mock.calls[0][0].input.additionalReferenceImages).toEqual([])
expect(jest.mocked(nativeRenderRepository.createWithJob).mock.calls[0][0].outputMode).toBe('SCENE')
})
test('failed result writes retain the saved reference and discard only unattached result uploads', async () => {
repo.update.mockImplementation(async (_id,data) => { if(data.status==='SUCCEEDED')throw Error('write failed');Object.assign(row,data);return row as never })
const result = await diyAiRenderService.create(input,user)
expect(result.status).toBe('FAILED'); expect(result.referenceImageUrl).toContain('reference')
expect(discardDiyAiUploads).toHaveBeenCalledWith(['result'])
test('a failed enqueue does not leave a pending business row and discards its unattached reference', async () => {
jest.mocked(enqueueNativeImageGeneration).mockRejectedValueOnce(new Error('queue write failed'))
await expect(diyAiRenderService.create(input,user)).rejects.toThrow('queue write failed')
expect(nativeRenderRepository.createWithJob).not.toHaveBeenCalled()
expect(discardDiyAiUploads).toHaveBeenCalledWith(['reference'])
})
test('list requests use the authenticated owner and optional arrangement filter', async () => {
const key = 'a'.repeat(64)
await diyAiRenderService.list(user,key)
expect(repo.listOwned).toHaveBeenCalledWith('designer',key,undefined)
await expect(diyAiRenderService.list(user,'bad')).rejects.toMatchObject({statusCode:422})
})
test('same DIY request id rejects a changed prompt and preserves the original paid task', async () => {
await diyAiRenderService.create(input, user)
await expect(diyAiRenderService.create({ ...input, prompt: 'A different prompt' }, user)).rejects.toThrow('同一出图请求编号')
expect(enqueueNativeImageGeneration).toHaveBeenCalledTimes(1)
})

View File

@ -0,0 +1,75 @@
/** @jest-environment node */
import { EventEmitter } from 'node:events'
import { lookup } from 'node:dns/promises'
import { request } from 'node:https'
import { Readable } from 'node:stream'
import sharp from 'sharp'
import { createObjectReadUrl, readObjectStorageKeyFromUrl, uploadBufferToObjectStorageWithMetadata } from '@/lib/storage'
import { downloadNativeImage, persistNativeImageOutputs } from '../native-image-files'
jest.mock('node:dns/promises', () => ({ lookup: jest.fn() }))
jest.mock('node:https', () => ({ request: jest.fn() }))
jest.mock('@/lib/storage', () => ({
readObjectStorageKeyFromUrl: jest.fn(() => null),
createObjectReadUrl: jest.fn(key => `https://images.example/${key}?signed=local`),
uploadBufferToObjectStorageWithMetadata: jest.fn(async (_bytes, key) => ({ storageKey: key, url: `/api/storage/object?key=${encodeURIComponent(key)}` })),
}))
let fixture: Buffer
beforeAll(async () => { fixture = await sharp({ create: { width: 2, height: 2, channels: 3, background: '#ffffff' } }).png().toBuffer() })
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(readObjectStorageKeyFromUrl).mockReturnValue(null)
jest.mocked(lookup).mockResolvedValue([{ address: '8.8.8.8', family: 4 }] as never)
mockResponse(() => ({ bytes: fixture, status: 200 }))
})
function mockResponse(result: () => { bytes: Buffer; status: number; headers?: Record<string, string> }) {
jest.mocked(request).mockImplementation(((_url: unknown, _options: unknown, callback: (response: unknown) => void) => {
const req = new EventEmitter() as EventEmitter & { end(): void; destroy(error: Error): void }
req.end = () => {
const { bytes, status, headers = {} } = result()
const response = Object.assign(Readable.from([bytes]), { statusCode: status, headers })
callback(response)
response.on('close', () => req.emit('close'))
}
req.destroy = error => { req.emit('error', error); req.emit('close') }
return req
}) as never)
}
test('pins public DNS and validates actual image bytes', async () => {
await expect(downloadNativeImage('https://images.example/image.png')).resolves.toMatchObject({ contentType: 'image/png', extension: 'png', buffer: fixture })
const options = jest.mocked(request).mock.calls[0]![1] as { lookup: (host: string, options: { all?: boolean }, callback: jest.Mock) => void }
const cb = jest.fn()
options.lookup('images.example', { all: true }, cb)
expect(cb).toHaveBeenCalledWith(null, [{ address: '8.8.8.8', family: 4 }])
})
test.each(['https://127.0.0.1/x', 'https://[::1]/x', 'https://100.64.0.1/x', 'https://198.18.0.1/x', 'https://192.0.0.1/x', 'https://[2001:db8::1]/x', 'https://[2001::1]/x', 'https://[2001:0000:1234::1]/x', 'https://[2001:0002::1]/x', 'https://user:secret@images.example/x', 'https://images.example:8443/x'])('rejects non-public or credentialed address %s', async url => {
await expect(downloadNativeImage(url)).rejects.toThrow()
expect(request).not.toHaveBeenCalled()
})
test('rejects mixed public/private DNS and never follows redirects', async () => {
jest.mocked(lookup).mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }, { address: '192.168.1.1', family: 4 }] as never)
await expect(downloadNativeImage('https://images.example/ref.png')).rejects.toThrow('公开')
expect(request).not.toHaveBeenCalled()
mockResponse(() => ({ bytes: Buffer.alloc(0), status: 302, headers: { location: 'http://127.0.0.1/secret' } }))
await expect(downloadNativeImage('https://images.example/ref.png')).rejects.toThrow('下载失败')
expect(request).toHaveBeenCalledTimes(1)
})
test('caps streamed bytes even when no Content-Length is sent', async () => {
mockResponse(() => ({ bytes: Buffer.alloc(5000), status: 200 }))
await expect(downloadNativeImage('https://images.example/ref.png', 1000)).rejects.toThrow('下载中断')
})
test('re-signs project object URLs before downloading', async () => {
jest.mocked(readObjectStorageKeyFromUrl).mockReturnValue('materials/one.png')
await downloadNativeImage('/api/storage/object?key=materials%2Fone.png')
expect(createObjectReadUrl).toHaveBeenCalledWith('materials/one.png')
expect(jest.mocked(request).mock.calls[0]![0]).toEqual(new URL('https://images.example/materials/one.png?signed=local'))
})
test('stores outputs under stable keys so worker recovery never needs regeneration', async () => {
const images = [{ originalUrl: 'https://images.example/output.png', id: 'asset' }]
const first = await persistNativeImageOutputs('job', 'task', images)
const second = await persistNativeImageOutputs('job', 'task', images)
expect(first).toEqual(second)
expect(first[0]!.originalUrl).toContain('/api/storage/object?key=native-ai-results')
expect(jest.mocked(uploadBufferToObjectStorageWithMetadata).mock.calls[0]![1]).toEqual(jest.mocked(uploadBufferToObjectStorageWithMetadata).mock.calls[1]![1])
})

View File

@ -0,0 +1,103 @@
/** @jest-environment node */
import { enqueueNativeImageGeneration, nativeImageRequestHash } from '../native-image-generation.service'
import { nativeImageGenerationRepository as repo } from '@/server/repositories/native-image-generation.repository'
import { providerConfigService } from '../provider-config.service'
import { resolveRelayImageTaskContract } from '../relay-image-task.contract'
import { createObjectReadUrl, deleteObject, readObjectStorageKeyFromUrl, uploadBufferToObjectStorageWithMetadata } from '@/lib/storage'
import { downloadNativeImage } from '../native-image-files'
jest.mock('@/server/repositories/native-image-generation.repository', () => ({ nativeImageGenerationRepository: { findById: jest.fn(), insert: jest.fn() } }))
jest.mock('../provider-config.service', () => ({ providerConfigService: { getActive: jest.fn() } }))
jest.mock('../relay-image-task.contract', () => ({ resolveRelayImageTaskContract: jest.fn() }))
jest.mock('../native-image-files', () => ({
NATIVE_IMAGE_INPUT_URL_TTL_SECONDS: 72 * 3600,
downloadNativeImage: jest.fn(async () => ({ buffer: Buffer.from('downloaded'), contentType: 'image/png', extension: 'png' })),
}))
jest.mock('@/lib/storage', () => ({
uploadBufferToObjectStorageWithMetadata: jest.fn(async (_b, key) => ({ storageKey: key, url: `https://images.example/${key}` })),
createObjectReadUrl: jest.fn(key => `https://images.example/${key}?signed=example`), deleteObject: jest.fn(),
readObjectStorageKeyFromUrl: jest.fn(() => null),
}))
const input = () => ({
id: 'render:one', createdBy: 'owner', completion: { kind: 'render' as const, source: 'diy' as const, renderId: 'one' },
input: { prompt: 'A cabinet', negativePrompt: 'no extra doors', imageCount: 2, aspectRatio: '16:9', imageSize: '2K',
referenceImage: { buffer: Buffer.from('image'), contentType: 'image/png' }, referenceImageUrls: ['https://images.example/material.png'] },
})
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(readObjectStorageKeyFromUrl).mockReturnValue(null)
jest.mocked(repo.findById).mockResolvedValue(null)
jest.mocked(repo.insert).mockImplementation(async data => data as never)
jest.mocked(providerConfigService.getActive).mockResolvedValue({ id: 'provider', baseUrl: 'https://relay.example', apiKey: 'test-secret', model: 'image-model', extra: null } as never)
jest.mocked(resolveRelayImageTaskContract).mockResolvedValue({ routeOptionId: 'published-option' })
})
test('business and task writes use the supplied transaction and frozen per-image keys', async () => {
const tx = {} as never
let transactionRan = false
await enqueueNativeImageGeneration(input(), async persist => { transactionRan = true; await persist(tx) })
expect(transactionRan).toBe(true)
expect(repo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 'render:one', createdBy: 'owner' }), tx)
const saved = jest.mocked(repo.insert).mock.calls[0]![0]
expect(JSON.stringify(saved)).not.toContain('test-secret')
expect(saved.progress).toEqual([expect.objectContaining({ idempotencyKey: expect.any(String) }), expect.objectContaining({ idempotencyKey: expect.any(String) })])
expect(saved.request).toEqual(expect.objectContaining({ routeOptionId: 'published-option', imageCount: 2, referenceImageUrls: [expect.stringContaining('https://images.example/native-ai-inputs/'), expect.stringContaining('https://images.example/native-ai-inputs/')] }))
expect(downloadNativeImage).toHaveBeenCalledWith('https://images.example/material.png')
expect(createObjectReadUrl).toHaveBeenCalledWith(expect.any(String), 72 * 3600)
})
test('same owner/request returns without uploads or a second task', async () => {
jest.mocked(repo.findById).mockResolvedValue({ createdBy: 'owner', requestHash: nativeImageRequestHash(input()) } as never)
await enqueueNativeImageGeneration(input())
expect(repo.insert).not.toHaveBeenCalled()
expect(uploadBufferToObjectStorageWithMetadata).not.toHaveBeenCalled()
})
test('renewed signatures for the same project reference keep the original request identity', () => {
jest.mocked(readObjectStorageKeyFromUrl).mockReturnValue('materials/same.png')
const before = input(), after = input()
before.input.referenceImageUrls = ['https://images.example/materials/same.png?e=1&token=first']
after.input.referenceImageUrls = ['https://images.example/materials/same.png?e=2&token=second']
expect(nativeImageRequestHash(before)).toBe(nativeImageRequestHash(after))
jest.mocked(readObjectStorageKeyFromUrl).mockImplementation(url => url.includes('different') ? 'materials/different.png' : 'materials/same.png')
after.input.referenceImageUrls = ['https://images.example/materials/different.png?e=2']
expect(nativeImageRequestHash(before)).not.toBe(nativeImageRequestHash(after))
})
test('reusing an id with changed inputs or another owner is rejected', async () => {
jest.mocked(repo.findById).mockResolvedValue({ createdBy: 'someone-else', requestHash: nativeImageRequestHash(input()) } as never)
await expect(enqueueNativeImageGeneration(input())).rejects.toThrow('不同内容')
expect(uploadBufferToObjectStorageWithMetadata).not.toHaveBeenCalled()
})
test('a failed transaction removes only its unattached input upload', async () => {
await expect(enqueueNativeImageGeneration(input(), async () => { throw new Error('rollback') })).rejects.toThrow('rollback')
expect(deleteObject).toHaveBeenCalledTimes(2)
expect(repo.insert).not.toHaveBeenCalled()
})
test('ambiguous commit checks persisted identity before deleting uploads', async () => {
jest.mocked(repo.findById).mockResolvedValueOnce(null).mockImplementationOnce(async () => ({ createdBy: 'owner', requestHash: nativeImageRequestHash(input()), request: jest.mocked(repo.insert).mock.calls[0]![0].request } as never))
await enqueueNativeImageGeneration(input(), async persist => { await persist({} as never); throw new Error('connection dropped after commit') })
expect(deleteObject).not.toHaveBeenCalled()
})
test('unknown database outcome retains input files for recovery', async () => {
jest.mocked(repo.findById).mockResolvedValueOnce(null).mockRejectedValueOnce(new Error('offline'))
await expect(enqueueNativeImageGeneration(input(), async () => { throw new Error('offline') })).rejects.toThrow('offline')
expect(deleteObject).not.toHaveBeenCalled()
})
test('concurrent preparations own different objects, so failed preparation cleanup cannot delete the winner input', async () => {
const captured: string[][] = []
jest.mocked(repo.insert).mockImplementation(async data => { captured.push((data.request as { inputStorageKeys: string[] }).inputStorageKeys); return data as never })
const results = await Promise.allSettled([
enqueueNativeImageGeneration(input(), async persist => { await persist({} as never); throw new Error('transaction rollback') }),
enqueueNativeImageGeneration(input()),
])
expect(results.map(result => result.status).sort()).toEqual(['fulfilled', 'rejected'])
expect(captured[0]![0]).not.toEqual(captured[1]![0])
expect(deleteObject).toHaveBeenCalledWith(captured[0]![0])
expect(deleteObject).not.toHaveBeenCalledWith(captured[1]![0])
})

View File

@ -0,0 +1,238 @@
/** @jest-environment node */
import { nativeImageGenerationWorker } from '../native-image-generation.worker'
import { nativeImageGenerationRepository as repo } from '@/server/repositories/native-image-generation.repository'
import { providerConfigService } from '../provider-config.service'
import { nativeImageProviderFingerprint } from '../native-image-generation.service'
import { createRelayImageTask, getRelayImageTaskResult, RelayImageTaskError } from '../relay-image-task.client'
import { completeNativeRenderGeneration, failNativeRenderGeneration, markNativeRenderBlockedGeneration } from '../native-render-completion.service'
import { persistNativeImageOutputs } from '../native-image-files'
import { AppError, ErrorCode } from '@/server/core/errors'
jest.mock('@/server/repositories/native-image-generation.repository', () => ({ nativeImageGenerationRepository: { claimNext: jest.fn(), saveClaim: jest.fn() } }))
jest.mock('../provider-config.service', () => ({ providerConfigService: { getById: jest.fn() } }))
jest.mock('../native-image-generation.service', () => ({ nativeImageProviderFingerprint: jest.fn(() => 'fingerprint') }))
jest.mock('../relay-image-task.client', () => {
const actual = jest.requireActual('../relay-image-task.client')
return { ...actual, createRelayImageTask: jest.fn(), getRelayImageTaskResult: jest.fn() }
})
jest.mock('../native-render-completion.service', () => ({ completeNativeRenderGeneration: jest.fn(), failNativeRenderGeneration: jest.fn(), markNativeRenderBlockedGeneration: jest.fn() }))
jest.mock('../native-image-files', () => ({ persistNativeImageOutputs: jest.fn() }))
const makeJob = (overrides = {}) => ({
id: 'job', createdBy: 'owner', status: 'QUEUED', retryCount: 0, createdAt: new Date(), providerConfigId: 'provider', providerFingerprint: 'fingerprint',
request: { model: 'image-model', imageCount: 1, prompt: 'cabinet', aspectRatio: '16:9', imageSize: '2K', referenceImageUrls: ['https://images.example/ref.png'] },
progress: [{ idempotencyKey: 'fixed-key' }], completion: { kind: 'render', renderId: 'render', source: 'diy' }, ...overrides,
})
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(repo.saveClaim).mockResolvedValue(undefined)
jest.mocked(providerConfigService.getById).mockResolvedValue({ baseUrl: 'https://relay.example', apiKey: 'test-key' } as never)
jest.mocked(nativeImageProviderFingerprint).mockReturnValue('fingerprint')
jest.mocked(createRelayImageTask).mockResolvedValue({ taskId: 'remote-1' })
jest.mocked(getRelayImageTaskResult).mockResolvedValue({ status: 'pending' })
jest.mocked(completeNativeRenderGeneration).mockResolvedValue(undefined)
jest.mocked(failNativeRenderGeneration).mockResolvedValue(undefined)
jest.mocked(markNativeRenderBlockedGeneration).mockResolvedValue(undefined)
jest.mocked(persistNativeImageOutputs).mockImplementation(async (_job, _task, images) => images)
})
test('stores remote ID before finalization, then a restarted worker polls without another create', async () => {
jest.mocked(repo.claimNext).mockResolvedValue(makeJob() as never)
await nativeImageGenerationWorker.runNext()
const saved = jest.mocked(repo.saveClaim).mock.calls.at(-1)![2]
expect(saved.progress).toEqual([{ idempotencyKey: 'fixed-key', taskId: 'remote-1', lastAttemptedAt: expect.any(String) }])
expect(completeNativeRenderGeneration).not.toHaveBeenCalled()
jest.mocked(repo.claimNext).mockResolvedValue(makeJob({ status: 'RUNNING', progress: saved.progress }) as never)
await nativeImageGenerationWorker.runNext()
expect(createRelayImageTask).toHaveBeenCalledTimes(1)
expect(getRelayImageTaskResult).toHaveBeenCalledWith(expect.anything(), 'remote-1')
})
test('unknown POST outcome retries with exactly the original key and payload', async () => {
jest.mocked(repo.claimNext).mockImplementation(async () => makeJob() as never)
jest.mocked(createRelayImageTask).mockRejectedValueOnce(new RelayImageTaskError('network', 'unknown', { retryable: true, outcomeUnknown: true }))
await nativeImageGenerationWorker.runNext()
await nativeImageGenerationWorker.runNext()
expect(jest.mocked(createRelayImageTask).mock.calls.map(call => call[2])).toEqual(['fixed-key', 'fixed-key'])
expect(jest.mocked(createRelayImageTask).mock.calls[0]![1]).toEqual(jest.mocked(createRelayImageTask).mock.calls[1]![1])
})
test('completed results survive finalizer failure without regenerating', async () => {
const progress = [{ idempotencyKey: 'fixed-key', taskId: 'remote-1', images: [{ originalUrl: 'https://images.example/result.png' }] }]
jest.mocked(repo.claimNext).mockResolvedValue(makeJob({ status: 'FINALIZING', progress }) as never)
jest.mocked(completeNativeRenderGeneration).mockRejectedValueOnce(new Error('storage unavailable'))
await nativeImageGenerationWorker.runNext()
await nativeImageGenerationWorker.runNext()
expect(completeNativeRenderGeneration).toHaveBeenCalledTimes(2)
expect(createRelayImageTask).not.toHaveBeenCalled()
expect(getRelayImageTaskResult).not.toHaveBeenCalled()
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({ status: 'SUCCEEDED' }))
})
test('credential changes block a job instead of submitting to a different account', async () => {
jest.mocked(repo.claimNext).mockResolvedValue(makeJob() as never)
jest.mocked(nativeImageProviderFingerprint).mockReturnValue('changed')
await nativeImageGenerationWorker.runNext()
expect(createRelayImageTask).not.toHaveBeenCalled()
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({ status: 'BLOCKED' }))
})
test('mixed outcomes retain successful images and expose partial failure', async () => {
const job = makeJob({ status: 'FINALIZING', progress: [
{ idempotencyKey: 'one', taskId: 'remote-1', images: [{ originalUrl: 'https://images.example/result.png' }] },
{ idempotencyKey: 'two', taskId: 'remote-2', errorMessage: 'generation failed' },
] })
job.request.imageCount = 2
jest.mocked(repo.claimNext).mockResolvedValue(job as never)
await nativeImageGenerationWorker.runNext()
expect(completeNativeRenderGeneration).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ images: [{ originalUrl: 'https://images.example/result.png' }], attempts: expect.arrayContaining([expect.objectContaining({ status: 'failed' })]) }))
expect(failNativeRenderGeneration).not.toHaveBeenCalled()
})
test('persists BLOCKED and releases its lease even when the business notification throws', async () => {
jest.mocked(repo.claimNext).mockResolvedValue(makeJob() as never)
jest.mocked(nativeImageProviderFingerprint).mockReturnValue('changed')
jest.mocked(markNativeRenderBlockedGeneration).mockImplementation(async () => {
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({
status: 'BLOCKED', leaseOwner: null, leaseUntil: null,
}))
throw new Error('product was deleted')
})
const warning = jest.spyOn(console, 'warn').mockImplementation(() => {})
try {
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'blocked' })
expect(createRelayImageTask).not.toHaveBeenCalled()
expect(warning).toHaveBeenCalledWith('[native-ai.business-status-pending]', { jobId: 'job' })
} finally { warning.mockRestore() }
})
test('pending review retains the remote ID and blocks instead of resubmitting', async () => {
jest.mocked(repo.claimNext).mockResolvedValue(makeJob({
status: 'RUNNING', progress: [{ idempotencyKey: 'fixed-key', taskId: 'remote-1' }],
}) as never)
jest.mocked(getRelayImageTaskResult).mockResolvedValue({ status: 'pending', needsReview: true })
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'blocked' })
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({
status: 'BLOCKED', progress: [expect.objectContaining({ taskId: 'remote-1', idempotencyKey: 'fixed-key', needsReview: true })],
}))
expect(createRelayImageTask).not.toHaveBeenCalled()
expect(completeNativeRenderGeneration).not.toHaveBeenCalled()
})
test('retrieves and archives other successful images before blocking a mixed review job', async () => {
const job = makeJob({ status: 'RUNNING', progress: [
{ idempotencyKey: 'one', taskId: 'remote-review' },
{ idempotencyKey: 'two', taskId: 'remote-success' },
] })
job.request.imageCount = 2
jest.mocked(repo.claimNext).mockResolvedValue(job as never)
jest.mocked(getRelayImageTaskResult).mockResolvedValueOnce({ status: 'pending', needsReview: true })
.mockResolvedValueOnce({ status: 'succeeded', images: [{ originalUrl: 'https://upstream.example/result.png' }] })
jest.mocked(persistNativeImageOutputs).mockResolvedValue([{ originalUrl: 'https://project.example/native-ai-results/result.png' }])
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'processed' })
const savedFirst = jest.mocked(repo.saveClaim).mock.calls.at(-1)![2]
expect(savedFirst.status).toBe('RUNNING')
jest.mocked(repo.claimNext).mockResolvedValue({ ...job, progress: savedFirst.progress } as never)
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'blocked' })
expect(jest.mocked(getRelayImageTaskResult).mock.calls.map(call => call[1])).toEqual(['remote-review', 'remote-success'])
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({
status: 'BLOCKED', progress: [
expect.objectContaining({ taskId: 'remote-review', needsReview: true }),
expect.objectContaining({ taskId: 'remote-success', images: [{ originalUrl: 'https://project.example/native-ai-results/result.png' }] }),
],
}))
expect(createRelayImageTask).not.toHaveBeenCalled()
expect(completeNativeRenderGeneration).not.toHaveBeenCalled()
})
test('rotates pending polls so the first image cannot starve later images', async () => {
const job = makeJob({ status: 'RUNNING', progress: [
{ idempotencyKey: 'one', taskId: 'remote-1' },
{ idempotencyKey: 'two', taskId: 'remote-2' },
] })
job.request.imageCount = 2
jest.mocked(repo.claimNext).mockResolvedValue(job as never)
await nativeImageGenerationWorker.runNext()
const saved = jest.mocked(repo.saveClaim).mock.calls.at(-1)![2]
jest.mocked(repo.claimNext).mockResolvedValue({ ...job, progress: saved.progress } as never)
await nativeImageGenerationWorker.runNext()
expect(jest.mocked(getRelayImageTaskResult).mock.calls.map(call => call[1])).toEqual(['remote-1', 'remote-2'])
})
test('an uncertain first submission does not prevent attempting the second image with its own fixed key', async () => {
const job = makeJob({ progress: [{ idempotencyKey: 'one' }, { idempotencyKey: 'two' }] })
job.request.imageCount = 2
jest.mocked(repo.claimNext).mockResolvedValue(job as never)
jest.mocked(createRelayImageTask).mockRejectedValueOnce(new RelayImageTaskError('network', 'unknown', { retryable: true, outcomeUnknown: true }))
await nativeImageGenerationWorker.runNext()
const saved = jest.mocked(repo.saveClaim).mock.calls.at(-1)![2]
jest.mocked(repo.claimNext).mockResolvedValue({ ...job, progress: saved.progress } as never)
await nativeImageGenerationWorker.runNext()
expect(jest.mocked(createRelayImageTask).mock.calls.map(call => call[2])).toEqual(['one', 'two'])
})
test('output archive failure retries GET for the existing task and never creates another generation', async () => {
const job = makeJob({ status: 'RUNNING', progress: [{ idempotencyKey: 'fixed-key', taskId: 'remote-1' }] })
jest.mocked(repo.claimNext).mockResolvedValue(job as never)
jest.mocked(getRelayImageTaskResult).mockResolvedValue({ status: 'succeeded', images: [{ originalUrl: 'https://upstream.example/result.png' }] })
jest.mocked(persistNativeImageOutputs).mockRejectedValueOnce(new Error('storage offline'))
.mockResolvedValueOnce([{ originalUrl: 'https://project.example/native-ai-results/result.png' }])
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'retry' })
const failed = jest.mocked(repo.saveClaim).mock.calls.at(-1)![2]
expect(failed.progress).toEqual([expect.objectContaining({ taskId: 'remote-1' })])
expect(failed.progress).not.toEqual([expect.objectContaining({ images: expect.anything() })])
jest.mocked(repo.claimNext).mockResolvedValue({ ...job, progress: failed.progress } as never)
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'processed' })
expect(createRelayImageTask).not.toHaveBeenCalled()
expect(jest.mocked(getRelayImageTaskResult).mock.calls.map(call => call[1])).toEqual(['remote-1', 'remote-1'])
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({ status: 'FINALIZING' }))
})
test('pending work older than 24 hours blocks without another remote operation', async () => {
jest.mocked(repo.claimNext).mockResolvedValue(makeJob({ createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000) }) as never)
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'blocked' })
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({ status: 'BLOCKED', errorMessage: expect.stringContaining('24 小时') }))
expect(createRelayImageTask).not.toHaveBeenCalled()
expect(getRelayImageTaskResult).not.toHaveBeenCalled()
})
test('finished work still archives beyond the 24-hour generation limit', async () => {
jest.mocked(repo.claimNext).mockResolvedValue(makeJob({
status: 'FINALIZING', createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000),
progress: [{ idempotencyKey: 'fixed-key', taskId: 'remote-1', images: [{ originalUrl: 'https://project.example/result.png' }] }],
}) as never)
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'finalized' })
expect(completeNativeRenderGeneration).toHaveBeenCalledTimes(1)
expect(markNativeRenderBlockedGeneration).not.toHaveBeenCalled()
expect(createRelayImageTask).not.toHaveBeenCalled()
})
test('known terminal failure retries its business projection before marking the durable job FAILED', async () => {
jest.mocked(repo.claimNext).mockResolvedValue(makeJob({ status: 'FINALIZING', progress: [{ idempotencyKey: 'fixed-key', errorMessage: 'generation rejected' }] }) as never)
jest.mocked(failNativeRenderGeneration).mockRejectedValueOnce(new Error('database unavailable'))
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'retry' })
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({ retryCount: 1, leaseOwner: null, leaseUntil: null }))
expect(jest.mocked(repo.saveClaim).mock.calls.at(-1)![2]).not.toHaveProperty('status', 'FAILED')
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'finalized' })
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({ status: 'FAILED', leaseOwner: null, leaseUntil: null }))
})
test('context-validation failure keeps retrying until the business failed state is saved', async () => {
jest.mocked(repo.claimNext).mockResolvedValue(makeJob({ status: 'FINALIZING', progress: [{ idempotencyKey: 'fixed-key', images: [{ originalUrl: 'https://project.example/result.png' }] }] }) as never)
jest.mocked(completeNativeRenderGeneration).mockRejectedValue(new AppError(ErrorCode.VALIDATION_FAILED, 'source changed'))
jest.mocked(failNativeRenderGeneration).mockRejectedValueOnce(new Error('database unavailable'))
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'retry' })
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({ retryCount: 1, leaseOwner: null, leaseUntil: null }))
await expect(nativeImageGenerationWorker.runNext()).resolves.toMatchObject({ status: 'failed' })
expect(repo.saveClaim).toHaveBeenLastCalledWith('job', expect.any(String), expect.objectContaining({ status: 'FAILED', leaseOwner: null, leaseUntil: null }))
})
test('a lost lease during provider lookup prevents a subsequent generation request', async () => {
jest.useFakeTimers()
let releaseLookup!: (value: never) => void
jest.mocked(repo.claimNext).mockResolvedValue(makeJob() as never)
jest.mocked(providerConfigService.getById).mockImplementation(() => new Promise(resolve => { releaseLookup = resolve }))
jest.mocked(repo.saveClaim).mockRejectedValue(new Error('lease transferred'))
const work = nativeImageGenerationWorker.runNext()
try {
await jest.advanceTimersByTimeAsync(30_000)
releaseLookup({ baseUrl: 'https://relay.example', apiKey: 'test-key' } as never)
await expect(work).resolves.toMatchObject({ status: 'lease-lost' })
expect(createRelayImageTask).not.toHaveBeenCalled()
} finally { jest.useRealTimers() }
})

View File

@ -0,0 +1,88 @@
/** @jest-environment node */
import { completeNativeRenderGeneration, failNativeRenderGeneration, markNativeRenderBlockedGeneration } from '../native-render-completion.service'
import { nativeRenderRepository } from '@/server/repositories/native-render.repository'
import { storeDiyAiResults } from '../diy-ai-render.storage'
import { persistPermanentIntakeResultImages } from '../assisted-assembly-ai-render.permanent-storage'
import type { ExternalImageGenerateResult } from '../external-image-generate.service'
jest.mock('@/server/repositories/native-render.repository', () => ({ nativeRenderRepository: { find: jest.fn(), updatePending: jest.fn() } }))
jest.mock('../diy-ai-render.storage', () => ({ storeDiyAiResults: jest.fn() }))
jest.mock('../assisted-assembly-ai-render.permanent-storage', () => ({ persistPermanentIntakeResultImages: jest.fn() }))
jest.mock('../assisted-assembly-ai-render.temp-storage', () => ({ fetchImageBuffer: jest.fn(), getTemporaryRenderExpiresAt: jest.fn(), normalizeWorkbenchResultOutputSize: jest.fn(), uploadTemporaryWorkbenchResultImage: jest.fn(), readImageStorageKey: jest.fn(() => null), isTemporaryWorkbenchRenderKey: jest.fn(() => false) }))
const completion = { kind: 'render' as const, renderId: 'render-1', source: 'diy' as const }
const result: ExternalImageGenerateResult = { requestId: 'task-1', model: 'image-model', images: [{ originalUrl: 'https://images.invalid/result.png' }], attempts: [], generatedAssetIds: [], generatedImageIds: [] }
let row: Record<string, unknown>
beforeEach(() => {
jest.clearAllMocks()
row = { id: 'render-1', createdBy: 'owner', status: 'PENDING', params: { source: 'diy', mode: 'white-background', imageCount: 1 }, createdAt: new Date() }
jest.mocked(nativeRenderRepository.find).mockImplementation(async () => row as never)
jest.mocked(nativeRenderRepository.updatePending).mockImplementation(async (_id, data) => { Object.assign(row, data); return { count: 1 } })
jest.mocked(storeDiyAiResults).mockResolvedValue([{ originalUrl: '/stored/result.png', thumbnailUrl: '/stored/result.png', storageKey: 'stable-key', width: 1000, height: 1000 }])
})
test('completion updates the existing row and repeated worker deliveries do not store a second result', async () => {
await completeNativeRenderGeneration(completion, result)
await completeNativeRenderGeneration(completion, result)
expect(storeDiyAiResults).toHaveBeenCalledTimes(1)
expect(storeDiyAiResults).toHaveBeenCalledWith(result.images, 'owner', 'render-1', [])
expect(nativeRenderRepository.updatePending).toHaveBeenCalledWith('render-1', expect.objectContaining({ status: 'SUCCEEDED', externalRequestId: 'task-1', errorMessage: null }))
})
test('a storage write interruption retries persistence with the same render without regenerating', async () => {
jest.mocked(storeDiyAiResults).mockRejectedValueOnce(new Error('storage interrupted'))
await expect(completeNativeRenderGeneration(completion, result)).rejects.toThrow('storage interrupted')
expect(nativeRenderRepository.updatePending).not.toHaveBeenCalled()
await completeNativeRenderGeneration(completion, result)
expect(row.status).toBe('SUCCEEDED')
})
test('a committed database write with a lost response is recognized on redelivery without removing its images', async () => {
jest.mocked(nativeRenderRepository.updatePending).mockImplementationOnce(async (_id, data) => {
Object.assign(row, data)
throw new Error('connection lost after commit')
})
await expect(completeNativeRenderGeneration(completion, result)).rejects.toThrow('connection lost after commit')
await completeNativeRenderGeneration(completion, result)
expect(storeDiyAiResults).toHaveBeenCalledTimes(1)
expect(row.resultImages).toEqual([expect.objectContaining({ storageKey: 'stable-key' })])
})
test('partial success saves usable images and records the missing image count', async () => {
row.params = { source: 'diy', imageCount: 2 }
await completeNativeRenderGeneration(completion, { ...result, attempts: [{ status: 'failed', errorMessage: 'upstream rejected second image' }] })
expect(row.status).toBe('SUCCEEDED')
expect(row.errorMessage).toContain('1/2')
expect(row.errorMessage).toContain('upstream rejected second image')
})
test('failure cannot overwrite an already successful record', async () => {
await completeNativeRenderGeneration(completion, result)
await failNativeRenderGeneration(completion, 'late error')
expect(nativeRenderRepository.updatePending).toHaveBeenCalledTimes(1)
expect(row.status).toBe('SUCCEEDED')
})
test('permanent intake results use the saved SKU and mode, without an authenticated user object', async () => {
row.params = { mode: 'eight-view', imageCount: 1 }
row.intakeSkuItemId = 'sku-1'
jest.mocked(persistPermanentIntakeResultImages).mockResolvedValue([{ originalUrl: '/stored/intake.png' }])
await completeNativeRenderGeneration({ ...completion, source: 'assisted' }, result)
expect(persistPermanentIntakeResultImages).toHaveBeenCalledWith({ images: [expect.objectContaining(result.images[0])], skuItemId: 'sku-1', renderId: 'render-1', mode: 'eight-view' })
expect(row.status).toBe('SUCCEEDED')
})
test('missing or failed records do not persist orphaned result images', async () => {
row.status = 'FAILED'
await completeNativeRenderGeneration(completion, result)
jest.mocked(nativeRenderRepository.find).mockResolvedValue(null)
await completeNativeRenderGeneration(completion, result)
expect(storeDiyAiResults).not.toHaveBeenCalled()
})
test('an uncertain upstream outcome stays pending and asks for review instead of a new generation', async () => {
await markNativeRenderBlockedGeneration(completion, '上游结果尚未确认')
expect(row.status).toBe('PENDING')
expect(row.params).toMatchObject({ stage: 'needs-review' })
expect(row.errorMessage).toContain('请勿重复提交')
})

View File

@ -29,6 +29,10 @@ jest.mock("../intake-non-integrated-product.service", () => ({
},
}));
jest.mock("../native-image-generation.service", () => ({ enqueueNativeImageGeneration: jest.fn() }));
jest.mock("../../repositories/non-integrated-ai-white-background.repository", () => ({ saveWhiteBackgroundIntentWithJob: jest.fn() }));
jest.mock("../../repositories/user.repository", () => ({ userRepository: { findPermissionUserById: jest.fn() } }));
jest.mock("../provider-config.service", () => ({
providerConfigService: {
getActive: jest.fn(),
@ -93,11 +97,34 @@ jest.mock("@/app/intake/non-integrated/core/nonIntegratedAssetServer", () => ({
import { intakeNonIntegratedProductService } from "../intake-non-integrated-product.service";
import {
nonIntegratedAiWhiteBackgroundService,
completeNativeWhiteBackgroundGeneration,
failNativeWhiteBackgroundGeneration,
type NativeWhiteBackgroundCompletion,
} from "../non-integrated-ai-white-background.service";
import { providerConfigService } from "../provider-config.service";
import { systemConfigService } from "../system-config.service";
import { materialService } from "../material.service";
const mockedEnqueue = jest.requireMock("../native-image-generation.service").enqueueNativeImageGeneration as jest.Mock;
const mockedSaveIntent = jest.requireMock("../../repositories/non-integrated-ai-white-background.repository").saveWhiteBackgroundIntentWithJob as jest.Mock;
const mockedReadWorkerUser = jest.requireMock("../../repositories/user.repository").userRepository.findPermissionUserById as jest.Mock;
let resultUrlOverride: string | null = null;
// Business-rule tests explicitly finish persisted jobs; the real request returns before generation.
async function generateSessionAndFinish(...args: Parameters<typeof nonIntegratedAiWhiteBackgroundService.generateSession>) {
const start = mockedEnqueue.mock.calls.length;
const accepted = await nonIntegratedAiWhiteBackgroundService.generateSession(...args);
for (const [job] of mockedEnqueue.mock.calls.slice(start)) {
const completion = job.completion as NativeWhiteBackgroundCompletion;
await completeNativeWhiteBackgroundGeneration(completion, {
requestId: job.id, model: "test-model", generatedImageIds: [], generatedAssetIds: [], attempts: [],
images: [{ originalUrl: resultUrlOverride ?? `https://cdn.example.com/generated-${completion.viewKey === "back" ? "back" : "front"}.png` }],
});
}
const saved = mockedSavePayloadOptimistically.mock.calls.at(-1)?.[0] ?? accepted.product;
return { product: saved, session: readNonIntegratedAiWhiteBackgroundMetadata(saved.metadata).sessions.find((session) => session.id === accepted.session.id)! };
}
const mockedUploadHelpers = jest.requireMock(
"@/app/api/intake/non-integrated/uploadHelpers",
) as {
@ -395,6 +422,16 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
.spyOn(console, "warn")
.mockImplementation(() => undefined);
uploadTargetSequence = 0;
resultUrlOverride = null;
mockedReadWorkerUser.mockResolvedValue(authUser);
mockedEnqueue.mockReset();
mockedEnqueue.mockImplementation(async (_job, commit) => { await commit(async () => {}); });
mockedSaveIntent.mockReset();
mockedSaveIntent.mockImplementation(async (product, _updatedAt, _userId, persistJob) => {
await persistJob({});
mockedGetProductById.mockResolvedValue(product);
return true;
});
global.fetch = mockedFetch as unknown as typeof fetch;
@ -404,7 +441,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
async (product) => product as never,
);
mockedSavePayloadOptimistically.mockImplementation(
async (product) => product as never,
async (product) => { mockedGetProductById.mockResolvedValue(product as never); return product as never; },
);
mockedGetActiveProviderConfig.mockResolvedValue(null as never);
@ -492,17 +529,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-1",
images: [{ url: "https://cdn.example.com/generated-front.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-front.png") {
return {
ok: true,
@ -538,7 +564,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
const result =
await nonIntegratedAiWhiteBackgroundService.generateSession(
await generateSessionAndFinish(
"product-1",
{
styleKey: "K02",
@ -548,16 +574,10 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
authUser,
);
expect(mockedFetch).toHaveBeenCalledTimes(2);
const generateRequest = mockedFetch.mock.calls[0]?.[1] as
| RequestInit
| undefined;
expect(generateRequest?.headers).toMatchObject({
Authorization: "Bearer ai-key",
});
const generateFormData = generateRequest?.body as FormData | undefined;
expect(generateFormData?.get("images")).toBeTruthy();
expect(generateFormData?.get("imageSize")).toBe("2K");
expect(mockedFetch).toHaveBeenCalledTimes(1);
const generateInput = mockedEnqueue.mock.calls[0]?.[0].input;
expect(generateInput.referenceImage).toBeTruthy();
expect(generateInput.imageSize).toBe("2K");
const savedProduct = mockedSavePayloadOptimistically.mock.calls[0]?.[0] as
| NonIntegratedProductRecord
@ -628,7 +648,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
mockedGetProductById.mockResolvedValue(product as never);
await expect(
nonIntegratedAiWhiteBackgroundService.generateSession(
generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -642,7 +662,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
message: expect.any(String),
});
await expect(
nonIntegratedAiWhiteBackgroundService.generateSession(
generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -683,17 +703,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-auto-1",
images: [{ url: "https://cdn.example.com/generated-front.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-front.png") {
return {
ok: true,
@ -721,7 +730,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
const result = await nonIntegratedAiWhiteBackgroundService.generateSession(
const result = await generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -730,15 +739,12 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
authUser,
);
expect(mockedFetch).toHaveBeenCalledTimes(2);
const generateRequest = mockedFetch.mock.calls[0]?.[1] as
| RequestInit
| undefined;
const generateFormData = generateRequest?.body as FormData | undefined;
expect(generateFormData?.get("images")).toBeTruthy();
expect(generateFormData?.get("imageCount")).toBe("1");
expect(String(generateFormData?.get("prompt"))).toContain("胡桃色");
expect(String(generateFormData?.get("negativePrompt"))).toContain(
expect(mockedFetch).toHaveBeenCalledTimes(1);
const generateInput = mockedEnqueue.mock.calls[0]?.[0].input;
expect(generateInput.referenceImage).toBeTruthy();
expect(generateInput.imageCount).toBe(1);
expect(String(generateInput.prompt)).toContain("胡桃色");
expect(String(generateInput.negativePrompt)).toContain(
"target color",
);
expect(result.session.targetColor).toBe("胡桃色");
@ -770,17 +776,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-rollback-1",
images: [{ url: "https://cdn.example.com/generated-front.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-front.png") {
return {
ok: true,
@ -810,7 +805,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
mockedGetProductById.mockResolvedValue(product as never);
await expect(
nonIntegratedAiWhiteBackgroundService.generateSession(
generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -821,7 +816,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
),
).rejects.toMatchObject({
code: ErrorCode.SYSTEM_INTERNAL,
message: "白底图生成结果保存失败,请稍后重试。",
message: "白底图结果暂时无法保存,请稍后重试归档。",
});
expect(mockedDeleteObject).toHaveBeenCalledWith(
@ -852,17 +847,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-replace-1",
images: [{ url: "https://cdn.example.com/generated-front.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-front.png") {
return {
ok: true,
@ -915,7 +899,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
await nonIntegratedAiWhiteBackgroundService.generateSession(
await generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -953,17 +937,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-color-1",
images: [{ url: "https://cdn.example.com/generated-front.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-front.png") {
return {
ok: true,
@ -991,7 +964,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
const result = await nonIntegratedAiWhiteBackgroundService.generateSession(
const result = await generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -1002,12 +975,9 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
authUser,
);
const generateRequest = mockedFetch.mock.calls[0]?.[1] as
| RequestInit
| undefined;
const generateFormData = generateRequest?.body as FormData | undefined;
expect(String(generateFormData?.get("prompt"))).toContain("黑胡桃色");
expect(String(generateFormData?.get("prompt"))).not.toContain(
const generateInput = mockedEnqueue.mock.calls[0]?.[0].input;
expect(String(generateInput.prompt)).toContain("黑胡桃色");
expect(String(generateInput.prompt)).not.toContain(
"target color: 胡桃色",
);
expect(result.session.targetColor).toBe("黑胡桃色");
@ -1036,17 +1006,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-borrow-1",
images: [{ url: "https://cdn.example.com/generated-front.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-front.png") {
return {
ok: true,
@ -1087,7 +1046,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
const result = await nonIntegratedAiWhiteBackgroundService.generateSession(
const result = await generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -1098,13 +1057,9 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
authUser,
);
const generateCall = mockedFetch.mock.calls.find(
([url]) => String(url) === "https://ai.example.com/api/external/image/generate",
);
const generateRequest = generateCall?.[1] as RequestInit | undefined;
const generateFormData = generateRequest?.body as FormData | undefined;
expect(generateFormData?.getAll("images")).toHaveLength(1);
expect(String(generateFormData?.get("prompt"))).toContain("light oak");
const generateInput = mockedEnqueue.mock.calls[0]?.[0].input;
expect([generateInput.referenceImage, ...(generateInput.additionalReferenceImages ?? [])]).toHaveLength(1);
expect(String(generateInput.prompt)).toContain("light oak");
expect(result.session.targetBarcode).toBe("BAR-002");
expect(result.session.sourceBarcode).toBe("BAR-001");
expect(result.session.targetColor).toBe("light oak");
@ -1138,17 +1093,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-structure-1",
images: [{ url: "https://cdn.example.com/generated-front.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-front.png") {
return {
ok: true,
@ -1198,7 +1142,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
const result = await nonIntegratedAiWhiteBackgroundService.generateSession(
const result = await generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -1209,28 +1153,24 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
authUser,
);
const generateCall = mockedFetch.mock.calls.find(
([url]) => String(url) === "https://ai.example.com/api/external/image/generate",
);
const generateRequest = generateCall?.[1] as RequestInit | undefined;
const generateFormData = generateRequest?.body as FormData | undefined;
expect(generateFormData?.getAll("images")).toHaveLength(3);
expect(String(generateFormData?.get("prompt"))).toContain(
const generateInput = mockedEnqueue.mock.calls[0]?.[0].input;
expect([generateInput.referenceImage, ...(generateInput.additionalReferenceImages ?? [])]).toHaveLength(3);
expect(String(generateInput.prompt)).toContain(
"The primary reference image is the front view",
);
expect(String(generateFormData?.get("prompt"))).toContain(
expect(String(generateInput.prompt)).toContain(
"additional structure reference images",
);
expect(String(generateFormData?.get("prompt"))).toContain(
expect(String(generateInput.prompt)).toContain(
"The final output must still be the front view",
);
expect(String(generateFormData?.get("prompt"))).toContain(
expect(String(generateInput.prompt)).toContain(
"Highest-priority operator correction",
);
expect(String(generateFormData?.get("prompt"))).toContain(
expect(String(generateInput.prompt)).toContain(
"overrides the target finish theme",
);
expect(String(generateFormData?.get("prompt"))).toContain(
expect(String(generateInput.prompt)).toContain(
"横隔板边缘保持平直,轮子不要缺失",
);
expect(result.session.structureReferenceFileIdsByView).toEqual({
@ -1264,17 +1204,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-back-refine-1",
images: [{ url: "https://cdn.example.com/generated-back.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-back.png") {
return {
ok: true,
@ -1304,7 +1233,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
await nonIntegratedAiWhiteBackgroundService.generateSession(
await generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -1315,27 +1244,23 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
authUser,
);
const generateCall = mockedFetch.mock.calls.find(
([url]) => String(url) === "https://ai.example.com/api/external/image/generate",
);
const generateRequest = generateCall?.[1] as RequestInit | undefined;
const generateFormData = generateRequest?.body as FormData | undefined;
expect(String(generateFormData?.get("prompt"))).toContain(
const generateInput = mockedEnqueue.mock.calls[0]?.[0].input;
expect(String(generateInput.prompt)).toContain(
"Strict camera lock: output a straight-on back view",
);
expect(String(generateFormData?.get("prompt"))).toContain(
expect(String(generateInput.prompt)).toContain(
"Do not show the front face",
);
expect(String(generateFormData?.get("prompt"))).toContain(
expect(String(generateInput.prompt)).toContain(
"The correction must never change the requested camera view",
);
expect(String(generateFormData?.get("negativePrompt"))).toContain(
expect(String(generateInput.negativePrompt)).toContain(
"not side view",
);
expect(String(generateFormData?.get("negativePrompt"))).toContain(
expect(String(generateInput.negativePrompt)).toContain(
"not three-quarter view",
);
expect(String(generateFormData?.get("negativePrompt"))).toContain(
expect(String(generateInput.negativePrompt)).toContain(
"not front face visible",
);
});
@ -1363,17 +1288,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-back-refine-2",
images: [{ url: "https://cdn.example.com/generated-back.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-back.png") {
return {
ok: true,
@ -1419,7 +1333,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
const result = await nonIntegratedAiWhiteBackgroundService.generateSession(
const result = await generateSessionAndFinish(
"product-1",
{
sessionId: "session-existing",
@ -1431,12 +1345,8 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
authUser,
);
const generateCall = mockedFetch.mock.calls.find(
([url]) => String(url) === "https://ai.example.com/api/external/image/generate",
);
const generateRequest = generateCall?.[1] as RequestInit | undefined;
const generateFormData = generateRequest?.body as FormData | undefined;
const prompt = String(generateFormData?.get("prompt"));
const generateInput = mockedEnqueue.mock.calls[0]?.[0].input;
const prompt = String(generateInput.prompt);
expect(prompt).toContain("上一轮修正要求");
expect(prompt).toContain("结构不对,看起来不是后视图而是侧视图");
expect(prompt).toContain("本轮追加修正要求");
@ -1472,17 +1382,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-front-refine-related",
images: [{ url: "https://cdn.example.com/generated-front.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-front.png") {
return {
ok: true,
@ -1529,7 +1428,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
const result = await nonIntegratedAiWhiteBackgroundService.generateSession(
const result = await generateSessionAndFinish(
"product-1",
{
sessionId: "session-existing",
@ -1541,12 +1440,8 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
authUser,
);
const generateCall = mockedFetch.mock.calls.find(
([url]) => String(url) === "https://ai.example.com/api/external/image/generate",
);
const generateRequest = generateCall?.[1] as RequestInit | undefined;
const generateFormData = generateRequest?.body as FormData | undefined;
const prompt = String(generateFormData?.get("prompt"));
const generateInput = mockedEnqueue.mock.calls[0]?.[0].input;
const prompt = String(generateInput.prompt);
expect(prompt).toContain("当前正视图修正要求:轮子是不对的。");
expect(prompt).toContain("同一目标条码其他视图的历史修正要求");
expect(prompt).toContain("只继承其中适用于整件产品的颜色");
@ -1581,17 +1476,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-back-refine-consistency",
images: [{ url: "https://cdn.example.com/generated-back.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-back.png") {
return {
ok: true,
@ -1639,7 +1523,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
await nonIntegratedAiWhiteBackgroundService.generateSession(
await generateSessionAndFinish(
"product-1",
{
sessionId: "session-existing",
@ -1651,13 +1535,9 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
authUser,
);
const generateCall = mockedFetch.mock.calls.find(
([url]) => String(url) === "https://ai.example.com/api/external/image/generate",
);
const generateRequest = generateCall?.[1] as RequestInit | undefined;
const generateFormData = generateRequest?.body as FormData | undefined;
expect(generateFormData?.getAll("images")).toHaveLength(3);
const prompt = String(generateFormData?.get("prompt"));
const generateInput = mockedEnqueue.mock.calls[0]?.[0].input;
expect([generateInput.referenceImage, ...(generateInput.additionalReferenceImages ?? [])]).toHaveLength(3);
const prompt = String(generateInput.prompt);
expect(prompt).toContain("existing generated white-background reference images");
expect(prompt).toContain("match the target barcode's approved finish color");
expect(prompt).toContain("Do not copy their camera angle");
@ -1687,17 +1567,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-infer-1",
images: [{ url: "https://cdn.example.com/generated-front.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-front.png") {
return {
ok: true,
@ -1759,7 +1628,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
mockedGetProductById.mockResolvedValue(product as never);
const result = await nonIntegratedAiWhiteBackgroundService.generateSession(
const result = await generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -1770,22 +1639,18 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
authUser,
);
const generateCall = mockedFetch.mock.calls.find(
([url]) => String(url) === "https://ai.example.com/api/external/image/generate",
);
const generateRequest = generateCall?.[1] as RequestInit | undefined;
const generateFormData = generateRequest?.body as FormData | undefined;
expect(generateFormData?.getAll("images")).toHaveLength(3);
expect(String(generateFormData?.get("prompt"))).toContain(
const generateInput = mockedEnqueue.mock.calls[0]?.[0].input;
expect([generateInput.referenceImage, ...(generateInput.additionalReferenceImages ?? [])]).toHaveLength(3);
expect(String(generateInput.prompt)).toContain(
"The requested final output is the front view",
);
expect(String(generateFormData?.get("prompt"))).toContain(
expect(String(generateInput.prompt)).toContain(
"The primary reference image is the back view",
);
expect(String(generateFormData?.get("prompt"))).toContain(
expect(String(generateInput.prompt)).toContain(
"same finish color, wood tone, saturation, brightness",
);
expect(String(generateFormData?.get("prompt"))).toContain(
expect(String(generateInput.prompt)).toContain(
"Keep shelf boards, front and back rail edges",
);
expect(result.session.generatedFileIdsByView).toEqual({
@ -1831,7 +1696,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
mockedGetProductById.mockResolvedValue(product as never);
await expect(
nonIntegratedAiWhiteBackgroundService.generateSession(
generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -1849,6 +1714,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
});
it("times out when the generated result image download hangs", async () => {
resultUrlOverride = "https://cdn.example.com/generated-timeout.png";
mockedGetActiveProviderConfig.mockResolvedValue({
baseUrl: "https://ai.example.com",
apiKey: "ai-key",
@ -1871,24 +1737,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-timeout-1",
images: [
{
originalUrl: "https://cdn.example.com/generated-timeout.png",
},
],
generatedImageIds: [],
generatedAssetIds: [],
attempts: [],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-timeout.png") {
const abortError = new Error("aborted");
abortError.name = "AbortError";
@ -1913,7 +1761,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
mockedGetProductById.mockResolvedValue(product as never);
await expect(
nonIntegratedAiWhiteBackgroundService.generateSession(
generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -1949,17 +1797,6 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
},
} as never;
}
if (url === "https://ai.example.com/api/external/image/generate") {
return {
ok: true,
json: async () => ({
data: {
requestId: "ai-request-retry-1",
images: [{ url: "https://cdn.example.com/generated-front.png" }],
},
}),
} as never;
}
if (url === "https://cdn.example.com/generated-front.png") {
return {
ok: true,
@ -1990,7 +1827,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
.mockResolvedValueOnce(null as never)
.mockImplementation(async (savedProduct) => savedProduct as never);
const result = await nonIntegratedAiWhiteBackgroundService.generateSession(
const result = await generateSessionAndFinish(
"product-1",
{
sessionId: "client-session-1",
@ -2027,7 +1864,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
mockedGetProductById.mockResolvedValue(product as never);
await expect(
nonIntegratedAiWhiteBackgroundService.generateSession(
generateSessionAndFinish(
"product-1",
{
styleKey: "K01",
@ -2687,4 +2524,136 @@ describe("nonIntegratedAiWhiteBackgroundService", () => {
}),
);
});
function prepareNativeGenerationProduct() {
const product = createProduct({ uploadFiles: [{
id: "source-positive", name: "正视图-K01.png", category: "产品实拍图",
dataUrl: "/api/intake/non-integrated/assets/source-positive.png", mimeType: "image/png",
photoGroup: "positive", productPhotoStyleKey: "K01",
}] });
mockedGetProductById.mockResolvedValue(product as never);
return product;
}
const nativeInput = { sessionId: "native-session", requestId: "native-request", styleKey: "K01", barcode: "BAR-001", requestedViewKeys: ["front" as const] };
const nativeResult = { requestId: "relay-task-1", model: "model", images: [{ originalUrl: "https://cdn.example.com/native.png" }], generatedImageIds: [], generatedAssetIds: [], attempts: [] };
it("returns a persisted pending view without waiting for Relay and atomically stores its job", async () => {
prepareNativeGenerationProduct();
const accepted = await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
expect(accepted.session.status).toBe("generating");
expect(accepted.session.generationTasksByView?.front).toMatchObject({ requestId: "native-request", status: "pending" });
expect(accepted.session.generatedFileIdsByView).toEqual({});
expect(mockedSaveIntent).toHaveBeenCalledTimes(1);
expect(mockedEnqueue).toHaveBeenCalledTimes(1);
expect(mockedUploadBufferToObjectStorage).not.toHaveBeenCalled();
expect(mockedFetch).not.toHaveBeenCalled();
const job = mockedEnqueue.mock.calls[0][0];
expect(job.completion.createdBy).toBe(authUser.id);
expect(job.completion).not.toHaveProperty("user");
});
it("reuses the persisted view for retried HTTP submissions instead of submitting another paid task", async () => {
prepareNativeGenerationProduct();
const accepted = await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
const retried = await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
expect(retried.session.generationTasksByView?.front?.taskId).toBe(accepted.session.generationTasksByView?.front?.taskId);
expect(mockedEnqueue).toHaveBeenCalledTimes(1);
});
it("resumes completion from the persisted job and ignores repeated completion after saving", async () => {
prepareNativeGenerationProduct();
await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
const completion = mockedEnqueue.mock.calls[0][0].completion;
mockedFetch.mockResolvedValue({ ok: true, arrayBuffer: async () => generatedImageBuffer, headers: { get: () => "image/png" } });
await completeNativeWhiteBackgroundGeneration(completion, nativeResult);
const saved = await mockedGetProductById("product-1", authUser);
expect(readNonIntegratedAiWhiteBackgroundMetadata(saved!.metadata).sessions[0].generationTasksByView?.front?.status).toBe("succeeded");
await completeNativeWhiteBackgroundGeneration(completion, nativeResult);
expect(mockedUploadBufferToObjectStorage).toHaveBeenCalledTimes(1);
expect(mockedSavePayloadOptimistically).toHaveBeenCalledTimes(1);
expect(mockedEnqueue).toHaveBeenCalledTimes(1);
});
it("does not create a second paid task for the same pending barcode view under another session", async () => {
prepareNativeGenerationProduct();
await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
await expect(nonIntegratedAiWhiteBackgroundService.generateSession("product-1", {
...nativeInput, sessionId: "another-session", requestId: "another-request",
}, authUser)).rejects.toMatchObject({ code: ErrorCode.VALIDATION_FAILED });
expect(mockedEnqueue).toHaveBeenCalledTimes(1);
});
it("returns the completed original request when its HTTP acknowledgement was lost instead of generating again", async () => {
prepareNativeGenerationProduct();
await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
const completion = mockedEnqueue.mock.calls[0][0].completion;
mockedFetch.mockResolvedValue({ ok: true, arrayBuffer: async () => generatedImageBuffer, headers: { get: () => "image/png" } });
await completeNativeWhiteBackgroundGeneration(completion, nativeResult);
const recovered = await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
expect(recovered.session.generationTasksByView?.front?.status).toBe("succeeded");
expect(recovered.session.generatedFileIdsByView.front).toBeTruthy();
expect(mockedEnqueue).toHaveBeenCalledTimes(1);
expect(mockedUploadBufferToObjectStorage).toHaveBeenCalledTimes(1);
});
it("does not overwrite a new product version or material change when an older task completes", async () => {
prepareNativeGenerationProduct();
await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
const completion = mockedEnqueue.mock.calls[0][0].completion;
const current = (await mockedGetProductById("product-1", authUser))!;
mockedGetProductById.mockResolvedValue({ ...current, skuItems: current.skuItems.map((sku) => ({ ...sku, versionNo: "V02", color: "新颜色" })) } as never);
await expect(completeNativeWhiteBackgroundGeneration(completion, nativeResult)).rejects.toMatchObject({ code: ErrorCode.VALIDATION_FAILED });
expect(mockedUploadBufferToObjectStorage).not.toHaveBeenCalled();
expect(mockedSavePayloadOptimistically).not.toHaveBeenCalled();
await failNativeWhiteBackgroundGeneration(completion, "产品已变更");
const failed = (await mockedGetProductById("product-1", authUser))!;
expect(failed.skuItems[0].versionNo).toBe("V02");
expect(readNonIntegratedAiWhiteBackgroundMetadata(failed.metadata).sessions[0].generationTasksByView?.front?.status).toBe("failed");
});
it("keeps the referenced output when the database commits but its acknowledgement is lost", async () => {
prepareNativeGenerationProduct();
await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
const completion = mockedEnqueue.mock.calls[0][0].completion;
mockedFetch.mockResolvedValue({ ok: true, arrayBuffer: async () => generatedImageBuffer, headers: { get: () => "image/png" } });
mockedSavePayloadOptimistically.mockImplementationOnce(async (product) => {
mockedGetProductById.mockResolvedValue(product as never);
throw new Error("connection lost after commit");
});
await expect(completeNativeWhiteBackgroundGeneration(completion, nativeResult)).rejects.toThrow("connection lost after commit");
expect(mockedDeleteObject).not.toHaveBeenCalled();
await completeNativeWhiteBackgroundGeneration(completion, nativeResult);
expect(mockedUploadBufferToObjectStorage).toHaveBeenCalledTimes(1);
expect(mockedEnqueue).toHaveBeenCalledTimes(1);
});
it("retains an uploaded output when the save outcome cannot be read back", async () => {
prepareNativeGenerationProduct();
await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
const completion = mockedEnqueue.mock.calls[0][0].completion;
mockedFetch.mockResolvedValue({ ok: true, arrayBuffer: async () => generatedImageBuffer, headers: { get: () => "image/png" } });
mockedSavePayloadOptimistically.mockImplementationOnce(async () => {
mockedGetProductById.mockRejectedValue(new Error("database offline"));
throw new Error("save outcome unknown");
});
await expect(completeNativeWhiteBackgroundGeneration(completion, nativeResult)).rejects.toThrow("save outcome unknown");
expect(mockedDeleteObject).not.toHaveBeenCalled();
});
it("merges a completed view into the latest product after a concurrent unrelated edit", async () => {
prepareNativeGenerationProduct();
await nonIntegratedAiWhiteBackgroundService.generateSession("product-1", nativeInput, authUser);
const completion = mockedEnqueue.mock.calls[0][0].completion;
mockedFetch.mockResolvedValue({ ok: true, arrayBuffer: async () => generatedImageBuffer, headers: { get: () => "image/png" } });
mockedSavePayloadOptimistically.mockImplementationOnce(async (candidate) => {
mockedGetProductById.mockResolvedValue({ ...candidate, designer: "另一人修改的设计师", metadata: (await mockedGetProductById("product-1", authUser))!.metadata } as never);
return null;
});
await completeNativeWhiteBackgroundGeneration(completion, nativeResult);
const saved = mockedSavePayloadOptimistically.mock.calls.at(-1)![0];
expect(saved.designer).toBe("另一人修改的设计师");
expect(readNonIntegratedAiWhiteBackgroundMetadata(saved.metadata).sessions[0].generationTasksByView?.front?.status).toBe("succeeded");
expect(mockedEnqueue).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,203 @@
/** @jest-environment node */
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
import {
createRelayImageTask,
getRelayImageTaskResult,
RelayImageTaskError,
type RelayImageTaskInput,
} from '../relay-image-task.client'
const config = { baseUrl: 'https://relay.example/', apiKey: 'test-only-api-key' }
const input: RelayImageTaskInput = {
model: 'image-model',
prompt: 'Keep the original furniture.\n',
aspectRatio: '16:9',
imageSize: '2K',
referenceImageUrls: ['https://assets.example/main.png', 'https://assets.example/material.png'],
}
const originalFetch = global.fetch
const fetchMock = jest.fn<typeof fetch>()
function response(status: number, body: unknown): Response {
return { status, json: async () => body } as Response
}
function accepted() {
return response(202, { id: 'task-1', object: 'extension.task', status: 'accepted' })
}
function ready(outputs: unknown[]) {
return response(200, {
id: 'task-1', object: 'extension.task.result', status: 'succeeded', ready: true,
result: { outputs },
})
}
beforeEach(() => {
fetchMock.mockReset()
global.fetch = fetchMock
})
afterEach(() => {
global.fetch = originalFetch
jest.useRealTimers()
})
describe('createRelayImageTask', () => {
it('submits native JSON with a fixed key, selected route and references in their original order', async () => {
fetchMock.mockResolvedValue(accepted())
const result = await createRelayImageTask({ ...config, routeOptionId: 'route-1' }, input, 'job-1-image-0')
expect(result).toEqual({ taskId: 'task-1' })
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('https://relay.example/extensions/v1/tasks')
expect(init).toMatchObject({
method: 'POST', redirect: 'error', cache: 'no-store',
headers: {
Authorization: 'Bearer test-only-api-key',
'Content-Type': 'application/json',
'Idempotency-Key': 'job-1-image-0',
'X-Relay-Route-Option': 'route-1',
},
})
expect(JSON.parse(init?.body as string)).toEqual({
model: 'image-model', prompt: input.prompt, resolution: '2K', aspect_ratio: '16:9',
file_urls: input.referenceImageUrls,
})
expect(input.referenceImageUrls).toEqual(['https://assets.example/main.png', 'https://assets.example/material.png'])
})
it('preserves the original prompt and appends the complete negative constraints', async () => {
fetchMock.mockResolvedValue(accepted())
const negativePrompt = ' Do not change the legs.\nNo extra handles. '
await createRelayImageTask(config, { ...input, negativePrompt }, 'job-1')
const body = JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)
expect(body.prompt).toBe(`${input.prompt}\n\nNegative constraints (must not appear in the generated image):\n${negativePrompt}`)
expect(body).not.toHaveProperty('negativePrompt')
expect(body).not.toHaveProperty('n')
expect(body).not.toHaveProperty('image_units')
})
it('does not auto-retry or generate a replacement key after a connection loss', async () => {
fetchMock.mockRejectedValueOnce(new Error('raw upstream URL and secret')).mockResolvedValueOnce(accepted())
await expect(createRelayImageTask(config, input, 'fixed-key')).rejects.toMatchObject({
name: 'RelayImageTaskError', code: 'network_error', retryable: true, outcomeUnknown: true,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
await createRelayImageTask(config, input, 'fixed-key')
expect(fetchMock.mock.calls.map(call => (call[1]?.headers as Record<string, string>)['Idempotency-Key']))
.toEqual(['fixed-key', 'fixed-key'])
})
it.each([429, 500, 502, 503])('marks HTTP %i retryable without exposing response text', async status => {
fetchMock.mockResolvedValue(response(status, { error: { message: config.apiKey, code: 'supplier-internal-secret' } }))
const error = await createRelayImageTask(config, input, 'fixed-key').catch(value => value)
expect(error).toBeInstanceOf(RelayImageTaskError)
expect(error).toMatchObject({ retryable: true, statusCode: status, code: 'http_error' })
expect(JSON.stringify(error)).not.toContain(config.apiKey)
expect(error.message).not.toContain('supplier-internal-secret')
})
it.each([400, 401, 403, 422])('marks explicit HTTP %i rejection terminal', async status => {
fetchMock.mockResolvedValue(response(status, { error: { code: 'invalid_request', message: 'raw error body' } }))
await expect(createRelayImageTask(config, input, 'fixed-key')).rejects.toMatchObject({
code: 'invalid_request', retryable: false, outcomeUnknown: false, statusCode: status,
})
})
it.each(['idempotency_in_progress', 'idempotency_indeterminate', 'request_outcome_unknown'])('preserves retry semantics for %s', async code => {
fetchMock.mockResolvedValue(response(409, { error: { code } }))
await expect(createRelayImageTask(config, input, 'fixed-key')).rejects.toMatchObject({ code, retryable: true })
})
it('does not retry an idempotency conflict under a different key', async () => {
fetchMock.mockResolvedValue(response(409, { error: { code: 'idempotency_conflict' } }))
await expect(createRelayImageTask(config, input, 'fixed-key')).rejects.toMatchObject({
code: 'idempotency_conflict', retryable: false,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it.each([{}, { id: 'task-1' }, { id: '../bad', object: 'extension.task' }])('treats malformed acceptance as uncertain', async body => {
fetchMock.mockResolvedValue(response(202, body))
await expect(createRelayImageTask(config, input, 'fixed-key')).rejects.toMatchObject({
code: 'invalid_response', retryable: true, outcomeUnknown: true,
})
})
it('accepts a pending-review task ID for subsequent polling', async () => {
fetchMock.mockResolvedValue(response(202, {
id: 'task-1', object: 'extension.task', status: 'pending_review', code: 'submission_pending_reconciliation',
}))
await expect(createRelayImageTask(config, input, 'fixed-key')).resolves.toEqual({ taskId: 'task-1' })
})
it.each(['http://relay.example/', 'https://name:password@relay.example/', 'https://relay.example/v1', 'https://relay.example/?secret=x'])('rejects unsafe or ambiguous base URL %s before sending a key', async baseUrl => {
await expect(createRelayImageTask({ ...config, baseUrl }, input, 'fixed-key')).rejects.toMatchObject({ retryable: false })
expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects insecure reference URLs, unsupported sizes and empty idempotency keys', async () => {
await expect(createRelayImageTask(config, { ...input, referenceImageUrls: ['http://assets.example/a.png'] }, 'fixed-key')).rejects.toMatchObject({ code: 'invalid_input' })
await expect(createRelayImageTask(config, { ...input, imageSize: '999K' }, 'fixed-key')).rejects.toMatchObject({ code: 'invalid_input' })
await expect(createRelayImageTask(config, input, '')).rejects.toMatchObject({ code: 'invalid_input' })
expect(fetchMock).not.toHaveBeenCalled()
})
it.each(['headers', 'body'])('bounds a stalled %s read to 30 seconds', async phase => {
jest.useFakeTimers()
if (phase === 'headers') fetchMock.mockImplementation(() => new Promise(() => {}))
else fetchMock.mockResolvedValue({ status: 202, json: () => new Promise(() => {}) } as Response)
const promise = createRelayImageTask(config, input, 'fixed-key')
const assertion = expect(promise).rejects.toMatchObject({ code: 'request_timeout', retryable: true, outcomeUnknown: true })
await jest.advanceTimersByTimeAsync(30_000)
await assertion
expect(fetchMock.mock.calls[0]![1]?.signal?.aborted).toBe(true)
})
})
describe('getRelayImageTaskResult', () => {
it.each(['queued', 'in_progress', 'finalizing'])('returns pending for %s instead of creating another task', async status => {
fetchMock.mockResolvedValue(response(202, { id: 'task-1', object: 'extension.task.result', ready: false, status }))
await expect(getRelayImageTaskResult(config, 'task-1')).resolves.toEqual({ status: 'pending' })
expect(fetchMock).toHaveBeenCalledWith('https://relay.example/extensions/v1/tasks/task-1/result', expect.objectContaining({ method: 'GET' }))
})
it('surfaces pending_review for the worker without exposing raw upstream details', async () => {
fetchMock.mockResolvedValue(response(202, {
id: 'task-1', object: 'extension.task.result', ready: false, status: 'pending_review',
code: 'billing_reconciliation_required', message: config.apiKey,
}))
await expect(getRelayImageTaskResult(config, 'task-1')).resolves.toEqual({ status: 'pending', needsReview: true })
})
it('normalizes native output images without fetching their URLs or forwarding the key', async () => {
fetchMock.mockResolvedValue(ready([{ type: 'image', asset_id: 'asset-1', url: 'https://assets.example/result.png?signature=public' }]))
await expect(getRelayImageTaskResult(config, 'task-1')).resolves.toEqual({
status: 'succeeded', images: [{ id: 'asset-1', originalUrl: 'https://assets.example/result.png?signature=public' }],
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it.each(['task_failed', 'task_timeout', 'task_cancelled', 'task_expired', 'content_policy_violation'])('maps confirmed terminal %s into a safe failure', async code => {
fetchMock.mockResolvedValue(response(409, { error: { code, message: config.apiKey } }))
const result = await getRelayImageTaskResult(config, 'task-1')
expect(result).toMatchObject({ status: 'failed', errorMessage: expect.any(String) })
expect(JSON.stringify(result)).not.toContain(config.apiKey)
})
it.each([{ outputs: [] }, { outputs: [{ type: 'image', url: 'http://assets.example/result.png' }] }, { outputs: [{ type: 'text', text: 'no image' }] }])('keeps invalid result output retryable, without declaring the task failed', async ({ outputs }) => {
fetchMock.mockResolvedValue(ready(outputs))
await expect(getRelayImageTaskResult(config, 'task-1')).rejects.toMatchObject({ code: 'invalid_response', retryable: true })
})
it('does not treat a 404 query response as a generated task failure', async () => {
fetchMock.mockResolvedValue(response(404, { error: { code: 'not_found' } }))
await expect(getRelayImageTaskResult(config, 'task-1')).rejects.toBeInstanceOf(RelayImageTaskError)
})
it('rejects a result for a different task', async () => {
fetchMock.mockResolvedValue(response(202, { id: 'other-task', object: 'extension.task.result', ready: false }))
await expect(getRelayImageTaskResult(config, 'task-1')).rejects.toMatchObject({ code: 'invalid_response' })
})
})

View File

@ -0,0 +1,158 @@
/** @jest-environment node */
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
import { resolveRelayImageTaskContract } from '../relay-image-task.contract'
import type { RelayImageTaskInput } from '../relay-image-task.client'
const config = { baseUrl: 'https://relay.example/', apiKey: 'test-only-api-key' }
const input: RelayImageTaskInput = {
model: 'image-model', prompt: 'Keep this furniture', aspectRatio: '16:9', imageSize: '2K',
referenceImageUrls: ['https://assets.example/a.png', 'https://assets.example/b.png'],
}
const originalFetch = global.fetch
const fetchMock = jest.fn<typeof fetch>()
function schema(maxItems = 14): Record<string, unknown> {
return {
type: 'object', additionalProperties: false, required: ['model', 'prompt'],
properties: {
model: { type: 'string' }, prompt: { type: 'string', maxLength: 32000 },
aspect_ratio: { type: 'string', allOf: [{ enum: ['1:1', '16:9'] }] },
resolution: { type: 'string', allOf: [{ enum: ['1K', '2K', '4K'] }, { enum: ['2K', '4K'] }] },
file_urls: {
type: 'array', allOf: [
{ type: 'array', maxItems: 16, items: { type: 'string', format: 'uri', pattern: '^https://' } },
{ type: 'array', maxItems },
],
},
},
anyOf: [
{ required: ['prompt'], properties: { prompt: { minLength: 1 } } },
{ required: ['file_urls'], properties: { file_urls: { minItems: 1 } } },
],
}
}
function invocation(routeOptionId?: string, inputSchema = schema()): Record<string, unknown> {
return {
method: 'POST', path: '/extensions/v1/tasks', content_type: 'application/json', response_mode: 'async',
headers: routeOptionId ? { 'X-Relay-Route-Option': routeOptionId } : {}, input_schema: inputSchema,
}
}
function catalog(options: { invocation: Record<string, unknown>; is_default?: boolean; available?: boolean }[], merged = invocation()): unknown {
return { data: { sections: [{ models: [{ id: 'image-model', invocations: [merged], service_offers: [{
available: true, invocation: merged, route_options: options,
}] }] }] } }
}
function serveCatalog(publicCatalog: unknown, authorizedModels: unknown = { data: [{ id: 'image-model' }] }) {
fetchMock.mockImplementation(async url => ({
status: 200,
json: async () => String(url).endsWith('/v1/models') ? authorizedModels : publicCatalog,
} as Response))
}
beforeEach(() => {
fetchMock.mockReset()
global.fetch = fetchMock
})
afterEach(() => { global.fetch = originalFetch })
describe('resolveRelayImageTaskContract', () => {
it('checks model authorization with the Key and reads public contracts anonymously', async () => {
serveCatalog(catalog([{ invocation: invocation('route-1'), is_default: true }]))
await expect(resolveRelayImageTaskContract(config, input)).resolves.toEqual({ routeOptionId: 'route-1' })
expect(fetchMock).toHaveBeenCalledTimes(2)
const modelRead = fetchMock.mock.calls.find(call => String(call[0]).endsWith('/v1/models'))!
const catalogRead = fetchMock.mock.calls.find(call => String(call[0]).endsWith('/api/v2/catalog/models'))!
expect(modelRead[1]).toMatchObject({ method: 'GET', headers: { Authorization: 'Bearer test-only-api-key' }, redirect: 'error' })
expect(catalogRead[1]?.headers).not.toHaveProperty('Authorization')
expect(fetchMock.mock.calls.every(call => call[1]?.method === 'GET')).toBe(true)
})
it('requires current Key authorization even when a public contract exists', async () => {
serveCatalog(catalog([{ invocation: invocation('route-1') }]), { data: [{ id: 'other-model' }] })
await expect(resolveRelayImageTaskContract(config, input)).rejects.toMatchObject({ code: 'model_not_authorized', retryable: false })
})
it('uses a compatible concrete alternative when the default excludes the requested resolution', async () => {
const incompatible = schema()
;(incompatible.properties as Record<string, unknown>).resolution = { type: 'string', enum: ['1K'] }
serveCatalog(catalog([
{ invocation: invocation('route-low', incompatible), is_default: true },
{ invocation: invocation('route-high') },
]))
await expect(resolveRelayImageTaskContract(config, input)).resolves.toEqual({ routeOptionId: 'route-high' })
})
it('never silently replaces an explicitly selected incompatible route', async () => {
serveCatalog(catalog([
{ invocation: invocation('route-small', schema(1)) },
{ invocation: invocation('route-large') },
]))
await expect(resolveRelayImageTaskContract({ ...config, routeOptionId: 'route-small' }, input)).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
await expect(resolveRelayImageTaskContract({ ...config, routeOptionId: 'missing' }, input)).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
})
it('honors allOf reference limits instead of the looser outer schema', async () => {
serveCatalog(catalog([{ invocation: invocation('route-1', schema(14)) }]))
const references = Array.from({ length: 15 }, (_, index) => `https://assets.example/${index}.png`)
await expect(resolveRelayImageTaskContract(config, { ...input, referenceImageUrls: references })).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
serveCatalog(catalog([{ invocation: invocation('route-2', schema(16)) }]))
await expect(resolveRelayImageTaskContract(config, { ...input, referenceImageUrls: references })).resolves.toEqual({ routeOptionId: 'route-2' })
})
it('rejects unsupported aspect ratio, missing reference support and undeclared required parameters', async () => {
serveCatalog(catalog([{ invocation: invocation('route-1') }]))
await expect(resolveRelayImageTaskContract(config, { ...input, aspectRatio: '7:5' })).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
const noReferences = schema()
delete (noReferences.properties as Record<string, unknown>).file_urls
serveCatalog(catalog([{ invocation: invocation('route-1', noReferences) }]))
await expect(resolveRelayImageTaskContract(config, input)).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
serveCatalog(catalog([{ invocation: invocation('route-1', { ...schema(), required: ['model', 'vendor_required_field'] }) }]))
await expect(resolveRelayImageTaskContract(config, input)).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
})
it('validates the appended negative constraints against the published prompt limit', async () => {
const shortPrompt = schema()
;(shortPrompt.properties as Record<string, unknown>).prompt = { type: 'string', maxLength: 30 }
serveCatalog(catalog([{ invocation: invocation('route-1', shortPrompt) }]))
await expect(resolveRelayImageTaskContract(config, input)).resolves.toEqual({ routeOptionId: 'route-1' })
await expect(resolveRelayImageTaskContract(config, { ...input, negativePrompt: 'Do not add handles' })).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
})
it('rejects unknown validation requirements and malformed schemas conservatively', async () => {
serveCatalog(catalog([{ invocation: invocation('route-1', { ...schema(), if: { required: ['model'] }, then: { required: ['unknown'] } }) }]))
await expect(resolveRelayImageTaskContract(config, input)).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
})
it('does not escape incompatible concrete routes through a broad merged invocation', async () => {
serveCatalog(catalog([{ invocation: invocation('route-1', schema(1)) }], invocation(undefined, schema(16))))
await expect(resolveRelayImageTaskContract(config, input)).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
})
it('does not select unavailable options', async () => {
serveCatalog(catalog([
{ invocation: invocation('offline'), available: false, is_default: true },
{ invocation: invocation('available') },
]))
await expect(resolveRelayImageTaskContract(config, input)).resolves.toEqual({ routeOptionId: 'available' })
})
it('does not bypass all unavailable concrete options with the aggregate model contract', async () => {
serveCatalog(catalog([{ invocation: invocation('offline'), available: false }]))
await expect(resolveRelayImageTaskContract(config, input)).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
})
it('accepts a model with a single unpinned native contract when no route options are published', async () => {
serveCatalog(catalog([], invocation()))
await expect(resolveRelayImageTaskContract(config, input)).resolves.toEqual({})
})
it('requires the async JSON endpoint instead of treating synchronous images as native tasks', async () => {
const sync = { ...invocation('sync'), path: '/v1/images/generations', response_mode: 'sync' }
serveCatalog(catalog([{ invocation: sync }], sync))
await expect(resolveRelayImageTaskContract(config, input)).rejects.toMatchObject({ code: 'model_parameters_unsupported' })
})
})

View File

@ -5,40 +5,30 @@ import {
IntakeAiRenderStorageClass,
} from '@prisma/client'
import { nanoid } from 'nanoid'
import { prisma } from '@/lib/prisma'
import {
createObjectReadUrl,
uploadBufferToObjectStorageWithMetadata,
} from '@/lib/storage'
import { createHash } from 'node:crypto'
import { assistedRenderIdForRequest, nativeRenderRequestFingerprint, assertNativeRenderRequestMatches } from './native-render-request'
import { nativeRenderRepository } from '@/server/repositories/native-render.repository'
import { AppError, ErrorCode } from '@/server/core/errors'
import { enqueueNativeImageGeneration } from './native-image-generation.service'
import { createObjectReadUrl } from '@/lib/storage'
import type { AuthUser } from '@/server/core/types'
import { aiRenderWorkbenchService } from './ai-render-workbench.service'
import { intakeAiAssetService } from './intake-ai-asset.service'
import {
cleanWorkbenchResultBackground,
shouldCleanWorkbenchResultBackground,
} from './assisted-assembly-ai-render.background'
import {
buildEnhancedMaterialMappingPromptSuffix,
extractEnhancedMaterialEntriesFromSnapshot,
type MaterialTextureEntry,
} from './assisted-assembly-ai-render.materials'
import {
normalizeResultImages,
toAiRenderDTO,
type AssistedAssemblyAiRenderDTO,
type AssistedAssemblyAiRenderImageDTO,
} from './assisted-assembly-ai-render.records'
import {
fetchImageBuffer,
getTemporaryRenderExpiresAt,
normalizeWorkbenchResultOutputSize,
uploadTemporaryWorkbenchReferenceImage,
uploadTemporaryWorkbenchResultImage,
} from './assisted-assembly-ai-render.temp-storage'
import {
persistPermanentIntakeResultImages,
uploadPermanentIntakeReferenceImage,
} from './assisted-assembly-ai-render.permanent-storage'
import { uploadPermanentIntakeReferenceImage } from './assisted-assembly-ai-render.permanent-storage'
import {
decodeReferenceImageDataUrl,
decodeReferenceImageDataUrls,
@ -54,14 +44,12 @@ import {
normalizeWorkbenchRequestImageSize,
trimToNull,
} from './assisted-assembly-ai-render.validation'
import {
callExternalImageGenerateApi,
getExternalImageApiConfig,
} from './external-image-generate.service'
import { getExternalImageApiConfig } from './external-image-generate.service'
type MaterialReferenceDeliveryMode = 'signed-url' | 'file-upload' | 'mixed' | 'disabled'
export interface CreateAssistedAssemblyAiRenderInput {
requestId?: string
prompt: string
negativePrompt?: string | null
imageCount?: number | null
@ -234,7 +222,7 @@ function buildAiRenderParams(params: {
nodeNames: string[]
instruction: string
}>
}): Prisma.InputJsonValue {
}): Prisma.InputJsonObject {
return {
imageCount: params.imageCount,
aspectRatio: params.aspectRatio,
@ -258,52 +246,6 @@ function buildAiRenderParams(params: {
}
}
async function persistTemporaryWorkbenchResultImages(params: {
images: AssistedAssemblyAiRenderImageDTO[]
createdBy: string
mode?: string | null
}): Promise<AssistedAssemblyAiRenderImageDTO[]> {
const expiresAt = getTemporaryRenderExpiresAt()
const storedImages: AssistedAssemblyAiRenderImageDTO[] = []
for (let index = 0; index < params.images.length; index += 1) {
const image = params.images[index]!
const sourceUrl = trimToNull(image.originalUrl)
if (!sourceUrl) continue
const fetchedImage = await fetchImageBuffer(sourceUrl)
const cleanedImageBuffer = shouldCleanWorkbenchResultBackground(params.mode)
? await cleanWorkbenchResultBackground(fetchedImage)
: fetchedImage
const imageBuffer = await normalizeWorkbenchResultOutputSize({
buffer: cleanedImageBuffer.buffer,
extension: cleanedImageBuffer.extension,
})
const upload = await uploadTemporaryWorkbenchResultImage({
buffer: imageBuffer.buffer,
contentType: imageBuffer.contentType,
extension: imageBuffer.extension,
createdBy: params.createdBy,
index,
})
storedImages.push({
...(image.id !== undefined ? { id: image.id } : {}),
originalUrl: upload.url,
thumbnailUrl: upload.url,
imageSize:
imageBuffer.width && imageBuffer.height
? `${imageBuffer.width}x${imageBuffer.height}`
: image.imageSize ?? null,
storageKey: upload.storageKey,
expiresAt,
temporary: true,
})
}
return storedImages
}
export async function createAssistedAssemblyAiRender(
input: CreateAssistedAssemblyAiRenderInput,
user: AuthUser,
@ -328,7 +270,15 @@ export async function createAssistedAssemblyAiRender(
? await aiRenderWorkbenchService.getSkuForRender(legacyWorkbenchSkuId, user)
: null
const isCatalogRender = Boolean(intakeSku || workbenchSku)
const renderId = nanoid(24)
const requestId = trimToNull(input.requestId)
if (requestId && !/^[a-zA-Z0-9_-]{8,120}$/.test(requestId)) throw new AppError(ErrorCode.VALIDATION_FAILED, 'AI 请求标识无效')
const renderId = requestId ? assistedRenderIdForRequest(createdBy, requestId) : nanoid(24)
const requestFingerprint = nativeRenderRequestFingerprint(input)
const existing = await nativeRenderRepository.find(renderId)
if (existing) {
assertNativeRenderRequestMatches(existing, createdBy, requestFingerprint)
return toAiRenderDTO(existing, user.name)
}
imageSize = normalizeWorkbenchRequestImageSize(imageSize, isCatalogRender)
const referenceImage = decodeReferenceImageDataUrl(input.referenceImageDataUrl)
const structureReferenceImages = isCatalogRender
@ -358,8 +308,7 @@ export async function createAssistedAssemblyAiRender(
const structureReferenceExpiresAt = getTemporaryRenderExpiresAt()
let referenceUpload: { url: string; storageKey: string } | null = null
const buildCurrentParams = () =>
buildAiRenderParams({
const buildCurrentParams = () => ({ requestId, requestFingerprint, ...buildAiRenderParams({
imageCount,
aspectRatio,
imageSize,
@ -374,7 +323,7 @@ export async function createAssistedAssemblyAiRender(
...(materialPartAnnotations.length > 0
? { materialPartAnnotations }
: {}),
})
}) })
const buildBaseCreateData = (referenceImageUrl: string) => ({
id: renderId,
@ -408,21 +357,16 @@ export async function createAssistedAssemblyAiRender(
contentType: referenceImage.contentType,
extension: referenceImage.extension,
skuItemId: intakeSku.id,
renderId,
renderId: `${renderId}-${createHash('sha256').update(referenceImage.buffer).digest('hex').slice(0, 16)}`,
mode,
})
: workbenchSku
? await uploadTemporaryWorkbenchReferenceImage({
: await uploadTemporaryWorkbenchReferenceImage({
buffer: referenceImage.buffer,
contentType: referenceImage.contentType,
extension: referenceImage.extension,
createdBy,
renderId: `${renderId}-${createHash('sha256').update(referenceImage.buffer).digest('hex').slice(0, 16)}`,
})
: await uploadBufferToObjectStorageWithMetadata(
referenceImage.buffer,
`assisted-assembly/ai-renders/${createdBy}/${Date.now()}-${nanoid(8)}.${referenceImage.extension}`,
referenceImage.contentType,
)
for (let index = 0; index < structureReferenceImages.length; index += 1) {
const image = structureReferenceImages[index]!
@ -432,6 +376,7 @@ export async function createAssistedAssemblyAiRender(
extension: image.extension,
createdBy,
index,
renderId: `${renderId}-${createHash('sha256').update(image.buffer).digest('hex').slice(0, 16)}`,
})
structureReferenceUploads.push({
url: upload.url,
@ -443,14 +388,12 @@ export async function createAssistedAssemblyAiRender(
const config = await getExternalImageApiConfig()
if (!config.enabled || !config.baseUrl || !config.apiKey) {
const record = await prisma.assistedAssemblyAiRender.create({
data: {
const record = await nativeRenderRepository.create({
...buildBaseCreateData(referenceUpload.url),
params: buildCurrentParams(),
status: AssistedAssemblyAiRenderStatus.FAILED,
errorMessage: '辅助装配 AI 生图服务未启用或未配置',
durationMs: Date.now() - startedAt,
},
})
return toAiRenderDTO(record, user.name)
}
@ -481,7 +424,9 @@ export async function createAssistedAssemblyAiRender(
...materialReferenceDelivery.additionalReferenceImages,
]
const result = await callExternalImageGenerateApi({
await enqueueNativeImageGeneration({
id: `render:${renderId}`, createdBy,
input: {
prompt: enhancedPrompt,
negativePrompt,
imageCount,
@ -498,54 +443,28 @@ export async function createAssistedAssemblyAiRender(
? materialReferenceDelivery.referenceImageUrls
: undefined,
disabledMessage: '辅助装配 AI 生图服务未启用或未配置',
})
const resultImages = intakeSku
? await persistPermanentIntakeResultImages({
images: normalizeResultImages(result.images),
skuItemId: intakeSku.id,
renderId,
mode,
})
: workbenchSku
? await persistTemporaryWorkbenchResultImages({
images: normalizeResultImages(result.images),
createdBy,
mode,
})
: normalizeResultImages(result.images)
const status =
resultImages.length > 0
? AssistedAssemblyAiRenderStatus.SUCCEEDED
: AssistedAssemblyAiRenderStatus.FAILED
const record = await prisma.assistedAssemblyAiRender.create({
data: {
...buildBaseCreateData(referenceUpload.url),
params: buildCurrentParams(),
status,
externalRequestId: result.requestId,
resultImages: resultImages as unknown as Prisma.InputJsonValue,
attempts: result.attempts as unknown as Prisma.InputJsonValue,
errorMessage: status === AssistedAssemblyAiRenderStatus.FAILED ? '上游未返回可用图片' : null,
durationMs: Date.now() - startedAt,
},
})
completion: { kind: 'render', renderId, source: 'assisted' },
}, (persistJob) => nativeRenderRepository.createWithJob(buildBaseCreateData(referenceUpload!.url), persistJob))
const record = await nativeRenderRepository.find(renderId)
if (!record) throw new Error('AI 任务记录未保存')
return toAiRenderDTO(record, user.name)
} catch (error) {
if (!referenceUpload) {
throw error
}
const record = await prisma.assistedAssemblyAiRender.create({
data: {
const existing = await nativeRenderRepository.find(renderId)
if (existing) {
assertNativeRenderRequestMatches(existing, createdBy, requestFingerprint)
// A concurrent duplicate must never overwrite a successfully queued task.
return toAiRenderDTO(existing, user.name)
}
const record = await nativeRenderRepository.create({
...buildBaseCreateData(referenceUpload.url),
params: buildCurrentParams(),
status: AssistedAssemblyAiRenderStatus.FAILED,
errorMessage: error instanceof Error ? error.message : 'AI 生图失败',
errorMessage: error instanceof Error ? error.message : 'AI 生图请求失败',
durationMs: Date.now() - startedAt,
},
})
return toAiRenderDTO(record, user.name)
}

View File

@ -1,3 +1,5 @@
import { assistedRenderIdForRequest } from './native-render-request'
import { nativeRenderRepository } from '@/server/repositories/native-render.repository'
import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import type { AuthUser } from '@/server/core/types'
@ -52,9 +54,8 @@ async function cleanupExpiredWorkbenchRenderFiles(): Promise<void> {
for (;;) {
const candidates = await prisma.assistedAssemblyAiRender.findMany({
where: {
workbenchSkuId: {
not: null,
},
storageClass: 'TEMPORARY',
status: { not: 'PENDING' },
OR: [
{ referenceImageUrl: { contains: WORKBENCH_RENDER_STORAGE_PREFIX } },
{ referenceImageUrl: { contains: WORKBENCH_RENDER_STORAGE_ENCODED_PREFIX } },
@ -109,6 +110,12 @@ async function cleanupExpiredWorkbenchRenderFilesSafely(): Promise<void> {
}
export class AssistedAssemblyAiRenderService {
async findForRequest(requestId: string, user: AuthUser): Promise<AssistedAssemblyAiRenderDTO | null> {
const record = await nativeRenderRepository.find(assistedRenderIdForRequest(user.id, requestId))
if (!record || record.createdBy !== user.id) return null
return this.getForUser(record.id, user)
}
async listForUser(user: AuthUser, options?: { limit?: number }): Promise<AssistedAssemblyAiRenderDTO[]> {
await cleanupExpiredWorkbenchRenderFilesSafely()
const createdBy = normalizeRequiredText(user.id, '闁活潿鍔嶉崺?ID')
@ -189,6 +196,7 @@ export class AssistedAssemblyAiRenderService {
select: {
id: true,
createdBy: true,
status: true,
workbenchSkuId: true,
intakeSkuItemId: true,
referenceImageUrl: true,
@ -247,6 +255,10 @@ export class AssistedAssemblyAiRenderService {
}
}
if (record.status === 'PENDING') {
throw new AppError(ErrorCode.VALIDATION_FAILED, '图片仍在生成,请等待任务结束后再删除')
}
const deleteResults = await deleteRenderStorageFiles(record)
logWorkbenchRenderStorageDeleteFailures('delete-record', deleteResults, {
recordId: record.id,

View File

@ -129,9 +129,11 @@ function buildTemporaryRenderStorageKey(params: {
kind: 'references' | 'results' | 'detail-references'
extension: string
index?: number
renderId?: string
}): string {
const indexPart = typeof params.index === 'number' ? `-${params.index + 1}` : ''
return `${WORKBENCH_RENDER_STORAGE_PREFIX}/${params.createdBy}/${params.kind}/${Date.now()}-${nanoid(8)}${indexPart}.${params.extension}`
const name = params.renderId ? encodeURIComponent(params.renderId) : `${Date.now()}-${nanoid(8)}`
return `${WORKBENCH_RENDER_STORAGE_PREFIX}/${params.createdBy}/${params.kind}/${name}${indexPart}.${params.extension}`
}
function resolveLocalWorkbenchRenderAbsolutePath(storagePath: string): string {
@ -191,12 +193,14 @@ async function uploadTemporaryWorkbenchImage(params: {
createdBy: string
kind: 'references' | 'results' | 'detail-references'
index?: number
renderId?: string
}): Promise<TemporaryWorkbenchStoredImage> {
const storagePath = buildTemporaryRenderStorageKey({
createdBy: params.createdBy,
kind: params.kind,
extension: params.extension,
index: params.index,
renderId: params.renderId,
})
if (shouldUseLocalWorkbenchRenderStorage()) {
@ -521,6 +525,7 @@ export async function uploadTemporaryWorkbenchReferenceImage(params: {
extension: string
createdBy: string
index?: number
renderId?: string
}): Promise<TemporaryWorkbenchStoredImage> {
return uploadTemporaryWorkbenchImage({
...params,
@ -534,6 +539,7 @@ export async function uploadTemporaryWorkbenchResultImage(params: {
extension: string
createdBy: string
index?: number
renderId?: string
}): Promise<TemporaryWorkbenchStoredImage> {
return uploadTemporaryWorkbenchImage({
...params,

View File

@ -6,12 +6,16 @@ import type { DiyCatalogDTO } from '@/lib/diy/types'
import type { AuthUser } from '@/server/core/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import { diyAiRenderRepository } from '@/server/repositories/diy-ai-render.repository'
import { callExternalImageGenerateApi, getExternalImageApiConfig } from './external-image-generate.service'
import { getExternalImageApiConfig } from './external-image-generate.service'
import { decodeReferenceImageDataUrl, decodeReferenceImageDataUrls } from './assisted-assembly-ai-render.validation'
import { uploadDiyAiReference, storeDiyAiResults, discardDiyAiUploads } from './diy-ai-render.storage'
import { uploadDiyAiReference, discardDiyAiUploads } from './diy-ai-render.storage'
import { nativeRenderRepository } from '@/server/repositories/native-render.repository'
import { enqueueNativeImageGeneration } from './native-image-generation.service'
import { nativeRenderRequestFingerprint, assertNativeRenderRequestMatches } from './native-render-request'
const json = (value: unknown) => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
export async function createDiyAiRender(input: DiyAiRenderInput, user: AuthUser, catalog: DiyCatalogDTO) {
export async function createDiyAiRender(input: DiyAiRenderInput, user: AuthUser, catalog: DiyCatalogDTO, requestFingerprint = nativeRenderRequestFingerprint(input)) {
const config = await getExternalImageApiConfig('ai-render')
if (!config.enabled || !config.baseUrl || !config.apiKey || !config.model) throw new AppError(ErrorCode.VALIDATION_FAILED, 'AI 生图服务未启用或未配置,请在 AI 出图中配置')
const reference = decodeReferenceImageDataUrl(input.referenceImageDataUrl)
@ -30,45 +34,28 @@ export async function createDiyAiRender(input: DiyAiRenderInput, user: AuthUser,
? 'Use a pure white seamless background, soft studio lighting and a tight natural contact shadow. No room, floor texture, UI, grid, selection outlines, green targets or material swatches. '
: 'Use the scene request only for the surrounding room and lighting. Do not alter the furniture, current composition, camera angle or door artwork. '
const prompt = (lock + modeLock + input.prompt).slice(0, 5000)
const started = Date.now(), keys: string[] = []
const params = { source: 'diy', name: input.name, schemeCode: input.schemeCode || null, compositionKey, userPrompt: input.userPrompt,
const keys: string[] = []
const params = { requestFingerprint, source: 'diy', name: input.name, schemeCode: input.schemeCode || null, compositionKey, userPrompt: input.userPrompt,
imageCount: input.imageCount, aspectRatio: input.aspectRatio, imageSize: input.imageSize, mode: input.mode, stage: 'generating' }
let row
const data = { id: input.requestId, createdBy: user.id, prompt, negativePrompt: input.negativePrompt || null,
outputMode: input.mode === 'scene' ? 'SCENE' as const : 'WHITE_BACKGROUND' as const, storageClass: 'PERMANENT' as const, params: json(params),
workspaceSnapshot: json({ source: 'diy', snapshot: input.snapshot, cameraView: input.cameraView }), assetSnapshot: json(assets), status: 'PENDING' as const }
try {
row = await diyAiRenderRepository.create({ id: input.requestId, createdBy: user.id, prompt, negativePrompt: input.negativePrompt || null,
outputMode: input.mode === 'scene' ? 'SCENE' : 'WHITE_BACKGROUND', storageClass: 'PERMANENT', params: json(params),
workspaceSnapshot: json({ source: 'diy', snapshot: input.snapshot, cameraView: input.cameraView }), assetSnapshot: json(assets), status: 'PENDING' })
} catch (error) {
// Retrying the same browser request must never charge for a second generation.
const existing = await diyAiRenderRepository.findOwned(input.requestId, user.id)
if (existing) return existing
throw error
}
let referenceLinked = false
let updatingResults = false
try {
const upload = await uploadDiyAiReference(reference, user.id, row.id); keys.push(upload.storageKey)
await diyAiRenderRepository.update(row.id, { referenceImageUrl: upload.url })
referenceLinked = true
const result = await callExternalImageGenerateApi({ serviceType: 'ai-render', prompt, negativePrompt: input.negativePrompt,
const upload = await uploadDiyAiReference(reference, user.id, input.requestId); keys.push(upload.storageKey)
await enqueueNativeImageGeneration({ id: `render:${input.requestId}`, createdBy: user.id,
input: { serviceType: 'ai-render', prompt, negativePrompt: input.negativePrompt,
imageCount: input.imageCount, aspectRatio: input.aspectRatio, imageSize: input.imageSize,
referenceImage: { ...reference, fileName: `diy-current-view.${reference.extension}` },
additionalReferenceImages: structures.map((image,index) => ({ ...image, fileName: `same-view-structure-${index + 1}.${image.extension}` })) })
if (!result.images.length) throw new Error('AI 未返回图片,请稍后重试')
const images = await storeDiyAiResults(result.images, user.id, row.id, keys)
updatingResults = true
const completed = await diyAiRenderRepository.update(row.id, { status: 'SUCCEEDED', resultImages: json(images),
externalRequestId: result.requestId, durationMs: Date.now() - started, params: json({ ...params, stage: 'succeeded' }) })
return completed
additionalReferenceImages: structures.map((image,index) => ({ ...image, fileName: `same-view-structure-${index + 1}.${image.extension}` })) },
completion: { kind: 'render', renderId: input.requestId, source: 'diy' },
}, (persistJob) => nativeRenderRepository.createWithJob({ ...data, referenceImageUrl: upload.url }, persistJob))
const row = await diyAiRenderRepository.findOwned(input.requestId, user.id)
if (!row) throw new Error('AI 任务记录未保存')
return row
} catch (error) {
if (updatingResults) {
// A transport error may arrive after the database committed; never remove referenced results.
const committed = await diyAiRenderRepository.findOwned(row.id, user.id)
if (committed?.status === 'SUCCEEDED') return committed
}
// A failed history record retains its reference. Only unattached result uploads are discarded.
await discardDiyAiUploads(keys.slice(referenceLinked ? 1 : 0))
return diyAiRenderRepository.update(row.id, { status: 'FAILED', errorMessage: error instanceof Error ? error.message : 'DIY 出图失败',
durationMs: Date.now() - started, params: json({ ...params, stage: 'failed' }) })
const existing = await diyAiRenderRepository.findOwned(input.requestId, user.id)
if (existing) { assertNativeRenderRequestMatches(existing, user.id, requestFingerprint); return existing }
await discardDiyAiUploads(keys)
throw error
}
}

View File

@ -1,3 +1,4 @@
import { nativeRenderRequestFingerprint, assertNativeRenderRequestMatches } from './native-render-request'
import type { AssistedAssemblyAiRender } from '@prisma/client'
import { diyAiRenderSchema, type DiyAiRenderDTO, type DiyAiRenderInput } from '@/lib/diy/ai-render'
import { createDiySnapshotSchema } from '@/lib/diy/schema'
@ -20,6 +21,12 @@ export function toDiyAiRenderDTO(row: AssistedAssemblyAiRender): DiyAiRenderDTO
snapshot: workspace.snapshot as DiyAiRenderDTO['snapshot'], cameraView: workspace.cameraView as DiyAiRenderDTO['cameraView'] }
}
export const diyAiRenderService = {
async findForRequest(requestId: string, user: AuthUser) {
await assertDiyAiRenderAccess(user)
if (!/^[a-zA-Z0-9_-]{8,120}$/.test(requestId)) throw new AppError(ErrorCode.VALIDATION_FAILED, 'AI 请求标识无效')
const row = await diyAiRenderRepository.findOwned(requestId, user.id)
return row ? toDiyAiRenderDTO(row) : null
},
async list(user: AuthUser, compositionKey?: string, schemeCode?: string) {
const access = await assertDiyAiRenderAccess(user)
if (compositionKey && !/^[a-f0-9]{64}$/.test(compositionKey)) throw new AppError(ErrorCode.VALIDATION_FAILED, '方案标识无效')
@ -31,13 +38,14 @@ export const diyAiRenderService = {
const parsed = diyAiRenderSchema.safeParse(raw)
if (!parsed.success) throw new AppError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message || '出图参数无效')
const existing = await diyAiRenderRepository.findOwned(parsed.data.requestId, user.id)
if (existing) return toDiyAiRenderDTO(existing)
const requestFingerprint = nativeRenderRequestFingerprint(parsed.data)
if (existing) { assertNativeRenderRequestMatches(existing, user.id, requestFingerprint); return toDiyAiRenderDTO(existing) }
const catalog = await diyCatalogService.getWorkbenchCatalog()
if (catalog.sourceHash !== parsed.data.catalogHash) throw new AppError(ErrorCode.VALIDATION_FAILED, '素材已更新,请刷新 DIY 后重新生成')
const snapshot = createDiySnapshotSchema(catalog).safeParse(parsed.data.snapshot)
if (!snapshot.success) throw new AppError(ErrorCode.VALIDATION_FAILED, snapshot.error.issues[0]?.message || '方案无效')
if (parsed.data.schemeCode && !await diySchemeRepository.findOwned(parsed.data.schemeCode, user.id)) throw new AppError(ErrorCode.FILE_NOT_FOUND, '方案不存在')
return toDiyAiRenderDTO(await createDiyAiRender({ ...parsed.data, snapshot: snapshot.data } as DiyAiRenderInput, user, catalog))
return toDiyAiRenderDTO(await createDiyAiRender({ ...parsed.data, snapshot: snapshot.data } as DiyAiRenderInput, user, catalog, requestFingerprint))
},
async optimize(raw: { prompt: string; referenceImageDataUrl: string; mode: 'white-background' | 'scene' }, user: AuthUser) {
await assertDiyAiRenderAccess(user, true)

View File

@ -1,4 +1,5 @@
import sharp from 'sharp'
import { randomUUID } from 'node:crypto'
import { uploadBufferToObjectStorageWithMetadata, deleteObjectWithResult } from '@/lib/storage'
import type { DiyAiRenderImage } from '@/lib/diy/ai-render'
import type { ExternalImageGenerateImageDTO } from './external-image-generate.service'
@ -7,7 +8,7 @@ import type { DecodedReferenceImage } from './assisted-assembly-ai-render.valida
export async function uploadDiyAiReference(image: DecodedReferenceImage, userId: string, renderId: string) {
return uploadBufferToObjectStorageWithMetadata(image.buffer,
`diy/ai-renders/${userId}/${renderId}/reference.${image.extension}`, image.contentType)
`diy/ai-renders/${userId}/${renderId}/reference-${randomUUID()}.${image.extension}`, image.contentType)
}
export async function storeDiyAiResults(images: ExternalImageGenerateImageDTO[], userId: string, renderId: string, keys: string[]) {
const stored: DiyAiRenderImage[] = []

View File

@ -0,0 +1,93 @@
import { createHash } from 'node:crypto'
import { lookup } from 'node:dns/promises'
import { request as httpRequest } from 'node:http'
import { request as httpsRequest } from 'node:https'
import { isIP, type LookupFunction } from 'node:net'
import sharp from 'sharp'
import { createObjectReadUrl, readObjectStorageKeyFromUrl, uploadBufferToObjectStorageWithMetadata } from '@/lib/storage'
import type { ExternalImageGenerateImageDTO } from './external-image-generate.service'
export const NATIVE_IMAGE_INPUT_URL_TTL_SECONDS = 72 * 3600
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
function publicAddress(address: string): boolean {
if (isIP(address) === 4) {
const [a, b, c] = address.split('.').map(Number)
return a !== 0 && a !== 10 && a !== 127 && a! < 224
&& !(a === 169 && b === 254) && !(a === 172 && b! >= 16 && b! <= 31)
&& !(a === 192 && b === 168) && !(a === 100 && b! >= 64 && b! <= 127)
&& !(a === 192 && b === 0 && (c === 0 || c === 2))
&& !(a === 192 && b === 88 && c === 99)
&& !(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100)))
&& !(a === 203 && b === 0 && c === 113)
}
// Only global unicast IPv6. This also rejects IPv4-mapped/private literals.
return isIP(address) === 6 && /^[23][0-9a-f]{3}:/i.test(address)
&& !/^2001:(?:0{1,4}:|:|0{0,3}2:|0?db8:)/i.test(address) && !/^2002:/i.test(address)
}
/** Bounded public image download. Pin checked DNS results and never follow redirects. */
export async function downloadNativeImage(source: string, maxBytes = 20 * 1024 * 1024) {
const storageKey = readObjectStorageKeyFromUrl(source)
const url = new URL(storageKey ? createObjectReadUrl(storageKey) : source)
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('AI 图片地址无效')
const hostname = url.hostname.replace(/^\[|\]$/g, '')
if (url.port && !['80', '443'].includes(url.port) && !(hostname === 'static.jiansun.vip' && url.protocol === 'https:' && url.port === '3333')) {
throw new Error('AI 图片地址端口不可用')
}
let dnsTimer: ReturnType<typeof setTimeout> | undefined
const addresses = isIP(hostname) ? [{ address: hostname, family: isIP(hostname) }] : await Promise.race([
lookup(hostname, { all: true, verbatim: true }),
new Promise<never>((_resolve, reject) => { dnsTimer = setTimeout(() => reject(new Error('AI 图片地址解析超时')), 10_000) }),
]).finally(() => { if (dnsTimer) clearTimeout(dnsTimer) })
if (!addresses.length || addresses.some(item => !publicAddress(item.address))) throw new Error('AI 图片必须使用公开可访问的地址')
const pinnedLookup: LookupFunction = (_host, options, callback) => {
if (options.all) callback(null, addresses)
else callback(null, addresses[0]!.address, addresses[0]!.family)
}
const bytes = await new Promise<Buffer>((resolve, reject) => {
const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
method: 'GET', lookup: pinnedLookup, agent: false,
headers: { Accept: 'image/png,image/jpeg,image/webp' },
}, response => {
if (response.statusCode !== 200 || Number(response.headers['content-length'] ?? 0) > maxBytes) {
response.destroy()
reject(new Error('AI 图片下载失败或超过大小限制'))
return
}
const chunks: Buffer[] = []
let length = 0
response.on('data', (chunk: Buffer) => {
length += chunk.length
if (length > maxBytes) {
response.destroy(new Error('AI 图片超过大小限制'))
return
}
chunks.push(chunk)
})
response.on('error', () => reject(new Error('AI 图片下载中断')))
response.on('end', () => resolve(Buffer.concat(chunks)))
})
const timer = setTimeout(() => request.destroy(new Error('AI 图片下载超时')), 30_000)
request.on('error', () => reject(new Error('AI 图片下载失败')))
request.on('close', () => clearTimeout(timer))
request.end()
})
const metadata = await sharp(bytes, { limitInputPixels: 100_000_000 }).metadata()
const extension = ({ png: 'png', jpeg: 'jpg', webp: 'webp' } as Record<string, string>)[metadata.format ?? '']
if (!extension || !metadata.width || !metadata.height) throw new Error('AI 图片必须为 PNG、JPEG 或 WebP')
return { buffer: bytes, contentType: `image/${metadata.format}`, extension }
}
/** Archive upstream outputs immediately, including partial results in a blocked multi-image task. */
export async function persistNativeImageOutputs(jobId: string, taskId: string, images: ExternalImageGenerateImageDTO[]) {
const saved: ExternalImageGenerateImageDTO[] = []
for (let index = 0; index < images.length; index++) {
const image = images[index]!
const downloaded = await downloadNativeImage(image.originalUrl, 50 * 1024 * 1024)
const key = `native-ai-results/${hash(jobId)}/${hash(taskId)}/${index}.${downloaded.extension}`
const uploaded = await uploadBufferToObjectStorageWithMetadata(downloaded.buffer, key, downloaded.contentType)
saved.push({ ...image, originalUrl: uploaded.url, thumbnailUrl: uploaded.url })
}
return saved
}

View File

@ -0,0 +1,156 @@
import { createHash, randomUUID } from 'node:crypto'
import type { Prisma } from '@prisma/client'
import { createObjectReadUrl, deleteObject, readObjectStorageKeyFromUrl, uploadBufferToObjectStorageWithMetadata } from '@/lib/storage'
import { AppError, ErrorCode } from '@/server/core/errors'
import { nativeImageGenerationRepository } from '@/server/repositories/native-image-generation.repository'
import type { CallExternalImageGenerateApiInput } from './external-image-generate.service'
import { providerConfigService } from './provider-config.service'
import { resolveRelayImageTaskContract } from './relay-image-task.contract'
import type { NativeImageCompletion, NativeImageRequest } from './native-image-generation.types'
import { downloadNativeImage, NATIVE_IMAGE_INPUT_URL_TTL_SECONDS } from './native-image-files'
export type NativeImageGenerationCommit = (
persistJob: (tx: Prisma.TransactionClient) => Promise<void>,
) => Promise<void>
export interface EnqueueNativeImageGenerationInput {
id: string
createdBy: string
input: CallExternalImageGenerateApiInput
completion: NativeImageCompletion
}
const json = (value: unknown) => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>
return `{${Object.keys(record).sort().filter(key => record[key] !== undefined).map(key => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`
}
return JSON.stringify(value) ?? 'null'
}
export function nativeImageProviderFingerprint(config: { baseUrl: string; apiKey: string }) {
return hash(`${config.baseUrl.replace(/\/+$/, '')}\0${config.apiKey}`)
}
export function nativeImageRequestHash(options: EnqueueNativeImageGenerationInput) {
const input = options.input
const files = [input.referenceImage, ...(input.additionalReferenceImages ?? [])].filter(
(file): file is NonNullable<typeof input.referenceImage> => Boolean(file),
)
return hash(stableJson({
createdBy: options.createdBy, completion: options.completion,
prompt: input.prompt, negativePrompt: input.negativePrompt ?? null,
imageCount: input.imageCount, aspectRatio: input.aspectRatio, imageSize: input.imageSize,
files: files.map(file => ({ contentType: file.contentType, sha256: hash(file.buffer) })),
// A refreshed signature does not turn the same project object into different input.
urls: (input.referenceImageUrls ?? []).map(url => {
const storageKey = readObjectStorageKeyFromUrl(url)
return storageKey ? { storageKey } : { url }
}),
}))
}
/** The business record and its executable task are committed together through the hook. */
export async function enqueueNativeImageGeneration(
options: EnqueueNativeImageGenerationInput,
commit?: NativeImageGenerationCommit,
): Promise<void> {
const { id, createdBy, input, completion } = options
if (!id.trim() || id.length > 200 || !createdBy.trim() || !input.prompt.trim()) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '缺少出图任务标识、用户或提示词')
}
if (!Number.isInteger(input.imageCount) || input.imageCount < 1 || input.imageCount > 8) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '每次出图数量必须为 1 至 8 张')
}
if (input.serviceType && input.serviceType !== 'ai-render') {
throw new AppError(ErrorCode.VALIDATION_FAILED, '该任务入口仅处理 AI 出图')
}
const requestHash = nativeImageRequestHash(options)
const existing = await nativeImageGenerationRepository.findById(id)
if (existing) {
if (existing.createdBy !== createdBy || existing.requestHash !== requestHash) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '同一出图请求编号不能用于不同内容')
}
return
}
const config = await providerConfigService.getActive('ai-render')
if (!config?.baseUrl || !config.apiKey || !config.model) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '请先配置有效的 AI 出图服务和模型')
}
const files = [input.referenceImage, ...(input.additionalReferenceImages ?? [])].filter(
(file): file is NonNullable<typeof input.referenceImage> => Boolean(file),
)
const urls = (input.referenceImageUrls ?? []).map(url => url.trim()).filter(Boolean)
if (files.length + urls.length === 0 || files.length + urls.length > 16) {
throw new AppError(ErrorCode.VALIDATION_FAILED, 'AI 出图需要 1 至 16 张参考图')
}
const uploadedKeys: string[] = []
// Each preparation owns its objects. Concurrent attempts must never delete each other's inputs.
const uploadAttempt = randomUUID()
try {
const referenceImageUrls: string[] = []
// Snapshot URL inputs too: external/material signed links must not expire while queued.
for (let index = 0; index < files.length + urls.length; index++) {
const file = index < files.length ? files[index]! : await downloadNativeImage(urls[index - files.length]!)
const extension = ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' } as Record<string, string>)[file.contentType]
if (!extension || file.buffer.length === 0 || file.buffer.length > 20 * 1024 * 1024) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '参考图必须为不超过 20MB 的 PNG、JPEG 或 WebP 图片')
}
const key = `native-ai-inputs/${hash(id)}/${requestHash}/${uploadAttempt}/${index}.${extension}`
const uploaded = await uploadBufferToObjectStorageWithMetadata(file.buffer, key, file.contentType)
uploadedKeys.push(uploaded.storageKey)
// Freeze the exact signed URL before the first POST: retries must have identical payloads.
referenceImageUrls.push(createObjectReadUrl(uploaded.storageKey, NATIVE_IMAGE_INPUT_URL_TTL_SECONDS))
}
const request: NativeImageRequest = {
model: config.model, prompt: input.prompt, negativePrompt: input.negativePrompt,
aspectRatio: input.aspectRatio, imageSize: input.imageSize, imageCount: input.imageCount,
referenceImageUrls, inputStorageKeys: uploadedKeys,
}
const configuredRoute = typeof config.extra?.relayRouteOptionId === 'string'
? config.extra.relayRouteOptionId : undefined
const contract = await resolveRelayImageTaskContract({
baseUrl: config.baseUrl, apiKey: config.apiKey, routeOptionId: configuredRoute,
}, request)
request.routeOptionId = contract.routeOptionId
const data: Prisma.NativeImageGenerationJobUncheckedCreateInput = {
id, createdBy, providerConfigId: config.id,
providerFingerprint: nativeImageProviderFingerprint(config), requestHash,
request: json(request), completion: json(completion),
progress: json(Array.from({ length: input.imageCount }, (_, index) => ({
idempotencyKey: `image-${hash(`${id}:${index}`)}`,
}))),
}
let usedKeys: string[] | null = null
const persistJob = async (tx: Prisma.TransactionClient) => {
const saved = await nativeImageGenerationRepository.insert(data, tx)
usedKeys = (saved.request as unknown as NativeImageRequest).inputStorageKeys
}
if (commit) await commit(persistJob)
else {
const saved = await nativeImageGenerationRepository.insert(data)
usedKeys = (saved.request as unknown as NativeImageRequest).inputStorageKeys
}
if (!usedKeys) throw new Error('出图业务事务没有保存后台任务')
const referenced = new Set(usedKeys)
await Promise.allSettled(uploadedKeys.filter(key => !referenced.has(key)).map(key => deleteObject(key)))
} catch (error) {
// A transaction can have committed before a connection error. Never remove referenced files.
let knownAbsent = false
try {
const committed = await nativeImageGenerationRepository.findById(id)
if (committed?.createdBy === createdBy && committed.requestHash === requestHash) {
const referenced = new Set((committed.request as unknown as NativeImageRequest).inputStorageKeys)
await Promise.allSettled(uploadedKeys.filter(key => !referenced.has(key)).map(key => deleteObject(key)))
return
}
knownAbsent = !committed
} catch { /* Keep files when transaction outcome is unknown. */ }
if (knownAbsent) await Promise.allSettled(uploadedKeys.map(key => deleteObject(key)))
throw error
}
}

View File

@ -0,0 +1,27 @@
import type { ExternalImageGenerateImageDTO } from './external-image-generate.service'
export type NativeImageCompletion =
| { kind: 'render'; renderId: string; source: 'diy' | 'assisted' }
| ({ kind: 'white-background' } & Record<string, unknown>)
export interface NativeImageRequest {
model: string
prompt: string
negativePrompt?: string | null
aspectRatio: string
imageSize: string
imageCount: number
referenceImageUrls: string[]
inputStorageKeys: string[]
routeOptionId?: string
}
export interface NativeImageProgress {
idempotencyKey: string
taskId?: string
images?: ExternalImageGenerateImageDTO[]
errorMessage?: string
needsReview?: boolean
reviewMessage?: string
lastAttemptedAt?: string
}

View File

@ -0,0 +1,236 @@
import { randomUUID } from 'node:crypto'
import type { NativeImageGenerationJob, Prisma } from '@prisma/client'
import { AppError, ErrorCode } from '@/server/core/errors'
import { nativeImageGenerationRepository as repository } from '@/server/repositories/native-image-generation.repository'
import { providerConfigService } from './provider-config.service'
import { nativeImageProviderFingerprint } from './native-image-generation.service'
import { createRelayImageTask, getRelayImageTaskResult, RelayImageTaskError } from './relay-image-task.client'
import type { ExternalImageGenerateResult } from './external-image-generate.service'
import type { NativeImageCompletion, NativeImageProgress, NativeImageRequest } from './native-image-generation.types'
import { persistNativeImageOutputs } from './native-image-files'
const LEASE_MS = 120_000
const GENERATION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const json = (value: unknown) => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
async function complete(completion: NativeImageCompletion, result: ExternalImageGenerateResult) {
if (completion.kind === 'render') {
const handlers = await import('./native-render-completion.service')
await handlers.completeNativeRenderGeneration(completion, result)
} else {
const handlers = await import('./non-integrated-ai-white-background.service')
await handlers.completeNativeWhiteBackgroundGeneration(
completion as unknown as Parameters<typeof handlers.completeNativeWhiteBackgroundGeneration>[0], result,
)
}
}
async function fail(completion: NativeImageCompletion, message: string) {
if (completion.kind === 'render') {
const handlers = await import('./native-render-completion.service')
await handlers.failNativeRenderGeneration(completion, message)
} else {
const handlers = await import('./non-integrated-ai-white-background.service')
await handlers.failNativeWhiteBackgroundGeneration(
completion as unknown as Parameters<typeof handlers.failNativeWhiteBackgroundGeneration>[0], message,
)
}
}
async function markBlocked(completion: NativeImageCompletion, message: string) {
if (completion.kind === 'render') {
const handlers = await import('./native-render-completion.service')
await handlers.markNativeRenderBlockedGeneration(completion, message)
} else {
const handlers = await import('./non-integrated-ai-white-background.service')
await handlers.markNativeWhiteBackgroundBlockedGeneration(
completion as unknown as Parameters<typeof handlers.markNativeWhiteBackgroundBlockedGeneration>[0], message,
)
}
}
function finished(step: NativeImageProgress) { return Boolean(step.images || step.errorMessage) }
async function notifySafely(job: NativeImageGenerationJob, notify: () => Promise<void>) {
try { await notify() }
catch {
// Business projections can become unwritable after a user/product is removed.
// The durable task state must still stop execution and release its lease.
console.warn('[native-ai.business-status-pending]', { jobId: job.id })
}
}
async function blockJob(
job: NativeImageGenerationJob,
owner: string,
message: string,
progress?: NativeImageProgress[],
retryCount = job.retryCount,
) {
await repository.saveClaim(job.id, owner, {
status: 'BLOCKED', errorMessage: message, retryCount,
...(progress ? { progress: json(progress) } : {}),
leaseOwner: null, leaseUntil: null,
})
await notifySafely(job, () => markBlocked(job.completion as unknown as NativeImageCompletion, message))
}
function nextUnfinishedIndex(progress: NativeImageProgress[]): number {
// Rotate all operations, including uncertain submissions. A single unresolved
// image must not starve another image that is ready to submit or retrieve.
let selected = -1
let earliest = Number.POSITIVE_INFINITY
for (let index = 0; index < progress.length; index++) {
const step = progress[index]!
if (finished(step) || step.needsReview) continue
const attempted = step.lastAttemptedAt ? Date.parse(step.lastAttemptedAt) : 0
const timestamp = Number.isFinite(attempted) ? attempted : 0
if (timestamp < earliest) { selected = index; earliest = timestamp }
}
return selected
}
async function finalize(job: NativeImageGenerationJob, owner: string, progress: NativeImageProgress[]) {
const request = job.request as unknown as NativeImageRequest
const completion = job.completion as unknown as NativeImageCompletion
const images = progress.flatMap(step => step.images ?? [])
const errorMessage = progress.map(step => step.errorMessage).filter(Boolean).join(';').slice(0, 1000)
if (images.length) {
await complete(completion, {
requestId: progress.find(step => step.taskId)?.taskId ?? null,
model: request.model, images, generatedImageIds: [], generatedAssetIds: [],
attempts: progress.map((step, index) => ({
index, taskId: step.taskId ?? null, status: step.errorMessage ? 'failed' : 'succeeded',
...(step.errorMessage ? { errorMessage: step.errorMessage } : {}),
})),
})
} else {
await fail(completion, errorMessage || 'AI 服务未返回图片')
await repository.saveClaim(job.id, owner, {
status: 'FAILED', finishedAt: new Date(), errorMessage: errorMessage || 'AI 服务未返回图片',
leaseOwner: null, leaseUntil: null,
})
return
}
await repository.saveClaim(job.id, owner, {
status: images.length ? 'SUCCEEDED' : 'FAILED', finishedAt: new Date(),
errorMessage: errorMessage || null, leaseOwner: null, leaseUntil: null,
})
}
export const nativeImageGenerationWorker = {
/** One bounded remote operation per turn; no HTTP request holds the whole generation open. */
async runNext() {
const owner = randomUUID(), now = new Date()
const job = await repository.claimNext(owner, now, new Date(now.getTime() + LEASE_MS))
if (!job) return { status: 'idle' as const }
let leaseLost = false
const heartbeat = setInterval(() => {
void repository.saveClaim(job.id, owner, { leaseUntil: new Date(Date.now() + LEASE_MS) })
.catch(() => { leaseLost = true })
}, 30_000)
heartbeat.unref?.()
let progress: NativeImageProgress[] | undefined
let finalizing = false
try {
const request = job.request as unknown as NativeImageRequest
const savedProgress = job.progress as unknown as NativeImageProgress[]
if (!Array.isArray(savedProgress) || savedProgress.length !== request.imageCount || !savedProgress.length) {
throw new RelayImageTaskError('invalid_saved_job', '已保存的 AI 任务数据不完整,需要管理员核查', { retryable: false, outcomeUnknown: true })
}
progress = savedProgress
if (progress.every(finished)) {
finalizing = true
await finalize(job, owner, progress)
return { status: 'finalized' as const, id: job.id }
}
if (job.status === 'FINALIZING') {
throw new RelayImageTaskError('invalid_saved_job', '待归档任务仍有未确认的图片,需要管理员核查', { retryable: false, outcomeUnknown: true })
}
if (now.getTime() - job.createdAt.getTime() >= GENERATION_MAX_AGE_MS) {
await blockJob(job, owner, '出图任务已超过 24 小时,需核对原任务后恢复,请勿重复提交', progress)
return { status: 'blocked' as const, id: job.id }
}
const nextIndex = nextUnfinishedIndex(progress)
if (nextIndex < 0) {
const message = progress.find(step => step.needsReview)?.reviewMessage || 'AI 任务结果需要人工核对,请勿重复提交'
await blockJob(job, owner, message, progress)
return { status: 'blocked' as const, id: job.id }
}
const provider = await providerConfigService.getById(job.providerConfigId)
if (!provider || nativeImageProviderFingerprint(provider) !== job.providerFingerprint) {
throw new RelayImageTaskError('provider_changed', 'AI 任务的服务地址或账号已变更,需要核对原任务后恢复', { retryable: false, outcomeUnknown: true })
}
if (leaseLost) return { status: 'lease-lost' as const, id: job.id }
const config = { baseUrl: provider.baseUrl, apiKey: provider.apiKey, routeOptionId: request.routeOptionId }
const step = progress[nextIndex]!
step.lastAttemptedAt = new Date().toISOString()
try {
if (!step.taskId) {
step.taskId = (await createRelayImageTask(config, request, step.idempotencyKey)).taskId
} else {
const result = await getRelayImageTaskResult(config, step.taskId)
if (result.status === 'succeeded') {
step.images = await persistNativeImageOutputs(job.id, step.taskId, result.images)
}
if (result.status === 'failed') step.errorMessage = result.errorMessage
if (result.status === 'pending' && result.needsReview) {
step.needsReview = true
step.reviewMessage = 'AI 服务正在核对该图片的提交或计费状态,请勿重复提交'
}
}
} catch (error) {
if (error instanceof RelayImageTaskError && !error.retryable && !error.outcomeUnknown && !step.taskId) {
step.errorMessage = error.message
} else if (error instanceof RelayImageTaskError && !error.retryable) {
step.needsReview = true
step.reviewMessage = error.message
} else throw error
}
if (leaseLost) throw new Error('AI worker lease lost')
const allFinished = progress.every(finished)
if (!allFinished && nextUnfinishedIndex(progress) < 0) {
const message = progress.find(item => item.needsReview)?.reviewMessage || 'AI 任务结果需要人工核对,请勿重复提交'
await blockJob(job, owner, message, progress)
return { status: 'blocked' as const, id: job.id }
}
// Commit remote IDs/results before invoking business finalization. A crash only repeats finalization.
await repository.saveClaim(job.id, owner, {
progress: json(progress), status: allFinished ? 'FINALIZING' : 'RUNNING',
retryCount: 0, errorMessage: null, nextRunAt: new Date(Date.now() + (allFinished ? 0 : 3000)),
leaseOwner: null, leaseUntil: null,
})
return { status: 'processed' as const, id: job.id }
} catch (error) {
if (leaseLost) return { status: 'lease-lost' as const, id: job.id }
if (finalizing && error instanceof AppError && error.code === ErrorCode.VALIDATION_FAILED) {
try {
await fail(job.completion as unknown as NativeImageCompletion, error.message)
await repository.saveClaim(job.id, owner, {
status: 'FAILED', errorMessage: error.message, finishedAt: new Date(), leaseOwner: null, leaseUntil: null,
})
return { status: 'failed' as const, id: job.id }
} catch (projectionError) {
// Keep the durable job runnable until the normal failed-state projection
// is saved. Persistent projection errors eventually enter safe BLOCKED.
error = projectionError
}
}
const retryCount = job.retryCount + 1
const typed = error instanceof RelayImageTaskError ? error : null
// Unknown outcomes are held for review, never replaced with a fresh chargeable request.
const blocked = (typed && !typed.retryable && typed.outcomeUnknown) || retryCount >= 30
const message = typed?.message ?? '出图任务暂未完成,将从已保存的进度继续处理'
if (blocked) {
await blockJob(job, owner, message, progress, retryCount)
return { status: 'blocked' as const, id: job.id }
}
await repository.saveClaim(job.id, owner, {
retryCount, errorMessage: message, ...(progress ? { progress: json(progress) } : {}),
nextRunAt: new Date(Date.now() + Math.min(60_000, 2000 * 2 ** Math.min(retryCount, 5))),
leaseOwner: null, leaseUntil: null,
})
return { status: 'retry' as const, id: job.id }
} finally { clearInterval(heartbeat) }
},
}

View File

@ -0,0 +1,107 @@
import type { Prisma } from '@prisma/client'
import { nativeRenderRepository } from '@/server/repositories/native-render.repository'
import type { ExternalImageGenerateResult } from './external-image-generate.service'
import { normalizeResultImages, type AssistedAssemblyAiRenderImageDTO } from './assisted-assembly-ai-render.records'
import { toRecord, trimToNull } from './assisted-assembly-ai-render.validation'
import { cleanWorkbenchResultBackground, shouldCleanWorkbenchResultBackground } from './assisted-assembly-ai-render.background'
import { fetchImageBuffer, getTemporaryRenderExpiresAt, normalizeWorkbenchResultOutputSize, uploadTemporaryWorkbenchResultImage } from './assisted-assembly-ai-render.temp-storage'
import { persistPermanentIntakeResultImages } from './assisted-assembly-ai-render.permanent-storage'
import { storeDiyAiResults } from './diy-ai-render.storage'
export interface NativeRenderCompletion {
kind: 'render'
renderId: string
source: 'diy' | 'assisted'
}
const json = (value: unknown) => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
async function persistTemporaryWorkbenchResultImages(params: {
images: AssistedAssemblyAiRenderImageDTO[]
createdBy: string
mode?: string | null
renderId: string
normalizeOutput?: boolean
}): Promise<AssistedAssemblyAiRenderImageDTO[]> {
const expiresAt = getTemporaryRenderExpiresAt()
const storedImages: AssistedAssemblyAiRenderImageDTO[] = []
for (let index = 0; index < params.images.length; index += 1) {
const image = params.images[index]!
const sourceUrl = trimToNull(image.originalUrl)
if (!sourceUrl) continue
const fetchedImage = await fetchImageBuffer(sourceUrl)
const cleanedImageBuffer = shouldCleanWorkbenchResultBackground(params.mode)
? await cleanWorkbenchResultBackground(fetchedImage)
: fetchedImage
const imageBuffer = params.normalizeOutput === false ? { ...cleanedImageBuffer, width: undefined, height: undefined } : await normalizeWorkbenchResultOutputSize({
buffer: cleanedImageBuffer.buffer,
extension: cleanedImageBuffer.extension,
})
const upload = await uploadTemporaryWorkbenchResultImage({
buffer: imageBuffer.buffer,
contentType: imageBuffer.contentType,
extension: imageBuffer.extension,
createdBy: params.createdBy,
index,
renderId: params.renderId,
})
storedImages.push({
...(image.id !== undefined ? { id: image.id } : {}),
originalUrl: upload.url,
thumbnailUrl: upload.url,
imageSize:
imageBuffer.width && imageBuffer.height
? `${imageBuffer.width}x${imageBuffer.height}`
: image.imageSize ?? null,
storageKey: upload.storageKey,
expiresAt,
temporary: true,
})
}
return storedImages
}
/** Stable per-render storage keys make worker retries safe after an interrupted database write. */
export async function completeNativeRenderGeneration(completion: NativeRenderCompletion, result: ExternalImageGenerateResult): Promise<void> {
const row = await nativeRenderRepository.find(completion.renderId)
if (!row || row.status !== 'PENDING') return
const params = toRecord(row.params)
if ((params.source === 'diy') !== (completion.source === 'diy')) throw new Error('AI render task source mismatch')
const mode = typeof params.mode === 'string' ? params.mode : undefined
if (!result.images.length) throw new Error('AI 未返回可用图片')
const images = completion.source === 'diy'
? await storeDiyAiResults(result.images, row.createdBy, row.id, [])
: row.intakeSkuItemId
? await persistPermanentIntakeResultImages({ images: normalizeResultImages(result.images), skuItemId: row.intakeSkuItemId, renderId: row.id, mode })
: await persistTemporaryWorkbenchResultImages({ images: normalizeResultImages(result.images), createdBy: row.createdBy, renderId: row.id, mode: row.workbenchSkuId ? mode : undefined, normalizeOutput: Boolean(row.workbenchSkuId) })
if (!images.length) throw new Error('AI 未返回可用图片')
const failedAttempts = result.attempts.map(toRecord).filter((attempt) => ['failed', 'cancelled', 'expired'].includes(String(attempt.status).toLowerCase()))
const expectedCount = typeof params.imageCount === 'number' ? params.imageCount : images.length
const partialMessage = images.length < expectedCount || failedAttempts.length
? `已生成 ${images.length}/${expectedCount} 张图片;部分图片生成失败${failedAttempts[0]?.errorMessage ? `:${String(failedAttempts[0].errorMessage)}` : ''}`
: null
await nativeRenderRepository.updatePending(row.id, {
status: 'SUCCEEDED', resultImages: json(images), externalRequestId: result.requestId,
attempts: json(result.attempts), errorMessage: partialMessage,
durationMs: Date.now() - row.createdAt.getTime(), params: json({ ...params, stage: 'succeeded' }),
})
}
export async function failNativeRenderGeneration(completion: NativeRenderCompletion, errorMessage: string): Promise<void> {
const row = await nativeRenderRepository.find(completion.renderId)
if (!row || row.status !== 'PENDING') return
await nativeRenderRepository.updatePending(row.id, { status: 'FAILED', errorMessage,
durationMs: Date.now() - row.createdAt.getTime(), params: json({ ...toRecord(row.params), stage: 'failed' }) })
}
export async function markNativeRenderBlockedGeneration(completion: NativeRenderCompletion, errorMessage: string): Promise<void> {
const row = await nativeRenderRepository.find(completion.renderId)
if (!row || row.status !== 'PENDING') return
await nativeRenderRepository.updatePending(row.id, {
errorMessage: `${errorMessage};任务待核对,请勿重复提交`,
params: json({ ...toRecord(row.params), stage: 'needs-review' }),
})
}

View File

@ -0,0 +1,24 @@
import { createHash } from 'node:crypto'
import { AppError, ErrorCode } from '@/server/core/errors'
export function assistedRenderIdForRequest(createdBy: string, requestId: string) {
if (!/^[a-zA-Z0-9_-]{8,120}$/.test(requestId)) throw new AppError(ErrorCode.VALIDATION_FAILED, 'AI 请求标识无效')
return `air_${createHash('sha256').update(`${createdBy}:${requestId}`).digest('hex').slice(0, 40)}`
}
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical)
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)]))
return value
}
export function nativeRenderRequestFingerprint(input: { requestId?: string } & object) {
return createHash('sha256').update(JSON.stringify(canonical({ ...input, requestId: undefined }))).digest('hex')
}
export function assertNativeRenderRequestMatches(row: { createdBy: string; params: unknown }, owner: string, fingerprint: string) {
const params = row.params as { requestFingerprint?: string } | null
if (row.createdBy !== owner || params?.requestFingerprint !== fingerprint) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '同一出图请求编号不能用于不同内容,请先核对原任务。')
}
}

View File

@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { randomUUID } from "crypto";
import { createHash, randomUUID } from "crypto";
import { mkdtemp, readFile, rm } from "fs/promises";
import os from "os";
import path from "path";
@ -19,7 +19,6 @@ import {
import {
AI_WHITE_BACKGROUND_SOURCE_SLOTS,
AI_WHITE_BACKGROUND_SOURCE_SLOT_LABELS,
AI_WHITE_BACKGROUND_SOURCE_SLOT_TO_VIEW_KEY,
AI_WHITE_BACKGROUND_VIEW_KEYS,
AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT,
AI_WHITE_BACKGROUND_VIEW_LABELS,
@ -27,7 +26,6 @@ import {
getAiWhiteBackgroundSessionById,
getLatestAiWhiteBackgroundSession,
getDefaultAiWhiteBackgroundStyleKey,
hasCompleteAiWhiteBackgroundSession,
inferAiWhiteBackgroundSourceSlotFromUploadFile,
readNonIntegratedAiWhiteBackgroundMetadata,
resolveAiWhiteBackgroundSourceStyleKey,
@ -48,7 +46,10 @@ import { createNonIntegratedUploadTarget } from "@/app/api/intake/non-integrated
import { AppError, ErrorCode } from "@/server/core/errors";
import type { AuthUser } from "@/server/core/types";
import { intakeNonIntegratedProductService } from "./intake-non-integrated-product.service";
import { callExternalImageGenerateApi } from "./external-image-generate.service";
import type { ExternalImageGenerateResult } from "./external-image-generate.service";
import { enqueueNativeImageGeneration } from "./native-image-generation.service";
import { saveWhiteBackgroundIntentWithJob } from "../repositories/non-integrated-ai-white-background.repository";
import { userRepository } from "../repositories/user.repository";
import { materialService } from "./material.service";
import { systemConfigService } from "./system-config.service";
import { normalizeMaterialAssetPath } from "@/lib/material-asset-path";
@ -139,6 +140,7 @@ type AiWhiteBackgroundResolvedMaterialReference = {
};
export type GenerateNonIntegratedAiWhiteBackgroundSessionInput = {
requestId?: string;
sessionId?: string;
styleKey: string;
targetBarcode?: string;
@ -1100,66 +1102,6 @@ function readResponseJsonRecord(value: unknown) {
: {};
}
async function callAiWhiteBackgroundGenerateApi(input: {
referenceBuffer: Buffer;
referenceContentType: string;
additionalReferenceImages?: AiWhiteBackgroundReferenceImage[];
prompt: string;
negativePrompt: string;
}) {
const referenceImageExtension =
input.referenceContentType === "image/jpeg" ? "jpg" : "png";
const result = await callExternalImageGenerateApi({
prompt: input.prompt,
negativePrompt: input.negativePrompt,
imageCount: 1,
aspectRatio: AI_WHITE_BACKGROUND_ASPECT_RATIO,
imageSize: AI_WHITE_BACKGROUND_IMAGE_SIZE,
referenceImage: {
buffer: input.referenceBuffer,
contentType: input.referenceContentType,
fileName: `white-background-reference.${referenceImageExtension}`,
},
additionalReferenceImages:
input.additionalReferenceImages && input.additionalReferenceImages.length > 0
? input.additionalReferenceImages
: undefined,
disabledMessage: "AI 白底图服务未启用或未配置。",
});
const imageUrl =
result.images.find((item) => item.originalUrl.trim())?.originalUrl ?? "";
if (!imageUrl) {
throw new AppError(
ErrorCode.FILE_UPLOAD_FAILED,
"AI 白底图服务没有返回可用图片。",
);
}
return {
requestId: result.requestId,
imageUrl,
};
}
async function generateAiWhiteBackgroundImage(input: {
referenceBuffer: Buffer;
referenceContentType: string;
additionalReferenceImages?: AiWhiteBackgroundReferenceImage[];
prompt: string;
negativePrompt: string;
}) {
try {
return await callAiWhiteBackgroundGenerateApi(input);
} catch (error) {
throw new AppError(
ErrorCode.FILE_UPLOAD_FAILED,
`AI 白底图生成失败:${readUnknownErrorMessage(error, "未知错误")}`,
);
}
}
async function readJsonishResponse(response: Response) {
let text = "";
@ -1941,28 +1883,6 @@ function buildGeneratedFileMap(
) as Record<AiWhiteBackgroundViewKey, NonIntegratedUploadFile | null>;
}
function toAiWhiteBackgroundErrorLog(error: unknown) {
if (error instanceof AppError) {
return {
name: "AppError",
code: error.code,
message: error.message,
};
}
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
};
}
return {
name: typeof error,
message: String(error),
};
}
function buildUpdatedUploadFiles(
currentFiles: NonIntegratedProductRecord["uploadFiles"],
nextFiles: Array<NonIntegratedUploadFile & { storageKey?: string; size?: number }>,
@ -1974,15 +1894,6 @@ function buildUpdatedUploadFiles(
];
}
async function deleteUploadFileObjects(
filesToDelete: Array<NonIntegratedUploadFile & { storageKey?: string; size?: number }>,
) {
const deleteKeys = new Set(extractObjectStorageKeys(filesToDelete, "uploadFiles"));
for (const key of deleteKeys) {
await deleteObject(key);
}
}
async function deleteUploadFileObjectsNoLongerReferenced(
filesToDelete: Array<NonIntegratedUploadFile & { storageKey?: string; size?: number }>,
remainingFiles: NonIntegratedProductRecord["uploadFiles"],
@ -2218,114 +2129,6 @@ function applyAiWhiteBackgroundPartGroupTemplates(params: {
};
}
async function saveGeneratedSessionWithRetry(params: {
productId: string;
user: AuthUser;
sessionId: string;
styleKey: string;
targetBarcode: string;
sourceBarcode: string;
skuBatchNo: number;
targetColor: string | null;
partGroups: AiWhiteBackgroundPartGroup[];
confirmedPartGroupingSource: AiWhiteBackgroundPartGroupingSource | null;
sourceFileIdsBySlot: Partial<Record<AiWhiteBackgroundSourceSlot, string>>;
generatedFileIdsByView: Partial<Record<AiWhiteBackgroundViewKey, string>>;
structureReferenceFileIdsByView: Partial<Record<AiWhiteBackgroundViewKey, string[]>>;
refinementPromptsByView: Partial<Record<AiWhiteBackgroundViewKey, string>>;
generatedFiles: Array<NonIntegratedUploadFile & { storageKey?: string; size?: number }>;
}) {
for (let attempt = 0; attempt < AI_WHITE_BACKGROUND_SAVE_RETRY_LIMIT; attempt += 1) {
const latestProduct = await getWritableProduct(params.productId, params.user);
const now = new Date().toISOString();
const baseSession =
getAiWhiteBackgroundSessionById(latestProduct.metadata, params.sessionId) ??
getLatestAiWhiteBackgroundSession(latestProduct.metadata, {
styleKey: params.styleKey,
targetBarcode: params.targetBarcode,
sourceBarcode: params.sourceBarcode,
targetColor: params.targetColor,
partGroups: params.partGroups,
});
const session = createAiWhiteBackgroundSession({
sessionId: params.sessionId,
styleKey: params.styleKey,
targetBarcode: params.targetBarcode,
sourceBarcode: params.sourceBarcode,
skuBatchNo: params.skuBatchNo,
targetColor: params.targetColor,
partGroups: params.partGroups,
confirmedPartGroupingSource: params.confirmedPartGroupingSource,
now,
baseSession,
});
const nextSession: AiWhiteBackgroundSession = {
...session,
status: "generated",
sourceFileIdsBySlot: {
...session.sourceFileIdsBySlot,
...params.sourceFileIdsBySlot,
},
generatedFileIdsByView: {
...session.generatedFileIdsByView,
...params.generatedFileIdsByView,
},
structureReferenceFileIdsByView: {
...(session.structureReferenceFileIdsByView ?? {}),
...params.structureReferenceFileIdsByView,
},
refinementPromptsByView: params.refinementPromptsByView,
updatedAt: now,
};
const nextProduct = applyAiWhiteBackgroundPartGroupTemplates({
product: {
...latestProduct,
metadata: applyUpdatedSession(latestProduct.metadata, nextSession),
uploadFiles: buildUpdatedUploadFiles(
latestProduct.uploadFiles,
params.generatedFiles,
),
updatedAt: now,
},
styleKey: params.styleKey,
targetBarcode: params.targetBarcode,
skuBatchNo: params.skuBatchNo,
partGroups: params.partGroups,
confirmedPartGroupingSource: params.confirmedPartGroupingSource,
});
const replacedUploadFiles = latestProduct.uploadFiles.filter((file) =>
params.generatedFiles.some((nextFile) => nextFile.id === file.id),
);
const savedProduct =
await intakeNonIntegratedProductService.savePayloadOptimistically(
nextProduct,
params.user,
latestProduct.updatedAt,
);
if (!savedProduct) {
continue;
}
await deleteUploadFileObjectsNoLongerReferenced(
replacedUploadFiles,
savedProduct.uploadFiles,
);
return {
product: savedProduct,
session:
getAiWhiteBackgroundSessionById(savedProduct.metadata, params.sessionId) ??
nextSession,
};
}
throw new AppError(
ErrorCode.SYSTEM_INTERNAL,
"白底图生成结果保存失败,请稍后重试。",
);
}
async function resolveAiWhiteBackgroundTargetFinish(
targetFinishId: string | null,
): Promise<AiWhiteBackgroundTargetFinish | null> {
@ -2388,6 +2191,186 @@ function buildLegacyAiWhiteBackgroundPartGroups(params: {
];
}
export type NativeWhiteBackgroundCompletion = {
kind: "white-background";
productId: string;
sessionId: string;
viewKey: AiWhiteBackgroundViewKey;
taskId: string;
createdBy: string;
expectedContextSignature: string;
sourceFileId: string | null;
sourceSlot: AiWhiteBackgroundSourceSlot;
structureReferenceFileIds: string[];
refinementPrompt: string | null;
applyRefinementPrompt: boolean;
fileId: string;
};
function whiteBackgroundProductSourceSignature(product: NonIntegratedProductRecord, session: AiWhiteBackgroundSession) {
const skus = [session.targetBarcode, session.sourceBarcode].map((barcode) => {
const sku = findSkuItemByBarcodeAndBatchNo(product, barcode, session.skuBatchNo);
return sku ? {
id: sku.id, barcode: sku.barcode, batchNo: sku.batchNo, model: sku.model,
color: sku.color, material: sku.material, versionNo: sku.versionNo,
attributeCode: sku.attributeCode, drawingId: sku.drawingId,
} : null;
});
const sources = buildSourceFileMap(product, session.styleKey, session.sourceBarcode);
return createHash("sha256").update(JSON.stringify({ skus, sources })).digest("hex");
}
function whiteBackgroundContextSignature(product: NonIntegratedProductRecord, session: AiWhiteBackgroundSession) {
return createHash("sha256").update(JSON.stringify({
source: whiteBackgroundProductSourceSignature(product, session),
styleKey: session.styleKey, targetBarcode: session.targetBarcode,
sourceBarcode: session.sourceBarcode, skuBatchNo: session.skuBatchNo,
targetColor: session.targetColor,
recipe: createAiWhiteBackgroundMaterialRecipeSignature(session.partGroups ?? []),
styleRecipe: product.styleDetails.find((style) => style.styleKey.toUpperCase() === session.styleKey.toUpperCase())?.aiWhiteBackgroundPartGroups,
skuRecipe: findSkuItemByBarcodeAndBatchNo(product, session.targetBarcode, session.skuBatchNo)?.aiWhiteBackgroundPartGroups,
})).digest("hex");
}
function whiteBackgroundSessionStatus(session: AiWhiteBackgroundSession): AiWhiteBackgroundSession["status"] {
const tasks = Object.values(session.generationTasksByView ?? {});
if (tasks.some((task) => task?.status === "pending")) return "generating";
if (tasks.some((task) => task?.status === "failed")) return "generation_failed";
return Object.keys(session.generatedFileIdsByView).length ? "generated" : "draft";
}
async function readWhiteBackgroundWorkerUser(id: string): Promise<AuthUser> {
const user = await userRepository.findPermissionUserById(id);
if (!user || user.status !== "ACTIVE") {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, "白底图任务发起人已停用或不存在。");
}
return {
id: user.id, email: user.email, name: user.name, role: user.role, status: user.status,
businessRoles: user.businessRoles, permissionRoleId: user.permissionRoleId,
loginSource: user.loginSource, supplierCode: user.supplierCode, supplierName: user.supplierName,
department: user.department, departmentId: user.departmentId ?? undefined,
teamId: user.teamId ?? undefined, managedDepartmentId: user.managedDepartment?.id,
managedTeamId: user.managedTeam?.id,
};
}
export async function completeNativeWhiteBackgroundGeneration(
completion: NativeWhiteBackgroundCompletion,
result: ExternalImageGenerateResult,
): Promise<void> {
const user = await readWhiteBackgroundWorkerUser(completion.createdBy);
const firstProduct = await getWritableProduct(completion.productId, user);
const firstSession = getAiWhiteBackgroundSessionById(firstProduct.metadata, completion.sessionId);
const firstTask = firstSession?.generationTasksByView?.[completion.viewKey];
if (!firstSession || firstTask?.taskId !== completion.taskId || firstTask.status === "failed") return;
if (firstTask.status === "succeeded") return;
if (whiteBackgroundContextSignature(firstProduct, firstSession) !== completion.expectedContextSignature) {
throw new AppError(ErrorCode.VALIDATION_FAILED, "产品、源图或材质方案已变更,本次结果未覆盖新的资料,请重新生成。");
}
const imageUrl = result.images.find((image) => image.originalUrl.trim())?.originalUrl;
if (!imageUrl) throw new AppError(ErrorCode.FILE_UPLOAD_FAILED, "AI 白底图服务没有返回可用图片。");
const generated = await fetchRemoteImageBuffer(imageUrl);
const target = await createNonIntegratedUploadTarget({
originalFileName: createGeneratedFileName({ styleKey: firstSession.styleKey, barcode: firstSession.targetBarcode, viewKey: completion.viewKey }),
productId: completion.productId, category: AI_WHITE_BACKGROUND_WORKSPACE_CATEGORY,
});
const key = buildNonIntegratedAssetObjectStorageKey(target.storedFileName);
await uploadBufferToObjectStorage(generated.buffer, key, generated.contentType);
const file = {
id: completion.fileId, name: path.basename(target.storedFileName),
category: AI_WHITE_BACKGROUND_WORKSPACE_CATEGORY, dataUrl: buildStableObjectAccessUrl(key),
mimeType: generated.contentType, photoGroup: AI_WHITE_BACKGROUND_UPLOAD_FILE_GROUP,
productPhotoStyleKey: firstSession.styleKey, productPhotoBarcode: firstSession.targetBarcode,
aiWhiteBackgroundSessionId: firstSession.id, aiWhiteBackgroundViewKey: completion.viewKey,
aiWhiteBackgroundSourceSlot: completion.sourceSlot, storageKey: target.storedFileName,
size: generated.buffer.length,
} satisfies NonIntegratedUploadFile & { storageKey: string; size: number };
let committed = false;
try {
for (let attempt = 0; attempt < AI_WHITE_BACKGROUND_SAVE_RETRY_LIMIT; attempt += 1) {
const product = await getWritableProduct(completion.productId, user);
const session = getAiWhiteBackgroundSessionById(product.metadata, completion.sessionId);
const task = session?.generationTasksByView?.[completion.viewKey];
if (!session || task?.taskId !== completion.taskId || task.status !== "pending") return;
if (whiteBackgroundContextSignature(product, session) !== completion.expectedContextSignature) {
throw new AppError(ErrorCode.VALIDATION_FAILED, "产品、源图或材质方案已变更,本次结果未覆盖新的资料,请重新生成。");
}
const now = new Date().toISOString();
const next: AiWhiteBackgroundSession = {
...session,
sourceFileIdsBySlot: { ...session.sourceFileIdsBySlot },
generatedFileIdsByView: { ...session.generatedFileIdsByView, [completion.viewKey]: completion.fileId },
structureReferenceFileIdsByView: { ...session.structureReferenceFileIdsByView, [completion.viewKey]: completion.structureReferenceFileIds },
refinementPromptsByView: { ...session.refinementPromptsByView },
generationTasksByView: { ...session.generationTasksByView, [completion.viewKey]: { ...task, status: "succeeded", errorMessage: undefined, updatedAt: now } },
updatedAt: now,
};
if (completion.sourceFileId) next.sourceFileIdsBySlot[completion.sourceSlot] = completion.sourceFileId;
else delete next.sourceFileIdsBySlot[completion.sourceSlot];
if (completion.applyRefinementPrompt) {
if (completion.refinementPrompt) next.refinementPromptsByView![completion.viewKey] = completion.refinementPrompt;
else delete next.refinementPromptsByView![completion.viewKey];
}
next.status = whiteBackgroundSessionStatus(next);
const saved = await intakeNonIntegratedProductService.savePayloadOptimistically({
...product, metadata: applyUpdatedSession(product.metadata, next),
uploadFiles: buildUpdatedUploadFiles(product.uploadFiles, [file]), updatedAt: now,
}, user, product.updatedAt);
if (!saved) continue;
committed = true;
// Cleanup failure must not turn a committed generation into a failed task.
await deleteUploadFileObjectsNoLongerReferenced(product.uploadFiles.filter((old) => old.id === file.id), saved.uploadFiles)
.catch(() => console.warn("[ai-white-background.cleanup-pending]", { taskId: completion.taskId }));
return;
}
throw new AppError(ErrorCode.SYSTEM_INTERNAL, "白底图结果暂时无法保存,请稍后重试归档。");
} finally {
if (!committed) {
// The save may have committed before its response was lost. An unreadable
// product is also an unknown outcome; retain the object for later audit.
let knownUnreferenced = false;
try {
const latest = await intakeNonIntegratedProductService.getById(completion.productId, user);
if (latest) {
knownUnreferenced = !new Set(extractObjectStorageKeys(latest, "product")).has(key);
}
} catch { /* Preserve the output when the save outcome cannot be verified. */ }
if (knownUnreferenced) await deleteObject(key);
}
}
}
export async function failNativeWhiteBackgroundGeneration(
completion: NativeWhiteBackgroundCompletion,
errorMessage: string,
keepPending = false,
): Promise<void> {
const user = await readWhiteBackgroundWorkerUser(completion.createdBy);
for (let attempt = 0; attempt < AI_WHITE_BACKGROUND_SAVE_RETRY_LIMIT; attempt += 1) {
const product = await getWritableProduct(completion.productId, user);
const session = getAiWhiteBackgroundSessionById(product.metadata, completion.sessionId);
const task = session?.generationTasksByView?.[completion.viewKey];
if (!session || task?.taskId !== completion.taskId || task.status !== "pending") return;
const now = new Date().toISOString();
const next: AiWhiteBackgroundSession = {
...session, updatedAt: now,
generationTasksByView: { ...session.generationTasksByView, [completion.viewKey]: { ...task, status: keepPending ? "pending" : "failed", errorMessage, updatedAt: now } },
};
next.status = whiteBackgroundSessionStatus(next);
if (await intakeNonIntegratedProductService.savePayloadOptimistically({
...product, metadata: applyUpdatedSession(product.metadata, next), updatedAt: now,
}, user, product.updatedAt)) return;
}
throw new AppError(ErrorCode.SYSTEM_INTERNAL, "白底图任务状态暂时无法保存。");
}
export async function markNativeWhiteBackgroundBlockedGeneration(
completion: NativeWhiteBackgroundCompletion,
errorMessage: string,
): Promise<void> {
await failNativeWhiteBackgroundGeneration(completion, `任务状态待核对,请勿重复提交:${errorMessage}`, true);
}
export class NonIntegratedAiWhiteBackgroundService {
async getClientCapabilities(
productId: string,
@ -2539,6 +2522,13 @@ export class NonIntegratedAiWhiteBackgroundService {
partGroups,
});
const sessionId = trimToNull(input.sessionId) ?? existingSession?.id ?? buildSessionId();
const overlappingSession = readNonIntegratedAiWhiteBackgroundMetadata(product.metadata).sessions.find((entry) =>
entry.id !== sessionId && entry.styleKey === styleKey && entry.targetBarcode === targetBarcode &&
effectiveRequestedViewKeys.some((viewKey) => entry.generationTasksByView?.[viewKey]?.status === "pending"),
);
if (overlappingSession) {
throw new AppError(ErrorCode.VALIDATION_FAILED, "该条码的视图已有后台生成任务,请等待当前任务完成后再提交。");
}
const session = createAiWhiteBackgroundSession({
sessionId,
styleKey,
@ -2559,302 +2549,115 @@ export class NonIntegratedAiWhiteBackgroundService {
(reference): reference is AiWhiteBackgroundReferenceImage =>
reference !== null,
);
const generationStartedAt = Date.now();
console.info("[ai-white-background.generate.request]", {
productId: normalizedProductId,
sessionId: session.id,
styleKey,
targetBarcode,
sourceBarcode,
skuBatchNo,
requestedViewKeys,
effectiveRequestedViewKeys,
sourceSlots: availableSourceSlots,
sourceFileCount,
generatedFileCountBefore: Object.keys(session.generatedFileIdsByView).length,
});
const nextGeneratedFiles: Array<
NonIntegratedUploadFile & { storageKey?: string; size?: number }
> = [];
const nextSourceFileIdsBySlot = { ...session.sourceFileIdsBySlot };
const nextGeneratedFileIdsByView = { ...session.generatedFileIdsByView };
const nextStructureReferenceFileIdsByView = {
...(session.structureReferenceFileIdsByView ?? {}),
};
const nextRefinementPromptsByView = {
...(session.refinementPromptsByView ?? {}),
};
const requestId = trimToNull(input.requestId) ?? randomUUID();
if (requestId.length > 128) {
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "生成请求编号过长。");
}
const sourceSignature = whiteBackgroundProductSourceSignature(product, session);
for (const viewKey of effectiveRequestedViewKeys) {
const viewStartedAt = Date.now();
const requestSignature = createHash("sha256").update(JSON.stringify({
sourceSignature, styleKey, targetBarcode, sourceBarcode, targetColor, partGroups, viewKey,
refinementPrompt: shouldApplyRefinementPrompt ? requestedRefinementPrompt : null,
})).digest("hex");
const currentProduct = await getWritableProduct(normalizedProductId, user);
const currentSession = getAiWhiteBackgroundSessionById(currentProduct.metadata, sessionId);
const currentTask = currentSession?.generationTasksByView?.[viewKey];
// A repeated browser request or an already running view reuses the durable job.
if (currentTask?.requestId === requestId || currentTask?.status === "pending") {
if (currentTask.requestSignature && currentTask.requestSignature !== requestSignature) {
throw new AppError(ErrorCode.VALIDATION_FAILED, "该视图已有不同内容的生成请求,请等待当前任务结束后重新提交。");
}
continue;
}
const taskId = `white-background-${createHash("sha256").update(`${normalizedProductId}:${sessionId}:${requestId}:${viewKey}`).digest("hex")}`;
const sourceSlot = AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[viewKey];
const refinementPromptContext = shouldApplyRefinementPrompt
const refinement = shouldApplyRefinementPrompt
? buildAiWhiteBackgroundActiveRefinementPrompt({
viewKey,
refinementPromptsByView: session.refinementPromptsByView,
viewKey, refinementPromptsByView: session.refinementPromptsByView,
requestedPrompt: requestedRefinementPrompt,
})
: { promptForGeneration: null, promptForSession: null };
const activeRefinementPrompt = refinementPromptContext.promptForGeneration;
const primaryReference = findAiWhiteBackgroundPrimaryReferenceFile(
viewKey,
sourceFileMap,
);
const primaryReference = findAiWhiteBackgroundPrimaryReferenceFile(viewKey, sourceFileMap);
if (!primaryReference) {
console.error("[ai-white-background.generate.view-missing-source]", {
productId: normalizedProductId,
sessionId: session.id,
styleKey,
targetBarcode,
sourceBarcode,
viewKey,
sourceSlot,
sourceSlots: availableSourceSlots,
});
throw new AppError(
ErrorCode.FILE_NOT_FOUND,
`没有找到 ${AI_WHITE_BACKGROUND_SOURCE_SLOT_LABELS[sourceSlot]} 源图。`,
);
throw new AppError(ErrorCode.FILE_NOT_FOUND, `没有找到 ${AI_WHITE_BACKGROUND_SOURCE_SLOT_LABELS[sourceSlot]} 源图。`);
}
const sourceBuffer = await readAssetBufferByUrl(primaryReference.file.dataUrl);
const normalizedReferenceBuffer =
await normalizeReferenceImageForAi(sourceBuffer);
const structureReferences =
await buildAiWhiteBackgroundStructureReferencesForView(
viewKey,
sourceFileMap,
{
const normalizedReferenceBuffer = await normalizeReferenceImageForAi(
await readAssetBufferByUrl(primaryReference.file.dataUrl),
);
const structureReferences = await buildAiWhiteBackgroundStructureReferencesForView(viewKey, sourceFileMap, {
excludeFileIds: new Set([primaryReference.file.id]),
maxReferences: primaryReference.isDirect ? 2 : 4,
strategy: primaryReference.isDirect ? "adjacent" : "nearest",
},
);
const generatedConsistencyReferences = activeRefinementPrompt
? await buildAiWhiteBackgroundGeneratedConsistencyReferencesForView(
viewKey,
product.uploadFiles,
session.generatedFileIdsByView,
)
});
const consistencyReferences = refinement.promptForGeneration
? await buildAiWhiteBackgroundGeneratedConsistencyReferencesForView(viewKey, product.uploadFiles, session.generatedFileIdsByView)
: { referenceImages: [], fileIds: [], viewLabels: [] };
console.info("[ai-white-background.generate.view-start]", {
productId: normalizedProductId,
sessionId: session.id,
styleKey,
targetBarcode,
sourceBarcode,
viewKey,
sourceSlot,
primaryReferenceViewKey: primaryReference.viewKey,
primaryReferenceFileId: primaryReference.file.id,
primaryReferenceIsDirect: primaryReference.isDirect,
structureReferenceFileIds: structureReferences.fileIds,
generatedConsistencyReferenceFileIds:
generatedConsistencyReferences.fileIds,
refinementPrompt: activeRefinementPrompt,
savedRefinementPrompt: refinementPromptContext.promptForSession,
const proposedProduct = applyAiWhiteBackgroundPartGroupTemplates({
product: currentProduct, styleKey, targetBarcode, skuBatchNo, partGroups, confirmedPartGroupingSource,
});
let generatedResult: Awaited<
ReturnType<typeof generateAiWhiteBackgroundImage>
>;
try {
generatedResult = await generateAiWhiteBackgroundImage({
referenceBuffer: normalizedReferenceBuffer,
referenceContentType: "image/jpeg",
additionalReferenceImages: [
...materialReferenceImages,
...generatedConsistencyReferences.referenceImages,
...structureReferences.referenceImages,
],
prompt: buildAiWhiteBackgroundPrompt(
viewKey,
targetColor,
materialReferences,
structureReferences.viewLabels,
generatedConsistencyReferences.viewLabels,
primaryReference.viewKey,
activeRefinementPrompt,
),
negativePrompt: buildAiWhiteBackgroundNegativePrompt(
targetColor,
viewKey,
Boolean(activeRefinementPrompt),
),
const completion: NativeWhiteBackgroundCompletion = {
kind: "white-background", productId: normalizedProductId, sessionId, viewKey, taskId,
createdBy: user.id,
expectedContextSignature: whiteBackgroundContextSignature(proposedProduct, session),
sourceFileId: primaryReference.isDirect ? primaryReference.file.id : null,
sourceSlot, structureReferenceFileIds: structureReferences.fileIds,
refinementPrompt: refinement.promptForSession, applyRefinementPrompt: shouldApplyRefinementPrompt,
fileId: session.generatedFileIdsByView[viewKey] ?? `file-${taskId}`,
};
await enqueueNativeImageGeneration({
id: taskId, createdBy: user.id, completion,
input: {
prompt: buildAiWhiteBackgroundPrompt(viewKey, targetColor, materialReferences,
structureReferences.viewLabels, consistencyReferences.viewLabels, primaryReference.viewKey, refinement.promptForGeneration),
negativePrompt: buildAiWhiteBackgroundNegativePrompt(targetColor, viewKey, Boolean(refinement.promptForGeneration)),
imageCount: 1, aspectRatio: AI_WHITE_BACKGROUND_ASPECT_RATIO, imageSize: AI_WHITE_BACKGROUND_IMAGE_SIZE,
referenceImage: { buffer: normalizedReferenceBuffer, contentType: "image/jpeg", fileName: "white-background-reference.jpg" },
additionalReferenceImages: [...materialReferenceImages, ...consistencyReferences.referenceImages, ...structureReferences.referenceImages],
disabledMessage: "AI 白底图服务未启用或未配置。",
},
}, async (persistJob) => {
for (let attempt = 0; attempt < AI_WHITE_BACKGROUND_SAVE_RETRY_LIMIT; attempt += 1) {
const latest = await getWritableProduct(normalizedProductId, user);
if (whiteBackgroundProductSourceSignature(latest, session) !== sourceSignature) {
throw new AppError(ErrorCode.VALIDATION_FAILED, "产品或源图已变更,请刷新后重新提交白底图任务。");
}
const base = getAiWhiteBackgroundSessionById(latest.metadata, sessionId);
if (readNonIntegratedAiWhiteBackgroundMetadata(latest.metadata).sessions.some((entry) =>
entry.id !== sessionId && entry.styleKey === styleKey && entry.targetBarcode === targetBarcode &&
entry.generationTasksByView?.[viewKey]?.status === "pending")) {
throw new AppError(ErrorCode.VALIDATION_FAILED, "该条码的视图已有后台生成任务,请等待当前任务完成后再提交。");
}
const active = base?.generationTasksByView?.[viewKey];
if (active?.status === "pending" && active.taskId !== taskId) {
throw new AppError(ErrorCode.VALIDATION_FAILED, "该视图已在生成中,请等待当前任务完成。");
}
const updatedAt = new Date().toISOString();
const nextSession = createAiWhiteBackgroundSession({
sessionId, styleKey, targetBarcode, sourceBarcode, skuBatchNo, targetColor,
partGroups, confirmedPartGroupingSource, now: updatedAt, baseSession: base ?? session,
});
} catch (error) {
console.error("[ai-white-background.generate.view-failed]", {
productId: normalizedProductId,
sessionId: session.id,
styleKey,
targetBarcode,
sourceBarcode,
viewKey,
phase: "external-generate",
durationMs: Date.now() - viewStartedAt,
error: toAiWhiteBackgroundErrorLog(error),
nextSession.generationTasksByView = {
...nextSession.generationTasksByView,
[viewKey]: { taskId, requestId, requestSignature, status: "pending", updatedAt },
};
nextSession.status = "generating";
const nextProduct = applyAiWhiteBackgroundPartGroupTemplates({
product: { ...latest, metadata: applyUpdatedSession(latest.metadata, nextSession), updatedAt },
styleKey, targetBarcode, skuBatchNo, partGroups, confirmedPartGroupingSource,
});
if (error instanceof AppError) {
throw new AppError(
error.code,
`${AI_WHITE_BACKGROUND_VIEW_LABELS[viewKey]}生成失败:${error.message}`,
);
if (whiteBackgroundContextSignature(nextProduct, nextSession) !== completion.expectedContextSignature) {
throw new AppError(ErrorCode.VALIDATION_FAILED, "材质方案已变更,请刷新后重新提交白底图任务。");
}
throw error;
if (await saveWhiteBackgroundIntentWithJob(nextProduct, latest.updatedAt, user.id, persistJob)) return;
}
let generatedFile: Awaited<ReturnType<typeof fetchRemoteImageBuffer>>;
try {
generatedFile = await fetchRemoteImageBuffer(generatedResult.imageUrl);
} catch (error) {
console.error("[ai-white-background.generate.view-failed]", {
productId: normalizedProductId,
sessionId: session.id,
styleKey,
targetBarcode,
sourceBarcode,
viewKey,
phase: "result-fetch",
durationMs: Date.now() - viewStartedAt,
error: toAiWhiteBackgroundErrorLog(error),
});
if (error instanceof AppError) {
throw new AppError(
error.code,
`${AI_WHITE_BACKGROUND_VIEW_LABELS[viewKey]}结果图回拉失败:${error.message}`,
);
}
throw error;
}
const fileId =
session.generatedFileIdsByView[viewKey] ||
`file-${Date.now()}-${randomUUID().slice(0, 8)}`;
const uploadTarget = await createNonIntegratedUploadTarget({
originalFileName: createGeneratedFileName({
styleKey,
barcode: targetBarcode,
viewKey,
}),
productId: normalizedProductId,
category: AI_WHITE_BACKGROUND_WORKSPACE_CATEGORY,
});
const objectStorageKey = buildNonIntegratedAssetObjectStorageKey(
uploadTarget.storedFileName,
);
try {
await uploadBufferToObjectStorage(
generatedFile.buffer,
objectStorageKey,
generatedFile.contentType,
);
} catch (error) {
console.error("[ai-white-background.generate.view-failed]", {
productId: normalizedProductId,
sessionId: session.id,
styleKey,
targetBarcode,
sourceBarcode,
viewKey,
phase: "object-upload",
durationMs: Date.now() - viewStartedAt,
error: toAiWhiteBackgroundErrorLog(error),
});
throw error;
}
if (primaryReference.isDirect) {
nextSourceFileIdsBySlot[sourceSlot] = primaryReference.file.id;
} else {
delete nextSourceFileIdsBySlot[sourceSlot];
}
nextGeneratedFileIdsByView[viewKey] = fileId;
if (structureReferences.fileIds.length > 0) {
nextStructureReferenceFileIdsByView[viewKey] = [
...structureReferences.fileIds,
];
} else {
delete nextStructureReferenceFileIdsByView[viewKey];
}
if (shouldApplyRefinementPrompt) {
if (refinementPromptContext.promptForSession) {
nextRefinementPromptsByView[viewKey] =
refinementPromptContext.promptForSession;
} else {
delete nextRefinementPromptsByView[viewKey];
}
}
nextGeneratedFiles.push({
id: fileId,
name: path.basename(uploadTarget.storedFileName),
category: AI_WHITE_BACKGROUND_WORKSPACE_CATEGORY,
dataUrl: buildStableObjectAccessUrl(objectStorageKey),
mimeType: generatedFile.contentType,
photoGroup: AI_WHITE_BACKGROUND_UPLOAD_FILE_GROUP,
productPhotoStyleKey: styleKey,
productPhotoBarcode: targetBarcode,
aiWhiteBackgroundSessionId: session.id,
aiWhiteBackgroundViewKey: viewKey,
aiWhiteBackgroundSourceSlot: sourceSlot,
storageKey: uploadTarget.storedFileName,
size: generatedFile.buffer.length,
});
console.info("[ai-white-background.generate.view-ready]", {
productId: normalizedProductId,
sessionId: session.id,
styleKey,
targetBarcode,
sourceBarcode,
viewKey,
generatedFileId: fileId,
size: generatedFile.buffer.length,
durationMs: Date.now() - viewStartedAt,
throw new AppError(ErrorCode.SYSTEM_INTERNAL, "白底图任务暂时无法保存,请稍后重试。");
});
}
try {
const saveStartedAt = Date.now();
const savedSession = await saveGeneratedSessionWithRetry({
productId: normalizedProductId,
user,
sessionId,
styleKey,
targetBarcode,
sourceBarcode,
skuBatchNo,
targetColor,
partGroups,
confirmedPartGroupingSource,
sourceFileIdsBySlot: nextSourceFileIdsBySlot,
generatedFileIdsByView: nextGeneratedFileIdsByView,
structureReferenceFileIdsByView: nextStructureReferenceFileIdsByView,
refinementPromptsByView: nextRefinementPromptsByView,
generatedFiles: nextGeneratedFiles,
});
console.info("[ai-white-background.generate.session-saved]", {
productId: normalizedProductId,
sessionId,
styleKey,
targetBarcode,
sourceBarcode,
generatedViews: Object.keys(nextGeneratedFileIdsByView),
newGeneratedFileCount: nextGeneratedFiles.length,
saveDurationMs: Date.now() - saveStartedAt,
totalDurationMs: Date.now() - generationStartedAt,
});
return savedSession;
} catch (error) {
console.error("[ai-white-background.generate.session-save-failed]", {
productId: normalizedProductId,
sessionId,
styleKey,
targetBarcode,
sourceBarcode,
newGeneratedFileCount: nextGeneratedFiles.length,
error: toAiWhiteBackgroundErrorLog(error),
});
await deleteUploadFileObjects(nextGeneratedFiles);
throw error;
}
const savedProduct = await getWritableProduct(normalizedProductId, user);
return {
product: savedProduct,
session: getAiWhiteBackgroundSessionById(savedProduct.metadata, sessionId) ?? session,
};
}
async uploadSession(
@ -2872,6 +2675,9 @@ export class NonIntegratedAiWhiteBackgroundService {
if (!session) {
throw new AppError(ErrorCode.FILE_NOT_FOUND, "没有找到要上传的白底图会话。");
}
if (Object.values(session.generationTasksByView ?? {}).some((task) => task?.status === "pending")) {
throw new AppError(ErrorCode.VALIDATION_FAILED, "白底图仍在生成或等待状态核对,请完成后再上传素材库。");
}
const uploadBarcode =
trimToNull(input.targetBarcode ?? input.barcode)?.toUpperCase() ??
session.targetBarcode;

View File

@ -0,0 +1,290 @@
import type { ExternalImageGenerateImageDTO } from './external-image-generate.service'
export interface RelayImageTaskInput {
model: string
prompt: string
negativePrompt?: string | null
aspectRatio: string
imageSize: string
referenceImageUrls: string[]
}
export interface RelayImageTaskConfig {
baseUrl: string
apiKey: string
routeOptionId?: string
}
export type RelayImageTaskResult =
| { status: 'pending'; needsReview?: boolean }
| { status: 'succeeded'; images: ExternalImageGenerateImageDTO[] }
| { status: 'failed'; errorMessage: string }
/** Retry only with the persisted task ID / original idempotency key and payload. */
export class RelayImageTaskError extends Error {
readonly code: string
readonly retryable: boolean
readonly outcomeUnknown: boolean
readonly statusCode?: number
constructor(
code: string,
message: string,
options: { retryable: boolean; outcomeUnknown?: boolean; statusCode?: number },
) {
super(message)
this.name = 'RelayImageTaskError'
this.code = code
this.retryable = options.retryable
this.outcomeUnknown = options.outcomeUnknown ?? false
this.statusCode = options.statusCode
}
}
const REQUEST_TIMEOUT_MS = 30_000
const TASK_PATH = '/extensions/v1/tasks'
const TERMINAL_MESSAGES: Readonly<Record<string, string>> = {
task_failed: 'AI 图片生成失败。',
task_timeout: 'AI 图片生成超时。',
task_cancelled: 'AI 图片生成任务已取消。',
task_expired: 'AI 图片生成结果已过期。',
content_policy_violation: '提示词或参考图片未通过模型的内容检查。',
}
const ERROR_MESSAGES: Readonly<Record<string, string>> = {
...TERMINAL_MESSAGES,
invalid_request: 'AI 图片请求参数不符合模型要求。',
invalid_api_key: 'AI 服务密钥无效,请联系管理员检查配置。',
authentication_error: 'AI 服务身份验证失败,请联系管理员检查配置。',
permission_denied: '当前 AI 服务账号没有此操作权限。',
insufficient_quota: 'AI 服务可用额度不足。',
rate_limit_exceeded: 'AI 服务请求过于频繁,请稍后继续查询。',
idempotency_conflict: 'AI 请求标识已用于不同的请求,需要管理员核查。',
idempotency_in_progress: 'AI 请求仍在处理中,将使用原请求标识继续查询。',
idempotency_indeterminate: 'AI 请求结果尚未确认,请保留原请求标识。',
request_outcome_unknown: 'AI 请求结果尚未确认,请保留原请求标识。',
task_result_unavailable: 'AI 图片结果暂时无法读取,请继续查询原任务。',
task_state_unknown: 'AI 任务状态暂时无法确认,请继续查询原任务。',
service_unavailable: 'AI 服务暂时不可用,请稍后重试原请求。',
}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function invalidInput(message: string): never {
throw new RelayImageTaskError('invalid_input', message, { retryable: false })
}
function isHttpsUrl(value: unknown): value is string {
if (typeof value !== 'string' || value.length > 32_768 || value !== value.trim()) return false
try {
const url = new URL(value)
return url.protocol === 'https:' && !url.username && !url.password && !url.hash
} catch {
return false
}
}
function relayEndpoint(config: RelayImageTaskConfig, pathname: string): string {
if (!isHttpsUrl(config.baseUrl)) invalidInput('AI 服务地址必须是有效的 HTTPS 地址。')
const base = new URL(config.baseUrl)
// Native endpoints are rooted at the configured Relay origin. A pasted legacy
// endpoint or a query-bearing URL must not silently send the key elsewhere.
if (base.pathname !== '/' || base.search) invalidInput('AI 服务地址应填写 Relay 的 HTTPS 根地址。')
if (!config.apiKey || config.apiKey !== config.apiKey.trim() || /[\r\n]/.test(config.apiKey)) {
invalidInput('AI 服务密钥配置无效。')
}
return new URL(pathname, base).toString()
}
function safeIdentifier(value: unknown): value is string {
return typeof value === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(value) && value !== '.' && value !== '..'
}
export function buildRelayImageTaskPayload(input: RelayImageTaskInput): Record<string, unknown> {
if (!input.model.trim() || input.model.length > 256) invalidInput('请选择有效的 AI 模型。')
if (!input.prompt.trim()) invalidInput('AI 图片提示词不能为空。')
if (!input.aspectRatio.trim() || input.aspectRatio.length > 32) invalidInput('AI 图片比例无效。')
if (!['1K', '2K', '4K'].includes(input.imageSize)) invalidInput('AI 图片尺寸应为 1K、2K 或 4K。')
if (input.referenceImageUrls.length > 16 || input.referenceImageUrls.some(url => !isHttpsUrl(url))) {
invalidInput('AI 参考图必须使用 HTTPS 地址,且不能超过 16 张。')
}
// The native schema has no negativePrompt property. Preserve both original
// texts and express the exclusion requirements in its supported prompt field.
const prompt = input.negativePrompt?.trim()
? `${input.prompt}\n\nNegative constraints (must not appear in the generated image):\n${input.negativePrompt}`
: input.prompt
if (Array.from(prompt).length > 32_000) invalidInput('AI 图片提示词与排除要求合计不能超过 32000 个字符。')
return {
model: input.model,
prompt,
aspect_ratio: input.aspectRatio,
resolution: input.imageSize,
...(input.referenceImageUrls.length ? { file_urls: [...input.referenceImageUrls] } : {}),
}
}
function malformedResponse(statusCode?: number): RelayImageTaskError {
return new RelayImageTaskError('invalid_response', 'AI 服务响应无法确认,请继续查询原任务或重试原请求标识。', {
retryable: true,
outcomeUnknown: true,
statusCode,
})
}
function publicErrorCode(body: unknown): string | null {
const code = record(record(body)?.error)?.code
return typeof code === 'string' && Object.hasOwn(ERROR_MESSAGES, code) ? code : null
}
function httpError(statusCode: number, body: unknown): RelayImageTaskError {
const code = publicErrorCode(body) ?? 'http_error'
const uncertain = ['idempotency_indeterminate', 'request_outcome_unknown'].includes(code)
const retryable = statusCode >= 500 || statusCode === 429 || statusCode === 408 ||
code === 'idempotency_in_progress' || uncertain
return new RelayImageTaskError(
code,
ERROR_MESSAGES[code] ?? (retryable
? 'AI 服务暂时无法确认请求,请使用原任务或原请求标识重试。'
: 'AI 服务拒绝了此请求,请联系管理员检查模型权限与配置。'),
{ retryable, outcomeUnknown: uncertain || statusCode >= 500 || statusCode === 408, statusCode },
)
}
async function requestJson(
config: RelayImageTaskConfig,
pathname: string,
init: RequestInit,
authenticated = true,
): Promise<{ status: number; body: unknown }> {
const endpoint = relayEndpoint(config, pathname)
const controller = new AbortController()
let timeout: ReturnType<typeof setTimeout> | undefined
// The deadline covers headers AND response body, including a stalled body
// stream. No generated asset URL is fetched by this credentialed client.
const expired = new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
controller.abort()
reject(new RelayImageTaskError('request_timeout', 'AI 服务响应超时,请使用原任务或原请求标识重试。', {
retryable: true,
outcomeUnknown: true,
}))
}, REQUEST_TIMEOUT_MS)
})
try {
return await Promise.race([
(async () => {
const response = await fetch(endpoint, {
...init,
headers: {
Accept: 'application/json',
...init.headers,
...(authenticated ? { Authorization: `Bearer ${config.apiKey}` } : {}),
},
signal: controller.signal,
redirect: 'error',
cache: 'no-store',
})
let body: unknown
try {
body = await response.json()
} catch {
// A non-JSON 4xx/5xx still has useful HTTP semantics. A successful
// response without readable JSON cannot prove submission or completion.
if (response.status < 400) throw malformedResponse(response.status)
body = null
}
return { status: response.status, body }
})(),
expired,
])
} catch (error) {
if (error instanceof RelayImageTaskError) throw error
throw new RelayImageTaskError('network_error', 'AI 服务连接中断,请使用原任务或原请求标识重试。', {
retryable: true,
outcomeUnknown: true,
})
} finally {
if (timeout !== undefined) clearTimeout(timeout)
}
}
export async function createRelayImageTask(
config: RelayImageTaskConfig,
input: RelayImageTaskInput,
idempotencyKey: string,
): Promise<{ taskId: string }> {
if (!safeIdentifier(idempotencyKey)) invalidInput('AI 请求标识无效,必须保留同一次请求的固定标识。')
if (config.routeOptionId !== undefined && !safeIdentifier(config.routeOptionId)) {
invalidInput('AI 模型线路配置无效。')
}
const { status, body } = await requestJson(config, TASK_PATH, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
...(config.routeOptionId ? { 'X-Relay-Route-Option': config.routeOptionId } : {}),
},
body: JSON.stringify(buildRelayImageTaskPayload(input)),
})
if (status >= 400) throw httpError(status, body)
const payload = record(body)
if (status !== 202 || payload?.object !== 'extension.task' || !safeIdentifier(payload.id)) {
throw malformedResponse(status)
}
return { taskId: payload.id }
}
export async function getRelayImageTaskResult(
config: RelayImageTaskConfig,
taskId: string,
): Promise<RelayImageTaskResult> {
if (!safeIdentifier(taskId)) invalidInput('AI 任务标识无效。')
const { status, body } = await requestJson(config, `${TASK_PATH}/${encodeURIComponent(taskId)}/result`, { method: 'GET' })
const terminalCode = publicErrorCode(body)
if (status === 409 && terminalCode && Object.hasOwn(TERMINAL_MESSAGES, terminalCode)) {
return { status: 'failed', errorMessage: TERMINAL_MESSAGES[terminalCode]! }
}
if (status >= 400) throw httpError(status, body)
const payload = record(body)
if (payload?.object !== 'extension.task.result' || payload.id !== taskId) throw malformedResponse(status)
if (status === 202 && payload.ready === false && ['queued', 'in_progress', 'finalizing', 'pending_review'].includes(String(payload.status))) {
return payload.status === 'pending_review' ? { status: 'pending', needsReview: true } : { status: 'pending' }
}
if (status !== 200 || payload.ready !== true || !['succeeded', 'partially_succeeded'].includes(String(payload.status))) {
throw malformedResponse(status)
}
const outputs = record(payload.result)?.outputs
if (!Array.isArray(outputs) || !outputs.length) throw malformedResponse(status)
const images: ExternalImageGenerateImageDTO[] = []
for (const item of outputs) {
const output = record(item)
if (output?.type !== 'image') continue
if (!isHttpsUrl(output.url)) throw malformedResponse(status)
images.push({
originalUrl: output.url,
...(safeIdentifier(output.asset_id) ? { id: output.asset_id } : {}),
})
}
if (!images.length) throw malformedResponse(status)
return { status: 'succeeded', images }
}
/** Read-only inputs for contract selection; the public catalog must be anonymous. */
export async function readRelayImageTaskContractSources(
config: RelayImageTaskConfig,
): Promise<{ authorizedModels: unknown; publicCatalog: unknown }> {
const read = async (pathname: string, authenticated: boolean) => {
const { status, body } = await requestJson(config, pathname, { method: 'GET' }, authenticated)
if (status >= 400) throw httpError(status, body)
if (status !== 200 || !record(body)) throw malformedResponse(status)
return body
}
const [authorizedModels, publicCatalog] = await Promise.all([
read('/v1/models', true),
read('/api/v2/catalog/models', false),
])
return { authorizedModels, publicCatalog }
}

View File

@ -0,0 +1,172 @@
import {
buildRelayImageTaskPayload,
readRelayImageTaskContractSources,
RelayImageTaskError,
type RelayImageTaskConfig,
type RelayImageTaskInput,
} from './relay-image-task.client'
type JsonRecord = Record<string, unknown>
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : null
}
function records(value: unknown): JsonRecord[] {
return Array.isArray(value) ? value.map(record).filter((item): item is JsonRecord => item !== null) : []
}
function rejectContract(code: string, message: string): never {
throw new RelayImageTaskError(code, message, { retryable: false })
}
function equalJson(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
// Validate the JSON Schema subset published by Relay for native image inputs.
// Unknown validation keywords are rejected rather than silently weakening a
// model contract. This is intentionally not a general JSON Schema engine.
const SCHEMA_KEYS = new Set([
'type', 'properties', 'required', 'additionalProperties', 'allOf', 'anyOf', 'oneOf', 'not',
'enum', 'const', 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength',
'pattern', 'format', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum',
'title', 'description', 'default', 'examples', 'deprecated', '$schema', '$id',
])
function matchesSchema(value: unknown, rawSchema: unknown, depth = 0): boolean {
if (rawSchema === true) return true
if (rawSchema === false || depth > 24) return false
const schema = record(rawSchema)
if (!schema || Object.keys(schema).some(key => !SCHEMA_KEYS.has(key) && !key.startsWith('x-'))) return false
const matches = (child: unknown) => matchesSchema(value, child, depth + 1)
if (Array.isArray(schema.allOf) && !schema.allOf.every(matches)) return false
if (Array.isArray(schema.anyOf) && !schema.anyOf.some(matches)) return false
if (Array.isArray(schema.oneOf) && schema.oneOf.filter(matches).length !== 1) return false
if (schema.not !== undefined && matches(schema.not)) return false
if (Array.isArray(schema.enum) && !schema.enum.some(option => equalJson(option, value))) return false
if (Object.hasOwn(schema, 'const') && !equalJson(schema.const, value)) return false
if (schema.type !== undefined) {
const types = Array.isArray(schema.type) ? schema.type : [schema.type]
const typeMatches = (type: unknown) => {
switch (type) {
case 'null': return value === null
case 'object': return record(value) !== null
case 'array': return Array.isArray(value)
case 'integer': return typeof value === 'number' && Number.isInteger(value)
case 'number': return typeof value === 'number' && Number.isFinite(value)
case 'string': return typeof value === 'string'
case 'boolean': return typeof value === 'boolean'
default: return false
}
}
if (!types.some(typeMatches)) return false
}
if (typeof value === 'string') {
const length = Array.from(value).length
if (typeof schema.minLength === 'number' && length < schema.minLength) return false
if (typeof schema.maxLength === 'number' && length > schema.maxLength) return false
if (typeof schema.pattern === 'string') {
try {
if (!new RegExp(schema.pattern).test(value)) return false
} catch { return false }
}
if (schema.format !== undefined) {
if (schema.format !== 'uri' && schema.format !== 'uri-reference') return false
try { new URL(value) } catch { return false }
}
}
if (typeof value === 'number') {
if (typeof schema.minimum === 'number' && value < schema.minimum) return false
if (typeof schema.maximum === 'number' && value > schema.maximum) return false
if (typeof schema.exclusiveMinimum === 'number' && value <= schema.exclusiveMinimum) return false
if (typeof schema.exclusiveMaximum === 'number' && value >= schema.exclusiveMaximum) return false
}
if (Array.isArray(value)) {
if (typeof schema.minItems === 'number' && value.length < schema.minItems) return false
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) return false
if (schema.uniqueItems === true && new Set(value.map(item => JSON.stringify(item))).size !== value.length) return false
if (schema.items !== undefined && !value.every(item => matchesSchema(item, schema.items, depth + 1))) return false
}
const objectValue = record(value)
if (objectValue) {
if (Array.isArray(schema.required) && !schema.required.every(key => typeof key === 'string' && Object.hasOwn(objectValue, key))) return false
const properties = record(schema.properties) ?? {}
for (const [key, item] of Object.entries(objectValue)) {
if (Object.hasOwn(properties, key)) {
if (!matchesSchema(item, properties[key], depth + 1)) return false
} else if (schema.additionalProperties === false) return false
else if (record(schema.additionalProperties) && !matchesSchema(item, schema.additionalProperties, depth + 1)) return false
}
}
return true
}
function declaresProperty(schema: JsonRecord, name: string, depth = 0): boolean {
if (depth > 24) return false
if (Object.hasOwn(record(schema.properties) ?? {}, name)) return true
return ['allOf', 'anyOf', 'oneOf'].some(key => records(schema[key]).some(child => declaresProperty(child, name, depth + 1)))
}
type Candidate = { invocation: JsonRecord; preferred: boolean; available: boolean }
function collectInvocations(model: JsonRecord): Candidate[] {
const candidates: Candidate[] = []
// Route options carry the executable fixed-route header. Model-level entries
// can be unions of different contracts and must not win over a concrete route.
for (const offer of records(model.service_offers)) {
for (const option of records(offer.route_options)) {
const invocation = record(option.invocation)
if (invocation) candidates.push({
invocation, preferred: option.is_default === true,
available: offer.available !== false && option.available !== false,
})
}
const invocation = record(offer.invocation)
if (invocation) candidates.push({ invocation, preferred: false, available: offer.available !== false })
}
for (const invocation of records(model.invocations)) candidates.push({ invocation, preferred: false, available: true })
return candidates
}
function routeId(invocation: JsonRecord): string | undefined {
const header = Object.entries(record(invocation.headers) ?? {}).find(([key]) => key.toLowerCase() === 'x-relay-route-option')?.[1]
return typeof header === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(header) ? header : undefined
}
/** Check authorization and published input constraints before persisting a new job. */
export async function resolveRelayImageTaskContract(
config: RelayImageTaskConfig,
input: RelayImageTaskInput,
): Promise<{ routeOptionId?: string }> {
const payload = buildRelayImageTaskPayload(input)
const { authorizedModels, publicCatalog } = await readRelayImageTaskContractSources(config)
const permitted = record(authorizedModels)?.data
if (!Array.isArray(permitted)) rejectContract('invalid_catalog_response', 'AI 服务模型授权目录格式无法确认。')
if (!records(permitted).some(model => model.id === input.model)) {
rejectContract('model_not_authorized', '当前 AI 服务账号未开放所选模型,请联系管理员。')
}
const sections = record(record(publicCatalog)?.data)?.sections
if (!Array.isArray(sections)) rejectContract('invalid_catalog_response', 'AI 服务公开模型目录格式无法确认。')
const models = records(sections).flatMap(section => records(section.models)).filter(model => model.id === input.model && model.available !== false)
if (!models.length) rejectContract('model_contract_unavailable', '所选 AI 模型没有可核对的公开接口合同。')
const candidates = models.flatMap(collectInvocations).filter(({ invocation }) =>
invocation.method === 'POST' && invocation.path === '/extensions/v1/tasks' &&
invocation.content_type === 'application/json' && invocation.response_mode === 'async',
)
// Once concrete route options exist, never fall back to a merged unpinned
// contract: that could silently choose a different schema during a retry.
const explicitCandidates = candidates.filter(candidate => routeId(candidate.invocation) !== undefined)
const selectable = (explicitCandidates.length ? explicitCandidates : candidates).filter(candidate => candidate.available)
const ordered = [...selectable].sort((left, right) => Number(right.preferred) - Number(left.preferred))
for (const candidate of ordered) {
const id = routeId(candidate.invocation)
if (config.routeOptionId !== undefined && id !== config.routeOptionId) continue
const schema = record(candidate.invocation.input_schema)
if (!schema || Object.keys(payload).some(key => !declaresProperty(schema, key)) || !matchesSchema(payload, schema)) continue
return id ? { routeOptionId: id } : {}
}
rejectContract('model_parameters_unsupported', config.routeOptionId
? '指定的 AI 模型线路不支持当前提示词、尺寸、比例或参考图,请调整参数或联系管理员。'
: '当前模型没有支持本次提示词、尺寸、比例及参考图的原生异步线路,请调整参数或联系管理员。')
}

View File

@ -0,0 +1,84 @@
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/storage', () => {
const urls = jest.requireActual<typeof import('@/lib/object-storage-url')>('@/lib/object-storage-url')
return {
getStorageBucketName: () => 'qiniu-bucket',
getStorageFilePathPrefix: urls.getConfiguredStorageFilePathPrefix,
getStorageProvider: urls.getConfiguredStorageProvider,
normalizeObjectStorageKey: urls.normalizeObjectStorageKey,
readObjectStorageKeyFromUrl: urls.readObjectStorageKeyFromUrl,
}
})
function loadStorageGc() {
let storageGc!: typeof import('@/lib/storage-gc')
jest.isolateModules(() => {
storageGc = require('@/lib/storage-gc')
})
return storageGc
}
describe.each(['OBJECT_STORAGE_FILE_PATH', 'QINIU_FILE_PATH'] as const)(
'native AI storage GC with %s',
(prefixVariable) => {
beforeEach(() => {
const env = { ...process.env, STORAGE_PROVIDER: 'qiniu', QINIU_PUBLIC_DOMAIN: 'https://objects.example.test' }
delete env.OBJECT_STORAGE_FILE_PATH
delete env.STORAGE_FILE_PATH
delete env.QINIU_FILE_PATH
delete env.STORAGE_GC_RESERVED_PREFIXES
env[prefixVariable] = '/production/furniture/'
jest.replaceProperty(process, 'env', env)
})
afterEach(() => jest.restoreAllMocks())
it('protects input and result uploads before database references exist without matching neighboring directories', () => {
const { buildStorageGcReport, isReservedObjectStorageKey } = loadStorageGc()
const protectedKeys = [
'production/furniture/native-ai-inputs/job/input.png',
'production/furniture/native-ai-results/job/task/0.png',
]
const orphanKey = 'production/furniture/native-ai-results-other/orphan.png'
const report = buildStorageGcReport({
bucketObjects: [...protectedKeys, orphanKey].map((key) => ({
key, size: 10, hash: null, mimeType: 'image/png', putTime: null,
})),
referencesByKey: new Map(),
})
expect(report.filePathPrefix).toBe('production/furniture/')
expect(report.counts.skippedReserved).toBe(2)
expect(report.candidates.map((object) => object.key)).toEqual([orphanKey])
expect(isReservedObjectStorageKey('native-ai-inputs/job/input.png')).toBe(true)
expect(isReservedObjectStorageKey('native-ai-results/job/task/0.png')).toBe(true)
expect(isReservedObjectStorageKey('production/furniture/.system/health')).toBe(true)
})
it('discovers native job input and result references through stable API and object-domain URLs', async () => {
const { collectDatabaseObjectStorageReferences } = loadStorageGc()
const inputKey = 'production/furniture/native-ai-inputs/job/input.png'
const resultKey = 'production/furniture/native-ai-results/job/task/0.png'
const findMany = jest.fn(async () => [{
id: 'native-job',
request: {
referenceImageUrls: [`https://objects.example.test/${inputKey}?e=123&token=signed`],
inputStorageKeys: [inputKey],
},
progress: [{ images: [{ originalUrl: `/api/storage/object?key=${encodeURIComponent(resultKey)}` }] }],
}])
const { referencesByKey, issues } = await collectDatabaseObjectStorageReferences({
nativeImageGenerationJob: { findMany },
})
expect(findMany).toHaveBeenCalledTimes(2)
expect(issues.filter((issue) => issue.scope.startsWith('NativeImageGenerationJob'))).toEqual([])
expect(referencesByKey.get(inputKey)).toEqual([expect.objectContaining({
recordType: 'NativeImageGenerationJob', recordId: 'native-job', fieldPath: 'request.referenceImageUrls[0]',
})])
expect(referencesByKey.get(resultKey)).toEqual([expect.objectContaining({
recordType: 'NativeImageGenerationJob', recordId: 'native-job', fieldPath: 'progress[0].images[0].originalUrl',
})])
})
},
)

View File

@ -192,6 +192,7 @@ start_pm2_release() {
local worker_command
local worker_timeout
assert_release_ready "$app_root" || return 1
ensure_ai_image_worker_secret "$app_root" || return 1
# Read the shared environment before resolving the curl limit. The worker
# command itself sources this file later, but doing it only there leaves the
# already-interpolated --max-time stuck at an unsafe long default.
@ -210,6 +211,7 @@ start_pm2_release() {
pm2 delete "$BOM_WORKER_NAME" >/dev/null 2>&1 || true
pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command"
pm2 start /bin/bash --name "$BOM_WORKER_NAME" -- -lc "$worker_command"
start_ai_image_worker "$app_root" || return 1
pm2 save >/dev/null
}

View File

@ -364,6 +364,7 @@ start_pm2_release() {
local worker_command
local worker_timeout
assert_release_ready "$app_root" || return 1
ensure_ai_image_worker_secret "$app_root" || return 1
# Read the shared environment before resolving the curl limit. The worker
# command itself sources this file later, but doing it only there leaves the
# already-interpolated --max-time stuck at an unsafe long default.
@ -386,6 +387,7 @@ start_pm2_release() {
pm2 delete "$BOM_WORKER_NAME" >/dev/null 2>&1 || true
pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command"
pm2 start /bin/bash --name "$BOM_WORKER_NAME" -- -lc "$worker_command"
start_ai_image_worker "$app_root" || return 1
pm2 save >/dev/null
}

View File

@ -10,9 +10,10 @@ import test from 'node:test'
const exec = promisify(execFile)
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const guards = await readFile(join(root, 'scripts/yydd-prod-release-guards.sh'), 'utf8')
const readText = async file => (await readFile(file, 'utf8')).replaceAll('\r\n', '\n')
const guards = await readText(join(root, 'scripts/yydd-prod-release-guards.sh'))
const bash = process.env.DEPLOY_TEST_BASH || (process.platform === 'win32'
? 'C:/Users/sxx/.workbuddy/vendor/PortableGit/bin/bash.exe' : 'bash')
? 'C:/Program Files/Git/bin/bash.exe' : 'bash')
const python = process.env.DEPLOY_TEST_PYTHON || 'python3'
const scratch = await mkdtemp(join(process.env.DEPLOY_TEST_TMP || tmpdir(), 'release-guards-'))
const quote = value => `'${value.replaceAll("'", "'\\''")}'`
@ -45,9 +46,9 @@ test('complete output is copied and can be checked without recopying', async ()
test('both generated deployment paths prepare before switching and retain failure evidence', async () => {
for (const file of ['deploy-yydd-prod-from-git.ps1', 'create-yydd-prod-deploy.ps1']) {
const source = await readFile(join(root, 'scripts', file), 'utf8')
const source = await readText(join(root, 'scripts', file))
const shell = source.match(/\$shellScript = @'\r?\n([\s\S]*?)\r?\n'@/)[1]
.replace('__RELEASE_GUARDS__', guards)
.replace('__RELEASE_GUARDS__', () => guards)
execFileSync(bash, ['-n'], { input: shell })
assert(shell.includes('acquire_deploy_lock\n'))
assert(shell.includes('assert_release_ready "$app_root" || return 1'))
@ -95,7 +96,7 @@ test('HTTP verification parses pages and rejects missing or disguised static res
})
test('a started remote deployment cannot silently retry via archive fallback', async () => {
const source = await readFile(join(root, 'scripts/deploy-yydd-prod-from-git.ps1'), 'utf8')
const source = await readText(join(root, 'scripts/deploy-yydd-prod-from-git.ps1'))
assert(source.includes('$remoteDeployStarted = $true'))
assert(source.includes('if ($remoteDeployStarted) {\n throw\n }'))
assert(source.includes('$Command = $guard + "`n" + $Command'))
@ -103,7 +104,7 @@ test('a started remote deployment cannot silently retry via archive fallback', a
test('failed post-switch validation restores only the previous release and retains evidence', async () => {
for (const file of ['deploy-yydd-prod-from-git.ps1', 'create-yydd-prod-deploy.ps1']) {
const source = await readFile(join(root, 'scripts', file), 'utf8')
const source = await readText(join(root, 'scripts', file))
const cleanup = source.match(/cleanup_failed_release\(\) \{[\s\S]*?\n\}/)[0]
const base = join(scratch, file.replace('.ps1', ''))
await mkdir(join(base, 'previous'), { recursive: true })
@ -130,3 +131,62 @@ exit 1
assert.equal(await readFile(join(base, 'check.log'), 'utf8'), 'static 404')
}
})
test('image worker gets one persistent secret and starts only its own PM2 process', async () => {
const app = join(scratch, 'worker-release')
const shared = join(scratch, 'worker-shared')
await mkdir(join(app, 'scripts'), { recursive: true })
await mkdir(shared, { recursive: true })
await writeFile(join(app, 'scripts/run-ai-image-worker.mjs'), '// worker fixture')
await writeFile(join(shared, '.env'), 'EXISTING_SETTING=keep\n')
await run(`
SHARED=${quote(posix(shared))}
PM2_NAME=furniture-test
APP_PORT=3001
pm2() { printf '%s\\n' "$*" >> "$SHARED/pm2-calls"; }
ensure_ai_image_worker_secret ${quote(posix(app))}
ensure_ai_image_worker_secret ${quote(posix(app))}
start_ai_image_worker ${quote(posix(app))}
`)
const env = await readText(join(shared, '.env'))
assert(env.includes('EXISTING_SETTING=keep\n'))
assert.equal(env.match(/^AI_IMAGE_WORKER_SECRET=/gm)?.length, 1)
assert.match(env, /^AI_IMAGE_WORKER_SECRET=[a-f0-9]{64}$/m)
const calls = await readText(join(shared, 'pm2-calls'))
assert(calls.includes('delete furniture-test-ai-image-worker\n'))
assert(calls.includes('start /bin/bash --name furniture-test-ai-image-worker -- -lc '))
assert(calls.includes('exec env PORT=3001 node scripts/run-ai-image-worker.mjs'))
assert(!calls.includes('delete all'))
await run(`SHARED=${quote(posix(shared))}\nensure_ai_image_worker_secret ${quote(posix(app))}`)
assert.equal(await readText(join(shared, '.env')), env)
})
test('rolling back to a release without the worker removes only the worker and does not rotate credentials', async () => {
const app = join(scratch, 'old-worker-release')
const shared = join(scratch, 'old-worker-shared')
await mkdir(app, { recursive: true })
await mkdir(shared, { recursive: true })
await writeFile(join(shared, '.env'), 'EXISTING_SETTING=keep\nAI_IMAGE_WORKER_SECRET=unchanged-test-secret\n')
const before = await readText(join(shared, '.env'))
await run(`
SHARED=${quote(posix(shared))}
PM2_NAME=furniture-test
APP_PORT=3001
pm2() { printf '%s\\n' "$*" >> "$SHARED/pm2-calls"; }
ensure_ai_image_worker_secret ${quote(posix(app))}
start_ai_image_worker ${quote(posix(app))}
`)
assert.equal(await readText(join(shared, '.env')), before)
assert.equal(await readText(join(shared, 'pm2-calls')), 'delete furniture-test-ai-image-worker\n')
})
test('a failed secret generator rejects startup preparation without saving an empty credential', async () => {
const app = join(scratch, 'failed-secret-release')
const shared = join(scratch, 'failed-secret-shared')
await mkdir(join(app, 'scripts'), { recursive: true })
await mkdir(shared, { recursive: true })
await writeFile(join(app, 'scripts/run-ai-image-worker.mjs'), '// worker fixture')
await writeFile(join(shared, '.env'), 'EXISTING_SETTING=keep\n')
await assert.rejects(run(`SHARED=${quote(posix(shared))}\nnode() { return 1; }\nensure_ai_image_worker_secret ${quote(posix(app))}`))
assert.equal(await readText(join(shared, '.env')), 'EXISTING_SETTING=keep\n')
})

View File

@ -1,4 +1,26 @@
# Embedded by both official deployment entry points; no remote helper dependency.
ensure_ai_image_worker_secret() {
local app_root="$1"
[ -f "$app_root/scripts/run-ai-image-worker.mjs" ] || return 0
if ! grep -qE '^AI_IMAGE_WORKER_SECRET=.+$' "$SHARED/.env"; then
local secret
secret="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))")" || return 1
[ -n "$secret" ] || return 1
(umask 077; printf '\nAI_IMAGE_WORKER_SECRET=%s\n' "$secret" >> "$SHARED/.env")
chmod 600 "$SHARED/.env"
fi
}
start_ai_image_worker() {
local app_root="$1"
local name="${PM2_NAME}-ai-image-worker"
pm2 delete "$name" >/dev/null 2>&1 || true
# Rolling back to an older release pauses the new queue without losing persisted jobs.
[ -f "$app_root/scripts/run-ai-image-worker.mjs" ] || return 0
local command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec env PORT=$APP_PORT node scripts/run-ai-image-worker.mjs"
pm2 start /bin/bash --name "$name" -- -lc "$command"
}
assert_release_ready() {
local app_root="$1"
local runtime="$app_root/.next/standalone"