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} 天` +}