diff --git a/components/home/MaterialCard.test.tsx b/components/home/MaterialCard.test.tsx index aa14c69..a22906d 100644 --- a/components/home/MaterialCard.test.tsx +++ b/components/home/MaterialCard.test.tsx @@ -206,6 +206,24 @@ describe('MaterialCard', () => { expect(defaultProps.showToast).not.toHaveBeenCalled() }) + test('刚上传的直播切片卡片可查看详情和下载,不显示等待期', () => { + const liveMaterial = { + ...baseMaterial, + materialType: '直播切片', + createdAt: new Date().toISOString(), + videoCoverUrl: 'https://example.com/live-cover.jpg', + videoPreviewClipUrl: 'https://example.com/live-clip.mp4', + } + const { container } = render() + + expect(container.querySelector('[data-video-preview-notice]')).toBeNull() + fireEvent.click(container.querySelector('[data-material-link="true"]')!) + expect(defaultProps.onClick).toHaveBeenCalledWith(liveMaterial) + fireEvent.click(screen.getByRole('button', { name: '下载' })) + expect(defaultProps.onDownload).toHaveBeenCalledWith('material-1') + expect(defaultProps.showToast).not.toHaveBeenCalled() + }) + test('接口未返回并发上限时按当前单任务策略展示', () => { render( { expect(videoItem.videoBlocked).toBeUndefined() }) + test('刚上传的直播切片在外部接口立即返回预览和下载链接', async () => { + ;(prisma.material.findMany as jest.Mock).mockResolvedValue([{ + id: 'live-clip', + productModel: 'SPU-LIVE', + materialType: '直播切片', + categoryName: '顾家', + sceneName: '通用', + zipFileName: 'live.mp4', + zipFilePath: 'uploads/live.mp4', + zipFileSize: BigInt(8192), + previewUrl: 'https://s3.example/live-cover.jpg', + displayImages: [], + videoPreview540Url: 'https://s3.example/live-preview.mp4', + videoPreviewClipUrl: 'https://s3.example/live-clip.mp4', + videoCoverUrl: 'https://s3.example/live-cover.jpg', + videoDuration: 120, + createdAt: new Date(), + }]) + + const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU-LIVE')) + const body = await response.json() + const videoItem = body.data.videos.items[0] + + expect(response.status).toBe(200) + expect(videoItem.materialType).toBe('直播切片') + expect(videoItem.previewUrl).not.toBeNull() + expect(videoItem.previewClipUrl).not.toBeNull() + expect(videoItem.downloadUrl).not.toBeNull() + expect(videoItem.videoBlocked).toBeUndefined() + }) + test('非安装视频上传未满 15 天时应返回 blocked 字段并隐藏下载链接', async () => { const recentDate = new Date() recentDate.setDate(recentDate.getDate() - 3) // 3 天前,未满 15 天 diff --git a/lib/__tests__/materials-download-video-wait-route.test.ts b/lib/__tests__/materials-download-video-wait-route.test.ts index add9dd0..1ceb98c 100644 --- a/lib/__tests__/materials-download-video-wait-route.test.ts +++ b/lib/__tests__/materials-download-video-wait-route.test.ts @@ -208,6 +208,29 @@ describe('materials download route video wait period', () => { expect(body.code).toBe('NO_DOWNLOADABLE_FILES') }) + test('直播切片上传当天不受 15 天限制,仍需通过正常下载校验', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ + user: { + id: 'user-1', + role: 'USER', + department: '测试部门', + managedDepartment: null, + }, + }) + ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true }) + ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) + mockMaterial('直播切片', new Date()) + + const response = await GET( + new NextRequest('http://localhost/api/materials/download?id=material-1&validate=1'), + ) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body.code).toBe('NO_DOWNLOADABLE_FILES') + expect(canUserDownloadMaterial).toHaveBeenCalled() + }) + test('非安装视频满 15 天后不再被等待期拦截', async () => { const uploadedAt = new Date(Date.now() - 16 * 24 * 60 * 60 * 1000) mockMaterial('抖音-千川素材', uploadedAt) diff --git a/lib/__tests__/materials-share-security.test.ts b/lib/__tests__/materials-share-security.test.ts index c4050be..3796c80 100644 --- a/lib/__tests__/materials-share-security.test.ts +++ b/lib/__tests__/materials-share-security.test.ts @@ -170,6 +170,26 @@ describe('materials share security', () => { expect(body.videoSourceUrl).toBeNull() }) + test('刚上传的直播切片分享链接立即返回预览', async () => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({ + materialType: '直播切片', + createdAt: new Date(), + videoSourceUrl: '/uploads/materials/live-source.mp4', + videoPreview540Url: 'https://example.com/live-preview.mp4', + videoPreviewClipUrl: 'https://example.com/live-clip.mp4', + })) + + const response = await GET( + new NextRequest('http://localhost/api/materials/share?id=material-live'), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.videoPreview540Url).toBeTruthy() + expect(body.videoPreviewClipUrl).toBeTruthy() + expect(body.videoSourceUrl).toBeNull() + }) + test('素材所有者可收藏和编辑,但不能获得管理员删除权限', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'owner-1' }, diff --git a/lib/__tests__/video-download-policy.test.ts b/lib/__tests__/video-download-policy.test.ts index ea2a507..fee51ad 100644 --- a/lib/__tests__/video-download-policy.test.ts +++ b/lib/__tests__/video-download-policy.test.ts @@ -21,6 +21,25 @@ describe('video download policy', () => { expect(availability.allowed).toBe(true) }) + test('刚上传的直播切片不受 15 天限制且保留预览地址', () => { + const material = applyVideoPreviewPolicy( + { + materialType: '直播切片', + createdAt: '2026-05-25T11:00:00.000Z', + videoSourceUrl: 'https://example.com/live-source.mp4', + videoPreview540Url: 'https://example.com/live-preview.mp4', + videoPreviewClipUrl: 'https://example.com/live-clip.mp4', + }, + now, + ) + + expect(getVideoDownloadAvailability('直播切片', material.createdAt, now)).toEqual({ allowed: true }) + expect(material.videoPreviewBlocked).toBe(false) + expect(material.videoSourceUrl).toBe('https://example.com/live-source.mp4') + expect(material.videoPreview540Url).toBe('https://example.com/live-preview.mp4') + expect(material.videoPreviewClipUrl).toBe('https://example.com/live-clip.mp4') + }) + test('非安装视频上传未满 15 天时禁止下载', () => { const availability = getVideoDownloadAvailability( '淘系-主图视频', diff --git a/lib/__tests__/video-preview-access-live-clip.test.ts b/lib/__tests__/video-preview-access-live-clip.test.ts new file mode 100644 index 0000000..f82efa7 --- /dev/null +++ b/lib/__tests__/video-preview-access-live-clip.test.ts @@ -0,0 +1,34 @@ +import { prisma } from '@/lib/prisma' +import { findBlockedVideoPreviewByUrl } from '@/lib/video-preview-access' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + material: { findFirst: jest.fn() }, + }, +})) + +describe('video preview direct access wait period', () => { + const now = new Date('2026-09-23T12:00:00.000Z') + const previewUrl = 'https://example.com/live-preview.mp4' + + test('刚上传的直播切片直链不触发 15 天拦截,普通视频仍被拦截', async () => { + const findFirst = prisma.material.findFirst as jest.Mock + findFirst.mockResolvedValueOnce({ + id: 'live-1', + materialType: '直播切片', + createdAt: new Date('2026-09-23T11:00:00.000Z'), + }) + + expect(await findBlockedVideoPreviewByUrl([previewUrl], now)).toBeNull() + + findFirst.mockResolvedValueOnce({ + id: 'video-1', + materialType: '淘系-主图视频', + createdAt: new Date('2026-09-23T11:00:00.000Z'), + }) + + const blocked = await findBlockedVideoPreviewByUrl([previewUrl], now) + expect(blocked?.material.id).toBe('video-1') + expect(blocked?.availability.reason).toBe('VIDEO_WAIT_PERIOD') + }) +}) diff --git a/lib/video-download-policy.ts b/lib/video-download-policy.ts index 1ef0503..23d48b0 100644 --- a/lib/video-download-policy.ts +++ b/lib/video-download-policy.ts @@ -1,11 +1,16 @@ -import { INSTALL_VIDEO_TYPE, isVideoMaterialType, normalizeVideoMaterialType } from './video-material-types' +import { + INSTALL_VIDEO_TYPE, + LIVE_CLIP_VIDEO_TYPE, + isVideoMaterialType, + normalizeVideoMaterialType, +} from './video-material-types' export const VIDEO_DOWNLOAD_WAIT_DAYS = 15 export const VIDEO_DOWNLOAD_WAIT_MS = VIDEO_DOWNLOAD_WAIT_DAYS * 24 * 60 * 60 * 1000 /** - * 15 天保护期的显式豁免。仅站内用户经超级管理员开通后生效; - * 外部 apiKey 接口不传此项,维持完整保护期语义。 + * 站内用户经超级管理员开通的额外豁免; + * 安装视频和直播切片的类型豁免对站内、外部接口都生效。 */ export interface VideoPolicyOptions { bypassWaitPeriod?: boolean @@ -42,7 +47,11 @@ export function getVideoDownloadAvailability( const normalizedType = normalizeVideoMaterialType(materialType) - if (!isVideoMaterialType(normalizedType) || normalizedType === INSTALL_VIDEO_TYPE) { + if ( + !isVideoMaterialType(normalizedType) || + normalizedType === INSTALL_VIDEO_TYPE || + normalizedType === LIVE_CLIP_VIDEO_TYPE + ) { return { allowed: true } }