feat: protect private task details and unify supervisor resolution
This commit is contained in:
parent
69750d32b4
commit
d2ca6a1bd8
@ -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
|
||||
|
||||
@ -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)
|
||||
await verifyUploadedS3Object(url, slice.length, 'image/webp')
|
||||
if (!isPrivate) {
|
||||
await verifyUploadedS3Object(url, slice.length, 'image/webp')
|
||||
}
|
||||
releaseMaterialLock.assertHeld()
|
||||
}
|
||||
|
||||
@ -404,7 +418,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '素材编号无效' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await regeneratePreview(materialId)
|
||||
const result = await regeneratePreview(materialId, userId)
|
||||
return NextResponse.json({ success: true, ...result })
|
||||
} catch (error) {
|
||||
console.error('[Regenerate Preview] Error:', error)
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
3
app/api/external/download/route.ts
vendored
3
app/api/external/download/route.ts
vendored
@ -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);
|
||||
}
|
||||
|
||||
|
||||
7
app/api/external/product-assets/route.ts
vendored
7
app/api/external/product-assets/route.ts
vendored
@ -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({
|
||||
|
||||
@ -64,6 +64,7 @@ export async function GET(request: NextRequest) {
|
||||
assetType: 'sku-preview',
|
||||
material: {
|
||||
materialType: '任务-详情',
|
||||
taskDetailPrivate: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
|
||||
@ -642,6 +642,7 @@ async function getTaskDetailMaterialsBySpu(normalizedSpu: string) {
|
||||
where: {
|
||||
productModel: normalizedSpu,
|
||||
materialType: '任务-详情',
|
||||
taskDetailPrivate: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
87
app/api/organization/material-permissions/route.test.ts
Normal file
87
app/api/organization/material-permissions/route.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
@ -10,6 +10,7 @@ import { selectVisibleOrganizationPeople } from '@/lib/auto-personnel-rules'
|
||||
import {
|
||||
collectOccupiedOrganizationPaths,
|
||||
lastOrganizationPathSegment,
|
||||
normalizeTaskDetailOnlyFlags,
|
||||
normalizeOrganizationPath,
|
||||
pruneOrganizationTree,
|
||||
resolveEmployeeOrganizationPaths,
|
||||
@ -78,6 +79,7 @@ export async function GET() {
|
||||
canDownloadSource: true,
|
||||
canShare: true,
|
||||
canEditAssets: true,
|
||||
canAccessPrivate: true,
|
||||
},
|
||||
},
|
||||
inheritors: {
|
||||
@ -260,20 +262,25 @@ export async function PUT(request: NextRequest) {
|
||||
canDownloadSource?: boolean
|
||||
canShare?: boolean
|
||||
canEditAssets?: boolean
|
||||
canAccessPrivate?: boolean
|
||||
}> = Array.isArray(body?.permissions) ? body.permissions : []
|
||||
if (incoming.length === 0) {
|
||||
return NextResponse.json({ error: '素材类型列表不能为空' }, { status: 400 })
|
||||
}
|
||||
await savePermissionPackItems(
|
||||
slug,
|
||||
incoming.map((item) => ({
|
||||
materialType: item.materialType,
|
||||
canView: Boolean(item.canView),
|
||||
canDownload: Boolean(item.canDownload),
|
||||
canDownloadSource: Boolean(item.canDownloadSource),
|
||||
canShare: true,
|
||||
canEditAssets: item.canEditAssets === true,
|
||||
})),
|
||||
incoming.map((item) => {
|
||||
const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item)
|
||||
return {
|
||||
materialType: item.materialType,
|
||||
canView: Boolean(item.canView),
|
||||
canDownload: Boolean(item.canDownload),
|
||||
canDownloadSource: Boolean(item.canDownloadSource),
|
||||
canShare: true,
|
||||
canEditAssets: taskDetailOnlyFlags.canEditAssets,
|
||||
canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate,
|
||||
}
|
||||
}),
|
||||
)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
83
app/api/organization/user-permission-inherits/route.test.ts
Normal file
83
app/api/organization/user-permission-inherits/route.test.ts
Normal 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,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from '@/lib/user-permission-inherit'
|
||||
import { filterSelectableMaterialTypes } from '@/lib/material-type-options'
|
||||
import { getAllMaterialTypes } from '@/lib/department-permission'
|
||||
import { normalizeTaskDetailOnlyFlags } from '@/lib/organization-material-permissions'
|
||||
|
||||
async function requireSuperAdmin() {
|
||||
const session = await getServerSession(authOptions)
|
||||
@ -41,6 +42,7 @@ export async function GET() {
|
||||
canDownloadSource: true,
|
||||
canShare: true,
|
||||
canEditAssets: true,
|
||||
canAccessPrivate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -115,6 +117,7 @@ export async function PUT(request: NextRequest) {
|
||||
canDownloadSource?: boolean
|
||||
canShare?: boolean
|
||||
canEditAssets?: boolean
|
||||
canAccessPrivate?: boolean
|
||||
}> = Array.isArray(body?.permissions) ? body.permissions : []
|
||||
if (!slug) return NextResponse.json({ error: '缺少套餐' }, { status: 400 })
|
||||
if (incoming.length === 0) {
|
||||
@ -122,14 +125,18 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
await savePermissionPackItems(
|
||||
slug,
|
||||
incoming.map((item) => ({
|
||||
materialType: item.materialType,
|
||||
canView: Boolean(item.canView),
|
||||
canDownload: Boolean(item.canDownload),
|
||||
canDownloadSource: Boolean(item.canDownloadSource),
|
||||
canShare: item.canShare !== false,
|
||||
canEditAssets: item.canEditAssets === true,
|
||||
})),
|
||||
incoming.map((item) => {
|
||||
const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item)
|
||||
return {
|
||||
materialType: item.materialType,
|
||||
canView: Boolean(item.canView),
|
||||
canDownload: Boolean(item.canDownload),
|
||||
canDownloadSource: Boolean(item.canDownloadSource),
|
||||
canShare: item.canShare !== false,
|
||||
canEditAssets: taskDetailOnlyFlags.canEditAssets,
|
||||
canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate,
|
||||
}
|
||||
}),
|
||||
)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}`}
|
||||
/>
|
||||
@ -685,6 +693,7 @@ export default function PermissionGroupsDashboard() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,27 +1441,73 @@ export default function ProductPackageUploader({
|
||||
onAddScene={handleAddNewScene}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="task-detail-source-type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
出图方式 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="task-detail-source-type"
|
||||
value={taskDetailSourceType}
|
||||
onChange={(e) => setTaskDetailSourceType(e.target.value as TaskDetailSourceType | '')}
|
||||
disabled={uploading}
|
||||
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"
|
||||
>
|
||||
<option value="">请选择出图方式</option>
|
||||
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<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
|
||||
id="task-detail-source-type"
|
||||
value={taskDetailSourceType}
|
||||
onChange={(e) => setTaskDetailSourceType(e.target.value as TaskDetailSourceType | '')}
|
||||
disabled={uploading}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={Boolean(error && !taskDetailSourceType)}
|
||||
className={TASK_DETAIL_METADATA_FIELD_CLASS_NAME}
|
||||
>
|
||||
<option value="">请选择出图方式</option>
|
||||
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
|
||||
@ -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` 仍通过;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -160,6 +160,7 @@ describe('external dingtalk-material-match route', () => {
|
||||
where: {
|
||||
deletedAt: null,
|
||||
productModel: 'SPU002',
|
||||
taskDetailPrivate: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
44
lib/__tests__/employee-manager.test.ts
Normal file
44
lib/__tests__/employee-manager.test.ts
Normal 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('主管')
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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='),
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'))
|
||||
|
||||
|
||||
81
lib/__tests__/task-detail-preview-storage.test.ts
Normal file
81
lib/__tests__/task-detail-preview-storage.test.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
37
lib/__tests__/task-detail-privacy-fields.test.ts
Normal file
37
lib/__tests__/task-detail-privacy-fields.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
171
lib/__tests__/task-detail-privacy.test.ts
Normal file
171
lib/__tests__/task-detail-privacy.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
106
lib/__tests__/user-permission-inherit.test.ts
Normal file
106
lib/__tests__/user-permission-inherit.test.ts
Normal 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,
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -544,6 +544,8 @@ async function loadStoredPermissionPacks() {
|
||||
canShare: row.canShare,
|
||||
// 旧部门权限表没有编辑图片列;编辑能力只认权限组勾选
|
||||
canEditAssets: false,
|
||||
// 私密任务-详情同样只认权限组勾选,旧部门权限表默认没有这列
|
||||
canAccessPrivate: false,
|
||||
}
|
||||
stored.set(row.department, current)
|
||||
}
|
||||
|
||||
@ -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
73
lib/employee-manager.ts
Normal 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]
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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) ||
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { EmployeeManagerResolutionError, resolveEmployeeDirectManagerProfile } from '@/lib/employee-manager'
|
||||
import { open, rm, unlink } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import type { Session } from 'next-auth'
|
||||
@ -5304,85 +5305,18 @@ async function resolveAvailableDirectManagerProfile(targetEmployee: any, manager
|
||||
}
|
||||
|
||||
async function resolveDirectManagerForWorkflow(targetEmployee: any) {
|
||||
const managerDingtalkUserId = clean(targetEmployee?.managerDingtalkUserId)
|
||||
const managerJobNumber = clean(targetEmployee?.managerJobNumber)
|
||||
|
||||
// 上游已提供主管唯一值时只做精确匹配;值失效或冲突不能用组织推断掩盖。
|
||||
if (managerDingtalkUserId || managerJobNumber) {
|
||||
const configuredManagerIdentity = {
|
||||
...(managerDingtalkUserId ? { dingtalkUserId: managerDingtalkUserId } : {}),
|
||||
...(managerJobNumber ? { jobNumber: managerJobNumber } : {}),
|
||||
try {
|
||||
const manager = await resolveEmployeeDirectManagerProfile(
|
||||
{ ...targetEmployee, displayName: getEmployeeDisplayName(targetEmployee) },
|
||||
{ activeWhere: getActivePerformanceSurveyEmployeeProfileWhere() },
|
||||
)
|
||||
return resolveAvailableDirectManagerProfile(targetEmployee, manager)
|
||||
} catch (error) {
|
||||
if (error instanceof EmployeeManagerResolutionError) {
|
||||
throw new PerformanceSurveyApiError(error.message, 409)
|
||||
}
|
||||
const managers = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
getActivePerformanceSurveyEmployeeProfileWhere(),
|
||||
configuredManagerIdentity,
|
||||
],
|
||||
},
|
||||
take: 2,
|
||||
})
|
||||
if (managers.length !== 1) {
|
||||
throw new PerformanceSurveyApiError(`无法唯一匹配${getEmployeeDisplayName(targetEmployee)}的直属主管`, 409)
|
||||
}
|
||||
return resolveAvailableDirectManagerProfile(targetEmployee, managers[0])
|
||||
throw error
|
||||
}
|
||||
|
||||
// 只有主管唯一值完全缺失时,才按双方的主组织唯一值推断唯一在职 M 级人员。
|
||||
const targetEmployeeId = clean(targetEmployee?.id)
|
||||
if (!targetEmployeeId) {
|
||||
throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409)
|
||||
}
|
||||
|
||||
const primaryMemberships = await prisma.performanceSurveyEmployeeDepartmentMembership.findMany({
|
||||
where: {
|
||||
employeeProfileId: targetEmployeeId,
|
||||
isPrimary: true,
|
||||
},
|
||||
select: { sourceDeptId: true },
|
||||
take: 2,
|
||||
})
|
||||
const primarySourceDeptIds = Array.from(new Set(
|
||||
primaryMemberships
|
||||
.map((membership) => clean(membership.sourceDeptId))
|
||||
.filter((sourceDeptId): sourceDeptId is string => Boolean(sourceDeptId)),
|
||||
))
|
||||
if (primarySourceDeptIds.length !== 1) {
|
||||
throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409)
|
||||
}
|
||||
|
||||
const managers = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
getActivePerformanceSurveyEmployeeProfileWhere(),
|
||||
{ id: { not: targetEmployeeId } },
|
||||
{
|
||||
OR: [
|
||||
{ isLeadership: true },
|
||||
{ levelType: { equals: 'M', mode: 'insensitive' } },
|
||||
{ positionLevel: { startsWith: 'M', mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
departmentMemberships: {
|
||||
some: {
|
||||
sourceDeptId: primarySourceDeptIds[0],
|
||||
isPrimary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
take: 2,
|
||||
})
|
||||
if (managers.length === 0) {
|
||||
throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内没有可用的M级主管,请先校准人员档案`, 409)
|
||||
}
|
||||
if (managers.length > 1) {
|
||||
throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内存在多名M级人员,请明确配置直属主管`, 409)
|
||||
}
|
||||
|
||||
return resolveAvailableDirectManagerProfile(targetEmployee, managers[0])
|
||||
}
|
||||
|
||||
async function resolveConfiguredWorkflowEvaluators(input: {
|
||||
|
||||
@ -23,6 +23,7 @@ const PACK_ITEM_SELECT = {
|
||||
canDownloadSource: true,
|
||||
canShare: true,
|
||||
canEditAssets: true,
|
||||
canAccessPrivate: true,
|
||||
} as const
|
||||
|
||||
function packTargetDepartment(slug: string): string | null {
|
||||
|
||||
@ -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(
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
uploadedPreviewUrls.push(previewUrl)
|
||||
if (/^https?:\/\//i.test(previewUrl)) {
|
||||
uploadedPreviewUrls.push(previewUrl)
|
||||
} else {
|
||||
privatePreviewPaths.push(previewUrl)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@ -880,10 +886,12 @@ async function generatePreviewsFromAssets(
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 9)
|
||||
const sliceSuffix = buffers.length > 1 ? `-slice${sliceIndex}` : ''
|
||||
const fileName = `${type}-preview-${timestamp}-${index}${sliceSuffix}-${randomSuffix}.webp`
|
||||
const previewUrl = await uploadPublicDisplayToS3(
|
||||
const previewUrl = await saveTaskDetailPreview(
|
||||
buffers[sliceIndex],
|
||||
buildTaskDetailPublicDisplayPath(materialId, type, fileName),
|
||||
'image/webp',
|
||||
materialId,
|
||||
type,
|
||||
fileName,
|
||||
isPrivate,
|
||||
)
|
||||
previews.push({
|
||||
fileUrl: previewUrl,
|
||||
@ -896,13 +904,26 @@ async function generatePreviewsFromAssets(
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
uploadedPreviewUrls.push(previewUrl)
|
||||
if (/^https?:\/\//i.test(previewUrl)) {
|
||||
uploadedPreviewUrls.push(previewUrl)
|
||||
} else {
|
||||
privatePreviewPaths.push(previewUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return previews
|
||||
}
|
||||
|
||||
async function cleanupGeneratedPrivatePreviews(fileUrls: string[]): Promise<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?.()
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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({
|
||||
|
||||
59
lib/task-detail-preview-storage.ts
Normal file
59
lib/task-detail-preview-storage.ts
Normal 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
|
||||
}
|
||||
56
lib/task-detail-privacy-fields.ts
Normal file
56
lib/task-detail-privacy-fields.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
43
lib/task-detail-privacy.ts
Normal file
43
lib/task-detail-privacy.ts
Normal 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)),
|
||||
] }
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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) => ({
|
||||
packId: pack.id,
|
||||
materialType: item.materialType,
|
||||
canView: item.canView,
|
||||
canDownload: item.canDownload,
|
||||
canDownloadSource: item.canDownloadSource,
|
||||
canShare: item.canShare,
|
||||
canEditAssets: item.canEditAssets === true,
|
||||
})),
|
||||
.map((item) => {
|
||||
const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item)
|
||||
return {
|
||||
packId: pack.id,
|
||||
materialType: item.materialType,
|
||||
canView: item.canView,
|
||||
canDownload: item.canDownload,
|
||||
canDownloadSource: item.canDownloadSource,
|
||||
canShare: item.canShare,
|
||||
canEditAssets: taskDetailOnlyFlags.canEditAssets,
|
||||
canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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/)
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user