feat: implement robustness guards
This commit is contained in:
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
npm run verify:commit
|
||||
4
.husky/pre-push
Executable file
4
.husky/pre-push
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
npm run verify:push
|
||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -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",
|
||||
|
||||
17
package.json
17
package.json
@@ -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",
|
||||
|
||||
116
scripts/guards/api-route-contract-guard.mjs
Normal file
116
scripts/guards/api-route-contract-guard.mjs
Normal 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()
|
||||
}
|
||||
101
scripts/guards/image-reference-normalization-guard.mjs
Normal file
101
scripts/guards/image-reference-normalization-guard.mjs
Normal 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()
|
||||
}
|
||||
84
scripts/guards/task-submit-compensation-guard.mjs
Normal file
84
scripts/guards/task-submit-compensation-guard.mjs
Normal 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()
|
||||
}
|
||||
@@ -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. 后续步骤提示
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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')}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '与参考图风格一致',
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
207
tests/integration/api/contract/infra-routes.test.ts
Normal file
207
tests/integration/api/contract/infra-routes.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
298
tests/integration/api/specific/panel-variant-route.test.ts
Normal file
298
tests/integration/api/specific/panel-variant-route.test.ts
Normal 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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
53
tests/unit/guards/api-route-contract-guard.test.ts
Normal file
53
tests/unit/guards/api-route-contract-guard.test.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
43
tests/unit/guards/task-submit-compensation-guard.test.ts
Normal file
43
tests/unit/guards/task-submit-compensation-guard.test.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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: '未使用场景参考图',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user