feat: enable brand knowledge uploads

This commit is contained in:
江林铖 2026-09-08 17:46:49 +08:00
parent c71185afc5
commit b3fa6b23d8
12 changed files with 698 additions and 23 deletions

View File

@ -92,7 +92,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \
test -f "/app/prisma/migrations/20260829_add_recoverable_api_key_secret/migration.sql" && \
test -f "/app/prisma/migrations/20260831_add_private_task_detail/migration.sql" && \
test -f "/app/prisma/migrations/20260902_add_employee_roster_history/migration.sql" && \
test -f "/app/prisma/migrations/20260903_add_work_review_brand/migration.sql"
test -f "/app/prisma/migrations/20260903_add_work_review_brand/migration.sql" && \
test -f "/app/prisma/migrations/20260908_add_brand_knowledge/migration.sql"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
@ -218,6 +219,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260829_add_recove
COPY --from=builder --chown=root:root /app/prisma/migrations/20260831_add_private_task_detail ./prisma/migrations/20260831_add_private_task_detail
COPY --from=builder --chown=root:root /app/prisma/migrations/20260902_add_employee_roster_history ./prisma/migrations/20260902_add_employee_roster_history
COPY --from=builder --chown=root:root /app/prisma/migrations/20260903_add_work_review_brand ./prisma/migrations/20260903_add_work_review_brand
COPY --from=builder --chown=root:root /app/prisma/migrations/20260908_add_brand_knowledge ./prisma/migrations/20260908_add_brand_knowledge
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,50 @@
import { readFile, stat } from 'fs/promises'
import path from 'path'
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import { prisma } from '@/lib/prisma'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import { isPathFreeOfSymbolicLinks, isPathInAllowedDirs } from '@/lib/path-security'
export const runtime = 'nodejs'
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 } }) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const item = await prisma.brandKnowledge.findUnique({ where: { id: context.params.id } })
if (!item) return NextResponse.json({ success: false, error: '知识文件不存在' }, { status: 404, headers: PRIVATE_HEADERS })
const uploadRoot = path.resolve(getUploadRootDir())
const filePath = path.resolve(resolveStoredUploadPath(item.filePath))
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() || fileStats.size !== Number(item.fileSize)) {
return NextResponse.json({ success: false, error: '知识文件不可用' }, { status: 404, headers: PRIVATE_HEADERS })
}
const body = await readFile(filePath)
const encodedName = encodeURIComponent(item.fileName)
return new NextResponse(body, {
status: 200,
headers: {
...PRIVATE_HEADERS,
'Content-Type': item.mimeType || 'application/octet-stream',
'Content-Length': String(fileStats.size),
'Content-Disposition': `attachment; filename*=UTF-8''${encodedName}`,
},
})
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS })
}
console.error('[department-dashboard] 品牌知识文件读取失败:', error)
return NextResponse.json({ success: false, error: '品牌知识文件读取失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
}
}

View File

@ -0,0 +1,199 @@
import { randomUUID } from 'crypto'
import { rename, unlink, writeFile } from 'fs/promises'
import path from 'path'
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import { prisma } from '@/lib/prisma'
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'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const MAX_FILE_SIZE = 50 * 1024 * 1024
const MAX_REQUEST_SIZE = 52 * 1024 * 1024
const KNOWLEDGE_TYPES = new Set(['品牌规范', '产品知识', '内容规范', '流程文档'])
const ALLOWED_EXTENSIONS = new Set(['.pdf', '.doc', '.docx', '.ppt', '.pptx', '.xls', '.xlsx', '.csv', '.md', '.txt'])
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
function accessErrorResponse(error: unknown) {
if (error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS })
}
return null
}
function normalizeText(value: FormDataEntryValue | null, maxLength: number) {
return typeof value === 'string' ? value.trim().slice(0, maxLength) : ''
}
function serializeKnowledge(item: {
id: string
title: string
knowledgeType: string
brand: string
fileName: string
mimeType: string
fileSize: bigint
createdAt: Date
uploader: { realName: string | null; username: string }
}) {
return {
id: item.id,
title: item.title,
knowledgeType: item.knowledgeType,
brand: item.brand,
fileName: item.fileName,
mimeType: item.mimeType,
fileSize: Number(item.fileSize),
createdAt: item.createdAt.toISOString(),
uploadedBy: item.uploader.realName || item.uploader.username,
downloadUrl: `/api/department-dashboard/brand-knowledge/${item.id}/file`,
}
}
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const params = new URL(request.url).searchParams
const type = params.get('type')?.trim() || ''
const query = params.get('q')?.trim() || ''
const items = await prisma.brandKnowledge.findMany({
where: {
...(KNOWLEDGE_TYPES.has(type) ? { knowledgeType: type } : {}),
...(query ? {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ fileName: { contains: query, mode: 'insensitive' } },
{ brand: { contains: query, mode: 'insensitive' } },
],
} : {}),
},
orderBy: { createdAt: 'desc' },
take: 200,
select: {
id: true,
title: true,
knowledgeType: true,
brand: true,
fileName: true,
mimeType: true,
fileSize: true,
createdAt: true,
uploader: { select: { realName: true, username: true } },
},
})
return NextResponse.json({ success: true, data: items.map(serializeKnowledge) }, { headers: PRIVATE_HEADERS })
} catch (error) {
const response = accessErrorResponse(error)
if (response) return response
console.error('[department-dashboard] 品牌知识列表读取失败:', error)
return NextResponse.json({ success: false, error: '品牌知识列表读取失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
}
}
export async function POST(request: Request) {
let storedPath: string | null = null
let temporaryPath: string | null = null
let releaseRequestSlot: (() => void) | null = null
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const userId = session?.user?.id
if (!userId) return NextResponse.json({ success: false, error: '请先登录' }, { status: 401, headers: PRIVATE_HEADERS })
assertBoundedMultipartHeaders(request.headers, MAX_REQUEST_SIZE)
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire()
const formData = await request.formData()
assertMultipartFiles(formData, {
fields: { file: { maxCount: 1, maxFileBytes: MAX_FILE_SIZE } },
maxFiles: 1,
maxTotalBytes: MAX_FILE_SIZE,
})
const file = formData.get('file')
if (!(file instanceof File)) {
return NextResponse.json({ success: false, error: '请选择知识文件' }, { status: 400, headers: PRIVATE_HEADERS })
}
const title = normalizeText(formData.get('title'), 200)
const knowledgeType = normalizeText(formData.get('knowledgeType'), 30)
const brand = normalizeText(formData.get('brand'), 40)
if (!title) return NextResponse.json({ success: false, error: '请填写知识标题' }, { status: 400, headers: PRIVATE_HEADERS })
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 至 50MB 之间' }, { status: 400, headers: PRIVATE_HEADERS })
const safeOriginalName = normalizeSafeFileName(file.name)
if (!safeOriginalName) return NextResponse.json({ success: false, error: '文件名不合法' }, { status: 400, headers: PRIVATE_HEADERS })
const extension = path.extname(safeOriginalName).toLowerCase()
if (!ALLOWED_EXTENSIONS.has(extension)) return NextResponse.json({ success: false, error: '仅支持 PDF、Office、表格、Markdown 和文本文件' }, { status: 400, headers: PRIVATE_HEADERS })
const uploadRoot = getUploadRootDir()
const knowledgeDirectory = await ensureSafeDirectoryWithinRoot(uploadRoot, 'brand-knowledge')
if (!knowledgeDirectory) throw new Error('品牌知识存储目录不可用')
const storageName = `${randomUUID()}${extension}`
const destination = resolveStrictChildPath(knowledgeDirectory, storageName)
if (!destination) throw new Error('品牌知识文件路径不可用')
if (!(await isPathFreeOfSymbolicLinks(uploadRoot, destination, { allowMissingLeaf: true }))) throw new Error('品牌知识存储目录不安全')
temporaryPath = `${destination}.uploading`
await writeFile(temporaryPath, Buffer.from(await file.arrayBuffer()), { flag: 'wx' })
await rename(temporaryPath, destination)
temporaryPath = null
storedPath = `uploads/brand-knowledge/${storageName}`
const created = await prisma.brandKnowledge.create({
data: {
title,
knowledgeType,
brand,
fileName: safeOriginalName,
filePath: storedPath,
mimeType: file.type || 'application/octet-stream',
fileSize: BigInt(file.size),
uploadedBy: userId,
},
select: {
id: true,
title: true,
knowledgeType: true,
brand: true,
fileName: true,
mimeType: true,
fileSize: true,
createdAt: true,
uploader: { select: { realName: true, username: true } },
},
})
storedPath = null
return NextResponse.json({ success: true, data: serializeKnowledge(created) }, { status: 201, headers: PRIVATE_HEADERS })
} catch (error) {
if (temporaryPath) {
await unlink(temporaryPath).catch(() => undefined)
}
if (storedPath) {
try {
const absolute = path.join(getUploadRootDir(), storedPath.replace(/^uploads\//, ''))
await unlink(absolute)
} catch {
// 数据库写入失败时尽力清理孤立文件,不能覆盖原始错误。
}
}
const response = accessErrorResponse(error)
if (response) return response
const boundedFailure = getBoundedMultipartFailure(error)
if (boundedFailure) {
return NextResponse.json(
{ success: false, error: boundedFailure.message, code: boundedFailure.code },
{ status: boundedFailure.status, headers: { ...PRIVATE_HEADERS, ...(boundedFailure.retryAfterSeconds ? { 'Retry-After': String(boundedFailure.retryAfterSeconds) } : {}) } },
)
}
console.error('[department-dashboard] 品牌知识上传失败:', error)
return NextResponse.json({ success: false, error: '品牌知识上传失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
} finally {
releaseRequestSlot?.()
}
}

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import BrandKnowledgeDashboard from './BrandKnowledgeDashboard'
jest.mock('next-auth/react', () => ({
@ -7,23 +7,51 @@ jest.mock('next-auth/react', () => ({
}))
describe('BrandKnowledgeDashboard', () => {
it('uses the material-library browsing structure without inventing knowledge data', () => {
beforeEach(() => { global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, data: [] }) } as Response) })
afterEach(() => { jest.restoreAllMocks() })
it('shows the real upload entry and empty state', async () => {
render(<BrandKnowledgeDashboard />)
expect(screen.getByRole('heading', { level: 1, name: '品牌知识库' })).toBeInTheDocument()
expect(screen.getByRole('searchbox', { name: '搜索品牌知识' })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: '知识类型' })).toBeInTheDocument()
expect(screen.getByText('品牌知识库暂未接入内容')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '上传知识' })).toBeDisabled()
expect(screen.getByRole('button', { name: '上传知识' })).toBeEnabled()
await waitFor(() => expect(screen.getByText('品牌知识库暂未上传内容')).toBeInTheDocument())
})
it('updates the honest empty state for filters and search', () => {
it('filters the empty state by type and search', async () => {
render(<BrandKnowledgeDashboard />)
await waitFor(() => expect(screen.getByText('品牌知识库暂未上传内容')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '品牌规范' }))
expect(screen.getByText('品牌规范暂未接入内容')).toBeInTheDocument()
expect(screen.getByText('品牌规范暂未上传内容')).toBeInTheDocument()
fireEvent.change(screen.getByRole('searchbox', { name: '搜索品牌知识' }), { target: { value: '索菲亚' } })
expect(screen.getByText('没有找到与“索菲亚”匹配的知识内容')).toBeInTheDocument()
})
it('submits a selected knowledge file to the independent API', async () => {
const fetchMock = global.fetch as jest.Mock
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ success: true, data: [] }) } as Response).mockResolvedValueOnce({ ok: true, json: async () => ({ success: true, data: {} }) } as Response)
render(<BrandKnowledgeDashboard />)
const uploadButton = screen.getByRole('button', { name: '上传知识' })
fireEvent.click(uploadButton)
expect(screen.getByRole('dialog', { name: '上传知识' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '提交知识' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
const knowledgeFile = new File(['品牌资料'], '品牌手册.pdf', { type: 'application/pdf' })
fireEvent.change(screen.getByLabelText('选择知识文件'), { target: { files: [knowledgeFile] } })
expect(screen.getByText('品牌手册.pdf')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '提交知识' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '提交知识' }))
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('/api/department-dashboard/brand-knowledge', expect.objectContaining({ method: 'POST', body: expect.any(FormData) })))
fireEvent.click(screen.getByRole('button', { name: '关闭上传知识窗口' }))
expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument()
expect(uploadButton).toHaveFocus()
})
})

View File

@ -1,20 +1,41 @@
'use client'
import { useMemo, useState } from 'react'
import { BookOpen, ChevronDown, Search, Upload } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, ChevronDown, Download, FileText, Search, Upload } from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import BrandKnowledgeUploadDialog from './BrandKnowledgeUploadDialog'
const KNOWLEDGE_TYPES = ['全部', '品牌规范', '产品知识', '内容规范', '流程文档']
type BrandKnowledgeItem = { id: string; title: string; knowledgeType: string; brand: string; fileName: string; mimeType: string; fileSize: number; createdAt: string; uploadedBy: string; downloadUrl: string }
function formatFileSize(size: number) { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`; return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB` }
function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date) }
export default function BrandKnowledgeDashboard() {
const [selectedType, setSelectedType] = useState('全部')
const [searchKeyword, setSearchKeyword] = useState('')
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false)
const [items, setItems] = useState<BrandKnowledgeItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [notice, setNotice] = useState('')
const uploadButtonRef = useRef<HTMLButtonElement>(null)
const loadKnowledge = useCallback(async () => {
setIsLoading(true); setLoadError('')
try { const response = await fetch('/api/department-dashboard/brand-knowledge', { cache: 'no-store' }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识列表读取失败'); setItems(Array.isArray(payload.data) ? payload.data : []) }
catch (error) { setLoadError(error instanceof Error ? error.message : '品牌知识列表读取失败,请稍后重试') }
finally { setIsLoading(false) }
}, [])
useEffect(() => { void loadKnowledge() }, [loadKnowledge])
const visibleItems = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase(); return items.filter((item) => (selectedType === '全部' || item.knowledgeType === selectedType) && (!keyword || [item.title, item.fileName, item.brand, item.uploadedBy].some((value) => value.toLowerCase().includes(keyword)))) }, [items, searchKeyword, selectedType])
const emptyMessage = useMemo(() => {
if (loadError) return loadError
if (searchKeyword.trim()) return `没有找到与“${searchKeyword.trim()}”匹配的知识内容`
if (selectedType !== '全部') return `${selectedType}暂未接入内容`
return '品牌知识库暂未接入内容'
}, [searchKeyword, selectedType])
if (selectedType !== '全部') return `${selectedType}暂未上传内容`
return '品牌知识库暂未上传内容'
}, [loadError, searchKeyword, selectedType])
return (
<DepartmentShell>
@ -33,16 +54,17 @@ export default function BrandKnowledgeDashboard() {
</div>
<span className="inline-flex shrink-0 items-center gap-2 rounded-lg border border-[#b8e5d5] bg-[#f0fbf6] px-3 py-2 text-xs font-semibold text-[#087052]">
<span className="h-2 w-2 rounded-full bg-[#31d0aa]" aria-hidden="true" />
待接入
可上传
</span>
</div>
<div className="flex justify-end">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-[#66758b]" role="status" aria-live="polite">{notice}</p>
<button
ref={uploadButtonRef}
type="button"
disabled
className="inline-flex min-h-11 items-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white opacity-55 shadow-[0_8px_22px_rgba(36,127,211,0.16)]"
title="知识内容接入后开放上传"
onClick={() => { setNotice(''); setIsUploadDialogOpen(true) }}
className="inline-flex min-h-11 items-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white shadow-[0_8px_22px_rgba(36,127,211,0.16)] transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
<Upload className="h-4 w-4" aria-hidden="true" />
上传知识
@ -70,7 +92,7 @@ export default function BrandKnowledgeDashboard() {
<button
key={type}
type="button"
aria-current={active ? 'page' : undefined}
aria-pressed={active}
onClick={() => setSelectedType(type)}
className={`relative min-h-11 shrink-0 whitespace-nowrap px-0.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 ${active ? 'text-[#101828]' : 'text-[#6b7075] hover:text-[#101828]'}`}
>
@ -86,7 +108,7 @@ export default function BrandKnowledgeDashboard() {
<div className="flex items-center justify-between gap-4 border-b border-[#dfe4ea] pb-4">
<div>
<h2 id="brand-knowledge-content-title" className="text-lg font-bold text-[#101828]">知识内容</h2>
<p className="mt-1 text-sm text-[#8a8f97]">内容接入后将在此按类型浏览和检索</p>
<p className="mt-1 text-sm text-[#8a8f97]">人员上传的知识文件将按类型保存,并可在此下载</p>
</div>
<button
type="button"
@ -99,14 +121,21 @@ export default function BrandKnowledgeDashboard() {
</button>
</div>
<div className="border-t border-[#e2e8f0] py-24 text-center">
{isLoading ? <div className="border-t border-[#e2e8f0] py-24 text-center text-sm text-[#66758b]" role="status">正在读取知识内容…</div> : visibleItems.length > 0 ? <ul className="mt-4 grid gap-3 md:grid-cols-2" aria-label="品牌知识内容列表">{visibleItems.map((item) => <li key={item.id} className="rounded-xl border border-[#e2e8f0] bg-white p-5 shadow-[0_6px_18px_rgba(16,24,40,0.04)]"><div className="flex items-start justify-between gap-4"><div className="flex min-w-0 gap-3"><span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#eef6ff] text-[#247fd3]"><FileText className="h-5 w-5" aria-hidden="true" /></span><div className="min-w-0"><h3 className="truncate font-bold text-[#101828]" title={item.title}>{item.title}</h3><p className="mt-1 truncate text-sm text-[#66758b]" title={item.fileName}>{item.fileName}</p></div></div><a href={item.downloadUrl} className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-[#247fd3] hover:bg-[#eef6ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]" aria-label={`下载${item.title}`} title="下载"><Download className="h-4 w-4" aria-hidden="true" /></a></div><div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-[#66758b]"><span className="rounded-full bg-[#f0fbf6] px-2.5 py-1 font-semibold text-[#087052]">{item.knowledgeType}</span>{item.brand && item.brand !== '未关联品牌' && <span className="rounded-full bg-[#f5f7fa] px-2.5 py-1">{item.brand}</span>}<span>{formatFileSize(item.fileSize)}</span><span>上传人:{item.uploadedBy}</span><span>{formatDate(item.createdAt)}</span></div></li>)}</ul> : <div className="border-t border-[#e2e8f0] py-24 text-center">
<BookOpen className="mx-auto h-8 w-8 text-[#aab5c3]" aria-hidden="true" />
<p className="mt-4 text-base font-semibold text-[#4f6077]">{emptyMessage}</p>
<p className="mt-2 text-sm text-[#8a8f97]">品牌知识内容接入后,这里会显示可检索的知识卡片。</p>
</div>
<p className="mt-2 text-sm text-[#8a8f97]">点击右上角“上传知识”,即可将文件保存到品牌知识库。</p>
</div>}
</section>
</div>
</main>
{isUploadDialogOpen && (
<BrandKnowledgeUploadDialog
onClose={() => setIsUploadDialogOpen(false)}
onUploaded={async () => { setNotice('知识文件已上传到品牌知识库'); await loadKnowledge() }}
returnFocusRef={uploadButtonRef}
/>
)}
</DepartmentShell>
)
}

View File

@ -0,0 +1,290 @@
'use client'
import { useEffect, useRef, useState, type DragEvent, type RefObject } from 'react'
import { AlertCircle, FileText, FolderOpen, Upload, X } from 'lucide-react'
const KNOWLEDGE_TYPE_OPTIONS = ['品牌规范', '产品知识', '内容规范', '流程文档']
const BRAND_OPTIONS = ['未关联品牌', '亿家达', '卓禾', '欧意朗', '顾家', '索菲亚', '朴美']
interface BrandKnowledgeUploadDialogProps {
onClose: () => void
onUploaded: () => void | Promise<void>
returnFocusRef: RefObject<HTMLButtonElement>
}
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, returnFocusRef }: BrandKnowledgeUploadDialogProps) {
const [knowledgeType, setKnowledgeType] = useState(KNOWLEDGE_TYPE_OPTIONS[0])
const [title, setTitle] = useState('')
const [brand, setBrand] = useState(BRAND_OPTIONS[0])
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const dialogRef = useRef<HTMLDivElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
useEffect(() => {
const previousOverflow = document.body.style.overflow
const returnFocusElement = returnFocusRef.current
document.body.style.overflow = 'hidden'
closeButtonRef.current?.focus()
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (event.key !== 'Tab' || !dialogRef.current) return
const focusableElements = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
),
)
if (focusableElements.length === 0) return
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.body.style.overflow = previousOverflow
document.removeEventListener('keydown', handleKeyDown)
returnFocusElement?.focus()
}
}, [onClose, returnFocusRef])
const setFile = (file: File | undefined) => {
if (file) setSelectedFile(file)
}
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current += 1
setIsDragging(true)
}
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current -= 1
if (dragDepthRef.current <= 0) {
dragDepthRef.current = 0
setIsDragging(false)
}
}
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current = 0
setIsDragging(false)
setFile(event.dataTransfer.files[0])
}
const handleSubmit = async () => {
if (isUploading || !selectedFile || !title.trim()) return
setIsUploading(true)
setErrorMessage('')
try {
const formData = new FormData()
formData.set('title', title.trim())
formData.set('knowledgeType', knowledgeType)
formData.set('brand', brand)
formData.set('file', selectedFile)
const response = await fetch('/api/department-dashboard/brand-knowledge', { method: 'POST', body: formData })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识上传失败,请稍后重试')
await onUploaded()
onClose()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '品牌知识上传失败,请稍后重试')
} finally {
setIsUploading(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[#06192d]/45 p-4 sm:p-6"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="brand-knowledge-upload-title"
aria-describedby="brand-knowledge-upload-status"
className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-[#e2e8f0] bg-white shadow-[0_24px_56px_rgba(6,25,45,0.22)] sm:max-h-[calc(100dvh-3rem)]"
>
<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">支持 PDF、Office 文档、表格、Markdown 和文本文件,单个文件不超过 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"
>
{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>
<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>
<section aria-labelledby="brand-knowledge-file-label">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 id="brand-knowledge-file-label" className="text-sm font-semibold text-[#101828]">知识文件</h3>
{selectedFile && (
<button
type="button"
onClick={() => setSelectedFile(null)}
className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2"
>
移除文件
</button>
)}
</div>
<div
onDragEnter={handleDragEnter}
onDragOver={(event) => event.preventDefault()}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`rounded-xl border border-dashed p-6 text-center transition-colors sm:p-8 ${isDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}
>
<input
ref={fileInputRef}
id="brand-knowledge-file"
aria-label="选择知识文件"
type="file"
accept=".pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.csv,.md,.txt"
onChange={(event) => setFile(event.currentTarget.files?.[0])}
className="sr-only"
/>
{selectedFile ? (
<div className="flex flex-col items-center">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#087052] shadow-sm">
<FileText className="h-5 w-5" aria-hidden="true" />
</span>
<p className="mt-3 max-w-full break-all text-sm font-semibold text-[#101828]">{selectedFile.name}</p>
<p className="mt-1 text-sm text-[#66758b]">{formatFileSize(selectedFile.size)} · 提交后保存到品牌知识库</p>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
<FolderOpen className="h-4 w-4" aria-hidden="true" />
更换文件
</button>
</div>
) : (
<div className="flex flex-col items-center">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm">
<Upload className="h-5 w-5" aria-hidden="true" />
</span>
<p className="mt-3 text-sm font-semibold text-[#101828]">拖放文件到这里,或选择文件</p>
<p className="mt-1 text-sm text-[#66758b]">支持 PDF、Office 文档、表格、Markdown 和文本文件</p>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
<FolderOpen className="h-4 w-4" aria-hidden="true" />
选择文件
</button>
</div>
)}
</div>
</section>
</div>
<footer className="shrink-0 flex flex-col-reverse gap-3 border-t border-[#e2e8f0] px-5 py-4 sm:flex-row sm:items-center sm:justify-end sm:px-8">
<button
type="button"
onClick={onClose}
className="inline-flex min-h-11 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-5 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
取消
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={isUploading || !selectedFile || !title.trim()}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
<Upload className="h-4 w-4" aria-hidden="true" />
{isUploading ? '上传中…' : '提交知识'}
</button>
</footer>
</div>
</div>
)
}

View File

@ -25,6 +25,7 @@
- `20260831_add_private_task_detail`
- `20260902_add_employee_roster_history`
- `20260903_add_work_review_brand`
- `20260908_add_brand_knowledge`
它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。`20260829_add_recoverable_api_key_secret` 只新增 `api_keys.secret_ciphertext` / `secret_nonce` / `secret_auth_tag` 三个可空加密列、`api_key_reveal_audits` 审计表和 `api_key_id+created_at`、`viewer_user_id+created_at` 两个非唯一 btree 索引,不回填旧 key。`20260831_add_private_task_detail` 只新增 `materials.task_detail_demand_type`、`materials.task_detail_private default false`、`material_permission_pack_items.can_access_private default false`,并加上 `materials_private_optimization_check`,确保私密任务详情只能落在“任务-详情 + 优化”语义下。`20260902_add_employee_roster_history` 新增员工完整花名册的时间快照表及查询索引,团队产能按每周结束前最近一份快照统计在职人数;不会用当前花名册回填没有快照的历史周。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。

View File

@ -0,0 +1,24 @@
CREATE TABLE "brand_knowledge" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"knowledge_type" TEXT NOT NULL,
"brand" TEXT NOT NULL DEFAULT '',
"file_name" TEXT NOT NULL,
"file_path" TEXT NOT NULL,
"mime_type" TEXT NOT NULL,
"file_size" BIGINT NOT NULL,
"uploaded_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "brand_knowledge_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "brand_knowledge_knowledge_type_idx" ON "brand_knowledge"("knowledge_type");
CREATE INDEX "brand_knowledge_brand_idx" ON "brand_knowledge"("brand");
CREATE INDEX "brand_knowledge_created_at_idx" ON "brand_knowledge"("created_at");
CREATE INDEX "brand_knowledge_uploaded_by_idx" ON "brand_knowledge"("uploaded_by");
ALTER TABLE "brand_knowledge"
ADD CONSTRAINT "brand_knowledge_uploaded_by_fkey"
FOREIGN KEY ("uploaded_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -58,6 +58,7 @@ model User {
permissionInherits UserPermissionInherit[]
submittedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeRequesterUser")
managedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeManagerUser")
brandKnowledgeUploads BrandKnowledge[] @relation("BrandKnowledgeUploader")
@@index([status])
@@index([archivedAt])
@ -163,6 +164,27 @@ model Material {
@@map("materials")
}
model BrandKnowledge {
id String @id @default(uuid())
title String
knowledgeType String @map("knowledge_type")
brand String @default("")
fileName String @map("file_name")
filePath String @map("file_path")
mimeType String @map("mime_type")
fileSize BigInt @map("file_size")
uploadedBy String @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
uploader User @relation("BrandKnowledgeUploader", fields: [uploadedBy], references: [id], onDelete: Cascade)
@@index([knowledgeType])
@@index([brand])
@@index([createdAt])
@@index([uploadedBy])
@@map("brand_knowledge")
}
model ProductPackageUploadJob {
id String @id @default(uuid())
materialId String @map("material_id")

View File

@ -1512,6 +1512,7 @@ const expectedMigrationNames = new Set([
'20260831_add_private_task_detail',
'20260902_add_employee_roster_history',
'20260903_add_work_review_brand',
'20260908_add_brand_knowledge',
]);
if (migrations.length !== expectedMigrationNames.size
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)

View File

@ -28,6 +28,7 @@ const TARGET_MIGRATIONS = Object.freeze([
'20260831_add_private_task_detail',
'20260902_add_employee_roster_history',
'20260903_add_work_review_brand',
'20260908_add_brand_knowledge',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
@ -312,7 +313,8 @@ async function queryEvidence(prisma, localMigrations) {
OR (table_name = 'ding_workflow_snapshots' AND column_name IN ('process_instance_id', 'status', 'is_ongoing', 'source_updated_at_raw', 'scheme_field_count', 'task_count', 'active_task_count', 'synced_at', 'brand', 'demand_type', 'requester_name', 'requester_department', 'project_type', 'active_tasks', 'form_fields'))
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 = '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', 'file_name', 'file_path', 'mime_type', 'file_size', 'uploaded_by', 'created_at', 'updated_at')))
ORDER BY table_name, column_name`,
)
const indexes = await prisma.$queryRawUnsafe(
@ -428,6 +430,17 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
requireColumn('task_detail_change_requests', 'material_id', 'text', 'NO')
requireColumn('task_detail_change_requests', 'requester_user_id', 'text', 'NO')
requireColumn('task_detail_change_requests', 'manager_dingtalk_user_id', 'text', 'NO')
requireColumn('brand_knowledge', 'id', 'text', 'NO')
requireColumn('brand_knowledge', 'title', 'text', 'NO')
requireColumn('brand_knowledge', 'knowledge_type', 'text', 'NO')
requireColumn('brand_knowledge', 'brand', 'text', 'NO')
requireColumn('brand_knowledge', 'file_name', 'text', 'NO')
requireColumn('brand_knowledge', 'file_path', 'text', 'NO')
requireColumn('brand_knowledge', 'mime_type', 'text', 'NO')
requireColumn('brand_knowledge', 'file_size', 'bigint', 'NO')
requireColumn('brand_knowledge', 'uploaded_by', 'text', 'NO')
requireColumn('brand_knowledge', 'created_at', 'timestamp without time zone', 'NO')
requireColumn('brand_knowledge', 'updated_at', 'timestamp without time zone', 'NO')
requireColumn('task_detail_change_requests', 'status', 'text', 'NO')
requireColumn('task_detail_change_requests', 'operations', 'jsonb', 'NO')
requireColumn('task_detail_change_requests', 'base_material_updated_at', 'timestamp without time zone', 'NO')

View File

@ -344,6 +344,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
['20260831_add_private_task_detail', { checksum: 'q' }],
['20260902_add_employee_roster_history', { checksum: 'r' }],
['20260903_add_work_review_brand', { checksum: 's' }],
['20260908_add_brand_knowledge', { checksum: 't' }],
])
const evidence = {
migrationRows: [
@ -366,6 +367,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ migration_name: '20260831_add_private_task_detail', checksum: 'q', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260902_add_employee_roster_history', checksum: 'r', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260903_add_work_review_brand', checksum: 's', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260908_add_brand_knowledge', checksum: 't', finished_at: 'now', rolled_back_at: null },
],
columns: [
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
@ -449,6 +451,17 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ table_name: 'task_detail_change_notifications', column_name: 'business_key', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_notifications', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'task_detail_change_notifications', column_name: 'next_retry_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'brand_knowledge', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'title', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'knowledge_type', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'brand', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'file_name', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'file_path', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'mime_type', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'file_size', data_type: 'bigint', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'uploaded_by', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'created_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'brand_knowledge', column_name: 'updated_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'system_settings', column_name: 'headerLogoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/header-logo.png'::text" },
{ table_name: 'system_settings', column_name: 'loginLogoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/login-logo.png'::text" },
{ table_name: 'system_settings', column_name: 'logoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/header-logo.png'::text" },
@ -695,6 +708,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co
'20260831_add_private_task_detail',
'20260902_add_employee_roster_history',
'20260903_add_work_review_brand',
'20260908_add_brand_knowledge',
]) {
assert.match(dockerfile, new RegExp(migrationName))
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
@ -710,6 +724,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.ok(sop.includes('`20260902_add_employee_roster_history`'))
assert.match(deploymentSource, /'20260903_add_work_review_brand'/)
assert.ok(sop.includes('`20260903_add_work_review_brand`'))
assert.match(deploymentSource, /'20260908_add_brand_knowledge'/)
assert.ok(sop.includes('`20260908_add_brand_knowledge`'))
assert.match(sop, /materials_private_optimization_check/)
assert.match(sop, /task_detail_private/)
assert.match(sop, /can_access_private/)