fix: restore certificate report PDF preview and download

This commit is contained in:
WuYongJie 2026-09-27 14:53:03 +08:00
parent 2e7235c704
commit 41ecfb6d36
6 changed files with 105 additions and 8 deletions

View File

@ -13,7 +13,7 @@ import {
import { updateMaterialWithSharedRules, type MaterialUpdateInput } from '@/lib/material-update';
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { isWebFileLike } from '@/lib/web-file';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType, isPublicCertificateReportPdf } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper';
@ -556,9 +556,11 @@ export async function GET(
...asset,
fileUrl: isPreview
? rewriteMediaUrlForClient(asset.fileUrl, request, 'image')
: canAccessSource && asset.assetType === 'report-pdf'
? buildProtectedUploadUrl(asset.fileUrl) || ''
: '',
: isPublicCertificateReportPdf(asset.assetType, asset.fileUrl, response.id)
? rewriteMediaUrlForClient(asset.fileUrl, request, 'source')
: canAccessSource && asset.assetType === 'report-pdf'
? buildProtectedUploadUrl(asset.fileUrl) || ''
: '',
fileSize:
typeof asset.fileSize === 'bigint'
? asset.fileSize.toString()

View File

@ -14,7 +14,7 @@ import {
import { isProductPackageType, is3DOriginalType, needsAssetsList } from '@/lib/material-type-mapper';
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { canManageMaterial } from '@/lib/material-authorization';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType, isPublicCertificateReportPdf } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
@ -414,9 +414,11 @@ export async function GET(request: NextRequest) {
...asset,
fileUrl: isPreview
? rewriteMediaUrlForClient(asset.fileUrl, request, 'image')
: canAccessSource && asset.assetType === 'report-pdf'
? buildProtectedUploadUrl(asset.fileUrl) || ''
: '',
: isPublicCertificateReportPdf(asset.assetType, asset.fileUrl, response.id)
? rewriteMediaUrlForClient(asset.fileUrl, request, 'source')
: canAccessSource && asset.assetType === 'report-pdf'
? buildProtectedUploadUrl(asset.fileUrl) || ''
: '',
}
}),
};

View File

@ -0,0 +1,40 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import DocumentAssetViewer from './DocumentAssetViewer'
test('报告地址可用于 PDF 预览、打开和按原文件名下载,并能切换图片', async () => {
const pdfUrl = 'https://s3.jiansun.vip/gongxiangpan/public-display/certificate-report/material-1/pdf/report.pdf'
const imageUrl = 'https://s3.jiansun.vip/gongxiangpan/public-display/certificate-report/material-1/image/report.jpg'
const originalFetch = global.fetch
const originalCreate = URL.createObjectURL
const originalRevoke = URL.revokeObjectURL
const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' })
global.fetch = jest.fn().mockResolvedValue({ ok: true, blob: async () => blob })
URL.createObjectURL = jest.fn().mockReturnValue('blob:report')
URL.revokeObjectURL = jest.fn()
const open = jest.spyOn(window, 'open').mockImplementation(() => null)
let savedName = ''
const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
savedName = this.download
})
try {
render(<DocumentAssetViewer title="检测报告" items={[
{ id: 'pdf', fileName: '检测报告.pdf', fileUrl: pdfUrl, fileKind: 'pdf' },
{ id: 'image', fileName: '检测报告.jpg', fileUrl: imageUrl, fileKind: 'image' },
]} />)
expect(screen.getByTitle('检测报告.pdf')).toHaveAttribute('src', `${pdfUrl}#toolbar=0&navpanes=0&scrollbar=1`)
fireEvent.click(screen.getByRole('button', { name: '打开原文件' }))
expect(open).toHaveBeenCalledWith(pdfUrl, '_blank', 'noopener,noreferrer')
fireEvent.click(screen.getByRole('button', { name: '下载原文件' }))
await waitFor(() => expect(savedName).toBe('检测报告.pdf'))
expect(global.fetch).toHaveBeenCalledWith(pdfUrl)
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:report')
fireEvent.click(screen.getByRole('button', { name: '图片 · 检测报告.jpg' }))
expect(screen.getByAltText('检测报告.jpg')).toHaveAttribute('src', imageUrl)
} finally {
click.mockRestore()
open.mockRestore()
global.fetch = originalFetch
URL.createObjectURL = originalCreate
URL.revokeObjectURL = originalRevoke
}
})

View File

@ -1,6 +1,7 @@
import {
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
getCertificateReportDisplayTitle,
isPublicCertificateReportPdf,
parseCertificateTestDate,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
@ -16,6 +17,16 @@ const metadata = {
platformName: '大货',
}
test('公开报告 PDF 必须位于当前素材的公开 PDF 目录', () => {
const root = 'https://s3.jiansun.vip/gongxiangpan/public-display/certificate-report/material-1/pdf/'
expect(isPublicCertificateReportPdf('report-pdf', `${root}report.pdf`, 'material-1')).toBe(true)
expect(isPublicCertificateReportPdf('source', `${root}report.pdf`, 'material-1')).toBe(false)
expect(isPublicCertificateReportPdf('report-pdf', `${root}report.pdf`, 'material-2')).toBe(false)
expect(isPublicCertificateReportPdf('report-pdf', `${root}../private.pdf`, 'material-1')).toBe(false)
expect(isPublicCertificateReportPdf('report-pdf', `${root}%ZZ.pdf`, 'material-1')).toBe(false)
expect(isPublicCertificateReportPdf('report-pdf', 'uploads/materials/report.pdf', 'material-1')).toBe(false)
})
describe('检测报告字段规则', () => {
test('成品沿用 SPU 或报告名称至少填一个,原材料必须填报告名称', () => {
expect(validateCertificateReportMetadata(metadata)).toHaveProperty('value.reportKind', '成品')

View File

@ -10,6 +10,7 @@ import { prisma } from '@/lib/prisma'
jest.mock('@/lib/prisma', () => ({
prisma: {
materialAsset: { findMany: jest.fn() },
material: {
findFirst: jest.fn(),
},
@ -72,6 +73,7 @@ function buildMaterial(overrides: Record<string, unknown> = {}) {
describe('materials share security', () => {
beforeEach(() => {
jest.clearAllMocks()
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([])
;(getServerSession as jest.Mock).mockResolvedValue(null)
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.materialLike.findFirst as jest.Mock).mockResolvedValue(null)
@ -123,6 +125,27 @@ describe('materials share security', () => {
})
})
test.each([null, { user: { id: 'admin-1' } }])('公开检测报告返回 PDF 和图片,保留源文件隔离 (%j)', async (session) => {
;(getServerSession as jest.Mock).mockResolvedValue(session)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'SYSTEM_ADMIN' })
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({ materialType: '证书-检测报告' }))
const pdfUrl = 'https://s3.jiansun.vip/gongxiangpan/public-display/certificate-report/material-1/pdf/0-report.pdf'
const imageUrl = 'https://s3.jiansun.vip/gongxiangpan/public-display/certificate-report/material-1/image/1-report.jpg'
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([
{ id: 'pdf', assetType: 'report-pdf', fileUrl: pdfUrl, fileName: 'report.pdf', fileSize: BigInt(10), sortOrder: 0 },
{ id: 'image', assetType: 'report-image', fileUrl: imageUrl, fileName: 'report.jpg', fileSize: BigInt(10), sortOrder: 1 },
{ id: 'source', assetType: 'source', fileUrl: 'uploads/materials/private.zip', fileName: 'private.zip', fileSize: BigInt(10), sortOrder: 2 },
{ id: 'private-pdf', assetType: 'report-pdf', fileUrl: 'https://s3.jiansun.vip/gongxiangpan/private/report.pdf', fileName: 'private.pdf', fileSize: BigInt(10), sortOrder: 3 },
])
const response = await GET(new NextRequest('https://sc.jiansun.vip/api/materials/share?id=material-1'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.assets[0]).toMatchObject({ fileUrl: pdfUrl, fileKind: 'pdf' })
expect(body.assets[1]).toMatchObject({ fileUrl: imageUrl, fileKind: 'image' })
expect(body.assets[2].fileUrl).toBe('')
expect(body.assets[3].fileUrl).toBe('')
})
test('未满15天的视频分享链接保留有效,但不返回任何视频内容', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
materialType: '淘系-逛逛视频',

View File

@ -41,6 +41,25 @@ export function isCertificateReportMaterialType(materialType: string | null | un
return String(materialType || '').trim() === CERTIFICATE_REPORT_MATERIAL_TYPE
}
// 上传完成接口将报告 PDF 放入公开展示目录;它不是受保护的本地源文件。
export function isPublicCertificateReportPdf(assetType: unknown, fileUrl: unknown, materialId: string): boolean {
if (assetType !== CERTIFICATE_REPORT_ASSET_TYPE.PDF || typeof fileUrl !== 'string') return false
try {
const url = new URL(fileUrl)
if (url.protocol !== 'https:' && url.protocol !== 'http:') return false
const segments = url.pathname.split('/').map(decodeURIComponent)
const index = segments.indexOf('public-display')
return index >= 0
&& segments[index + 1] === 'certificate-report'
&& segments[index + 2] === materialId
&& segments[index + 3] === 'pdf'
&& segments.length === index + 5
&& /\.pdf$/i.test(segments[index + 4])
} catch {
return false
}
}
export function normalizeReportTitle(value: string | null | undefined): string | null {
const normalized = typeof value === 'string' ? value.trim() : ''
return normalized || null