From 128201944f30545e0ef24cf28af53e62d08cf2f6 Mon Sep 17 00:00:00 2001 From: chenpei Date: Thu, 30 Jul 2026 14:51:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=88=86=E7=A6=BB=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E4=B8=8E=E5=85=83=E6=95=B0=E6=8D=AE=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/materials/[id]/route.ts | 65 ++++++++++++--- app/edit/[id]/page.tsx | 2 +- lib/__tests__/material-edit-routes.test.ts | 94 ++++++++++++++++++++++ 3 files changed, 150 insertions(+), 11 deletions(-) diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index 3c25640..04087ab 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -144,6 +144,7 @@ export async function GET( { status: 401 } ); } + const isEditRequest = request.nextUrl.searchParams.get('intent') === 'edit'; let material; @@ -323,6 +324,60 @@ export async function GET( // 超级管理员不受限制 const { isSuperAdmin } = await import('@/lib/department-permission'); const userIsSuperAdmin = await isSuperAdmin(userId); + const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin; + const canEdit = + (material as any).userId === userId || + canBypassVideoPreviewGate; + const materialWithUser = material as any; + const displayAuthorName = + materialWithUser.user?.realName || + materialWithUser.user?.username || + materialWithUser.authorName; + + if (isEditRequest) { + if (!canEdit) { + return NextResponse.json( + { error: '您没有权限编辑此素材' }, + { status: 403 }, + ); + } + + const { rewriteMediaUrlForClient } = await import('@/lib/url-rewrite'); + const canShowPreview = + !videoPreviewPolicy.videoPreviewBlocked || + canBypassVideoPreviewGate; + + return NextResponse.json({ + id: materialWithUser.id, + userId: materialWithUser.userId, + productModel: materialWithUser.productModel, + reportTitle: materialWithUser.reportTitle || null, + categoryName: materialWithUser.categoryName, + sceneName: materialWithUser.sceneName, + platformName: materialWithUser.platformName || '', + productCategoryName: materialWithUser.productCategoryName || '', + materialType: materialWithUser.materialType, + renderer: materialWithUser.renderer, + authorName: displayAuthorName, + previewUrl: canShowPreview + ? rewriteMediaUrlForClient(materialWithUser.previewUrl, request, 'image') + : null, + taskNo: materialWithUser.taskNo || null, + dingtalkApprovalNo: materialWithUser.dingtalkApprovalNo || null, + taskStatus: materialWithUser.taskStatus || null, + taskFetchedAt: materialWithUser.taskFetchedAt || null, + isFullSet: deriveTaskDetailIsFullSet( + materialWithUser.materialType, + materialWithUser.assets, + Boolean(materialWithUser.isFullSet), + ), + taskDetailSourceType: materialWithUser.taskDetailSourceType || null, + taskDetailRequesterUserId: materialWithUser.taskDetailRequesterUserId || null, + taskDetailRequesterName: materialWithUser.taskDetailRequesterName || null, + canEdit: true, + }); + } + if (!userIsSuperAdmin) { const canView = await canUserViewMaterial( userDepartment, @@ -340,10 +395,6 @@ export async function GET( } } - const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin; - const canEdit = - (material as any).userId === userId || - canBypassVideoPreviewGate; if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) { return buildVideoPreviewBlockedResponse(); } @@ -379,12 +430,6 @@ export async function GET( } } - // 类型断言,确保 user 字段存在 - const materialWithUser = material as any; - - // 优先使用用户的真实姓名或用户名,如果没有才使用 authorName - const displayAuthorName = materialWithUser.user?.realName || materialWithUser.user?.username || materialWithUser.authorName; - const formattedAssets = (materialWithUser.assets || []).map((asset: any) => formatTaskDetailAsset(asset, displayAuthorName), ); diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 412fed7..734a349 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -250,7 +250,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) setError('') setMaterial(null) - const materialResponse = await fetch(`/api/materials/${params.id}`, { + const materialResponse = await fetch(`/api/materials/${params.id}?intent=edit`, { signal, }) if (!materialResponse.ok) { diff --git a/lib/__tests__/material-edit-routes.test.ts b/lib/__tests__/material-edit-routes.test.ts index 8ac2b17..d83e330 100644 --- a/lib/__tests__/material-edit-routes.test.ts +++ b/lib/__tests__/material-edit-routes.test.ts @@ -299,6 +299,100 @@ describe('material edit routes', () => { expect(body.canEdit).toBe(true) }) + test('视频等待期内,素材所有者仍可读取元数据编辑信息,但不能取得视频预览地址', async () => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + productModel: 'VIDEO-001', + reportTitle: null, + categoryName: '店铺A', + sceneName: '场景A', + platformName: '', + productCategoryName: '', + materialType: '淘系-主图视频', + isAiGenerated: false, + taskDetailSourceType: null, + taskDetailRequesterUserId: null, + taskDetailRequesterName: null, + renderer: '', + authorName: '测试作者', + previewUrl: 'https://s3.jiansun.vip/video-cover.webp', + zipFileName: 'video.mp4', + zipFilePath: 'uploads/video.mp4', + zipFileSize: BigInt(123), + downloadCount: 0, + likeCount: 0, + displayImages: [], + createdAt: new Date(), + videoSourceUrl: 'https://s3.jiansun.vip/video.mp4', + videoPreview540Url: 'https://s3.jiansun.vip/video-540.mp4', + videoPreviewClipUrl: 'https://s3.jiansun.vip/video-clip.mp4', + videoCoverUrl: 'https://s3.jiansun.vip/video-cover.webp', + videoDuration: 20, + videoProcessingStatus: 'completed', + taskNo: null, + dingtalkApprovalNo: null, + taskStatus: null, + taskFetchedAt: null, + isFullSet: false, + user: { + id: 'user-1', + username: '测试用户', + realName: '测试用户', + }, + assets: [], + }) + + const response = await getMaterialById( + new NextRequest('http://localhost/api/materials/material-1?intent=edit'), + { params: { id: 'material-1' } }, + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.canEdit).toBe(true) + expect(body.productModel).toBe('VIDEO-001') + expect(body.previewUrl).toBeNull() + expect(body).not.toHaveProperty('videoSourceUrl') + expect(body).not.toHaveProperty('videoPreview540Url') + expect(body).not.toHaveProperty('sourceFileUrl') + expect(canUserViewMaterial).not.toHaveBeenCalled() + + const detailResponse = await getMaterialById( + new NextRequest('http://localhost/api/materials/material-1'), + { params: { id: 'material-1' } }, + ) + const detailBody = await detailResponse.json() + + expect(detailResponse.status).toBe(403) + expect(detailBody.code).toBe('VIDEO_WAIT_PERIOD') + }) + + test('非素材所有者不能通过编辑读取意图绕过权限', async () => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'other-user', + materialType: '3D场景', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + user: { + id: 'other-user', + username: '其他用户', + realName: '其他用户', + }, + assets: [], + }) + + const response = await getMaterialById( + new NextRequest('http://localhost/api/materials/material-1?intent=edit'), + { params: { id: 'material-1' } }, + ) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toBe('您没有权限编辑此素材') + expect(canUserViewMaterial).not.toHaveBeenCalled() + }) + test('PUT 路由应透传任务详情出图方式字段', async () => { const request = new NextRequest('http://localhost/api/materials/material-1', { method: 'PUT',