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:
parent
dc2c86275e
commit
928a7b37a9
@ -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",
|
||||
|
||||
@ -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");
|
||||
@ -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
|
||||
|
||||
23
furniture-3d-system/scripts/run-ai-image-worker.mjs
Normal file
23
furniture-3d-system/scripts/run-ai-image-worker.mjs
Normal 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))
|
||||
}
|
||||
@ -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 格口与柜门几何;柜门目录和文件引用使用结构化表。']],
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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 })
|
||||
|
||||
|
||||
@ -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', '生成失败,请稍后重试')
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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" }),
|
||||
}),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -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) }
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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('') }
|
||||
|
||||
@ -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,8 +13,9 @@ 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', {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"],
|
||||
})]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -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 },
|
||||
|
||||
@ -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()
|
||||
})
|
||||
102
furniture-3d-system/src/lib/recoverable-image-submission.ts
Normal file
102
furniture-3d-system/src/lib/recoverable-image-submission.ts
Normal 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>>
|
||||
}
|
||||
@ -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' } },
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
@ -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 })
|
||||
},
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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])
|
||||
})
|
||||
@ -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])
|
||||
})
|
||||
@ -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() }
|
||||
})
|
||||
@ -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('请勿重复提交')
|
||||
})
|
||||
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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[] = []
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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) }
|
||||
},
|
||||
}
|
||||
@ -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' }),
|
||||
})
|
||||
}
|
||||
@ -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, '同一出图请求编号不能用于不同内容,请先核对原任务。')
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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 模型线路不支持当前提示词、尺寸、比例或参考图,请调整参数或联系管理员。'
|
||||
: '当前模型没有支持本次提示词、尺寸、比例及参考图的原生异步线路,请调整参数或联系管理员。')
|
||||
}
|
||||
84
furniture-3d-system/tests/lib/storage-gc.native-ai.test.ts
Normal file
84
furniture-3d-system/tests/lib/storage-gc.native-ai.test.ts
Normal 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',
|
||||
})])
|
||||
})
|
||||
},
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user