Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
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<string, string[]> = {
|
||
'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 function buildIncompleteSpecialInitShellWhere(): Prisma.MaterialWhereInput {
|
||
return {
|
||
AND: [
|
||
{ materialType: { in: [...SPECIAL_INIT_MATERIAL_TYPES] } },
|
||
{ previewUrl: '' },
|
||
{ zipFilePath: '' },
|
||
{ zipFileName: '' },
|
||
{ displayImages: { isEmpty: true } },
|
||
{ assets: { none: {} } },
|
||
{
|
||
OR: [
|
||
{ taskStatus: { in: [...PROVABLY_INCOMPLETE_TASK_STATUSES] } },
|
||
{
|
||
materialType: '任务-详情',
|
||
taskStatus: null,
|
||
taskDetailSourceType: { in: [...TASK_DETAIL_SOURCE_TYPE_OPTIONS] },
|
||
taskDetailRequesterUserId: { not: null },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
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<string>([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<string>()
|
||
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<boolean> {
|
||
if (user.isSuperAdmin === true) {
|
||
return true
|
||
}
|
||
|
||
return user.id ? await isSuperAdmin(user.id) : false
|
||
}
|
||
|
||
export async function canAccessDeletedMaterials(user: MaterialSessionUser): Promise<boolean> {
|
||
if (await isSessionUserSuperAdmin(user)) {
|
||
return true
|
||
}
|
||
|
||
return isSystemAdmin(user.role || '')
|
||
}
|
||
|
||
export async function buildMaterialSearchOrConditions(search: string): Promise<any[] | undefined> {
|
||
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)
|
||
const parsedMonth = Number.parseInt(monthNum, 10)
|
||
|
||
if (!Number.isFinite(parsedYear) || !Number.isFinite(parsedMonth)) {
|
||
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<MaterialQueryContext> {
|
||
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,
|
||
// 上方已确认当前用户不是超级管理员,避免再次查询同一用户记录。
|
||
null,
|
||
)
|
||
|
||
return {
|
||
canAccessDeleted,
|
||
expandedViewableTypes: expandCompatibleMaterialTypes(viewableTypes),
|
||
isSuperAdmin: false,
|
||
searchOrConditions,
|
||
}
|
||
}
|
||
|
||
export async function buildMaterialWhereFromSearchParams(
|
||
searchParams: URLSearchParams,
|
||
user: MaterialSessionUser | null | undefined,
|
||
options: {
|
||
context?: MaterialQueryContext
|
||
exclude?: MaterialFilterDimension
|
||
} = {},
|
||
): Promise<MaterialWhereResult> {
|
||
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
|
||
// 专用上传会先创建 Material 再完成文件落库。统一查询层隐藏可证明未完成、
|
||
// 且完全没有文件证据的空壳,因此列表、筛选计数和搜索建议保持一致。
|
||
// 按 ID / uploadId 的状态查询不经过这里,上传重试和状态恢复仍可访问原记录。
|
||
where.NOT = buildIncompleteSpecialInitShellWhere()
|
||
}
|
||
|
||
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 }
|
||
}
|
||
|