diff --git a/Dockerfile b/Dockerfile index 58ce679..1240880 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,7 +95,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260903_add_work_review_brand/migration.sql" && \ 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_add_brand_knowledge_engagement/migration.sql" && \ + test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -224,6 +225,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260903_add_work_r COPY --from=builder --chown=root:root /app/prisma/migrations/20260908_add_brand_knowledge ./prisma/migrations/20260908_add_brand_knowledge 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=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.test.ts b/app/api/department-dashboard/brand-knowledge/[id]/file/route.test.ts new file mode 100644 index 0000000..ecc8566 --- /dev/null +++ b/app/api/department-dashboard/brand-knowledge/[id]/file/route.test.ts @@ -0,0 +1,132 @@ +import { getServerSession } from 'next-auth' +import { createReadStream } from 'fs' +import { stat } from 'fs/promises' +import { Readable } from 'stream' +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(), update: jest.fn() } }, +})) +jest.mock('@/lib/upload-paths', () => ({ + getUploadRootDir: () => '/uploads', + resolveStoredUploadPath: () => '/uploads/brand-knowledge/guide.pdf', +})) +jest.mock('@/lib/path-security', () => ({ + isPathFreeOfSymbolicLinks: jest.fn(async () => true), + isPathInAllowedDirs: jest.fn(() => true), +})) +jest.mock('fs/promises', () => ({ + stat: jest.fn(), +})) +jest.mock('fs', () => ({ createReadStream: 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, + ReadableStream: edgePrimitives.ReadableStream, + Request: edgePrimitives.Request, + Response: edgePrimitives.Response, +}) + +let GET: typeof import('./route').GET + +class CancellableReadable extends Readable { + private emitted = false + + override _read() { + if (!this.emitted) { + this.emitted = true + this.push(Buffer.from('part')) + } + } +} + +class FailingReadable extends Readable { + override _read() { + this.destroy(new Error('stream failed')) + } +} + +describe('brand knowledge file 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({ + id: 'knowledge-1', + fileName: 'guide.pdf', + filePath: 'uploads/brand-knowledge/guide.pdf', + mimeType: 'application/pdf', + fileSize: BigInt(4), + }) + ;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 4 }) + ;(createReadStream as jest.Mock).mockReturnValue(Readable.from([Buffer.from('test')])) + ;(prisma.brandKnowledge.update as jest.Mock).mockResolvedValue({ id: 'knowledge-1' }) + }) + + test('increments the download count after the validated file stream completes', async () => { + const response = await GET(new Request('http://localhost/file'), { params: { id: 'knowledge-1' } }) + + expect(response.status).toBe(200) + expect(createReadStream).toHaveBeenCalledWith('/uploads/brand-knowledge/guide.pdf') + expect(prisma.brandKnowledge.update).not.toHaveBeenCalled() + expect(await response.text()).toBe('test') + expect(prisma.brandKnowledge.update).toHaveBeenCalledWith({ + where: { id: 'knowledge-1' }, + data: { downloadCount: { increment: 1 } }, + }) + expect(response.headers.get('content-disposition')).toContain("filename*=UTF-8''guide.pdf") + }) + + test('does not count a cancelled download', async () => { + ;(createReadStream as jest.Mock).mockReturnValue(new CancellableReadable()) + const response = await GET(new Request('http://localhost/file'), { params: { id: 'knowledge-1' } }) + const reader = response.body!.getReader() + + await reader.read() + await reader.cancel() + + expect(prisma.brandKnowledge.update).not.toHaveBeenCalled() + }) + + test('does not count a stream that fails before EOF', async () => { + ;(createReadStream as jest.Mock).mockReturnValue(new FailingReadable()) + const response = await GET(new Request('http://localhost/file'), { params: { id: 'knowledge-1' } }) + + await expect(response.text()).rejects.toThrow('stream failed') + expect(prisma.brandKnowledge.update).not.toHaveBeenCalled() + }) + + test('does not fail the completed download when count persistence fails', async () => { + const countError = new Error('count unavailable') + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + ;(prisma.brandKnowledge.update as jest.Mock).mockRejectedValue(countError) + const response = await GET(new Request('http://localhost/file'), { params: { id: 'knowledge-1' } }) + + await expect(response.text()).resolves.toBe('test') + await Promise.resolve() + + expect(errorSpy).toHaveBeenCalledWith('[department-dashboard] 品牌知识下载计数更新失败:', countError) + }) + + test('does not increment when the knowledge file does not exist', async () => { + ;(prisma.brandKnowledge.findUnique as jest.Mock).mockResolvedValue(null) + + const response = await GET(new Request('http://localhost/file'), { params: { id: 'missing' } }) + + expect(response.status).toBe(404) + expect(prisma.brandKnowledge.update).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/department-dashboard/brand-knowledge/[id]/file/route.ts b/app/api/department-dashboard/brand-knowledge/[id]/file/route.ts index 088c737..dfa3cc1 100644 --- a/app/api/department-dashboard/brand-knowledge/[id]/file/route.ts +++ b/app/api/department-dashboard/brand-knowledge/[id]/file/route.ts @@ -1,4 +1,5 @@ -import { readFile, stat } from 'fs/promises' +import { createReadStream } from 'fs' +import { stat } from 'fs/promises' import path from 'path' import { getServerSession } from 'next-auth' import { NextResponse } from 'next/server' @@ -7,6 +8,7 @@ import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } fro import { prisma } from '@/lib/prisma' import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths' import { isPathFreeOfSymbolicLinks, isPathInAllowedDirs } from '@/lib/path-security' +import { nodeReadableToWebStream } from '@/lib/node-readable-stream' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -29,9 +31,18 @@ export async function GET(_request: Request, context: { params: { id: string } } 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 body = nodeReadableToWebStream(createReadStream(filePath), { + onComplete: () => { + void prisma.brandKnowledge.update({ + where: { id: item.id }, + data: { downloadCount: { increment: 1 } }, + }).catch((error) => { + console.error('[department-dashboard] 品牌知识下载计数更新失败:', error) + }) + }, + }) const encodedName = encodeURIComponent(item.fileName) - return new NextResponse(new Uint8Array(body), { + return new NextResponse(body, { status: 200, headers: { ...PRIVATE_HEADERS, diff --git a/app/api/department-dashboard/brand-knowledge/[id]/route.ts b/app/api/department-dashboard/brand-knowledge/[id]/route.ts new file mode 100644 index 0000000..0a24d49 --- /dev/null +++ b/app/api/department-dashboard/brand-knowledge/[id]/route.ts @@ -0,0 +1,75 @@ +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' + +export const dynamic = 'force-dynamic' + +const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' } + +export async function GET(_request: Request, { params }: { params: { id: string } }) { + 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 }) + } + + const item = await prisma.brandKnowledge.findUnique({ + where: { id: params.id }, + select: { + id: true, + title: true, + knowledgeType: true, + brand: true, + productCategoryName: true, + fileName: true, + mimeType: true, + fileSize: true, + downloadCount: true, + previewImageName: true, + previewImagePath: true, + createdAt: true, + uploader: { select: { realName: true, username: true } }, + likes: { where: { userId }, select: { id: true } }, + favorites: { where: { userId }, select: { id: true } }, + _count: { select: { likes: true } }, + }, + }) + + if (!item) { + return NextResponse.json({ success: false, error: '知识内容不存在' }, { status: 404, headers: PRIVATE_HEADERS }) + } + + return NextResponse.json({ + success: true, + data: { + id: item.id, + title: item.title, + knowledgeType: item.knowledgeType, + brand: item.brand, + productCategoryName: item.productCategoryName, + fileName: item.fileName, + mimeType: item.mimeType, + fileSize: Number(item.fileSize), + downloadCount: item.downloadCount, + createdAt: item.createdAt.toISOString(), + 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, + previewImageName: item.previewImageName, + likeCount: item._count.likes, + isLiked: item.likes.length > 0, + isFavorited: item.favorites.length > 0, + }, + }, { headers: PRIVATE_HEADERS }) + } 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/engagement-routes.test.ts b/app/api/department-dashboard/brand-knowledge/engagement-routes.test.ts index e9caa1d..25ff4eb 100644 --- a/app/api/department-dashboard/brand-knowledge/engagement-routes.test.ts +++ b/app/api/department-dashboard/brand-knowledge/engagement-routes.test.ts @@ -29,6 +29,7 @@ Object.assign(globalThis, { }) let GET: typeof import('./route').GET +let getDetail: typeof import('./[id]/route').GET let putLike: typeof import('./[id]/like/route').PUT let putFavorite: typeof import('./[id]/favorite/route').PUT @@ -44,6 +45,7 @@ function actionRequest(path: string, active: unknown) { describe('brand knowledge engagement routes', () => { beforeAll(async () => { ;({ GET } = await import('./route')) + ;({ GET: getDetail } = await import('./[id]/route')) ;({ PUT: putLike } = await import('./[id]/like/route')) ;({ PUT: putFavorite } = await import('./[id]/favorite/route')) }) @@ -65,6 +67,7 @@ describe('brand knowledge engagement routes', () => { fileName: 'guide.pdf', mimeType: 'application/pdf', fileSize: BigInt(10), + downloadCount: 2, previewImageName: null, previewImagePath: null, previewImageMimeType: null, @@ -81,7 +84,7 @@ describe('brand knowledge engagement routes', () => { expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0') - expect(body.data[0]).toEqual(expect.objectContaining({ likeCount: 3, isLiked: true, isFavorited: true })) + expect(body.data[0]).toEqual(expect.objectContaining({ downloadCount: 2, likeCount: 3, isLiked: true, isFavorited: true })) expect(prisma.brandKnowledge.findMany).toHaveBeenCalledWith(expect.objectContaining({ select: expect.objectContaining({ likes: expect.objectContaining({ where: { userId: 'user-1' } }), @@ -91,6 +94,40 @@ describe('brand knowledge engagement routes', () => { })) }) + test('detail returns one knowledge item with file metrics and engagement state', async () => { + ;(prisma.brandKnowledge.findUnique as jest.Mock).mockResolvedValue({ + id: 'knowledge-1', + title: '影像规范', + knowledgeType: '影像规范', + brand: '索菲亚', + productCategoryName: '衣柜', + fileName: 'guide.pdf', + mimeType: 'application/pdf', + fileSize: BigInt(10), + downloadCount: 4, + previewImageName: 'cover.png', + previewImagePath: 'uploads/brand-knowledge/cover.png', + createdAt: new Date('2026-09-14T00:00:00.000Z'), + uploader: { realName: '无邪', username: 'wuxie' }, + likes: [{ id: 'like-1' }], + favorites: [{ id: 'favorite-1' }], + _count: { likes: 3 }, + }) + + const response = await getDetail(new Request('http://localhost/detail'), context) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual(expect.objectContaining({ + id: 'knowledge-1', + downloadCount: 4, + likeCount: 3, + isLiked: true, + isFavorited: true, + previewUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/preview', + })) + }) + test('like uses an idempotent active state and returns the new count', async () => { const response = await putLike(actionRequest('/like', true), context) diff --git a/app/api/department-dashboard/brand-knowledge/route.ts b/app/api/department-dashboard/brand-knowledge/route.ts index 19531da..6e1e5e1 100644 --- a/app/api/department-dashboard/brand-knowledge/route.ts +++ b/app/api/department-dashboard/brand-knowledge/route.ts @@ -52,6 +52,7 @@ function serializeKnowledge(item: { fileName: string mimeType: string fileSize: bigint + downloadCount: number previewImageName: string | null previewImagePath: string | null previewImageMimeType: string | null @@ -71,6 +72,7 @@ function serializeKnowledge(item: { fileName: item.fileName, mimeType: item.mimeType, fileSize: Number(item.fileSize), + downloadCount: item.downloadCount, createdAt: item.createdAt.toISOString(), uploadedBy: item.uploader.realName || item.uploader.username, downloadUrl: `/api/department-dashboard/brand-knowledge/${item.id}/file`, @@ -114,6 +116,7 @@ export async function GET(request: Request) { fileName: true, mimeType: true, fileSize: true, + downloadCount: true, previewImageName: true, previewImagePath: true, previewImageMimeType: true, @@ -255,6 +258,7 @@ export async function POST(request: Request) { fileName: true, mimeType: true, fileSize: true, + downloadCount: true, previewImageName: true, previewImagePath: true, previewImageMimeType: true, diff --git a/app/workspaces/assets/brand-knowledge/[id]/page.tsx b/app/workspaces/assets/brand-knowledge/[id]/page.tsx new file mode 100644 index 0000000..a0f9968 --- /dev/null +++ b/app/workspaces/assets/brand-knowledge/[id]/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' +import BrandKnowledgeDetail from '@/components/department-dashboard/BrandKnowledgeDetail' + +export const metadata: Metadata = { + title: '知识详情 | 品牌知识库 | 品牌中心部门看板', + description: '查看品牌知识预览、文件信息与互动状态。', +} + +export default function BrandKnowledgeDetailPage({ params }: { params: { id: string } }) { + return +} diff --git a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx index 4e197e6..429d36f 100644 --- a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx +++ b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx @@ -57,6 +57,7 @@ describe('BrandKnowledgeDashboard', () => { fileName: '索菲亚图像影像规范.rar', mimeType: 'application/vnd.rar', fileSize: 668886630, + downloadCount: 4, createdAt: '2026-09-12T00:00:00.000Z', uploadedBy: '无邪', downloadUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/file', @@ -74,6 +75,10 @@ describe('BrandKnowledgeDashboard', () => { const preview = await screen.findByRole('img', { name: '预览图:索菲亚图像影像规范' }) expect(preview).toHaveClass('object-contain', 'object-center') expect(preview).not.toHaveClass('object-cover') + expect(screen.getByRole('link', { name: '查看索菲亚图像影像规范详情' })).toHaveAttribute( + 'href', + '/workspaces/assets/brand-knowledge/knowledge-1', + ) expect(screen.getByRole('heading', { level: 3, name: '索菲亚图像影像规范' })).toBeInTheDocument() expect(screen.getByText(/637\.90 MB/)).toBeInTheDocument() expect(screen.getByText(/无邪/)).toBeInTheDocument() @@ -107,6 +112,7 @@ describe('BrandKnowledgeDashboard', () => { fileName: '索菲亚图像影像规范.rar', mimeType: 'application/vnd.rar', fileSize: 668886630, + downloadCount: 4, createdAt: '2026-09-12T00:00:00.000Z', uploadedBy: '无邪', downloadUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/file', @@ -130,7 +136,7 @@ describe('BrandKnowledgeDashboard', () => { fireEvent.click(await screen.findByRole('button', { name: '分享索菲亚图像影像规范' })) await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringMatching(/\/workspaces\/assets\/brand-knowledge\?knowledge=knowledge-1$/), + expect.stringMatching(/\/workspaces\/assets\/brand-knowledge\/knowledge-1$/), )) await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('分享链接已复制')) diff --git a/components/department-dashboard/BrandKnowledgeDashboard.tsx b/components/department-dashboard/BrandKnowledgeDashboard.tsx index e82e30b..8b23c2a 100644 --- a/components/department-dashboard/BrandKnowledgeDashboard.tsx +++ b/components/department-dashboard/BrandKnowledgeDashboard.tsx @@ -2,13 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Image from 'next/image' +import Link from 'next/link' import { BookOpen, ChevronDown, Download, FileText, Heart, Search, Share2, Upload } from 'lucide-react' import DepartmentShell from './DepartmentShell' import BrandKnowledgeUploadDialog from './BrandKnowledgeUploadDialog' import { BRAND_KNOWLEDGE_TYPE_OPTIONS } from '@/lib/brand-knowledge-file-types' const KNOWLEDGE_TYPES = ['全部', ...BRAND_KNOWLEDGE_TYPE_OPTIONS] -type BrandKnowledgeItem = { id: string; title: string; knowledgeType: string; brand: string; productCategoryName: string; fileName: string; mimeType: string; fileSize: number; createdAt: string; uploadedBy: string; downloadUrl: string; previewUrl: string | null; previewImageName: string | null; likeCount: number; isLiked: boolean; isFavorited: boolean } +type BrandKnowledgeItem = { id: string; title: string; knowledgeType: string; brand: string; productCategoryName: string; fileName: string; mimeType: string; fileSize: number; downloadCount: number; createdAt: string; uploadedBy: string; downloadUrl: string; previewUrl: string | null; previewImageName: string | null; likeCount: number; isLiked: boolean; isFavorited: boolean } function formatFileSize(size: number) { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`; if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(2)} MB`; return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB` } function formatDate(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; const parts = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date); const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); return `${values.year}.${values.month}.${values.day}` } @@ -54,8 +55,7 @@ function BrandKnowledgeCard({ item, onNotice, onLike, onFavorite }: { } const handleShare = async () => { - const url = new URL('/workspaces/assets/brand-knowledge', window.location.origin) - url.searchParams.set('knowledge', item.id) + const url = new URL(`/workspaces/assets/brand-knowledge/${encodeURIComponent(item.id)}`, window.location.origin) onNotice(await copyText(url.toString()) ? '分享链接已复制' : '分享链接复制失败,请手动复制') } @@ -80,7 +80,11 @@ function BrandKnowledgeCard({ item, onNotice, onLike, onFavorite }: { return (
  • -
    + {item.previewUrl ? ( ) : ( -
    +
    )} -
    +

    - {item.title} + + {item.title} +

    -

    +

    {details.map((detail, index) => ( {index > 0 && } @@ -113,7 +119,7 @@ function BrandKnowledgeCard({ item, onNotice, onLike, onFavorite }: { + ) +} + +export default function BrandKnowledgeDetail({ knowledgeId }: { knowledgeId: string }) { + const router = useRouter() + const [item, setItem] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [busyAction, setBusyAction] = useState<'like' | 'favorite' | 'share' | null>(null) + + const loadItem = useCallback(async () => { + setIsLoading(true) + setError('') + try { + const response = await fetch(`/api/department-dashboard/brand-knowledge/${encodeURIComponent(knowledgeId)}`, { cache: 'no-store' }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识详情读取失败') + setItem(payload.data) + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : '品牌知识详情读取失败,请稍后重试') + } finally { + setIsLoading(false) + } + }, [knowledgeId]) + + useEffect(() => { void loadItem() }, [loadItem]) + + const goBack = () => { + if (window.history.length > 1) { + router.back() + return + } + router.replace('/workspaces/assets/brand-knowledge') + } + + const share = async () => { + if (!item || busyAction) return + setBusyAction('share') + const succeeded = await copyText(window.location.href) + setNotice(succeeded ? '分享链接已复制' : '分享链接复制失败,请手动复制') + setBusyAction(null) + } + + const setEngagement = async (action: 'like' | 'favorite') => { + if (!item || busyAction) return + const active = action === 'like' ? !item.isLiked : !item.isFavorited + setBusyAction(action) + try { + const response = await fetch(`/api/department-dashboard/brand-knowledge/${encodeURIComponent(item.id)}/${action}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ active }), + }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error || '操作失败,请稍后重试') + setItem((current) => current ? { + ...current, + ...(action === 'like' + ? { isLiked: payload.liked, likeCount: payload.likeCount } + : { isFavorited: payload.isFavorited }), + } : current) + setNotice(action === 'like' + ? (active ? '点赞成功' : '已取消点赞') + : (active ? '收藏成功' : '已取消收藏')) + } catch (actionError) { + setNotice(actionError instanceof Error ? actionError.message : '操作失败,请稍后重试') + } finally { + setBusyAction(null) + } + } + + return ( + +

    +
    + {isLoading ? ( +
    +
    +
    +
    +
    +
    +
    + ) : error || !item ? ( +
    +
    + ) : ( + <> +
    +
    +
    + +
    +

    {item.title}

    +

    + {item.knowledgeType} + + ID {item.id} +

    +
    +
    +
    + void setEngagement('favorite')} + > + + void setEngagement('like')} + > + + void share()} + > + +
    +
    +

    {notice}

    +
    + +
    +
    +
    + {item.previewUrl ? ( + {`预览图:${item.title}`} + ) : ( +
    +
    + )} +
    +
    + + +
    + + )} +
    +
    + + ) +} diff --git a/components/upload/ProductPackageUploader.layout.test.tsx b/components/upload/ProductPackageUploader.layout.test.tsx index f352902..d1f8a5e 100644 --- a/components/upload/ProductPackageUploader.layout.test.tsx +++ b/components/upload/ProductPackageUploader.layout.test.tsx @@ -183,7 +183,7 @@ describe('ProductPackageUploader compact layout interactions', () => { try { const user = userEvent.setup() await renderUploader() - await user.click(screen.getByRole('button', { name: '提交', exact: true })) + await user.click(screen.getByRole('button', { name: /^提交$/ })) expect(screen.getByRole('alert')).toHaveTextContent('请选择需求运营人员') expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }) } finally { diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index af8dfe6..8eac87a 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -2,7 +2,7 @@ ## 结论 -当前应用发布工具验证二十二份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。 +当前应用发布工具验证二十三份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。 目标 migration: @@ -28,6 +28,9 @@ - `20260908_add_brand_knowledge` - `20260909_expand_brand_knowledge_upload` - `20260914_add_brand_knowledge_engagement` +- `20260914_expand_brand_knowledge_download_count` + +`20260914_expand_brand_knowledge_download_count` 只为品牌知识增加默认值为 0 的非空下载次数列,用于详情页展示,并在文件流正常结束后原子计数;客户端取消或传输失败不计数,计数写入失败也不阻断文件响应。该迁移不改写知识文件。 `20260914_add_brand_knowledge_engagement` 只新增品牌知识点赞、收藏关联表及唯一约束、查询索引和级联外键,不改写已有知识内容。 @@ -46,7 +49,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。 @@ -370,7 +373,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 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写: @@ -386,7 +389,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` 仍通过; @@ -394,7 +397,7 @@ test -n "$MIGRATION_AUDIT_SHA256" 任意一项不成立:不部署应用、不手工修改 `_prisma_migrations`、不批量改业务状态;先保留现场并依据备份制定恢复方案。 -## 6. 用同一 SHA 执行迁移批次的蓝绿部署 +## 6. 用同一 SHA 执行迁移批次的直接替换部署 数据库迁移与日常应用发布是两条独立流程。只有本节这种“刚完成迁移、要上线依赖该 schema 的代码”的批次,才向部署脚本传入 `-RequireMigrationAudit` 和审计凭证。日常代码、前端与配置发布不传这些参数,不会创建 dump、恢复隔离库或要求历史审计文件。 diff --git a/lib/__tests__/node-readable-stream.test.ts b/lib/__tests__/node-readable-stream.test.ts index ab31593..dc30b9f 100644 --- a/lib/__tests__/node-readable-stream.test.ts +++ b/lib/__tests__/node-readable-stream.test.ts @@ -108,4 +108,25 @@ describe('nodeReadableToWebStream', () => { expect(destroySpy).toHaveBeenCalledTimes(1) expect(source.destroyed).toBe(true) }) + + test('notifies completion only after the source reaches EOF', async () => { + const onComplete = jest.fn() + const source = Readable.from([Buffer.from('complete')]) + + const result = await readAll(nodeReadableToWebStream(source, { onComplete })) + + expect(result.toString()).toBe('complete') + expect(onComplete).toHaveBeenCalledTimes(1) + }) + + test('does not notify completion when the reader is cancelled', async () => { + const onComplete = jest.fn() + const source = new CancellableReadable() + const reader = nodeReadableToWebStream(source, { onComplete }).getReader() + + await reader.read() + await reader.cancel() + + expect(onComplete).not.toHaveBeenCalled() + }) }) diff --git a/lib/__tests__/work-review-first-pass.test.ts b/lib/__tests__/work-review-first-pass.test.ts index 0b29389..5a12233 100644 --- a/lib/__tests__/work-review-first-pass.test.ts +++ b/lib/__tests__/work-review-first-pass.test.ts @@ -48,6 +48,7 @@ describe('department dashboard first-pass review details', () => { ;(prisma.workReviewProject.findMany as jest.Mock).mockResolvedValue([{ id: 'project-1', title: '品牌主视觉', + brand: null, description: '第一版视觉作品', status: 'revision_required', latestVersion: 2, @@ -147,6 +148,7 @@ describe('department dashboard first-pass review details', () => { expect(result.items).toEqual([{ id: 'project-1', title: '品牌主视觉', + brand: null, description: '第一版视觉作品', currentStatus: 'revision_required', latestVersion: 2, diff --git a/lib/node-readable-stream.ts b/lib/node-readable-stream.ts index 5738166..8d2dbea 100644 --- a/lib/node-readable-stream.ts +++ b/lib/node-readable-stream.ts @@ -2,6 +2,7 @@ import { Readable } from 'node:stream' export interface NodeReadableToWebStreamOptions { onCancel?: (reason?: unknown) => void | Promise + onComplete?: () => void } /** @@ -29,6 +30,11 @@ export function nodeReadableToWebStream( if (next.done) { finished = true controller.close() + try { + options.onComplete?.() + } catch (error) { + console.error('[node-readable-stream] completion callback failed:', error) + } return } diff --git a/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql b/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql new file mode 100644 index 0000000..03674d6 --- /dev/null +++ b/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "brand_knowledge" + ADD COLUMN "download_count" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2f9e1af..f638416 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -180,6 +180,7 @@ model BrandKnowledge { previewImagePath String? @map("preview_image_path") previewImageMimeType String? @map("preview_image_mime_type") previewImageSize BigInt? @map("preview_image_size") + downloadCount Int @default(0) @map("download_count") uploadedBy String @map("uploaded_by") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 1c620d9..759941e 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -1515,6 +1515,7 @@ const expectedMigrationNames = new Set([ '20260908_add_brand_knowledge', '20260909_expand_brand_knowledge_upload', '20260914_add_brand_knowledge_engagement', + '20260914_expand_brand_knowledge_download_count', ]); if (migrations.length !== expectedMigrationNames.size || migrations.some((item) => !expectedMigrationNames.has(item && item.name) @@ -2563,6 +2564,33 @@ direct_replace_active_container() { if ! replacement_ip="$(wait_for_container_health "$name" "$COMMIT_SHA")"; then fail_direct_replace_with_restore "New direct container failed its health check" "$name" "$previous_image" fi + if ! verify_transcode_lease_schema "$name"; then + fail_direct_replace_with_restore "New direct container cannot verify required video transcode lease columns" "$name" "$previous_image" + fi + if ! verify_task_detail_upload_lease_schema "$name"; then + fail_direct_replace_with_restore "New direct container cannot verify task-detail upload job lease schema" "$name" "$previous_image" + fi + if ! verify_brand_logo_defaults "$name"; then + fail_direct_replace_with_restore "New direct container cannot verify safe brand logo database defaults" "$name" "$previous_image" + fi + if ! verify_material_upload_idempotency_schema "$name"; then + fail_direct_replace_with_restore "New direct container cannot verify material upload idempotency schema" "$name" "$previous_image" + fi + if ! verify_candidate_database_identity "$name"; then + fail_direct_replace_with_restore "New direct container database identity differs from the approved migration audit" "$name" "$previous_image" + fi + if ! verify_candidate_brand_and_storage_dependencies "$name" "$replacement_ip"; then + fail_direct_replace_with_restore "New direct container cannot authenticate to S3 or decode configured brand images" "$name" "$previous_image" + fi + if ! verify_video_transcode_capability "$name"; then + fail_direct_replace_with_restore "New direct container did not publish the required video transcode capability" "$name" "$previous_image" + fi + if ! verify_task_detail_upload_capability "$name"; then + fail_direct_replace_with_restore "New direct container did not publish the required task-detail upload capability" "$name" "$previous_image" + fi + if ! verify_container_uploads_storage "$name" 1; then + fail_direct_replace_with_restore "New direct container uploads storage identity changed before traffic switch" "$name" "$previous_image" + fi if ! write_proxy_config "${replacement_ip}:3000" "$name" || ! switch_proxy || ! verify_proxy_target "$name" "$COMMIT_SHA"; then fail_direct_replace_with_restore "New direct container could not become the verified proxy target" "$name" "$previous_image" fi diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index ea10337..e46e5a5 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -68,6 +68,33 @@ test('migration deployment gate recognizes the latest approved schema migration' assert.match(gate, /20260831_add_private_task_detail/) assert.match(gate, /20260908_add_brand_knowledge/) assert.match(gate, /20260914_add_brand_knowledge_engagement/) + assert.match(gate, /20260914_expand_brand_knowledge_download_count/) +}) + +test('direct replacement runs database and dependency gates before switching traffic', () => { + const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8') + const functionStart = source.indexOf('direct_replace_active_container() {') + const functionEnd = source.indexOf('\n}\n\ncontainer_upstream_connection_count()', functionStart) + assert.ok(functionStart >= 0 && functionEnd > functionStart, 'direct replacement function is missing') + const direct = source.slice(functionStart, functionEnd) + const switchTraffic = direct.indexOf('write_proxy_config "${replacement_ip}:3000"') + assert.ok(switchTraffic > 0, 'direct traffic switch is missing') + + for (const gate of [ + 'verify_transcode_lease_schema "$name"', + 'verify_task_detail_upload_lease_schema "$name"', + 'verify_brand_logo_defaults "$name"', + 'verify_material_upload_idempotency_schema "$name"', + 'verify_candidate_database_identity "$name"', + 'verify_candidate_brand_and_storage_dependencies "$name" "$replacement_ip"', + 'verify_video_transcode_capability "$name"', + 'verify_task_detail_upload_capability "$name"', + ]) { + const position = direct.indexOf(gate) + assert.ok(position > 0 && position < switchTraffic, `${gate} must run before the direct traffic switch`) + } + assert.match(direct, /fail_direct_replace_with_restore "New direct container database identity differs/) + assert.match(direct, /fail_direct_replace_with_restore "New direct container cannot authenticate to S3/) }) test('brand knowledge upload stays bounded in the route and stable proxy', () => { diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs index a084892..2d1f255 100644 --- a/scripts/safe-production-migrate.mjs +++ b/scripts/safe-production-migrate.mjs @@ -31,6 +31,7 @@ const TARGET_MIGRATIONS = Object.freeze([ '20260908_add_brand_knowledge', '20260909_expand_brand_knowledge_upload', '20260914_add_brand_knowledge_engagement', + '20260914_expand_brand_knowledge_download_count', ]) const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs' const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT' @@ -354,7 +355,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', '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_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`, ) @@ -503,6 +504,7 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { requireColumn('brand_knowledge', 'file_path', 'text', 'NO') requireColumn('brand_knowledge', 'mime_type', 'text', 'NO') requireColumn('brand_knowledge', 'file_size', 'bigint', 'NO') + 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_mime_type', 'text', 'YES') @@ -592,6 +594,7 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { const taskDetailDemandType = columnByKey.get('materials.task_detail_demand_type') 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') if (!header || normalizeColumnDefault(header.column_default) !== '/brand/header-logo.png') { fail('Database default mismatch: system_settings.headerLogoUrl') } @@ -607,6 +610,9 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { if (!canAccessPrivate || normalizeColumnDefault(canAccessPrivate.column_default) !== 'false') { fail('Database default mismatch: material_permission_pack_items.can_access_private') } + if (!brandKnowledgeDownloadCount || normalizeColumnDefault(brandKnowledgeDownloadCount.column_default) !== '0') { + fail('Database default mismatch: brand_knowledge.download_count') + } if (legacy && (legacy.data_type !== 'text' || legacy.is_nullable !== 'NO')) { fail('Database column mismatch: system_settings.logoUrl') } diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index 3fc078f..0fdcfa7 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -347,6 +347,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index ['20260908_add_brand_knowledge', { checksum: 't' }], ['20260909_expand_brand_knowledge_upload', { checksum: 'u' }], ['20260914_add_brand_knowledge_engagement', { checksum: 'v' }], + ['20260914_expand_brand_knowledge_download_count', { checksum: 'w' }], ]) const evidence = { migrationRows: [ @@ -372,6 +373,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index { migration_name: '20260908_add_brand_knowledge', checksum: 't', finished_at: 'now', rolled_back_at: null }, { 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 }, ], columns: [ { table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' }, @@ -464,6 +466,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index { 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: '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_mime_type', data_type: 'text', is_nullable: 'YES' }, @@ -615,6 +618,16 @@ test('target evidence rejects nullable logo columns and structurally wrong index () => assertTargetMigrationEvidence(migrations, wrongTaskDetailPrivateDefault), /materials\.task_detail_private/, ) + const wrongDownloadCountDefault = { + ...evidence, + columns: evidence.columns.map((column) => column.table_name === 'brand_knowledge' && column.column_name === 'download_count' + ? { ...column, column_default: null } + : column), + } + assert.throws( + () => assertTargetMigrationEvidence(migrations, wrongDownloadCountDefault), + /brand_knowledge\.download_count/, + ) const unexpectedTaskDetailDemandDefault = { ...evidence, columns: evidence.columns.map((column) => column.table_name === 'materials' && column.column_name === 'task_detail_demand_type' @@ -765,6 +778,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co '20260908_add_brand_knowledge', '20260909_expand_brand_knowledge_upload', '20260914_add_brand_knowledge_engagement', + '20260914_expand_brand_knowledge_download_count', ]) { assert.match(dockerfile, new RegExp(migrationName)) assert.match(migratorSource, new RegExp(`'${migrationName}'`)) @@ -786,6 +800,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co assert.ok(sop.includes('`20260909_expand_brand_knowledge_upload`')) assert.match(deploymentSource, /'20260914_add_brand_knowledge_engagement'/) 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`')) const indexesQueryStart = migratorSource.indexOf('const indexes = await prisma.$queryRawUnsafe(') const indexesQueryEnd = migratorSource.indexOf('const constraints = await prisma.$queryRawUnsafe(', indexesQueryStart) assert.ok(indexesQueryStart >= 0) @@ -865,8 +881,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)