fix(materials): grant super admins full restricted asset access

This commit is contained in:
WuYongJie 2026-11-10 10:41:26 +08:00
parent 268ca4886f
commit 03e54dd541
47 changed files with 228 additions and 232 deletions

View File

@ -1,4 +1,4 @@
import { buildMaterialOrganizationViewWhere } from '@/lib/material-organization-access'
import { buildMaterialOrganizationWhere } from '@/lib/material-organization-access'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
@ -38,7 +38,7 @@ export async function GET(request: NextRequest) {
const endDate = searchParams.get('endDate')
const departmentKey = searchParams.get('departmentKey')
const accessWhere = await buildMaterialOrganizationViewWhere((session.user as any).id)
const accessWhere = await buildMaterialOrganizationWhere((session.user as any).id)
const stats = await getMaterialUploadStats({
accessWhere,
startDate,

View File

@ -1,4 +1,4 @@
import { canViewOperationsGMaterials, getOperationsGMaterialIds } from '@/lib/material-organization-access'
import { canUseOperationsGMaterials, getOperationsGMaterialIds } from '@/lib/material-organization-access'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
@ -53,7 +53,7 @@ export async function GET(request: NextRequest) {
const endDate = searchParams.get('endDate')
const keyword = normalizeKeyword(searchParams.get('keyword'))
const excludedIds = await canViewOperationsGMaterials((session.user as any).id) ? [] : await getOperationsGMaterialIds()
const excludedIds = await canUseOperationsGMaterials((session.user as any).id) ? [] : await getOperationsGMaterialIds()
const materialLogWhere: any = { ...(excludedIds.length ? { materialId: { notIn: excludedIds } } : {}) }
const downloadLogWhere: any = { ...(excludedIds.length ? { materialId: { notIn: excludedIds } } : {}) }

View File

@ -1,10 +1,11 @@
import { invalidateMaterialFacetsCache } from '@/lib/material-facets-cache'
import { canAccessOperationsGMaterials } from '@/lib/material-organization-access'
import { isRestrictedMaterial } from '@/lib/material-access-scope'
import { buildMaterialPrivacyWhere, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isSystemAdmin } from '@/lib/department-permission'
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
import { createMaterialLog } from '@/lib/material-log'
import { enqueueMaterialPurge, ensureMaterialPurgeWorkerStarted, MaterialPurgeError } from '@/lib/material-purge'
@ -25,19 +26,18 @@ export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions)
// 使用统一的权限检查函数(与单条删除保持一致)
if (!session || !isSystemAdmin((session.user as any)?.role)) {
const operatorId = (session?.user as any)?.id
if (!session || (!isSystemAdmin(session.user?.role) && !(await isSuperAdmin(operatorId)))) {
return NextResponse.json({ error: '无权限,需要系统管理员权限' }, { status: 403 })
}
if (!operatorId) {
return NextResponse.json({ error: '缺少操作人信息' }, { status: 401 })
}
const { ids, permanent } = await request.json()
if (permanent !== undefined && typeof permanent !== 'boolean') {
return NextResponse.json({ error: '删除方式无效' }, { status: 400 })
}
const operatorId = (session.user as any).id
if (!operatorId) {
return NextResponse.json({ error: '缺少操作人信息' }, { status: 401 })
}
// 校验 ids 是数组且非空
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
@ -63,15 +63,19 @@ export async function POST(request: NextRequest) {
// 查询素材(需要处理任务号)
const materials = await prisma.material.findMany({
where: { id: { in: ids } },
select: { id: true, taskNo: true, productModel: true, materialType: true, accessScope: true }
select: { id: true, taskNo: true, productModel: true, materialType: true, ...TASK_DETAIL_PRIVACY_SELECT }
})
if (materials.length === 0) {
return NextResponse.json({ error: '未找到要删除的素材' }, { status: 404 })
}
if (materials.some((material) => material.accessScope === 'operations_g') && !(await canAccessOperationsGMaterials(operatorId))) {
return NextResponse.json({ error: '无权限操作运营中心 G 专属素材' }, { status: 403 })
if (materials.some(isRestrictedMaterial)) {
const privacyWhere = await buildMaterialPrivacyWhere(operatorId)
const materialIds = materials.map((material) => material.id)
if (privacyWhere.OR && await prisma.material.count({ where: { id: { in: materialIds }, ...privacyWhere } }) !== materialIds.length) {
return NextResponse.json({ error: '无权限操作部分素材' }, { status: 403 })
}
}
const deletedAt = new Date()

View File

@ -1,17 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { isSystemAdmin } from '@/lib/department-permission'
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
import { prisma } from '@/lib/prisma'
import { canViewOperationsGMaterials, getOperationsGMaterialIds } from '@/lib/material-organization-access'
import { canUseOperationsGMaterials, getOperationsGMaterialIds } from '@/lib/material-organization-access'
import { ensureMaterialPurgeWorkerStarted, MaterialPurgeError, retryMaterialPurge, toPurgeJobView } from '@/lib/material-purge'
export const dynamic = 'force-dynamic'
async function operator() {
const session = await getServerSession(authOptions)
if (!session?.user || !(session.user as any).id) return null
return isSystemAdmin((session.user as any).role) ? (session.user as any).id as string : null
const user = session?.user
if (!user?.id) return null
return isSystemAdmin(user.role) || await isSuperAdmin(user.id) ? user.id : null
}
export async function GET(request: NextRequest) {
@ -22,7 +23,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '清理任务无效' }, { status: 400 })
}
try {
const excludedIds = await canViewOperationsGMaterials(operatorId) ? [] : await getOperationsGMaterialIds()
const excludedIds = await canUseOperationsGMaterials(operatorId) ? [] : await getOperationsGMaterialIds()
const scopeWhere = excludedIds.length ? { materialId: { notIn: excludedIds } } : {}
const [pending, tracked] = await Promise.all([
prisma.materialPurgeJob.findMany({ where: { ...scopeWhere, status: { not: 'completed' } }, orderBy: { createdAt: 'asc' }, take: 100 }),

View File

@ -1,4 +1,4 @@
import { canAccessOperationsGMaterials } from '@/lib/material-organization-access'
import { canAccessMaterialPrivacy } from '@/lib/task-detail-privacy'
import { isRestrictedMaterial } from '@/lib/material-access-scope'
import { createHash, timingSafeEqual } from 'crypto'
import { lstat, realpath } from 'fs/promises'
@ -292,7 +292,7 @@ export async function POST(request: NextRequest) {
throw new RegeneratePdfPreviewInputError('素材不存在', 404)
}
const session = isRestrictedMaterial(material) ? await getServerSession(authOptions) : null
if (isRestrictedMaterial(material) && !(await canAccessOperationsGMaterials((session?.user as any)?.id))) {
if (!(await canAccessMaterialPrivacy(material, (session?.user as any)?.id))) {
throw new RegeneratePdfPreviewInputError('无权限操作此素材', 403)
}
if (material.materialType !== '品牌VI灵感') {

View File

@ -10,7 +10,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { buildMaterialOrganizationViewWhere } from '@/lib/material-organization-access'
import { buildMaterialOrganizationWhere } from '@/lib/material-organization-access'
export async function GET(req: NextRequest) {
try {
@ -24,7 +24,7 @@ export async function GET(req: NextRequest) {
)
}
const organizationWhere = await buildMaterialOrganizationViewWhere((session.user as any).id)
const organizationWhere = await buildMaterialOrganizationWhere((session.user as any).id)
// 计算时间范围(30天前和7天前)
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)

View File

@ -1,4 +1,4 @@
import { buildMaterialOrganizationViewWhere } from '@/lib/material-organization-access'
import { buildMaterialPrivacyWhere } from '@/lib/task-detail-privacy'
/**
* 回收站 API - 获取已删除的素材
*
@ -9,13 +9,15 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite'
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
const user = session?.user
if (!user?.id || (!isSystemAdmin(user.role) && !(await isSuperAdmin(user.id)))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
@ -27,7 +29,7 @@ export async function GET(req: NextRequest) {
const pageSize = parseInt(searchParams.get('pageSize') || '20')
const skip = (page - 1) * pageSize
const scopeWhere = await buildMaterialOrganizationViewWhere((session.user as any).id)
const scopeWhere = await buildMaterialPrivacyWhere(user.id)
// 获取已删除的素材
const [deletedMaterials, total] = await Promise.all([

View File

@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { buildMaterialOrganizationViewWhere } from '@/lib/material-organization-access'
import { buildMaterialOrganizationWhere } from '@/lib/material-organization-access'
import { LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES } from '@/lib/video-material-types'
import { USER_TRANSCODE_FAILURE_MESSAGE } from '@/lib/video-transcode-task'
@ -27,7 +27,7 @@ export async function GET(req: NextRequest) {
)
}
const organizationWhere = await buildMaterialOrganizationViewWhere((session.user as any).id)
const organizationWhere = await buildMaterialOrganizationWhere((session.user as any).id)
// 识别视频素材类型
const videoMaterialTypes = [...LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES]

View File

@ -1,4 +1,4 @@
import { buildMaterialOrganizationViewWhere } from '@/lib/material-organization-access'
import { buildMaterialOrganizationWhere } from '@/lib/material-organization-access'
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
@ -12,7 +12,7 @@ export async function GET() {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const scopeWhere = await buildMaterialOrganizationViewWhere((session.user as any).id);
const scopeWhere = await buildMaterialOrganizationWhere((session.user as any).id);
const categories = await prisma.category.findMany({
orderBy: { name: 'asc' },

View File

@ -323,7 +323,7 @@ export async function GET(
canAccessMaterialPrivacy(material as any, userId),
isSuperAdmin(userId),
]);
if (!hasPrivacyAccess && !userIsSuperAdmin) {
if (!hasPrivacyAccess) {
return NextResponse.json({ error: '无权限查看此素材' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' } });
}
@ -332,7 +332,7 @@ export async function GET(
(material as any).materialType,
(material as any).createdAt,
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) },
{ bypassWaitPeriod: userIsSuperAdmin || userHasVideoEarlyAccess(session?.user) },
);
const buildVideoPreviewBlockedResponse = () =>
@ -348,7 +348,7 @@ export async function GET(
);
const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin;
const canEdit = hasPrivacyAccess && ((material as any).userId === userId || canBypassVideoPreviewGate);
const canEdit = (material as any).userId === userId || canBypassVideoPreviewGate;
const materialWithUser = material as any;
const displayAuthorName =
materialWithUser.user?.realName ||
@ -429,9 +429,9 @@ export async function GET(
return buildVideoPreviewBlockedResponse();
}
if (hasPrivacyAccess && canBypassVideoPreviewGate) {
if (canBypassVideoPreviewGate) {
canAccessSource = true;
} else if (hasPrivacyAccess) {
} else {
const userDownloadSetting = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true },

View File

@ -90,10 +90,10 @@ export async function GET(
const userId = (session.user as any).id
const user = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true },
select: { canDownload: true, isSuperAdmin: true },
})
if (!user?.canDownload) {
if (!user?.canDownload && !user?.isSuperAdmin) {
return NextResponse.json({ error: '您没有下载权限,请联系管理员' }, { status: 403 })
}

View File

@ -4,7 +4,6 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isSuperAdmin } from '@/lib/department-permission'
import { createMaterialLog } from '@/lib/material-log'
import { deleteFileIfUnreferenced, deleteLocalFile } from '@/lib/file-cleanup'
import {
@ -54,8 +53,7 @@ export async function GET(
}
const session = await getServerSession(authOptions)
const userId = (session?.user as any)?.id
const hasPrivacyAccess = await canAccessMaterialPrivacy(material, userId)
if (!hasPrivacyAccess && !(await isSuperAdmin(userId))) {
if (!(await canAccessMaterialPrivacy(material, userId))) {
return NextResponse.json({ error: '无权限访问此素材' }, {
status: session?.user ? 403 : 401,
headers: { 'Cache-Control': 'private, no-store' },
@ -87,7 +85,7 @@ export async function GET(
skuName: sku.skuName,
previewUrl: (sku as any).previewUrl || '',
zipFileName: (sku as any).fileName || '',
zipFilePath: hasPrivacyAccess ? sku.filePath : '',
zipFilePath: sku.filePath,
zipFileSize: sku.fileSize.toString(),
sortOrder: sku.sortOrder,
whiteBackgroundSourceType: sku.whiteBackgroundSourceType,

View File

@ -27,7 +27,7 @@ export async function canManageSkuMaterial(
): Promise<boolean> {
const stored = await prisma.material.findUnique({ where: { id: material.id }, select: TASK_DETAIL_PRIVACY_SELECT })
if (!stored || !(await canAccessMaterialPrivacy(stored, user.id))) return false
if (material.userId === user.id || isSystemAdmin(user.role) || user.isSuperAdmin === true) {
if (material.userId === user.id || isSystemAdmin(user.role)) {
return true
}

View File

@ -80,10 +80,10 @@ export async function POST(
const userId = (session.user as any).id
const user = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true },
select: { canDownload: true, isSuperAdmin: true },
})
if (!user?.canDownload) {
if (!user?.canDownload && !user?.isSuperAdmin) {
return NextResponse.json({ error: '您没有下载权限,请联系管理员' }, { status: 403 })
}

View File

@ -4,7 +4,6 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isSuperAdmin } from '@/lib/department-permission'
import { rewriteMediaUrlForClient } from '@/lib/url-rewrite'
import { createMaterialLog } from '@/lib/material-log'
import { resolveWhiteBackgroundPreviewFileUrl } from '@/lib/white-background-preview'
@ -67,8 +66,7 @@ export async function GET(
}
const session = await getServerSession(authOptions)
const userId = (session?.user as any)?.id
const hasPrivacyAccess = await canAccessMaterialPrivacy(material, userId)
if (!hasPrivacyAccess && (validateSkuName !== null || !(await isSuperAdmin(userId)))) {
if (!(await canAccessMaterialPrivacy(material, userId))) {
return NextResponse.json({ error: '无权限访问该私密素材' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' } })
}
@ -139,7 +137,6 @@ export async function GET(
whiteBackgroundGroup?.other?.[0] ||
whiteBackgroundGroup?.zipAssets?.[0]
const previewAssetUrl = resolveWhiteBackgroundPreviewFileUrl(previewAsset?.fileUrl || '')
const canShowWhiteBackgroundPreview = hasPrivacyAccess || /\.(webp|png|jpe?g)(?:[?#]|$)/i.test(previewAssetUrl || '')
const uploader = sku.whiteBackgroundUploadedBy
? uploaderMap.get(sku.whiteBackgroundUploadedBy) || null
: null
@ -150,7 +147,7 @@ export async function GET(
skuName: sku.skuName,
previewUrl: rewriteMediaUrlForClient((sku as any).previewUrl || '', request, 'image') || '',
zipFileName: (sku as any).fileName || '',
zipFilePath: hasPrivacyAccess ? sku.filePath : '',
zipFilePath: sku.filePath,
zipFileSize: sku.fileSize.toString(),
sortOrder: sku.sortOrder,
createdAt: sku.createdAt,
@ -165,9 +162,7 @@ export async function GET(
whiteBackgroundUploadedAt: sku.whiteBackgroundUploadedAt,
whiteBackgroundUploader: uploader,
whiteBackgroundFileName: whiteBackgroundUploaded ? `${sku.skuName}_白底图.zip` : '',
whiteBackgroundFileUrl: canShowWhiteBackgroundPreview
? rewriteMediaUrlForClient(previewAssetUrl || '', request, 'image') || ''
: '',
whiteBackgroundFileUrl: rewriteMediaUrlForClient(previewAssetUrl || '', request, 'image') || '',
}
}),
total: skus.length,

View File

@ -14,6 +14,7 @@ import { lookup } from 'mime-types';
import {
canUserDownloadMaterial,
canUserDownloadSource,
isSuperAdmin,
} from '@/lib/department-permission';
import {
DownloadErrorCode,
@ -21,7 +22,7 @@ import {
} from '@/lib/download-errors';
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper';
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log';
import { resolveStoredUploadPath } from '@/lib/upload-paths';
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths';
import {
getVideoDownloadAvailability,
getVideoDownloadUnavailableMessage,
@ -47,9 +48,10 @@ export async function GET(request: NextRequest) {
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const userIsSuperAdmin = await isSuperAdmin(userId);
// 检查用户级别的下载权限
if (userRole !== 'ADMIN') {
if (userRole !== 'ADMIN' && !userIsSuperAdmin) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true },
@ -109,7 +111,7 @@ export async function GET(request: NextRequest) {
material.materialType,
material.createdAt,
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(session.user) },
{ bypassWaitPeriod: userIsSuperAdmin || userHasVideoEarlyAccess(session.user) },
);
if (!videoDownloadAvailability.allowed) {
return NextResponse.json(
@ -126,8 +128,7 @@ export async function GET(request: NextRequest) {
const managedDepartment = (session.user as any).managedDepartment;
// 超级管理员不受限制
const { isSuperAdmin } = await import('@/lib/department-permission');
if (!(await isSuperAdmin(userId))) {
if (!userIsSuperAdmin) {
const canDownload = isRestrictedMaterial(material) || await canUserDownloadMaterial(
userDepartment,
userRole,
@ -261,7 +262,7 @@ async function serveFile(filePath: string, material: any, fileNameSuffix: string
);
}
const allowedDirs = getAllowedFileDirectories();
const allowedDirs = [getUploadRootDir(), ...getAllowedFileDirectories()];
if (!isPathInAllowedDirs(filePath, allowedDirs)) {
console.error('[Download] 路径越界:', filePath, '允许的目录:', allowedDirs);
return NextResponse.json(

View File

@ -1,5 +1,6 @@
import { invalidateMaterialFacetsCache } from '@/lib/material-facets-cache'
import { canAccessOperationsGMaterials } from '@/lib/material-organization-access'
import { canAccessMaterialPrivacy } from '@/lib/task-detail-privacy'
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
@ -15,7 +16,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN') {
const userId = (session.user as any).id;
if (!isSystemAdmin(userRole) && !(await isSuperAdmin(userId))) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
@ -34,8 +36,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
if (material.accessScope === 'operations_g' && !(await canAccessOperationsGMaterials((session.user as any).id))) {
return NextResponse.json({ error: '无权限操作运营中心 G 专属素材' }, { status: 403 });
if (!(await canAccessMaterialPrivacy(material, userId))) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 });
}
const restoredTaskNo = getOriginalTaskNo((material as any).taskNo);
@ -143,7 +145,6 @@ export async function POST(request: NextRequest) {
]);
// 记录操作日志
const userId = (session.user as any).id;
await createMaterialLog({
materialId: id,
userId,

View File

@ -1,12 +1,11 @@
import { invalidateMaterialFacetsCache } from '@/lib/material-facets-cache'
import { canAccessOperationsGMaterials } from '@/lib/material-organization-access'
import { isOperationsGMaterial, isPreviewAssetType, isRestrictedMaterial } from '@/lib/material-access-scope'
import { canAccessMaterialPrivacy, isPrivateTaskDetail, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy';
import { isRestrictedMaterial } from '@/lib/material-access-scope'
import { canAccessMaterialPrivacy, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy';
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { isSystemAdmin } from '@/lib/department-permission';
import { canUserViewMaterial, isSuperAdmin, isSystemAdmin } from '@/lib/department-permission';
import { createMaterialLog } from '@/lib/material-log';
import { enqueueMaterialPurge, ensureMaterialPurgeWorkerStarted, MaterialPurgeError } from '@/lib/material-purge';
import { normalizeUrl, normalizeMaterial } from '@/lib/utils';
@ -176,9 +175,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ material: null });
}
const userId = (session.user as any).id;
const { isSuperAdmin, canUserViewMaterial } = await import('@/lib/department-permission');
const hasPrivacyAccess = await canAccessMaterialPrivacy(material, userId);
if (!hasPrivacyAccess && !(await isSuperAdmin(userId))) {
if (!(await canAccessMaterialPrivacy(material, userId))) {
return NextResponse.json({ error: '无权限查看此素材' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' } });
}
@ -266,7 +263,6 @@ export async function GET(request: NextRequest) {
// 优先使用用户的真实姓名或用户名,如果没有才使用 authorName
const displayAuthorName = material.user?.realName || material.user?.username || material.authorName;
const formattedAssets = (material.assets || [])
.filter((asset: any) => hasPrivacyAccess || isPreviewAssetType(asset.assetType))
.map((asset: any) => formatTaskDetailAssetSummary(
asset,
displayAuthorName,
@ -275,9 +271,6 @@ export async function GET(request: NextRequest) {
const normalizedMaterial = normalizeMaterial({
...material,
isViewOnly: !hasPrivacyAccess,
zipFileName: hasPrivacyAccess ? material.zipFileName : '',
videoSourceUrl: hasPrivacyAccess ? material.videoSourceUrl : null,
authorName: displayAuthorName, // 使用处理后的作者名
previewUrl, // 使用处理后的previewUrl
zipFileSize: material.zipFileSize.toString(),
@ -773,10 +766,6 @@ export async function GET(request: NextRequest) {
}
const formattedMaterials = materials.map(material => {
const isViewOnly = context.isSuperAdmin && (
(isOperationsGMaterial(material) && !context.canAccessOperationsGMaterials) ||
(isPrivateTaskDetail(material) && !context.canAccessPrivateTaskDetails && material.taskDetailRequesterUserId !== user.id)
);
// 处理previewUrl的fallback逻辑
let previewUrl = material.previewUrl || material.videoCoverUrl || '';
@ -814,7 +803,6 @@ export async function GET(request: NextRequest) {
// 优先使用用户的真实姓名或用户名,如果没有才使用 authorName
const displayAuthorName = material.user?.realName || material.user?.username || material.authorName;
const formattedAssets = (material.assets || [])
.filter((asset: any) => !isViewOnly || isPreviewAssetType(asset.assetType))
.map((asset: any) => formatTaskDetailAssetSummary(
asset,
displayAuthorName,
@ -823,7 +811,6 @@ export async function GET(request: NextRequest) {
const formatted = {
...material,
isViewOnly,
authorName: displayAuthorName, // 使用处理后的作者名
previewUrl, // 使用处理后的previewUrl
zipFileSize: material.zipFileSize.toString(),
@ -843,7 +830,7 @@ export async function GET(request: NextRequest) {
// 处理音频文件的zipFileName(特殊处理)
let processedZipFileName = formatted.zipFileName;
if (!isViewOnly && formatted.zipFileName && formatted.materialType === '音乐') {
if (formatted.zipFileName && formatted.materialType === '音乐') {
const zipFileName = formatted.zipFileName;
if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) {
// 老素材:S3 URL,使用统一规则(音频通常>1MB,会走代理)
@ -871,13 +858,13 @@ export async function GET(request: NextRequest) {
// 视频:使用统一规则(内网用户直接访问内网MinIO,外网用户通常>1MB会走代理)
videoPreview540Url: rewriteMediaUrlForClient(formatted.videoPreview540Url, request, 'video'),
videoPreviewClipUrl: rewriteMediaUrlForClient(formatted.videoPreviewClipUrl, request, 'video'),
videoSourceUrl: isViewOnly ? null : rewriteMediaUrlForClient(formatted.videoSourceUrl, request, 'video'),
videoSourceUrl: rewriteMediaUrlForClient(formatted.videoSourceUrl, request, 'video'),
// 音频文件:特殊处理
zipFileName: isViewOnly ? '' : processedZipFileName,
zipFileName: processedZipFileName,
assets: formattedAssets,
};
return applyVideoPreviewPolicy(rewrittenMaterial, new Date(), { bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) });
return applyVideoPreviewPolicy(rewrittenMaterial, new Date(), { bypassWaitPeriod: context.isSuperAdmin || userHasVideoEarlyAccess(session?.user) });
});
return NextResponse.json({
@ -921,7 +908,8 @@ export async function DELETE(request: NextRequest) {
);
}
const userRole = (session.user as any).role;
if (!isSystemAdmin(userRole)) {
const userId = (session.user as any).id;
if (!isSystemAdmin(userRole) && !(await isSuperAdmin(userId))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
@ -957,10 +945,8 @@ export async function DELETE(request: NextRequest) {
);
}
const userId = (session.user as any).id;
if (material.accessScope === 'operations_g' && !(await canAccessOperationsGMaterials(userId))) {
return NextResponse.json({ error: '无权限操作运营中心 G 专属素材' }, { status: 403 });
if (!(await canAccessMaterialPrivacy(material, userId))) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 });
}
// 软删除

View File

@ -11,7 +11,6 @@ import {
canUserDownloadSource,
hasSystemAdminPermission,
isSuperAdmin,
isSystemAdmin,
} from '@/lib/department-permission';
import { isProductPackageType, is3DOriginalType, needsAssetsList } from '@/lib/material-type-mapper';
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
@ -19,6 +18,7 @@ import { canManageMaterial } from '@/lib/material-authorization';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType, getDefaultCertificateExpiryDate, isCertificateReportMaterialType, isPublicCertificateReportPdf } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
import { isVideoMaterialType } from '@/lib/video-material-types';
// 解析 displayImages 字段
function parseDisplayImages(displayImages: any): string[] {
@ -160,8 +160,7 @@ export async function GET(request: NextRequest) {
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
const hasPrivacyAccess = await canAccessMaterialPrivacy(material as any, userId);
if (!hasPrivacyAccess && !(await isSuperAdmin(userId))) {
if (!(await canAccessMaterialPrivacy(material as any, userId))) {
return NextResponse.json({ error: '请登录有权限的账号查看此素材' }, { status: userId ? 403 : 401, headers: { 'Cache-Control': 'private, no-store' } });
}
@ -169,7 +168,7 @@ export async function GET(request: NextRequest) {
(material as any).materialType,
(material as any).createdAt,
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) },
{ bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) || (isVideoMaterialType((material as any).materialType) && await isSuperAdmin(userId)) },
);
if (videoPreviewPolicy.videoPreviewBlocked) {
@ -293,7 +292,7 @@ export async function GET(request: NextRequest) {
// 任务-详情 PSD 和任务原图 ZIP 按各自素材类型检查源文件权限。
let canAccessSource = false;
if (hasPrivacyAccess && userId && currentUser) {
if (userId && currentUser) {
if (hasSystemAdminPermission(currentUser.role, Boolean(currentUser.isSuperAdmin))) {
canAccessSource = true;
} else if (currentUser.canDownload) {
@ -321,9 +320,8 @@ export async function GET(request: NextRequest) {
}
const capabilities = {
canFavorite: Boolean(hasPrivacyAccess && userId && currentUser),
canFavorite: Boolean(userId && currentUser),
canEdit: Boolean(
hasPrivacyAccess &&
userId &&
currentUser &&
(
@ -331,14 +329,13 @@ export async function GET(request: NextRequest) {
hasSystemAdminPermission(currentUser.role, Boolean(currentUser.isSuperAdmin))
)
),
// 与 DELETE /api/materials 当前契约保持一致:仅系统管理员角色可删除。
canDelete: Boolean(hasPrivacyAccess && currentUser && isSystemAdmin(currentUser.role)),
canDelete: Boolean(currentUser && hasSystemAdminPermission(currentUser.role, currentUser.isSuperAdmin)),
};
// 任务-详情单图编辑(替换/改名/删除)与元数据编辑权限口径不同:
// 走 canManageMaterial,托管部门的成员也能维护本部门素材。
let canManageTaskDetailAssets = false;
if (hasPrivacyAccess && userId && currentUser && isProductPackageType(material.materialType as unknown as string)) {
if (userId && currentUser && isProductPackageType(material.materialType as unknown as string)) {
canManageTaskDetailAssets = await canManageMaterial(
{ id: userId, role: currentUser.role, isSuperAdmin: currentUser.isSuperAdmin },
{ id: material.id, userId: material.userId } as any,
@ -352,7 +349,6 @@ export async function GET(request: NextRequest) {
const response: any = {
...material,
isViewOnly: !hasPrivacyAccess,
zipFileName: getSourceDisplayName(material.zipFileName || material.zipFilePath),
zipFilePath: '',
videoSourceUrl: canAccessSource ? material.videoSourceUrl : null,

View File

@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { buildMaterialViewWhere } from '@/lib/task-detail-privacy';
import { buildMaterialPrivacyWhere } from '@/lib/task-detail-privacy';
// GET - 获取素材统计数据(只统计未删除的素材)
export async function GET() {
@ -11,7 +11,7 @@ export async function GET() {
if (!session?.user) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const privacyWhere = await buildMaterialViewWhere((session.user as any).id);
const privacyWhere = await buildMaterialPrivacyWhere((session.user as any).id);
// 获取总数(只统计未删除的素材)
let total: number;

View File

@ -1,4 +1,4 @@
import { canAccessOperationsGMaterials, canViewOperationsGMaterials } from '@/lib/material-organization-access'
import { canUseOperationsGMaterials } from '@/lib/material-organization-access'
// 视频转码API - 完整版本(支持自动恢复)
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
@ -6,7 +6,7 @@ import { prisma } from '@/lib/prisma';
import { transcodeQueue, VIDEO_MATERIAL_TYPES } from '@/lib/video-transcode-queue';
import { executeTranscodeTask, USER_TRANSCODE_FAILURE_MESSAGE } from '@/lib/video-transcode-task';
import { authOptions } from '@/lib/auth';
import { canUserViewMaterial, hasSystemAdminPermission } from '@/lib/department-permission';
import { canUserViewMaterial, hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission';
export const dynamic = 'force-dynamic';
export const maxDuration = 3600; // 1小时
@ -69,10 +69,7 @@ export async function GET(request: NextRequest) {
}
const sessionUser = session.user as any;
const isSystemAdministrator = hasSystemAdminPermission(
sessionUser.role,
Boolean(sessionUser.isSuperAdmin),
);
const isSystemAdministrator = hasSystemAdminPermission(sessionUser.role) || await isSuperAdmin(sessionUser.id);
const retryAfter = consumeRateLimit(`status:${String(sessionUser.id || 'unknown')}`, STATUS_RATE_LIMIT);
if (retryAfter) {
return rateLimitResponse(retryAfter);
@ -111,7 +108,7 @@ export async function GET(request: NextRequest) {
{ status: 404 }
);
}
const canAccessG = await canViewOperationsGMaterials(sessionUser.id);
const canAccessG = await canUseOperationsGMaterials(sessionUser.id);
if (material.accessScope === 'operations_g' && !canAccessG) {
return NextResponse.json({ error: '无权限访问此素材' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' } });
}
@ -173,10 +170,7 @@ export async function POST(request: NextRequest) {
}
const sessionUser = session.user as any;
const isSystemAdministrator = hasSystemAdminPermission(
sessionUser.role,
Boolean(sessionUser.isSuperAdmin),
);
const isSystemAdministrator = hasSystemAdminPermission(sessionUser.role) || await isSuperAdmin(sessionUser.id);
const retryAfter = consumeRateLimit(`start:${String(sessionUser.id || 'unknown')}`, START_RATE_LIMIT);
if (retryAfter) {
@ -223,7 +217,7 @@ export async function POST(request: NextRequest) {
);
}
const canAccessG = await canAccessOperationsGMaterials(sessionUser.id);
const canAccessG = await canUseOperationsGMaterials(sessionUser.id);
if (material.accessScope === 'operations_g' && !canAccessG) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 });
}

View File

@ -1,4 +1,4 @@
import { buildMaterialOrganizationViewWhere } from '@/lib/material-organization-access'
import { buildMaterialOrganizationWhere } from '@/lib/material-organization-access'
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
@ -13,7 +13,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const scopeWhere = await buildMaterialOrganizationViewWhere((session.user as any).id);
const scopeWhere = await buildMaterialOrganizationWhere((session.user as any).id);
const platforms = await prisma.platform.findMany({
orderBy: { createdAt: 'asc' },

View File

@ -1,4 +1,4 @@
import { buildMaterialOrganizationViewWhere } from '@/lib/material-organization-access'
import { buildMaterialOrganizationWhere } from '@/lib/material-organization-access'
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
@ -12,7 +12,7 @@ export async function GET() {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const scopeWhere = await buildMaterialOrganizationViewWhere((session.user as any).id);
const scopeWhere = await buildMaterialOrganizationWhere((session.user as any).id);
const categories = await prisma.productCategory.findMany({
orderBy: { name: 'asc' },

View File

@ -15,6 +15,7 @@ import {
} from '@/lib/proxy-media-security'
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'
import { userHasVideoEarlyAccess } from '@/lib/video-download-policy'
import { isSuperAdmin } from '@/lib/department-permission'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@ -165,7 +166,7 @@ export async function GET(request: NextRequest) {
targetUrl.pathname,
],
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) },
{ bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) || await isSuperAdmin(videoSession?.user.id) },
)
if (blockedPreview) {

View File

@ -1,4 +1,4 @@
import { buildMaterialOrganizationViewWhere } from '@/lib/material-organization-access'
import { buildMaterialOrganizationWhere } from '@/lib/material-organization-access'
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const scopeWhere = await buildMaterialOrganizationViewWhere((session.user as any).id);
const scopeWhere = await buildMaterialOrganizationWhere((session.user as any).id);
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');

View File

@ -11,12 +11,13 @@ import {
canUserDownloadMaterial,
canUserDownloadSource,
hasSystemAdminPermission,
isSuperAdmin,
} from '@/lib/department-permission'
import { DownloadErrorCode } from '@/lib/download-errors'
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper'
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'
import { userHasVideoEarlyAccess } from '@/lib/video-download-policy'
import { canAccessMaterialPrivacy, canViewMaterialPrivacy, TASK_DETAIL_PRIVACY_SELECT, type TaskDetailPrivacyMaterial } from '@/lib/task-detail-privacy'
import { canAccessMaterialPrivacy, TASK_DETAIL_PRIVACY_SELECT, type TaskDetailPrivacyMaterial } from '@/lib/task-detail-privacy'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@ -269,8 +270,7 @@ async function handleFileRequest(
}
if (isRestrictedMaterial(access.material)) {
const session = await getServerSession(authOptions)
const checkPrivacy = access.kind === 'preview' ? canViewMaterialPrivacy : canAccessMaterialPrivacy
if (!(await checkPrivacy(access.material, (session?.user as any)?.id))) {
if (!(await canAccessMaterialPrivacy(access.material, (session?.user as any)?.id))) {
return new Response(headOnly ? null : '无权限访问此素材', {
status: session?.user ? 403 : 401,
headers: { 'Cache-Control': 'private, no-store', Vary: 'Cookie' },
@ -301,7 +301,7 @@ async function handleFileRequest(
normalizedRelativePath,
],
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) },
{ bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) || await isSuperAdmin(videoSession?.user.id) },
)
if (blockedPreview) {

View File

@ -1,4 +1,4 @@
import { buildMaterialViewWhere } from '@/lib/task-detail-privacy';
import { buildMaterialPrivacyWhere } from '@/lib/task-detail-privacy';
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
@ -47,7 +47,7 @@ export async function GET(request: NextRequest) {
const favorites = await prisma.materialFavorite.findMany({
where: {
userId,
material: await buildMaterialViewWhere(userId),
material: await buildMaterialPrivacyWhere(userId),
},
include: {
material: {

View File

@ -1,4 +1,4 @@
import { buildMaterialViewWhere } from '@/lib/task-detail-privacy';
import { buildMaterialPrivacyWhere } from '@/lib/task-detail-privacy';
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
@ -43,7 +43,7 @@ export async function GET(request: NextRequest) {
const materials = await prisma.material.findMany({
where: {
userId,
AND: [await buildMaterialViewWhere(userId)],
AND: [await buildMaterialPrivacyWhere(userId)],
deletedAt: null,
},
select: {
@ -107,7 +107,7 @@ export async function GET(request: NextRequest) {
const materials = await prisma.material.findMany({
where: {
userId,
AND: [await buildMaterialViewWhere(userId)],
AND: [await buildMaterialPrivacyWhere(userId)],
},
select: {
id: true,

View File

@ -1492,7 +1492,7 @@ export default function MaterialLibraryPage() {
<MaterialGrid
materials={materials as any}
displayedCount={displayedCount}
session={session ? { user: { id: (session.user as any)?.id || '', role: (session.user as any)?.role || '' } } : null}
session={session?.user ? { user: session.user } : null}
loading={loading}
isTransitionLoading={isTransitionLoading}
playingAudioId={playingAudioId}

View File

@ -1563,7 +1563,7 @@ export default function Home() {
<MaterialGrid
materials={materials as any}
displayedCount={displayedCount}
session={session ? { user: { id: (session.user as any)?.id || '', role: (session.user as any)?.role || '' } } : null}
session={session?.user ? { user: session.user } : null}
loading={loading}
isTransitionLoading={isTransitionLoading}
playingAudioId={playingAudioId}

View File

@ -71,7 +71,6 @@ interface Material {
isLiked?: boolean // 是否已点赞
isFavorited?: boolean // 是否已收藏
capabilities?: MaterialDetailCapabilities
isViewOnly?: boolean
taskNo?: string // 产品全案任务号
taskStatus?: string // 产品全案任务状态
taskFetchedAt?: string // 产品全案被获取时间
@ -601,7 +600,7 @@ export default function SharePage() {
: ''
const canManageStandardModelSku = Boolean(
session?.user && material && !material.isViewOnly,
session?.user && material,
)
const reportViewerItems: ViewerAssetItem[] = material && isCertificateReportMaterialType(material.materialType)
@ -1698,7 +1697,6 @@ export default function SharePage() {
assets: material.assets || [],
}}
canDownloadSource={material.canDownloadSource ?? false} // 产品全案PSD下载权限参考"详情模板"类型的 canDownloadSource 权限
canDownload={!material.isViewOnly}
showToast={showToast}
canManageAssets={Boolean(material.canManageTaskDetailAssets)}
editingAssetId={taskDetailEditingAssetId}
@ -2060,7 +2058,6 @@ export default function SharePage() {
<SkuList
materialId={materialId}
canEdit={canManageStandardModelSku}
canDownload={!material.isViewOnly}
onEditClick={() => setShowSkuEditModal(true)}
onSelectionChange={setSelectedSkuIds}
refreshKey={skuListRefreshKey}
@ -2160,7 +2157,7 @@ export default function SharePage() {
)}
{/* 操作按钮 - 统一样式 */}
{!material.isViewOnly && <div className="space-y-3 pt-2">
<div className="space-y-3 pt-2">
{material.materialType === '标准模型' ? (
<div className="grid grid-cols-2 gap-3">
<button
@ -2252,7 +2249,7 @@ export default function SharePage() {
</button>
)}
</div>}
</div>
</div>
</div>
</div>

View File

@ -41,13 +41,12 @@ interface Sku {
interface SkuListProps {
materialId: string
canEdit: boolean
canDownload?: boolean
onEditClick: () => void
onSelectionChange?: (selectedIds: Set<string>) => void
refreshKey?: number
}
export default function SkuList({ materialId, canEdit, canDownload = true, onEditClick, onSelectionChange, refreshKey = 0 }: SkuListProps) {
export default function SkuList({ materialId, canEdit, onEditClick, onSelectionChange, refreshKey = 0 }: SkuListProps) {
const [skus, setSkus] = useState<Sku[]>([])
const [loading, setLoading] = useState(true)
const { toast, showToast } = useToast()
@ -207,7 +206,7 @@ export default function SkuList({ materialId, canEdit, canDownload = true, onEdi
)}
</div>
{canDownload && <div className="mb-3 flex items-center justify-between rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] px-3 py-2">
<div className="mb-3 flex items-center justify-between rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] px-3 py-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@ -220,7 +219,7 @@ export default function SkuList({ materialId, canEdit, canDownload = true, onEdi
{selectedSkuIds.size > 0 && (
<span className="text-[12px] text-[#86898e]">已选 {selectedSkuIds.size} 个</span>
)}
</div>}
</div>
<div className="overflow-y-auto space-y-2 pr-1" style={{ maxHeight: 'calc(100vh - 500px)' }}>
{skus.map((sku) => (
@ -228,12 +227,12 @@ export default function SkuList({ materialId, canEdit, canDownload = true, onEdi
key={sku.id}
className="flex items-center gap-2 rounded-[8px] border border-[#e7e8ea] p-2 transition-colors hover:bg-[#fafafa]"
>
{canDownload && <input
<input
type="checkbox"
checked={selectedSkuIds.has(sku.id)}
onChange={() => handleSelectSku(sku.id)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 flex-shrink-0"
/>}
/>
<div
className="w-10 h-10 flex-shrink-0 rounded overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
@ -252,7 +251,7 @@ export default function SkuList({ materialId, canEdit, canDownload = true, onEdi
<div className="flex-1 min-w-0">
<p className="truncate text-[13px] font-medium text-[#1a1c1d]">{sku.skuName}</p>
<p className="truncate text-[12px] text-[#86898e]">
模型:{(canDownload ? sku.zipFilePath : sku.zipFileName)?.trim() ? '已上传' : '未上传'}
模型:{sku.zipFilePath && sku.zipFilePath.trim() ? '已上传' : '未上传'}
<span className="mx-1.5 text-[#e7e8ea]">·</span>
白底图:{sku.whiteBackgroundUploaded ? `${sku.whiteBackgroundCount || 0}张` : '未上传'}
{sku.whiteBackgroundUploaded && sku.whiteBackgroundComplete === false ? '(未补齐)' : ''}
@ -269,7 +268,7 @@ export default function SkuList({ materialId, canEdit, canDownload = true, onEdi
)}
</div>
{canDownload && <div className="flex flex-shrink-0 items-center gap-1.5">
<div className="flex flex-shrink-0 items-center gap-1.5">
<button
onClick={() => handleDownloadWhiteBackground(sku)}
disabled={!sku.whiteBackgroundUploaded || downloadingWhiteBgSkuId === sku.id}
@ -284,7 +283,7 @@ export default function SkuList({ materialId, canEdit, canDownload = true, onEdi
>
{downloadingSkuId === sku.id ? '下载中' : '模型'}
</button>
</div>}
</div>
</div>
))}
</div>

View File

@ -52,7 +52,6 @@ interface ProductPackageDetailProps {
assets: Asset[];
};
canDownloadSource: boolean;
canDownload?: boolean;
onDownload: (type: 'all' | 'main' | 'sku' | 'detail' | 'psd', assetId?: string) => Promise<void>;
onBack?: () => void;
showToast?: (message: string, options?: { variant?: 'success' | 'error' }) => void;
@ -218,7 +217,6 @@ function AssetEditMenu({
export default function ProductPackageDetail({
material,
canDownloadSource,
canDownload = true,
onDownload,
onBack,
showToast,
@ -639,7 +637,7 @@ export default function ProductPackageDetail({
)}
{/* 下载按钮组 */}
{canDownload && <div className={`${detailPanelClass} p-4`}>
<div className={`${detailPanelClass} p-4`}>
<h4 className={detailSectionTitleClass}>批量下载</h4>
{downloading && (
<div className="mb-3 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
@ -772,7 +770,7 @@ export default function ProductPackageDetail({
下载全部
</button>
</div>
</div>}
</div>
{/* 返回上一页按钮 */}
{onBack && (

View File

@ -18,7 +18,6 @@ import {
// 素材类型定义
export interface Material {
accessScope?: string
isViewOnly?: boolean
id: string
productModel: string
@ -66,14 +65,15 @@ export interface Material {
}
// 用户会话类型
interface UserSession {
export interface MaterialCardUser {
id: string
role: string
isSuperAdmin?: boolean
}
interface MaterialCardProps {
material: Material
session: { user: UserSession } | null
session: { user: MaterialCardUser } | null
// 音频播放状态
playingAudioId: string | null
audioElement: HTMLAudioElement | null
@ -161,10 +161,13 @@ export default function MaterialCard({
const [shouldLoadVideoPreview, setShouldLoadVideoPreview] = useState(false)
// 判断素材类型
const canManageLibrary = session?.user.isSuperAdmin || session?.user.role === 'ADMIN'
const isVideoType = isVideoMaterialType(material.materialType)
const videoDownloadAvailability = getVideoDownloadAvailability(
material.materialType,
material.createdAt,
new Date(),
{ bypassWaitPeriod: session?.user.isSuperAdmin },
)
const isDownloadWaiting = !videoDownloadAvailability.allowed
const isPreviewWaiting = isVideoType && (material.videoPreviewBlocked || isDownloadWaiting)
@ -172,7 +175,7 @@ export default function MaterialCard({
material.videoPreviewUnavailableMessage ||
getVideoDownloadUnavailableMessage(videoDownloadAvailability).replace('下载', '查看详情')
const isDownloadDisabled = downloadingId === material.id
const isAudioMaterial = material.materialType === '音乐' && !material.isViewOnly
const isAudioMaterial = material.materialType === '音乐'
// 视频预览URL
const videoPreviewUrl = isPreviewWaiting ? null : material.videoPreviewClipUrl || material.videoPreview540Url
@ -316,7 +319,7 @@ export default function MaterialCard({
>
<div className="flex items-center justify-between mb-1">
<span className="truncate">{label}</span>
{!material.isViewOnly && status === 'failed' && !material.videoPreviewClipUrl && !material.videoPreview540Url && (
{status === 'failed' && !material.videoPreviewClipUrl && !material.videoPreview540Url && (
<button
onClick={(e) => {
e.stopPropagation()
@ -636,7 +639,7 @@ export default function MaterialCard({
ID {material.id}
</p>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px]">
{!material.isViewOnly && <button
<button
type="button"
onClick={() => {
if (isDownloadWaiting) {
@ -665,7 +668,7 @@ export default function MaterialCard({
{downloadingId !== material.id && !isDownloadWaiting && material.downloadCount > 0 && (
<span className="font-en text-[11px] tabular-nums text-[#86898e]">{material.downloadCount}</span>
)}
</button>}
</button>
<button
type="button"
onClick={() => onShare(material.id)}
@ -676,7 +679,7 @@ export default function MaterialCard({
<ShareGlyph />
分享
</button>
{!material.isViewOnly && <button
<button
type="button"
onClick={(e) => {
e.stopPropagation()
@ -688,8 +691,8 @@ export default function MaterialCard({
title={material.isLiked ? '取消点赞' : '点赞'}
>
点赞 <span className="font-en text-[12px] tabular-nums tracking-[0.06em]">{material.likeCount}</span>
</button>}
{!material.isViewOnly && session?.user && (
</button>
{session?.user && (
<button
type="button"
onClick={(e) => {
@ -705,7 +708,7 @@ export default function MaterialCard({
{material.isFavorited ? '已收藏' : '收藏'}
</button>
)}
{!material.isViewOnly && session?.user && (material.userId === session.user.id || session.user.role === 'ADMIN') && (
{session?.user && (material.userId === session.user.id || canManageLibrary) && (
<button
type="button"
onClick={() => onEdit(material)}
@ -715,7 +718,7 @@ export default function MaterialCard({
编辑
</button>
)}
{!material.isViewOnly && session?.user && session.user.role === 'ADMIN' && (
{canManageLibrary && (
<button
type="button"
onClick={(e) => {

View File

@ -1,18 +1,12 @@
'use client'
import React from 'react'
import MaterialCard, { Material } from './MaterialCard'
// 用户会话类型
interface UserSession {
id: string
role: string
}
import MaterialCard, { type Material, type MaterialCardUser } from './MaterialCard'
interface MaterialGridProps {
materials: Material[]
displayedCount: number
session: { user: UserSession } | null
session: { user: MaterialCardUser } | null
loading: boolean
isTransitionLoading: boolean
// 音频播放状态

View File

@ -5,13 +5,13 @@
| 值 | 界面名称 | 使用规则 |
|---|---|---|
| `general` | 通用素材 | 沿用原有素材类型、私密与操作权限 |
| `operations_g` | 运营中心 G 专属 | 整个运营中心 G 及其下属部门共享,超级管理员可查看 |
| `operations_g` | 运营中心 G 专属 | 整个运营中心 G 及其下属部门共享,超级管理员可查看、下载和管理 |
历史记录默认 `general`。专属素材不设置自动开放期限,也不能叠加个人私密。
组织资格复用正式账号与人员绑定、当前组织任职路径。运营中心 G 绑定 HR 部门编号 `911666246`,部门改名后读取当前节点路径。账号调出或人员离职后失去成员资格。超级管理员可以在素材库查看个人私密和部门专属素材。下载与维护按素材原有的授权规则判断。
组织资格复用正式账号与人员绑定、当前组织任职路径。运营中心 G 绑定 HR 部门编号 `911666246`,部门改名后读取当前节点路径。账号调出或人员离职后失去成员资格。超级管理员可以在素材库跨部门查看、下载和管理素材,包括个人私密与部门专属素材。
上传与编辑的“使用范围”字段只对运营中心 G 成员显示。其他账号上传时默认使用通用素材。专属素材的预览、图片、视频派生产物使用本地鉴权存储。列表、搜索、统计、详情、分享、日志和回收站使用统一查看规则。下载、导出和编辑按各自的授权规则判断。外部 API 与对外通知仅提供通用且非个人私密的素材,下载令牌和重试回放按当前范围核验。
上传与编辑的“使用范围”字段及首页的专属筛选只对运营中心 G 成员显示。其他账号上传时默认使用通用素材。专属素材的预览、图片、视频派生产物使用本地鉴权存储。列表、搜索、详情、分享和回收站按当前账号的素材范围限制访问。外部 API 与对外通知仅提供通用且非个人私密的素材,下载令牌和重试回放按当前范围核验。
性能监控中的素材任务遵循相同范围。上传会话尚未关联素材时,仅上传者和 G 内管理员可查看文件名。

View File

@ -1,6 +1,7 @@
/** @jest-environment node */
import { prisma } from '@/lib/prisma'
import { isSuperAdmin } from '@/lib/department-permission'
import { getEmployeeByUserId, getEmployeeOrganizationPathsByUserId } from '@/lib/employee-directory'
import { canUserAccessPrivateTaskDetail } from '@/lib/user-permission-inherit'
import { canAccessOperationsGMaterials, resolveMaterialUploadAccessScope, OPERATIONS_G_SOURCE_DEPT_ID } from '@/lib/material-organization-access'
@ -22,6 +23,7 @@ const exclusive = { accessScope: 'operations_g', userId: 'uploader', taskDetailR
beforeEach(() => {
jest.clearAllMocks()
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
;(prisma.organizationUnit.findUnique as jest.Mock).mockResolvedValue({ path: root, isActive: true })
;(getEmployeeByUserId as jest.Mock).mockResolvedValue({ employmentState: 'ACTIVE' })
;(getEmployeeOrganizationPathsByUserId as jest.Mock).mockResolvedValue([`${root}/运营七部(HZ)/设计组`])
@ -82,6 +84,14 @@ test('匿名访问专属素材被拒绝,通用素材保留原有准入', async
expect(await canAccessMaterialPrivacy({ accessScope: 'general' }, null)).toBe(true)
})
test('中心外超级管理员可操作专属素材,上传范围仍按中心成员资格', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(getEmployeeOrganizationPathsByUserId as jest.Mock).mockResolvedValue([])
expect(await canAccessMaterialPrivacy(exclusive, 'super-admin')).toBe(true)
expect(await buildMaterialPrivacyWhere('super-admin')).toEqual({})
await expect(resolveMaterialUploadAccessScope('operations_g', 'super-admin')).rejects.toMatchObject({ status: 403 })
})
test('上传校验拒绝非法范围和中心外专属上传', async () => {
await expect(resolveMaterialUploadAccessScope('any-department', 'member')).rejects.toMatchObject({ status: 400 })
;(getEmployeeOrganizationPathsByUserId as jest.Mock).mockResolvedValue([])
@ -112,7 +122,7 @@ test('中心内可浏览各类型专属素材,通用素材继续遵守原类
test('列表、收藏、月份计数的共用过滤不向中心外泄露专属条目', async () => {
;(getEmployeeOrganizationPathsByUserId as jest.Mock).mockResolvedValue([])
const outside = await buildMaterialPrivacyWhere('other')
expect(outside.OR.every((branch: any) => branch.accessScope === 'general')).toBe(true)
expect(outside.OR?.every((branch: any) => branch.accessScope === 'general')).toBe(true)
;(getEmployeeOrganizationPathsByUserId as jest.Mock).mockResolvedValue([root])
expect((await buildMaterialPrivacyWhere('member')).OR).toContainEqual({ accessScope: 'operations_g' })
})

View File

@ -10,7 +10,10 @@ jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn() }))
jest.mock('@/lib/department-permission', () => ({ isSystemAdmin: (role: string) => ['ADMIN', 'SYSTEM_ADMIN'].includes(role) }))
jest.mock('@/lib/department-permission', () => ({
isSystemAdmin: (role: string) => ['ADMIN', 'SYSTEM_ADMIN'].includes(role),
isSuperAdmin: jest.fn().mockResolvedValue(false),
}))
jest.mock('@/lib/material-purge', () => ({
enqueueMaterialPurge: jest.fn(), ensureMaterialPurgeWorkerStarted: jest.fn(),
MaterialPurgeError: class extends Error { constructor(message: string, readonly status = 409) { super(message) } },

View File

@ -50,8 +50,8 @@ describe('materials download route video wait period', () => {
managedDepartment: null,
},
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true })
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true })
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
;(canUserDownloadMaterial as jest.Mock).mockResolvedValue(true)
;(canUserDownloadSource as jest.Mock).mockResolvedValue(true)
})
@ -196,6 +196,15 @@ describe('materials download route video wait period', () => {
expect(body.details).toContain('满 15 天')
})
test('超级管理员可以跳过视频等待期', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
mockMaterial('淘系-主图视频', new Date())
const response = await GET(new NextRequest('http://localhost/api/materials/download?id=material-1&validate=1'))
expect(response.status).toBe(404)
expect((await response.json()).code).toBe('NO_DOWNLOADABLE_FILES')
})
test('安装视频不受 15 天限制', async () => {
mockMaterial('安装视频', new Date())

View File

@ -193,7 +193,7 @@ describe('admin regenerate-pdf-preview route', () => {
expect(body.previewUrl).toBe('https://preview.example/new.jpg')
expect(getServerSession).not.toHaveBeenCalled()
expect(withImageProcessingSlot).toHaveBeenCalledTimes(1)
expect(processPdfUpload).toHaveBeenCalledWith(sourcePath, '')
expect(processPdfUpload).toHaveBeenCalledWith(sourcePath, '', undefined)
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { previewUrl: 'https://preview.example/new.jpg' },

View File

@ -36,7 +36,7 @@ jest.mock('@/lib/prisma', () => ({ prisma: {
jest.mock('@/lib/user-permission-inherit', () => ({ canUserAccessPrivateTaskDetail: jest.fn(), canUserEditTaskDetailAssets: jest.fn().mockResolvedValue(false) }))
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn().mockResolvedValue(false), isSystemAdmin: jest.fn().mockReturnValue(false),
hasSystemAdminPermission: jest.fn().mockReturnValue(false), canUserViewMaterial: jest.fn().mockResolvedValue(false),
hasSystemAdminPermission: jest.fn((_role, isSuperAdmin) => isSuperAdmin === true), canUserViewMaterial: jest.fn().mockResolvedValue(false),
canUserDownloadMaterial: jest.fn().mockResolvedValue(false), canUserDownloadSource: jest.fn().mockResolvedValue(false),
}))
jest.mock('@/lib/download-token', () => ({ isTokenExpired: jest.fn(() => false), verifyDownloadToken: jest.fn(() => ({ materialId: 'private-one', downloadType: 'all' })) }))
@ -73,11 +73,11 @@ test('私密详情/编辑详情/分享/下载都先拒绝无特权的上传者
test.each([
material,
{ ...material, taskDetailPrivate: false, accessScope: 'operations_g' },
])('超级管理员可查看受限素材的详情和分享页', async (restrictedMaterial) => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'super-admin', role: 'ADMIN' } })
])('超级管理员可以查看、下载和编辑受限素材', async (restrictedMaterial) => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'super-admin', role: 'USER' } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...restrictedMaterial, zipFileSize: BigInt(0), assets: [] })
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'ADMIN', isSuperAdmin: true, canDownload: true })
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'USER', isSuperAdmin: true, canDownload: false })
const url = 'https://example.com/api/materials'
const params = { id: restrictedMaterial.id }
@ -86,10 +86,11 @@ test.each([
for (const response of [detail, shared]) {
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toContain('no-store')
expect(await response.json()).toMatchObject({ id: params.id, canDownloadSource: false })
expect(await response.json()).toMatchObject({ id: params.id, canDownloadSource: true })
}
expect((await getDetail(new NextRequest(`${url}/${params.id}?intent=edit`), { params })).status).toBe(403)
expect((await getDownload(new NextRequest(`${url}/download?id=${params.id}`))).status).toBe(403)
const edit = await getDetail(new NextRequest(`${url}/${params.id}?intent=edit`), { params })
expect(edit.status).toBe(200)
expect(await edit.json()).toMatchObject({ canEdit: true, canSelectOperationsGScope: false })
})
test('所选需求运营可看详情,即使没有公开任务详情类型查看权限', async () => {
@ -185,16 +186,25 @@ describe('私密预览真实文件入口', () => {
expect(await response.text()).toBe('private-image-bytes')
})
test('超级管理员能读取私密预览,源文件仍需私密授权', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'super-admin', role: 'ADMIN' } })
test('超级管理员可读取私密预览并下载源文件', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'super-admin', role: 'USER' } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'USER', isSuperAdmin: true, canDownload: false })
const response = await GET(new NextRequest(`https://example.com/api/uploads/${segments.join('/')}`), { params: { path: segments } })
expect(response.status).toBe(200)
expect(await response.text()).toBe('private-image-bytes')
;(prisma.material.findMany as jest.Mock).mockResolvedValue([{ ...material, zipFilePath: `uploads/${segments.join('/')}` }])
const source = await GET(new NextRequest(`https://example.com/api/uploads/${segments.join('/')}`), { params: { path: segments } })
expect(source.status).toBe(403)
expect(source.status).toBe(200)
expect(await source.text()).toBe('private-image-bytes')
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...material, zipFilePath: path.join(root, ...segments), zipFileName: 'fixture.zip', productModel: 'fixture',
})
const download = await getDownload(new NextRequest('https://example.com/api/materials/download?id=private-one'))
expect(download.status).toBe(200)
expect(await download.text()).toBe('private-image-bytes')
})
test('未被源文件记录引用的旧原件不能按派生预览放行', async () => {

View File

@ -4,13 +4,12 @@ import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { POST } from '@/app/api/materials/transcode/route'
import { prisma } from '@/lib/prisma'
import { hasSystemAdminPermission } from '@/lib/department-permission'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { transcodeQueue } from '@/lib/video-transcode-queue'
import { canAccessOperationsGMaterials } from '@/lib/material-organization-access'
import { canUseOperationsGMaterials } from '@/lib/material-organization-access'
jest.mock('@/lib/material-organization-access', () => ({
canAccessOperationsGMaterials: jest.fn(),
canViewOperationsGMaterials: jest.fn(),
canUseOperationsGMaterials: jest.fn(),
}))
jest.mock('next-auth', () => ({
@ -34,6 +33,7 @@ jest.mock('@/lib/prisma', () => ({
jest.mock('@/lib/department-permission', () => ({
canUserViewMaterial: jest.fn(),
hasSystemAdminPermission: jest.fn(),
isSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/video-transcode-task', () => ({
@ -67,7 +67,8 @@ function createRequest() {
describe('transcode route authorization', () => {
beforeEach(() => {
jest.clearAllMocks()
;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(true)
;(canUseOperationsGMaterials as jest.Mock).mockResolvedValue(true)
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
;(transcodeQueue.add as jest.Mock).mockResolvedValue(undefined)
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'owner-1', role: 'USER', isSuperAdmin: false },
@ -163,7 +164,7 @@ describe('transcode route authorization', () => {
test('调出运营中心 G 的上传者不能重试专属视频,管理员也不能越过组织范围', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'moved-owner', role: 'ADMIN' } })
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(false)
;(canUseOperationsGMaterials as jest.Mock).mockResolvedValue(false)
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1', userId: 'moved-owner', accessScope: 'operations_g',
materialType: '淘系-主图视频', videoSourceUrl: 'uploads/video.mp4', videoProcessingStatus: 'failed',
@ -175,7 +176,7 @@ describe('transcode route authorization', () => {
test('中心外管理员的转码队列只返回通用素材', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'outside-admin', role: 'ADMIN' } })
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(false)
;(canUseOperationsGMaterials as jest.Mock).mockResolvedValue(false)
;(prisma.material.findMany as jest.Mock).mockResolvedValue([{ id: 'material-1' }])
;(transcodeQueue.getStatus as jest.Mock).mockReturnValueOnce({
waiting: ['g-video'], processingIds: ['material-1'], maxConcurrent: 1,

View File

@ -54,7 +54,7 @@ describe('video download policy integration', () => {
// 列表必须应用策略,且豁免标记来自当前登录用户(站内豁免,外部接口不传)。
expect(content).toContain('applyVideoPreviewPolicy(rewrittenMaterial')
expect(content).toContain('bypassWaitPeriod: userHasVideoEarlyAccess(')
expect(content).toContain('bypassWaitPeriod: context.isSuperAdmin || userHasVideoEarlyAccess(')
})
test('本地上传文件代理应拦截等待期内的视频预览文件', () => {

View File

@ -73,8 +73,7 @@ export async function canManageMaterial(
if (!(await canAccessMaterialPrivacy(privacyMaterial, user.id))) return false
if (
material.userId === user.id ||
isSystemAdmin(user.role) ||
user.isSuperAdmin === true
isSystemAdmin(user.role)
) {
return true
}

View File

@ -7,6 +7,7 @@ import { isSuperAdmin } from '@/lib/department-permission'
// HR 组织树中运营中心 G 的稳定部门编号。
export const OPERATIONS_G_SOURCE_DEPT_ID = 911666246
/** G 成员资格用于显示筛选和设置素材使用范围。 */
export async function canAccessOperationsGMaterials(userId?: string | null): Promise<boolean> {
if (!userId) return false
const [employee, paths, organization] = await Promise.all([
@ -45,16 +46,12 @@ export async function resolveMaterialUploadAccessScope(
return scope
}
export async function buildMaterialOrganizationWhere(userId?: string | null) {
return await canAccessOperationsGMaterials(userId) ? {} : { accessScope: 'general' }
}
export async function canViewOperationsGMaterials(userId?: string | null): Promise<boolean> {
export async function canUseOperationsGMaterials(userId?: string | null): Promise<boolean> {
return await isSuperAdmin(userId) || canAccessOperationsGMaterials(userId)
}
export async function buildMaterialOrganizationViewWhere(userId?: string | null) {
return await canViewOperationsGMaterials(userId) ? {} : { accessScope: 'general' }
export async function buildMaterialOrganizationWhere(userId?: string | null) {
return await canUseOperationsGMaterials(userId) ? {} : { accessScope: 'general' }
}
export async function getOperationsGMaterialIds(): Promise<string[]> {

View File

@ -2,7 +2,8 @@ import { invalidateMaterialFacetsCache } from '@/lib/material-facets-cache'
import { randomUUID } from 'crypto'
import { Prisma, type MaterialPurgeJob } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { canAccessOperationsGMaterials } from '@/lib/material-organization-access'
import { isRestrictedMaterial } from '@/lib/material-access-scope'
import { buildMaterialPrivacyWhere, canAccessMaterialPrivacy, type TaskDetailPrivacyMaterial } from '@/lib/task-detail-privacy'
import { collectPurgeFiles, findPurgeFileReferences, purgeFileIdentity, removePurgeFile, type PurgeFile } from '@/lib/material-purge-files'
export class MaterialPurgeError extends Error {
@ -46,9 +47,11 @@ export async function enqueueMaterialPurge(input: unknown, operatorUserId: strin
await lock(tx, 'product-package-upload-worker')
await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(193582731)`
const jobs = await tx.materialPurgeJob.findMany({ where: { materialId: { in: ids } } })
if (jobs.some((job) => (job.materialSnapshot as { accessScope?: string }).accessScope === 'operations_g')
&& !(await canAccessOperationsGMaterials(operatorUserId))) {
throw new MaterialPurgeError('无权限操作运营中心 G 专属素材', 403)
const canAccessJobs = await Promise.all(jobs.map((job) =>
canAccessMaterialPrivacy(job.materialSnapshot as TaskDetailPrivacyMaterial, operatorUserId),
))
if (canAccessJobs.includes(false)) {
throw new MaterialPurgeError('无权限操作部分素材', 403)
}
const newIds = ids.filter(id => !jobs.some(job => job.materialId === id))
if (!newIds.length) return jobs.map(toPurgeJobView)
@ -57,9 +60,11 @@ export async function enqueueMaterialPurge(input: unknown, operatorUserId: strin
where: { id: { in: newIds } }, include: { assets: true, skus: true },
})
if (materials.length !== newIds.length) throw new MaterialPurgeError('部分素材已不存在,请刷新后重试', 404)
if (materials.some((material) => material.accessScope === 'operations_g')
&& !(await canAccessOperationsGMaterials(operatorUserId))) {
throw new MaterialPurgeError('无权限操作运营中心 G 专属素材', 403)
if (materials.some(isRestrictedMaterial)) {
const privacyWhere = await buildMaterialPrivacyWhere(operatorUserId)
if (privacyWhere.OR && await tx.material.count({ where: { id: { in: newIds }, ...privacyWhere } }) !== materials.length) {
throw new MaterialPurgeError('无权限操作部分素材', 403)
}
}
if (materials.some(material => !material.deletedAt)) throw new MaterialPurgeError('只能彻底删除回收站中的素材')
const uploading = await tx.productPackageUploadJob.count({ where: { materialId: { in: newIds }, status: { in: ['queued', 'processing'] } } })
@ -74,6 +79,8 @@ export async function enqueueMaterialPurge(input: unknown, operatorUserId: strin
}
const snapshot = {
accessScope: material.accessScope || 'general',
taskDetailPrivate: material.taskDetailPrivate,
taskDetailRequesterUserId: material.taskDetailRequesterUserId,
id: material.id, productModel: material.productModel, materialType: material.materialType,
taskNo: material.taskNo, ownerUserId: material.userId, deletedAt: material.deletedAt!.toISOString(),
}
@ -186,9 +193,8 @@ export async function processNextMaterialPurge(): Promise<boolean> {
export async function retryMaterialPurge(jobId: string, operatorUserId: string) {
if (typeof jobId !== 'string' || !UUID.test(jobId)) throw new MaterialPurgeError('清理任务无效', 400)
const job = await prisma.materialPurgeJob.findUnique({ where: { id: jobId } })
if ((job?.materialSnapshot as { accessScope?: string } | undefined)?.accessScope === 'operations_g'
&& !(await canAccessOperationsGMaterials(operatorUserId))) {
throw new MaterialPurgeError('无权限操作运营中心 G 专属素材', 403)
if (job && !(await canAccessMaterialPrivacy(job.materialSnapshot as TaskDetailPrivacyMaterial, operatorUserId))) {
throw new MaterialPurgeError('无权限操作此素材', 403)
}
return prisma.$transaction(async tx => {
const result = await tx.materialPurgeJob.updateMany({ where: { id: jobId, status: 'failed' }, data: {

View File

@ -21,26 +21,19 @@ export function isPrivateTaskDetail(material: TaskDetailPrivacyMaterial): boolea
return material.taskDetailPrivate === true
}
/** 下载和维护仍按素材自身的私密权限和部门范围准入。 */
/** 登录账号的素材范围准入。外部接口独立使用公开范围条件。 */
export async function canAccessMaterialPrivacy(
material: TaskDetailPrivacyMaterial,
userId?: string | null,
): Promise<boolean> {
if (isOperationsGMaterial(material)) return canAccessOperationsGMaterials(userId)
if (!isPrivateTaskDetail(material)) return true
if (!isRestrictedMaterial(material)) return true
if (!userId) return false
if (await isSuperAdmin(userId)) return true
if (isOperationsGMaterial(material)) return canAccessOperationsGMaterials(userId)
if (material.taskDetailRequesterUserId === userId) return true
return canUserAccessPrivateTaskDetail(userId)
}
export async function canViewMaterialPrivacy(
material: TaskDetailPrivacyMaterial,
userId?: string | null,
): Promise<boolean> {
if (isRestrictedMaterial(material) && await isSuperAdmin(userId)) return true
return canAccessMaterialPrivacy(material, userId)
}
export function privateTaskDetailAccessWhere(userId: string, canAccessAll: boolean) {
return {
materialType: '任务-详情',
@ -51,6 +44,8 @@ export function privateTaskDetailAccessWhere(userId: string, canAccessAll: boole
}
export async function buildMaterialPrivacyWhere(userId: string) {
if (await isSuperAdmin(userId)) return {}
const [canAccessPrivate, canAccessOperationsG] = await Promise.all([
canUserAccessPrivateTaskDetail(userId),
canAccessOperationsGMaterials(userId),
@ -61,7 +56,3 @@ export async function buildMaterialPrivacyWhere(userId: string) {
...(canAccessOperationsG ? [{ accessScope: 'operations_g' }] : []),
] }
}
export async function buildMaterialViewWhere(userId: string) {
return await isSuperAdmin(userId) ? {} : buildMaterialPrivacyWhere(userId)
}