diff --git a/Dockerfile b/Dockerfile index 68d16ac..9cde4e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -88,7 +88,9 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql" && \ test -f "/app/prisma/migrations/20260826_add_pack_video_early_access/migration.sql" && \ test -f "/app/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql" && \ - test -f "/app/prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql" + test -f "/app/prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql" && \ + test -f "/app/prisma/migrations/20260829_add_recoverable_api_key_secret/migration.sql" && \ + test -f "/app/prisma/migrations/20260831_add_private_task_detail/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -211,6 +213,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_pack_v COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_pack_item_can_edit_assets ./prisma/migrations/20260828_add_pack_item_can_edit_assets COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_task_detail_change_approvals ./prisma/migrations/20260828_add_task_detail_change_approvals COPY --from=builder --chown=root:root /app/prisma/migrations/20260829_add_recoverable_api_key_secret ./prisma/migrations/20260829_add_recoverable_api_key_secret +COPY --from=builder --chown=root:root /app/prisma/migrations/20260831_add_private_task_detail ./prisma/migrations/20260831_add_private_task_detail COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs diff --git a/app/api/admin/regenerate-preview/route.ts b/app/api/admin/regenerate-preview/route.ts index a4e51cf..6c20f4a 100644 --- a/app/api/admin/regenerate-preview/route.ts +++ b/app/api/admin/regenerate-preview/route.ts @@ -10,6 +10,7 @@ import sharp from 'sharp' import { authOptions } from '@/lib/auth' import { readBoundedRegularFile } from '@/lib/bounded-file-reader' import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission' +import { deleteLocalFile } from '@/lib/file-cleanup' import { assertProcessableMetadata, sliceImage } from '@/lib/image-processor' import { withImageProcessingSlot } from '@/lib/image-processing-limiter' import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock' @@ -19,10 +20,8 @@ import { isPathInAllowedDirs, } from '@/lib/path-security' import { prisma } from '@/lib/prisma' -import { - buildTaskDetailPublicDisplayPath, - uploadPublicDisplayToS3, -} from '@/lib/public-display' +import { canAccessMaterialPrivacy } from '@/lib/task-detail-privacy' +import { saveTaskDetailPreview } from '@/lib/task-detail-preview-storage' import { deleteFromS3, verifyUploadedS3Object } from '@/lib/s3' import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths' @@ -235,11 +234,18 @@ async function inspectPreviewCommit(materialId: string, newUrls: string[]) { } } -async function cleanupDerivedObjects(urls: string[]): Promise { - await Promise.allSettled(urls.map((url) => deleteFromS3(url))) +async function cleanupDerivedObjects(fileUrls: string[]): Promise { + await Promise.allSettled( + Array.from(new Set(fileUrls.filter(Boolean))).map((fileUrl) => + (/^https?:\/\//i.test(fileUrl) + ? deleteFromS3(fileUrl) + : deleteLocalFile(fileUrl) + ).catch(() => undefined), + ), + ) } -async function regeneratePreview(materialId: string) { +async function regeneratePreview(materialId: string, userId: string) { let releaseMaterialLock: Awaited> | null = null let tempDir: string | null = null const uploadedUrls: string[] = [] @@ -251,6 +257,10 @@ async function regeneratePreview(materialId: string) { const material = await prisma.material.findUnique({ where: { id: materialId } }) if (!material) throw new RegeneratePreviewInputError('素材不存在') + if (!(await canAccessMaterialPrivacy(material, userId))) { + throw new RegeneratePreviewInputError('无权限操作', 403) + } + const isPrivate = Boolean(material.taskDetailPrivate) const detailAssets = await prisma.materialAsset.findMany({ where: { materialId, assetType: 'detail' }, @@ -323,13 +333,17 @@ async function regeneratePreview(materialId: string) { for (let index = 0; index < sliceBuffers.length; index += 1) { const slice = sliceBuffers[index] const fileName = `detail-preview-${operationId}-${index}.webp` - const url = await uploadPublicDisplayToS3( + const url = await saveTaskDetailPreview( slice, - buildTaskDetailPublicDisplayPath(materialId, 'detail', fileName), - 'image/webp', + materialId, + 'detail', + fileName, + isPrivate, ) uploadedUrls.push(url) - await verifyUploadedS3Object(url, slice.length, 'image/webp') + if (!isPrivate) { + await verifyUploadedS3Object(url, slice.length, 'image/webp') + } releaseMaterialLock.assertHeld() } @@ -404,7 +418,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: '素材编号无效' }, { status: 400 }) } - const result = await regeneratePreview(materialId) + const result = await regeneratePreview(materialId, userId) return NextResponse.json({ success: true, ...result }) } catch (error) { console.error('[Regenerate Preview] Error:', error) diff --git a/app/api/external/dingtalk-material-match/route.ts b/app/api/external/dingtalk-material-match/route.ts index f012320..40c6075 100644 --- a/app/api/external/dingtalk-material-match/route.ts +++ b/app/api/external/dingtalk-material-match/route.ts @@ -181,6 +181,7 @@ export async function GET(request: NextRequest) { try { const matchedMaterial = await prisma.material.findFirst({ where: { + taskDetailPrivate: false, deletedAt: null, productModel: trace.normalizedSpu, ...(materialType ? { materialType } : {}), diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index 2089cca..455675c 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -16,6 +16,7 @@ import { createReadStream } from 'fs'; import { access, stat } from 'fs/promises'; import { lookup, extension } from 'mime-types'; import { isProductPackageType } from '@/lib/material-type-mapper'; +import { isPrivateTaskDetail } from '@/lib/task-detail-privacy'; import { nodeReadableToWebStream } from '@/lib/node-readable-stream'; import { createArchiveDownloadStream } from '@/lib/archive-download-stream'; import { @@ -63,7 +64,7 @@ export async function GET(request: NextRequest) { }, }); - if (!material) { + if (!material || isPrivateTaskDetail(material)) { return errorResponse(API_ERROR.TASK_NOT_FOUND); } diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts index 7b8b228..ac98906 100644 --- a/app/api/external/product-assets/route.ts +++ b/app/api/external/product-assets/route.ts @@ -1,3 +1,4 @@ +import { isPrivateTaskDetail } from '@/lib/task-detail-privacy'; /** * 外部接口:产品全案素材查询和删除 * GET - 通过任务号查询素材详情 @@ -101,7 +102,8 @@ export async function GET(request: NextRequest) { try { // 查询素材 console.log('[external/product-assets] Querying material:', { taskNo, materialId }); - const material: any = materialId ? await getMaterialById(materialId) : await getMaterialByTaskNo(taskNo as string); + const found: any = materialId ? await getMaterialById(materialId) : await getMaterialByTaskNo(taskNo as string); + const material = found && !isPrivateTaskDetail(found) ? found : null; console.log('[external/product-assets] Material found:', material ? { id: material.id, taskNo: material.taskNo, @@ -394,7 +396,8 @@ export async function DELETE(request: NextRequest) { try { // 查询素材 - const material: any = await getMaterialByTaskNo(taskNo); + const found: any = await getMaterialByTaskNo(taskNo); + const material = found && !isPrivateTaskDetail(found) ? found : null; if (!material) { await logApiCall({ diff --git a/app/api/external/product-barcode-images/route.ts b/app/api/external/product-barcode-images/route.ts index 01d9d16..2038c68 100644 --- a/app/api/external/product-barcode-images/route.ts +++ b/app/api/external/product-barcode-images/route.ts @@ -64,6 +64,7 @@ export async function GET(request: NextRequest) { assetType: 'sku-preview', material: { materialType: '任务-详情', + taskDetailPrivate: false, deletedAt: null, }, }, diff --git a/app/api/external/standard-model-white-background/route.ts b/app/api/external/standard-model-white-background/route.ts index f42b4cb..f35382f 100644 --- a/app/api/external/standard-model-white-background/route.ts +++ b/app/api/external/standard-model-white-background/route.ts @@ -642,6 +642,7 @@ async function getTaskDetailMaterialsBySpu(normalizedSpu: string) { where: { productModel: normalizedSpu, materialType: '任务-详情', + taskDetailPrivate: false, deletedAt: null, }, select: { diff --git a/app/api/materials/[id]/assets/route.ts b/app/api/materials/[id]/assets/route.ts index 5f921d7..140a9a4 100644 --- a/app/api/materials/[id]/assets/route.ts +++ b/app/api/materials/[id]/assets/route.ts @@ -21,6 +21,7 @@ import { type MaterialAuthorizationUser, } from '@/lib/material-authorization'; import { isProductPackageType } from '@/lib/material-type-mapper'; +import { canAccessMaterialPrivacy, isPrivateTaskDetail, TASK_DETAIL_PRIVACY_SELECT, type TaskDetailPrivacyMaterial } from '@/lib/task-detail-privacy'; type AssetRouteSessionUser = MaterialAuthorizationUser & { department?: string | null; @@ -29,8 +30,9 @@ type AssetRouteSessionUser = MaterialAuthorizationUser & { async function canViewMaterial( user: AssetRouteSessionUser, - material: { userId: string; materialType: string }, + material: { userId: string; materialType: string } & TaskDetailPrivacyMaterial, ) { + if (isPrivateTaskDetail(material)) return canAccessMaterialPrivacy(material, user.id); if (material.userId === user.id) { return true; } @@ -69,6 +71,7 @@ export async function GET( id: true, userId: true, materialType: true, + ...TASK_DETAIL_PRIVACY_SELECT, previewUrl: true, zipFilePath: true, zipFileName: true, @@ -97,7 +100,7 @@ export async function GET( return NextResponse.json({ success: true, data: assets, - }); + }, { headers: { 'Cache-Control': 'private, no-store' } }); } catch (error) { console.error('[materials/assets] GET error:', error); return NextResponse.json( diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index bc3e2f4..45e0e34 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -1,3 +1,4 @@ +import { canAccessMaterialPrivacy, isPrivateTaskDetail, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy'; import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import path from 'path'; @@ -172,6 +173,7 @@ export async function GET( materialType: true, isAiGenerated: true, taskDetailSourceType: true, + ...TASK_DETAIL_PRIVACY_SELECT, taskDetailRequesterUserId: true, taskDetailRequesterName: true, renderer: true, @@ -244,6 +246,7 @@ export async function GET( materialType: true, isAiGenerated: true, taskDetailSourceType: true, + ...TASK_DETAIL_PRIVACY_SELECT, taskDetailRequesterUserId: true, taskDetailRequesterName: true, renderer: true, @@ -303,6 +306,9 @@ export async function GET( if (!material) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }); } + if (!(await canAccessMaterialPrivacy(material as any, (session.user as any).id))) { + return NextResponse.json({ error: '无权限查看该私密素材' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' } }); + } let canAccessSource = false; const videoPreviewPolicy = getVideoPreviewPolicyFields( @@ -382,11 +388,13 @@ export async function GET( taskDetailSourceType: materialWithUser.taskDetailSourceType || null, taskDetailRequesterUserId: materialWithUser.taskDetailRequesterUserId || null, taskDetailRequesterName: materialWithUser.taskDetailRequesterName || null, + taskDetailDemandType: materialWithUser.taskDetailDemandType || null, + taskDetailPrivate: materialWithUser.taskDetailPrivate === true, canEdit: true, }); } - if (!userIsSuperAdmin) { + if (!userIsSuperAdmin && !isPrivateTaskDetail(material as any)) { const canView = await canUserViewMaterial( userDepartment, userRole, @@ -415,7 +423,7 @@ export async function GET( select: { canDownload: true }, }); if (userDownloadSetting?.canDownload) { - const canDownloadMaterial = await canUserDownloadMaterial( + const canDownloadMaterial = isPrivateTaskDetail(material as any) || await canUserDownloadMaterial( userDepartment, userRole, managedDepartment, @@ -456,6 +464,8 @@ export async function GET( taskDetailSourceType: materialWithUser.taskDetailSourceType || null, taskDetailRequesterUserId: materialWithUser.taskDetailRequesterUserId || null, taskDetailRequesterName: materialWithUser.taskDetailRequesterName || null, + taskDetailDemandType: materialWithUser.taskDetailDemandType || null, + taskDetailPrivate: materialWithUser.taskDetailPrivate === true, renderer: materialWithUser.renderer, authorName: displayAuthorName, // 使用处理后的作者名 previewUrl: materialWithUser.previewUrl, @@ -531,7 +541,7 @@ export async function GET( }), }; - return NextResponse.json(rewrittenResponse); + return NextResponse.json(rewrittenResponse, { headers: { 'Cache-Control': 'private, no-store' } }); } catch (error) { console.error('获取素材失败:', error); return NextResponse.json({ error: '获取素材失败' }, { status: 500 }); diff --git a/app/api/materials/[id]/skus/route.ts b/app/api/materials/[id]/skus/route.ts index 12f77da..ec7ef9c 100644 --- a/app/api/materials/[id]/skus/route.ts +++ b/app/api/materials/[id]/skus/route.ts @@ -8,6 +8,7 @@ import { rewriteMediaUrlForClient } from '@/lib/url-rewrite' import { createMaterialLog } from '@/lib/material-log' import { resolveWhiteBackgroundPreviewFileUrl } from '@/lib/white-background-preview' import { canManageSkuMaterial } from './authorization' +import { canAccessMaterialPrivacy, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy' import { countWhiteBackgroundAssets, countWhiteBackgroundFixedViews, @@ -57,12 +58,16 @@ export async function GET( const material = await prisma.material.findUnique({ where: { id: materialId, deletedAt: null }, - select: { id: true, materialType: true, userId: true }, + select: { id: true, materialType: true, userId: true, ...TASK_DETAIL_PRIVACY_SELECT }, }) if (!material) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }) } + const session = await getServerSession(authOptions) + if (!(await canAccessMaterialPrivacy(material, (session?.user as any)?.id))) { + return NextResponse.json({ error: '无权限访问该私密素材' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' } }) + } if (validateSkuName !== null) { const normalizedSkuName = normalizeSkuName(validateSkuName) diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts index 5795d72..b1ff4cf 100644 --- a/app/api/materials/download/route.ts +++ b/app/api/materials/download/route.ts @@ -27,6 +27,7 @@ import { userHasVideoEarlyAccess, } from '@/lib/video-download-policy'; import { nodeReadableToWebStream } from '@/lib/node-readable-stream'; +import { canAccessMaterialPrivacy, isPrivateTaskDetail, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy'; function normalizeStoredLocalPath(filePath: string) { return String(filePath || '').replace(/\\/g, '/'); @@ -87,6 +88,7 @@ export async function GET(request: NextRequest) { sceneName: true, productCategoryName: true, materialType: true, + ...TASK_DETAIL_PRIVACY_SELECT, downloadCount: true, createdAt: true, }, @@ -98,6 +100,9 @@ export async function GET(request: NextRequest) { { status: 404 } ); } + if (!(await canAccessMaterialPrivacy(material, userId))) { + return NextResponse.json(createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载该私密素材'), { status: 403, headers: { 'Cache-Control': 'private, no-store' } }); + } const videoDownloadAvailability = getVideoDownloadAvailability( material.materialType, @@ -122,7 +127,7 @@ export async function GET(request: NextRequest) { // 超级管理员不受限制 const { isSuperAdmin } = await import('@/lib/department-permission'); if (!(await isSuperAdmin(userId))) { - const canDownload = await canUserDownloadMaterial( + const canDownload = isPrivateTaskDetail(material) || await canUserDownloadMaterial( userDepartment, userRole, managedDepartment, diff --git a/app/api/materials/favorite/route.ts b/app/api/materials/favorite/route.ts index 1065e46..63b0bc4 100644 --- a/app/api/materials/favorite/route.ts +++ b/app/api/materials/favorite/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { canAccessMaterialPrivacy } from '@/lib/task-detail-privacy'; // POST - 收藏/取消收藏 export async function POST(request: NextRequest) { @@ -28,6 +29,9 @@ export async function POST(request: NextRequest) { if (!material) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }); } + if (!(await canAccessMaterialPrivacy(material, userId))) { + return NextResponse.json({ error: '无权限访问该私密素材' }, { status: 403 }); + } // 检查是否已收藏 const existingFavorite = await prisma.materialFavorite.findFirst({ diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 9d2e911..d5936a1 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -1,3 +1,4 @@ +import { canAccessMaterialPrivacy, isPrivateTaskDetail, 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'; @@ -114,6 +115,7 @@ export async function GET(request: NextRequest) { materialType: true, isAiGenerated: true, taskDetailSourceType: true, + ...TASK_DETAIL_PRIVACY_SELECT, taskDetailRequesterUserId: true, taskDetailRequesterName: true, renderer: true, @@ -163,6 +165,9 @@ export async function GET(request: NextRequest) { if (!material) { return NextResponse.json({ material: null }); } + if (!(await canAccessMaterialPrivacy(material, (session.user as any).id))) { + return NextResponse.json({ error: '无权限查看该私密素材' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' } }); + } // 权限检查:分享页使用 /api/materials/share?id=...,普通素材接口不接受 share 参数绕过权限。 const userRole = (session.user as any).role; @@ -172,7 +177,7 @@ export async function GET(request: NextRequest) { // 超级管理员不受限制 const { isSuperAdmin, canUserViewMaterial } = await import('@/lib/department-permission'); - if (!(await isSuperAdmin(userId))) { + if (!isPrivateTaskDetail(material) && !(await isSuperAdmin(userId))) { const canView = await canUserViewMaterial( userDepartment, userRole, @@ -272,7 +277,7 @@ export async function GET(request: NextRequest) { assets: formattedAssets, }); - return NextResponse.json({ material: normalizedMaterial }); + return NextResponse.json({ material: normalizedMaterial }, { headers: { 'Cache-Control': 'private, no-store' } }); } // 列表查询 @@ -353,6 +358,7 @@ export async function GET(request: NextRequest) { materialType: true, isAiGenerated: true, taskDetailSourceType: true, + ...TASK_DETAIL_PRIVACY_SELECT, taskDetailRequesterUserId: true, taskDetailRequesterName: true, renderer: true, @@ -435,6 +441,7 @@ export async function GET(request: NextRequest) { materialType: true, isAiGenerated: true, taskDetailSourceType: true, + ...TASK_DETAIL_PRIVACY_SELECT, taskDetailRequesterUserId: true, taskDetailRequesterName: true, renderer: true, @@ -499,6 +506,7 @@ export async function GET(request: NextRequest) { materialType: true, isAiGenerated: true, taskDetailSourceType: true, + ...TASK_DETAIL_PRIVACY_SELECT, taskDetailRequesterUserId: true, taskDetailRequesterName: true, renderer: true, @@ -569,6 +577,7 @@ export async function GET(request: NextRequest) { materialType: true, isAiGenerated: true, taskDetailSourceType: true, + ...TASK_DETAIL_PRIVACY_SELECT, taskDetailRequesterUserId: true, taskDetailRequesterName: true, renderer: true, @@ -859,7 +868,7 @@ export async function GET(request: NextRequest) { totalPages, } : {}), - }); + }, { headers: { 'Cache-Control': 'private, no-store' } }); } catch (error) { if (error instanceof MaterialQueryError) { return NextResponse.json( diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 8555afa..695ee84 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -1,3 +1,4 @@ +import { canAccessMaterialPrivacy, isPrivateTaskDetail, 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'; @@ -131,6 +132,7 @@ export async function GET(request: NextRequest) { materialType: true, isAiGenerated: true, taskDetailSourceType: true, + ...TASK_DETAIL_PRIVACY_SELECT, authorName: true, zipFileName: true, zipFilePath: true, @@ -156,6 +158,9 @@ export async function GET(request: NextRequest) { if (!material) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }); } + if (!(await canAccessMaterialPrivacy(material as any, userId))) { + return NextResponse.json({ error: '请登录有权限的账号查看该私密素材' }, { status: userId ? 403 : 401, headers: { 'Cache-Control': 'private, no-store' } }); + } const videoPreviewPolicy = getVideoPreviewPolicyFields( (material as any).materialType, @@ -289,7 +294,7 @@ export async function GET(request: NextRequest) { if (hasSystemAdminPermission(currentUser.role, Boolean(currentUser.isSuperAdmin))) { canAccessSource = true; } else if (currentUser.canDownload) { - const canDownloadMaterial = await canUserDownloadMaterial( + const canDownloadMaterial = isPrivateTaskDetail(material as any) || await canUserDownloadMaterial( currentUser.department, currentUser.role, currentUser.managedDepartment, @@ -349,6 +354,8 @@ export async function GET(request: NextRequest) { reportTitle: (material as any).reportTitle || null, isAiGenerated: Boolean((material as any).isAiGenerated), taskDetailSourceType: (material as any).taskDetailSourceType || null, + taskDetailDemandType: (material as any).taskDetailDemandType || null, + taskDetailPrivate: isPrivateTaskDetail(material as any), zipFileSize: material.zipFileSize.toString(), displayImages: parseDisplayImages(material.displayImages || null), isLiked, @@ -400,7 +407,7 @@ export async function GET(request: NextRequest) { }), }; - return NextResponse.json(rewrittenResponse); + return NextResponse.json(rewrittenResponse, { headers: { 'Cache-Control': 'private, no-store' } }); } catch (error) { console.error('Error fetching material for share:', error); return NextResponse.json({ error: '获取素材失败' }, { status: 500 }); diff --git a/app/api/materials/stats/route.ts b/app/api/materials/stats/route.ts index 2b2c142..be33fe0 100644 --- a/app/api/materials/stats/route.ts +++ b/app/api/materials/stats/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { buildMaterialPrivacyWhere } from '@/lib/task-detail-privacy'; // GET - 获取素材统计数据(只统计未删除的素材) export async function GET() { @@ -10,6 +11,7 @@ export async function GET() { if (!session?.user) { return NextResponse.json({ error: '未登录' }, { status: 401 }); } + const privacyWhere = await buildMaterialPrivacyWhere((session.user as any).id); // 获取总数(只统计未删除的素材) let total: number; @@ -19,6 +21,7 @@ export async function GET() { total = await prisma.material.count({ where: { deletedAt: null, + AND: [privacyWhere], } as any, }); @@ -26,6 +29,7 @@ export async function GET() { materials = await prisma.material.findMany({ where: { deletedAt: null, + AND: [privacyWhere], } as any, select: { createdAt: true, @@ -38,8 +42,9 @@ export async function GET() { // 兼容旧版本数据库(没有deletedAt字段) if (e?.meta?.column?.includes('deletedAt')) { console.warn('[Materials Stats] deletedAt field not found, using all materials'); - total = await prisma.material.count(); + total = await prisma.material.count({ where: privacyWhere }); materials = await prisma.material.findMany({ + where: privacyWhere, select: { createdAt: true, }, @@ -76,7 +81,7 @@ export async function GET() { return NextResponse.json({ total, months, - }); + }, { headers: { 'Cache-Control': 'private, no-store' } }); } catch (error) { console.error('Error fetching material stats:', error); return NextResponse.json( diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts index aa677ce..b994c31 100644 --- a/app/api/materials/upload/product-package/complete/route.ts +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -15,6 +15,7 @@ import { getLatestTaskDetailUploadJobStatus, TaskDetailUploadQueueCapacityError, } from '@/lib/task-detail-upload-jobs' +import { normalizeTaskDetailPrivacyFields } from '@/lib/task-detail-privacy-fields' import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type' export const runtime = 'nodejs' @@ -62,6 +63,8 @@ export async function POST(request: NextRequest) { materialType: true, taskDetailRequesterUserId: true, taskDetailSourceType: true, + taskDetailDemandType: true, + taskDetailPrivate: true, zipFilePath: true, _count: { select: { assets: true } }, }, @@ -102,6 +105,21 @@ export async function POST(request: NextRequest) { ) } + try { + normalizeTaskDetailPrivacyFields({ + taskDetailDemandType: material.taskDetailDemandType, + taskDetailPrivate: material.taskDetailPrivate, + }) + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : '任务详情上传选项不合法', + }, + { status: 400 }, + ) + } + // 先恢复已有任务的状态,再检查素材是否已有持久化结果。首次 202 响应若在 // 网络中丢失,后台可能已经完成;此时重试必须返回原 job,而不是误报 409。 const existingJob = await getLatestTaskDetailUploadJobStatus( diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts index 311ba82..feaa048 100644 --- a/app/api/materials/upload/product-package/init/route.ts +++ b/app/api/materials/upload/product-package/init/route.ts @@ -9,6 +9,7 @@ import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { validateTaskNo, isTaskNoExists } from '@/lib/product-package'; import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'; +import { normalizeTaskDetailPrivacyFields } from '@/lib/task-detail-privacy-fields'; import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query'; @@ -36,8 +37,22 @@ export async function POST(request: NextRequest) { const rawTaskDetailSourceType = body?.taskDetailSourceType; const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType); const taskDetailRequesterUserId = String(body?.taskDetailRequesterUserId || '').trim(); + let taskDetailDemandType: '新品' | '优化' = '新品'; + let taskDetailPrivate = false; const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); + try { + ({ + taskDetailDemandType, + taskDetailPrivate, + } = normalizeTaskDetailPrivacyFields(body)); + } catch (error) { + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : '任务详情上传选项不合法' }, + { status: 400 } + ); + } + if (dingtalkApprovalNoError) { return NextResponse.json( { success: false, error: dingtalkApprovalNoError }, @@ -147,6 +162,8 @@ export async function POST(request: NextRequest) { renderer: '', // 任务-详情不需要渲染器 isFullSet: false, taskDetailSourceType, + taskDetailDemandType, + taskDetailPrivate, taskDetailRequesterUserId: taskDetailRequester.id, taskDetailRequesterName: taskDetailRequester.realName || taskDetailRequester.username, ...INCOMPLETE_SHELL_CREATE_DATA, diff --git a/app/api/organization/material-permissions/route.test.ts b/app/api/organization/material-permissions/route.test.ts new file mode 100644 index 0000000..c838804 --- /dev/null +++ b/app/api/organization/material-permissions/route.test.ts @@ -0,0 +1,87 @@ +/** @jest-environment node */ + +import { getServerSession } from 'next-auth' +import { isSuperAdmin } from '@/lib/department-permission' +import { prisma } from '@/lib/prisma' + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + organizationUnit: { + findMany: jest.fn(), + }, + departmentMaterialPermission: { + findMany: jest.fn(), + }, + userEmployeeBinding: { + findMany: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/department-permission', () => ({ + getAllMaterialTypes: jest.fn(async () => ['任务-详情']), + isSuperAdmin: jest.fn(), +})) + +jest.mock('@/lib/material-type-options', () => ({ + filterSelectableMaterialTypes: jest.fn((types: string[]) => types), +})) + +const { GET } = require('@/app/api/organization/material-permissions/route') as typeof import('@/app/api/organization/material-permissions/route') + +describe('/api/organization/material-permissions GET', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'super-admin' } }) + ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) + ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([ + { + id: 'brand-center', + name: '品牌中心', + path: '江苏简尚品牌管理有限公司/品牌中心', + level: 2, + parentId: null, + }, + ]) + ;(prisma.departmentMaterialPermission.findMany as jest.Mock).mockResolvedValue([ + { + department: '江苏简尚品牌管理有限公司/品牌中心', + materialType: '任务-详情', + canView: true, + canDownload: true, + canDownloadSource: true, + canShare: true, + }, + ]) + ;(prisma.userEmployeeBinding.findMany as jest.Mock).mockResolvedValue([ + { + employeeProfile: { + departmentPath: '江苏简尚品牌管理有限公司/品牌中心', + }, + }, + ]) + }) + + test('旧部门权限 GET 默认返回 canAccessPrivate false', async () => { + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.permissions['江苏简尚品牌管理有限公司/品牌中心']['任务-详情']).toMatchObject({ + canView: true, + canDownload: true, + canDownloadSource: true, + canShare: true, + canEditAssets: false, + canAccessPrivate: false, + }) + }) +}) diff --git a/app/api/organization/material-permissions/route.ts b/app/api/organization/material-permissions/route.ts index c91a837..d3055ce 100644 --- a/app/api/organization/material-permissions/route.ts +++ b/app/api/organization/material-permissions/route.ts @@ -26,6 +26,7 @@ const emptyFlags = (): MaterialPermissionFlags => ({ canDownloadSource: false, canShare: true, canEditAssets: false, + canAccessPrivate: false, }) async function requireSuperAdmin() { @@ -75,6 +76,7 @@ export async function GET() { canDownloadSource: row.canDownloadSource, canShare: row.canShare, canEditAssets: false, + canAccessPrivate: false, } stored.set(row.department, current) } diff --git a/app/api/organization/permission-groups/route.test.ts b/app/api/organization/permission-groups/route.test.ts index 522eb94..e1e95b8 100644 --- a/app/api/organization/permission-groups/route.test.ts +++ b/app/api/organization/permission-groups/route.test.ts @@ -52,7 +52,7 @@ describe('/api/organization/permission-groups 保存权限', () => { }) }) - test('任务详情编辑图片勾选值应原样保存,不能回退为 false', async () => { + test('任务详情私密权限仅任务详情可保存 true,其他类型会被后端压回 false', async () => { const response = await PUT({ json: async () => ({ action: 'save-pack', @@ -64,6 +64,14 @@ describe('/api/organization/permission-groups 保存权限', () => { canDownload: true, canDownloadSource: true, canEditAssets: true, + canAccessPrivate: true, + }, + { + materialType: '3D场景', + canView: true, + canDownload: true, + canDownloadSource: false, + canAccessPrivate: true, }, ], }), @@ -74,6 +82,12 @@ describe('/api/organization/permission-groups 保存权限', () => { expect.objectContaining({ materialType: '任务-详情', canEditAssets: true, + canAccessPrivate: true, + }), + expect.objectContaining({ + materialType: '3D场景', + canEditAssets: false, + canAccessPrivate: false, }), ]) }) diff --git a/app/api/organization/permission-groups/route.ts b/app/api/organization/permission-groups/route.ts index 6a9679d..d084310 100644 --- a/app/api/organization/permission-groups/route.ts +++ b/app/api/organization/permission-groups/route.ts @@ -10,6 +10,7 @@ import { selectVisibleOrganizationPeople } from '@/lib/auto-personnel-rules' import { collectOccupiedOrganizationPaths, lastOrganizationPathSegment, + normalizeTaskDetailOnlyFlags, normalizeOrganizationPath, pruneOrganizationTree, resolveEmployeeOrganizationPaths, @@ -78,6 +79,7 @@ export async function GET() { canDownloadSource: true, canShare: true, canEditAssets: true, + canAccessPrivate: true, }, }, inheritors: { @@ -260,20 +262,25 @@ export async function PUT(request: NextRequest) { canDownloadSource?: boolean canShare?: boolean canEditAssets?: boolean + canAccessPrivate?: boolean }> = Array.isArray(body?.permissions) ? body.permissions : [] if (incoming.length === 0) { return NextResponse.json({ error: '素材类型列表不能为空' }, { status: 400 }) } await savePermissionPackItems( slug, - incoming.map((item) => ({ - materialType: item.materialType, - canView: Boolean(item.canView), - canDownload: Boolean(item.canDownload), - canDownloadSource: Boolean(item.canDownloadSource), - canShare: true, - canEditAssets: item.canEditAssets === true, - })), + incoming.map((item) => { + const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item) + return { + materialType: item.materialType, + canView: Boolean(item.canView), + canDownload: Boolean(item.canDownload), + canDownloadSource: Boolean(item.canDownloadSource), + canShare: true, + canEditAssets: taskDetailOnlyFlags.canEditAssets, + canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate, + } + }), ) return NextResponse.json({ ok: true }) } diff --git a/app/api/organization/user-permission-inherits/route.test.ts b/app/api/organization/user-permission-inherits/route.test.ts new file mode 100644 index 0000000..d3643a9 --- /dev/null +++ b/app/api/organization/user-permission-inherits/route.test.ts @@ -0,0 +1,83 @@ +/** @jest-environment node */ + +import { getServerSession } from 'next-auth' +import { isSuperAdmin } from '@/lib/department-permission' +import { savePermissionPackItems } from '@/lib/user-permission-inherit' + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + findUnique: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/department-permission', () => ({ + getAllMaterialTypes: jest.fn(), + isSuperAdmin: jest.fn(), +})) + +jest.mock('@/lib/user-permission-inherit', () => ({ + ensureDefaultPermissionPacks: jest.fn(), + packItemsToMap: jest.fn(), + savePermissionPackItems: jest.fn(), +})) + +const { PUT } = require('@/app/api/organization/user-permission-inherits/route') as typeof import('@/app/api/organization/user-permission-inherits/route') + +describe('/api/organization/user-permission-inherits 保存权限包', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'super-admin' } }) + ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) + }) + + test('私密任务详情权限仅任务详情可保存 true,其他类型强制回落为 false', async () => { + const response = await PUT({ + json: async () => ({ + action: 'save-pack', + slug: 'visual', + permissions: [ + { + materialType: '任务-详情', + canView: true, + canDownload: true, + canDownloadSource: true, + canEditAssets: true, + canAccessPrivate: true, + }, + { + materialType: '3D场景', + canView: true, + canDownload: true, + canDownloadSource: false, + canEditAssets: true, + canAccessPrivate: true, + }, + ], + }), + } as never) + + expect(response.status).toBe(200) + expect(savePermissionPackItems).toHaveBeenCalledWith('visual', [ + expect.objectContaining({ + materialType: '任务-详情', + canEditAssets: true, + canAccessPrivate: true, + }), + expect.objectContaining({ + materialType: '3D场景', + canEditAssets: false, + canAccessPrivate: false, + }), + ]) + }) +}) diff --git a/app/api/organization/user-permission-inherits/route.ts b/app/api/organization/user-permission-inherits/route.ts index 28650b8..c574afc 100644 --- a/app/api/organization/user-permission-inherits/route.ts +++ b/app/api/organization/user-permission-inherits/route.ts @@ -11,6 +11,7 @@ import { } from '@/lib/user-permission-inherit' import { filterSelectableMaterialTypes } from '@/lib/material-type-options' import { getAllMaterialTypes } from '@/lib/department-permission' +import { normalizeTaskDetailOnlyFlags } from '@/lib/organization-material-permissions' async function requireSuperAdmin() { const session = await getServerSession(authOptions) @@ -41,6 +42,7 @@ export async function GET() { canDownloadSource: true, canShare: true, canEditAssets: true, + canAccessPrivate: true, }, }, }, @@ -115,6 +117,7 @@ export async function PUT(request: NextRequest) { canDownloadSource?: boolean canShare?: boolean canEditAssets?: boolean + canAccessPrivate?: boolean }> = Array.isArray(body?.permissions) ? body.permissions : [] if (!slug) return NextResponse.json({ error: '缺少套餐' }, { status: 400 }) if (incoming.length === 0) { @@ -122,14 +125,18 @@ export async function PUT(request: NextRequest) { } await savePermissionPackItems( slug, - incoming.map((item) => ({ - materialType: item.materialType, - canView: Boolean(item.canView), - canDownload: Boolean(item.canDownload), - canDownloadSource: Boolean(item.canDownloadSource), - canShare: item.canShare !== false, - canEditAssets: item.canEditAssets === true, - })), + incoming.map((item) => { + const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item) + return { + materialType: item.materialType, + canView: Boolean(item.canView), + canDownload: Boolean(item.canDownload), + canDownloadSource: Boolean(item.canDownloadSource), + canShare: item.canShare !== false, + canEditAssets: taskDetailOnlyFlags.canEditAssets, + canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate, + } + }), ) return NextResponse.json({ ok: true }) } diff --git a/app/api/test-download/route.ts b/app/api/test-download/route.ts index fa9dde1..abf00f1 100644 --- a/app/api/test-download/route.ts +++ b/app/api/test-download/route.ts @@ -29,7 +29,7 @@ export async function GET(request: NextRequest) { try { const material = await prisma.material.findUnique({ - where: { id: materialId }, + where: { id: materialId, taskDetailPrivate: false }, select: { id: true, productModel: true, diff --git a/app/api/uploads/[...path]/route.ts b/app/api/uploads/[...path]/route.ts index 9af7f57..467e06b 100644 --- a/app/api/uploads/[...path]/route.ts +++ b/app/api/uploads/[...path]/route.ts @@ -15,13 +15,14 @@ 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, isPrivateTaskDetail, TASK_DETAIL_PRIVACY_SELECT, type TaskDetailPrivacyMaterial } from '@/lib/task-detail-privacy' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' type FileAccessKind = 'preview' | 'source' -interface FileMaterial { +interface FileMaterial extends TaskDetailPrivacyMaterial { id: string materialType: string } @@ -88,6 +89,7 @@ async function resolveFileAccess( select: { id: true, materialType: true, + ...TASK_DETAIL_PRIVACY_SELECT, previewUrl: true, zipFileName: true, zipFilePath: true, @@ -111,6 +113,7 @@ async function resolveFileAccess( select: { id: true, materialType: true, + ...TASK_DETAIL_PRIVACY_SELECT, }, }, }, @@ -191,7 +194,7 @@ async function authorizeSourceAccess( ) } - const canDownload = await canUserDownloadMaterial( + const canDownload = isPrivateTaskDetail(material) || await canUserDownloadMaterial( user.department, user.role, user.managedDepartment, @@ -252,6 +255,15 @@ async function handleFileRequest( if (!access) { return new Response(headOnly ? null : 'Not Found', { status: 404 }) } + if (isPrivateTaskDetail(access.material)) { + const session = await getServerSession(authOptions) + 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' }, + }) + } + } if (access.kind === 'source') { const deniedResponse = await authorizeSourceAccess(request, access.material) @@ -301,7 +313,7 @@ async function handleFileRequest( const contentType = mimeTypeLookup(path.extname(filePath)) || 'application/octet-stream' const cacheControl = - access.kind === 'source' ? 'private, no-store' : 'public, max-age=31536000, immutable' + access.kind === 'source' || isPrivateTaskDetail(access.material) ? 'private, no-store' : 'public, max-age=31536000, immutable' if (headOnly) { return new Response(null, { diff --git a/app/api/user/favorites/route.ts b/app/api/user/favorites/route.ts index 5517dfe..6354f76 100644 --- a/app/api/user/favorites/route.ts +++ b/app/api/user/favorites/route.ts @@ -1,3 +1,4 @@ +import { buildMaterialPrivacyWhere } from '@/lib/task-detail-privacy'; import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; @@ -46,6 +47,7 @@ export async function GET(request: NextRequest) { const favorites = await prisma.materialFavorite.findMany({ where: { userId, + material: await buildMaterialPrivacyWhere(userId), }, include: { material: { @@ -119,7 +121,7 @@ export async function GET(request: NextRequest) { }); } - return NextResponse.json(materials); + return NextResponse.json(materials, { headers: { 'Cache-Control': 'private, no-store' } }); } catch (error: any) { // 兼容旧版本数据库(没有material_favorites表) if (error?.meta?.table?.includes('material_favorites')) { diff --git a/app/api/user/materials/route.ts b/app/api/user/materials/route.ts index 30ec61e..0e3a21e 100644 --- a/app/api/user/materials/route.ts +++ b/app/api/user/materials/route.ts @@ -1,3 +1,4 @@ +import { buildMaterialPrivacyWhere } from '@/lib/task-detail-privacy'; import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; @@ -42,6 +43,7 @@ export async function GET(request: NextRequest) { const materials = await prisma.material.findMany({ where: { userId, + AND: [await buildMaterialPrivacyWhere(userId)], deletedAt: null, }, select: { @@ -88,7 +90,7 @@ export async function GET(request: NextRequest) { return rewriteMaterialStorageUrlsForClient(normalizeMaterial(formatted), request); }); - return NextResponse.json(formattedMaterials); + return NextResponse.json(formattedMaterials, { headers: { 'Cache-Control': 'private, no-store' } }); } catch (error: any) { // 兼容旧版本数据库(没有deletedAt字段) if (error?.meta?.column?.includes('deletedAt')) { @@ -104,6 +106,7 @@ export async function GET(request: NextRequest) { const materials = await prisma.material.findMany({ where: { userId, + AND: [await buildMaterialPrivacyWhere(userId)], }, select: { id: true, @@ -152,7 +155,7 @@ export async function GET(request: NextRequest) { return normalizeMaterial(formatted); }); - return NextResponse.json(formattedMaterials); + return NextResponse.json(formattedMaterials, { headers: { 'Cache-Control': 'private, no-store' } }); } catch (fallbackError) { console.error('[UserMaterials] Fallback query error:', fallbackError); return NextResponse.json( diff --git a/components/department-dashboard/PermissionGroupsDashboard.tsx b/components/department-dashboard/PermissionGroupsDashboard.tsx index 0be7455..e3bcb27 100644 --- a/components/department-dashboard/PermissionGroupsDashboard.tsx +++ b/components/department-dashboard/PermissionGroupsDashboard.tsx @@ -55,6 +55,7 @@ const FLAG_COLUMNS: Array<{ key: Exclude ({ @@ -63,6 +64,7 @@ const emptyFlags = (): MaterialPermissionFlags => ({ canDownloadSource: false, canShare: true, canEditAssets: false, + canAccessPrivate: false, }) function flattenTree(nodes: OrgNode[]): OrgNode[] { @@ -647,7 +649,8 @@ export default function PermissionGroupsDashboard() { - +
+
@@ -665,15 +668,20 @@ export default function PermissionGroupsDashboard() { {FLAG_COLUMNS.map((column) => { - // 编辑图片能力只对任务-详情类型有意义,其余行灰显禁用 - const editDisabled = column.key === 'canEditAssets' && materialType !== '任务-详情' + const taskDetailOnlyDisabled = + (column.key === 'canEditAssets' || column.key === 'canAccessPrivate') && + materialType !== '任务-详情' + const disabledTitle = + column.key === 'canAccessPrivate' + ? '仅任务-详情支持私密素材查看/下载' + : '仅任务-详情支持单图编辑' return (
素材类型
{materialType} updateFlag(materialType, column.key, event.target.checked)} aria-label={`${materialType} ${column.label}`} /> @@ -685,6 +693,7 @@ export default function PermissionGroupsDashboard() { })}
+ )} diff --git a/components/detail/ProductPackageDetail.tsx b/components/detail/ProductPackageDetail.tsx index ff6dc68..03c2517 100644 --- a/components/detail/ProductPackageDetail.tsx +++ b/components/detail/ProductPackageDetail.tsx @@ -43,6 +43,8 @@ interface ProductPackageDetailProps { categoryName: string; sceneName?: string; taskDetailSourceType?: string | null; + taskDetailDemandType?: string | null; + taskDetailPrivate?: boolean; isFullSet?: boolean; taskStatus: string; taskFetchedAt?: string; @@ -501,6 +503,12 @@ export default function ProductPackageDetail({ {getTaskDetailSourceTypeLabel(material.taskDetailSourceType)}

+ {material.taskDetailDemandType && ( +
+

需求类型 / 可见范围

+

{material.taskDetailDemandType} · {material.taskDetailPrivate ? '私密' : '公开'}

+
+ )}

是否整套

diff --git a/components/upload/ProductPackageUploader.test.tsx b/components/upload/ProductPackageUploader.test.tsx index 3ed17bc..0b112b9 100644 --- a/components/upload/ProductPackageUploader.test.tsx +++ b/components/upload/ProductPackageUploader.test.tsx @@ -117,7 +117,7 @@ function thumbnailResult(sourceName: string) { } function installUploadFlowFetch(completeResponse?: Promise) { - const fetchMock = jest.fn((input: RequestInfo | URL) => { + const fetchMock = jest.fn((input: RequestInfo | URL, _init?: RequestInit) => { const url = String(input) if (url === '/api/users/list') return Promise.resolve(usersResponse()) if (url === '/api/materials/upload/product-package/init') { @@ -217,17 +217,73 @@ describe('ProductPackageUploader', () => { expect(screen.queryByText('empty.psd')).not.toBeInTheDocument() }) - test('应提供任务详情出图方式选择项,且默认为必填', async () => { + test('应提供任务详情需求类型、可见性和出图方式字段', async () => { render() await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员') + const demandTypeSelect = screen.getByLabelText(/需求类型/) as HTMLSelectElement + const visibilitySelect = screen.getByLabelText(/可见性/) as HTMLSelectElement const select = screen.getByLabelText(/出图方式/) as HTMLSelectElement + expect(demandTypeSelect.value).toBe('新品') + expect(visibilitySelect.value).toBe('public') + expect(screen.getByRole('option', { name: '私密' })).toBeDisabled() expect(select.value).toBe('') expect(select).toBeRequired() expect(screen.getByRole('option', { name: '请选择出图方式' })).toBeInTheDocument() expect(screen.getByRole('option', { name: '实拍' })).toBeInTheDocument() expect(screen.getByRole('option', { name: '渲染' })).toBeInTheDocument() expect(screen.getByRole('option', { name: 'AI' })).toBeInTheDocument() + expect(screen.getByText('新品固定公开,仅优化需求支持私密;若设为私密,仅所选需求运营及已授权权限组可访问,上传人不自动放行。')).toBeInTheDocument() + }) + + test('切回新品会强制恢复公开,并把公开状态发给 init', async () => { + const user = userEvent.setup() + mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError()) + const fetchMock = installUploadFlowFetch(Promise.resolve({ + ok: true, + json: async () => ({ data: { jobId: 'job-demand-type-reset' } }), + })) + + render() + await fillRequiredTaskFields( + user, + new File(['image'], 'demand-reset.jpg', { type: 'image/jpeg' }), + ) + + const demandTypeSelect = screen.getByLabelText(/需求类型/) as HTMLSelectElement + const visibilitySelect = screen.getByLabelText(/可见性/) as HTMLSelectElement + + await user.selectOptions(demandTypeSelect, '优化') + expect(screen.getByRole('option', { name: '私密' })).not.toBeDisabled() + expect(screen.getByText('私密仅所选需求运营及已授权权限组可访问,上传人不自动放行。')).toBeInTheDocument() + + await user.selectOptions(visibilitySelect, 'private') + expect(visibilitySelect.value).toBe('private') + + await user.selectOptions(demandTypeSelect, '新品') + expect(visibilitySelect.value).toBe('public') + + await user.click(screen.getByRole('button', { name: '提交' })) + + await waitFor(() => expect(fetchMock.mock.calls.some( + ([url]) => url === '/api/materials/upload/product-package/init', + )).toBe(true)) + + const initRequest = fetchMock.mock.calls.find( + (call): call is [RequestInfo | URL, RequestInit] => ( + call[0] === '/api/materials/upload/product-package/init' + && typeof call[1] !== 'undefined' + ), + ) + expect(initRequest).toBeDefined() + if (!initRequest) { + throw new Error('未捕获到 init 请求') + } + expect(initRequest[1].body).toBeDefined() + expect(JSON.parse(String(initRequest[1].body))).toMatchObject({ + taskDetailDemandType: '新品', + taskDetailPrivate: false, + }) }) test('应提供必填的需求运营搜索框并允许选择人员', async () => { diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index ccd10db..d5e4df3 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -13,6 +13,11 @@ import { TASK_DETAIL_SOURCE_TYPE_OPTIONS, type TaskDetailSourceType, } from '@/lib/task-detail-source-type'; +import { + normalizeTaskDetailPrivacyFields, + TASK_DETAIL_DEMAND_TYPE_OPTIONS, + type TaskDetailDemandType, +} from '@/lib/task-detail-privacy-fields'; import { hasDragLeftDropZone, handleDropZoneKeyDown, @@ -49,6 +54,7 @@ const TASK_DETAIL_SOURCE_FILE_LABEL = '.psd、.psb、.tif、.tiff、.zip、.rar const TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] as const; const TASK_DETAIL_BROWSER_IMAGE_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'; const TASK_DETAIL_SELECTION_THUMBNAIL_TIMEOUT_MS = 60_000; +const TASK_DETAIL_METADATA_FIELD_CLASS_NAME = 'w-full rounded-apple border border-gray-300 bg-white px-4 py-2 text-gray-900 outline-none focus:border-gray-900 focus:ring-2 focus:ring-gray-200 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500'; function splitEmptyFiles(files: FileList | File[] | null): { validFiles: File[]; @@ -367,6 +373,8 @@ export default function ProductPackageUploader({ const [platformName, setPlatformName] = useState(''); const [productCategoryName, setProductCategoryName] = useState(''); const [taskDetailSourceType, setTaskDetailSourceType] = useState(''); + const [taskDetailDemandType, setTaskDetailDemandType] = useState('新品'); + const [taskDetailPrivate, setTaskDetailPrivate] = useState(false); const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState(''); const [users, setUsers] = useState>([]); const [usersLoading, setUsersLoading] = useState(true); @@ -536,6 +544,25 @@ export default function ProductPackageUploader({ }); }, [updateAssetPreview]); + const isOptimizationDemand = taskDetailDemandType === '优化'; + const taskDetailVisibilityValue = taskDetailPrivate ? 'private' : 'public'; + const taskDetailPrivacyHint = isOptimizationDemand + ? '私密仅所选需求运营及已授权权限组可访问,上传人不自动放行。' + : '新品固定公开,仅优化需求支持私密;若设为私密,仅所选需求运营及已授权权限组可访问,上传人不自动放行。'; + + const handleTaskDetailDemandTypeChange = useCallback((value: TaskDetailDemandType) => { + setTaskDetailDemandType(value); + if (value !== '优化') { + setTaskDetailPrivate(false); + } + setError(''); + }, []); + + const handleTaskDetailVisibilityChange = useCallback((value: 'public' | 'private') => { + setTaskDetailPrivate(value === 'private' && taskDetailDemandType === '优化'); + setError(''); + }, [taskDetailDemandType]); + useEffect(() => { // React Strict Mode 在开发环境会执行一次 setup → cleanup → setup。 // 每次 setup 都要恢复挂载标记,避免后续进度和错误状态被误判为已卸载。 @@ -928,6 +955,17 @@ export default function ProductPackageUploader({ // 提交上传 const handleSubmit = async () => { + let taskDetailPrivacyFields: ReturnType; + try { + taskDetailPrivacyFields = normalizeTaskDetailPrivacyFields({ + taskDetailDemandType, + taskDetailPrivate, + }); + } catch (validationError) { + setError(validationError instanceof Error ? validationError.message : '任务详情上传选项不合法'); + return; + } + if (!taskDetailRequesterUserId) { setError('请选择需求运营人员'); return; @@ -1038,6 +1076,8 @@ export default function ProductPackageUploader({ platformName: platformName || undefined, productCategoryName: productCategoryName || undefined, taskDetailSourceType: taskDetailSourceType || undefined, + taskDetailDemandType: taskDetailPrivacyFields.taskDetailDemandType, + taskDetailPrivate: taskDetailPrivacyFields.taskDetailPrivate, taskDetailRequesterUserId, }), }); @@ -1401,27 +1441,73 @@ export default function ProductPackageUploader({ onAddScene={handleAddNewScene} /> -

- - +
+
+
+ + +
+ +
+ + +
+
+ +

+ {taskDetailPrivacyHint} +

+ +
+ + +

diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index ada1309..cf57f3c 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -2,7 +2,7 @@ ## 结论 -当前应用发布工具验证十六份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。 +当前应用发布工具验证十七份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。 目标 migration: @@ -22,8 +22,9 @@ - `20260828_add_pack_item_can_edit_assets` - `20260828_add_task_detail_change_approvals` - `20260829_add_recoverable_api_key_secret` +- `20260831_add_private_task_detail` -它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。`20260829_add_recoverable_api_key_secret` 只新增 `api_keys.secret_ciphertext` / `secret_nonce` / `secret_auth_tag` 三个可空加密列、`api_key_reveal_audits` 审计表和 `api_key_id+created_at`、`viewer_user_id+created_at` 两个非唯一 btree 索引,不回填旧 key。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。 +它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。`20260829_add_recoverable_api_key_secret` 只新增 `api_keys.secret_ciphertext` / `secret_nonce` / `secret_auth_tag` 三个可空加密列、`api_key_reveal_audits` 审计表和 `api_key_id+created_at`、`viewer_user_id+created_at` 两个非唯一 btree 索引,不回填旧 key。`20260831_add_private_task_detail` 只新增 `materials.task_detail_demand_type`、`materials.task_detail_private default false`、`material_permission_pack_items.can_access_private default false`,并加上 `materials_private_optimization_check`,确保私密任务详情只能落在“任务-详情 + 优化”语义下。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。 ## 强制前提 @@ -38,7 +39,7 @@ 1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator; 2. legacy bootstrap `Audit`; 3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留; -4. 在同一维护窗内运行本文的真实备份、隔离恢复、十六条目标 migration 和迁移后审计; +4. 在同一维护窗内运行本文的真实备份、隔离恢复、十七条目标 migration 和迁移后审计; 5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。 顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。 @@ -362,7 +363,7 @@ docker run --rm \ "$MIGRATOR_IMAGE" --apply ``` -工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对十六条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、字段类型与可空性、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。 +工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对十七条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、字段类型与可空性、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下;最后原子写审计 JSON。 apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写: @@ -378,7 +379,7 @@ test -n "$MIGRATION_AUDIT_SHA256" 只有以下全部成立,才进入应用首次安全上线步骤: - `applied.json` 的 `verified=true`; -- 十六条 target migration 均 `completed=true`,source/database checksum 一致; +- 十七条 target migration 均 `completed=true`,source/database checksum 一致; - `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空; - 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配; - 再次运行 `--audit` 仍通过; diff --git a/lib/__tests__/department-permission-roster.test.ts b/lib/__tests__/department-permission-roster.test.ts index c628da8..0f5c56e 100644 --- a/lib/__tests__/department-permission-roster.test.ts +++ b/lib/__tests__/department-permission-roster.test.ts @@ -2,7 +2,7 @@ import { canUserViewMaterial, getUserViewableMaterialTypes } from '@/lib/department-permission' import { prisma } from '@/lib/prisma' -import { canUserEditTaskDetailAssets } from '@/lib/user-permission-inherit' +import { canUserAccessPrivateTaskDetail, canUserEditTaskDetailAssets } from '@/lib/user-permission-inherit' jest.mock('@/lib/prisma', () => ({ prisma: { @@ -173,6 +173,54 @@ describe('roster material visibility', () => { ) }) + test('部门权限组勾选任务详情私密查看后,运行时只认任务详情这条授权', async () => { + ;(prisma.userEmployeeBinding.findFirst as jest.Mock).mockResolvedValue({ + employeeProfile: { + departmentPath: '江苏简尚品牌管理有限公司/品牌中心/空间视觉部', + }, + }) + ;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([ + { + departmentPath: '江苏简尚品牌管理有限公司/品牌中心', + pack: { + items: [ + { + materialType: '任务-详情', + canView: true, + canDownload: true, + canDownloadSource: true, + canShare: true, + canAccessPrivate: true, + }, + { + materialType: '3D场景', + canView: true, + canDownload: true, + canDownloadSource: false, + canShare: true, + canAccessPrivate: true, + }, + ], + }, + }, + ]) + + await expect(canUserAccessPrivateTaskDetail('visual-department-member')).resolves.toBe(true) + expect(prisma.materialPermissionPackDepartment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.objectContaining({ + pack: { + select: { + items: { + select: expect.objectContaining({ canAccessPrivate: true }), + }, + }, + }, + }), + }), + ) + }) + test('人进多组时,各组能看的取并集', async () => { ;(prisma.userEmployeeBinding.findFirst as jest.Mock).mockResolvedValue({ employeeProfile: { diff --git a/lib/__tests__/dingtalk-material-match-route.test.ts b/lib/__tests__/dingtalk-material-match-route.test.ts index 74a2b6a..3780c9e 100644 --- a/lib/__tests__/dingtalk-material-match-route.test.ts +++ b/lib/__tests__/dingtalk-material-match-route.test.ts @@ -160,6 +160,7 @@ describe('external dingtalk-material-match route', () => { where: { deletedAt: null, productModel: 'SPU002', + taskDetailPrivate: false, }, }), ) diff --git a/lib/__tests__/employee-manager.test.ts b/lib/__tests__/employee-manager.test.ts new file mode 100644 index 0000000..3e731cd --- /dev/null +++ b/lib/__tests__/employee-manager.test.ts @@ -0,0 +1,44 @@ +/** @jest-environment node */ +import { prisma } from '@/lib/prisma' +import { resolveEmployeeDirectManagerProfile } from '@/lib/employee-manager' + +jest.mock('@/lib/prisma', () => ({ prisma: { + performanceSurveyEmployeeProfile: { findMany: jest.fn() }, + performanceSurveyEmployeeDepartmentMembership: { findMany: jest.fn() }, +} })) +const subject = { employeeProfileId: 'employee-1', displayName: '测试人员' } +const manager = { id: 'manager-1', dingtalkUserId: 'manager-ding', jobNumber: 'M001' } +beforeEach(() => { + jest.clearAllMocks() + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([manager]) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([{ sourceDeptId: '123' }]) +}) +test('主管钉钉ID和工号均存在时必须同时匹配,不能降级组织推断', async () => { + expect(await resolveEmployeeDirectManagerProfile({ ...subject, managerDingtalkUserId: 'manager-ding', managerJobNumber: 'M001' })).toEqual(manager) + expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith({ where: { AND: [{ employmentState: 'ACTIVE' }, { dingtalkUserId: 'manager-ding', jobNumber: 'M001' }] }, take: 2 }) + expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).not.toHaveBeenCalled() +}) +test('只有主管工号也支持精确解析', async () => { + await resolveEmployeeDirectManagerProfile({ ...subject, managerJobNumber: 'M001' }) + expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { AND: [{ employmentState: 'ACTIVE' }, { jobNumber: 'M001' }] } })) +}) +test('主管唯一值缺失时,仅按同一主组织唯一在职管理人员解析', async () => { + expect(await resolveEmployeeDirectManagerProfile(subject)).toEqual(manager) + expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { AND: expect.arrayContaining([ + { employmentState: 'ACTIVE' }, { id: { not: 'employee-1' } }, { departmentMemberships: { some: { sourceDeptId: '123', isPrimary: true } } }, + ]) } })) +}) +test.each([{ rows: [] }, { rows: [{ id: 'm1' }, { id: 'm2' }] }])('明确身份无结果或冲突时禁止组织降级', async ({ rows }) => { + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue(rows) + await expect(resolveEmployeeDirectManagerProfile({ ...subject, managerDingtalkUserId: 'bad' })).rejects.toThrow('无法唯一匹配') + expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).not.toHaveBeenCalled() +}) +test.each([{ memberships: [] }, { memberships: [{ sourceDeptId:'1' }, { sourceDeptId:'2' }] }])('主组织缺失或冲突时拒绝', async ({ memberships }) => { + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue(memberships) + await expect(resolveEmployeeDirectManagerProfile(subject)).rejects.toThrow('主组织') + expect(prisma.performanceSurveyEmployeeProfile.findMany).not.toHaveBeenCalled() +}) +test.each([{ rows: [] }, { rows: [{ id: 'm1' }, { id: 'm2' }] }])('管理候选缺失或多于一人时拒绝', async ({ rows }) => { + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue(rows) + await expect(resolveEmployeeDirectManagerProfile(subject)).rejects.toThrow('主管') +}) diff --git a/lib/__tests__/material-query.test.ts b/lib/__tests__/material-query.test.ts index d9c851d..901a765 100644 --- a/lib/__tests__/material-query.test.ts +++ b/lib/__tests__/material-query.test.ts @@ -29,6 +29,8 @@ jest.mock('@/lib/department-permission', () => ({ isSystemAdmin: jest.fn(), })) +jest.mock('@/lib/user-permission-inherit', () => ({ canUserAccessPrivateTaskDetail: jest.fn().mockResolvedValue(false) })) + describe('material search conditions', () => { beforeEach(() => { jest.clearAllMocks() diff --git a/lib/__tests__/materials-route-ai-generated.test.ts b/lib/__tests__/materials-route-ai-generated.test.ts index 2242d36..cb7846e 100644 --- a/lib/__tests__/materials-route-ai-generated.test.ts +++ b/lib/__tests__/materials-route-ai-generated.test.ts @@ -55,6 +55,8 @@ jest.mock('@/lib/department-permission', () => ({ canUserViewMaterial: jest.fn(), })) +jest.mock('@/lib/user-permission-inherit', () => ({ canUserAccessPrivateTaskDetail: jest.fn().mockResolvedValue(false) })) + jest.mock('@/lib/s3', () => ({ deleteFromS3: jest.fn(), isS3Url: jest.fn(() => false), @@ -211,6 +213,8 @@ describe('materials route ai generated', () => { test('share 参数不能绕过普通素材列表权限过滤', async () => { ;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['买家秀图片']) + ;(prisma.material.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.material.count as jest.Mock).mockResolvedValue(0) const request = new NextRequest('http://localhost/api/materials?share=1&materialType=任务-详情&page=1&pageSize=20') @@ -220,7 +224,12 @@ describe('materials route ai generated', () => { expect(response.status).toBe(200) expect(body.materials).toEqual([]) expect(body.totalCount).toBe(0) - expect(prisma.material.findMany).not.toHaveBeenCalled() + expect(prisma.material.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ AND: [{ OR: [ + { taskDetailPrivate: false, materialType: { in: ['买家秀图片'] } }, + { materialType: '任务-详情', taskDetailPrivate: true, taskDetailRequesterUserId: 'user-1' }, + ] }] }), + })) }) test('任务-页面缺少顶层 previewUrl 时应使用 preview 资产作为首页封面', async () => { diff --git a/lib/__tests__/organization-material-permissions.test.ts b/lib/__tests__/organization-material-permissions.test.ts index 62e7410..90f406e 100644 --- a/lib/__tests__/organization-material-permissions.test.ts +++ b/lib/__tests__/organization-material-permissions.test.ts @@ -304,14 +304,14 @@ describe('organization people counts', () => { test('多组合并时允许的取并集,不允许的不会把别组已经给的拿掉', () => { const merged = unionPermissionPacks( { - '3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true, canEditAssets: false }, + '3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true, canEditAssets: false, canAccessPrivate: false }, }, { - '3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false }, - 详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true, canEditAssets: false }, + '3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false, canAccessPrivate: false }, + 详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true, canEditAssets: false, canAccessPrivate: false }, }, { - 详情模板: { canView: true, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false }, + 详情模板: { canView: true, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false, canAccessPrivate: false }, }, ) @@ -321,6 +321,7 @@ describe('organization people counts', () => { canDownloadSource: false, canShare: true, canEditAssets: false, + canAccessPrivate: false, }) expect(merged['详情模板']).toEqual({ canView: true, @@ -328,18 +329,19 @@ describe('organization people counts', () => { canDownloadSource: true, canShare: true, canEditAssets: false, + canAccessPrivate: false, }) }) test('用户继承叠在部门权限上,能给的加上,不能把部门已有的拿掉', () => { const merged = mergeDepartmentAndInheritPacks( { - '3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true, canEditAssets: false }, - '任务-详情': { canView: true, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false }, + '3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true, canEditAssets: false, canAccessPrivate: false }, + '任务-详情': { canView: true, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false, canAccessPrivate: false }, }, { - '3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false }, - 详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true, canEditAssets: false }, + '3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false, canAccessPrivate: false }, + 详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true, canEditAssets: false, canAccessPrivate: false }, }, ) @@ -349,6 +351,7 @@ describe('organization people counts', () => { canDownloadSource: false, canShare: true, canEditAssets: false, + canAccessPrivate: false, }) expect(merged['任务-详情']?.canDownload).toBe(false) expect(merged['详情模板']).toEqual({ @@ -357,6 +360,41 @@ describe('organization people counts', () => { canDownloadSource: true, canShare: true, canEditAssets: false, + canAccessPrivate: false, + }) + }) + + test('任务详情私密权限同样按并集合并,别的组不能把已给的权限抹掉', () => { + const merged = unionPermissionPacks( + { + '任务-详情': { + canView: true, + canDownload: true, + canDownloadSource: false, + canShare: true, + canEditAssets: false, + canAccessPrivate: true, + }, + }, + { + '任务-详情': { + canView: false, + canDownload: false, + canDownloadSource: false, + canShare: true, + canEditAssets: false, + canAccessPrivate: false, + }, + }, + ) + + expect(merged['任务-详情']).toEqual({ + canView: true, + canDownload: true, + canDownloadSource: false, + canShare: true, + canEditAssets: false, + canAccessPrivate: true, }) }) diff --git a/lib/__tests__/product-assets-route.test.ts b/lib/__tests__/product-assets-route.test.ts index aeeb6bf..df7b433 100644 --- a/lib/__tests__/product-assets-route.test.ts +++ b/lib/__tests__/product-assets-route.test.ts @@ -94,6 +94,14 @@ describe('external product-assets route', () => { } as any } + test('私密任务详情不向外部系统暴露资料或更新领取状态', async () => { + ;(getMaterialById as jest.Mock).mockResolvedValue({ id: 'private-one', materialType: '任务-详情', taskDetailPrivate: true }) + const response = await GET(createRequest('https://example.com/api/external/product-assets?materialId=35d8644b-1e09-4ce8-9bb0-c529b39ba52e')) + expect(response.status).toBe(404) + expect(updateTaskStatus).not.toHaveBeenCalled() + expect((await response.json()).data).toBeUndefined() + }) + test('materialId 查询应返回任务详情正式资源,并为所有资源组补齐 sortOrder', async () => { ;(getMaterialById as jest.Mock).mockResolvedValue({ id: '35d8644b-1e09-4ce8-9bb0-c529b39ba52e', diff --git a/lib/__tests__/product-barcode-images-route.test.ts b/lib/__tests__/product-barcode-images-route.test.ts index 3e6d97f..74661b4 100644 --- a/lib/__tests__/product-barcode-images-route.test.ts +++ b/lib/__tests__/product-barcode-images-route.test.ts @@ -84,6 +84,13 @@ describe('external product-barcode-images route', () => { expect(body.error.code).toBe('INVALID_API_KEY') }) + test('条码查询必须在数据库过滤私密任务详情', async () => { + await GET(createRequest('https://example.com/api/external/product-barcode-images?barcode=690000000001')) + expect(prisma.materialAssetBarcode.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ asset: expect.objectContaining({ material: expect.objectContaining({ taskDetailPrivate: false }) }) }), + })) + }) + test('barcode 为空时应返回 400', async () => { const response = await GET( createRequest('https://example.com/api/external/product-barcode-images?barcode='), diff --git a/lib/__tests__/product-package-complete-route-security.test.ts b/lib/__tests__/product-package-complete-route-security.test.ts index cec5b0c..ad0d66a 100644 --- a/lib/__tests__/product-package-complete-route-security.test.ts +++ b/lib/__tests__/product-package-complete-route-security.test.ts @@ -11,6 +11,7 @@ import { getLatestTaskDetailUploadJobStatus, TaskDetailUploadQueueCapacityError, } from '@/lib/task-detail-upload-jobs' +import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) @@ -22,9 +23,50 @@ jest.mock('@/lib/prisma', () => ({ }, })) jest.mock('@/lib/product-package-completion', () => { - const actual = jest.requireActual('@/lib/product-package-completion') + const { TASK_DETAIL_UPLOAD_LIMITS } = jest.requireActual('@/lib/task-detail-upload-policy') + const normalizeImageGroup = (group: unknown, label: string) => { + if (!Array.isArray(group)) return [] + if (group.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) { + throw new Error(`${label}最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批提交`) + } + return group.map((item) => { + if (!item || typeof item !== 'object') { + throw new Error('资产参数无效') + } + if ('fileUrl' in item || 'localFilePath' in item) { + throw new Error('公开上传不允许 fileUrl 或 localFilePath') + } + return item + }) + } + const normalizeSourceGroup = (group: unknown) => { + if (!Array.isArray(group)) return [] + if (group.length > TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount) { + throw new Error(`PSD 源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount} 个,请分批提交`) + } + return group.map((item) => { + if (!item || typeof item !== 'object') { + throw new Error('资产参数无效') + } + if ('fileUrl' in item || 'localFilePath' in item) { + throw new Error('公开上传不允许 fileUrl 或 localFilePath') + } + return item + }) + } return { - ...actual, + parsePublicProductPackageAssets: jest.fn((value: unknown) => { + if (!value || typeof value !== 'object') { + return {} + } + const assets = value as Record + return { + main: normalizeImageGroup(assets.main, '主图'), + sku: normalizeImageGroup(assets.sku, 'SKU 图'), + detail: normalizeImageGroup(assets.detail, '详情图'), + psd: normalizeSourceGroup(assets.psd), + } + }), completeProductPackageUpload: jest.fn(), } }) @@ -142,6 +184,46 @@ describe('product-package complete public route security', () => { expect(completeProductPackageUpload).not.toHaveBeenCalled() }) + test('已有非法新品私密状态的素材不能继续完成上传', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: '实拍', + taskDetailDemandType: '新品', + taskDetailPrivate: true, + zipFilePath: '', + _count: { assets: 0 }, + }) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + assets: { + main: [{ + uploadId: 'upload-main', + fileName: 'main.png', + fileSize: 10, + previewAssets: [{ + uploadId: 'upload-preview', + fileName: 'main-preview.webp', + fileSize: 5, + width: 800, + height: 800, + kind: 'preview', + }], + }], + }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toBe('仅优化需求支持私密上传') + expect(createTaskDetailUploadJob).not.toHaveBeenCalled() + }) + test('合法任务详情上传会快速入队并返回 jobId', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', @@ -349,7 +431,7 @@ describe('product-package complete public route security', () => { materialId: 'material-1', allowServerPreviewFallback: true, assets: { - main: Array.from({ length: 61 }, (_, index) => ({ + main: Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup + 1 }, (_, index) => ({ uploadId: `upload-main-${index}`, fileName: `${index}.jpg`, fileSize: 1, @@ -360,7 +442,7 @@ describe('product-package complete public route security', () => { const body = await response.json() expect(response.status).toBe(400) - expect(body.error).toContain('主图最多上传 60 张') + expect(body.error).toContain(`主图最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张`) expect(prisma.material.findUnique).not.toHaveBeenCalled() }) diff --git a/lib/__tests__/product-package-completion.test.ts b/lib/__tests__/product-package-completion.test.ts index 65e676e..b80e62a 100644 --- a/lib/__tests__/product-package-completion.test.ts +++ b/lib/__tests__/product-package-completion.test.ts @@ -13,12 +13,14 @@ import { queueTaskDetailFullSetNotification, } from '@/lib/external-auto-listing' import { createMaterialLog } from '@/lib/material-log' +import { uploadPublicDisplayToS3 } from '@/lib/public-display' import { cleanupOnError, cleanupTempFiles, createZipFromFiles, saveProductPackageAssetsAndComplete, } from '@/lib/task-material-processor' +import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions' import { withImageProcessingSlot } from '@/lib/image-processing-limiter' import { mkdtemp, readFile, rm, writeFile } from 'fs/promises' @@ -144,20 +146,20 @@ describe('product-package-completion', () => { test('后台处理入口再次执行图片、PSD 和预览数量门禁', () => { expect(() => assertTaskDetailUploadAssetLimits({ - main: Array.from({ length: 61 }, (_, index) => ({ + main: Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup + 1 }, (_, index) => ({ uploadId: `main-${index}`, fileName: `${index}.jpg`, fileSize: 1, })), - })).toThrow('主图最多上传 60 张') + })).toThrow(`主图最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张`) expect(() => assertTaskDetailUploadAssetLimits({ - psd: Array.from({ length: 21 }, (_, index) => ({ + psd: Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount + 1 }, (_, index) => ({ uploadId: `psd-${index}`, fileName: `${index}.psd`, fileSize: 1, })), - })).toThrow('PSD 源文件最多上传 20 个') + })).toThrow(`PSD 源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount} 个`) const imageGroup = (prefix: string, count: number) => Array.from({ length: count }, (_, index) => ({ uploadId: `${prefix}-${index}`, @@ -165,12 +167,12 @@ describe('product-package-completion', () => { fileSize: 1, })) expect(() => assertTaskDetailUploadAssetLimits({ - main: imageGroup('main', 41), - sku: imageGroup('sku', 40), - detail: imageGroup('detail', 40), - })).toThrow('本次图片最多上传 120 张') + main: imageGroup('main', 250), + sku: imageGroup('sku', 200), + detail: imageGroup('detail', 151), + })).toThrow(`本次图片最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount} 张`) - const previews = Array.from({ length: 60 }, (_, index) => ({ + const previews = Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage }, (_, index) => ({ uploadId: `preview-${index}`, fileName: `${index}.webp`, fileSize: 1, @@ -179,7 +181,7 @@ describe('product-package-completion', () => { kind: 'preview', })) expect(() => assertTaskDetailUploadAssetLimits({ - main: Array.from({ length: 17 }, (_, index) => ({ + main: Array.from({ length: 34 }, (_, index) => ({ uploadId: `source-${index}`, fileName: `${index}.jpg`, fileSize: 1, @@ -338,6 +340,65 @@ describe('product-package-completion', () => { } }) + test('私密任务详情完成时预览写入本地 private-previews,且不上传 public-display', async () => { + const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-private-task-detail-preview-')) + const previousUploadDir = process.env.UPLOAD_DIR + process.env.UPLOAD_DIR = uploadRoot + + try { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailPrivate: true, + taskDetailSourceType: '实拍', + taskStatus: 'uploading', + previewUrl: '', + zipFilePath: '', + zipFileName: '', + zipFileSize: BigInt(0), + taskNo: null, + dingtalkApprovalNo: null, + isFullSet: false, + productModel: 'SPU-001', + categoryName: '旗舰店', + user: { realName: '张三', username: 'zhangsan' }, + }) + ;(createZipFromFiles as jest.Mock).mockResolvedValue({ + filePath: 'zip-main', + fileName: 'main.zip', + fileSize: 11, + }) + + await expect(completeProductPackageUpload({ + materialId: 'material-1', + taskNo: null, + operatorUserId: 'user-1', + assets: { + main: [{ + fileUrl: 'https://img.example/main.png', + fileName: 'main.png', + fileSize: 10, + }], + }, + })).resolves.toEqual(expect.objectContaining({ materialId: 'material-1' })) + + const previewAsset = (saveProductPackageAssetsAndComplete as jest.Mock).mock.calls.at(-1)?.[0]?.previewAssets?.main?.[0] + expect(previewAsset?.fileUrl).toMatch(/^uploads\/materials\/material-1\/private-previews\/main\/.+\.webp$/) + expect(uploadPublicDisplayToS3).not.toHaveBeenCalled() + + const absolutePath = path.join( + uploadRoot, + String(previewAsset.fileUrl).replace(/^uploads\//, '').replace(/\//g, path.sep), + ) + await expect(readFile(absolutePath)).resolves.toEqual(Buffer.from('image-binary')) + } finally { + if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR + else process.env.UPLOAD_DIR = previousUploadDir + await rm(uploadRoot, { recursive: true, force: true }) + } + }) + test.each([ [{ format: 'jpeg', width: 800, height: 600, pages: 1 }, { width: 800, height: 600 }, '内容不是有效 WebP'], [{ format: 'webp', width: 800, height: 600, pages: 2 }, { width: 800, height: 600 }, '不允许使用动画 WebP'], diff --git a/lib/__tests__/product-package-init-route.test.ts b/lib/__tests__/product-package-init-route.test.ts index 9a1a402..4d8454c 100644 --- a/lib/__tests__/product-package-init-route.test.ts +++ b/lib/__tests__/product-package-init-route.test.ts @@ -33,6 +33,12 @@ jest.mock('@/lib/product-package', () => ({ purgeDeletedMaterialsByTaskNo: jest.fn(() => Promise.resolve({ deletedMaterials: 0, deletedFiles: 0, failedFiles: 0 })), })) +jest.mock('@/lib/material-query', () => ({ + INCOMPLETE_SHELL_CREATE_DATA: { + isIncompleteShell: true, + }, +})) + describe('product-package init route', () => { beforeEach(() => { jest.clearAllMocks() @@ -102,7 +108,31 @@ describe('product-package init route', () => { expect(prisma.material.create).not.toHaveBeenCalled() }) - test('传入合法出图方式时应原样保存', async () => { + test('新品私密组合会在初始化阶段被拒绝', async () => { + const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', { + method: 'POST', + body: JSON.stringify({ + productModel: 'SPU-001', + categoryName: '旗舰店', + taskDetailSourceType: '渲染', + taskDetailDemandType: '新品', + taskDetailPrivate: true, + taskDetailRequesterUserId: 'requester-1', + }), + headers: { + 'Content-Type': 'application/json', + }, + }) + + const response = await POST(request) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toBe('仅优化需求支持私密上传') + expect(prisma.material.create).not.toHaveBeenCalled() + }) + + test('优化私密会原样持久化', async () => { const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', { method: 'POST', body: JSON.stringify({ @@ -110,6 +140,8 @@ describe('product-package init route', () => { categoryName: '旗舰店', taskNo: 'TASK202604230001', taskDetailSourceType: '渲染', + taskDetailDemandType: '优化', + taskDetailPrivate: true, taskDetailRequesterUserId: 'requester-1', taskDetailRequesterName: '伪造姓名', }), @@ -127,6 +159,8 @@ describe('product-package init route', () => { expect(prisma.material.create).toHaveBeenCalledWith({ data: expect.objectContaining({ taskDetailSourceType: '渲染', + taskDetailDemandType: '优化', + taskDetailPrivate: true, taskDetailRequesterUserId: 'requester-1', taskDetailRequesterName: '运营甲', // 作者名必须取自数据库中的用户名,而不是登录态里并不存在的字段, @@ -141,6 +175,31 @@ describe('product-package init route', () => { }) }) + test('缺省需求类型和私密时回填新品公开', async () => { + const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', { + method: 'POST', + body: JSON.stringify({ + productModel: 'SPU-001', + categoryName: '旗舰店', + taskDetailSourceType: '渲染', + taskDetailRequesterUserId: 'requester-1', + }), + headers: { + 'Content-Type': 'application/json', + }, + }) + + const response = await POST(request) + + expect(response.status).toBe(200) + expect(prisma.material.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + taskDetailDemandType: '新品', + taskDetailPrivate: false, + }), + }) + }) + test('未选择需求运营人员时应返回 400', async () => { const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', { method: 'POST', diff --git a/lib/__tests__/regenerate-preview-route.test.ts b/lib/__tests__/regenerate-preview-route.test.ts index 3329cb9..7c35e97 100644 --- a/lib/__tests__/regenerate-preview-route.test.ts +++ b/lib/__tests__/regenerate-preview-route.test.ts @@ -11,6 +11,7 @@ import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-p import { prisma } from '@/lib/prisma' import { uploadPublicDisplayToS3 } from '@/lib/public-display' import { deleteFromS3, verifyUploadedS3Object } from '@/lib/s3' +import { canAccessMaterialPrivacy } from '@/lib/task-detail-privacy' jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) @@ -20,8 +21,10 @@ jest.mock('@/lib/department-permission', () => ({ isSuperAdmin || role === 'ADMIN' || role === 'SYSTEM_ADMIN'), })) jest.mock('fs/promises', () => ({ + mkdir: jest.fn().mockResolvedValue(undefined), mkdtemp: jest.fn().mockResolvedValue('/uploads/temp/regenerate-material-1-unique'), rm: jest.fn().mockResolvedValue(undefined), + writeFile: jest.fn().mockResolvedValue(undefined), })) jest.mock('@/lib/bounded-file-reader', () => ({ readBoundedRegularFile: jest.fn() })) jest.mock('@/lib/path-security', () => ({ @@ -61,6 +64,9 @@ jest.mock('@/lib/public-display', () => ({ ...jest.requireActual('@/lib/public-display'), uploadPublicDisplayToS3: jest.fn(), })) +jest.mock('@/lib/task-detail-privacy', () => ({ + canAccessMaterialPrivacy: jest.fn(), +})) jest.mock('@/lib/s3', () => ({ deleteFromS3: jest.fn(), verifyUploadedS3Object: jest.fn(), @@ -102,6 +108,7 @@ describe('admin regenerate-preview route', () => { ;(uploadPublicDisplayToS3 as jest.Mock) .mockResolvedValueOnce(uploadedUrls[0]) .mockResolvedValueOnce(uploadedUrls[1]) + ;(canAccessMaterialPrivacy as jest.Mock).mockResolvedValue(true) ;(verifyUploadedS3Object as jest.Mock).mockResolvedValue(undefined) ;(deleteFromS3 as jest.Mock).mockResolvedValue(undefined) ;(prisma.$transaction as jest.Mock).mockImplementation(async (action) => action({ @@ -277,6 +284,41 @@ describe('admin regenerate-preview route', () => { expect(uploadPublicDisplayToS3).not.toHaveBeenCalled() }) + test('管理员没有私密详情访问权时返回 403,不生成任何预览', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({ + id: 'material-1', + previewUrl: 'uploads/materials/material-1/private-previews/detail/old.webp', + taskDetailPrivate: true, + taskDetailRequesterUserId: 'other-user', + }) + ;(canAccessMaterialPrivacy as jest.Mock).mockResolvedValueOnce(false) + + const response = await POST(request()) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toBe('无权限操作') + expect(uploadPublicDisplayToS3).not.toHaveBeenCalled() + expect(verifyUploadedS3Object).not.toHaveBeenCalled() + }) + + test('私密详情重建写入本地 private-previews,且不会校验 public-display 对象', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({ + id: 'material-1', + previewUrl: 'uploads/materials/material-1/private-previews/detail/old.webp', + taskDetailPrivate: true, + }) + ;(canAccessMaterialPrivacy as jest.Mock).mockResolvedValueOnce(true) + + const response = await POST(request()) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.previewUrls[0]).toMatch(/^uploads\/materials\/material-1\/private-previews\/detail\/.+\.webp$/) + expect(uploadPublicDisplayToS3).not.toHaveBeenCalled() + expect(verifyUploadedS3Object).not.toHaveBeenCalled() + }) + test.each([ ['gzip 请求体', rawRequest('{"materialId":"material-1"}', { 'content-encoding': 'gzip' }), 415], ['额外字段', rawRequest('{"materialId":"material-1","admin":true}'), 400], diff --git a/lib/__tests__/task-detail-asset-edit-batch.test.ts b/lib/__tests__/task-detail-asset-edit-batch.test.ts index 9d6621f..4ff1686 100644 --- a/lib/__tests__/task-detail-asset-edit-batch.test.ts +++ b/lib/__tests__/task-detail-asset-edit-batch.test.ts @@ -22,6 +22,10 @@ jest.mock('@/lib/public-display', () => ({ buildTaskDetailPublicDisplayPath: jest.fn((materialId: string, group: string, fileName: string) => `${materialId}/${group}/${fileName}`), uploadPublicDisplayToS3: jest.fn(), })) +jest.mock('@/lib/user-permission-inherit', () => ({ + canUserAccessPrivateTaskDetail: jest.fn().mockResolvedValue(false), + canUserEditTaskDetailAssets: jest.fn().mockResolvedValue(false), +})) jest.mock('@/lib/temp-cleanup', () => ({ cleanupConsumedUploadSession: jest.fn().mockResolvedValue(undefined) })) jest.mock('@/lib/remote-upload-sync', () => ({ deleteStoredUploadFileFromRemote: jest.fn().mockResolvedValue(undefined) })) jest.mock('@/lib/upload-sessions', () => ({ @@ -70,6 +74,10 @@ import { queueTaskDetailBarcodeImagesUpdatedNotification } from '@/lib/external- import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock' import { prisma } from '@/lib/prisma' import { uploadPublicDisplayToS3 } from '@/lib/public-display' +import { + canUserAccessPrivateTaskDetail, + canUserEditTaskDetailAssets, +} from '@/lib/user-permission-inherit' import { createZipFromFiles, createZipFromZips } from '@/lib/task-material-processor' import { ServerTrustedTaskDetailStagedFile, @@ -93,6 +101,8 @@ const mockCreateWorkspace = createTaskDetailEditWorkspace as jest.Mock const mockExtractZipEntries = extractZipEntriesToDirectory as jest.Mock const mockListZipEntries = listZipEntries as jest.Mock const mockQueueSkuNotification = queueTaskDetailBarcodeImagesUpdatedNotification as jest.Mock +const mockCanAccessPrivateTaskDetail = canUserAccessPrivateTaskDetail as jest.Mock +const mockCanUserEditTaskDetailAssets = canUserEditTaskDetailAssets as jest.Mock type PreviewRow = { id: string @@ -112,6 +122,8 @@ describe('task-detail asset edit batch data-plane', () => { tempRoot = await mkdtemp(path.join(os.tmpdir(), 'task-detail-batch-')) mockCanManageMaterial.mockResolvedValue(true) + mockCanAccessPrivateTaskDetail.mockResolvedValue(false) + mockCanUserEditTaskDetailAssets.mockResolvedValue(false) mockAcquireLock.mockResolvedValue(Object.assign(jest.fn().mockResolvedValue(undefined), { assertHeld: jest.fn(), })) @@ -517,6 +529,84 @@ describe('task-detail asset edit batch data-plane', () => { ]) }) + test('私密详情动图插图把新 preview 写到本地 private-previews,不走 public-display', async () => { + const previousUploadDir = process.env.UPLOAD_DIR + process.env.UPLOAD_DIR = tempRoot + + try { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ + id: 'material-1', + userId: 'operator-1', + materialType: '任务-详情', + taskDetailPrivate: true, + taskDetailRequesterUserId: 'operator-1', + taskStatus: 'completed', + productModel: 'MODEL-1', + authorName: '作者A', + categoryName: '店铺A', + previewUrl: 'uploads/materials/material-1/private-previews/detail/old-cover.webp', + isFullSet: false, + updatedAt: new Date('2026-08-28T08:00:00.000Z'), + }) + + const longGif = await createAnimatedGif('private-long-run.gif', 100, 2000) + const topGif = await createAnimatedGif('private-long-run-top.gif', 100, 1500) + const bottomGif = await createAnimatedGif('private-long-run-bottom.gif', 100, 500) + const topPreview = await createSolidImage('private-gif-001.webp', 100, 1500, 'webp') + const bottomPreview = await createSolidImage('private-gif-002.webp', 100, 500, 'webp') + const inserted = await createSolidImage('private-insert.png', 100, 300) + + configureUploadSources({ + insert: { path: inserted.filePath, size: inserted.size, fileName: 'private-insert.png' }, + }) + configureAssetQueries({ + previewRows: [ + { id: 'gif-1', assetType: 'detail-preview', fileUrl: topPreview.filePath, fileName: 'gif-run-001.webp', sortOrder: 0 }, + { id: 'gif-2', assetType: 'detail-preview', fileUrl: bottomPreview.filePath, fileName: 'gif-run-002.webp', sortOrder: 1 }, + ], + zipRows: [ + { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'private-detail-gif.zip'), fileName: 'detail-gif.zip' }, + ], + }) + mockListZipEntries.mockResolvedValue([{ name: 'gif-run.gif' }]) + mockExtractZipEntries.mockResolvedValue([ + { entryName: 'gif-run.gif', filePath: longGif.filePath, size: longGif.size }, + ]) + + mockExecFile.mockImplementation((file: string, args: string[], options: any, callback: (error: Error | null, stdout?: string, stderr?: string) => void) => { + const outputPath = args[args.length - 1] + if (String(outputPath).endsWith('.gif')) { + const filters = args.join(' ') + writeFileSync(outputPath, filters.includes('crop=100:1500') ? topGif.buffer : bottomGif.buffer) + } else { + writeFileSync(outputPath, Buffer.from('RIFFxxxxWEBP')) + } + callback(null, '', '') + }) + + const result = await batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [ + { kind: 'insert', afterAssetId: 'gif-1', beforeAssetId: 'gif-2', uploadId: 'insert' }, + ], + }) + + expect(result.entryCount).toBe(3) + expect(mockUploadPreview).not.toHaveBeenCalled() + const createdRows = (prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data + const localPreviewUrls = createdRows.map((row: { fileUrl: string }) => row.fileUrl) + expect(localPreviewUrls).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^uploads\/materials\/material-1\/private-previews\/detail\/.+\.webp$/), + ]), + ) + } finally { + if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR + else process.env.UPLOAD_DIR = previousUploadDir + } + }) + test('追加后超过图片组上限会拒绝提交', async () => { const existingRows: PreviewRow[] = [] const entryNames: Array<{ name: string; filePath: string }> = [] @@ -558,6 +648,36 @@ describe('task-detail asset edit batch data-plane', () => { expect(mockCreateZipFromFiles).not.toHaveBeenCalled() }) + test('私密素材访问门禁先于 canEditAssets,编辑权限不能绕过', async () => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ + id: 'material-1', + userId: 'owner-2', + materialType: '任务-详情', + taskDetailPrivate: true, + taskDetailRequesterUserId: 'owner-2', + taskStatus: 'completed', + productModel: 'MODEL-1', + authorName: '作者A', + categoryName: '店铺A', + previewUrl: 'uploads/materials/material-1/private-previews/main/cover.webp', + isFullSet: false, + updatedAt: new Date('2026-08-28T08:00:00.000Z'), + }) + mockCanManageMaterial.mockResolvedValue(false) + mockCanUserEditTaskDetailAssets.mockResolvedValue(true) + mockCanAccessPrivateTaskDetail.mockResolvedValue(false) + + await expect(batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [ + { kind: 'append', group: 'main', uploadId: 'append-any' }, + ], + })).rejects.toThrow('您没有权限编辑此素材') + + expect(prisma.materialAsset.findMany).not.toHaveBeenCalled() + }) + test('审批期间正式素材已更新时在持锁上下文中停止应用', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ id: 'material-1', diff --git a/lib/__tests__/task-detail-change-approval.test.ts b/lib/__tests__/task-detail-change-approval.test.ts index aaa4072..0097151 100644 --- a/lib/__tests__/task-detail-change-approval.test.ts +++ b/lib/__tests__/task-detail-change-approval.test.ts @@ -21,7 +21,8 @@ import { jest.mock('@/lib/prisma', () => ({ prisma: { material: { findFirst: jest.fn(), findUnique: jest.fn() }, - performanceSurveyEmployeeProfile: { findUnique: jest.fn() }, + performanceSurveyEmployeeProfile: { findUnique: jest.fn(), findMany: jest.fn() }, + performanceSurveyEmployeeDepartmentMembership: { findMany: jest.fn() }, taskDetailChangeRequest: { create: jest.fn(), findUnique: jest.fn(), @@ -94,6 +95,10 @@ describe('任务详情图片变更主管审批', () => { beforeEach(() => { jest.clearAllMocks() ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(material) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([ + { id: 'manager-employee', displayName: '直属主管', dingtalkUserId: 'manager-ding', employmentState: 'ACTIVE' }, + ]) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([{ sourceDeptId: 'department-1' }]) ;(canManageMaterial as jest.Mock).mockResolvedValue(true) ;(batchEditTaskDetailImages as jest.Mock).mockResolvedValue({ success: true }) ;(queueTaskDetailChangeNotification as jest.Mock).mockResolvedValue('notification-1') @@ -201,6 +206,23 @@ describe('任务详情图片变更主管审批', () => { expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalled() }) + test('主管唯一值缺失时按同一主部门唯一M级人员进入主管确认,不提前修改图片', async () => { + ;(getEmployeeByUserId as jest.Mock).mockResolvedValue({ ...employee('P'), managerDingtalkUserId: null, managerJobNumber: null, managerName: null }) + ;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({ + id: 'manager-employee', displayName: '部门主管', employmentState: 'ACTIVE', dingtalkUserId: 'manager-ding', + userBindings: [{ userId: 'manager-user' }], + }) + ;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map())) + ;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({ ...data, material, submittedAt: new Date() })) + const result = await submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER' }, [{ kind: 'delete', assetId: 'asset-1' }]) + expect(result.mode).toBe('pending_approval') + expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { employeeProfileId: 'employee-P', isPrimary: true }, + })) + expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ managerDingtalkUserId: 'manager-ding', managerUserId: 'manager-user' }) })) + expect(batchEditTaskDetailImages).not.toHaveBeenCalled() + }) + test('跨主图和详情图的变更在暂存前直接拒绝', async () => { ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P')) diff --git a/lib/__tests__/task-detail-preview-storage.test.ts b/lib/__tests__/task-detail-preview-storage.test.ts new file mode 100644 index 0000000..d0d7e03 --- /dev/null +++ b/lib/__tests__/task-detail-preview-storage.test.ts @@ -0,0 +1,81 @@ +/** @jest-environment node */ + +import { mkdtemp, readFile, rm } from 'fs/promises' +import os from 'os' +import path from 'path' + +import { uploadPublicDisplayToS3 } from '@/lib/public-display' +import { saveTaskDetailPreview } from '@/lib/task-detail-preview-storage' + +jest.mock('@/lib/public-display', () => ({ + ...jest.requireActual('@/lib/public-display'), + uploadPublicDisplayToS3: jest.fn(), +})) + +describe('task-detail-preview-storage', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('公开预览仍沿用既有 public-display 路径', async () => { + const buffer = Buffer.from('public-preview') + ;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue( + 'https://s3.example/public-display/task-detail/material-1/detail/cover.webp', + ) + + const result = await saveTaskDetailPreview( + buffer, + 'material-1', + 'detail', + 'cover.webp', + false, + ) + + expect(result).toBe('https://s3.example/public-display/task-detail/material-1/detail/cover.webp') + expect(uploadPublicDisplayToS3).toHaveBeenCalledWith( + buffer, + 'task-detail/material-1/detail/cover.webp', + 'image/webp', + ) + }) + + test('私密预览写入本地 uploads/materials 私有目录并返回相对存储路径', async () => { + const uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'task-detail-preview-storage-')) + const previousUploadDir = process.env.UPLOAD_DIR + process.env.UPLOAD_DIR = uploadRoot + + try { + const first = await saveTaskDetailPreview( + Buffer.from('private-preview-1'), + 'material-1', + 'detail', + 'bad/../封面?.webp', + true, + ) + const second = await saveTaskDetailPreview( + Buffer.from('private-preview-2'), + 'material-1', + 'detail', + 'bad/../封面?.webp', + true, + ) + + expect(first).toMatch(/^uploads\/materials\/material-1\/private-previews\/detail\/.+\.webp$/) + expect(second).toMatch(/^uploads\/materials\/material-1\/private-previews\/detail\/.+\.webp$/) + expect(second).not.toBe(first) + expect(uploadPublicDisplayToS3).not.toHaveBeenCalled() + + const firstAbsolutePath = path.join(uploadRoot, first.replace(/^uploads\//, '').replace(/\//g, path.sep)) + const secondAbsolutePath = path.join(uploadRoot, second.replace(/^uploads\//, '').replace(/\//g, path.sep)) + await expect(readFile(firstAbsolutePath)).resolves.toEqual(Buffer.from('private-preview-1')) + await expect(readFile(secondAbsolutePath)).resolves.toEqual(Buffer.from('private-preview-2')) + } finally { + if (previousUploadDir === undefined) { + delete process.env.UPLOAD_DIR + } else { + process.env.UPLOAD_DIR = previousUploadDir + } + await rm(uploadRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/lib/__tests__/task-detail-privacy-fields.test.ts b/lib/__tests__/task-detail-privacy-fields.test.ts new file mode 100644 index 0000000..08cdcb6 --- /dev/null +++ b/lib/__tests__/task-detail-privacy-fields.test.ts @@ -0,0 +1,37 @@ +import { normalizeTaskDetailPrivacyFields } from '@/lib/task-detail-privacy-fields' + +describe('task-detail privacy fields', () => { + test('缺省值回填为新品公开', () => { + expect(normalizeTaskDetailPrivacyFields({})).toEqual({ + taskDetailDemandType: '新品', + taskDetailPrivate: false, + }) + + expect(normalizeTaskDetailPrivacyFields({ + taskDetailDemandType: null, + taskDetailPrivate: null, + })).toEqual({ + taskDetailDemandType: '新品', + taskDetailPrivate: false, + }) + }) + + test('优化需求允许私密', () => { + expect(normalizeTaskDetailPrivacyFields({ + taskDetailDemandType: '优化', + taskDetailPrivate: true, + })).toEqual({ + taskDetailDemandType: '优化', + taskDetailPrivate: true, + }) + }) + + test.each([ + [{ taskDetailDemandType: '' }, '需求类型只能是“新品”或“优化”'], + [{ taskDetailDemandType: '升级' }, '需求类型只能是“新品”或“优化”'], + [{ taskDetailPrivate: 'true' }, '私密标记只能传 true 或 false'], + [{ taskDetailDemandType: '新品', taskDetailPrivate: true }, '仅优化需求支持私密上传'], + ])('非法输入会抛出明确中文错误: %j', (input, message) => { + expect(() => normalizeTaskDetailPrivacyFields(input)).toThrow(message) + }) +}) diff --git a/lib/__tests__/task-detail-privacy.test.ts b/lib/__tests__/task-detail-privacy.test.ts new file mode 100644 index 0000000..2444c06 --- /dev/null +++ b/lib/__tests__/task-detail-privacy.test.ts @@ -0,0 +1,171 @@ +/** @jest-environment node */ + +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import os from 'os' +import path from 'path' +import { NextRequest } from 'next/server' +import { getServerSession } from 'next-auth' +import { prisma } from '@/lib/prisma' +import { canUserAccessPrivateTaskDetail } from '@/lib/user-permission-inherit' +import { canAccessMaterialPrivacy, isPrivateTaskDetail } from '@/lib/task-detail-privacy' +import { buildMaterialWhereFromSearchParams } from '@/lib/material-query' +import { rewriteMediaUrlForClient } from '@/lib/url-rewrite' +import { GET, HEAD } from '@/app/api/uploads/[...path]/route' +import { GET as getDetail } from '@/app/api/materials/[id]/route' +import { GET as getSharedDetail } from '@/app/api/materials/share/route' +import { GET as getDownload } from '@/app/api/materials/download/route' +import { GET as getExternalDownload } from '@/app/api/external/download/route' + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/path-security', () => ({ + ...jest.requireActual('@/lib/path-security'), + getAllowedFileDirectories: () => [process.env.UPLOAD_DIR], +})) +jest.mock('@/lib/prisma', () => ({ prisma: { + material: { findMany: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn() }, + materialAsset: { findMany: jest.fn() }, + user: { findUnique: jest.fn() }, +} })) +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), + 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' })) })) + +const material = { + id: 'private-one', + materialType: '任务-详情', + taskDetailPrivate: true, + taskDetailRequesterUserId: 'requester', + userId: 'uploader', +} + +beforeEach(() => { + jest.clearAllMocks() + ;(canUserAccessPrivateTaskDetail as jest.Mock).mockResolvedValue(false) + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, zipFileSize: BigInt(0), assets: [] }) + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue(material) + ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true }) +}) + +test('私密详情/编辑详情/分享/下载都先拒绝无特权的上传者管理员', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'uploader', role: 'ADMIN', isSuperAdmin: true } }) + for (const suffix of ['', '?intent=edit']) { + const response = await getDetail(new NextRequest(`https://example.com/api/materials/private-one${suffix}`), { params: { id: 'private-one' } }) + expect(response.status).toBe(403) + } + expect((await getSharedDetail(new NextRequest('https://example.com/api/materials/share?id=private-one'))).status).toBe(403) + expect((await getDownload(new NextRequest('https://example.com/api/materials/download?id=private-one'))).status).toBe(403) +}) + +test('所选需求运营可看详情,即使没有公开任务详情类型查看权限', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'requester', role: 'USER' } }) + ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: false }) + const response = await getDetail(new NextRequest('https://example.com/api/materials/private-one'), { params: { id: 'private-one' } }) + expect(response.status).toBe(200) + expect((await response.json()).taskDetailPrivate).toBe(true) + expect(response.headers.get('cache-control')).toContain('no-store') +}) + +test('匿名分享及有效外部下载token也不能暴露私密素材', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue(null) + expect((await getSharedDetail(new NextRequest('https://example.com/api/materials/share?id=private-one'))).status).toBe(401) + expect((await getExternalDownload(new NextRequest('https://example.com/api/external/download?token=valid-old-token'))).status).toBe(404) +}) + +test('公开/旧素材维持原链路;需求运营可访问私密且无需权限组', async () => { + expect(await canAccessMaterialPrivacy({}, null)).toBe(true) + expect(await canAccessMaterialPrivacy(material, 'requester')).toBe(true) + expect(canUserAccessPrivateTaskDetail).not.toHaveBeenCalled() +}) + +test.each([null, 'uploader', 'super-admin', 'other'])('私密不因匿名/上传者/管理员身份隐式放行:%s', async (userId) => { + expect(await canAccessMaterialPrivacy(material, userId)).toBe(false) +}) + +test('授权组可访问;撤销后立即拒绝,不缓存决定', async () => { + ;(canUserAccessPrivateTaskDetail as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(false) + expect(await canAccessMaterialPrivacy(material, 'department-user')).toBe(true) + expect(await canAccessMaterialPrivacy(material, 'department-user')).toBe(false) +}) + +test('损坏或未完整读取需求类型不能让私密标记变公开', () => { + expect(isPrivateTaskDetail({ taskDetailPrivate: true })).toBe(true) +}) + +test.each([false, true])('列表在搜索和分页前叠加私密过滤,超管同样受限:%s', async (isSuperAdmin) => { + const result = await buildMaterialWhereFromSearchParams( + new URLSearchParams('materialType=任务-详情&search=SPU'), + { id: 'requester' }, + { context: { canAccessDeleted: false, expandedViewableTypes: ['任务-详情'], isSuperAdmin, + canAccessPrivateTaskDetails: false, searchOrConditions: [{ productModel: { contains: 'SPU' } }] } }, + ) + expect(result.where.materialType).toEqual({ in: ['任务-详情', '产品全案'] }) + expect(result.where.OR).toEqual([{ productModel: { contains: 'SPU' } }]) + expect(result.where.AND[0].OR[0].taskDetailPrivate).toBe(false) + expect(result.where.AND[0].OR[1]).toEqual({ materialType: '任务-详情', taskDetailPrivate: true, taskDetailRequesterUserId: 'requester' }) +}) + +test('私密权限组无需按需求运营过滤,公开类型权限仍在', async () => { + const result = await buildMaterialWhereFromSearchParams(new URLSearchParams(), { id: 'member' }, { + context: { canAccessDeleted: false, expandedViewableTypes: ['视频'], isSuperAdmin: false, canAccessPrivateTaskDetails: true }, + }) + expect(result.where.AND[0].OR).toEqual([ + { taskDetailPrivate: false, materialType: { in: ['视频'] } }, + { materialType: '任务-详情', taskDetailPrivate: true }, + ]) +}) + +describe('私密预览真实文件入口', () => { + let root: string + const previousRoot = process.env.UPLOAD_DIR + const segments = ['materials', 'private-one', 'private-previews', 'detail', 'slice.webp'] + beforeAll(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'private-preview-test-')) + process.env.UPLOAD_DIR = root + await mkdir(path.join(root, ...segments.slice(0, -1)), { recursive: true }) + await writeFile(path.join(root, ...segments), 'private-image-bytes') + }) + afterAll(async () => { + if (previousRoot === undefined) delete process.env.UPLOAD_DIR + else process.env.UPLOAD_DIR = previousRoot + await rm(root, { recursive: true, force: true }) + }) + beforeEach(() => { + ;(prisma.material.findMany as jest.Mock).mockResolvedValue([{ ...material, previewUrl: `uploads/${segments.join('/')}` }]) + ;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([]) + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'requester' } }) + }) + test('对外仅输出当前站点鉴权路径,不输出CDN/相对页面地址', () => { + expect(rewriteMediaUrlForClient(`uploads/${segments.join('/')}`, undefined, 'image')) + .toBe(`/api/uploads/${segments.join('/')}`) + }) + test('需求运营得到实际图片字节,响应不可缓存', async () => { + const response = await GET(new NextRequest(`https://example.com/api/uploads/${segments.join('/')}`), { params: { path: segments } }) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(await response.text()).toBe('private-image-bytes') + }) + test.each(['requester', 'group-member'])('私密准入者可下载实际文件且不缓存:%s', async (userId) => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: userId, role: 'USER' } }) + ;(canUserAccessPrivateTaskDetail as jest.Mock).mockResolvedValue(userId === 'group-member') + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ + ...material, zipFilePath: path.join(root, ...segments), zipFileName: 'fixture.zip', productModel: 'fixture', + }) + const response = await getDownload(new NextRequest('https://example.com/api/materials/download?id=private-one')) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toContain('no-store') + expect(await response.text()).toBe('private-image-bytes') + }) + test.each([null, { user: { id: 'uploader', role: 'ADMIN' } }])('匿名与未授权上传者不能通过GET/HEAD/Range访问:%j', async (session) => { + ;(getServerSession as jest.Mock).mockResolvedValue(session) + for (const handler of [GET, HEAD]) { + const response = await handler(new NextRequest(`https://example.com/api/uploads/${segments.join('/')}`, { headers: { Range: 'bytes=0-3' } }), { params: { path: segments } }) + expect(response.status).toBe(session ? 403 : 401) + expect(response.headers.get('cache-control')).toContain('no-store') + } + }) +}) diff --git a/lib/__tests__/user-permission-inherit.test.ts b/lib/__tests__/user-permission-inherit.test.ts new file mode 100644 index 0000000..7a0ff1e --- /dev/null +++ b/lib/__tests__/user-permission-inherit.test.ts @@ -0,0 +1,106 @@ +/** @jest-environment node */ + +import { packItemsToMap, savePermissionPackItems } from '@/lib/user-permission-inherit' +import { prisma } from '@/lib/prisma' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + materialPermissionPack: { + findUnique: jest.fn(), + }, + materialPermissionPackItem: { + deleteMany: jest.fn(), + createMany: jest.fn(), + }, + $transaction: jest.fn(async (operations: unknown[]) => operations), + }, +})) + +describe('user permission inherit helpers', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(prisma.materialPermissionPack.findUnique as jest.Mock).mockResolvedValue({ id: 'visual-pack' }) + ;(prisma.materialPermissionPackItem.deleteMany as jest.Mock).mockReturnValue({ kind: 'deleteMany' }) + ;(prisma.materialPermissionPackItem.createMany as jest.Mock).mockReturnValue({ kind: 'createMany' }) + }) + + test('packItemsToMap 只让任务详情保留私密权限和编辑图片权限', () => { + expect( + packItemsToMap([ + { + materialType: '任务-详情', + canView: true, + canDownload: true, + canDownloadSource: true, + canShare: true, + canEditAssets: true, + canAccessPrivate: true, + }, + { + materialType: '3D场景', + canView: true, + canDownload: true, + canDownloadSource: false, + canShare: true, + canEditAssets: true, + canAccessPrivate: true, + }, + ]), + ).toEqual({ + '任务-详情': { + canView: true, + canDownload: true, + canDownloadSource: true, + canShare: true, + canEditAssets: true, + canAccessPrivate: true, + }, + '3D场景': { + canView: true, + canDownload: true, + canDownloadSource: false, + canShare: true, + canEditAssets: false, + canAccessPrivate: false, + }, + }) + }) + + test('savePermissionPackItems 会把非任务详情的私密权限强制保存为 false', async () => { + await savePermissionPackItems('visual', [ + { + materialType: '任务-详情', + canView: true, + canDownload: true, + canDownloadSource: true, + canShare: true, + canEditAssets: true, + canAccessPrivate: true, + }, + { + materialType: '3D场景', + canView: true, + canDownload: true, + canDownloadSource: false, + canShare: true, + canEditAssets: true, + canAccessPrivate: true, + }, + ]) + + expect(prisma.materialPermissionPackItem.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + materialType: '任务-详情', + canEditAssets: true, + canAccessPrivate: true, + }), + expect.objectContaining({ + materialType: '3D场景', + canEditAssets: false, + canAccessPrivate: false, + }), + ], + }) + }) +}) diff --git a/lib/department-permission.ts b/lib/department-permission.ts index 4736024..39c667a 100644 --- a/lib/department-permission.ts +++ b/lib/department-permission.ts @@ -544,6 +544,8 @@ async function loadStoredPermissionPacks() { canShare: row.canShare, // 旧部门权限表没有编辑图片列;编辑能力只认权限组勾选 canEditAssets: false, + // 私密任务-详情同样只认权限组勾选,旧部门权限表默认没有这列 + canAccessPrivate: false, } stored.set(row.department, current) } diff --git a/lib/employee-directory.ts b/lib/employee-directory.ts index 2089550..c506973 100644 --- a/lib/employee-directory.ts +++ b/lib/employee-directory.ts @@ -54,6 +54,7 @@ export interface EmployeeDirectoryEntry { /** 直属上级 */ managerName: string | null managerDingtalkUserId: string | null + managerJobNumber?: string | null employmentStatus: string | null } @@ -72,6 +73,7 @@ const PROFILE_SELECT = { isLeadership: true, managerName: true, managerDingtalkUserId: true, + managerJobNumber: true, employmentStatus: true, } as const @@ -96,6 +98,7 @@ function toEntry(profile: RawProfile, userId: string | null): EmployeeDirectoryE isLeadership: Boolean(profile.isLeadership), managerName: profile.managerName, managerDingtalkUserId: profile.managerDingtalkUserId, + managerJobNumber: profile.managerJobNumber, employmentStatus: profile.employmentStatus, } } diff --git a/lib/employee-manager.ts b/lib/employee-manager.ts new file mode 100644 index 0000000..e47c916 --- /dev/null +++ b/lib/employee-manager.ts @@ -0,0 +1,73 @@ +import type { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' + +export class EmployeeManagerResolutionError extends Error {} + +export interface EmployeeManagerSubject { + id?: string | null + employeeProfileId?: string | null + displayName?: string | null + managerDingtalkUserId?: string | null + managerJobNumber?: string | null +} + +const clean = (value: unknown) => String(value ?? '').trim() + +/** 共享主管候选解析:唯一标识优先;完全缺失时只认同一主部门的唯一在职管理人员。 */ +export async function resolveEmployeeDirectManagerProfile( + subject: EmployeeManagerSubject, + options: { activeWhere?: Prisma.PerformanceSurveyEmployeeProfileWhereInput } = {}, +) { + const name = clean(subject.displayName) || '该人员' + const employeeId = clean(subject.employeeProfileId || subject.id) + const managerDingtalkUserId = clean(subject.managerDingtalkUserId) + const managerJobNumber = clean(subject.managerJobNumber) + const activeWhere = options.activeWhere || { employmentState: 'ACTIVE' } + + if (managerDingtalkUserId || managerJobNumber) { + const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ + where: { AND: [activeWhere, { + ...(managerDingtalkUserId ? { dingtalkUserId: managerDingtalkUserId } : {}), + ...(managerJobNumber ? { jobNumber: managerJobNumber } : {}), + }] }, + take: 2, + }) + if (managers.length !== 1) { + throw new EmployeeManagerResolutionError(`无法唯一匹配${name}的直属主管`) + } + return managers[0] + } + + if (!employeeId) { + throw new EmployeeManagerResolutionError(`无法确定${name}所属的主组织,请先校准人员档案`) + } + const primaryMemberships = await prisma.performanceSurveyEmployeeDepartmentMembership.findMany({ + where: { employeeProfileId: employeeId, isPrimary: true }, + select: { sourceDeptId: true }, + take: 2, + }) + const departmentIds = Array.from(new Set(primaryMemberships.map(row => clean(row.sourceDeptId)).filter(Boolean))) + if (departmentIds.length !== 1) { + throw new EmployeeManagerResolutionError(`无法确定${name}所属的主组织,请先校准人员档案`) + } + const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ + where: { AND: [ + activeWhere, + { id: { not: employeeId } }, + { OR: [ + { isLeadership: true }, + { levelType: { equals: 'M', mode: 'insensitive' } }, + { positionLevel: { startsWith: 'M', mode: 'insensitive' } }, + ] }, + { departmentMemberships: { some: { sourceDeptId: departmentIds[0], isPrimary: true } } }, + ] }, + take: 2, + }) + if (managers.length === 0) { + throw new EmployeeManagerResolutionError(`${name}所属组织内没有可用的M级主管,请先校准人员档案`) + } + if (managers.length > 1) { + throw new EmployeeManagerResolutionError(`${name}所属组织内存在多名M级人员,请明确配置直属主管`) + } + return managers[0] +} diff --git a/lib/external-auto-listing.ts b/lib/external-auto-listing.ts index a883399..0f0fd14 100644 --- a/lib/external-auto-listing.ts +++ b/lib/external-auto-listing.ts @@ -762,6 +762,7 @@ async function buildTaskDetailNotificationPayload(materialId: string) { id: materialId, deletedAt: null, materialType: '任务-详情', + taskDetailPrivate: false, isFullSet: true, }, select: { @@ -840,6 +841,7 @@ async function buildTaskDetailBarcodeNotificationPayload(materialId: string, ass id: materialId, deletedAt: null, materialType: '任务-详情', + taskDetailPrivate: false, }, select: { id: true, @@ -903,6 +905,7 @@ async function buildTaskDetailBarcodeNotificationPayload(materialId: string, ass material: { deletedAt: null, materialType: '任务-详情', + taskDetailPrivate: false, }, }, }, diff --git a/lib/material-authorization.ts b/lib/material-authorization.ts index 97d49ca..e928c0a 100644 --- a/lib/material-authorization.ts +++ b/lib/material-authorization.ts @@ -1,4 +1,5 @@ import { prisma } from '@/lib/prisma' +import { canAccessMaterialPrivacy, TASK_DETAIL_PRIVACY_SELECT, type TaskDetailPrivacyMaterial } from '@/lib/task-detail-privacy' import { isSuperAdmin, isSystemAdmin, @@ -12,7 +13,7 @@ export interface MaterialAuthorizationUser { departmentId?: string | null } -export interface MaterialAuthorizationTarget { +export interface MaterialAuthorizationTarget extends TaskDetailPrivacyMaterial { /** 素材 ID。当调用方未查询 custodyDepartmentId 时,用它补查 */ id?: string userId: string @@ -60,6 +61,16 @@ export async function canManageMaterial( user: MaterialAuthorizationUser, material: MaterialAuthorizationTarget, ): Promise { + let privacyMaterial: TaskDetailPrivacyMaterial = material + if (material.taskDetailPrivate === undefined && material.id) { + const stored = await prisma.material.findUnique({ + where: { id: material.id }, + select: TASK_DETAIL_PRIVACY_SELECT, + }) + if (!stored) return false + privacyMaterial = stored + } + if (!(await canAccessMaterialPrivacy(privacyMaterial, user.id))) return false if ( material.userId === user.id || isSystemAdmin(user.role) || diff --git a/lib/material-query.ts b/lib/material-query.ts index a379fe3..fe947bd 100644 --- a/lib/material-query.ts +++ b/lib/material-query.ts @@ -11,6 +11,8 @@ import { normalizeVideoMaterialType, } from './video-material-types' import type { Prisma } from '@prisma/client' +import { canUserAccessPrivateTaskDetail } from './user-permission-inherit' +import { privateTaskDetailAccessWhere } from './task-detail-privacy' export type MaterialFilterDimension = | 'materialType' @@ -37,6 +39,7 @@ export interface MaterialQueryContext { canAccessDeleted: boolean expandedViewableTypes: string[] isSuperAdmin: boolean + canAccessPrivateTaskDetails?: boolean searchOrConditions?: any[] } @@ -325,9 +328,10 @@ export async function createMaterialQueryContext( const requestedDeleted = isDeletedMaterialRequest(searchParams) const search = searchParams.get('search') || '' - const [superAdmin, searchOrConditions] = await Promise.all([ + const [superAdmin, searchOrConditions, canAccessPrivateTaskDetails] = await Promise.all([ isSessionUserSuperAdmin(user), buildMaterialSearchOrConditions(search), + canUserAccessPrivateTaskDetail(user.id), ]) const canAccessDeleted = superAdmin || isSystemAdmin(user.role || '') @@ -340,6 +344,7 @@ export async function createMaterialQueryContext( canAccessDeleted, expandedViewableTypes: [], isSuperAdmin: true, + canAccessPrivateTaskDetails, searchOrConditions, } } @@ -355,6 +360,7 @@ export async function createMaterialQueryContext( canAccessDeleted, expandedViewableTypes: expandCompatibleMaterialTypes(viewableTypes), isSuperAdmin: false, + canAccessPrivateTaskDetails, searchOrConditions, } } @@ -423,28 +429,20 @@ export async function buildMaterialWhereFromSearchParams( where.materialType = materialTypeWhere(requestedTypes) } - if (context.isSuperAdmin) { - return { where, empty: false } + if (!context.isSuperAdmin && requestedTypes.length > 0 && !requestedTypes.includes('任务-详情')) { + const allowed = requestedTypes.filter((type) => context.expandedViewableTypes.includes(type)) + if (allowed.length === 0) return { where, empty: true } + where.materialType = materialTypeWhere(allowed) } - const 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) + const publicScope = context.isSuperAdmin + ? { taskDetailPrivate: false } + : { taskDetailPrivate: false, materialType: { in: context.expandedViewableTypes } } + // 私密准入按人员/权限组授予,管理员和上传者不隐式豁免;公开素材仍用原有类型权限。 + where.AND = [{ OR: [ + publicScope, + privateTaskDetailAccessWhere(user.id, context.canAccessPrivateTaskDetails === true), + ] }] return { where, empty: false } } diff --git a/lib/material-update.ts b/lib/material-update.ts index d1ec6c6..6bc8a43 100644 --- a/lib/material-update.ts +++ b/lib/material-update.ts @@ -286,6 +286,9 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption } const currentTaskDetailRequesterUserId = normalizeOptionalString(material.taskDetailRequesterUserId) + if (material.taskDetailPrivate && materialType !== '任务-详情') { + return { error: '私密优化素材不能改为其他素材类型', status: 400 as const } + } const currentTaskDetailRequesterName = normalizeOptionalString(material.taskDetailRequesterName) let nextTaskDetailRequesterUserId = currentTaskDetailRequesterUserId let nextTaskDetailRequesterName = currentTaskDetailRequesterName diff --git a/lib/organization-material-permissions.ts b/lib/organization-material-permissions.ts index b936983..7fa33e6 100644 --- a/lib/organization-material-permissions.ts +++ b/lib/organization-material-permissions.ts @@ -5,10 +5,29 @@ export type MaterialPermissionFlags = { canShare: boolean /** 任务-详情单图编辑(替换/改名/删除):仅对「任务-详情」类型生效,其余类型忽略 */ canEditAssets: boolean + /** 私密任务-详情查看/下载:仅对「任务-详情」类型生效,其余类型忽略 */ + canAccessPrivate: boolean } export type PermissionPackMap = Record +type TaskDetailOnlyFlagInput = { + canEditAssets?: boolean + canAccessPrivate?: boolean +} + +/** 任务-详情以外的素材类型,不允许带出这两个组级特权位。 */ +export function normalizeTaskDetailOnlyFlags( + materialType: string, + flags: TaskDetailOnlyFlagInput = {}, +): Pick { + const isTaskDetail = materialType === '任务-详情' + return { + canEditAssets: isTaskDetail && flags.canEditAssets === true, + canAccessPrivate: isTaskDetail && flags.canAccessPrivate === true, + } +} + /** 部门是底子,用户继承是加餐。同一动作只要有一边允许就算允许。 */ export function mergeDepartmentAndInheritPacks( department: PermissionPackMap | null | undefined, @@ -30,6 +49,7 @@ export function mergeDepartmentAndInheritPacks( canDownloadSource: extra.canDownloadSource || base.canDownloadSource, canShare: extra.canShare || base.canShare, canEditAssets: extra.canEditAssets || base.canEditAssets, + canAccessPrivate: extra.canAccessPrivate || base.canAccessPrivate, } } return merged diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index 48f56f5..78ebf5f 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'crypto' +import { EmployeeManagerResolutionError, resolveEmployeeDirectManagerProfile } from '@/lib/employee-manager' import { open, rm, unlink } from 'fs/promises' import path from 'path' import type { Session } from 'next-auth' @@ -5304,85 +5305,18 @@ async function resolveAvailableDirectManagerProfile(targetEmployee: any, manager } async function resolveDirectManagerForWorkflow(targetEmployee: any) { - const managerDingtalkUserId = clean(targetEmployee?.managerDingtalkUserId) - const managerJobNumber = clean(targetEmployee?.managerJobNumber) - - // 上游已提供主管唯一值时只做精确匹配;值失效或冲突不能用组织推断掩盖。 - if (managerDingtalkUserId || managerJobNumber) { - const configuredManagerIdentity = { - ...(managerDingtalkUserId ? { dingtalkUserId: managerDingtalkUserId } : {}), - ...(managerJobNumber ? { jobNumber: managerJobNumber } : {}), + try { + const manager = await resolveEmployeeDirectManagerProfile( + { ...targetEmployee, displayName: getEmployeeDisplayName(targetEmployee) }, + { activeWhere: getActivePerformanceSurveyEmployeeProfileWhere() }, + ) + return resolveAvailableDirectManagerProfile(targetEmployee, manager) + } catch (error) { + if (error instanceof EmployeeManagerResolutionError) { + throw new PerformanceSurveyApiError(error.message, 409) } - const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ - where: { - AND: [ - getActivePerformanceSurveyEmployeeProfileWhere(), - configuredManagerIdentity, - ], - }, - take: 2, - }) - if (managers.length !== 1) { - throw new PerformanceSurveyApiError(`无法唯一匹配${getEmployeeDisplayName(targetEmployee)}的直属主管`, 409) - } - return resolveAvailableDirectManagerProfile(targetEmployee, managers[0]) + throw error } - - // 只有主管唯一值完全缺失时,才按双方的主组织唯一值推断唯一在职 M 级人员。 - const targetEmployeeId = clean(targetEmployee?.id) - if (!targetEmployeeId) { - throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409) - } - - const primaryMemberships = await prisma.performanceSurveyEmployeeDepartmentMembership.findMany({ - where: { - employeeProfileId: targetEmployeeId, - isPrimary: true, - }, - select: { sourceDeptId: true }, - take: 2, - }) - const primarySourceDeptIds = Array.from(new Set( - primaryMemberships - .map((membership) => clean(membership.sourceDeptId)) - .filter((sourceDeptId): sourceDeptId is string => Boolean(sourceDeptId)), - )) - if (primarySourceDeptIds.length !== 1) { - throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409) - } - - const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ - where: { - AND: [ - getActivePerformanceSurveyEmployeeProfileWhere(), - { id: { not: targetEmployeeId } }, - { - OR: [ - { isLeadership: true }, - { levelType: { equals: 'M', mode: 'insensitive' } }, - { positionLevel: { startsWith: 'M', mode: 'insensitive' } }, - ], - }, - { - departmentMemberships: { - some: { - sourceDeptId: primarySourceDeptIds[0], - isPrimary: true, - }, - }, - }, - ], - }, - take: 2, - }) - if (managers.length === 0) { - throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内没有可用的M级主管,请先校准人员档案`, 409) - } - if (managers.length > 1) { - throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内存在多名M级人员,请明确配置直属主管`, 409) - } - - return resolveAvailableDirectManagerProfile(targetEmployee, managers[0]) } async function resolveConfiguredWorkflowEvaluators(input: { diff --git a/lib/permission-groups.ts b/lib/permission-groups.ts index f3be4c0..861c9da 100644 --- a/lib/permission-groups.ts +++ b/lib/permission-groups.ts @@ -23,6 +23,7 @@ const PACK_ITEM_SELECT = { canDownloadSource: true, canShare: true, canEditAssets: true, + canAccessPrivate: true, } as const function packTargetDepartment(slug: string): string | null { diff --git a/lib/product-package-completion.ts b/lib/product-package-completion.ts index e584524..72736e4 100644 --- a/lib/product-package-completion.ts +++ b/lib/product-package-completion.ts @@ -20,19 +20,17 @@ import { } from '@/lib/upload-sessions' import { createMaterialLog } from '@/lib/material-log' import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set' -import { - buildTaskDetailPublicDisplayPath, - uploadPublicDisplayToS3, -} from '@/lib/public-display' import { queueTaskDetailBarcodeImagesUpdatedNotification, queueTaskDetailFullSetNotification, } from '@/lib/external-auto-listing' +import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' import path from 'path' import { mkdir, readFile, writeFile } from 'fs/promises' import { randomBytes } from 'crypto' import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type' import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' +import { saveTaskDetailPreview } from '@/lib/task-detail-preview-storage' import { withImageProcessingSlot } from '@/lib/image-processing-limiter' import { getDingtalkApprovalNoError, @@ -747,6 +745,8 @@ async function generatePreviewsFromAssets( assets: ProductPackageImageInput[] | undefined, type: 'main' | 'sku' | 'detail', uploadedPreviewUrls: string[], + privatePreviewPaths: string[], + isPrivate: boolean, assembledFiles?: Map, tempFiles?: string[], uploadIds?: string[], @@ -812,10 +812,12 @@ async function generatePreviewsFromAssets( } const fileName = safePreviewFileName(previewAsset.fileName, index, sliceIndex) - const previewUrl = await uploadPublicDisplayToS3( + const previewUrl = await saveTaskDetailPreview( buffer, - buildTaskDetailPublicDisplayPath(materialId, type, `client-${Date.now()}-${index}-${sliceIndex}-${fileName}`), - 'image/webp', + materialId, + type, + `client-${Date.now()}-${index}-${sliceIndex}-${fileName}`, + isPrivate, ) previews.push({ fileUrl: previewUrl, @@ -828,7 +830,11 @@ async function generatePreviewsFromAssets( } : {}), }) - uploadedPreviewUrls.push(previewUrl) + if (/^https?:\/\//i.test(previewUrl)) { + uploadedPreviewUrls.push(previewUrl) + } else { + privatePreviewPaths.push(previewUrl) + } } continue } @@ -880,10 +886,12 @@ async function generatePreviewsFromAssets( const randomSuffix = Math.random().toString(36).substring(2, 9) const sliceSuffix = buffers.length > 1 ? `-slice${sliceIndex}` : '' const fileName = `${type}-preview-${timestamp}-${index}${sliceSuffix}-${randomSuffix}.webp` - const previewUrl = await uploadPublicDisplayToS3( + const previewUrl = await saveTaskDetailPreview( buffers[sliceIndex], - buildTaskDetailPublicDisplayPath(materialId, type, fileName), - 'image/webp', + materialId, + type, + fileName, + isPrivate, ) previews.push({ fileUrl: previewUrl, @@ -896,13 +904,26 @@ async function generatePreviewsFromAssets( } : {}), }) - uploadedPreviewUrls.push(previewUrl) + if (/^https?:\/\//i.test(previewUrl)) { + uploadedPreviewUrls.push(previewUrl) + } else { + privatePreviewPaths.push(previewUrl) + } } } return previews } +async function cleanupGeneratedPrivatePreviews(fileUrls: string[]): Promise { + const uniquePaths = Array.from(new Set(fileUrls.filter(Boolean))) + await Promise.allSettled( + uniquePaths.map((fileUrl) => + deleteFileIfUnreferenced(fileUrl).catch(() => undefined), + ), + ) +} + /** @internal 同名 PSD 也必须生成不同的持久化路径。 */ export function buildPsdPermanentFileName( materialId: string, @@ -1205,6 +1226,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage const uploadIds: string[] = [] const assembledFiles = new Map() const uploadedPreviewUrls: string[] = [] + const privatePreviewPaths: string[] = [] const createdZipPaths: string[] = [] const requestedUploadIds = collectProductPackageUploadIds(assets) let persistentStateCommitted = false @@ -1325,9 +1347,39 @@ export async function completeProductPackageUpload(input: CompleteProductPackage await reportProgress('assembling', 30) const previewAssets = { - main: await generatePreviewsFromAssets(materialId, normalizedAssets.main, 'main', uploadedPreviewUrls, assembledFiles, tempFiles, uploadIds), - sku: await generatePreviewsFromAssets(materialId, normalizedAssets.sku, 'sku', uploadedPreviewUrls, assembledFiles, tempFiles, uploadIds), - detail: await generatePreviewsFromAssets(materialId, normalizedAssets.detail, 'detail', uploadedPreviewUrls, assembledFiles, tempFiles, uploadIds), + main: await generatePreviewsFromAssets( + materialId, + normalizedAssets.main, + 'main', + uploadedPreviewUrls, + privatePreviewPaths, + Boolean(material.taskDetailPrivate), + assembledFiles, + tempFiles, + uploadIds, + ), + sku: await generatePreviewsFromAssets( + materialId, + normalizedAssets.sku, + 'sku', + uploadedPreviewUrls, + privatePreviewPaths, + Boolean(material.taskDetailPrivate), + assembledFiles, + tempFiles, + uploadIds, + ), + detail: await generatePreviewsFromAssets( + materialId, + normalizedAssets.detail, + 'detail', + uploadedPreviewUrls, + privatePreviewPaths, + Boolean(material.taskDetailPrivate), + assembledFiles, + tempFiles, + uploadIds, + ), } await reportProgress('previews', 55) @@ -1579,6 +1631,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage [], false, ) + await cleanupGeneratedPrivatePreviews(privatePreviewPaths) throw error } finally { await releaseUploadSessionLocks?.() diff --git a/lib/product-package.ts b/lib/product-package.ts index e6aa803..8d73a84 100644 --- a/lib/product-package.ts +++ b/lib/product-package.ts @@ -117,7 +117,7 @@ export async function updateTaskStatus( */ export async function getMaterialByTaskNo(taskNo: string) { return prisma.material.findFirst({ - where: { taskNo, deletedAt: null }, + where: { taskNo, deletedAt: null, taskDetailPrivate: false }, include: { assets: { orderBy: { sortOrder: 'asc' }, @@ -263,7 +263,7 @@ export async function softDeleteByTaskNo(taskNo: string) { export async function getMaterialById(materialId: string) { return prisma.material.findFirst({ - where: { id: materialId, deletedAt: null }, + where: { id: materialId, deletedAt: null, taskDetailPrivate: false }, include: { assets: { orderBy: { sortOrder: 'asc' }, diff --git a/lib/task-detail-asset-edit.ts b/lib/task-detail-asset-edit.ts index af0e923..462caf8 100644 --- a/lib/task-detail-asset-edit.ts +++ b/lib/task-detail-asset-edit.ts @@ -9,14 +9,15 @@ import { } from '@/lib/material-upload-cross-process-lock' import { isProductPackageType } from '@/lib/material-type-mapper' import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set' +import { + canAccessMaterialPrivacy, + TASK_DETAIL_PRIVACY_SELECT, +} from '@/lib/task-detail-privacy' +import { saveTaskDetailPreview } from '@/lib/task-detail-preview-storage' import { queueTaskDetailBarcodeImagesUpdatedNotification, queueTaskDetailFullSetNotification, } from '@/lib/external-auto-listing' -import { - buildTaskDetailPublicDisplayPath, - uploadPublicDisplayToS3, -} from '@/lib/public-display' import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' import { withImageProcessingSlot } from '@/lib/image-processing-limiter' import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' @@ -97,6 +98,7 @@ interface PreviewRun { interface EditContext { materialId: string materialType: string + taskDetailPrivate: boolean productModel: string | null authorName: string | null categoryName: string | null @@ -562,6 +564,7 @@ async function loadEditContext(options: { select: { id: true, userId: true, + ...TASK_DETAIL_PRIVACY_SELECT, materialType: true, taskStatus: true, productModel: true, @@ -576,6 +579,9 @@ async function loadEditContext(options: { if (!material) { throw new TaskDetailAssetEditError('素材不存在或已被删除', 404) } + if (!(await canAccessMaterialPrivacy(material, options.operatorUserId))) { + throw new TaskDetailAssetEditError('您没有权限编辑此素材', 403) + } if (!isProductPackageType(material.materialType)) { throw new TaskDetailAssetEditError('只有任务-详情类型支持单图编辑') } @@ -669,6 +675,7 @@ async function loadEditContext(options: { return { materialId: material.id, materialType: material.materialType, + taskDetailPrivate: Boolean(material.taskDetailPrivate), productModel: material.productModel, authorName: material.authorName, categoryName: material.categoryName, @@ -754,6 +761,7 @@ async function loadEditContext(options: { return { materialId: material.id, materialType: material.materialType, + taskDetailPrivate: Boolean(material.taskDetailPrivate), productModel: material.productModel, authorName: material.authorName, categoryName: material.categoryName, @@ -1092,9 +1100,11 @@ async function generateAnimatedPreviews(options: { source: ResolvedMutationSource group: TaskDetailEditGroup materialId: string + isPrivate: boolean sourceBaseName: string entryIndex: number workspaceDir: string + generatedPreviewLocations: string[] }): Promise> { const width = Number(options.source.metadata.width) const height = getLogicalImageHeight(options.source.metadata) @@ -1139,11 +1149,14 @@ async function generateAnimatedPreviews(options: { cropHeight, }) const previewBuffer = await readFile(outputPath) - const fileUrl = await uploadPublicDisplayToS3( + const fileUrl = await saveTaskDetailPreview( previewBuffer, - buildTaskDetailPublicDisplayPath(options.materialId, options.group, fileName), - 'image/webp', + options.materialId, + options.group, + fileName, + options.isPrivate, ) + rememberGeneratedPrivatePreview(options.generatedPreviewLocations, fileUrl) previews.push({ fileName, fileUrl, size: previewBuffer.length }) } return previews @@ -1154,18 +1167,22 @@ async function generateServerPreviews( source: ResolvedMutationSource, group: TaskDetailEditGroup, materialId: string, + isPrivate: boolean, sourceBaseName: string, entryIndex: number, workspaceDir: string, + generatedPreviewLocations: string[], ): Promise> { if (source.animated) { return generateAnimatedPreviews({ source, group, materialId, + isPrivate, sourceBaseName, entryIndex, workspaceDir, + generatedPreviewLocations, }) } @@ -1218,16 +1235,27 @@ async function generateServerPreviews( entryIndex, buffers.length > 1 ? sliceIndex : null, ) - const fileUrl = await uploadPublicDisplayToS3( + const fileUrl = await saveTaskDetailPreview( buffers[sliceIndex], - buildTaskDetailPublicDisplayPath(materialId, group, fileName), - 'image/webp', + materialId, + group, + fileName, + isPrivate, ) + rememberGeneratedPrivatePreview(generatedPreviewLocations, fileUrl) previews.push({ fileName, fileUrl, size: buffers[sliceIndex].length }) } return previews } +function rememberGeneratedPrivatePreview(targets: string[], fileUrl: string) { + const normalized = String(fileUrl || '').trim().replace(/\\/g, '/') + if (!normalized || /^https?:\/\//i.test(normalized) || !normalized.includes('/private-previews/')) { + return + } + targets.push(fileUrl) +} + async function loadPreviewRowMetadata(row: PreviewRowLike): Promise<{ width: number; height: number }> { const sharp = await loadSharp() const target = String(row.fileUrl || '').trim() @@ -1501,6 +1529,7 @@ interface RebuildOutcome { nextEntryNames: string[] newGroupZip: { filePath: string; fileName: string; fileSize: number } | null newAllZip: { filePath: string; fileName: string; fileSize: number } | null + generatedPreviewLocations: string[] db: PlannedDbChanges finalRowCountByGroup: Record firstPreviewUrlByGroup: Record @@ -1511,6 +1540,7 @@ async function planPreviewRowChanges( mutation: EditMutation, finalEntryName: string, workspaceDir: string, + generatedPreviewLocations: string[], ): Promise { const { group, runs, targetRunIndex, targetEntryIndex } = context const targetRun = runs[targetRunIndex] @@ -1540,9 +1570,11 @@ async function planPreviewRowChanges( replacementSource, group, context.materialId, + context.taskDetailPrivate, stripExtension(finalEntryName), targetEntryIndex, workspaceDir, + generatedPreviewLocations, ) const barcodes = await loadRunBarcodes(targetRun.rows.map((row) => row.id)) for (const row of targetRun.rows) { @@ -1666,8 +1698,10 @@ async function buildNewBatchRunState(options: { entryName: string group: TaskDetailEditGroup materialId: string + isPrivate: boolean entryIndex: number workspaceDir: string + generatedPreviewLocations: string[] skuCode?: string | null barcodes?: string[] sourceAssetIds?: string[] @@ -1676,9 +1710,11 @@ async function buildNewBatchRunState(options: { options.source, options.group, options.materialId, + options.isPrivate, stripExtension(options.entryName), options.entryIndex, options.workspaceDir, + options.generatedPreviewLocations, ) if (options.sourceAssetIds && options.sourceAssetIds.length !== previews.length) { throw new TaskDetailAssetEditError('长图拆分后的预览数量与原接缝不一致,无法继续批量插图,请刷新后重试') @@ -1882,6 +1918,7 @@ async function executeBatchRebuild( ): Promise { const { group, entryNames } = context const workspace = await createTaskDetailEditWorkspace() + const generatedPreviewLocations: string[] = [] try { const extracted = context.groupZipAssets[group]?.fileUrl @@ -1927,8 +1964,10 @@ async function executeBatchRebuild( entryName, group, materialId: context.materialId, + isPrivate: context.taskDetailPrivate, entryIndex: finalRuns.length, workspaceDir: workspace.dir, + generatedPreviewLocations, skuCode: operation.mutation.skuCode, barcodes: operation.mutation.barcodes, })) @@ -1959,8 +1998,10 @@ async function executeBatchRebuild( entryName: insertedEntryName, group, materialId: context.materialId, + isPrivate: context.taskDetailPrivate, entryIndex: boundaryIndex, workspaceDir: workspace.dir, + generatedPreviewLocations, }) finalRuns.splice(boundaryIndex, 0, insertedRun) continue @@ -1979,8 +2020,10 @@ async function executeBatchRebuild( entryName: insertedEntryName, group, materialId: context.materialId, + isPrivate: context.taskDetailPrivate, entryIndex: seam.afterRunIndex + 1, workspaceDir: workspace.dir, + generatedPreviewLocations, }) finalRuns.splice(seam.beforeRunIndex, 0, insertedRun) repeatedInsertBoundaries.set(seamKey, boundaryRun) @@ -2017,8 +2060,10 @@ async function executeBatchRebuild( entryName: splitSources.top.entryName, group, materialId: context.materialId, + isPrivate: context.taskDetailPrivate, entryIndex: seam.afterRunIndex, workspaceDir: workspace.dir, + generatedPreviewLocations, sourceAssetIds: sourceAssetIds.slice(0, seam.afterRowIndex + 1) as string[], }) const insertedRun = await buildNewBatchRunState({ @@ -2026,16 +2071,20 @@ async function executeBatchRebuild( entryName: insertedEntryName, group, materialId: context.materialId, + isPrivate: context.taskDetailPrivate, entryIndex: seam.afterRunIndex + 1, workspaceDir: workspace.dir, + generatedPreviewLocations, }) const bottomRun = await buildNewBatchRunState({ source: splitSources.bottom, entryName: splitSources.bottom.entryName, group, materialId: context.materialId, + isPrivate: context.taskDetailPrivate, entryIndex: seam.afterRunIndex + 2, workspaceDir: workspace.dir, + generatedPreviewLocations, sourceAssetIds: sourceAssetIds.slice(seam.beforeRowIndex) as string[], }) finalRuns.splice(seam.afterRunIndex, 1, topRun, insertedRun, bottomRun) @@ -2092,8 +2141,10 @@ async function executeBatchRebuild( entryName: finalEntryName, group, materialId: context.materialId, + isPrivate: context.taskDetailPrivate, entryIndex: located.runIndex, workspaceDir: workspace.dir, + generatedPreviewLocations, skuCode: run.rows[0]?.skuCode || null, barcodes, }) @@ -2165,10 +2216,14 @@ async function executeBatchRebuild( nextEntryNames: finalRuns.map((run) => run.entry.name), newGroupZip, newAllZip, + generatedPreviewLocations, db, finalRowCountByGroup, firstPreviewUrlByGroup, } + } catch (error) { + await cleanupGeneratedPreviewLocations(generatedPreviewLocations) + throw error } finally { await workspace.cleanup() } @@ -2180,6 +2235,7 @@ async function executeRebuild( ): Promise { const { group, entryNames, targetEntryIndex } = context const workspace = await createTaskDetailEditWorkspace() + const generatedPreviewLocations: string[] = [] try { const groupZip = context.groupZipAssets[group]! @@ -2269,7 +2325,13 @@ async function executeRebuild( ) } - const db = await planPreviewRowChanges(context, mutation, finalEntryName, workspace.dir) + const db = await planPreviewRowChanges( + context, + mutation, + finalEntryName, + workspace.dir, + generatedPreviewLocations, + ) // 计算各组最终预览行数与首图地址(用于封面回退和整套判定) const finalRowCountByGroup: Record = { @@ -2304,10 +2366,14 @@ async function executeRebuild( nextEntryNames: finalEntries.map((entry) => entry.name), newGroupZip, newAllZip, + generatedPreviewLocations, db, finalRowCountByGroup, firstPreviewUrlByGroup, } + } catch (error) { + await cleanupGeneratedPreviewLocations(generatedPreviewLocations) + throw error } finally { await workspace.cleanup() } @@ -2554,6 +2620,30 @@ async function cleanupAfterCommit(context: EditContext, outcome: RebuildOutcome) await Promise.allSettled(tasks) } +async function cleanupGeneratedPreviewLocations(fileUrls: string[]) { + const uniqueUrls = Array.from(new Set( + fileUrls.filter((fileUrl) => String(fileUrl || '').replace(/\\/g, '/').includes('/private-previews/')), + )) + await Promise.allSettled( + uniqueUrls.map((fileUrl) => + deleteFileIfUnreferenced(fileUrl).catch(() => undefined), + ), + ) +} + +async function commitRebuildWithCleanup( + context: EditContext, + outcome: RebuildOutcome, + operationLabel: 'REPLACE_IMAGE' | 'RENAME_IMAGE' | 'DELETE_IMAGE' | 'BATCH_IMAGE_EDITS', +) { + try { + return await commitRebuild(context, outcome, operationLabel) + } catch (error) { + await cleanupGeneratedPreviewLocations(outcome.generatedPreviewLocations) + throw error + } +} + async function runEditOperation(materialId: string, operation: () => Promise): Promise { const release = await acquireTaskMaterialCompletionLock(materialId) try { @@ -2591,7 +2681,7 @@ export async function replaceTaskDetailImage(input: ReplaceTaskDetailImageInput) replacementFileName: source.fileName, buffer: source.buffer, }) - const { nextIsFullSet } = await commitRebuild(context, outcome, 'REPLACE_IMAGE') + const { nextIsFullSet } = await commitRebuildWithCleanup(context, outcome, 'REPLACE_IMAGE') await cleanupAfterCommit(context, outcome) if (input.uploadId) { await cleanupConsumedUploadSession(input.uploadId.trim()).catch(() => undefined) @@ -2648,7 +2738,7 @@ export async function renameTaskDetailImage(input: RenameTaskDetailImageInput) { } const outcome = await executeRebuild(context, { kind: 'rename', newBaseName: baseName }) - await commitRebuild(context, outcome, 'RENAME_IMAGE') + await commitRebuildWithCleanup(context, outcome, 'RENAME_IMAGE') await cleanupAfterCommit(context, outcome) return { @@ -2669,7 +2759,7 @@ export async function deleteTaskDetailImage(input: DeleteTaskDetailImageInput) { return runEditOperation(input.materialId, async () => { const context = await loadEditContext(input) const outcome = await executeRebuild(context, { kind: 'delete' }) - await commitRebuild(context, outcome, 'DELETE_IMAGE') + await commitRebuildWithCleanup(context, outcome, 'DELETE_IMAGE') await cleanupAfterCommit(context, outcome) if (!context.wasFullSet) { @@ -2856,7 +2946,7 @@ export async function batchEditTaskDetailImages(input: BatchTaskDetailImageEditI } const outcome = await executeBatchRebuild(context, prepared) - const { nextIsFullSet } = await commitRebuild(context, outcome, 'BATCH_IMAGE_EDITS') + const { nextIsFullSet } = await commitRebuildWithCleanup(context, outcome, 'BATCH_IMAGE_EDITS') await cleanupAfterCommit(context, outcome) for (const operation of prepared) { diff --git a/lib/task-detail-change-approval.ts b/lib/task-detail-change-approval.ts index 8ebf7b9..fbcc099 100644 --- a/lib/task-detail-change-approval.ts +++ b/lib/task-detail-change-approval.ts @@ -3,6 +3,8 @@ import { randomUUID } from 'crypto' import { getEmployeeByUserId, BINDING_STATUS } from '@/lib/employee-directory' import { canUserEditTaskDetailAssets } from '@/lib/user-permission-inherit' import { canManageMaterial } from '@/lib/material-authorization' +import { EmployeeManagerResolutionError, resolveEmployeeDirectManagerProfile } from '@/lib/employee-manager' +import { canAccessMaterialPrivacy, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy' import { isProductPackageType } from '@/lib/material-type-mapper' import { prisma } from '@/lib/prisma' import { getPublicAppOrigin } from '@/lib/public-app-url' @@ -173,6 +175,7 @@ async function loadAuthorizedContext(materialId: string, actor: Actor) { id: true, userId: true, materialType: true, + ...TASK_DETAIL_PRIVACY_SELECT, taskStatus: true, taskNo: true, productModel: true, @@ -186,6 +189,9 @@ async function loadAuthorizedContext(materialId: string, actor: Actor) { getEmployeeByUserId(actor.id), ]) if (!material) throw new TaskDetailChangeApprovalError('素材不存在或已删除', 404) + if (!(await canAccessMaterialPrivacy(material, actor.id))) { + throw new TaskDetailChangeApprovalError('无权限访问该私密素材', 403) + } if (!isProductPackageType(material.materialType)) { throw new TaskDetailChangeApprovalError('只有任务-详情素材支持图片变更') } @@ -221,9 +227,18 @@ function employeeSnapshot(employee: NonNullable>>) { - const managerDingtalkUserId = clean(employee.managerDingtalkUserId) + let resolvedManager + try { + resolvedManager = await resolveEmployeeDirectManagerProfile(employee) + } catch (error) { + if (error instanceof EmployeeManagerResolutionError) { + throw new TaskDetailChangeApprovalError(error.message, 409) + } + throw error + } + const managerDingtalkUserId = clean(resolvedManager.dingtalkUserId) if (!managerDingtalkUserId) { - throw new TaskDetailChangeApprovalError('花名册没有记录直属主管,暂不能提交图片变更申请', 409) + throw new TaskDetailChangeApprovalError('直属主管缺少钉钉唯一值,暂不能提交图片变更申请', 409) } const manager = await prisma.performanceSurveyEmployeeProfile.findUnique({ where: { dingtalkUserId: managerDingtalkUserId }, @@ -422,13 +437,16 @@ async function loadRequestForActor(id: string, actor: Actor) { prisma.taskDetailChangeRequest.findUnique({ where: { id }, include: { - material: { select: { id: true, taskNo: true, productModel: true, materialType: true, updatedAt: true } }, + material: { select: { id: true, taskNo: true, productModel: true, materialType: true, updatedAt: true, ...TASK_DETAIL_PRIVACY_SELECT } }, requesterEmployee: { select: { dingtalkUserId: true } }, }, }), getEmployeeByUserId(actor.id), ]) if (!request) throw new TaskDetailChangeApprovalError('图片变更申请不存在', 404) + if (!(await canAccessMaterialPrivacy(request.material, actor.id))) { + throw new TaskDetailChangeApprovalError('无权限访问该私密素材', 403) + } const canDecide = Boolean( employee?.dingtalkUserId && employee.dingtalkUserId === request.managerDingtalkUserId, ) @@ -463,6 +481,13 @@ export async function getTaskDetailChangeRequest(id: string, actor: Actor) { } export async function listTaskDetailChanges(materialId: string, actor: Actor) { + const material = await prisma.material.findFirst({ + where: { id: materialId, deletedAt: null }, + select: TASK_DETAIL_PRIVACY_SELECT, + }) + if (!material || !(await canAccessMaterialPrivacy(material, actor.id))) { + throw new TaskDetailChangeApprovalError('无权限访问该素材', 403) + } const employee = await getEmployeeByUserId(actor.id) const privileged = Boolean(actor.isSuperAdmin || isSystemAdmin(String(actor.role || ''))) const rows = await prisma.taskDetailChangeRequest.findMany({ diff --git a/lib/task-detail-preview-storage.ts b/lib/task-detail-preview-storage.ts new file mode 100644 index 0000000..2a57060 --- /dev/null +++ b/lib/task-detail-preview-storage.ts @@ -0,0 +1,59 @@ +import { mkdir, writeFile } from 'fs/promises' +import path from 'path' +import { randomUUID } from 'crypto' + +import { + buildTaskDetailPublicDisplayPath, + uploadPublicDisplayToS3, +} from '@/lib/public-display' +import { resolveStoredUploadPath } from '@/lib/upload-paths' + +export type TaskDetailPreviewGroup = 'main' | 'sku' | 'detail' + +function buildPrivatePreviewFileName( + group: TaskDetailPreviewGroup, + fileName: string, +): string { + const normalizedFileName = String(fileName || '').trim().replace(/\\/g, '/') + const parsed = path.posix.parse(path.posix.basename(normalizedFileName)) + const safeBaseName = (parsed.name || `${group}-preview`) + .replace(/[\/\\]/g, '_') + .replace(/\.\./g, '_') + .replace(/[\x00-\x1f]/g, '') + .replace(/[<>:"|?*]/g, '_') + .trim() + .slice(0, 80) + const uniqueBaseName = safeBaseName || `${group}-preview` + return `${Date.now()}-${randomUUID()}-${uniqueBaseName}.webp` +} + +export async function saveTaskDetailPreview( + buffer: Buffer, + materialId: string, + group: TaskDetailPreviewGroup, + fileName: string, + isPrivate: boolean, +): Promise { + if (!isPrivate) { + return uploadPublicDisplayToS3( + buffer, + buildTaskDetailPublicDisplayPath(materialId, group, fileName), + 'image/webp', + ) + } + + const storedPath = path.posix.join( + 'uploads', + 'materials', + materialId, + 'private-previews', + group, + buildPrivatePreviewFileName(group, fileName), + ) + const absolutePath = resolveStoredUploadPath(storedPath) + + await mkdir(path.dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, buffer) + + return storedPath +} diff --git a/lib/task-detail-privacy-fields.ts b/lib/task-detail-privacy-fields.ts new file mode 100644 index 0000000..22f532c --- /dev/null +++ b/lib/task-detail-privacy-fields.ts @@ -0,0 +1,56 @@ +export const TASK_DETAIL_DEMAND_TYPE_OPTIONS = ['新品', '优化'] as const + +export type TaskDetailDemandType = (typeof TASK_DETAIL_DEMAND_TYPE_OPTIONS)[number] + +export interface TaskDetailPrivacyFieldInput { + taskDetailDemandType?: unknown + taskDetailPrivate?: unknown +} + +export interface NormalizedTaskDetailPrivacyFields { + taskDetailDemandType: TaskDetailDemandType + taskDetailPrivate: boolean +} + +export function isTaskDetailDemandType(value: unknown): value is TaskDetailDemandType { + return TASK_DETAIL_DEMAND_TYPE_OPTIONS.includes(value as TaskDetailDemandType) +} + +export function normalizeTaskDetailPrivacyFields( + input: TaskDetailPrivacyFieldInput, +): NormalizedTaskDetailPrivacyFields { + const rawDemandType = input?.taskDetailDemandType + const rawPrivate = input?.taskDetailPrivate + + let taskDetailDemandType: TaskDetailDemandType = '新品' + if (rawDemandType != null) { + if (typeof rawDemandType !== 'string') { + throw new Error('需求类型只能是“新品”或“优化”') + } + + const normalizedDemandType = rawDemandType.trim() + if (!normalizedDemandType || !isTaskDetailDemandType(normalizedDemandType)) { + throw new Error('需求类型只能是“新品”或“优化”') + } + + taskDetailDemandType = normalizedDemandType + } + + let taskDetailPrivate = false + if (rawPrivate != null) { + if (typeof rawPrivate !== 'boolean') { + throw new Error('私密标记只能传 true 或 false') + } + + taskDetailPrivate = rawPrivate + } + + if (taskDetailPrivate && taskDetailDemandType !== '优化') { + throw new Error('仅优化需求支持私密上传') + } + + return { + taskDetailDemandType, + taskDetailPrivate, + } +} diff --git a/lib/task-detail-privacy.ts b/lib/task-detail-privacy.ts new file mode 100644 index 0000000..f7e8ab6 --- /dev/null +++ b/lib/task-detail-privacy.ts @@ -0,0 +1,43 @@ +import { canUserAccessPrivateTaskDetail } from '@/lib/user-permission-inherit' + +export const TASK_DETAIL_PRIVACY_SELECT = { + taskDetailPrivate: true, + taskDetailDemandType: true, + taskDetailRequesterUserId: true, +} as const + +export interface TaskDetailPrivacyMaterial { + materialType?: string | null + taskDetailPrivate?: boolean | null + taskDetailRequesterUserId?: string | null +} + +export function isPrivateTaskDetail(material: TaskDetailPrivacyMaterial): boolean { + return material.taskDetailPrivate === true +} + +/** 素材级准入先于上传者/管理员/分享/类型权限,不能被这些旧放行路径绕过。 */ +export async function canAccessMaterialPrivacy( + material: TaskDetailPrivacyMaterial, + userId?: string | null, +): Promise { + if (!isPrivateTaskDetail(material)) return true + if (!userId) return false + if (material.taskDetailRequesterUserId === userId) return true + return canUserAccessPrivateTaskDetail(userId) +} + +export function privateTaskDetailAccessWhere(userId: string, canAccessAll: boolean) { + return { + materialType: '任务-详情', + taskDetailPrivate: true, + ...(canAccessAll ? {} : { taskDetailRequesterUserId: userId }), + } +} + +export async function buildMaterialPrivacyWhere(userId: string) { + return { OR: [ + { taskDetailPrivate: false }, + privateTaskDetailAccessWhere(userId, await canUserAccessPrivateTaskDetail(userId)), + ] } +} diff --git a/lib/url-rewrite.ts b/lib/url-rewrite.ts index 6bdc48a..c549347 100644 --- a/lib/url-rewrite.ts +++ b/lib/url-rewrite.ts @@ -162,6 +162,11 @@ export function rewriteStorageUrlForClient(url?: string | null, request?: NextRe const trimmed = url.trim() if (!trimmed) return null + // 私密预览只能从当前应用的鉴权路由读取,不能变成相对页面地址或 CDN 直链。 + if (/^uploads\/materials\/[^/]+\/private-previews\//.test(trimmed)) { + return `/api/${trimmed.split('/').map(encodeURIComponent).join('/')}` + } + // 非 http(s) 直接返回(可能是相对路径或 data: URL) if (!/^https?:\/\//i.test(trimmed)) { return trimmed diff --git a/lib/user-permission-inherit.ts b/lib/user-permission-inherit.ts index e6f7628..03dd4e1 100644 --- a/lib/user-permission-inherit.ts +++ b/lib/user-permission-inherit.ts @@ -1,6 +1,7 @@ import { Prisma } from '@prisma/client' import { prisma } from './prisma' import { + normalizeTaskDetailOnlyFlags, unionPermissionPacks, type MaterialPermissionFlags, type PermissionPackMap, @@ -23,16 +24,19 @@ export function packItemsToMap( canDownloadSource: boolean canShare: boolean canEditAssets?: boolean + canAccessPrivate?: boolean }>, ): PermissionPackMap { const pack: PermissionPackMap = {} for (const item of items) { + const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item) pack[item.materialType] = { canView: item.canView, canDownload: item.canDownload, canDownloadSource: item.canDownloadSource, canShare: item.canShare, - canEditAssets: item.canEditAssets === true, + canEditAssets: taskDetailOnlyFlags.canEditAssets, + canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate, } } return pack @@ -68,6 +72,8 @@ export async function ensureDefaultPermissionPacks() { canDownload: row.canDownload, canDownloadSource: row.canDownloadSource, canShare: row.canShare, + canEditAssets: false, + canAccessPrivate: false, })), }) } @@ -83,6 +89,7 @@ const PACK_ITEM_SELECT = { canDownloadSource: true, canShare: true, canEditAssets: true, + canAccessPrivate: true, } as const export async function loadUserInheritPack(userId: string): Promise { @@ -122,6 +129,21 @@ export async function canUserEditTaskDetailAssets(userId: string): Promise { + if (!userId) return false + try { + const { resolveRosterPermissionPack } = await import('./department-permission') + const pack = await resolveRosterPermissionPack(userId) + return pack?.['任务-详情']?.canAccessPrivate === true + } catch (error) { + console.error('[user-permission-inherit] 读取任务-详情私密权限失败:', error) + return false + } +} + /** * 视频提前访问豁免:用户加入的任意权限组开启了该组级能力即获得。 * 部门进组是素材可见范围的底子,不参与此判定;豁免按"人进组是加餐"的口径只认个人挂载。 @@ -155,15 +177,19 @@ export async function savePermissionPackItems( prisma.materialPermissionPackItem.createMany({ data: permissions .filter((item) => item.materialType) - .map((item) => ({ - packId: pack.id, - materialType: item.materialType, - canView: item.canView, - canDownload: item.canDownload, - canDownloadSource: item.canDownloadSource, - canShare: item.canShare, - canEditAssets: item.canEditAssets === true, - })), + .map((item) => { + const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item) + return { + packId: pack.id, + materialType: item.materialType, + canView: item.canView, + canDownload: item.canDownload, + canDownloadSource: item.canDownloadSource, + canShare: item.canShare, + canEditAssets: taskDetailOnlyFlags.canEditAssets, + canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate, + } + }), }), ]) } diff --git a/prisma/migrations/20260831_add_private_task_detail/migration.sql b/prisma/migrations/20260831_add_private_task_detail/migration.sql new file mode 100644 index 0000000..786ba99 --- /dev/null +++ b/prisma/migrations/20260831_add_private_task_detail/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "materials" + ADD COLUMN "task_detail_demand_type" TEXT, + ADD COLUMN "task_detail_private" BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE "materials" ADD CONSTRAINT "materials_private_optimization_check" + CHECK (NOT "task_detail_private" OR ("materialType" = '任务-详情' AND "task_detail_demand_type" = '优化') IS TRUE); + +ALTER TABLE "material_permission_pack_items" + ADD COLUMN "can_access_private" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index db1e1a1..5ba0aa4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,6 +118,8 @@ model Material { taskDetailSourceType String? @map("task_detail_source_type") taskDetailRequesterUserId String? @map("task_detail_requester_user_id") taskDetailRequesterName String? @map("task_detail_requester_name") + taskDetailDemandType String? @map("task_detail_demand_type") + taskDetailPrivate Boolean @default(false) @map("task_detail_private") /// 专用 init 建档时写 true,完成落库后写 false。列表/筛选只读这个标记, /// 不再每次去资产表现场判断是不是上传空壳。 isIncompleteShell Boolean @default(false) @map("is_incomplete_shell") @@ -412,6 +414,7 @@ model MaterialPermissionPackItem { canDownloadSource Boolean @default(false) @map("can_download_source") canShare Boolean @default(true) @map("can_share") canEditAssets Boolean @default(false) @map("can_edit_assets") + canAccessPrivate Boolean @default(false) @map("can_access_private") pack MaterialPermissionPack @relation(fields: [packId], references: [id], onDelete: Cascade) @@unique([packId, materialType], map: "material_permission_pack_item_pack_type_key") diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 0f2bf5a..41e391d 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -1509,6 +1509,7 @@ const expectedMigrationNames = new Set([ '20260828_add_pack_item_can_edit_assets', '20260828_add_task_detail_change_approvals', '20260829_add_recoverable_api_key_secret', + '20260831_add_private_task_detail', ]); if (migrations.length !== expectedMigrationNames.size || migrations.some((item) => !expectedMigrationNames.has(item && item.name) diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index ee113a4..c4dad40 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -60,5 +60,5 @@ test('migration deployment gate recognizes the latest approved schema migration' assert.ok(gateStart >= 0 && gateEnd > gateStart, 'migration gate list is missing') const gate = source.slice(gateStart, gateEnd) - assert.match(gate, /20260827_add_ding_workflow_detail_fields/) + assert.match(gate, /20260831_add_private_task_detail/) }) diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs index 7daf169..03d7931 100644 --- a/scripts/safe-production-migrate.mjs +++ b/scripts/safe-production-migrate.mjs @@ -25,6 +25,7 @@ const TARGET_MIGRATIONS = Object.freeze([ '20260828_add_pack_item_can_edit_assets', '20260828_add_task_detail_change_approvals', '20260829_add_recoverable_api_key_secret', + '20260831_add_private_task_detail', ]) const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs' const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT' @@ -54,13 +55,49 @@ export function sha256(buffer) { } export function normalizeColumnDefault(value) { - let normalized = String(value ?? '').trim().replace(/::(?:text|character varying)$/i, '') + let normalized = String(value ?? '').trim().replace(/::(?:text|character varying|boolean)$/i, '') if (normalized.startsWith("'") && normalized.endsWith("'")) { normalized = normalized.slice(1, -1) } return normalized } +function normalizeConstraintExpression(value) { + return String(value ?? '') + .trim() + .replace(/::(?:text|character varying|boolean)/gi, '') + .replace(/["\s()]/g, '') + .toLowerCase() +} + +function assertExactCheckConstraint(constraint, expectedTable, expectedName, expectedColumns, expectedExpression) { + if ( + !constraint + || String(constraint.tablename || '') !== expectedTable + || String(constraint.constraint_name || '') !== expectedName + || String(constraint.contype || '') !== 'c' + || constraint.convalidated !== true + ) { + fail(`Database constraint mismatch: ${expectedName}`) + } + const actualColumns = Array.isArray(constraint.columns) + ? constraint.columns.map((column) => String(column)).sort() + : [] + const normalizedExpectedColumns = [...expectedColumns].sort() + if ( + actualColumns.length !== normalizedExpectedColumns.length + || actualColumns.some((column, index) => column !== normalizedExpectedColumns[index]) + ) { + fail(`Database constraint columns mismatch: ${expectedName}`) + } + if ( + normalizeConstraintExpression(constraint.expression) + !== normalizeConstraintExpression(expectedExpression) + ) { + fail(`Database constraint expression mismatch: ${expectedName}`) + } +} + function normalizedPort(value) { const port = String(value || '').trim() if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) { @@ -260,8 +297,9 @@ async function queryEvidence(prisma, localMigrations) { FROM information_schema.columns WHERE table_schema = current_schema() AND ((table_name = 'materials' AND column_name IN ('uploadId', 'transcode_lease_token', 'transcode_lease_expires_at')) - OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell')) + OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell', 'task_detail_demand_type', 'task_detail_private')) OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at')) + OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private')) OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag')) OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at')) OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl')) @@ -313,12 +351,33 @@ async function queryEvidence(prisma, localMigrations) { ) ORDER BY idx.relname`, ) + const constraints = await prisma.$queryRawUnsafe( + `SELECT cls.relname AS tablename, + con.conname AS constraint_name, + con.contype::text AS contype, + con.convalidated, + ARRAY( + SELECT att.attname + FROM unnest(con.conkey) WITH ORDINALITY AS keys(attnum, ordinal) + JOIN pg_attribute att + ON att.attrelid = con.conrelid + AND att.attnum = keys.attnum + ORDER BY keys.ordinal + ) AS columns, + pg_get_expr(con.conbin, con.conrelid, true) AS expression + FROM pg_constraint con + JOIN pg_class cls ON cls.oid = con.conrelid + JOIN pg_namespace ns ON ns.oid = cls.relnamespace + WHERE ns.nspname = current_schema() + AND con.conname IN ('materials_private_optimization_check') + ORDER BY con.conname`, + ) const brandRows = await prisma.$queryRawUnsafe( `SELECT id, "headerLogoUrl", "loginLogoUrl" FROM "system_settings" ORDER BY id`, ) - return { migrationRows, identity: identityRows[0], columns, indexes, brandRows } + return { migrationRows, identity: identityRows[0], columns, indexes, constraints, brandRows } } function completedMigrationMap(rows) { @@ -379,7 +438,10 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { requireColumn('materials', 'custody_department_id', 'text', 'YES') requireColumn('materials', 'custody_at', 'timestamp without time zone', 'YES') requireColumn('materials', 'custody_reason', 'text', 'YES') + requireColumn('materials', 'task_detail_demand_type', 'text', 'YES') + requireColumn('materials', 'task_detail_private', 'boolean', 'NO') requireColumn('materials', 'is_incomplete_shell', 'boolean', 'NO') + requireColumn('material_permission_pack_items', 'can_access_private', 'boolean', 'NO') requireColumn('product_package_upload_jobs', 'worker_id', 'text', 'YES') requireColumn('product_package_upload_jobs', 'lease_token', 'text', 'YES') requireColumn('product_package_upload_jobs', 'lease_expires_at', 'timestamp without time zone', 'YES') @@ -425,12 +487,24 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { const header = columnByKey.get('system_settings.headerLogoUrl') const login = columnByKey.get('system_settings.loginLogoUrl') const legacy = columnByKey.get('system_settings.logoUrl') + const taskDetailDemandType = columnByKey.get('materials.task_detail_demand_type') + const taskDetailPrivate = columnByKey.get('materials.task_detail_private') + const canAccessPrivate = columnByKey.get('material_permission_pack_items.can_access_private') if (!header || normalizeColumnDefault(header.column_default) !== '/brand/header-logo.png') { fail('Database default mismatch: system_settings.headerLogoUrl') } if (!login || normalizeColumnDefault(login.column_default) !== '/brand/login-logo.png') { fail('Database default mismatch: system_settings.loginLogoUrl') } + if (!taskDetailDemandType || normalizeColumnDefault(taskDetailDemandType.column_default) !== '') { + fail('Database default mismatch: materials.task_detail_demand_type') + } + if (!taskDetailPrivate || normalizeColumnDefault(taskDetailPrivate.column_default) !== 'false') { + fail('Database default mismatch: materials.task_detail_private') + } + if (!canAccessPrivate || normalizeColumnDefault(canAccessPrivate.column_default) !== 'false') { + fail('Database default mismatch: material_permission_pack_items.can_access_private') + } if (legacy && (legacy.data_type !== 'text' || legacy.is_nullable !== 'NO')) { fail('Database column mismatch: system_settings.logoUrl') } @@ -507,6 +581,13 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { 'api_key_reveal_audits', ['viewer_user_id', 'created_at'], ) + assertExactCheckConstraint( + (evidence.constraints || []).find((row) => String(row.constraint_name) === 'materials_private_optimization_check'), + 'materials', + 'materials_private_optimization_check', + ['materialType', 'task_detail_demand_type', 'task_detail_private'], + `NOT task_detail_private OR ((materialType = '任务-详情' AND task_detail_demand_type = '优化') IS TRUE)`, + ) const isTrustedBrandValue = (value, localDefault) => { const normalized = String(value || '').trim() diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index b88ad7e..b124ff0 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -341,6 +341,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index ['20260828_add_pack_item_can_edit_assets', { checksum: 'n' }], ['20260828_add_task_detail_change_approvals', { checksum: 'o' }], ['20260829_add_recoverable_api_key_secret', { checksum: 'p' }], + ['20260831_add_private_task_detail', { checksum: 'q' }], ]) const evidence = { migrationRows: [ @@ -360,11 +361,15 @@ test('target evidence rejects nullable logo columns and structurally wrong index { migration_name: '20260828_add_pack_item_can_edit_assets', checksum: 'n', finished_at: 'now', rolled_back_at: null }, { migration_name: '20260828_add_task_detail_change_approvals', checksum: 'o', finished_at: 'now', rolled_back_at: null }, { migration_name: '20260829_add_recoverable_api_key_secret', checksum: 'p', finished_at: 'now', rolled_back_at: null }, + { migration_name: '20260831_add_private_task_detail', checksum: 'q', finished_at: 'now', rolled_back_at: null }, ], columns: [ { table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' }, { table_name: 'materials', column_name: 'transcode_lease_expires_at', data_type: 'timestamp without time zone', is_nullable: 'YES' }, { table_name: 'materials', column_name: 'uploadId', data_type: 'text', is_nullable: 'YES' }, + { table_name: 'materials', column_name: 'task_detail_demand_type', data_type: 'text', is_nullable: 'YES', column_default: null }, + { table_name: 'materials', column_name: 'task_detail_private', data_type: 'boolean', is_nullable: 'NO', column_default: 'false' }, + { table_name: 'material_permission_pack_items', column_name: 'can_access_private', data_type: 'boolean', is_nullable: 'NO', column_default: 'false' }, { table_name: 'api_keys', column_name: 'secret_ciphertext', data_type: 'text', is_nullable: 'YES' }, { table_name: 'api_keys', column_name: 'secret_nonce', data_type: 'text', is_nullable: 'YES' }, { table_name: 'api_keys', column_name: 'secret_auth_tag', data_type: 'text', is_nullable: 'YES' }, @@ -460,6 +465,14 @@ test('target evidence rejects nullable logo columns and structurally wrong index { indexname: 'api_key_reveal_audits_api_key_id_created_at_idx', indexdef: 'CREATE INDEX api_key_reveal_audits_api_key_id_created_at_idx ON public.api_key_reveal_audits USING btree (api_key_id, created_at)' }, { indexname: 'api_key_reveal_audits_viewer_user_id_created_at_idx', indexdef: 'CREATE INDEX api_key_reveal_audits_viewer_user_id_created_at_idx ON public.api_key_reveal_audits USING btree (viewer_user_id, created_at)' }, ], + constraints: [{ + tablename: 'materials', + constraint_name: 'materials_private_optimization_check', + contype: 'c', + convalidated: true, + columns: ['task_detail_private', 'materialType', 'task_detail_demand_type'], + expression: `((NOT task_detail_private) OR (((materialType = '任务-详情'::text) AND (task_detail_demand_type = '优化'::text)) IS TRUE))`, + }], brandRows: [ { id: 'local', headerLogoUrl: '/brand/header-logo.png', loginLogoUrl: '/brand/login-logo.png' }, { id: 'custom', headerLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/header.png', loginLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/login.png' }, @@ -510,6 +523,36 @@ test('target evidence rejects nullable logo columns and structurally wrong index () => assertTargetMigrationEvidence(migrations, missingIncompleteShellColumn), /materials\.is_incomplete_shell/, ) + const wrongTaskDetailPrivateDefault = { + ...evidence, + columns: evidence.columns.map((column) => column.table_name === 'materials' && column.column_name === 'task_detail_private' + ? { ...column, column_default: 'true' } + : column), + } + assert.throws( + () => assertTargetMigrationEvidence(migrations, wrongTaskDetailPrivateDefault), + /materials\.task_detail_private/, + ) + const unexpectedTaskDetailDemandDefault = { + ...evidence, + columns: evidence.columns.map((column) => column.table_name === 'materials' && column.column_name === 'task_detail_demand_type' + ? { ...column, column_default: "'优化'::text" } + : column), + } + assert.throws( + () => assertTargetMigrationEvidence(migrations, unexpectedTaskDetailDemandDefault), + /materials\.task_detail_demand_type/, + ) + const missingPrivateAccessColumn = { + ...evidence, + columns: evidence.columns.filter((column) => !( + column.table_name === 'material_permission_pack_items' && column.column_name === 'can_access_private' + )), + } + assert.throws( + () => assertTargetMigrationEvidence(migrations, missingPrivateAccessColumn), + /material_permission_pack_items\.can_access_private/, + ) const missingDemandTypeColumn = { ...evidence, columns: evidence.columns.filter((column) => !( @@ -548,6 +591,16 @@ test('target evidence rejects nullable logo columns and structurally wrong index () => assertTargetMigrationEvidence(migrations, missingRevealAuditIndex), /api_key_reveal_audits/, ) + const wrongPrivateConstraint = { + ...evidence, + constraints: evidence.constraints.map((constraint) => constraint.constraint_name === 'materials_private_optimization_check' + ? { ...constraint, expression: `((NOT task_detail_private) OR ((materialType = '任务-详情'::text) AND (task_detail_demand_type = '优化'::text)))` } + : constraint), + } + assert.throws( + () => assertTargetMigrationEvidence(migrations, wrongPrivateConstraint), + /materials_private_optimization_check/, + ) const legacyStoredLogo = { ...evidence, brandRows: [{ @@ -624,6 +677,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co '20260826_add_pack_video_early_access', '20260828_add_pack_item_can_edit_assets', '20260828_add_task_detail_change_approvals', + '20260831_add_private_task_detail', ]) { assert.match(dockerfile, new RegExp(migrationName)) assert.match(migratorSource, new RegExp(`'${migrationName}'`)) @@ -633,6 +687,11 @@ test('container supply chain and migrator image are immutable while SOP keeps co assert.match(migratorSource, /'20260829_add_recoverable_api_key_secret'/) assert.match(deploymentSource, /'20260829_add_recoverable_api_key_secret'/) assert.ok(sop.includes('`20260829_add_recoverable_api_key_secret`')) + assert.match(deploymentSource, /'20260831_add_private_task_detail'/) + assert.ok(sop.includes('`20260831_add_private_task_detail`')) + assert.match(sop, /materials_private_optimization_check/) + assert.match(sop, /task_detail_private/) + assert.match(sop, /can_access_private/) assert.match(backupSource, /'--format=custom'/) assert.match(backupSource, /'--serializable-deferrable'/) assert.match(backupSource, /\.dump\.partial-/) @@ -697,8 +756,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/) assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/) assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/) - assert.match(sop, /十六份目标 migration/) - assert.match(sop, /十六条目标 migration/) + assert.match(sop, /十七份目标 migration/) + assert.match(sop, /十七条目标 migration/) const applyMigration = migratorSource.indexOf("['migrate', 'deploy'") const postEvidence = migratorSource.indexOf('after = await queryEvidence(verifyClient, localMigrations)', applyMigration)