import { prisma } from './prisma' import { getUserViewableMaterialTypes, isSuperAdmin, isSystemAdmin, } from './department-permission' import { filterSelectableMaterialTypes } from './material-type-options' import { TASK_DETAIL_SOURCE_TYPE_OPTIONS } from './task-detail-source-type' import { getCompatibleVideoMaterialTypes, normalizeVideoMaterialType, } from './video-material-types' import type { Prisma } from '@prisma/client' export type MaterialFilterDimension = | 'materialType' | 'category' | 'scene' | 'platform' | 'productCategory' | 'month' export interface MaterialSessionUser { id?: string | null role?: string | null department?: string | null managedDepartment?: string | null isSuperAdmin?: boolean | null } export interface MaterialWhereResult { where: any empty: boolean } export interface MaterialQueryContext { canAccessDeleted: boolean expandedViewableTypes: string[] isSuperAdmin: boolean searchOrConditions?: any[] } export class MaterialQueryError extends Error { status: number code: string constructor(status: number, code: string, message: string) { super(message) this.status = status this.code = code } } const MATERIAL_TYPE_COMPATIBILITY: Record = { '3D场景': ['3D场景', '3DMAX'], '3DMAX': ['3D场景', '3DMAX'], '任务-详情': ['任务-详情', '产品全案'], '产品全案': ['任务-详情', '产品全案'], '详情模板': ['详情模板', '详情页'], '详情页': ['详情模板', '详情页'], '矢量文件': ['矢量文件', 'AI'], 'AI': ['矢量文件', 'AI'], 'C4D模型': ['C4D模型', 'C4D'], 'C4D': ['C4D模型', 'C4D'], } /** * 这些类型的专用 init 接口会先创建 Material,完成接口随后才写入预览、ZIP 和资产。 * 普通列表必须隐藏上传尚未完成的空壳,但不能靠“taskStatus 不是 completed”做宽泛判断, * 否则会误伤 taskStatus 为空的历史有效数据。 */ export const SPECIAL_INIT_MATERIAL_TYPES = [ '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', '任务-页面', '证书-检测报告', '任务-详情', ] as const const PROVABLY_INCOMPLETE_TASK_STATUSES = [ 'uploading', 'processing', 'failed', ] as const export interface SpecialInitShellEvidence { materialType?: string | null taskStatus?: string | null previewUrl?: string | null zipFilePath?: string | null zipFileName?: string | null displayImages?: unknown assets?: unknown taskDetailSourceType?: string | null taskDetailRequesterUserId?: string | null } function hasNonEmptyString(value: unknown): boolean { return typeof value === 'string' && value.trim().length > 0 } /** * 与数据库过滤策略相同的纯函数,便于逐类型验证显示边界。 * 这里宁可保留证据不完整的旧记录,也不把“没有预览”直接等同于“上传空壳”。 */ export function isProvablyIncompleteSpecialInitShell( material: SpecialInitShellEvidence, ): boolean { if (!SPECIAL_INIT_MATERIAL_TYPES.includes( String(material.materialType || '').trim() as typeof SPECIAL_INIT_MATERIAL_TYPES[number], )) { return false } const hasDisplayImages = Array.isArray(material.displayImages) ? material.displayImages.length > 0 : hasNonEmptyString(material.displayImages) const hasAssets = Array.isArray(material.assets) ? material.assets.length > 0 : Boolean(material.assets) if ( hasNonEmptyString(material.previewUrl) || hasNonEmptyString(material.zipFilePath) || hasNonEmptyString(material.zipFileName) || hasDisplayImages || hasAssets ) { return false } if (PROVABLY_INCOMPLETE_TASK_STATUSES.includes( String(material.taskStatus || '').trim() as typeof PROVABLY_INCOMPLETE_TASK_STATUSES[number], )) { return true } // product-package/init 当前不会写 taskStatus;只有同时具备其两项专属初始化 // 标记时,才把“任务-详情 + null 状态 + 无资产”认定为新链路空壳。 return material.materialType === '任务-详情' && material.taskStatus == null && TASK_DETAIL_SOURCE_TYPE_OPTIONS.includes( String(material.taskDetailSourceType || '').trim() as typeof TASK_DETAIL_SOURCE_TYPE_OPTIONS[number], ) && hasNonEmptyString(material.taskDetailRequesterUserId) } export const INCOMPLETE_SHELL_CREATE_DATA = { isIncompleteShell: true, } as const export const LIST_VISIBLE_MATERIAL_DATA = { isIncompleteShell: false, } as const export function buildIncompleteSpecialInitShellWhere(): Prisma.MaterialWhereInput { return { isIncompleteShell: true } } function unique(values: string[]): string[] { return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) } export function normalizeMaterialTypeForUi(materialType?: string | null): string { const trimmed = String(materialType || '').trim() if (!trimmed) { return '' } if (trimmed === '3DMAX') return '3D场景' if (trimmed === '产品全案') return '任务-详情' if (trimmed === '详情页') return '详情模板' return normalizeVideoMaterialType(trimmed) } export function normalizeSelectableMaterialTypes(types: string[]): string[] { return filterSelectableMaterialTypes( unique(types.map((type) => normalizeMaterialTypeForUi(type))), ) } export function getCompatibleMaterialTypes(materialType?: string | null): string[] { const trimmed = String(materialType || '').trim() if (!trimmed) { return [] } const normalized = normalizeMaterialTypeForUi(trimmed) const candidates = new Set([trimmed, normalized]) for (const type of [trimmed, normalized]) { const mapped = MATERIAL_TYPE_COMPATIBILITY[type] if (mapped) { mapped.forEach((item) => candidates.add(item)) } getCompatibleVideoMaterialTypes(type).forEach((item) => candidates.add(item)) } return unique(Array.from(candidates)) } export function expandCompatibleMaterialTypes(types: string[]): string[] { const expanded = new Set() for (const type of types) { getCompatibleMaterialTypes(type).forEach((item) => expanded.add(item)) } return unique(Array.from(expanded)) } export function materialTypeWhere(types: string[]): any { const uniqueTypes = unique(types) if (uniqueTypes.length === 0) { return undefined } return uniqueTypes.length === 1 ? uniqueTypes[0] : { in: uniqueTypes } } export function isDeletedMaterialRequest(searchParams: URLSearchParams): boolean { return searchParams.get('showDeleted') === 'true' || searchParams.get('deleted') === 'true' } export async function isSessionUserSuperAdmin(user: MaterialSessionUser): Promise { if (user.isSuperAdmin === true) { return true } return user.id ? await isSuperAdmin(user.id) : false } export async function canAccessDeletedMaterials(user: MaterialSessionUser): Promise { if (await isSessionUserSuperAdmin(user)) { return true } return isSystemAdmin(user.role || '') } export async function buildMaterialSearchOrConditions(search: string): Promise { const keyword = search.trim() if (!keyword) { return undefined } const [skuMatches, userMatches] = await Promise.all([ prisma.materialSku.findMany({ where: { skuName: { contains: keyword, mode: 'insensitive' }, deletedAt: null, }, select: { materialId: true }, take: 1000, }), prisma.user.findMany({ where: { OR: [ { realName: { contains: keyword, mode: 'insensitive' } }, { username: { contains: keyword, mode: 'insensitive' } }, ], }, select: { id: true }, take: 1000, }), ]) const skuMaterialIds = skuMatches.map((item) => item.materialId) const userIds = userMatches.map((item) => item.id) const orConditions: any[] = [ { id: keyword }, { productModel: { contains: keyword, mode: 'insensitive' } }, { reportTitle: { contains: keyword, mode: 'insensitive' } }, { taskNo: { contains: keyword, mode: 'insensitive' } }, { dingtalkApprovalNo: { contains: keyword, mode: 'insensitive' } }, { categoryName: { contains: keyword, mode: 'insensitive' } }, { sceneName: { contains: keyword, mode: 'insensitive' } }, { authorName: { contains: keyword, mode: 'insensitive' } }, ] if (skuMaterialIds.length > 0) { orConditions.push({ id: { in: skuMaterialIds } }) } if (userIds.length > 0) { orConditions.push({ userId: { in: userIds } }) } return orConditions } function applyMonthFilter(where: any, month: string): void { const [year, monthNum] = month.split('-') const parsedYear = Number.parseInt(year, 10) if (!Number.isFinite(parsedYear) || year.length !== 4) { return } if (!monthNum) { where.createdAt = { gte: new Date(parsedYear, 0, 1), lt: new Date(parsedYear + 1, 0, 1), } return } const parsedMonth = Number.parseInt(monthNum, 10) if (!Number.isFinite(parsedMonth) || parsedMonth < 1 || parsedMonth > 12) { return } where.createdAt = { gte: new Date(parsedYear, parsedMonth - 1, 1), lt: new Date(parsedYear, parsedMonth, 1), } } export async function createMaterialQueryContext( searchParams: URLSearchParams, user: MaterialSessionUser | null | undefined, ): Promise { if (!user?.id) { throw new MaterialQueryError(401, 'UNAUTHORIZED', '未登录') } const requestedDeleted = isDeletedMaterialRequest(searchParams) const search = searchParams.get('search') || '' const [superAdmin, searchOrConditions] = await Promise.all([ isSessionUserSuperAdmin(user), buildMaterialSearchOrConditions(search), ]) const canAccessDeleted = superAdmin || isSystemAdmin(user.role || '') if (requestedDeleted && !canAccessDeleted) { throw new MaterialQueryError(403, 'FORBIDDEN_DELETED_MATERIALS', '无权限查看回收站素材') } if (superAdmin) { return { canAccessDeleted, expandedViewableTypes: [], isSuperAdmin: true, searchOrConditions, } } const viewableTypes = await getUserViewableMaterialTypes( user.department || '', user.role || 'USER', user.managedDepartment || null, user.id, ) return { canAccessDeleted, expandedViewableTypes: expandCompatibleMaterialTypes(viewableTypes), isSuperAdmin: false, searchOrConditions, } } export async function buildMaterialWhereFromSearchParams( searchParams: URLSearchParams, user: MaterialSessionUser | null | undefined, options: { context?: MaterialQueryContext exclude?: MaterialFilterDimension } = {}, ): Promise { if (!user?.id) { throw new MaterialQueryError(401, 'UNAUTHORIZED', '未登录') } const context = options.context || await createMaterialQueryContext(searchParams, user) const where: any = {} const requestedDeleted = isDeletedMaterialRequest(searchParams) if (requestedDeleted) { if (!context.canAccessDeleted) { throw new MaterialQueryError(403, 'FORBIDDEN_DELETED_MATERIALS', '无权限查看回收站素材') } where.deletedAt = { not: null } } else { where.deletedAt = null // 列表只读上传时写下的权威标记,不再现场推断空壳。 // 按 ID / uploadId 的状态查询不经过这里,上传重试仍可访问原记录。 where.isIncompleteShell = false } const category = searchParams.get('category') || '' const scene = searchParams.get('scene') || '' const materialType = searchParams.get('materialType') || '' const platform = searchParams.get('platform') || '' const productCategory = searchParams.get('productCategory') || '' const month = searchParams.get('month') || '' if (context.searchOrConditions) { where.OR = context.searchOrConditions } if (category && options.exclude !== 'category') { where.categoryName = category } if (scene && options.exclude !== 'scene') { where.sceneName = scene } if (platform && options.exclude !== 'platform') { where.platformName = platform } if (productCategory && options.exclude !== 'productCategory') { where.productCategoryName = productCategory } if (month && options.exclude !== 'month') { applyMonthFilter(where, month) } const requestedTypes = materialType && options.exclude !== 'materialType' ? getCompatibleMaterialTypes(materialType) : [] if (requestedTypes.length > 0) { where.materialType = materialTypeWhere(requestedTypes) } if (context.isSuperAdmin) { return { where, empty: false } } const expandedViewableTypes = context.expandedViewableTypes if (expandedViewableTypes.length === 0) { return { where, empty: true } } if (requestedTypes.length > 0) { const visibleSet = new Set(expandedViewableTypes) const allowedRequestedTypes = requestedTypes.filter((type) => visibleSet.has(type)) if (allowedRequestedTypes.length === 0) { return { where, empty: true } } where.materialType = materialTypeWhere(allowedRequestedTypes) return { where, empty: false } } where.materialType = materialTypeWhere(expandedViewableTypes) return { where, empty: false } }