gongxiangp2/lib/material-query.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

454 lines
13 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 { 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 }
}