fix: exempt live clips from video wait period
This commit is contained in:
parent
bea82d67b0
commit
45c4e6321e
@ -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(<MaterialCard {...defaultProps} material={liveMaterial} />)
|
||||
|
||||
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(
|
||||
<MaterialCard
|
||||
|
||||
@ -119,6 +119,7 @@ GET /api/external/maixiang-spu-assets?spu={SPU}
|
||||
- `淘系-逛逛视频`
|
||||
- `抖音-千川素材`
|
||||
- `抖音-账号维护`
|
||||
- `直播切片`
|
||||
- 兼容旧类型:`主图视频`、`种草视频`、`视频`、`VIDEO`
|
||||
|
||||
**请求示例**
|
||||
@ -217,7 +218,7 @@ curl -X GET \
|
||||
|
||||
**视频保护期说明**
|
||||
|
||||
除"安装视频"外,所有视频类素材上传后有 15 天保护期。保护期内视频对象会额外包含以下字段:
|
||||
除"安装视频"和"直播切片"外,视频类素材上传后有 15 天保护期。保护期内视频对象会额外包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@ -1089,7 +1090,7 @@ if result['success']:
|
||||
|
||||
### 7. 视频素材查询(按 SPU/SKU/条码)
|
||||
|
||||
供外部系统按任一维度查询全部视频类素材,返回结果自动处理 15 天保护期。
|
||||
供外部系统按任一维度查询全部视频类素材,返回结果自动处理 15 天保护期;安装视频和直播切片不受该限制。
|
||||
|
||||
**请求**
|
||||
|
||||
|
||||
@ -69,6 +69,7 @@ X-API-Key: your-api-key-here
|
||||
- `淘系-逛逛视频`
|
||||
- `抖音-千川素材`
|
||||
- `抖音-账号维护`
|
||||
- `直播切片`
|
||||
- `主图视频`
|
||||
- `种草视频`
|
||||
- `视频`
|
||||
@ -76,7 +77,7 @@ X-API-Key: your-api-key-here
|
||||
|
||||
## 6. 视频类素材保护期限制
|
||||
|
||||
视频类素材(**安装视频除外**)从上传之日起有 **15 天保护期**。保护期内,外部接口不会返回 720P 预览视频地址和视频片段地址。
|
||||
视频类素材(**安装视频、直播切片除外**)从上传之日起有 **15 天保护期**。保护期内,外部接口不会返回 720P 预览视频地址和视频片段地址。
|
||||
|
||||
| 视频类型 | 15天保护期 | 保护期内返回行为 |
|
||||
|----------|-----------|-----------------|
|
||||
@ -85,6 +86,7 @@ X-API-Key: your-api-key-here
|
||||
| 抖音-千川素材 | 有 | 不返回 previewUrl、previewClipUrl、downloadUrl |
|
||||
| 抖音-账号维护 | 有 | 不返回 previewUrl、previewClipUrl、downloadUrl |
|
||||
| 安装视频 | 无限制 | 始终返回全部字段 |
|
||||
| 直播切片 | 无限制 | 始终返回全部字段 |
|
||||
|
||||
### 保护期内返回的额外字段
|
||||
|
||||
@ -332,6 +334,6 @@ HTTP 状态码:`500`
|
||||
- 不建议脉象平台解析 `downloadUrl` 里的 token,只需要把它当成完整 URL 使用。
|
||||
- **视频保护期处理**:脉象平台应检查视频对象是否存在 `videoBlocked: true` 字段:
|
||||
- 如果存在,说明该视频仍在 15 天保护期内,`previewUrl`、`previewClipUrl`、`downloadUrl` 均为 null。建议使用 `coverUrl` 展示封面图,并显示 `videoBlockMessage` 提示用户。
|
||||
- 如果不存在该字段,说明视频不受保护期限制(已超过 15 天 或 是安装视频),所有字段正常返回。
|
||||
- 如果不存在该字段,说明视频不受保护期限制(已超过 15 天,或是安装视频、直播切片),所有字段正常返回。
|
||||
- 保护期到期后(超过 `videoAvailableAt`),再次请求时接口将正常返回全部字段,不再包含 `videoBlocked` 相关字段。
|
||||
- `coverUrl` 始终可用于展示,不受保护期限制。
|
||||
|
||||
@ -55,6 +55,7 @@ X-API-Key: 2de6cc09849a711bd7d72bece8a31778
|
||||
- `淘系-逛逛视频`
|
||||
- `抖音-千川素材`
|
||||
- `抖音-账号维护`
|
||||
- `直播切片`
|
||||
- `主图视频`(兼容旧类型)
|
||||
- `种草视频`(兼容旧类型)
|
||||
- `视频`(兼容旧类型)
|
||||
@ -62,12 +63,12 @@ X-API-Key: 2de6cc09849a711bd7d72bece8a31778
|
||||
|
||||
## 7. 视频保护期说明
|
||||
|
||||
除 **安装视频** 外,所有视频类素材上传后有 **15 天保护期**。
|
||||
除 **安装视频** 和 **直播切片** 外,视频类素材上传后有 **15 天保护期**。
|
||||
|
||||
| 场景 | previewUrl | previewClipUrl | downloadUrl | coverUrl |
|
||||
|------|-----------|---------------|-------------|---------|
|
||||
| 保护期内 | null | null | null | 正常返回 |
|
||||
| 保护期外 / 安装视频 | 正常返回 | 正常返回 | 正常返回 | 正常返回 |
|
||||
| 保护期外 / 安装视频 / 直播切片 | 正常返回 | 正常返回 | 正常返回 | 正常返回 |
|
||||
|
||||
保护期内,视频对象会额外包含以下字段用于展示状态:
|
||||
|
||||
|
||||
@ -359,6 +359,37 @@ describe('external maixiang-spu-assets route', () => {
|
||||
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 天
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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(
|
||||
'淘系-主图视频',
|
||||
|
||||
34
lib/__tests__/video-preview-access-live-clip.test.ts
Normal file
34
lib/__tests__/video-preview-access-live-clip.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user