feat: restrict recent video downloads

This commit is contained in:
chenpei 2026-05-25 10:39:26 +08:00
parent efc8f35a83
commit 91aad7bb5a
8 changed files with 320 additions and 5 deletions

View File

@ -17,6 +17,10 @@ import { access, stat } from 'fs/promises';
import { lookup, extension } from 'mime-types';
import JSZip from 'jszip';
import { isProductPackageType } from '@/lib/material-type-mapper';
import {
getVideoDownloadAvailability,
getVideoDownloadUnavailableMessage,
} from '@/lib/video-download-policy';
import {
getWhiteBackgroundZipFileName,
groupWhiteBackgroundAssetsBySku,
@ -61,6 +65,23 @@ export async function GET(request: NextRequest) {
return errorResponse(API_ERROR.TASK_NOT_FOUND);
}
const videoDownloadAvailability = getVideoDownloadAvailability(
material.materialType,
material.createdAt,
);
if (!videoDownloadAvailability.allowed) {
return NextResponse.json(
{
success: false,
error: {
code: 'VIDEO_WAIT_PERIOD',
message: getVideoDownloadUnavailableMessage(videoDownloadAvailability),
},
},
{ status: 403 },
);
}
if (
material.materialType === '标准模型' &&
(downloadType === 'white-background-single' || downloadType === 'white-background-zip')

View File

@ -23,6 +23,10 @@ import {
import { isProductPackageType } from '@/lib/material-type-mapper';
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log';
import { resolveStoredUploadPath } from '@/lib/upload-paths';
import {
getVideoDownloadAvailability,
getVideoDownloadUnavailableMessage,
} from '@/lib/video-download-policy';
function normalizeStoredLocalPath(filePath: string) {
return String(filePath || '').replace(/\\/g, '/');
@ -100,6 +104,20 @@ export async function GET(request: NextRequest) {
);
}
const videoDownloadAvailability = getVideoDownloadAvailability(
material.materialType,
material.createdAt,
);
if (!videoDownloadAvailability.allowed) {
return NextResponse.json(
createDownloadError(
DownloadErrorCode.VIDEO_WAIT_PERIOD,
getVideoDownloadUnavailableMessage(videoDownloadAvailability),
),
{ status: 403 }
);
}
// 权限检查:如果不是分享链接,检查部门级别的下载权限
if (!isShare) {
const session = await getServerSession(authOptions);

View File

@ -7,6 +7,11 @@ import AudioVisualizer from '../AudioVisualizer'
import { getTaskDetailSourceTypeLabel, normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
import { getCertificateReportDisplayTitle } from '@/lib/certificate-report'
import { isVideoMaterialType } from '@/lib/video-material-types'
import {
getVideoDownloadAvailability,
getVideoDownloadUnavailableMessage,
VIDEO_DOWNLOAD_WAIT_DAYS,
} from '@/lib/video-download-policy'
// 素材类型定义
export interface Material {
@ -157,6 +162,12 @@ export default function MaterialCard({
// 判断素材类型
const isVideoType = isVideoMaterialType(material.materialType)
const videoDownloadAvailability = getVideoDownloadAvailability(
material.materialType,
material.createdAt,
)
const isDownloadWaiting = !videoDownloadAvailability.allowed
const isDownloadDisabled = downloadingId === material.id
const isAudioMaterial = material.materialType === '音乐'
// 视频预览URL
@ -721,10 +732,24 @@ export default function MaterialCard({
</button>
{/* 下载按钮 */}
<button
onClick={() => onDownload(material.id)}
disabled={downloadingId === material.id}
className="flex-1 h-10 btn-primary text-sm font-medium rounded-xl transition-all disabled:opacity-50 flex items-center justify-center relative overflow-hidden whitespace-nowrap"
onClick={() => {
if (isDownloadWaiting) {
showToast(getVideoDownloadUnavailableMessage(videoDownloadAvailability), { variant: 'error' })
return
}
onDownload(material.id)
}}
disabled={isDownloadDisabled}
aria-disabled={isDownloadWaiting}
className={`flex-1 h-10 btn-primary text-sm font-medium rounded-xl transition-all disabled:opacity-50 flex items-center justify-center relative overflow-hidden whitespace-nowrap ${
isDownloadWaiting ? 'opacity-50 cursor-not-allowed' : ''
}`}
style={{ backgroundColor: themeColor }}
title={
videoDownloadAvailability.allowed
? '下载'
: getVideoDownloadUnavailableMessage(videoDownloadAvailability)
}
>
{downloadingId === material.id ? (
<>
@ -741,7 +766,11 @@ export default function MaterialCard({
</span>
</>
) : (
<span className="whitespace-nowrap">下载</span>
<span className="whitespace-nowrap">
{isDownloadWaiting
? `${videoDownloadAvailability.remainingDays || VIDEO_DOWNLOAD_WAIT_DAYS}天后可下载`
: '下载'}
</span>
)}
</button>
</div>

View File

@ -0,0 +1,83 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { GET } from '@/app/api/materials/download/route'
import { prisma } from '@/lib/prisma'
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findUnique: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
describe('materials download route video wait period', () => {
beforeEach(() => {
jest.clearAllMocks()
})
function mockMaterial(materialType: string, createdAt: Date, zipFilePath = '') {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
zipFilePath,
zipFileName: 'video.mp4',
productModel: 'SPU001',
categoryName: '店铺',
sceneName: '场景',
productCategoryName: '品类',
materialType,
downloadCount: 0,
createdAt,
})
}
test('非安装视频未满 15 天时应禁止下载,分享链接也不能绕过', async () => {
mockMaterial('淘系-主图视频', new Date())
const response = await GET(
new NextRequest('http://localhost/api/materials/download?id=material-1&share=1&validate=1'),
)
const body = await response.json()
expect(response.status).toBe(403)
expect(body.code).toBe('VIDEO_WAIT_PERIOD')
expect(body.details).toContain('满 15 天')
})
test('安装视频不受 15 天限制', async () => {
mockMaterial('安装视频', new Date())
const response = await GET(
new NextRequest('http://localhost/api/materials/download?id=material-1&share=1&validate=1'),
)
const body = await response.json()
expect(response.status).toBe(404)
expect(body.code).toBe('NO_DOWNLOADABLE_FILES')
})
test('非安装视频满 15 天后不再被等待期拦截', async () => {
const uploadedAt = new Date(Date.now() - 16 * 24 * 60 * 60 * 1000)
mockMaterial('抖音-千川素材', uploadedAt)
const response = await GET(
new NextRequest('http://localhost/api/materials/download?id=material-1&share=1&validate=1'),
)
const body = await response.json()
expect(response.status).toBe(404)
expect(body.code).toBe('NO_DOWNLOADABLE_FILES')
})
})

View File

@ -0,0 +1,31 @@
import fs from 'fs'
import path from 'path'
describe('video download policy integration', () => {
test('内部素材下载路由应执行视频 15 天门禁', () => {
const routePath = path.join(process.cwd(), 'app/api/materials/download/route.ts')
const content = fs.readFileSync(routePath, 'utf8')
expect(content).toContain("from '@/lib/video-download-policy'")
expect(content).toContain('getVideoDownloadAvailability(')
expect(content).toContain('DownloadErrorCode.VIDEO_WAIT_PERIOD')
})
test('外部 token 下载路由应执行视频 15 天门禁', () => {
const routePath = path.join(process.cwd(), 'app/api/external/download/route.ts')
const content = fs.readFileSync(routePath, 'utf8')
expect(content).toContain("from '@/lib/video-download-policy'")
expect(content).toContain('getVideoDownloadAvailability(')
expect(content).toContain("code: 'VIDEO_WAIT_PERIOD'")
})
test('首页素材卡片应在前端提示视频等待期', () => {
const cardPath = path.join(process.cwd(), 'components/home/MaterialCard.tsx')
const content = fs.readFileSync(cardPath, 'utf8')
expect(content).toContain("from '@/lib/video-download-policy'")
expect(content).toContain('getVideoDownloadAvailability(')
expect(content).toContain('getVideoDownloadUnavailableMessage(videoDownloadAvailability)')
})
})

View File

@ -0,0 +1,79 @@
import {
getVideoDownloadAvailability,
getVideoDownloadUnavailableMessage,
VIDEO_DOWNLOAD_WAIT_DAYS,
} from '@/lib/video-download-policy'
import { DownloadErrorCode, parseDownloadError } from '@/lib/download-errors'
describe('video download policy', () => {
const now = new Date('2026-05-25T12:00:00.000Z')
test('安装视频不受 15 天限制', () => {
const availability = getVideoDownloadAvailability(
'安装视频',
'2026-05-25T11:00:00.000Z',
now,
)
expect(availability.allowed).toBe(true)
})
test('非安装视频上传未满 15 天时禁止下载', () => {
const availability = getVideoDownloadAvailability(
'淘系-主图视频',
'2026-05-20T12:00:00.000Z',
now,
)
expect(availability).toMatchObject({
allowed: false,
reason: 'VIDEO_WAIT_PERIOD',
remainingDays: 10,
})
expect(availability.availableAt?.toISOString()).toBe('2026-06-04T12:00:00.000Z')
expect(getVideoDownloadUnavailableMessage(availability)).toContain(
`满 ${VIDEO_DOWNLOAD_WAIT_DAYS} 天才能下载`,
)
})
test('非安装视频上传满 15 天后允许下载', () => {
const availability = getVideoDownloadAvailability(
'抖音-千川素材',
'2026-05-10T12:00:00.000Z',
now,
)
expect(availability.allowed).toBe(true)
})
test('兼容旧视频类型名称', () => {
const availability = getVideoDownloadAvailability(
'主图视频',
'2026-05-24T12:00:00.000Z',
now,
)
expect(availability.allowed).toBe(false)
expect(availability.reason).toBe('VIDEO_WAIT_PERIOD')
})
test('非视频素材不受限制', () => {
const availability = getVideoDownloadAvailability(
'买家秀图片',
'2026-05-25T11:00:00.000Z',
now,
)
expect(availability.allowed).toBe(true)
})
test('下载错误解析应优先展示等待期详情', () => {
const parsed = parseDownloadError({
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
error: '视频类素材上传后满 15 天才能下载',
details: '视频类素材上传后满 15 天才能下载,当前还需约 3 天',
})
expect(parsed.message).toBe('视频类素材上传后满 15 天才能下载,当前还需约 3 天')
})
})

View File

@ -8,6 +8,7 @@ export enum DownloadErrorCode {
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
NO_PERMISSION = 'NO_PERMISSION',
VIDEO_WAIT_PERIOD = 'VIDEO_WAIT_PERIOD',
// 文件不存在
MATERIAL_NOT_FOUND = 'MATERIAL_NOT_FOUND',
@ -39,6 +40,7 @@ export const DownloadErrorMessages: Record<DownloadErrorCode, string> = {
[DownloadErrorCode.UNAUTHORIZED]: '请先登录',
[DownloadErrorCode.FORBIDDEN]: '您的下载权限已被限制,请联系管理员',
[DownloadErrorCode.NO_PERMISSION]: '无权限下载该素材',
[DownloadErrorCode.VIDEO_WAIT_PERIOD]: '视频类素材上传后满 15 天才能下载',
[DownloadErrorCode.MATERIAL_NOT_FOUND]: '素材不存在',
[DownloadErrorCode.FILE_NOT_FOUND]: '源文件已丢失(可能被清空),可尝试下载预览图',
@ -111,7 +113,10 @@ export function parseDownloadError(error: any): {
const code = error.code as DownloadErrorCode;
return {
code,
message: error.error || DownloadErrorMessages[code],
message:
code === DownloadErrorCode.VIDEO_WAIT_PERIOD
? error.details || error.error || DownloadErrorMessages[code]
: error.error || DownloadErrorMessages[code],
details: error.details,
};
}
@ -130,6 +135,9 @@ export function parseDownloadError(error: any): {
if (errorMessage.includes('权限') || errorMessage.includes('无权限')) {
return { code: DownloadErrorCode.NO_PERMISSION, message: DownloadErrorMessages[DownloadErrorCode.NO_PERMISSION] };
}
if (errorMessage.includes('满 15 天') || errorMessage.includes('上传后')) {
return { code: DownloadErrorCode.VIDEO_WAIT_PERIOD, message: DownloadErrorMessages[DownloadErrorCode.VIDEO_WAIT_PERIOD], details: error?.details };
}
if (errorMessage.includes('不存在') || errorMessage.includes('not found')) {
return { code: DownloadErrorCode.FILE_NOT_FOUND, message: DownloadErrorMessages[DownloadErrorCode.FILE_NOT_FOUND] };
}

View File

@ -0,0 +1,46 @@
import { INSTALL_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
export interface VideoDownloadAvailability {
allowed: boolean
reason?: 'VIDEO_WAIT_PERIOD'
availableAt?: Date
remainingDays?: number
}
export function getVideoDownloadAvailability(
materialType?: string | null,
createdAt?: Date | string | null,
now: Date = new Date(),
): VideoDownloadAvailability {
const normalizedType = normalizeVideoMaterialType(materialType)
if (!isVideoMaterialType(normalizedType) || normalizedType === INSTALL_VIDEO_TYPE) {
return { allowed: true }
}
const uploadedAt = createdAt instanceof Date ? createdAt : new Date(String(createdAt || ''))
if (Number.isNaN(uploadedAt.getTime())) {
return { allowed: true }
}
const availableAt = new Date(uploadedAt.getTime() + VIDEO_DOWNLOAD_WAIT_MS)
if (now.getTime() >= availableAt.getTime()) {
return { allowed: true }
}
const remainingMs = availableAt.getTime() - now.getTime()
return {
allowed: false,
reason: 'VIDEO_WAIT_PERIOD',
availableAt,
remainingDays: Math.max(1, Math.ceil(remainingMs / (24 * 60 * 60 * 1000))),
}
}
export function getVideoDownloadUnavailableMessage(availability: VideoDownloadAvailability): string {
const remainingDays = availability.remainingDays || VIDEO_DOWNLOAD_WAIT_DAYS
return `视频类素材上传后满 ${VIDEO_DOWNLOAD_WAIT_DAYS} 天才能下载,当前还需约 ${remainingDays} 天`
}