feat: add brand knowledge detail view
This commit is contained in:
parent
6d05ec2de6
commit
6c263e4999
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
75
app/api/department-dashboard/brand-knowledge/[id]/route.ts
Normal file
75
app/api/department-dashboard/brand-knowledge/[id]/route.ts
Normal file
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
11
app/workspaces/assets/brand-knowledge/[id]/page.tsx
Normal file
11
app/workspaces/assets/brand-knowledge/[id]/page.tsx
Normal file
@ -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 <BrandKnowledgeDetail knowledgeId={params.id} />
|
||||
}
|
||||
@ -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('分享链接已复制'))
|
||||
|
||||
|
||||
@ -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 (
|
||||
<li className="min-w-0">
|
||||
<article className="bg-transparent">
|
||||
<div className="relative h-[400px] w-full overflow-hidden rounded-[12px] border border-[#e7e8ea] bg-white">
|
||||
<Link
|
||||
href={`/workspaces/assets/brand-knowledge/${encodeURIComponent(item.id)}`}
|
||||
aria-label={`查看${item.title}详情`}
|
||||
className="relative block h-[400px] w-full overflow-hidden rounded-[12px] border border-[#e7e8ea] bg-white transition-colors hover:border-[#9ca3af] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
|
||||
>
|
||||
{item.previewUrl ? (
|
||||
<Image
|
||||
src={item.previewUrl}
|
||||
@ -91,18 +95,20 @@ function BrandKnowledgeCard({ item, onNotice, onLike, onFavorite }: {
|
||||
sizes="(max-width: 639px) 100vw, (max-width: 1279px) 50vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 bg-[#eceef0] px-6 text-center text-[#86898e]">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 bg-[#eceef0] px-6 text-center text-[#596579]">
|
||||
<FileText className="h-8 w-8" aria-hidden="true" />
|
||||
<span className="line-clamp-2 text-sm">暂无预览图</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="pt-4">
|
||||
<h3 className="truncate text-[16px] font-semibold tracking-[-0.03em] text-[#1a1c1d]" title={item.title}>
|
||||
{item.title}
|
||||
<Link href={`/workspaces/assets/brand-knowledge/${encodeURIComponent(item.id)}`} className="rounded-sm hover:text-[#1677ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">
|
||||
{item.title}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="mt-1.5 truncate text-[12px] leading-relaxed text-[#86898e]" title={details.join(' · ')}>
|
||||
<p className="mt-1.5 truncate text-[12px] leading-relaxed text-[#596579]" title={details.join(' · ')}>
|
||||
{details.map((detail, index) => (
|
||||
<span key={`${detail}-${index}`}>
|
||||
{index > 0 && <span aria-hidden="true"> · </span>}
|
||||
@ -113,7 +119,7 @@ function BrandKnowledgeCard({ item, onNotice, onLike, onFavorite }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopyId()}
|
||||
className="mt-1 block max-w-full truncate rounded-sm text-left text-[11px] tracking-[0.04em] text-[#86898e] transition-colors hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
|
||||
className="mt-1 block max-w-full truncate rounded-sm text-left text-[11px] tracking-[0.04em] text-[#596579] transition-colors hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
|
||||
title="点击复制完整作品ID"
|
||||
aria-label={`复制作品ID ${item.id}`}
|
||||
>
|
||||
@ -146,7 +152,7 @@ function BrandKnowledgeCard({ item, onNotice, onLike, onFavorite }: {
|
||||
disabled={busyAction !== null}
|
||||
aria-pressed={item.isLiked}
|
||||
aria-label={`${item.isLiked ? '取消点赞' : '点赞'}${item.title}`}
|
||||
className={`inline-flex min-h-10 items-center gap-1.5 rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60 ${item.isLiked ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'}`}
|
||||
className={`inline-flex min-h-10 items-center gap-1.5 rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60 ${item.isLiked ? 'text-[#1a1c1d]' : 'text-[#596579] hover:text-[#1a1c1d]'}`}
|
||||
title={item.isLiked ? '取消点赞' : '点赞'}
|
||||
>
|
||||
点赞 <span className="font-en text-[12px] tabular-nums tracking-[0.06em]">{item.likeCount}</span>
|
||||
@ -157,7 +163,7 @@ function BrandKnowledgeCard({ item, onNotice, onLike, onFavorite }: {
|
||||
disabled={busyAction !== null}
|
||||
aria-pressed={item.isFavorited}
|
||||
aria-label={`${item.isFavorited ? '取消收藏' : '收藏'}${item.title}`}
|
||||
className={`inline-flex min-h-10 items-center gap-1.5 rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60 ${item.isFavorited ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'}`}
|
||||
className={`inline-flex min-h-10 items-center gap-1.5 rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60 ${item.isFavorited ? 'text-[#1a1c1d]' : 'text-[#596579] hover:text-[#1a1c1d]'}`}
|
||||
title={item.isFavorited ? '取消收藏' : '收藏'}
|
||||
>
|
||||
<Heart className="h-3.5 w-3.5" fill={item.isFavorited ? 'currentColor' : 'none'} strokeWidth={1.6} aria-hidden="true" />
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import BrandKnowledgeDetail from './BrandKnowledgeDetail'
|
||||
|
||||
const back = jest.fn()
|
||||
const replace = jest.fn()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ back, replace }),
|
||||
}))
|
||||
|
||||
jest.mock('next-auth/react', () => ({
|
||||
useSession: () => ({ data: { user: { name: '本地超级管理员' } } }),
|
||||
signOut: jest.fn(),
|
||||
}))
|
||||
|
||||
const item = {
|
||||
id: 'knowledge-1',
|
||||
title: '索菲亚图像影像规范',
|
||||
knowledgeType: '影像规范',
|
||||
brand: '索菲亚',
|
||||
productCategoryName: '衣柜',
|
||||
fileName: '索菲亚图像影像规范.rar',
|
||||
mimeType: 'application/vnd.rar',
|
||||
fileSize: 668886630,
|
||||
downloadCount: 4,
|
||||
createdAt: '2026-09-12T10:38:00.000Z',
|
||||
uploadedBy: '无邪',
|
||||
downloadUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/file',
|
||||
previewUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/preview',
|
||||
previewImageName: 'preview.png',
|
||||
likeCount: 2,
|
||||
isLiked: false,
|
||||
isFavorited: false,
|
||||
}
|
||||
|
||||
describe('BrandKnowledgeDetail', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
window.history.pushState({}, '', '/workspaces/assets/brand-knowledge/knowledge-1')
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: jest.fn().mockResolvedValue(undefined) },
|
||||
})
|
||||
global.fetch = jest.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/like') && init?.method === 'PUT') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, liked: true, likeCount: 3 }) } as Response)
|
||||
}
|
||||
if (url.endsWith('/favorite') && init?.method === 'PUT') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, isFavorited: true }) } as Response)
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, data: item }) } as Response)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => { jest.restoreAllMocks() })
|
||||
|
||||
test('shows the material-detail layout with the complete knowledge metadata', async () => {
|
||||
render(<BrandKnowledgeDetail knowledgeId="knowledge-1" />)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: item.title })).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: `预览图:${item.title}` })).toHaveClass('object-contain', 'object-center')
|
||||
expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('索菲亚')
|
||||
expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('衣柜')
|
||||
expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('下载次数4')
|
||||
expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('点赞数2')
|
||||
expect(screen.getByRole('link', { name: `立即下载${item.title}` })).toHaveAttribute('href', item.downloadUrl)
|
||||
})
|
||||
|
||||
test('copies the detail link and keeps like and favorite states in sync', async () => {
|
||||
render(<BrandKnowledgeDetail knowledgeId="knowledge-1" />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '分享' }))
|
||||
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringMatching(/\/workspaces\/assets\/brand-knowledge\/knowledge-1$/)))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('分享链接已复制'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '点赞' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '已点赞' })).toHaveAttribute('aria-pressed', 'true'))
|
||||
expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('点赞数3')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '收藏' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '已收藏' })).toHaveAttribute('aria-pressed', 'true'))
|
||||
})
|
||||
|
||||
test('shows a recoverable error state when the item cannot be loaded', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ success: false, error: '知识内容不存在' }),
|
||||
} as Response)
|
||||
|
||||
render(<BrandKnowledgeDetail knowledgeId="missing" />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '无法打开品牌知识' })).toBeInTheDocument()
|
||||
expect(screen.getByText('知识内容不存在')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '返回品牌知识库' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
328
components/department-dashboard/BrandKnowledgeDetail.tsx
Normal file
328
components/department-dashboard/BrandKnowledgeDetail.tsx
Normal file
@ -0,0 +1,328 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Download,
|
||||
FileText,
|
||||
Heart,
|
||||
Share2,
|
||||
ThumbsUp,
|
||||
} from 'lucide-react'
|
||||
import DepartmentShell from './DepartmentShell'
|
||||
|
||||
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 formatDateTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date).replaceAll('/', '-')
|
||||
}
|
||||
|
||||
async function copyText(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
return true
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-999999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
return document.execCommand('copy')
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function DetailAction({
|
||||
active,
|
||||
disabled = false,
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-pressed={typeof active === 'boolean' ? active : undefined}
|
||||
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border px-3.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60 ${active ? 'border-[#1a1c1d] bg-[#1a1c1d] text-white' : 'border-[#dfe4ea] bg-white text-[#1a1c1d] hover:border-[#1a1c1d]'}`}
|
||||
>
|
||||
{children}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BrandKnowledgeDetail({ knowledgeId }: { knowledgeId: string }) {
|
||||
const router = useRouter()
|
||||
const [item, setItem] = useState<BrandKnowledgeItem | null>(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 (
|
||||
<DepartmentShell>
|
||||
<main className="min-h-[calc(100dvh-72px)] bg-[#f2f4f7] text-[#101828]">
|
||||
<div className="mx-auto max-w-[1360px] px-4 pb-14 pt-5 sm:px-6 sm:pt-7 lg:px-8">
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse" aria-label="正在加载品牌知识详情" aria-busy="true">
|
||||
<div className="h-20 border-b border-[#dfe4ea]" />
|
||||
<div className="mt-7 grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
|
||||
<div className="aspect-[4/3] rounded-xl bg-[#e4e8ed]" />
|
||||
<div className="h-[540px] rounded-xl bg-[#e4e8ed]" />
|
||||
</div>
|
||||
</div>
|
||||
) : error || !item ? (
|
||||
<section className="py-24 text-center" aria-live="polite">
|
||||
<BookOpen className="mx-auto h-9 w-9 text-[#aab5c3]" aria-hidden="true" />
|
||||
<h1 className="mt-4 text-xl font-bold text-[#101828]">无法打开品牌知识</h1>
|
||||
<p className="mt-2 text-sm text-[#596579]">{error || '知识内容不存在'}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="mt-6 inline-flex min-h-11 items-center gap-2 rounded-lg border border-[#dfe4ea] bg-white px-4 text-sm font-semibold text-[#101828] hover:border-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
返回品牌知识库
|
||||
</button>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<header className="border-b border-[#dfe4ea] pb-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
aria-label="返回品牌知识库"
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-[#dfe4ea] bg-white text-[#101828] transition-colors hover:border-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-bold text-[#101828] sm:text-2xl" title={item.title}>{item.title}</h1>
|
||||
<p className="mt-1 flex min-w-0 items-center gap-2 text-xs text-[#596579]">
|
||||
<span className="shrink-0">{item.knowledgeType}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="truncate font-en tabular-nums" title={item.id}>ID {item.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 sm:flex sm:flex-wrap xl:justify-end">
|
||||
<DetailAction
|
||||
active={item.isFavorited}
|
||||
disabled={busyAction !== null}
|
||||
label={item.isFavorited ? '已收藏' : '收藏'}
|
||||
onClick={() => void setEngagement('favorite')}
|
||||
>
|
||||
<Heart className="h-4 w-4" fill={item.isFavorited ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
</DetailAction>
|
||||
<DetailAction
|
||||
active={item.isLiked}
|
||||
disabled={busyAction !== null}
|
||||
label={item.isLiked ? '已点赞' : '点赞'}
|
||||
onClick={() => void setEngagement('like')}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" fill={item.isLiked ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
</DetailAction>
|
||||
<DetailAction
|
||||
disabled={busyAction !== null}
|
||||
label={busyAction === 'share' ? '复制中' : '分享'}
|
||||
onClick={() => void share()}
|
||||
>
|
||||
<Share2 className="h-4 w-4" aria-hidden="true" />
|
||||
</DetailAction>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 min-h-5 text-sm text-[#087052]" role="status" aria-live="polite">{notice}</p>
|
||||
</header>
|
||||
|
||||
<div className="mt-7 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] lg:gap-8">
|
||||
<section aria-label="知识预览" className="min-w-0 overflow-hidden rounded-xl border border-[#dfe4ea] bg-white">
|
||||
<div className="relative aspect-[4/3] min-h-[320px] w-full sm:min-h-[480px]">
|
||||
{item.previewUrl ? (
|
||||
<Image
|
||||
src={item.previewUrl}
|
||||
alt={`预览图:${item.title}`}
|
||||
fill
|
||||
priority
|
||||
unoptimized
|
||||
className="object-contain object-center"
|
||||
sizes="(max-width: 1023px) 100vw, 70vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[320px] flex-col items-center justify-center gap-3 bg-[#f8fafc] px-6 text-center text-[#596579]">
|
||||
<FileText className="h-10 w-10" aria-hidden="true" />
|
||||
<p className="text-sm font-semibold">此知识内容暂无预览图</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="rounded-xl border border-[#dfe4ea] bg-white p-5 sm:p-6 lg:sticky lg:top-6" aria-label="知识文件信息">
|
||||
<h2 className="break-words text-xl font-bold text-[#101828]">{item.title}</h2>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{[item.brand, item.productCategoryName, item.knowledgeType].filter(Boolean).map((value) => (
|
||||
<span key={value} className="rounded-md bg-[#eef2f6] px-2.5 py-1 text-xs font-semibold text-[#4f6077]">{value}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<dl className="mt-7 grid grid-cols-2 gap-x-5 gap-y-6">
|
||||
<div>
|
||||
<dt className="text-xs text-[#596579]">上传人</dt>
|
||||
<dd className="mt-1 break-words text-sm font-semibold text-[#101828]">{item.uploadedBy}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-[#596579]">文件大小</dt>
|
||||
<dd className="mt-1 text-sm font-semibold tabular-nums text-[#101828]">{formatFileSize(item.fileSize)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-[#596579]">下载次数</dt>
|
||||
<dd className="mt-1 text-sm font-semibold tabular-nums text-[#101828]">{item.downloadCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-[#596579]">点赞数</dt>
|
||||
<dd className="mt-1 text-sm font-semibold tabular-nums text-[#101828]">{item.likeCount}</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-xs text-[#596579]">文件名</dt>
|
||||
<dd className="mt-1 break-all text-sm font-semibold leading-6 text-[#101828]">{item.fileName}</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-xs text-[#596579]">上传时间</dt>
|
||||
<dd className="mt-1 text-sm font-semibold tabular-nums text-[#101828]">{formatDateTime(item.createdAt)}</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-xs text-[#596579]">作品 ID</dt>
|
||||
<dd className="mt-1 break-all font-en text-xs leading-5 text-[#4f6077]">{item.id}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<a
|
||||
href={item.downloadUrl}
|
||||
className="mt-8 inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-lg bg-[#1a1c1d] px-4 text-sm font-bold text-white transition-colors hover:bg-[#2a2d2f] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
|
||||
aria-label={`立即下载${item.title}`}
|
||||
>
|
||||
<Download className="h-5 w-5" aria-hidden="true" />
|
||||
立即下载
|
||||
</a>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</DepartmentShell>
|
||||
)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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、恢复隔离库或要求历史审计文件。
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -2,6 +2,7 @@ import { Readable } from 'node:stream'
|
||||
|
||||
export interface NodeReadableToWebStreamOptions {
|
||||
onCancel?: (reason?: unknown) => void | Promise<void>
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "brand_knowledge"
|
||||
ADD COLUMN "download_count" INTEGER NOT NULL DEFAULT 0;
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user