feat: protect private task details and unify supervisor resolution

This commit is contained in:
chenpei 2026-08-31 15:25:03 +08:00
parent 69750d32b4
commit d2ca6a1bd8
75 changed files with 2243 additions and 263 deletions

View File

@ -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

View File

@ -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<void> {
await Promise.allSettled(urls.map((url) => deleteFromS3(url)))
async function cleanupDerivedObjects(fileUrls: string[]): Promise<void> {
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<ReturnType<typeof acquireTaskMaterialCompletionLock>> | 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)
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)

View File

@ -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 } : {}),

View File

@ -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);
}

View File

@ -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({

View File

@ -64,6 +64,7 @@ export async function GET(request: NextRequest) {
assetType: 'sku-preview',
material: {
materialType: '任务-详情',
taskDetailPrivate: false,
deletedAt: null,
},
},

View File

@ -642,6 +642,7 @@ async function getTaskDetailMaterialsBySpu(normalizedSpu: string) {
where: {
productModel: normalizedSpu,
materialType: '任务-详情',
taskDetailPrivate: false,
deletedAt: null,
},
select: {

View File

@ -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(

View File

@ -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 });

View File

@ -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)

View File

@ -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,

View File

@ -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({

View File

@ -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(

View File

@ -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 });

View File

@ -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(

View File

@ -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(

View File

@ -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,

View File

@ -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,
})
})
})

View File

@ -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)
}

View File

@ -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,
}),
])
})

View File

@ -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) => ({
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: item.canEditAssets === true,
})),
canEditAssets: taskDetailOnlyFlags.canEditAssets,
canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate,
}
}),
)
return NextResponse.json({ ok: true })
}

View File

@ -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,
}),
])
})
})

View File

@ -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) => ({
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: item.canEditAssets === true,
})),
canEditAssets: taskDetailOnlyFlags.canEditAssets,
canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate,
}
}),
)
return NextResponse.json({ ok: true })
}

View File

@ -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,

View File

@ -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, {

View File

@ -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')) {

View File

@ -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(

View File

@ -55,6 +55,7 @@ const FLAG_COLUMNS: Array<{ key: Exclude<keyof MaterialPermissionFlags, 'canShar
{ key: 'canDownload', label: '下载' },
{ key: 'canDownloadSource', label: '源文件' },
{ key: 'canEditAssets', label: '编辑图片' },
{ key: 'canAccessPrivate', label: '私密素材查看/下载' },
]
const emptyFlags = (): MaterialPermissionFlags => ({
@ -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() {
</div>
</div>
<table className="min-w-full text-left text-sm">
<div className="overflow-x-auto" role="region" aria-label="素材类型权限,可横向滚动" tabIndex={0}>
<table className="w-full min-w-[640px] whitespace-nowrap text-left text-sm">
<thead className="bg-[#f8fafc] text-xs uppercase tracking-[0.08em] text-[#66758b]">
<tr>
<th className="px-5 py-3 font-semibold">素材类型</th>
@ -665,15 +668,20 @@ export default function PermissionGroupsDashboard() {
<tr key={materialType} className="border-t border-[#eef2f6]">
<td className="px-5 py-2.5 font-medium text-[#101828]">{materialType}</td>
{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 (
<td key={column.key} className="px-3 py-2.5">
<input
type="checkbox"
checked={Boolean(flags[column.key])}
disabled={editDisabled}
title={editDisabled ? '仅任务-详情支持单图编辑' : undefined}
disabled={taskDetailOnlyDisabled}
title={taskDetailOnlyDisabled ? disabledTitle : undefined}
onChange={(event) => updateFlag(materialType, column.key, event.target.checked)}
aria-label={`${materialType} ${column.label}`}
/>
@ -686,6 +694,7 @@ export default function PermissionGroupsDashboard() {
</tbody>
</table>
</div>
</div>
)}
</section>
</div>

View File

@ -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)}
</p>
</div>
{material.taskDetailDemandType && (
<div>
<p className={detailMetaLabelClass}>需求类型 / 可见范围</p>
<p className={detailMetaValueClass}>{material.taskDetailDemandType} · {material.taskDetailPrivate ? '私密' : '公开'}</p>
</div>
)}
<div>
<p className={detailMetaLabelClass}>是否整套</p>
<p className={detailMetaValueClass}>

View File

@ -117,7 +117,7 @@ function thumbnailResult(sourceName: string) {
}
function installUploadFlowFetch(completeResponse?: Promise<unknown>) {
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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 () => {

View File

@ -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<TaskDetailSourceType | ''>('');
const [taskDetailDemandType, setTaskDetailDemandType] = useState<TaskDetailDemandType>('新品');
const [taskDetailPrivate, setTaskDetailPrivate] = useState(false);
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState('');
const [users, setUsers] = useState<Array<{ id: string; username: string; displayName: string }>>([]);
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<typeof normalizeTaskDetailPrivacyFields>;
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,8 +1441,53 @@ export default function ProductPackageUploader({
onAddScene={handleAddNewScene}
/>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="task-detail-source-type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label htmlFor="task-detail-demand-type" className="mb-2 block text-sm font-medium text-gray-700">
需求类型 <span className="text-red-500">*</span>
</label>
<select
id="task-detail-demand-type"
value={taskDetailDemandType}
onChange={(e) => handleTaskDetailDemandTypeChange(e.target.value as TaskDetailDemandType)}
disabled={uploading}
required
aria-required="true"
className={TASK_DETAIL_METADATA_FIELD_CLASS_NAME}
>
{TASK_DETAIL_DEMAND_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div>
<label htmlFor="task-detail-visibility" className="mb-2 block text-sm font-medium text-gray-700">
可见性
</label>
<select
id="task-detail-visibility"
value={taskDetailVisibilityValue}
onChange={(e) => handleTaskDetailVisibilityChange(e.target.value as 'public' | 'private')}
disabled={uploading}
aria-describedby="task-detail-visibility-hint"
className={TASK_DETAIL_METADATA_FIELD_CLASS_NAME}
>
<option value="public">公开</option>
<option value="private" disabled={!isOptimizationDemand}>私密</option>
</select>
</div>
</div>
<p id="task-detail-visibility-hint" className="text-xs text-gray-500">
{taskDetailPrivacyHint}
</p>
<div>
<label htmlFor="task-detail-source-type" className="mb-2 block text-sm font-medium text-gray-700">
出图方式 <span className="text-red-500">*</span>
</label>
<select
@ -1413,7 +1498,7 @@ export default function ProductPackageUploader({
required
aria-required="true"
aria-invalid={Boolean(error && !taskDetailSourceType)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
className={TASK_DETAIL_METADATA_FIELD_CLASS_NAME}
>
<option value="">请选择出图方式</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
@ -1423,6 +1508,7 @@ export default function ProductPackageUploader({
))}
</select>
</div>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
同时上传主图 + SKU 图 + 详情图时自动标记为整套

View File

@ -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` 仍通过;

View File

@ -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: {

View File

@ -160,6 +160,7 @@ describe('external dingtalk-material-match route', () => {
where: {
deletedAt: null,
productModel: 'SPU002',
taskDetailPrivate: false,
},
}),
)

View File

@ -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('主管')
})

View File

@ -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()

View File

@ -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 () => {

View File

@ -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,
})
})

View File

@ -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',

View File

@ -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='),

View File

@ -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<string, unknown>
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()
})

View File

@ -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'],

View File

@ -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',

View File

@ -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],

View File

@ -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',

View File

@ -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'))

View File

@ -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 })
}
})
})

View File

@ -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)
})
})

View File

@ -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')
}
})
})

View File

@ -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,
}),
],
})
})
})

View File

@ -544,6 +544,8 @@ async function loadStoredPermissionPacks() {
canShare: row.canShare,
// 旧部门权限表没有编辑图片列;编辑能力只认权限组勾选
canEditAssets: false,
// 私密任务-详情同样只认权限组勾选,旧部门权限表默认没有这列
canAccessPrivate: false,
}
stored.set(row.department, current)
}

View File

@ -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,
}
}

73
lib/employee-manager.ts Normal file
View File

@ -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]
}

View File

@ -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,
},
},
},

View File

@ -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<boolean> {
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) ||

View File

@ -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 }
}

View File

@ -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

View File

@ -5,10 +5,29 @@ export type MaterialPermissionFlags = {
canShare: boolean
/** 任务-详情单图编辑(替换/改名/删除):仅对「任务-详情」类型生效,其余类型忽略 */
canEditAssets: boolean
/** 私密任务-详情查看/下载:仅对「任务-详情」类型生效,其余类型忽略 */
canAccessPrivate: boolean
}
export type PermissionPackMap = Record<string, MaterialPermissionFlags>
type TaskDetailOnlyFlagInput = {
canEditAssets?: boolean
canAccessPrivate?: boolean
}
/** 任务-详情以外的素材类型,不允许带出这两个组级特权位。 */
export function normalizeTaskDetailOnlyFlags(
materialType: string,
flags: TaskDetailOnlyFlagInput = {},
): Pick<MaterialPermissionFlags, 'canEditAssets' | 'canAccessPrivate'> {
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

View File

@ -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)
throw error
}
return resolveAvailableDirectManagerProfile(targetEmployee, managers[0])
}
// 只有主管唯一值完全缺失时,才按双方的主组织唯一值推断唯一在职 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: {

View File

@ -23,6 +23,7 @@ const PACK_ITEM_SELECT = {
canDownloadSource: true,
canShare: true,
canEditAssets: true,
canAccessPrivate: true,
} as const
function packTargetDepartment(slug: string): string | null {

View File

@ -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<string, string>,
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(
}
: {}),
})
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(
}
: {}),
})
if (/^https?:\/\//i.test(previewUrl)) {
uploadedPreviewUrls.push(previewUrl)
} else {
privatePreviewPaths.push(previewUrl)
}
}
}
return previews
}
async function cleanupGeneratedPrivatePreviews(fileUrls: string[]): Promise<void> {
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<string, string>()
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?.()

View File

@ -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' },

View File

@ -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<Array<{ fileName: string; fileUrl: string; size: number }>> {
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<Array<{ fileName: string; fileUrl: string; size: number }>> {
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<TaskDetailEditGroup, number>
firstPreviewUrlByGroup: Record<TaskDetailEditGroup, string | null>
@ -1511,6 +1540,7 @@ async function planPreviewRowChanges(
mutation: EditMutation,
finalEntryName: string,
workspaceDir: string,
generatedPreviewLocations: string[],
): Promise<PlannedDbChanges> {
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<RebuildOutcome> {
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<RebuildOutcome> {
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<TaskDetailEditGroup, number> = {
@ -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<T>(materialId: string, operation: () => Promise<T>): Promise<T> {
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) {

View File

@ -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<Awaited<ReturnType<typeof getEmp
}
async function resolveManager(employee: NonNullable<Awaited<ReturnType<typeof getEmployeeByUserId>>>) {
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({

View File

@ -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<string> {
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
}

View File

@ -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,
}
}

View File

@ -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<boolean> {
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)),
] }
}

View File

@ -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

View File

@ -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<PermissionPackMap | null> {
@ -122,6 +129,21 @@ export async function canUserEditTaskDetailAssets(userId: string): Promise<boole
}
}
/**
* 私密任务-详情查看/下载:只认权限组勾选,不给上传者/管理员额外豁免。
*/
export async function canUserAccessPrivateTaskDetail(userId: string): Promise<boolean> {
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) => ({
.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: item.canEditAssets === true,
})),
canEditAssets: taskDetailOnlyFlags.canEditAssets,
canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate,
}
}),
}),
])
}

View File

@ -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;

View File

@ -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")

View File

@ -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)

View File

@ -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/)
})

View File

@ -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()

View File

@ -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)