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