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

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
set -e
npm run verify:commit

4
.husky/pre-push Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
set -e
npm run verify:push

17
package-lock.json generated
View File

@@ -71,6 +71,7 @@
"cross-env": "^10.1.0",
"eslint": "^9",
"eslint-config-next": "15.5.4",
"husky": "^9.1.7",
"rimraf": "^6.1.2",
"tailwindcss": "^4",
"tsx": "^4.20.5",
@@ -10950,6 +10951,22 @@
"node": ">=10.17.0"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.0.tgz",

View File

@@ -8,6 +8,7 @@
},
"scripts": {
"postinstall": "prisma generate",
"prepare": "husky",
"dev": "npm run storage:init && concurrently --kill-others \"npm run dev:next\" \"npm run dev:worker\" \"npm run dev:watchdog\" \"npm run dev:board\"",
"dev:next": "cross-env NODE_OPTIONS=\"--no-deprecation\" next dev --turbopack -H 0.0.0.0",
"dev:worker": "tsx watch --env-file=.env src/lib/workers/index.ts",
@@ -23,7 +24,7 @@
"start:watchdog": "tsx --env-file=.env scripts/watchdog.ts",
"start:board": "tsx --env-file=.env scripts/bull-board.ts",
"stats:errors": "tsx scripts/task-error-stats.ts",
"check:api-handler": "tsx scripts/check-api-handler.ts",
"check:api-handler": "node scripts/guards/api-route-contract-guard.mjs",
"check:logs": "tsx scripts/check-no-console.ts",
"check:log-semantic": "tsx scripts/check-log-semantic.ts",
"check:media-normalization": "tsx scripts/check-media-normalization.ts",
@@ -54,6 +55,9 @@
"check:test-behavior-route-coverage": "node scripts/guards/test-behavior-route-coverage-guard.mjs",
"check:test-behavior-tasktype-coverage": "node scripts/guards/test-behavior-tasktype-coverage-guard.mjs",
"check:test-coverage-guards": "npm run check:test-behavior-quality && npm run check:test-route-coverage && npm run check:test-tasktype-coverage && npm run check:test-behavior-route-coverage && npm run check:test-behavior-tasktype-coverage",
"check:requirements-matrix": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/contracts/requirements-matrix.test.ts",
"check:image-reference-normalization": "node scripts/guards/image-reference-normalization-guard.mjs",
"check:task-submit-compensation": "node scripts/guards/task-submit-compensation-guard.mjs",
"check:locale-navigation": "node scripts/guards/locale-navigation-guard.mjs",
"check:prompt-i18n": "node scripts/guards/prompt-i18n-guard.mjs",
"check:prompt-i18n-regression": "node scripts/guards/prompt-semantic-regression.mjs",
@@ -67,17 +71,21 @@
"test:billing:integration": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/billing",
"test:billing:concurrency": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/concurrency/billing",
"test:billing:coverage": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run --coverage tests/unit/billing tests/integration/billing tests/concurrency/billing",
"test:guards": "npm run check:api-handler && npm run check:no-api-direct-llm-call && npm run check:test-coverage-guards && npm run check:locale-navigation",
"test:guards": "npm run check:api-handler && npm run check:image-reference-normalization && npm run check:task-submit-compensation && npm run check:no-api-direct-llm-call && npm run check:test-coverage-guards && npm run check:requirements-matrix && npm run check:locale-navigation",
"test:unit:all": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit",
"test:integration:api": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/api",
"test:integration:chain": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/chain",
"test:behavior:unit": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit/helpers tests/unit/worker tests/unit/optimistic",
"test:behavior:unit": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit/helpers tests/unit/worker tests/unit/optimistic tests/unit/guards",
"test:behavior:api": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/api/contract",
"test:behavior:chain": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/chain",
"test:behavior:guards": "npm run check:test-coverage-guards",
"test:behavior:guards": "npm run check:api-handler && npm run check:image-reference-normalization && npm run check:task-submit-compensation && npm run check:test-coverage-guards && npm run check:requirements-matrix",
"test:behavior:full": "npm run test:behavior:guards && npm run test:behavior:unit && npm run test:behavior:api && npm run test:behavior:chain",
"test:regression": "npm run test:guards && npm run test:unit:all && npm run test:billing:integration && npm run test:integration:api && npm run test:integration:chain",
"test:pr": "bash scripts/test-regression-runner.sh npm run test:regression",
"typecheck": "tsc --noEmit",
"lint:all": "npm run lint -- .",
"verify:commit": "npm run lint:all && npm run typecheck && npm run test:behavior:full",
"verify:push": "npm run lint:all && npm run typecheck && npm run test:regression && npm run build",
"migrate:image-urls-contract": "tsx scripts/migrate-image-urls-contract.ts",
"migrate:model-config-contract": "tsx scripts/migrations/migrate-model-config-contract.ts",
"migrate:capability-selections": "tsx scripts/migrations/migrate-capability-selections.ts",
@@ -156,6 +164,7 @@
"cross-env": "^10.1.0",
"eslint": "^9",
"eslint-config-next": "15.5.4",
"husky": "^9.1.7",
"rimraf": "^6.1.2",
"tailwindcss": "^4",
"tsx": "^4.20.5",

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
import { pathToFileURL } from 'url'
const root = process.cwd()
const apiDir = path.join(root, 'src', 'app', 'api')
export const API_HANDLER_ALLOWLIST = new Set([
'src/app/api/auth/[...nextauth]/route.ts',
'src/app/api/files/[...path]/route.ts',
'src/app/api/system/boot-id/route.ts',
])
export const PUBLIC_ROUTE_ALLOWLIST = new Set([
'src/app/api/auth/[...nextauth]/route.ts',
'src/app/api/auth/register/route.ts',
'src/app/api/cos/image/route.ts',
'src/app/api/files/[...path]/route.ts',
'src/app/api/storage/sign/route.ts',
'src/app/api/system/boot-id/route.ts',
])
const AUTH_CALL_PATTERNS = [
/\brequireUserAuth\s*\(/,
/\brequireProjectAuth\s*\(/,
/\brequireProjectAuthLight\s*\(/,
]
function fail(title, details = []) {
process.stderr.write(`\n[api-route-contract-guard] ${title}\n`)
for (const detail of details) {
process.stderr.write(` - ${detail}\n`)
}
process.exit(1)
}
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(fullPath, out)
continue
}
if (entry.name === 'route.ts') out.push(fullPath)
}
return out
}
function toRel(fullPath) {
return path.relative(root, fullPath).split(path.sep).join('/')
}
function hasApiHandlerWrapper(content) {
return /\bapiHandler\s*\(/.test(content)
}
function hasRequiredAuth(content) {
return AUTH_CALL_PATTERNS.some((pattern) => pattern.test(content))
}
export function inspectRouteContract(relPath, content) {
const violations = []
if (!API_HANDLER_ALLOWLIST.has(relPath) && !hasApiHandlerWrapper(content)) {
violations.push(`${relPath} missing apiHandler wrapper`)
}
if (!PUBLIC_ROUTE_ALLOWLIST.has(relPath) && !hasRequiredAuth(content)) {
violations.push(`${relPath} missing requireUserAuth/requireProjectAuth/requireProjectAuthLight`)
}
return violations
}
export function findApiRouteContractViolations(scanRoot = root) {
const routesRoot = path.join(scanRoot, 'src', 'app', 'api')
return walk(routesRoot)
.map((fullPath) => {
const relPath = path.relative(scanRoot, fullPath).split(path.sep).join('/')
const content = fs.readFileSync(fullPath, 'utf8')
return inspectRouteContract(relPath, content)
})
.flat()
}
export function main() {
if (!fs.existsSync(apiDir)) {
fail('Missing src/app/api directory')
}
const violations = walk(apiDir)
.map((fullPath) => {
const relPath = toRel(fullPath)
const content = fs.readFileSync(fullPath, 'utf8')
return inspectRouteContract(relPath, content)
})
.flat()
if (violations.length > 0) {
fail('Found API route contract violations', violations)
}
process.stdout.write(
`[api-route-contract-guard] OK routes=${walk(apiDir).length} public=${PUBLIC_ROUTE_ALLOWLIST.size} apiHandlerExceptions=${API_HANDLER_ALLOWLIST.size}\n`,
)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
import { pathToFileURL } from 'url'
const root = process.cwd()
const handlersDir = path.join(root, 'src', 'lib', 'workers', 'handlers')
export const NORMALIZATION_HELPER_ALLOWLIST = new Set([
'src/lib/workers/handlers/image-task-handler-shared.ts',
])
const ACCEPTED_NORMALIZATION_MARKERS = [
/\bnormalizeReferenceImagesForGeneration\s*\(/,
/\bnormalizeToBase64ForGeneration\s*\(/,
/\bgenerateLabeledImageToCos\s*\(/,
]
function fail(title, details = []) {
process.stderr.write(`\n[image-reference-normalization-guard] ${title}\n`)
for (const detail of details) {
process.stderr.write(` - ${detail}\n`)
}
process.exit(1)
}
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(fullPath, out)
continue
}
if (entry.name.endsWith('.ts')) out.push(fullPath)
}
return out
}
function toRel(fullPath) {
return path.relative(root, fullPath).split(path.sep).join('/')
}
function usesGenerationReferenceImages(content) {
return /\bresolveImageSourceFromGeneration\s*\(/.test(content) && /\breferenceImages\s*:/.test(content)
}
function hasNormalizationMarker(content) {
return ACCEPTED_NORMALIZATION_MARKERS.some((pattern) => pattern.test(content))
}
export function inspectImageReferenceNormalization(relPath, content) {
if (NORMALIZATION_HELPER_ALLOWLIST.has(relPath)) return []
if (!usesGenerationReferenceImages(content)) return []
if (hasNormalizationMarker(content)) return []
return [
`${relPath} uses resolveImageSourceFromGeneration with referenceImages but does not reference normalizeReferenceImagesForGeneration/normalizeToBase64ForGeneration/generateLabeledImageToCos`,
]
}
export function findImageReferenceNormalizationViolations(scanRoot = root) {
const scanDir = path.join(scanRoot, 'src', 'lib', 'workers', 'handlers')
return walk(scanDir)
.map((fullPath) => {
const relPath = path.relative(scanRoot, fullPath).split(path.sep).join('/')
const content = fs.readFileSync(fullPath, 'utf8')
return inspectImageReferenceNormalization(relPath, content)
})
.flat()
}
export function main() {
if (!fs.existsSync(handlersDir)) {
fail('Missing src/lib/workers/handlers directory')
}
const handlerFiles = walk(handlersDir)
const violations = handlerFiles
.map((fullPath) => {
const relPath = toRel(fullPath)
const content = fs.readFileSync(fullPath, 'utf8')
return inspectImageReferenceNormalization(relPath, content)
})
.flat()
if (violations.length > 0) {
fail('Found image reference normalization violations', violations)
}
process.stdout.write(
`[image-reference-normalization-guard] OK handlers=${handlerFiles.length} allowlist=${NORMALIZATION_HELPER_ALLOWLIST.size}\n`,
)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
import { pathToFileURL } from 'url'
const root = process.cwd()
const apiDir = path.join(root, 'src', 'app', 'api')
const CREATE_PATTERN = /\.\s*create\s*\(/
const SUBMIT_TASK_PATTERN = /\bsubmitTask\s*\(/
const ROLLBACK_PATTERN = /rollback|compensat/i
function fail(title, details = []) {
process.stderr.write(`\n[task-submit-compensation-guard] ${title}\n`)
for (const detail of details) {
process.stderr.write(` - ${detail}\n`)
}
process.exit(1)
}
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(fullPath, out)
continue
}
if (entry.name === 'route.ts') out.push(fullPath)
}
return out
}
function toRel(fullPath) {
return path.relative(root, fullPath).split(path.sep).join('/')
}
export function inspectTaskSubmitCompensation(relPath, content) {
if (!CREATE_PATTERN.test(content)) return []
if (!SUBMIT_TASK_PATTERN.test(content)) return []
if (ROLLBACK_PATTERN.test(content)) return []
return [
`${relPath} creates data before submitTask without explicit rollback/compensation marker`,
]
}
export function findTaskSubmitCompensationViolations(scanRoot = root) {
const routesRoot = path.join(scanRoot, 'src', 'app', 'api')
return walk(routesRoot)
.map((fullPath) => {
const relPath = path.relative(scanRoot, fullPath).split(path.sep).join('/')
const content = fs.readFileSync(fullPath, 'utf8')
return inspectTaskSubmitCompensation(relPath, content)
})
.flat()
}
export function main() {
if (!fs.existsSync(apiDir)) {
fail('Missing src/app/api directory')
}
const routeFiles = walk(apiDir)
const violations = routeFiles
.map((fullPath) => {
const relPath = toRel(fullPath)
const content = fs.readFileSync(fullPath, 'utf8')
return inspectTaskSubmitCompensation(relPath, content)
})
.flat()
if (violations.length > 0) {
fail('Found create+submitTask routes without compensation marker', violations)
}
process.stdout.write(`[task-submit-compensation-guard] OK routes=${routeFiles.length}\n`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}

View File

@@ -48,7 +48,7 @@ const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }
function log(level: string, message: string, ...args: unknown[]) {
if (LOG_LEVELS[level as keyof typeof LOG_LEVELS] >= LOG_LEVELS[CONFIG.options.logLevel as keyof typeof LOG_LEVELS]) {
const timestamp = new Date().toISOString()
console[level === 'error' ? 'error' : 'log'](\`[\${timestamp}] [\${level.toUpperCase()}] \${message}\`, ...args)
console[level === 'error' ? 'error' : 'log'](`[${timestamp}] [${level.toUpperCase()}] ${message}`, ...args)
}
}
@@ -87,7 +87,7 @@ async function scanLocalFiles(dir: string, basePath = ''): Promise<Array<{localP
}
}
} catch (err: unknown) {
log('warn', \`无法读取目录: \${dir}\`, (err as Error).message)
log('warn', `无法读取目录: ${dir}`, (err as Error).message)
}
return files
@@ -129,11 +129,11 @@ async function saveProgress(migratedKeys: Set<string>) {
// ==================== 存储桶检查/创建 ====================
async function ensureBucket() {
log('info', \`检查存储桶: \${CONFIG.minio.bucket}\`)
log('info', `检查存储桶: ${CONFIG.minio.bucket}`)
const exists = await minioClient.bucketExists(CONFIG.minio.bucket)
if (!exists) {
log('info', \`创建存储桶: \${CONFIG.minio.bucket}\`)
log('info', `创建存储桶: ${CONFIG.minio.bucket}`)
await minioClient.makeBucket(CONFIG.minio.bucket, CONFIG.minio.region)
// 设置存储桶为 public read (可选,根据需求)
@@ -144,7 +144,7 @@ async function ensureBucket() {
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [\`arn:aws:s3:::\${CONFIG.minio.bucket}/*\`]
Resource: [`arn:aws:s3:::${CONFIG.minio.bucket}/*`]
}
]
}
@@ -159,12 +159,12 @@ async function uploadFile(fileInfo: {localPath: string, key: string, size: numbe
// 检查是否已迁移
if (migratedKeys.has(key)) {
log('debug', \`跳过已迁移: \${key}\`)
log('debug', `跳过已迁移: ${key}`)
return { status: 'skipped', key }
}
if (CONFIG.options.dryRun) {
log('info', \`[DRY RUN] 将上传: \${key} (\${formatBytes(size)})\`)
log('info', `[DRY RUN] 将上传: ${key} (${formatBytes(size)})`)
return { status: 'dry_run', key }
}
@@ -185,11 +185,11 @@ async function uploadFile(fileInfo: {localPath: string, key: string, size: numbe
// 记录迁移成功
migratedKeys.add(key)
log('info', \`✓ 上传成功: \${key} (\${formatBytes(size)})\`)
log('info', `✓ 上传成功: ${key} (${formatBytes(size)})`)
return { status: 'success', key, size }
} catch (err: unknown) {
log('error', \`✗ 上传失败: \${key}\`, (err as Error).message)
log('error', `✗ 上传失败: ${key}`, (err as Error).message)
return { status: 'error', key, error: (err as Error).message }
}
}
@@ -251,17 +251,17 @@ async function main() {
console.log()
log('info', '配置信息:')
log('info', \` 本地目录: \${path.resolve(CONFIG.local.baseDir)}\`)
log('info', \` MinIO: \${CONFIG.minio.endPoint}:\${CONFIG.minio.port}/\${CONFIG.minio.bucket}\`)
log('info', \` 并发数: \${CONFIG.options.concurrency}\`)
log('info', \` 干运行: \${CONFIG.options.dryRun}\`)
log('info', \` 断点续传: \${CONFIG.options.resume}\`)
log('info', ` 本地目录: ${path.resolve(CONFIG.local.baseDir)}`)
log('info', ` MinIO: ${CONFIG.minio.endPoint}:${CONFIG.minio.port}/${CONFIG.minio.bucket}`)
log('info', ` 并发数: ${CONFIG.options.concurrency}`)
log('info', ` 干运行: ${CONFIG.options.dryRun}`)
log('info', ` 断点续传: ${CONFIG.options.resume}`)
console.log()
// 1. 扫描本地文件
log('info', '扫描本地文件...')
const files = await scanLocalFiles(CONFIG.local.baseDir)
log('info', \`找到 \${files.length} 个文件\`)
log('info', `找到 ${files.length} 个文件`)
if (files.length === 0) {
log('info', '没有需要迁移的文件')
@@ -269,12 +269,12 @@ async function main() {
}
const totalSize = files.reduce((sum, f) => sum + f.size, 0)
log('info', \`总大小: \${formatBytes(totalSize)}\`)
log('info', `总大小: ${formatBytes(totalSize)}`)
console.log()
// 2. 加载进度
const migratedKeys = await loadProgress()
log('info', \`已迁移: \${migratedKeys.size} 个文件\`)
log('info', `已迁移: ${migratedKeys.size} 个文件`)
// 3. 确保存储桶存在
await ensureBucket()
@@ -298,7 +298,7 @@ async function main() {
if (processed % 10 === 0) {
await saveProgress(migratedKeys)
const progress = ((processed / files.length) * 100).toFixed(1)
log('info', \`进度: \${progress}% (\${processed}/\${files.length})\`)
log('info', `进度: ${progress}% (${processed}/${files.length})`)
}
return result
@@ -315,11 +315,11 @@ async function main() {
console.log('╔══════════════════════════════════════════════════════════╗')
console.log('║ 迁移完成 ║')
console.log('╠══════════════════════════════════════════════════════════╣')
console.log(\`║ 总文件数: \${String(files.length).padEnd(39)}\`)
console.log(\`║ 成功: \${String(success).padEnd(39)}\`)
console.log(\`║ 失败: \${String(failed).padEnd(39)}\`)
console.log(\`║ 跳过: \${String(skipped).padEnd(39)}\`)
console.log(\`║ 耗时: \${String(duration + 's').padEnd(39)}\`)
console.log(`║ 总文件数: ${String(files.length).padEnd(39)}`)
console.log(`║ 成功: ${String(success).padEnd(39)}`)
console.log(`║ 失败: ${String(failed).padEnd(39)}`)
console.log(`║ 跳过: ${String(skipped).padEnd(39)}`)
console.log(`║ 耗时: ${String(duration + 's').padEnd(39)}`)
console.log('╚══════════════════════════════════════════════════════════╝')
// 7. 后续步骤提示

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 || '与参考图风格一致',
})

View File

@@ -57,6 +57,29 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
'tests/integration/chain/video.chain.test.ts',
],
},
{
id: 'REQ-NP-INSERT-PANEL-AUTO-ANALYZE',
feature: 'Novel promotion insert panel',
userValue: 'AI 自动分析插入分镜时不会因空输入失败',
risk: 'route 与 worker 契约分叉导致异步任务直接报错',
priority: 'P0',
tests: [
'tests/unit/novel-promotion/insert-panel-user-input.test.ts',
'tests/integration/api/contract/direct-submit-routes.test.ts',
],
},
{
id: 'REQ-NP-PANEL-VARIANT-SAFETY',
feature: 'Novel promotion panel variant',
userValue: '镜头变体只能插入当前 storyboard任务失败可回滚资产开关真实生效',
risk: '跨分镜误插入、创建脏 panel、参考图开关失效',
priority: 'P0',
tests: [
'tests/integration/api/specific/panel-variant-route.test.ts',
'tests/integration/api/contract/direct-submit-routes.test.ts',
'tests/unit/worker/panel-variant-task-handler.test.ts',
],
},
{
id: 'REQ-NP-TEXT-ANALYSIS',
feature: 'Text analysis and storyboard orchestration',
@@ -81,4 +104,24 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
'tests/unit/optimistic/sse-invalidation.test.ts',
],
},
{
id: 'REQ-API-CONFIG-TUTORIAL-PORTAL',
feature: 'API config tutorial modal layering',
userValue: '开通教程浮层只高亮当前教程,不污染其他 provider card',
risk: '弹层挂载在局部层叠上下文内,导致高亮重叠和误覆盖',
priority: 'P1',
tests: [
'tests/unit/api-config/provider-card-tutorial-modal.test.ts',
],
},
{
id: 'REQ-INFRA-PUBLIC-ROUTES',
feature: 'Infra and public routes',
userValue: '基础公共路由可稳定访问,公开范围明确且有测试兜底',
risk: '特殊公开路由缺少约束或回归覆盖,导致泄漏、误拦截或行为漂移',
priority: 'P1',
tests: [
'tests/integration/api/contract/infra-routes.test.ts',
],
},
]

View File

@@ -15,7 +15,7 @@ const CONTRACT_TEST_BY_GROUP: Record<RouteCatalogEntry['contractGroup'], string>
'task-infra-routes': 'tests/integration/api/contract/task-infra-routes.test.ts',
'user-project-routes': 'tests/integration/api/contract/crud-routes.test.ts',
'auth-routes': 'tests/integration/api/contract/crud-routes.test.ts',
'infra-routes': 'tests/integration/api/contract/crud-routes.test.ts',
'infra-routes': 'tests/integration/api/contract/infra-routes.test.ts',
}
function resolveChainTest(routeFile: string): string {

View File

@@ -25,6 +25,7 @@ export type RouteCatalogEntry = {
}
const ROUTE_FILES = [
'src/app/api/admin/download-logs/route.ts',
'src/app/api/asset-hub/ai-design-character/route.ts',
'src/app/api/asset-hub/ai-design-location/route.ts',
'src/app/api/asset-hub/ai-modify-character/route.ts',

View File

@@ -76,6 +76,17 @@ const prismaMock = vi.hoisted(() => ({
userPreference: {
findUnique: vi.fn(async () => ({ lipSyncModel: 'fal::lipsync-model' })),
},
novelPromotionStoryboard: {
findUnique: vi.fn(async () => ({
id: 'storyboard-1',
episode: {
novelPromotionProject: {
projectId: 'project-1',
},
},
})),
update: vi.fn(async () => ({})),
},
novelPromotionPanel: {
findFirst: vi.fn(async () => ({ id: 'panel-1' })),
findMany: vi.fn(async () => []),
@@ -84,6 +95,7 @@ const prismaMock = vi.hoisted(() => ({
if (id === 'panel-src') {
return {
id,
storyboardId: 'storyboard-1',
panelIndex: 1,
shotType: 'wide',
cameraMove: 'static',
@@ -98,6 +110,7 @@ const prismaMock = vi.hoisted(() => ({
if (id === 'panel-ins') {
return {
id,
storyboardId: 'storyboard-1',
panelIndex: 2,
shotType: 'medium',
cameraMove: 'push',
@@ -111,6 +124,7 @@ const prismaMock = vi.hoisted(() => ({
}
return {
id,
storyboardId: 'storyboard-1',
panelIndex: 0,
shotType: 'medium',
cameraMove: 'static',
@@ -124,6 +138,10 @@ const prismaMock = vi.hoisted(() => ({
}),
update: vi.fn(async () => ({})),
create: vi.fn(async () => ({ id: 'panel-created', panelIndex: 3 })),
findUniqueOrThrow: vi.fn(),
delete: vi.fn(async () => ({})),
count: vi.fn(async () => 3),
updateMany: vi.fn(async () => ({ count: 0 })),
},
novelPromotionProject: {
findUnique: vi.fn(async () => ({
@@ -153,14 +171,31 @@ const prismaMock = vi.hoisted(() => ({
novelPromotionPanel: {
findMany: (args: unknown) => Promise<Array<{ id: string; panelIndex: number }>>
update: (args: unknown) => Promise<unknown>
create: (args: unknown) => Promise<{ id: string; panelIndex: number }>
create: (args: { data?: { id?: string; panelIndex?: number } }) => Promise<{ id: string; panelIndex: number }>
findFirst: (args: unknown) => Promise<{ panelIndex: number } | null>
delete: (args: unknown) => Promise<unknown>
count: (args: unknown) => Promise<number>
updateMany: (args: unknown) => Promise<{ count: number }>
}
novelPromotionStoryboard: {
update: (args: unknown) => Promise<unknown>
}
}) => Promise<unknown>) => {
const tx = {
novelPromotionPanel: {
findMany: async () => [],
update: async () => ({}),
create: async () => ({ id: 'panel-created', panelIndex: 3 }),
create: async (args: { data?: { id?: string; panelIndex?: number } }) => ({
id: args.data?.id || 'panel-created',
panelIndex: args.data?.panelIndex ?? 3,
}),
findFirst: async () => ({ panelIndex: 3 }),
delete: async () => ({}),
count: async () => 3,
updateMany: async () => ({ count: 0 }),
},
novelPromotionStoryboard: {
update: async () => ({}),
},
}
return await fn(tx)

View File

@@ -0,0 +1,207 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ROUTE_CATALOG } from '../../../contracts/route-catalog'
import { buildMockRequest } from '../../../helpers/request'
const authState = vi.hoisted(() => ({
authenticated: false,
}))
const loggingMock = vi.hoisted(() => ({
readAllLogs: vi.fn(async () => 'worker log line 1\nworker log line 2'),
}))
const storageMock = vi.hoisted(() => ({
getSignedObjectUrl: vi.fn(async (key: string, ttl: number) => `https://signed.example/${key}?expires=${ttl}`),
}))
vi.mock('@/lib/api-auth', () => {
const unauthorized = () => new Response(
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
{ status: 401, headers: { 'content-type': 'application/json' } },
)
return {
isErrorResponse: (value: unknown) => value instanceof Response,
requireUserAuth: async () => {
if (!authState.authenticated) return unauthorized()
return { session: { user: { id: 'user-1' } } }
},
}
})
vi.mock('@/lib/logging/file-writer', () => loggingMock)
vi.mock('@/lib/storage', () => storageMock)
describe('api contract - infra routes (behavior)', () => {
const routes = ROUTE_CATALOG.filter((entry) => entry.contractGroup === 'infra-routes')
const originalUploadDir = process.env.UPLOAD_DIR
const tempState = {
uploadDirAbs: '',
uploadDirRel: '',
}
beforeEach(() => {
vi.clearAllMocks()
authState.authenticated = false
vi.resetModules()
})
afterEach(async () => {
vi.resetModules()
if (tempState.uploadDirAbs) {
await fs.rm(tempState.uploadDirAbs, { recursive: true, force: true })
tempState.uploadDirAbs = ''
tempState.uploadDirRel = ''
}
if (originalUploadDir === undefined) {
delete process.env.UPLOAD_DIR
} else {
process.env.UPLOAD_DIR = originalUploadDir
}
})
async function prepareUploadDir(): Promise<void> {
const unique = `test-uploads-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
tempState.uploadDirRel = path.join('.tmp', unique)
tempState.uploadDirAbs = path.join(process.cwd(), tempState.uploadDirRel)
process.env.UPLOAD_DIR = tempState.uploadDirRel
await fs.mkdir(tempState.uploadDirAbs, { recursive: true })
}
it('infra route group exists', () => {
expect(routes.map((entry) => entry.routeFile)).toEqual(expect.arrayContaining([
'src/app/api/admin/download-logs/route.ts',
'src/app/api/cos/image/route.ts',
'src/app/api/files/[...path]/route.ts',
'src/app/api/storage/sign/route.ts',
'src/app/api/system/boot-id/route.ts',
]))
})
it('GET /api/admin/download-logs rejects unauthenticated requests', async () => {
const mod = await import('@/app/api/admin/download-logs/route')
const req = buildMockRequest({
path: '/api/admin/download-logs',
method: 'GET',
})
const res = await mod.GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(401)
expect(loggingMock.readAllLogs).not.toHaveBeenCalled()
})
it('GET /api/admin/download-logs returns attachment headers when authenticated', async () => {
authState.authenticated = true
const mod = await import('@/app/api/admin/download-logs/route')
const req = buildMockRequest({
path: '/api/admin/download-logs',
method: 'GET',
})
const res = await mod.GET(req, { params: Promise.resolve({}) })
const text = await res.text()
expect(res.status).toBe(200)
expect(text).toContain('worker log line 1')
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8')
expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="waoowaoo-logs-/)
})
it('GET /api/cos/image redirects to signed storage route with normalized query', async () => {
const mod = await import('@/app/api/cos/image/route')
const req = buildMockRequest({
path: '/api/cos/image?key=folder/a.png&expires=7200',
method: 'GET',
})
const res = await mod.GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(307)
expect(res.headers.get('location')).toBe('http://localhost:3000/api/storage/sign?key=folder%2Fa.png&expires=7200')
})
it('GET /api/storage/sign redirects to signed object url with default ttl', async () => {
const mod = await import('@/app/api/storage/sign/route')
const req = buildMockRequest({
path: '/api/storage/sign?key=folder/a.png',
method: 'GET',
})
const res = await mod.GET(req, { params: Promise.resolve({}) })
expect(storageMock.getSignedObjectUrl).toHaveBeenCalledWith('folder/a.png', 3600)
expect(res.status).toBe(307)
expect(res.headers.get('location')).toBe('https://signed.example/folder/a.png?expires=3600')
})
it('GET /api/system/boot-id returns the current server boot id', async () => {
const mod = await import('@/app/api/system/boot-id/route')
const serverBoot = await import('@/lib/server-boot')
const res = await mod.GET()
const json = await res.json() as { bootId: string }
expect(res.status).toBe(200)
expect(json.bootId).toBe(serverBoot.SERVER_BOOT_ID)
expect(typeof json.bootId).toBe('string')
expect(json.bootId.length).toBeGreaterThan(0)
})
it('GET /api/files/[...path] rejects path traversal attempts', async () => {
await prepareUploadDir()
const mod = await import('@/app/api/files/[...path]/route')
const req = buildMockRequest({
path: '/api/files/%2E%2E/secret.txt',
method: 'GET',
})
const res = await mod.GET(req, {
params: Promise.resolve({ path: ['..', 'secret.txt'] }),
})
const json = await res.json() as { error: string }
expect(res.status).toBe(403)
expect(json.error).toBe('Access denied')
})
it('GET /api/files/[...path] returns 404 when the file is missing', async () => {
await prepareUploadDir()
const mod = await import('@/app/api/files/[...path]/route')
const req = buildMockRequest({
path: '/api/files/missing.txt',
method: 'GET',
})
const res = await mod.GET(req, {
params: Promise.resolve({ path: ['missing.txt'] }),
})
const json = await res.json() as { error: string }
expect(res.status).toBe(404)
expect(json.error).toBe('File not found')
})
it('GET /api/files/[...path] serves local files from the configured upload dir', async () => {
await prepareUploadDir()
const nestedDir = path.join(tempState.uploadDirAbs, 'folder')
await fs.mkdir(nestedDir, { recursive: true })
await fs.writeFile(path.join(nestedDir, 'hello.txt'), 'hello local file', 'utf8')
const mod = await import('@/app/api/files/[...path]/route')
const req = buildMockRequest({
path: '/api/files/folder/hello.txt',
method: 'GET',
})
const res = await mod.GET(req, {
params: Promise.resolve({ path: ['folder', 'hello.txt'] }),
})
const text = await res.text()
expect(res.status).toBe(200)
expect(text).toBe('hello local file')
expect(res.headers.get('content-type')).toBe('text/plain')
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000')
})
})

View File

@@ -0,0 +1,298 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildMockRequest } from '../../../helpers/request'
type PanelRecord = {
id: string
storyboardId: string
panelIndex: number
shotType: string
cameraMove: string
description: string
videoPrompt: string
location: string
characters: string
srtSegment: string
duration: number
}
type StoryboardRecord = {
id: string
episode: {
novelPromotionProject: {
projectId: string
}
}
}
const authMock = vi.hoisted(() => ({
requireProjectAuthLight: vi.fn(async () => ({
session: { user: { id: 'user-1' } },
project: { id: 'project-1', userId: 'user-1', mode: 'novel-promotion' },
})),
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
}))
const submitTaskMock = vi.hoisted(() => vi.fn<typeof import('@/lib/task/submitter').submitTask>(async () => ({
success: true,
async: true,
taskId: 'task-panel-variant',
runId: null,
status: 'queued',
deduped: false,
})))
const configServiceMock = vi.hoisted(() => ({
getProjectModelConfig: vi.fn(async () => ({
storyboardModel: 'img::storyboard',
})),
buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({
...input.basePayload,
generationOptions: { resolution: '1024x1024' },
})),
}))
const rollbackSpy = vi.hoisted(() => ({
delete: vi.fn(async () => ({})),
findFirst: vi.fn(async () => ({ panelIndex: 4 })),
updateMany: vi.fn(async () => ({ count: 2 })),
count: vi.fn(async () => 3),
storyboardUpdate: vi.fn(async () => ({})),
}))
const createTxSpy = vi.hoisted(() => ({
findMany: vi.fn(async () => [
{ id: 'panel-after-1', panelIndex: 2 },
{ id: 'panel-after-2', panelIndex: 3 },
]),
update: vi.fn(async () => ({})),
create: vi.fn(async (args: { data: PanelRecord }) => ({
id: args.data.id,
panelIndex: args.data.panelIndex,
})),
count: vi.fn(async () => 4),
storyboardUpdate: vi.fn(async () => ({})),
}))
const routeState = vi.hoisted(() => ({
storyboard: {
id: 'storyboard-1',
episode: {
novelPromotionProject: {
projectId: 'project-1',
},
},
} satisfies StoryboardRecord,
panels: new Map<string, PanelRecord>(),
}))
const prismaMock = vi.hoisted(() => ({
novelPromotionStoryboard: {
findUnique: vi.fn(async () => routeState.storyboard),
},
novelPromotionPanel: {
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => routeState.panels.get(where.id) ?? null),
},
$transaction: vi.fn(async (
fn: (tx: {
novelPromotionPanel: {
findMany: typeof createTxSpy.findMany
update: typeof createTxSpy.update
create: typeof createTxSpy.create
delete: typeof rollbackSpy.delete
findFirst: typeof rollbackSpy.findFirst
updateMany: typeof rollbackSpy.updateMany
count: typeof rollbackSpy.count
}
novelPromotionStoryboard: {
update: typeof createTxSpy.storyboardUpdate
}
}) => Promise<unknown>,
) => {
const invocation = prismaMock.$transaction.mock.calls.length
if (invocation > 1) {
return await fn({
novelPromotionPanel: {
findMany: createTxSpy.findMany,
update: createTxSpy.update,
create: createTxSpy.create,
delete: rollbackSpy.delete,
findFirst: rollbackSpy.findFirst,
updateMany: rollbackSpy.updateMany,
count: rollbackSpy.count,
},
novelPromotionStoryboard: {
update: rollbackSpy.storyboardUpdate,
},
})
}
return await fn({
novelPromotionPanel: {
findMany: createTxSpy.findMany,
update: createTxSpy.update,
create: createTxSpy.create,
delete: rollbackSpy.delete,
findFirst: rollbackSpy.findFirst,
updateMany: rollbackSpy.updateMany,
count: rollbackSpy.count,
},
novelPromotionStoryboard: {
update: createTxSpy.storyboardUpdate,
},
})
}),
}))
vi.mock('@/lib/api-auth', () => authMock)
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
vi.mock('@/lib/config-service', () => configServiceMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/billing', () => ({
buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),
}))
vi.mock('@/lib/task/resolve-locale', () => ({
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
}))
function buildPanel(id: string, storyboardId: string, panelIndex: number): PanelRecord {
return {
id,
storyboardId,
panelIndex,
shotType: 'medium',
cameraMove: 'static',
description: `description-${id}`,
videoPrompt: `prompt-${id}`,
location: 'Old Town',
characters: '[]',
srtSegment: '',
duration: 3,
}
}
async function invokeRoute(body: Record<string, unknown>): Promise<Response> {
const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/panel-variant',
method: 'POST',
body,
})
return await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
}
describe('api specific - panel variant route', () => {
beforeEach(() => {
vi.clearAllMocks()
routeState.storyboard = {
id: 'storyboard-1',
episode: {
novelPromotionProject: {
projectId: 'project-1',
},
},
}
routeState.panels = new Map<string, PanelRecord>([
['panel-src', buildPanel('panel-src', 'storyboard-1', 1)],
['panel-ins', buildPanel('panel-ins', 'storyboard-1', 2)],
])
})
it('returns INVALID_PARAMS when sourcePanelId does not belong to storyboardId', async () => {
routeState.panels.set('panel-src', buildPanel('panel-src', 'storyboard-other', 1))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string } }
expect(res.status).toBe(400)
expect(json.error.code).toBe('INVALID_PARAMS')
expect(createTxSpy.create).not.toHaveBeenCalled()
expect(submitTaskMock).not.toHaveBeenCalled()
})
it('returns INVALID_PARAMS when insertAfterPanelId does not belong to storyboardId', async () => {
routeState.panels.set('panel-ins', buildPanel('panel-ins', 'storyboard-other', 2))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string } }
expect(res.status).toBe(400)
expect(json.error.code).toBe('INVALID_PARAMS')
expect(createTxSpy.create).not.toHaveBeenCalled()
expect(submitTaskMock).not.toHaveBeenCalled()
})
it('does not create panel when image billing payload validation fails', async () => {
configServiceMock.buildImageBillingPayload.mockRejectedValueOnce(new Error('missing capability'))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string; message: string } }
expect(res.status).toBe(400)
expect(json.error.code).toBe('INVALID_PARAMS')
expect(json.error.message).toBe('missing capability')
expect(createTxSpy.create).not.toHaveBeenCalled()
expect(submitTaskMock).not.toHaveBeenCalled()
})
it('rolls back the created panel when submitTask fails after insertion', async () => {
submitTaskMock.mockRejectedValueOnce(new Error('queue unavailable'))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string } }
expect(res.status).toBe(502)
expect(json.error.code).toBe('EXTERNAL_ERROR')
expect(createTxSpy.create).toHaveBeenCalledTimes(1)
const createdPanelId = createTxSpy.create.mock.calls[0]?.[0].data.id
expect(createdPanelId).toEqual(expect.any(String))
expect(rollbackSpy.delete).toHaveBeenCalledWith({
where: { id: createdPanelId },
})
expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(1, {
where: {
storyboardId: 'storyboard-1',
panelIndex: { gt: 3 },
},
data: {
panelIndex: { increment: 1004 },
panelNumber: { increment: 1004 },
},
})
expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(2, {
where: {
storyboardId: 'storyboard-1',
panelIndex: { gt: 1007 },
},
data: {
panelIndex: { decrement: 1005 },
panelNumber: { decrement: 1005 },
},
})
expect(rollbackSpy.storyboardUpdate).toHaveBeenCalledWith({
where: { id: 'storyboard-1' },
data: { panelCount: 3 },
})
})
})

View File

@@ -107,6 +107,20 @@ function createState(tutorial: ProviderTutorial): UseProviderCardStateResult {
}
}
function ProviderCardShellWithBody(
props: Omit<React.ComponentProps<typeof ProviderCardShell>, 'children'>,
): React.ReactElement {
const ProviderCardShellComponent =
ProviderCardShell as unknown as React.ComponentType<
React.PropsWithChildren<Omit<React.ComponentProps<typeof ProviderCardShell>, 'children'>>
>
return createElement(
ProviderCardShellComponent,
props,
createElement('div', null, 'provider-body'),
)
}
describe('ProviderCardShell tutorial modal', () => {
afterEach(() => {
vi.clearAllMocks()
@@ -145,7 +159,7 @@ describe('ProviderCardShell tutorial modal', () => {
const html = renderToStaticMarkup(
createElement(
ProviderCardShell,
ProviderCardShellWithBody,
{
provider: {
id: 'ark',
@@ -156,7 +170,6 @@ describe('ProviderCardShell tutorial modal', () => {
t,
state,
},
createElement('div', null, 'provider-body'),
),
)

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
API_HANDLER_ALLOWLIST,
PUBLIC_ROUTE_ALLOWLIST,
inspectRouteContract,
} from '../../../scripts/guards/api-route-contract-guard.mjs'
describe('api route contract guard', () => {
it('allows explicit public and framework-managed exceptions', () => {
expect(API_HANDLER_ALLOWLIST.has('src/app/api/auth/[...nextauth]/route.ts')).toBe(true)
expect(PUBLIC_ROUTE_ALLOWLIST.has('src/app/api/system/boot-id/route.ts')).toBe(true)
expect(
inspectRouteContract(
'src/app/api/system/boot-id/route.ts',
'export async function GET() { return Response.json({ bootId: "x" }) }',
),
).toEqual([])
})
it('passes protected routes that use apiHandler and explicit auth', () => {
const content = `
import { requireUserAuth } from '@/lib/api-auth'
import { apiHandler } from '@/lib/api-errors'
export const GET = apiHandler(async () => {
await requireUserAuth()
return Response.json({ ok: true })
})
`
expect(inspectRouteContract('src/app/api/user/secure/route.ts', content)).toEqual([])
})
it('flags protected routes that skip apiHandler or auth', () => {
const missingApiHandler = `
import { requireUserAuth } from '@/lib/api-auth'
export async function GET() {
await requireUserAuth()
return Response.json({ ok: true })
}
`
const missingAuth = `
import { apiHandler } from '@/lib/api-errors'
export const GET = apiHandler(async () => Response.json({ ok: true }))
`
expect(inspectRouteContract('src/app/api/user/secure/route.ts', missingApiHandler)).toEqual([
'src/app/api/user/secure/route.ts missing apiHandler wrapper',
])
expect(inspectRouteContract('src/app/api/user/secure/route.ts', missingAuth)).toEqual([
'src/app/api/user/secure/route.ts missing requireUserAuth/requireProjectAuth/requireProjectAuthLight',
])
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
NORMALIZATION_HELPER_ALLOWLIST,
inspectImageReferenceNormalization,
} from '../../../scripts/guards/image-reference-normalization-guard.mjs'
describe('image reference normalization guard', () => {
it('allows shared helper exceptions explicitly', () => {
expect(NORMALIZATION_HELPER_ALLOWLIST.has('src/lib/workers/handlers/image-task-handler-shared.ts')).toBe(true)
expect(
inspectImageReferenceNormalization(
'src/lib/workers/handlers/image-task-handler-shared.ts',
'resolveImageSourceFromGeneration(job, { options: params.options })\nreferenceImages?: string[]',
),
).toEqual([])
})
it('passes handlers that normalize reference images before generation', () => {
const content = `
import { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image'
async function run() {
const normalizedRefs = await normalizeReferenceImagesForGeneration(refs)
return await resolveImageSourceFromGeneration(job, {
options: {
referenceImages: normalizedRefs,
},
})
}
`
expect(
inspectImageReferenceNormalization('src/lib/workers/handlers/panel-image-task-handler.ts', content),
).toEqual([])
})
it('flags handlers that send referenceImages without normalization markers', () => {
const content = `
async function run() {
return await resolveImageSourceFromGeneration(job, {
options: {
referenceImages: refs,
},
})
}
`
expect(
inspectImageReferenceNormalization('src/lib/workers/handlers/bad-handler.ts', content),
).toEqual([
'src/lib/workers/handlers/bad-handler.ts uses resolveImageSourceFromGeneration with referenceImages but does not reference normalizeReferenceImagesForGeneration/normalizeToBase64ForGeneration/generateLabeledImageToCos',
])
})
})

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { inspectTaskSubmitCompensation } from '../../../scripts/guards/task-submit-compensation-guard.mjs'
describe('task submit compensation guard', () => {
it('passes routes that create data before submitTask and define rollback handling', () => {
const content = `
async function rollbackCreatedRecord() {}
export const POST = apiHandler(async () => {
await prisma.panel.create({ data: {} })
try {
return await submitTask({})
} catch (error) {
await rollbackCreatedRecord()
throw error
}
})
`
expect(
inspectTaskSubmitCompensation('src/app/api/novel-promotion/[projectId]/panel-variant/route.ts', content),
).toEqual([])
})
it('ignores routes that do not combine create and submitTask', () => {
expect(inspectTaskSubmitCompensation('src/app/api/user/api-config/route.ts', 'await submitTask({})')).toEqual([])
expect(inspectTaskSubmitCompensation('src/app/api/projects/route.ts', 'await prisma.project.create({ data: {} })')).toEqual([])
})
it('flags routes that create data before submitTask without compensation marker', () => {
const content = `
export const POST = apiHandler(async () => {
await prisma.panel.create({ data: {} })
return await submitTask({})
})
`
expect(
inspectTaskSubmitCompensation('src/app/api/example/route.ts', content),
).toEqual([
'src/app/api/example/route.ts creates data before submitTask without explicit rollback/compensation marker',
])
})
})

View File

@@ -21,8 +21,16 @@ const sharedMock = vi.hoisted(() => ({
collectPanelReferenceImages: vi.fn(async () => ['https://signed.example/ref-character.png']),
resolveNovelData: vi.fn(async () => ({
videoRatio: '16:9',
characters: [{ name: 'Hero', introduction: '主角' }],
locations: [{ name: 'Old Town' }],
characters: [{
name: 'Hero',
introduction: '主角',
appearances: [{
changeReason: 'default',
imageUrls: JSON.stringify(['cos/hero-default.png']),
imageUrl: 'cos/hero-default.png',
}],
}],
locations: [{ name: 'Old Town', images: [] }],
})),
}))
@@ -30,6 +38,10 @@ const outboundMock = vi.hoisted(() => ({
normalizeReferenceImagesForGeneration: vi.fn(async (refs: string[]) => refs.map((item) => `normalized:${item}`)),
}))
const promptMock = vi.hoisted(() => ({
buildPrompt: vi.fn(() => 'panel-variant-prompt'),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/media/outbound-image', () => outboundMock)
@@ -46,7 +58,7 @@ vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
})
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_AGENT_SHOT_VARIANT_GENERATE: 'np_agent_shot_variant_generate' },
buildPrompt: vi.fn(() => 'panel-variant-prompt'),
buildPrompt: promptMock.buildPrompt,
}))
import { handlePanelVariantTask } from '@/lib/workers/handlers/panel-variant-task-handler'
@@ -123,7 +135,7 @@ describe('worker panel-variant-task-handler behavior', () => {
aspectRatio: '16:9',
referenceImages: [
'normalized:https://signed.example/cos/panel-source.png',
'normalized:https://signed.example/ref-character.png',
'normalized:https://signed.example/cos/hero-default.png',
],
}),
}),
@@ -140,4 +152,30 @@ describe('worker panel-variant-task-handler behavior', () => {
imageUrl: 'cos/panel-variant-new.png',
})
})
it('respects reference asset toggles when character/location assets are disabled', async () => {
const payload = {
newPanelId: 'panel-new',
sourcePanelId: 'panel-source',
includeCharacterAssets: false,
includeLocationAsset: false,
variant: {
title: '禁用资产版本',
description: '只参考原镜头',
video_prompt: '只参考原镜头',
},
}
await handlePanelVariantTask(buildJob(payload))
expect(outboundMock.normalizeReferenceImagesForGeneration).toHaveBeenCalledWith([
'https://signed.example/cos/panel-source.png',
])
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.objectContaining({
character_assets: '未使用角色参考图',
location_asset: '未使用场景参考图',
}),
}))
})
})