fix: 分离视频详情与元数据编辑门禁

This commit is contained in:
chenpei 2026-07-30 14:51:44 +08:00
parent 309e76989c
commit 128201944f
3 changed files with 150 additions and 11 deletions

View File

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

View File

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

View File

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