fix: slice long brand knowledge previews

This commit is contained in:
江林铖 2026-09-14 20:29:32 +08:00
parent 6c263e4999
commit 34f355b538
19 changed files with 516 additions and 57 deletions

View File

@ -96,7 +96,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \
test -f "/app/prisma/migrations/20260908_add_brand_knowledge/migration.sql" && \
test -f "/app/prisma/migrations/20260909_expand_brand_knowledge_upload/migration.sql" && \
test -f "/app/prisma/migrations/20260914_add_brand_knowledge_engagement/migration.sql" && \
test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql"
test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql" && \
test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices/migration.sql"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
@ -226,6 +227,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260908_add_brand_
COPY --from=builder --chown=root:root /app/prisma/migrations/20260909_expand_brand_knowledge_upload ./prisma/migrations/20260909_expand_brand_knowledge_upload
COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_add_brand_knowledge_engagement ./prisma/migrations/20260914_add_brand_knowledge_engagement
COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_brand_knowledge_download_count ./prisma/migrations/20260914_expand_brand_knowledge_download_count
COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices ./prisma/migrations/20260914_expand_brand_knowledge_preview_slices
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs

View File

@ -0,0 +1,109 @@
import { getServerSession } from 'next-auth'
import { readFile, stat } from 'fs/promises'
import { prisma } from '@/lib/prisma'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/brand-center-dashboard-access', () => ({
BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error {
constructor(message: string, readonly status = 403) { super(message) }
},
assertBrandCenterDashboardAccess: jest.fn(async () => undefined),
}))
jest.mock('@/lib/prisma', () => ({
prisma: { brandKnowledge: { findUnique: jest.fn() } },
}))
jest.mock('@/lib/upload-paths', () => ({
getUploadRootDir: () => '/uploads',
resolveStoredUploadPath: (storedPath: string) => `/${storedPath}`,
}))
jest.mock('@/lib/path-security', () => ({
isPathFreeOfSymbolicLinks: jest.fn(async () => true),
isPathInAllowedDirs: jest.fn(() => true),
}))
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
stat: jest.fn(),
}))
const { TextDecoder, TextEncoder } = require('util') as typeof import('util')
const { clearImmediate, setImmediate } = require('timers') as typeof import('timers')
Object.assign(globalThis, { TextDecoder, TextEncoder, clearImmediate, setImmediate })
const edgePrimitives = require('next/dist/compiled/@edge-runtime/primitives') as typeof globalThis
Object.assign(globalThis, {
Headers: edgePrimitives.Headers,
Request: edgePrimitives.Request,
Response: edgePrimitives.Response,
})
let GET: typeof import('./route').GET
describe('brand knowledge preview route', () => {
beforeAll(async () => { ({ GET } = await import('./route')) })
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(prisma.brandKnowledge.findUnique as jest.Mock).mockResolvedValue({
previewImagePath: 'uploads/brand-knowledge/part-001.webp',
previewImageSlices: [
'uploads/brand-knowledge/part-001.webp',
'uploads/brand-knowledge/part-002.webp',
],
previewImageMimeType: 'image/webp',
previewImageSize: BigInt(4),
})
;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 8 })
;(readFile as jest.Mock).mockResolvedValue(Buffer.from('slice-02'))
})
test('serves the requested ordered WebP slice without applying the legacy size check', async () => {
const response = await GET(
new Request('http://localhost/preview?part=1'),
{ params: { id: 'knowledge-1' } },
)
expect(response.status).toBe(200)
expect(readFile).toHaveBeenCalledWith('/uploads/brand-knowledge/part-002.webp')
expect(response.headers.get('content-type')).toBe('image/webp')
await expect(response.text()).resolves.toBe('slice-02')
})
test('verifies the stored size for the first generated slice', async () => {
;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 8 })
const response = await GET(
new Request('http://localhost/preview?part=0'),
{ params: { id: 'knowledge-1' } },
)
expect(response.status).toBe(404)
expect(readFile).not.toHaveBeenCalled()
})
test('rejects invalid and out-of-range slice indexes', async () => {
const invalid = await GET(new Request('http://localhost/preview?part=1.5'), { params: { id: 'knowledge-1' } })
const missing = await GET(new Request('http://localhost/preview?part=2'), { params: { id: 'knowledge-1' } })
expect(invalid.status).toBe(400)
expect(missing.status).toBe(404)
expect(readFile).not.toHaveBeenCalled()
})
test('falls back to the legacy single preview and still verifies its stored size', async () => {
;(prisma.brandKnowledge.findUnique as jest.Mock).mockResolvedValue({
previewImagePath: 'uploads/brand-knowledge/legacy.png',
previewImageSlices: [],
previewImageMimeType: 'image/png',
previewImageSize: BigInt(4),
})
;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 4 })
;(readFile as jest.Mock).mockResolvedValue(Buffer.from('test'))
const response = await GET(new Request('http://localhost/preview'), { params: { id: 'knowledge-1' } })
expect(response.status).toBe(200)
expect(readFile).toHaveBeenCalledWith('/uploads/brand-knowledge/legacy.png')
expect(response.headers.get('content-type')).toBe('image/png')
})
})

View File

@ -13,23 +13,36 @@ export const dynamic = 'force-dynamic'
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
export async function GET(_request: Request, context: { params: { id: string } }) {
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const item = await prisma.brandKnowledge.findUnique({
where: { id: context.params.id },
select: { previewImagePath: true, previewImageMimeType: true, previewImageSize: true },
select: { previewImagePath: true, previewImageSlices: true, previewImageMimeType: true, previewImageSize: true },
})
if (!item?.previewImagePath) return NextResponse.json({ success: false, error: '预览图不存在' }, { status: 404, headers: PRIVATE_HEADERS })
const rawPart = new URL(request.url).searchParams.get('part')
const part = rawPart === null || rawPart === '' ? 0 : Number(rawPart)
if (!Number.isSafeInteger(part) || part < 0) {
return NextResponse.json({ success: false, error: '预览图片段编号无效' }, { status: 400, headers: PRIVATE_HEADERS })
}
const slicePaths = item.previewImageSlices.length > 0 ? item.previewImageSlices : [item.previewImagePath]
const selectedPath = slicePaths[part]
if (!selectedPath) return NextResponse.json({ success: false, error: '预览图片段不存在' }, { status: 404, headers: PRIVATE_HEADERS })
const uploadRoot = path.resolve(getUploadRootDir())
const filePath = path.resolve(resolveStoredUploadPath(item.previewImagePath))
const filePath = path.resolve(resolveStoredUploadPath(selectedPath))
if (!isPathInAllowedDirs(filePath, [uploadRoot]) || !(await isPathFreeOfSymbolicLinks(uploadRoot, filePath))) {
return NextResponse.json({ success: false, error: '预览图路径不可用' }, { status: 404, headers: PRIVATE_HEADERS })
}
const fileStats = await stat(filePath)
if (!fileStats.isFile() || (item.previewImageSize != null && fileStats.size !== Number(item.previewImageSize))) {
const isLegacySinglePreview = item.previewImageSlices.length === 0
const expectedSize = isLegacySinglePreview || part === 0
? item.previewImageSize
: null
if (!fileStats.isFile() || (expectedSize != null && fileStats.size !== Number(expectedSize))) {
return NextResponse.json({ success: false, error: '预览图不可用' }, { status: 404, headers: PRIVATE_HEADERS })
}
const body = await readFile(filePath)
@ -37,7 +50,7 @@ export async function GET(_request: Request, context: { params: { id: string } }
status: 200,
headers: {
...PRIVATE_HEADERS,
'Content-Type': item.previewImageMimeType || 'image/webp',
'Content-Type': isLegacySinglePreview ? item.previewImageMimeType || 'image/webp' : 'image/webp',
'Content-Length': String(fileStats.size),
'Content-Disposition': 'inline',
},

View File

@ -31,6 +31,7 @@ export async function GET(_request: Request, { params }: { params: { id: string
downloadCount: true,
previewImageName: true,
previewImagePath: true,
previewImageSlices: true,
createdAt: true,
uploader: { select: { realName: true, username: true } },
likes: { where: { userId }, select: { id: true } },
@ -59,6 +60,11 @@ export async function GET(_request: Request, { params }: { params: { id: string
uploadedBy: item.uploader.realName || item.uploader.username,
downloadUrl: `/api/department-dashboard/brand-knowledge/${item.id}/file`,
previewUrl: item.previewImagePath ? `/api/department-dashboard/brand-knowledge/${item.id}/preview` : null,
previewUrls: item.previewImageSlices.length > 0
? item.previewImageSlices.map((_, index) => `/api/department-dashboard/brand-knowledge/${item.id}/preview?part=${index}`)
: item.previewImagePath
? [`/api/department-dashboard/brand-knowledge/${item.id}/preview`]
: [],
previewImageName: item.previewImageName,
likeCount: item._count.likes,
isLiked: item.likes.length > 0,

View File

@ -107,6 +107,10 @@ describe('brand knowledge engagement routes', () => {
downloadCount: 4,
previewImageName: 'cover.png',
previewImagePath: 'uploads/brand-knowledge/cover.png',
previewImageSlices: [
'uploads/brand-knowledge/cover-001.webp',
'uploads/brand-knowledge/cover-002.webp',
],
createdAt: new Date('2026-09-14T00:00:00.000Z'),
uploader: { realName: '无邪', username: 'wuxie' },
likes: [{ id: 'like-1' }],
@ -125,6 +129,10 @@ describe('brand knowledge engagement routes', () => {
isLiked: true,
isFavorited: true,
previewUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/preview',
previewUrls: [
'/api/department-dashboard/brand-knowledge/knowledge-1/preview?part=0',
'/api/department-dashboard/brand-knowledge/knowledge-1/preview?part=1',
],
}))
})

View File

@ -0,0 +1,146 @@
import { getServerSession } from 'next-auth'
import { rename, unlink, writeFile } from 'fs/promises'
import { prisma } from '@/lib/prisma'
import { prepareBrandKnowledgePreview } from '@/lib/brand-knowledge-preview'
const releaseRequestSlot = jest.fn()
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/brand-center-dashboard-access', () => ({
BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error {
constructor(message: string, readonly status = 403) { super(message) }
},
assertBrandCenterDashboardAccess: jest.fn(async () => undefined),
}))
jest.mock('@/lib/prisma', () => ({ prisma: { brandKnowledge: { create: jest.fn() } } }))
jest.mock('@/lib/brand-knowledge-preview', () => ({ prepareBrandKnowledgePreview: jest.fn() }))
jest.mock('@/lib/upload-paths', () => ({ getUploadRootDir: () => '/uploads' }))
jest.mock('@/lib/path-security', () => ({
ensureSafeDirectoryWithinRoot: jest.fn(async () => '/uploads/brand-knowledge'),
isPathFreeOfSymbolicLinks: jest.fn(async () => true),
normalizeSafeFileName: (value: string) => value,
resolveStrictChildPath: (_root: string, name: string) => `/uploads/brand-knowledge/${name}`,
}))
jest.mock('@/lib/multipart-request-guard', () => ({
assertBoundedMultipartHeaders: jest.fn(),
assertMultipartFiles: jest.fn(),
}))
jest.mock('@/lib/multipart-route-limits', () => ({
boundedMultipartRequestLimiter: { acquire: jest.fn(async () => releaseRequestSlot) },
getBoundedMultipartFailure: jest.fn(() => null),
}))
jest.mock('fs/promises', () => ({
rename: jest.fn(async () => undefined),
unlink: jest.fn(async () => undefined),
writeFile: jest.fn(async () => undefined),
}))
const { TextDecoder, TextEncoder } = require('util') as typeof import('util')
const { clearImmediate, setImmediate } = require('timers') as typeof import('timers')
Object.assign(globalThis, { TextDecoder, TextEncoder, clearImmediate, setImmediate })
const edgePrimitives = require('next/dist/compiled/@edge-runtime/primitives') as typeof globalThis
Object.assign(globalThis, {
Headers: edgePrimitives.Headers,
Request: edgePrimitives.Request,
Response: edgePrimitives.Response,
})
let POST: typeof import('./route').POST
const mainFile = {
name: 'knowledge.pdf',
size: 4,
type: 'application/pdf',
arrayBuffer: async () => Uint8Array.from([1, 2, 3, 4]).buffer,
}
const previewFile = {
name: 'long.png',
size: 8,
type: 'image/png',
arrayBuffer: async () => Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]).buffer,
}
function uploadRequest() {
const values = new Map<string, unknown>([
['title', '长图规范'],
['knowledgeType', '影像规范'],
['brand', '索菲亚'],
['productCategoryName', '衣柜'],
['file', mainFile],
['previewImage', previewFile],
])
return {
headers: new Headers(),
formData: async () => ({ get: (name: string) => values.get(name) ?? null }),
} as unknown as Request
}
function createdRecord(data: Record<string, unknown>) {
return {
id: 'knowledge-1',
title: data.title,
knowledgeType: data.knowledgeType,
brand: data.brand,
productCategoryName: data.productCategoryName,
fileName: data.fileName,
mimeType: data.mimeType,
fileSize: data.fileSize,
downloadCount: 0,
previewImageName: data.previewImageName,
previewImagePath: data.previewImagePath,
previewImageMimeType: data.previewImageMimeType,
previewImageSize: data.previewImageSize,
createdAt: new Date('2026-09-14T00:00:00.000Z'),
uploader: { realName: '无邪', username: 'wuxie' },
likes: [],
favorites: [],
_count: { likes: 0 },
}
}
describe('brand knowledge upload route', () => {
beforeAll(async () => { ({ POST } = await import('./route')) })
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(prepareBrandKnowledgePreview as jest.Mock).mockResolvedValue({
source: { extension: 'png', contentType: 'image/png', width: 800, height: 4_100 },
slices: [Buffer.from('first-slice'), Buffer.from('second-slice')],
})
;(prisma.brandKnowledge.create as jest.Mock).mockImplementation(async ({ data }: { data: Record<string, unknown> }) => createdRecord(data))
})
test('stores ordered WebP slices and keeps the first slice as the card preview', async () => {
const response = await POST(uploadRequest())
const call = (prisma.brandKnowledge.create as jest.Mock).mock.calls[0][0]
expect(response.status).toBe(201)
expect(prepareBrandKnowledgePreview).toHaveBeenCalledWith(expect.any(Buffer), 'image/png')
expect(call.data.previewImageSlices).toHaveLength(2)
expect(call.data.previewImageSlices[0]).toMatch(/-part-001\.webp$/)
expect(call.data.previewImageSlices[1]).toMatch(/-part-002\.webp$/)
expect(call.data.previewImagePath).toBe(call.data.previewImageSlices[0])
expect(call.data.previewImageMimeType).toBe('image/webp')
expect(call.data.previewImageSize).toBe(BigInt('first-slice'.length))
expect(writeFile).toHaveBeenCalledTimes(3)
expect(rename).toHaveBeenCalledTimes(3)
expect(releaseRequestSlot).toHaveBeenCalledTimes(1)
})
test('removes the source and every written slice when database persistence fails', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
;(prisma.brandKnowledge.create as jest.Mock).mockRejectedValue(new Error('database unavailable'))
const response = await POST(uploadRequest())
const writtenDestinations = (rename as jest.Mock).mock.calls.map((call) => call[1] as string)
expect(response.status).toBe(500)
for (const destination of writtenDestinations) {
expect(unlink).toHaveBeenCalledWith(destination)
}
expect(errorSpy).toHaveBeenCalled()
expect(releaseRequestSlot).toHaveBeenCalledTimes(1)
})
})

View File

@ -10,7 +10,7 @@ import { getUploadRootDir } from '@/lib/upload-paths'
import { ensureSafeDirectoryWithinRoot, isPathFreeOfSymbolicLinks, normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security'
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard'
import { boundedMultipartRequestLimiter, getBoundedMultipartFailure } from '@/lib/multipart-route-limits'
import { validateBrandImage } from '@/lib/brand-image-validation'
import { prepareBrandKnowledgePreview } from '@/lib/brand-knowledge-preview'
import { isWebFileLike } from '@/lib/web-file'
import {
BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS,
@ -139,9 +139,9 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
let storedPath: string | null = null
let storedPreviewPath: string | null = null
const storedPreviewPaths: string[] = []
let temporaryPath: string | null = null
let temporaryPreviewPath: string | null = null
const temporaryPreviewPaths: string[] = []
let releaseRequestSlot: (() => void) | null = null
try {
const session = await getServerSession(authOptions)
@ -180,25 +180,21 @@ export async function POST(request: Request) {
if (!KNOWLEDGE_TYPES.has(knowledgeType)) return NextResponse.json({ success: false, error: '知识类型不合法' }, { status: 400, headers: PRIVATE_HEADERS })
if (file.size <= 0 || file.size > MAX_FILE_SIZE) return NextResponse.json({ success: false, error: '主文件大小需在 1B 至 1GB 之间' }, { status: 400, headers: PRIVATE_HEADERS })
let validatedPreview: Awaited<ReturnType<typeof validateBrandImage>> | null = null
let preparedPreview: Awaited<ReturnType<typeof prepareBrandKnowledgePreview>> | null = null
let previewImageName: string | null = null
if (isWebFileLike(previewImage)) {
if (previewImage.size <= 0 || previewImage.size > MAX_PREVIEW_IMAGE_SIZE) {
return NextResponse.json({ success: false, error: '预览图需为 1B 至 50MB 之间的 PNG、JPEG 或 WebP 图片' }, { status: 400, headers: PRIVATE_HEADERS })
}
try {
validatedPreview = await validateBrandImage(
Buffer.from(await previewImage.arrayBuffer()),
previewImage.type,
{ maxBytes: MAX_PREVIEW_IMAGE_SIZE, maxBytesLabel: '50MB' },
)
preparedPreview = await prepareBrandKnowledgePreview(Buffer.from(await previewImage.arrayBuffer()), previewImage.type)
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : '预览图无效,请上传 PNG、JPEG 或 WebP 图片' },
{ status: 400, headers: PRIVATE_HEADERS },
)
}
previewImageName = normalizeSafeFileName(previewImage.name) || `preview.${validatedPreview.extension}`
previewImageName = normalizeSafeFileName(previewImage.name) || `preview.${preparedPreview.source.extension}`
}
const safeOriginalName = normalizeSafeFileName(file.name)
@ -219,18 +215,21 @@ export async function POST(request: Request) {
temporaryPath = null
storedPath = `uploads/brand-knowledge/${storageName}`
let previewStorageName: string | null = null
if (isWebFileLike(previewImage) && validatedPreview) {
previewStorageName = `${randomUUID()}.${validatedPreview.extension}`
if (isWebFileLike(previewImage) && preparedPreview) {
const previewBatchId = randomUUID()
for (let index = 0; index < preparedPreview.slices.length; index += 1) {
const previewStorageName = `${previewBatchId}-part-${String(index + 1).padStart(3, '0')}.webp`
const previewDestination = resolveStrictChildPath(knowledgeDirectory, previewStorageName)
if (!previewDestination || !(await isPathFreeOfSymbolicLinks(uploadRoot, previewDestination, { allowMissingLeaf: true }))) {
throw new Error('品牌知识预览图路径不可用')
}
temporaryPreviewPath = `${previewDestination}.uploading`
await writeFile(temporaryPreviewPath, Buffer.from(await previewImage.arrayBuffer()), { flag: 'wx' })
const temporaryPreviewPath = `${previewDestination}.uploading`
temporaryPreviewPaths.push(temporaryPreviewPath)
await writeFile(temporaryPreviewPath, preparedPreview.slices[index], { flag: 'wx' })
await rename(temporaryPreviewPath, previewDestination)
temporaryPreviewPath = null
storedPreviewPath = `uploads/brand-knowledge/${previewStorageName}`
temporaryPreviewPaths.splice(temporaryPreviewPaths.indexOf(temporaryPreviewPath), 1)
storedPreviewPaths.push(`uploads/brand-knowledge/${previewStorageName}`)
}
}
const created = await prisma.brandKnowledge.create({
@ -244,9 +243,10 @@ export async function POST(request: Request) {
mimeType: file.type || 'application/octet-stream',
fileSize: BigInt(file.size),
previewImageName,
previewImagePath: storedPreviewPath,
previewImageMimeType: validatedPreview?.contentType || null,
previewImageSize: isWebFileLike(previewImage) ? BigInt(previewImage.size) : null,
previewImagePath: storedPreviewPaths[0] || null,
previewImageSlices: [...storedPreviewPaths],
previewImageMimeType: preparedPreview ? 'image/webp' : null,
previewImageSize: preparedPreview ? BigInt(preparedPreview.slices[0].length) : null,
uploadedBy: userId,
},
select: {
@ -271,15 +271,13 @@ export async function POST(request: Request) {
},
})
storedPath = null
storedPreviewPath = null
storedPreviewPaths.length = 0
return NextResponse.json({ success: true, data: serializeKnowledge(created) }, { status: 201, headers: PRIVATE_HEADERS })
} catch (error) {
if (temporaryPath) {
await unlink(temporaryPath).catch(() => undefined)
}
if (temporaryPreviewPath) {
await unlink(temporaryPreviewPath).catch(() => undefined)
}
await Promise.all(temporaryPreviewPaths.map((previewPath) => unlink(previewPath).catch(() => undefined)))
if (storedPath) {
try {
const absolute = path.join(getUploadRootDir(), storedPath.replace(/^uploads\//, ''))
@ -288,14 +286,14 @@ export async function POST(request: Request) {
// 数据库写入失败时尽力清理孤立文件,不能覆盖原始错误。
}
}
if (storedPreviewPath) {
await Promise.all(storedPreviewPaths.map(async (previewPath) => {
try {
const absolute = path.join(getUploadRootDir(), storedPreviewPath.replace(/^uploads\//, ''))
const absolute = path.join(getUploadRootDir(), previewPath.replace(/^uploads\//, ''))
await unlink(absolute)
} catch {
// Keep the original error if cleanup of an orphan preview fails.
}
}
}))
const response = accessErrorResponse(error)
if (response) return response
const boundedFailure = getBoundedMultipartFailure(error)

View File

@ -27,6 +27,10 @@ const item = {
uploadedBy: '无邪',
downloadUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/file',
previewUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/preview',
previewUrls: [
'/api/department-dashboard/brand-knowledge/knowledge-1/preview?part=0',
'/api/department-dashboard/brand-knowledge/knowledge-1/preview?part=1',
],
previewImageName: 'preview.png',
likeCount: 2,
isLiked: false,
@ -59,7 +63,8 @@ describe('BrandKnowledgeDetail', () => {
render(<BrandKnowledgeDetail knowledgeId="knowledge-1" />)
expect(await screen.findByRole('heading', { level: 1, name: item.title })).toBeInTheDocument()
expect(screen.getByRole('img', { name: `预览图:${item.title}` })).toHaveClass('object-contain', 'object-center')
expect(screen.getAllByRole('img', { name: new RegExp(`预览图:${item.title}`) })).toHaveLength(2)
expect(screen.getByTestId('brand-knowledge-preview-slices')).toHaveClass('w-full')
expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('索菲亚')
expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('衣柜')
expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('下载次数4')

View File

@ -28,6 +28,7 @@ type BrandKnowledgeItem = {
uploadedBy: string
downloadUrl: string
previewUrl: string | null
previewUrls: string[]
previewImageName: string | null
likeCount: number
isLiked: boolean
@ -250,17 +251,23 @@ export default function BrandKnowledgeDetail({ knowledgeId }: { knowledgeId: str
<div className="mt-7 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] lg:gap-8">
<section aria-label="知识预览" className="min-w-0 overflow-hidden rounded-xl border border-[#dfe4ea] bg-white">
<div className="relative aspect-[4/3] min-h-[320px] w-full sm:min-h-[480px]">
{item.previewUrl ? (
<div className="w-full">
{(item.previewUrls?.length ? item.previewUrls : item.previewUrl ? [item.previewUrl] : []).length > 0 ? (
<div className="w-full bg-white" data-testid="brand-knowledge-preview-slices">
{(item.previewUrls?.length ? item.previewUrls : [item.previewUrl as string]).map((previewUrl, index, previewUrls) => (
<Image
src={item.previewUrl}
alt={`预览图:${item.title}`}
fill
priority
key={previewUrl}
src={previewUrl}
alt={previewUrls.length > 1 ? `预览图:${item.title}(第 ${index + 1} 片)` : `预览图:${item.title}`}
width={1600}
height={1500}
priority={index === 0}
unoptimized
className="object-contain object-center"
className="block h-auto w-full"
sizes="(max-width: 1023px) 100vw, 70vw"
/>
))}
</div>
) : (
<div className="flex h-full min-h-[320px] flex-col items-center justify-center gap-3 bg-[#f8fafc] px-6 text-center text-[#596579]">
<FileText className="h-10 w-10" aria-hidden="true" />

View File

@ -173,7 +173,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
<header className="shrink-0 border-b border-[#e2e8f0] px-5 py-5 sm:px-8 sm:py-6"><div className="flex items-start justify-between gap-5"><div className="min-w-0"><h2 id="brand-knowledge-upload-title" className="text-xl font-bold text-[#101828] sm:text-2xl">上传知识</h2><p className="mt-2 text-sm leading-6 text-[#66758b]">补充知识类型、品牌、品类和预览图,提交后独立保存到品牌知识库。</p></div><button ref={closeButtonRef} type="button" onClick={onClose} aria-label="关闭上传知识窗口" title="关闭" className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] transition-colors hover:bg-[#f2f4f7] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><X className="h-5 w-5" aria-hidden="true" /></button></div></header>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-6 sm:px-8">
<div id="brand-knowledge-upload-status" role="status" aria-live="polite" className={`flex gap-3 rounded-lg px-4 py-3 text-sm ${errorMessage ? 'border border-[#f4c7c3] bg-[#fff2f0] text-[#b42318]' : 'border border-[#b8e5d5] bg-[#f0fbf6] text-[#087052]'}`}><AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" /><div><p className="font-semibold">{errorMessage || '文件和预览图将独立保存,不会写入素材资产库'}</p>{!errorMessage && <p className="mt-1 leading-5">主文件支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL},单个主文件不超过 1GB;预览图支持 JPG、PNG、WebP,不超过 50MB。</p>}</div></div>
<div id="brand-knowledge-upload-status" role="status" aria-live="polite" className={`flex gap-3 rounded-lg px-4 py-3 text-sm ${errorMessage ? 'border border-[#f4c7c3] bg-[#fff2f0] text-[#b42318]' : 'border border-[#b8e5d5] bg-[#f0fbf6] text-[#087052]'}`}><AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" /><div><p className="font-semibold">{errorMessage || '文件和预览图将独立保存,不会写入素材资产库'}</p>{!errorMessage && <p className="mt-1 leading-5">主文件支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL},单个主文件不超过 1GB;预览图支持 JPG、PNG、WebP,不超过 50MB,长图会自动切片并连续展示。</p>}</div></div>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span><select value={knowledgeType} onChange={(event) => setKnowledgeType(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={brand} onChange={(event) => setBrand(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label></div>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">品类 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={productCategoryName} onChange={(event) => setProductCategoryName(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"><option value="">不选择品类</option>{productCategories.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}</select><span className="mt-2 block text-xs text-[#8a99ad]">品类列表与素材资产库保持一致</span></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识标题</span><input type="text" value={title} onChange={(event) => setTitle(event.currentTarget.value)} placeholder="例如:索菲亚新品场景图文规范" className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors placeholder:text-[#8a99ad] focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20" /></label></div>

View File

@ -2,7 +2,7 @@
## 结论
当前应用发布工具验证二十三份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
当前应用发布工具验证二十四份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
目标 migration:
@ -29,6 +29,9 @@
- `20260909_expand_brand_knowledge_upload`
- `20260914_add_brand_knowledge_engagement`
- `20260914_expand_brand_knowledge_download_count`
- `20260914_expand_brand_knowledge_preview_slices`
`20260914_expand_brand_knowledge_preview_slices` 只为品牌知识增加默认空数组的非空预览切片路径列。已有知识继续读取原单图路径;新上传的长预览图保存为有序 WebP 切片并在详情页连续展示,不改写已有文件。
`20260914_expand_brand_knowledge_download_count` 只为品牌知识增加默认值为 0 的非空下载次数列,用于详情页展示,并在文件流正常结束后原子计数;客户端取消或传输失败不计数,计数写入失败也不阻断文件响应。该迁移不改写知识文件。
@ -49,7 +52,7 @@
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
2. legacy bootstrap `Audit`;
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
4. 在同一维护窗内运行本文的真实备份、隔离恢复、二十三条目标 migration 和迁移后审计;
4. 在同一维护窗内运行本文的真实备份、隔离恢复、二十四条目标 migration 和迁移后审计;
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
@ -373,7 +376,7 @@ docker run --rm \
"$MIGRATOR_IMAGE" --apply
```
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对二十三条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、字段类型与可空性、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下;最后原子写审计 JSON。
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对二十四条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、字段类型与可空性、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;最后原子写审计 JSON。
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
@ -389,7 +392,7 @@ test -n "$MIGRATION_AUDIT_SHA256"
只有以下全部成立,才进入应用首次安全上线步骤:
- `applied.json` 的 `verified=true`;
- 二十三条 target migration 均 `completed=true`,source/database checksum 一致;
- 二十四条 target migration 均 `completed=true`,source/database checksum 一致;
- `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空;
- 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配;
- 再次运行 `--audit` 仍通过;

View File

@ -0,0 +1,48 @@
/** @jest-environment node */
import sharp from 'sharp'
import {
assertBrandKnowledgePreviewMetadata,
prepareBrandKnowledgePreview,
} from '@/lib/brand-knowledge-preview'
describe('brand knowledge long preview processing', () => {
test('slices a long image into ordered WebP segments suitable for seamless rendering', async () => {
const source = await sharp({
create: {
width: 800,
height: 4_100,
channels: 3,
background: { r: 238, g: 241, b: 244 },
},
}).png().toBuffer()
const result = await prepareBrandKnowledgePreview(source, 'image/png')
const metadata = await Promise.all(result.slices.map((slice) => sharp(slice).metadata()))
expect(result.source).toMatchObject({ contentType: 'image/png', width: 800, height: 4_100 })
expect(result.slices).toHaveLength(3)
expect(metadata.map((item) => item.height)).toEqual([1_500, 1_500, 1_100])
expect(metadata.every((item) => item.format === 'webp' && item.width === 800)).toBe(true)
})
test('rejects MIME mismatches and animated previews', async () => {
const png = await sharp({
create: { width: 20, height: 20, channels: 3, background: '#fff' },
}).png().toBuffer()
await expect(prepareBrandKnowledgePreview(png, 'image/jpeg')).rejects.toThrow('内容与声明格式不一致')
expect(() => assertBrandKnowledgePreviewMetadata({
format: 'webp', width: 20, height: 40, pages: 2,
}, 'image/webp')).toThrow('不支持动画或多页图片')
})
test('enforces the 60 million pixel and long-edge limits before slicing', () => {
expect(() => assertBrandKnowledgePreviewMetadata({
format: 'png', width: 10_000, height: 6_001, pages: 1,
}, 'image/png')).toThrow('6000 万像素')
expect(() => assertBrandKnowledgePreviewMetadata({
format: 'webp', width: 800, height: 90_001, pages: 1,
}, 'image/webp')).toThrow('尺寸过大')
})
})

View File

@ -0,0 +1,87 @@
import sharp from 'sharp'
import { sliceImage } from '@/lib/image-processor'
import { configureSharpRuntime } from '@/lib/sharp-runtime'
configureSharpRuntime()
export const BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH = 12_000
export const BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT = 90_000
export const BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS = 60_000_000
export const BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT = 1_500
export const BRAND_KNOWLEDGE_PREVIEW_SLICE_MAX_KB = 500
export const BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH = 1_600
const FORMAT_DETAILS = {
png: { extension: 'png', contentType: 'image/png' },
jpeg: { extension: 'jpg', contentType: 'image/jpeg' },
webp: { extension: 'webp', contentType: 'image/webp' },
} as const
type SupportedPreviewFormat = keyof typeof FORMAT_DETAILS
function normalizeContentType(value?: string | null) {
return String(value || '').split(';', 1)[0].trim().toLowerCase()
}
export function assertBrandKnowledgePreviewMetadata(
metadata: Pick<sharp.Metadata, 'format' | 'width' | 'height' | 'pages'>,
declaredContentType?: string | null,
) {
const format = metadata.format as SupportedPreviewFormat | undefined
const details = format ? FORMAT_DETAILS[format] : undefined
if (!details) throw new Error('预览图只支持 PNG、JPEG 或 WebP')
const width = metadata.width || 0
const height = metadata.height || 0
if (width < 1 || height < 1) throw new Error('无法获取预览图尺寸')
if (width > BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH || height > BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT) {
throw new Error(`预览图尺寸过大,宽度需不超过 ${BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH}px,高度需不超过 ${BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT}px`)
}
if (width * height > BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS) {
throw new Error('预览图像素总量过大,需不超过 6000 万像素')
}
if (Number(metadata.pages || 1) > 1) throw new Error('预览图不支持动画或多页图片')
const declared = normalizeContentType(declaredContentType)
if (declared && declared !== details.contentType) throw new Error('预览图内容与声明格式不一致')
return { ...details, width, height }
}
export async function prepareBrandKnowledgePreview(buffer: Buffer, declaredContentType?: string | null) {
let metadata: sharp.Metadata
try {
metadata = await sharp(buffer, {
animated: true,
failOn: 'error',
limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS,
sequentialRead: true,
}).metadata()
} catch (error) {
if (error instanceof Error && /pixel limit/i.test(error.message)) {
throw new Error('预览图像素总量过大,需不超过 6000 万像素')
}
throw new Error('预览图无法解码,请上传完整的 PNG、JPEG 或 WebP 图片')
}
const source = assertBrandKnowledgePreviewMetadata(metadata, declaredContentType)
let slices: Buffer[]
try {
slices = await sliceImage(
buffer,
BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT,
BRAND_KNOWLEDGE_PREVIEW_SLICE_MAX_KB,
BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH,
{
maxInputWidth: BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH,
maxInputHeight: BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT,
maxInputPixels: BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS,
},
)
} catch {
throw new Error('预览图处理失败,请确认图片完整且未超过尺寸限制')
}
if (slices.length === 0) throw new Error('预览图处理后没有可用片段')
return { source, slices }
}

View File

@ -0,0 +1,2 @@
ALTER TABLE "brand_knowledge"
ADD COLUMN "preview_image_slices" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];

View File

@ -178,6 +178,7 @@ model BrandKnowledge {
fileSize BigInt @map("file_size")
previewImageName String? @map("preview_image_name")
previewImagePath String? @map("preview_image_path")
previewImageSlices String[] @default([]) @map("preview_image_slices")
previewImageMimeType String? @map("preview_image_mime_type")
previewImageSize BigInt? @map("preview_image_size")
downloadCount Int @default(0) @map("download_count")

View File

@ -1516,6 +1516,7 @@ const expectedMigrationNames = new Set([
'20260909_expand_brand_knowledge_upload',
'20260914_add_brand_knowledge_engagement',
'20260914_expand_brand_knowledge_download_count',
'20260914_expand_brand_knowledge_preview_slices',
]);
if (migrations.length !== expectedMigrationNames.size
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)

View File

@ -69,6 +69,7 @@ test('migration deployment gate recognizes the latest approved schema migration'
assert.match(gate, /20260908_add_brand_knowledge/)
assert.match(gate, /20260914_add_brand_knowledge_engagement/)
assert.match(gate, /20260914_expand_brand_knowledge_download_count/)
assert.match(gate, /20260914_expand_brand_knowledge_preview_slices/)
})
test('direct replacement runs database and dependency gates before switching traffic', () => {

View File

@ -32,6 +32,7 @@ const TARGET_MIGRATIONS = Object.freeze([
'20260909_expand_brand_knowledge_upload',
'20260914_add_brand_knowledge_engagement',
'20260914_expand_brand_knowledge_download_count',
'20260914_expand_brand_knowledge_preview_slices',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
@ -355,7 +356,7 @@ async function queryEvidence(prisma, localMigrations) {
OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count'))
OR (table_name = 'task_detail_change_requests' AND column_name IN ('id', 'material_id', 'requester_user_id', 'manager_dingtalk_user_id', 'status', 'operations', 'base_material_updated_at', 'expires_at'))
OR (table_name = 'task_detail_change_notifications' AND column_name IN ('id', 'request_id', 'event_type', 'business_key', 'status', 'next_retry_at'))
OR (table_name = 'brand_knowledge' AND column_name IN ('id', 'title', 'knowledge_type', 'brand', 'product_category_name', 'file_name', 'file_path', 'mime_type', 'file_size', 'download_count', 'preview_image_name', 'preview_image_path', 'preview_image_mime_type', 'preview_image_size', 'uploaded_by', 'created_at', 'updated_at'))
OR (table_name = 'brand_knowledge' AND column_name IN ('id', 'title', 'knowledge_type', 'brand', 'product_category_name', 'file_name', 'file_path', 'mime_type', 'file_size', 'download_count', 'preview_image_name', 'preview_image_path', 'preview_image_slices', 'preview_image_mime_type', 'preview_image_size', 'uploaded_by', 'created_at', 'updated_at'))
OR (table_name IN ('brand_knowledge_likes', 'brand_knowledge_favorites') AND column_name IN ('id', 'knowledge_id', 'user_id', 'created_at')))
ORDER BY table_name, column_name`,
)
@ -507,6 +508,7 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
requireColumn('brand_knowledge', 'download_count', 'integer', 'NO')
requireColumn('brand_knowledge', 'preview_image_name', 'text', 'YES')
requireColumn('brand_knowledge', 'preview_image_path', 'text', 'YES')
requireColumn('brand_knowledge', 'preview_image_slices', 'ARRAY', 'NO')
requireColumn('brand_knowledge', 'preview_image_mime_type', 'text', 'YES')
requireColumn('brand_knowledge', 'preview_image_size', 'bigint', 'YES')
requireColumn('brand_knowledge', 'uploaded_by', 'text', 'NO')
@ -595,6 +597,7 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
const taskDetailPrivate = columnByKey.get('materials.task_detail_private')
const canAccessPrivate = columnByKey.get('material_permission_pack_items.can_access_private')
const brandKnowledgeDownloadCount = columnByKey.get('brand_knowledge.download_count')
const brandKnowledgePreviewSlices = columnByKey.get('brand_knowledge.preview_image_slices')
if (!header || normalizeColumnDefault(header.column_default) !== '/brand/header-logo.png') {
fail('Database default mismatch: system_settings.headerLogoUrl')
}
@ -613,6 +616,9 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
if (!brandKnowledgeDownloadCount || normalizeColumnDefault(brandKnowledgeDownloadCount.column_default) !== '0') {
fail('Database default mismatch: brand_knowledge.download_count')
}
if (!brandKnowledgePreviewSlices || String(brandKnowledgePreviewSlices.column_default || '').replace(/\s+/g, '').toLowerCase() !== 'array[]::text[]') {
fail('Database default mismatch: brand_knowledge.preview_image_slices')
}
if (legacy && (legacy.data_type !== 'text' || legacy.is_nullable !== 'NO')) {
fail('Database column mismatch: system_settings.logoUrl')
}

View File

@ -348,6 +348,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
['20260909_expand_brand_knowledge_upload', { checksum: 'u' }],
['20260914_add_brand_knowledge_engagement', { checksum: 'v' }],
['20260914_expand_brand_knowledge_download_count', { checksum: 'w' }],
['20260914_expand_brand_knowledge_preview_slices', { checksum: 'x' }],
])
const evidence = {
migrationRows: [
@ -374,6 +375,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ migration_name: '20260909_expand_brand_knowledge_upload', checksum: 'u', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260914_add_brand_knowledge_engagement', checksum: 'v', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260914_expand_brand_knowledge_download_count', checksum: 'w', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260914_expand_brand_knowledge_preview_slices', checksum: 'x', finished_at: 'now', rolled_back_at: null },
],
columns: [
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
@ -469,6 +471,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ table_name: 'brand_knowledge', column_name: 'download_count', data_type: 'integer', is_nullable: 'NO', column_default: '0' },
{ table_name: 'brand_knowledge', column_name: 'preview_image_name', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'brand_knowledge', column_name: 'preview_image_path', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'brand_knowledge', column_name: 'preview_image_slices', data_type: 'ARRAY', is_nullable: 'NO', column_default: 'ARRAY[]::text[]' },
{ table_name: 'brand_knowledge', column_name: 'preview_image_mime_type', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'brand_knowledge', column_name: 'preview_image_size', data_type: 'bigint', is_nullable: 'YES' },
{ table_name: 'brand_knowledge', column_name: 'uploaded_by', data_type: 'text', is_nullable: 'NO' },
@ -628,6 +631,16 @@ test('target evidence rejects nullable logo columns and structurally wrong index
() => assertTargetMigrationEvidence(migrations, wrongDownloadCountDefault),
/brand_knowledge\.download_count/,
)
const wrongPreviewSlicesDefault = {
...evidence,
columns: evidence.columns.map((column) => column.table_name === 'brand_knowledge' && column.column_name === 'preview_image_slices'
? { ...column, column_default: null }
: column),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, wrongPreviewSlicesDefault),
/brand_knowledge\.preview_image_slices/,
)
const unexpectedTaskDetailDemandDefault = {
...evidence,
columns: evidence.columns.map((column) => column.table_name === 'materials' && column.column_name === 'task_detail_demand_type'
@ -779,6 +792,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co
'20260909_expand_brand_knowledge_upload',
'20260914_add_brand_knowledge_engagement',
'20260914_expand_brand_knowledge_download_count',
'20260914_expand_brand_knowledge_preview_slices',
]) {
assert.match(dockerfile, new RegExp(migrationName))
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
@ -802,6 +816,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.ok(sop.includes('`20260914_add_brand_knowledge_engagement`'))
assert.match(deploymentSource, /'20260914_expand_brand_knowledge_download_count'/)
assert.ok(sop.includes('`20260914_expand_brand_knowledge_download_count`'))
assert.match(deploymentSource, /'20260914_expand_brand_knowledge_preview_slices'/)
assert.ok(sop.includes('`20260914_expand_brand_knowledge_preview_slices`'))
const indexesQueryStart = migratorSource.indexOf('const indexes = await prisma.$queryRawUnsafe(')
const indexesQueryEnd = migratorSource.indexOf('const constraints = await prisma.$queryRawUnsafe(', indexesQueryStart)
assert.ok(indexesQueryStart >= 0)
@ -881,8 +897,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/)
assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/)
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/)
assert.match(sop, /二十三份目标 migration/)
assert.match(sop, /二十三条目标 migration/)
assert.match(sop, /二十四份目标 migration/)
assert.match(sop, /二十四条目标 migration/)
const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")
const postEvidence = migratorSource.indexOf('after = await queryEvidence(verifyClient, localMigrations)', applyMigration)