diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts
index 6ece8ca..a395b85 100644
--- a/app/api/external/download/route.ts
+++ b/app/api/external/download/route.ts
@@ -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')
diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts
index e36004a..c059f07 100644
--- a/app/api/materials/download/route.ts
+++ b/app/api/materials/download/route.ts
@@ -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);
diff --git a/components/home/MaterialCard.tsx b/components/home/MaterialCard.tsx
index 32e73d9..a6797f2 100644
--- a/components/home/MaterialCard.tsx
+++ b/components/home/MaterialCard.tsx
@@ -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({
{/* 下载按钮 */}
diff --git a/lib/__tests__/materials-download-video-wait-route.test.ts b/lib/__tests__/materials-download-video-wait-route.test.ts
new file mode 100644
index 0000000..31f7cd6
--- /dev/null
+++ b/lib/__tests__/materials-download-video-wait-route.test.ts
@@ -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')
+ })
+})
diff --git a/lib/__tests__/video-download-policy-integration.test.ts b/lib/__tests__/video-download-policy-integration.test.ts
new file mode 100644
index 0000000..7c2f491
--- /dev/null
+++ b/lib/__tests__/video-download-policy-integration.test.ts
@@ -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)')
+ })
+})
diff --git a/lib/__tests__/video-download-policy.test.ts b/lib/__tests__/video-download-policy.test.ts
new file mode 100644
index 0000000..e869efb
--- /dev/null
+++ b/lib/__tests__/video-download-policy.test.ts
@@ -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 天')
+ })
+})
diff --git a/lib/download-errors.ts b/lib/download-errors.ts
index 169736c..a050683 100644
--- a/lib/download-errors.ts
+++ b/lib/download-errors.ts
@@ -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.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] };
}
diff --git a/lib/video-download-policy.ts b/lib/video-download-policy.ts
new file mode 100644
index 0000000..a170855
--- /dev/null
+++ b/lib/video-download-policy.ts
@@ -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} 天`
+}