From 45c4e6321e4d6975f2b64a7736d97a9323a01d4a Mon Sep 17 00:00:00 2001
From: WuYongJie <549727955@qq.com>
Date: Wed, 23 Sep 2026 18:19:56 +0800
Subject: [PATCH] fix: exempt live clips from video wait period
---
components/home/MaterialCard.test.tsx | 18 ++++++++++
docs/外部接口文档.md | 5 +--
docs/脉象平台SPU素材下载接口文档.md | 6 ++--
docs/视频素材查询接口文档.md | 5 +--
.../maixiang-spu-assets-route.test.ts | 31 +++++++++++++++++
...aterials-download-video-wait-route.test.ts | 23 +++++++++++++
.../materials-share-security.test.ts | 20 +++++++++++
lib/__tests__/video-download-policy.test.ts | 19 +++++++++++
.../video-preview-access-live-clip.test.ts | 34 +++++++++++++++++++
lib/video-download-policy.ts | 17 +++++++---
10 files changed, 168 insertions(+), 10 deletions(-)
create mode 100644 lib/__tests__/video-preview-access-live-clip.test.ts
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 }
}