- {item.title} + + {item.title} +
-+
{details.map((detail, index) => ( {index > 0 && } @@ -113,7 +119,7 @@ function BrandKnowledgeCard({ item, onNotice, onLike, onFavorite }: {
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
+
{details.map((detail, index) => ( {index > 0 && } @@ -113,7 +119,7 @@ function BrandKnowledgeCard({ item, onNotice, onLike, onFavorite }: {