feat: restrict recent video downloads
This commit is contained in:
parent
efc8f35a83
commit
91aad7bb5a
21
app/api/external/download/route.ts
vendored
21
app/api/external/download/route.ts
vendored
@ -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')
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
83
lib/__tests__/materials-download-video-wait-route.test.ts
Normal file
83
lib/__tests__/materials-download-video-wait-route.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
31
lib/__tests__/video-download-policy-integration.test.ts
Normal file
31
lib/__tests__/video-download-policy-integration.test.ts
Normal 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)')
|
||||
})
|
||||
})
|
||||
79
lib/__tests__/video-download-policy.test.ts
Normal file
79
lib/__tests__/video-download-policy.test.ts
Normal 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 天')
|
||||
})
|
||||
})
|
||||
@ -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] };
|
||||
}
|
||||
|
||||
46
lib/video-download-policy.ts
Normal file
46
lib/video-download-policy.ts
Normal 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} 天`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user