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' import { canUserAccessPrivateTaskDetail } from './user-permission-inherit' import { privateTaskDetailAccessWhere } from './task-detail-privacy' 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 canAccessPrivateTaskDetails?: 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' } }, { liveHost: { contains: keyword, mode: 'insensitive' } }, { livePlatform: { 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, canAccessPrivateTaskDetails] = await Promise.all([ isSessionUserSuperAdmin(user), buildMaterialSearchOrConditions(search), canUserAccessPrivateTaskDetail(user.id), ]) const canAccessDeleted = superAdmin || isSystemAdmin(user.role || '') if (requestedDeleted && !canAccessDeleted) { throw new MaterialQueryError(403, 'FORBIDDEN_DELETED_MATERIALS', '无权限查看回收站素材') } if (superAdmin) { return { canAccessDeleted, expandedViewableTypes: [], isSuperAdmin: true, canAccessPrivateTaskDetails, searchOrConditions, } } const viewableTypes = await getUserViewableMaterialTypes( user.department || '', user.role || 'USER', user.managedDepartment || null, user.id, ) return { canAccessDeleted, expandedViewableTypes: expandCompatibleMaterialTypes(viewableTypes), isSuperAdmin: false, canAccessPrivateTaskDetails, 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 livePlatform = searchParams.get('livePlatform') || '' const liveDateFrom = searchParams.get('liveDateFrom') || '' const liveDateTo = searchParams.get('liveDateTo') || '' const liveHost = searchParams.get('liveHost') || '' 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 (livePlatform) { where.livePlatform = livePlatform } if (liveHost) { where.liveHost = { contains: liveHost, mode: 'insensitive' } } if (liveDateFrom || liveDateTo) { where.liveDate = {} if (liveDateFrom) { where.liveDate.gte = new Date(`${liveDateFrom}T00:00:00.000Z`) } if (liveDateTo) { where.liveDate.lte = new Date(`${liveDateTo}T23:59:59.999Z`) } } 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 && requestedTypes.length > 0 && !requestedTypes.includes('任务-详情')) { const allowed = requestedTypes.filter((type) => context.expandedViewableTypes.includes(type)) if (allowed.length === 0) return { where, empty: true } where.materialType = materialTypeWhere(allowed) } const publicScope = context.isSuperAdmin ? { taskDetailPrivate: false } : { taskDetailPrivate: false, materialType: { in: context.expandedViewableTypes } } // 私密准入按人员/权限组授予,管理员和上传者不隐式豁免;公开素材仍用原有类型权限。 where.AND = [{ OR: [ publicScope, privateTaskDetailAccessWhere(user.id, context.canAccessPrivateTaskDetails === true), ] }] return { where, empty: false } }