fix: slice long brand knowledge previews
This commit is contained in:
parent
6c263e4999
commit
34f355b538
@ -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
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
],
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
146
app/api/department-dashboard/brand-knowledge/route.test.ts
Normal file
146
app/api/department-dashboard/brand-knowledge/route.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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}`
|
||||
const previewDestination = resolveStrictChildPath(knowledgeDirectory, previewStorageName)
|
||||
if (!previewDestination || !(await isPathFreeOfSymbolicLinks(uploadRoot, previewDestination, { allowMissingLeaf: true }))) {
|
||||
throw new Error('品牌知识预览图路径不可用')
|
||||
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('品牌知识预览图路径不可用')
|
||||
}
|
||||
const temporaryPreviewPath = `${previewDestination}.uploading`
|
||||
temporaryPreviewPaths.push(temporaryPreviewPath)
|
||||
await writeFile(temporaryPreviewPath, preparedPreview.slices[index], { flag: 'wx' })
|
||||
await rename(temporaryPreviewPath, previewDestination)
|
||||
temporaryPreviewPaths.splice(temporaryPreviewPaths.indexOf(temporaryPreviewPath), 1)
|
||||
storedPreviewPaths.push(`uploads/brand-knowledge/${previewStorageName}`)
|
||||
}
|
||||
temporaryPreviewPath = `${previewDestination}.uploading`
|
||||
await writeFile(temporaryPreviewPath, Buffer.from(await previewImage.arrayBuffer()), { flag: 'wx' })
|
||||
await rename(temporaryPreviewPath, previewDestination)
|
||||
temporaryPreviewPath = null
|
||||
storedPreviewPath = `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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 ? (
|
||||
<Image
|
||||
src={item.previewUrl}
|
||||
alt={`预览图:${item.title}`}
|
||||
fill
|
||||
priority
|
||||
unoptimized
|
||||
className="object-contain object-center"
|
||||
sizes="(max-width: 1023px) 100vw, 70vw"
|
||||
/>
|
||||
<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
|
||||
key={previewUrl}
|
||||
src={previewUrl}
|
||||
alt={previewUrls.length > 1 ? `预览图:${item.title}(第 ${index + 1} 片)` : `预览图:${item.title}`}
|
||||
width={1600}
|
||||
height={1500}
|
||||
priority={index === 0}
|
||||
unoptimized
|
||||
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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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` 仍通过;
|
||||
|
||||
48
lib/__tests__/brand-knowledge-preview.test.ts
Normal file
48
lib/__tests__/brand-knowledge-preview.test.ts
Normal 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('尺寸过大')
|
||||
})
|
||||
})
|
||||
87
lib/brand-knowledge-preview.ts
Normal file
87
lib/brand-knowledge-preview.ts
Normal 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 }
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "brand_knowledge"
|
||||
ADD COLUMN "preview_image_slices" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user