From 41ecfb6d3677c19b53bce995b638113d80ddd7e9 Mon Sep 17 00:00:00 2001
From: WuYongJie <549727955@qq.com>
Date: Sun, 27 Sep 2026 14:53:03 +0800
Subject: [PATCH] fix: restore certificate report PDF preview and download
---
app/api/materials/[id]/route.ts | 10 +++--
app/api/materials/share/route.ts | 10 +++--
components/DocumentAssetViewer.test.tsx | 40 +++++++++++++++++++
lib/__tests__/certificate-report.test.ts | 11 +++++
.../materials-share-security.test.ts | 23 +++++++++++
lib/certificate-report.ts | 19 +++++++++
6 files changed, 105 insertions(+), 8 deletions(-)
create mode 100644 components/DocumentAssetViewer.test.tsx
diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts
index f410fff..cb4c217 100644
--- a/app/api/materials/[id]/route.ts
+++ b/app/api/materials/[id]/route.ts
@@ -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()
diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts
index 8f86dd0..a570f58 100644
--- a/app/api/materials/share/route.ts
+++ b/app/api/materials/share/route.ts
@@ -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) || ''
+ : '',
}
}),
};
diff --git a/components/DocumentAssetViewer.test.tsx b/components/DocumentAssetViewer.test.tsx
new file mode 100644
index 0000000..378e15a
--- /dev/null
+++ b/components/DocumentAssetViewer.test.tsx
@@ -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()
+ 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
+ }
+})
diff --git a/lib/__tests__/certificate-report.test.ts b/lib/__tests__/certificate-report.test.ts
index 192460f..b58c47a 100644
--- a/lib/__tests__/certificate-report.test.ts
+++ b/lib/__tests__/certificate-report.test.ts
@@ -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', '成品')
diff --git a/lib/__tests__/materials-share-security.test.ts b/lib/__tests__/materials-share-security.test.ts
index 3796c80..fa27378 100644
--- a/lib/__tests__/materials-share-security.test.ts
+++ b/lib/__tests__/materials-share-security.test.ts
@@ -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 = {}) {
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: '淘系-逛逛视频',
diff --git a/lib/certificate-report.ts b/lib/certificate-report.ts
index 170f33a..40a1837 100644
--- a/lib/certificate-report.ts
+++ b/lib/certificate-report.ts
@@ -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