From b3fa6b23d824a65eb42e3f174efe945bbdd39e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E6=9E=97=E9=93=96?= Date: Tue, 8 Sep 2026 17:46:49 +0800 Subject: [PATCH] feat: enable brand knowledge uploads --- Dockerfile | 4 +- .../brand-knowledge/[id]/file/route.ts | 50 +++ .../brand-knowledge/route.ts | 199 ++++++++++++ .../BrandKnowledgeDashboard.test.tsx | 40 ++- .../BrandKnowledgeDashboard.tsx | 59 +++- .../BrandKnowledgeUploadDialog.tsx | 290 ++++++++++++++++++ docs/生产数据库安全增量迁移SOP-2026-08-12.md | 1 + .../migration.sql | 24 ++ prisma/schema.prisma | 22 ++ scripts/git-incremental-deploy.ps1 | 1 + scripts/safe-production-migrate.mjs | 15 +- scripts/safe-production-migrate.test.mjs | 16 + 12 files changed, 698 insertions(+), 23 deletions(-) create mode 100644 app/api/department-dashboard/brand-knowledge/[id]/file/route.ts create mode 100644 app/api/department-dashboard/brand-knowledge/route.ts create mode 100644 components/department-dashboard/BrandKnowledgeUploadDialog.tsx create mode 100644 prisma/migrations/20260908_add_brand_knowledge/migration.sql diff --git a/Dockerfile b/Dockerfile index ad40b1a..d463c44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/api/department-dashboard/brand-knowledge/[id]/file/route.ts b/app/api/department-dashboard/brand-knowledge/[id]/file/route.ts new file mode 100644 index 0000000..80f179c --- /dev/null +++ b/app/api/department-dashboard/brand-knowledge/[id]/file/route.ts @@ -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 }) + } +} diff --git a/app/api/department-dashboard/brand-knowledge/route.ts b/app/api/department-dashboard/brand-knowledge/route.ts new file mode 100644 index 0000000..0210bcd --- /dev/null +++ b/app/api/department-dashboard/brand-knowledge/route.ts @@ -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?.() + } +} diff --git a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx index bf52350..21c2dbe 100644 --- a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx +++ b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx @@ -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() 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() + 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() + + 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() + }) }) diff --git a/components/department-dashboard/BrandKnowledgeDashboard.tsx b/components/department-dashboard/BrandKnowledgeDashboard.tsx index e6d445c..807616a 100644 --- a/components/department-dashboard/BrandKnowledgeDashboard.tsx +++ b/components/department-dashboard/BrandKnowledgeDashboard.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [loadError, setLoadError] = useState('') + const [notice, setNotice] = useState('') + const uploadButtonRef = useRef(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 ( @@ -33,16 +54,17 @@ export default function BrandKnowledgeDashboard() { -
+
+

{notice}

-
+ {isLoading ?
正在读取知识内容…
: visibleItems.length > 0 ?
    {visibleItems.map((item) =>
  • {item.title}

    {item.fileName}

    {item.knowledgeType}{item.brand && item.brand !== '未关联品牌' && {item.brand}}{formatFileSize(item.fileSize)}上传人:{item.uploadedBy}{formatDate(item.createdAt)}
  • )}
:
+

点击右上角“上传知识”,即可将文件保存到品牌知识库。

+
}
+ {isUploadDialogOpen && ( + setIsUploadDialogOpen(false)} + onUploaded={async () => { setNotice('知识文件已上传到品牌知识库'); await loadKnowledge() }} + returnFocusRef={uploadButtonRef} + /> + )}
) } diff --git a/components/department-dashboard/BrandKnowledgeUploadDialog.tsx b/components/department-dashboard/BrandKnowledgeUploadDialog.tsx new file mode 100644 index 0000000..13ec078 --- /dev/null +++ b/components/department-dashboard/BrandKnowledgeUploadDialog.tsx @@ -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 + returnFocusRef: RefObject +} + +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(null) + const [isDragging, setIsDragging] = useState(false) + const [isUploading, setIsUploading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const dialogRef = useRef(null) + const closeButtonRef = useRef(null) + const fileInputRef = useRef(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( + '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) => { + event.preventDefault() + dragDepthRef.current += 1 + setIsDragging(true) + } + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault() + dragDepthRef.current -= 1 + if (dragDepthRef.current <= 0) { + dragDepthRef.current = 0 + setIsDragging(false) + } + } + + const handleDrop = (event: DragEvent) => { + 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 ( +
{ + if (event.target === event.currentTarget) onClose() + }} + > +
+
+
+
+

上传知识

+

选择知识类型、标题和文件,提交后会保存到品牌知识库。

+
+ +
+
+ +
+
+
+ +
+ + + +
+ + + +
+
+

知识文件

+ {selectedFile && ( + + )} +
+ +
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]'}`} + > + setFile(event.currentTarget.files?.[0])} + className="sr-only" + /> + {selectedFile ? ( +
+ + +

{selectedFile.name}

+

{formatFileSize(selectedFile.size)} · 提交后保存到品牌知识库

+ +
+ ) : ( +
+ + +

拖放文件到这里,或选择文件

+

支持 PDF、Office 文档、表格、Markdown 和文本文件

+ +
+ )} +
+
+
+ + +
+
+ ) +} diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index cbe934f..976824c 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -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;执行失败时停止发布,恢复依赖执行前的数据库备份。 diff --git a/prisma/migrations/20260908_add_brand_knowledge/migration.sql b/prisma/migrations/20260908_add_brand_knowledge/migration.sql new file mode 100644 index 0000000..01a3eea --- /dev/null +++ b/prisma/migrations/20260908_add_brand_knowledge/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c37c6e0..34e1675 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index c91e477..252e404 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -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) diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs index 3450be0..0f4718e 100644 --- a/scripts/safe-production-migrate.mjs +++ b/scripts/safe-production-migrate.mjs @@ -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') diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index cbd283c..4112b12 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -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/)