feat: implement robustness guards

This commit is contained in:
saturn
2026-03-09 02:53:06 +08:00
parent fba480ae6e
commit be1853534a
25 changed files with 1531 additions and 96 deletions

View File

@@ -11,5 +11,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const location = `/api/storage/sign?key=${encodeURIComponent(key)}&expires=${encodeURIComponent(expires)}`
return NextResponse.redirect(location)
return NextResponse.redirect(new URL(location, request.url))
})

View File

@@ -1,12 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'
import { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'
import { submitTask } from '@/lib/task/submitter'
import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'
import { TASK_TYPE } from '@/lib/task/types'
import { buildDefaultTaskBillingInfo } from '@/lib/billing'
import { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service'
import { prisma } from '@/lib/prisma'
import { submitTask } from '@/lib/task/submitter'
import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'
import { TASK_TYPE } from '@/lib/task/types'
function createPanelVariantId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `panel-variant-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
async function rollbackCreatedVariantPanel(params: {
panelId: string
storyboardId: string
panelIndex: number
}) {
await prisma.$transaction(async (tx) => {
await tx.novelPromotionPanel.delete({
where: { id: params.panelId },
})
const maxPanel = await tx.novelPromotionPanel.findFirst({
where: { storyboardId: params.storyboardId },
orderBy: { panelIndex: 'desc' },
select: { panelIndex: true },
})
const maxPanelIndex = maxPanel?.panelIndex ?? -1
const offset = maxPanelIndex + 1000
await tx.novelPromotionPanel.updateMany({
where: {
storyboardId: params.storyboardId,
panelIndex: { gt: params.panelIndex },
},
data: {
panelIndex: { increment: offset },
panelNumber: { increment: offset },
},
})
await tx.novelPromotionPanel.updateMany({
where: {
storyboardId: params.storyboardId,
panelIndex: { gt: params.panelIndex + offset },
},
data: {
panelIndex: { decrement: offset + 1 },
panelNumber: { decrement: offset + 1 },
},
})
const panelCount = await tx.novelPromotionPanel.count({
where: { storyboardId: params.storyboardId },
})
await tx.novelPromotionStoryboard.update({
where: { id: params.storyboardId },
data: { panelCount },
})
})
}
export const POST = apiHandler(async (
request: NextRequest,
@@ -33,42 +91,79 @@ export const POST = apiHandler(async (
throw new ApiError('INVALID_PARAMS')
}
// 在 API route 中同步创建 panel无图片确保新 panel 立即存在于数据库,
// 避免乐观更新与 worker 之间的状态真空期
const sourcePanel = await prisma.novelPromotionPanel.findUnique({ where: { id: sourcePanelId } })
if (!sourcePanel) {
const storyboard = await prisma.novelPromotionStoryboard.findUnique({
where: { id: storyboardId },
select: {
id: true,
episode: {
select: {
novelPromotionProject: {
select: {
projectId: true,
},
},
},
},
},
})
if (!storyboard || storyboard.episode.novelPromotionProject.projectId !== projectId) {
throw new ApiError('NOT_FOUND')
}
const sourcePanel = await prisma.novelPromotionPanel.findUnique({ where: { id: sourcePanelId } })
if (!sourcePanel || sourcePanel.storyboardId !== storyboardId) {
throw new ApiError('INVALID_PARAMS')
}
const insertAfter = await prisma.novelPromotionPanel.findUnique({ where: { id: insertAfterPanelId } })
if (!insertAfter) {
throw new ApiError('NOT_FOUND')
if (!insertAfter || insertAfter.storyboardId !== storyboardId) {
throw new ApiError('INVALID_PARAMS')
}
const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)
const imageModel = projectModelConfig.storyboardModel
const createdPanelId = createPanelVariantId()
let billingPayload: Record<string, unknown>
try {
billingPayload = await buildImageBillingPayload({
projectId,
userId: session.user.id,
imageModel,
basePayload: { ...body, newPanelId: createdPanelId },
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Image model capability not configured'
throw new ApiError('INVALID_PARAMS', {
code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED',
message,
})
}
const createdPanel = await prisma.$transaction(async (tx) => {
// Two-phase reindexing to avoid unique constraint collision on (storyboardId, panelIndex)
const affectedPanels = await tx.novelPromotionPanel.findMany({
where: { storyboardId, panelIndex: { gt: insertAfter.panelIndex } },
select: { id: true, panelIndex: true },
orderBy: { panelIndex: 'asc' }
orderBy: { panelIndex: 'asc' },
})
// Phase A: shift to negative indices
for (const p of affectedPanels) {
for (const panel of affectedPanels) {
await tx.novelPromotionPanel.update({
where: { id: p.id },
data: { panelIndex: -(p.panelIndex + 1) }
})
}
// Phase B: set final positive indices
for (const p of affectedPanels) {
await tx.novelPromotionPanel.update({
where: { id: p.id },
data: { panelIndex: p.panelIndex + 1 }
where: { id: panel.id },
data: { panelIndex: -(panel.panelIndex + 1) },
})
}
return tx.novelPromotionPanel.create({
for (const panel of affectedPanels) {
await tx.novelPromotionPanel.update({
where: { id: panel.id },
data: { panelIndex: panel.panelIndex + 1 },
})
}
const created = await tx.novelPromotionPanel.create({
data: {
id: createdPanelId,
storyboardId,
panelIndex: insertAfter.panelIndex + 1,
panelNumber: insertAfter.panelIndex + 2,
@@ -79,39 +174,44 @@ export const POST = apiHandler(async (
location: variant.location || sourcePanel.location,
characters: variant.characters ? JSON.stringify(variant.characters) : sourcePanel.characters,
srtSegment: sourcePanel.srtSegment,
duration: sourcePanel.duration
}
duration: sourcePanel.duration,
},
})
const panelCount = await tx.novelPromotionPanel.count({
where: { storyboardId },
})
await tx.novelPromotionStoryboard.update({
where: { id: storyboardId },
data: { panelCount },
})
return created
})
const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)
const imageModel = projectModelConfig.storyboardModel
let billingPayload: Record<string, unknown>
let result: Awaited<ReturnType<typeof submitTask>>
try {
billingPayload = await buildImageBillingPayload({
projectId,
result = await submitTask({
userId: session.user.id,
imageModel,
basePayload: { ...body, newPanelId: createdPanel.id },
locale,
requestId: getRequestId(request),
projectId,
type: TASK_TYPE.PANEL_VARIANT,
targetType: 'NovelPromotionPanel',
targetId: createdPanel.id,
payload: billingPayload,
dedupeKey: `panel_variant:${storyboardId}:${insertAfterPanelId}:${sourcePanelId}`,
billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.PANEL_VARIANT, billingPayload),
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Image model capability not configured'
throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })
} catch (error) {
await rollbackCreatedVariantPanel({
panelId: createdPanel.id,
storyboardId,
panelIndex: createdPanel.panelIndex,
})
throw error
}
// Task target 指向新创建的 panel使 task state 监控系统正确追踪
const result = await submitTask({
userId: session.user.id,
locale,
requestId: getRequestId(request),
projectId,
type: TASK_TYPE.PANEL_VARIANT,
targetType: 'NovelPromotionPanel',
targetId: createdPanel.id,
payload: billingPayload,
dedupeKey: `panel_variant:${storyboardId}:${insertAfterPanelId}:${sourcePanelId}`,
billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.PANEL_VARIANT, billingPayload)
})
return NextResponse.json({ ...result, panelId: createdPanel.id })
})

View File

@@ -19,6 +19,7 @@ export default function Navbar() {
const [checkMsg, setCheckMsg] = useState<string | null>(null)
const [checkMsgFading, setCheckMsgFading] = useState(false)
const [manualChecking, setManualChecking] = useState(false)
const downloadLogsHref = '/api/admin/download-logs'
const handleCheckUpdate = async () => {
setCheckMsg(null)
@@ -125,7 +126,7 @@ export default function Navbar() {
</Link>
<LanguageSwitcher />
<a
href="/api/admin/download-logs"
href={downloadLogsHref}
download
className="text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors flex items-center gap-1"
title={t('downloadLogs')}

View File

@@ -93,6 +93,37 @@ describe('outbound-image normalization', () => {
expect(dataUrl).toBe('data:image/png;base64,AQID')
})
it('sniffs png mime when upstream returns application/octet-stream', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/octet-stream' }),
arrayBuffer: async () => Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d,
]).buffer,
} as Response)
const dataUrl = await normalizeToBase64ForGeneration('images/direct.png')
expect(dataUrl).toBe('data:image/png;base64,iVBORw0KGgoAAAAN')
})
it('sniffs jpeg mime when upstream returns application/octet-stream', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/octet-stream' }),
arrayBuffer: async () => Uint8Array.from([
0xff, 0xd8, 0xff, 0xe0,
0x00, 0x10, 0x4a, 0x46,
0x49, 0x46, 0x00, 0x01,
]).buffer,
} as Response)
const dataUrl = await normalizeToBase64ForGeneration('images/direct.jpg')
expect(dataUrl).toBe('data:image/jpeg;base64,/9j/4AAQSkZJRgAB')
})
it('normalizes references with dedupe and failure isolation', async () => {
fetchMock.mockImplementation(async (url: string) => {
if (String(url).includes('/api/bad.png')) {

View File

@@ -163,9 +163,115 @@ function toUrlMaybe(value: string): URL | null {
return null
}
function guessContentType(input: string, contentTypeHeader: string | null): string {
function detectMimeFromBuffer(buffer: Uint8Array): string | null {
if (buffer.length >= 8) {
const isPng =
buffer[0] === 0x89
&& buffer[1] === 0x50
&& buffer[2] === 0x4e
&& buffer[3] === 0x47
&& buffer[4] === 0x0d
&& buffer[5] === 0x0a
&& buffer[6] === 0x1a
&& buffer[7] === 0x0a
if (isPng) return 'image/png'
}
if (buffer.length >= 3) {
const isJpeg = buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
if (isJpeg) return 'image/jpeg'
}
if (buffer.length >= 6) {
const isGif87a =
buffer[0] === 0x47
&& buffer[1] === 0x49
&& buffer[2] === 0x46
&& buffer[3] === 0x38
&& buffer[4] === 0x37
&& buffer[5] === 0x61
const isGif89a =
buffer[0] === 0x47
&& buffer[1] === 0x49
&& buffer[2] === 0x46
&& buffer[3] === 0x38
&& buffer[4] === 0x39
&& buffer[5] === 0x61
if (isGif87a || isGif89a) return 'image/gif'
}
if (buffer.length >= 12) {
const isWebp =
buffer[0] === 0x52
&& buffer[1] === 0x49
&& buffer[2] === 0x46
&& buffer[3] === 0x46
&& buffer[8] === 0x57
&& buffer[9] === 0x45
&& buffer[10] === 0x42
&& buffer[11] === 0x50
if (isWebp) return 'image/webp'
}
if (buffer.length >= 12) {
const isWav =
buffer[0] === 0x52
&& buffer[1] === 0x49
&& buffer[2] === 0x46
&& buffer[3] === 0x46
&& buffer[8] === 0x57
&& buffer[9] === 0x41
&& buffer[10] === 0x56
&& buffer[11] === 0x45
if (isWav) return 'audio/wav'
}
if (buffer.length >= 4) {
const isOgg =
buffer[0] === 0x4f
&& buffer[1] === 0x67
&& buffer[2] === 0x67
&& buffer[3] === 0x53
if (isOgg) return 'audio/ogg'
}
if (buffer.length >= 3) {
const isMp3WithId3 =
buffer[0] === 0x49
&& buffer[1] === 0x44
&& buffer[2] === 0x33
const isMp3FrameSync =
buffer[0] === 0xff
&& (buffer[1] & 0xe0) === 0xe0
if (isMp3WithId3 || isMp3FrameSync) return 'audio/mpeg'
}
if (buffer.length >= 12) {
const isWebm =
buffer[0] === 0x1a
&& buffer[1] === 0x45
&& buffer[2] === 0xdf
&& buffer[3] === 0xa3
if (isWebm) return 'video/webm'
}
if (buffer.length >= 8) {
const isMp4 =
buffer[4] === 0x66
&& buffer[5] === 0x74
&& buffer[6] === 0x79
&& buffer[7] === 0x70
if (isMp4) return 'video/mp4'
}
return null
}
function guessContentType(input: string, contentTypeHeader: string | null, buffer: Uint8Array): string {
const headerType = contentTypeHeader?.split(';')[0]?.trim()
if (headerType) return headerType
if (headerType && headerType !== DEFAULT_CONTENT_TYPE) return headerType
const sniffedType = detectMimeFromBuffer(buffer)
if (sniffedType) return sniffedType
const parsed = toUrlMaybe(input)
const pathname = parsed?.pathname ?? input
const ext = path.extname(pathname).toLowerCase()
@@ -308,8 +414,8 @@ export async function normalizeToBase64ForGeneration(input: string): Promise<str
})
}
const mimeType = guessContentType(normalizedUrl, response.headers.get('content-type'))
const buffer = Buffer.from(await response.arrayBuffer())
const mimeType = guessContentType(normalizedUrl, response.headers.get('content-type'), buffer)
return `data:${mimeType};base64,${buffer.toString('base64')}`
}

View File

@@ -13,8 +13,8 @@ import {
import { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image'
import {
AnyObj,
collectPanelReferenceImages,
findCharacterByName,
parseImageUrls,
parsePanelCharacterReferences,
pickFirstString,
resolveNovelData,
@@ -94,6 +94,72 @@ function buildCharacterAssetsDescription(
}).join('\n')
}
function buildLocationAssetDescription(params: {
includeLocationAsset: boolean
locationName: string
locale: TaskJobData['locale']
}): string {
if (params.locationName) {
if (params.includeLocationAsset) return `场景:${params.locationName}`
return params.locale === 'en' ? 'Location reference disabled' : '未使用场景参考图'
}
return params.locale === 'en' ? 'No location reference' : '无场景参考'
}
function buildVariantReferenceImages(params: {
includeCharacterAssets: boolean
includeLocationAsset: boolean
newPanel: {
characters: string | null
location: string | null
}
sourcePanelImageUrl: string | null
projectData: Awaited<ReturnType<typeof resolveNovelData>>
}): string[] {
const refs: string[] = []
if (params.sourcePanelImageUrl) refs.push(params.sourcePanelImageUrl)
if (params.includeCharacterAssets) {
const panelCharacters = parsePanelCharacterReferences(params.newPanel.characters)
for (const item of panelCharacters) {
const character = findCharacterByName(params.projectData.characters || [], item.name)
if (!character) continue
const appearances = character.appearances || []
let appearance = appearances[0]
if (item.appearance) {
const matched = appearances.find((candidate) => (candidate.changeReason || '').toLowerCase() === item.appearance!.toLowerCase())
if (matched) appearance = matched
}
if (!appearance) continue
const imageUrls = parseImageUrls((appearance as { imageUrls?: string | null }).imageUrls || null, 'characterAppearance.imageUrls')
const selectedIndex = typeof (appearance as { selectedIndex?: number | null }).selectedIndex === 'number'
? (appearance as { selectedIndex?: number | null }).selectedIndex
: null
const selectedUrl = (selectedIndex !== null && selectedIndex !== undefined ? imageUrls[selectedIndex] : null)
|| imageUrls[0]
|| appearance.imageUrl
|| null
const signed = toSignedUrlIfCos(selectedUrl, 3600)
if (signed) refs.push(signed)
}
}
if (params.includeLocationAsset && params.newPanel.location) {
const location = (params.projectData.locations || []).find(
(item) => item.name.toLowerCase() === params.newPanel.location!.toLowerCase(),
)
if (location) {
const selected = (location.images || []).find((image) => image.isSelected) || location.images?.[0]
const signed = toSignedUrlIfCos(selected?.imageUrl, 3600)
if (signed) refs.push(signed)
}
}
return refs
}
interface PanelVariantPayload {
shot_type?: string
camera_move?: string
@@ -108,6 +174,8 @@ export async function handlePanelVariantTask(job: Job<TaskJobData>) {
const payload = (job.data.payload || {}) as AnyObj
const newPanelId = pickFirstString(payload.newPanelId)
const sourcePanelId = pickFirstString(payload.sourcePanelId)
const includeCharacterAssets = payload.includeCharacterAssets !== false
const includeLocationAsset = payload.includeLocationAsset !== false
const variant: PanelVariantPayload = payload.variant && typeof payload.variant === 'object'
? (payload.variant as PanelVariantPayload)
: {}
@@ -132,16 +200,22 @@ export async function handlePanelVariantTask(job: Job<TaskJobData>) {
if (!storyboardModel) throw new Error('Storyboard model not configured')
// 收集参考图(与 panel-image-task-handler 共用同一链路)
const refs = await collectPanelReferenceImages(projectData, newPanel)
// 额外加入源镜头图片作为参考
const sourcePanelImageUrl = toSignedUrlIfCos(sourcePanel.imageUrl, 3600)
if (sourcePanelImageUrl) refs.unshift(sourcePanelImageUrl)
const refs = buildVariantReferenceImages({
includeCharacterAssets,
includeLocationAsset,
newPanel,
sourcePanelImageUrl,
projectData,
})
const normalizedRefs = await normalizeReferenceImagesForGeneration(refs)
// 使用 agent_shot_variant_generate.txt 提示词模板
const artStyle = getArtStylePrompt(modelConfig.artStyle, job.data.locale)
const charactersInfo = buildCharactersInfo(newPanel, projectData)
const characterAssetsDesc = buildCharacterAssetsDescription(newPanel, projectData)
const characterAssetsDesc = includeCharacterAssets
? buildCharacterAssetsDescription(newPanel, projectData)
: (job.data.locale === 'en' ? 'Character reference images disabled' : '未使用角色参考图')
const locationName = newPanel.location || sourcePanel.location || ''
const prompt = buildVariantPrompt({
@@ -157,7 +231,11 @@ export async function handlePanelVariantTask(job: Job<TaskJobData>) {
targetCameraMove: variant.camera_move || sourcePanel.cameraMove || '',
videoPrompt: pickFirstString(variant.video_prompt, variant.description) || '',
characterAssets: characterAssetsDesc,
locationAsset: locationName ? `场景:${locationName}` : '无场景参考',
locationAsset: buildLocationAssetDescription({
includeLocationAsset,
locationName,
locale: job.data.locale,
}),
aspectRatio,
style: artStyle || '与参考图风格一致',
})