gongxiangp2/lib/external-certificate-reports.ts

126 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string | number> = {}
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 },
}
}