import path from 'path' import { Prisma } from '@prisma/client' import { prisma } from '@/lib/prisma' import { generateDownloadUrl } from '@/lib/download-token' import { getPublicAppOrigin } from '@/lib/public-app-url' import { CERTIFICATE_REPORT_MATERIAL_TYPE, parseCertificateDate } from '@/lib/certificate-report' export class ExternalCertificateReportError extends Error { constructor(readonly status: number, readonly code: string, message: string) { super(message) } } export interface CertificateReportQuery { search?: string; reportKind?: '成品' | '原材料'; productCategory?: string; platform?: '大货' | '品牌' spu?: string; certificateNumber?: string; materialId?: string testedFrom?: string; testedTo?: string; expiresFrom?: string; expiresTo?: string expiryStatus: 'all' | 'unexpired' | 'expired' | 'unknown' fileKind: 'all' | 'pdf' | 'image'; page: number; pageSize: number } const keys = ['search', 'reportKind', 'productCategory', 'platform', 'spu', 'certificateNumber', 'materialId', 'testedFrom', 'testedTo', 'expiresFrom', 'expiresTo', 'expiryStatus', 'fileKind', 'page', 'pageSize'] const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i export function parseCertificateReportQuery(params: URLSearchParams): CertificateReportQuery { const invalid = (message: string): never => { throw new ExternalCertificateReportError(400, 'INVALID_QUERY', message) } for (const key of Array.from(params.keys())) if (!keys.includes(key) || params.getAll(key).length !== 1) invalid('查询参数未知或重复') const result: Record = {} for (const key of keys.filter(k => !['page', 'pageSize'].includes(k))) { if (!params.has(key)) continue const value = params.get(key)!.trim().normalize('NFC') if (!value || value.length > 200 || /[\x00-\x1f\x7f]/.test(value)) invalid(`${key} 无效或为空`) result[key] = value } const options = { reportKind: ['成品', '原材料'], platform: ['大货', '品牌'], expiryStatus: ['all', 'unexpired', 'expired', 'unknown'], fileKind: ['all', 'pdf', 'image'] } for (const [key, values] of Object.entries(options)) if (result[key] && !values.includes(String(result[key]))) invalid(`${key} 可选值:${values.join('、')}`) if (result.materialId && !uuid.test(String(result.materialId))) invalid('materialId 必须为素材 UUID') for (const key of ['testedFrom', 'testedTo', 'expiresFrom', 'expiresTo']) if (result[key] && !parseCertificateDate(result[key])) invalid(`${key} 必须为有效的 YYYY-MM-DD 日期`) for (const [from, to] of [['testedFrom', 'testedTo'], ['expiresFrom', 'expiresTo']]) if (result[from] && result[to] && result[from] > result[to]) invalid(`${from} 不能晚于 ${to}`) if (result.expiryStatus === 'unknown' && (result.expiresFrom || result.expiresTo)) invalid('到期日期未知时不能同时指定到期日期区间') for (const [key, fallback, maximum] of [['page', 1, 10000], ['pageSize', 20, 50]] as const) { const raw = params.get(key) if (raw !== null && (!/^[1-9]\d{0,5}$/.test(raw) || Number(raw) > maximum)) invalid(`${key} 超出范围`) result[key] = raw === null ? fallback : Number(raw) } return { expiryStatus: 'all', fileKind: 'all', ...result } as unknown as CertificateReportQuery } /** Database dates are UTC midnight; the platform's business day is UTC+8. */ export function certificateToday(now = new Date()): Date { return new Date(new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10) + 'T00:00:00.000Z') } export function certificateExpiryStatus(expires: Date | null, today: Date): 'unexpired' | 'expired' | 'unknown' { return !expires ? 'unknown' : expires < today ? 'expired' : 'unexpired' } const assetTypes = (kind: CertificateReportQuery['fileKind']) => kind === 'pdf' ? ['report-pdf'] : kind === 'image' ? ['report-image'] : ['report-pdf', 'report-image'] export function buildCertificateReportWhere(query: CertificateReportQuery, today = certificateToday()): Prisma.MaterialWhereInput { const where: Prisma.MaterialWhereInput = { materialType: CERTIFICATE_REPORT_MATERIAL_TYPE, deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false } if (query.materialId) where.id = query.materialId if (query.search) where.reportTitle = { contains: query.search, mode: 'insensitive' } if (query.reportKind) where.reportKind = query.reportKind if (query.productCategory) where.productCategoryName = query.productCategory if (query.platform) where.platformName = query.platform if (query.spu) where.productModel = { equals: query.spu, mode: 'insensitive' } if (query.certificateNumber) where.certificateNumber = { equals: query.certificateNumber, mode: 'insensitive' } if (query.testedFrom || query.testedTo) where.certificateTestedOn = { ...(query.testedFrom ? { gte: parseCertificateDate(query.testedFrom)! } : {}), ...(query.testedTo ? { lte: parseCertificateDate(query.testedTo)! } : {}) } const dates: Prisma.MaterialWhereInput[] = [] if (query.expiresFrom || query.expiresTo) dates.push({ certificateExpiresOn: { ...(query.expiresFrom ? { gte: parseCertificateDate(query.expiresFrom)! } : {}), ...(query.expiresTo ? { lte: parseCertificateDate(query.expiresTo)! } : {}) } }) if (query.expiryStatus === 'expired') dates.push({ certificateExpiresOn: { lt: today } }) if (query.expiryStatus === 'unexpired') dates.push({ certificateExpiresOn: { gte: today } }) if (query.expiryStatus === 'unknown') where.certificateExpiresOn = null if (dates.length) where.AND = dates if (query.fileKind !== 'all') where.assets = { some: { assetType: { in: assetTypes(query.fileKind) } } } return where } type ReportFile = { id: string; assetType: string; fileUrl: string; fileName?: string | null } function leaf(value: string): string { try { return decodeURIComponent(path.posix.basename(new URL(value).pathname)) } catch { return path.posix.basename(value.replace(/\\/g, '/')) } } export function reportFileKind(asset: ReportFile): 'pdf' | 'image' | null { if (!asset.fileUrl.trim()) return null const ext = path.extname(asset.fileName || leaf(asset.fileUrl)).toLowerCase() if (asset.assetType === 'report-pdf' && ext === '.pdf') return 'pdf' if (asset.assetType === 'report-image' && ['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) return 'image' return null } /** Only new report-source tokens use this resolver. Recheck current visibility and membership. */ export function resolveCertificateReportSource(material: { materialType: string; deletedAt?: Date | null; isIncompleteShell?: boolean; taskDetailPrivate?: boolean; assets?: ReportFile[] }, assetId?: string) { if (material.materialType !== CERTIFICATE_REPORT_MATERIAL_TYPE || material.deletedAt || material.isIncompleteShell || material.taskDetailPrivate || !assetId) return null const asset = material.assets?.find(a => a.id === assetId) return asset && reportFileKind(asset) ? asset : null } export async function findExternalCertificateReports(query: CertificateReportQuery) { const today = certificateToday(), where = buildCertificateReportWhere(query, today) const [totalReports, reports] = await prisma.$transaction([ prisma.material.count({ where }), prisma.material.findMany({ where, select: { id: true, reportTitle: true, reportKind: true, productModel: true, productCategoryName: true, platformName: true, certificateNumber: true, certificateTestedOn: true, certificateExpiresOn: true, certificateUsageInstructions: true, reportUsageRules: true, createdAt: true, updatedAt: true, assets: { where: { assetType: { in: assetTypes(query.fileKind) } }, select: { id: true, assetType: true, fileName: true, fileUrl: true, fileSize: true, sortOrder: true }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, }, orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }), ], { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }) const baseUrl = getPublicAppOrigin() return { query, asOfDate: today.toISOString().slice(0, 10), items: reports.map(report => { const files = report.assets.filter(a => reportFileKind(a)).map(a => ({ assetId: a.id, fileKind: reportFileKind(a)!, fileName: leaf(a.fileName || a.fileUrl), fileSize: Number.isSafeInteger(Number(a.fileSize)) ? Number(a.fileSize) : null, sortOrder: a.sortOrder, downloadUrl: generateDownloadUrl(baseUrl, { materialId: report.id, assetId: a.id, downloadType: 'certificate-report-source' }), })) return { materialId: report.id, materialType: CERTIFICATE_REPORT_MATERIAL_TYPE, reportTitle: report.reportTitle, reportKind: report.reportKind, spu: report.productModel || null, productCategory: report.productCategoryName, platform: report.platformName || null, certificateNumber: report.certificateNumber, testedOn: report.certificateTestedOn?.toISOString().slice(0, 10) || null, expiresOn: report.certificateExpiresOn?.toISOString().slice(0, 10) || null, expiryStatus: certificateExpiryStatus(report.certificateExpiresOn, today), certificateUsageInstructions: report.certificateUsageInstructions, reportUsageRules: report.reportUsageRules, createdAt: report.createdAt.toISOString(), updatedAt: report.updatedAt.toISOString(), files, issues: files.length ? [] : [{ code: 'REPORT_FILES_NOT_AVAILABLE', message: '没有可获取的报告 PDF/图片,不以预览图或压缩包代替' }], } }), pagination: { page: query.page, pageSize: query.pageSize, totalReports, hasMore: query.page * query.pageSize < totalReports }, } }