diff --git a/Dockerfile b/Dockerfile index 487dd23..0208fd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -85,7 +85,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260820_add_ding_workflow_snapshots/migration.sql" && \ test -f "/app/prisma/migrations/20260824_expand_ding_workflow_insights/migration.sql" && \ test -f "/app/prisma/migrations/20260826_add_ding_workflow_demand_type/migration.sql" && \ - test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql" + test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql" && \ + test -f "/app/prisma/migrations/20260826_add_user_video_early_access/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -204,6 +205,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_add_ding_w COPY --from=builder --chown=root:root /app/prisma/migrations/20260824_expand_ding_workflow_insights ./prisma/migrations/20260824_expand_ding_workflow_insights COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_ding_workflow_demand_type ./prisma/migrations/20260826_add_ding_workflow_demand_type COPY --from=builder --chown=root:root /app/prisma/migrations/20260827_add_ding_workflow_detail_fields ./prisma/migrations/20260827_add_ding_workflow_detail_fields +COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_user_video_early_access ./prisma/migrations/20260826_add_user_video_early_access COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 6cb969c..a9b13ae 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -22,6 +22,7 @@ export async function GET() { isActive: true, // 保留,向后兼容 status: true, // 新增:用户状态(阶段一) canDownload: true, + videoEarlyAccess: true, // 视频提前访问豁免(仅超管可开关) department: true, // 保留,向后兼容 departmentId: true, // 新增:部门ID(阶段二) managedDepartment: true, // 管理员负责的部门 diff --git a/app/api/admin/users/toggle-video-early-access/route.ts b/app/api/admin/users/toggle-video-early-access/route.ts new file mode 100644 index 0000000..a397f97 --- /dev/null +++ b/app/api/admin/users/toggle-video-early-access/route.ts @@ -0,0 +1,64 @@ +// 切换用户「视频提前访问」豁免API(跳过视频类素材 15 天保护期) +// 仅超级管理员可操作,与"设为管理员"同级的高敏感授权。 +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +// POST - 切换视频提前访问豁免(仅超级管理员) +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user || !(session.user as any).id) { + return NextResponse.json( + { error: '未登录' }, + { status: 401 } + ); + } + + if (!(session.user as any).isSuperAdmin) { + return NextResponse.json( + { error: '仅超级管理员可操作' }, + { status: 403 } + ); + } + + const { userId } = await request.json(); + + if (!userId) { + return NextResponse.json( + { error: '缺少用户ID' }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + return NextResponse.json( + { error: '用户不存在' }, + { status: 404 } + ); + } + + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { videoEarlyAccess: !user.videoEarlyAccess }, + }); + + return NextResponse.json({ + message: updatedUser.videoEarlyAccess + ? '已开启视频提前访问(跳过 15 天保护期)' + : '已关闭视频提前访问(恢复 15 天保护期)', + videoEarlyAccess: updatedUser.videoEarlyAccess, + }); + } catch (error) { + console.error('Error toggling video early access:', error); + return NextResponse.json( + { error: '操作失败' }, + { status: 500 } + ); + } +} diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index 1506ef1..bc3e2f4 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -14,7 +14,7 @@ import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'; import { isWebFileLike } from '@/lib/web-file'; import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; import { DownloadErrorCode } from '@/lib/download-errors'; -import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy'; +import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy'; import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper'; import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard'; import { @@ -308,6 +308,8 @@ export async function GET( const videoPreviewPolicy = getVideoPreviewPolicyFields( (material as any).materialType, (material as any).createdAt, + new Date(), + { bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) }, ); const buildVideoPreviewBlockedResponse = () => diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts index 97da5c0..5795d72 100644 --- a/app/api/materials/download/route.ts +++ b/app/api/materials/download/route.ts @@ -24,6 +24,7 @@ import { resolveStoredUploadPath } from '@/lib/upload-paths'; import { getVideoDownloadAvailability, getVideoDownloadUnavailableMessage, + userHasVideoEarlyAccess, } from '@/lib/video-download-policy'; import { nodeReadableToWebStream } from '@/lib/node-readable-stream'; @@ -101,6 +102,8 @@ export async function GET(request: NextRequest) { const videoDownloadAvailability = getVideoDownloadAvailability( material.materialType, material.createdAt, + new Date(), + { bypassWaitPeriod: userHasVideoEarlyAccess(session.user) }, ); if (!videoDownloadAvailability.allowed) { return NextResponse.json( diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 65a8757..9d2e911 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -14,7 +14,7 @@ import { import { getMaterialCoverUrl } from '@/lib/product-package'; import { isProductPackageType } from '@/lib/material-type-mapper'; import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; -import { applyVideoPreviewPolicy } from '@/lib/video-download-policy'; +import { applyVideoPreviewPolicy, userHasVideoEarlyAccess } from '@/lib/video-download-policy'; // 🔒 R45修复:数据库查询限制常量(防止OOM) const MAX_PAGE_SIZE = 100; // 单页最大100条记录 @@ -845,7 +845,7 @@ export async function GET(request: NextRequest) { assets: formattedAssets, }; - return applyVideoPreviewPolicy(rewrittenMaterial); + return applyVideoPreviewPolicy(rewrittenMaterial, new Date(), { bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) }); }); return NextResponse.json({ diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 92cefb3..b88e26d 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -15,7 +15,7 @@ import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'; import { canManageMaterial } from '@/lib/material-authorization'; import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; import { DownloadErrorCode } from '@/lib/download-errors'; -import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy'; +import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy'; // 解析 displayImages 字段 function parseDisplayImages(displayImages: any): string[] { @@ -160,6 +160,8 @@ export async function GET(request: NextRequest) { const videoPreviewPolicy = getVideoPreviewPolicyFields( (material as any).materialType, (material as any).createdAt, + new Date(), + { bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) }, ); if (videoPreviewPolicy.videoPreviewBlocked) { diff --git a/app/api/proxy-media/route.ts b/app/api/proxy-media/route.ts index ac9ebf4..7851e0f 100644 --- a/app/api/proxy-media/route.ts +++ b/app/api/proxy-media/route.ts @@ -1,5 +1,7 @@ // LEGACY_READ_ONLY: 旧域名仅用于代理读取历史素材,禁止作为新写入目标。 import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' import { DownloadErrorCode } from '@/lib/download-errors' import { assertProxyMediaFetchTargetIsSafe, @@ -12,6 +14,7 @@ import { safeProxyMediaUrlForLog, } from '@/lib/proxy-media-security' import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access' +import { userHasVideoEarlyAccess } from '@/lib/video-download-policy' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -154,10 +157,16 @@ export async function GET(request: NextRequest) { try { if (isVideoMediaPath(targetUrl.pathname)) { - const blockedPreview = await findBlockedVideoPreviewByUrl([ - targetUrl.toString(), - targetUrl.pathname, - ]) + // 懒获取登录态:豁免用户经数据接口拿到的播放地址必须能通过代理层,否则自相矛盾。 + const videoSession = await getServerSession(authOptions) + const blockedPreview = await findBlockedVideoPreviewByUrl( + [ + targetUrl.toString(), + targetUrl.pathname, + ], + new Date(), + { bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) }, + ) if (blockedPreview) { return NextResponse.json( diff --git a/app/api/uploads/[...path]/route.ts b/app/api/uploads/[...path]/route.ts index 325ff96..9af7f57 100644 --- a/app/api/uploads/[...path]/route.ts +++ b/app/api/uploads/[...path]/route.ts @@ -14,6 +14,7 @@ import { import { DownloadErrorCode } from '@/lib/download-errors' import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper' import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access' +import { userHasVideoEarlyAccess } from '@/lib/video-download-policy' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -265,12 +266,18 @@ async function handleFileRequest( } if (isVideoMediaPath(normalizedRelativePath)) { - const blockedPreview = await findBlockedVideoPreviewByUrl([ - request.url, - `/api/uploads/${normalizedRelativePath}`, - `/uploads/${normalizedRelativePath}`, - normalizedRelativePath, - ]) + // 懒获取登录态:只有视频文件才需要判定 15 天保护期豁免。 + const videoSession = await getServerSession(authOptions) + const blockedPreview = await findBlockedVideoPreviewByUrl( + [ + request.url, + `/api/uploads/${normalizedRelativePath}`, + `/uploads/${normalizedRelativePath}`, + normalizedRelativePath, + ], + new Date(), + { bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) }, + ) if (blockedPreview) { return headOnly diff --git a/lib/__tests__/video-download-policy-integration.test.ts b/lib/__tests__/video-download-policy-integration.test.ts index ea06c26..d9c6e03 100644 --- a/lib/__tests__/video-download-policy-integration.test.ts +++ b/lib/__tests__/video-download-policy-integration.test.ts @@ -52,7 +52,9 @@ describe('video download policy integration', () => { const routePath = path.join(process.cwd(), 'app/api/materials/route.ts') const content = fs.readFileSync(routePath, 'utf8') - expect(content).toContain('applyVideoPreviewPolicy(rewrittenMaterial)') + // 列表必须应用策略,且豁免标记来自当前登录用户(站内豁免,外部接口不传)。 + expect(content).toContain('applyVideoPreviewPolicy(rewrittenMaterial') + expect(content).toContain('bypassWaitPeriod: userHasVideoEarlyAccess(') }) test('本地上传文件代理应拦截等待期内的视频预览文件', () => { diff --git a/lib/__tests__/video-download-policy.test.ts b/lib/__tests__/video-download-policy.test.ts index 3cf7475..ea2a507 100644 --- a/lib/__tests__/video-download-policy.test.ts +++ b/lib/__tests__/video-download-policy.test.ts @@ -3,6 +3,7 @@ import { getVideoDownloadAvailability, getVideoDownloadUnavailableMessage, getVideoPreviewPolicyFields, + userHasVideoEarlyAccess, VIDEO_DOWNLOAD_WAIT_DAYS, } from '@/lib/video-download-policy' import { DownloadErrorCode, parseDownloadError } from '@/lib/download-errors' @@ -114,3 +115,72 @@ describe('video download policy', () => { expect(material.videoCoverUrl).toBe('https://example.com/cover.jpg') }) }) + +describe('video download policy — 15 天保护期豁免', () => { + const now = new Date('2026-05-25T12:00:00.000Z') + + test('豁免用户不受保护期限制(直接放行)', () => { + const availability = getVideoDownloadAvailability( + '淘系-主图视频', + '2026-05-20T12:00:00.000Z', + now, + { bypassWaitPeriod: true }, + ) + + expect(availability).toEqual({ allowed: true }) + }) + + test('豁免时预览字段全部放行', () => { + const fields = getVideoPreviewPolicyFields( + '逛逛视频', + '2026-05-25T00:00:00.000Z', + now, + { bypassWaitPeriod: true }, + ) + + expect(fields.videoPreviewBlocked).toBe(false) + expect(fields.videoPreviewUnavailableReason).toBeNull() + }) + + test('豁免时 URL 不被抹除', () => { + const result = applyVideoPreviewPolicy( + { + materialType: '淘系-主图视频', + createdAt: '2026-05-25T00:00:00.000Z', + videoSourceUrl: 'https://example.com/video.mp4', + videoPreview540Url: 'https://example.com/540.mp4', + }, + now, + { bypassWaitPeriod: true }, + ) + + expect(result.videoSourceUrl).toBe('https://example.com/video.mp4') + expect(result.videoPreview540Url).toBe('https://example.com/540.mp4') + expect(result.videoPreviewBlocked).toBe(false) + }) + + test('未豁免时行为与旧逻辑完全一致(默认参数不变)', () => { + const withOptions = getVideoDownloadAvailability( + '淘系-主图视频', + '2026-05-20T12:00:00.000Z', + now, + { bypassWaitPeriod: false }, + ) + const withoutOptions = getVideoDownloadAvailability( + '淘系-主图视频', + '2026-05-20T12:00:00.000Z', + now, + ) + + expect(withOptions).toEqual(withoutOptions) + expect(withOptions.allowed).toBe(false) + }) + + test('userHasVideoEarlyAccess 只认显式 true', () => { + expect(userHasVideoEarlyAccess({ videoEarlyAccess: true })).toBe(true) + expect(userHasVideoEarlyAccess({ videoEarlyAccess: false })).toBe(false) + expect(userHasVideoEarlyAccess({})).toBe(false) + expect(userHasVideoEarlyAccess(undefined)).toBe(false) + expect(userHasVideoEarlyAccess(null)).toBe(false) + }) +}) diff --git a/lib/auth.ts b/lib/auth.ts index 5156704..0b51638 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -154,6 +154,7 @@ export const authOptions: NextAuthOptions = { console.error('读取品牌中心访问范围失败,已按无权限处理:', error) return false }) + token.videoEarlyAccess = (user as any).videoEarlyAccess === true } else if (token.id) { const lastCheckedAt = Number((token as any).authCheckedAt || 0) const accessClaimMissing = typeof (token as any).canAccessBrandCenter !== 'boolean' @@ -168,6 +169,7 @@ export const authOptions: NextAuthOptions = { department: true, managedDepartment: true, isSuperAdmin: true, + videoEarlyAccess: true, }, }) @@ -182,6 +184,7 @@ export const authOptions: NextAuthOptions = { token.isSuperAdmin = currentUser.isSuperAdmin token.department = currentUser.department || '' token.managedDepartment = currentUser.managedDepartment || null + token.videoEarlyAccess = currentUser.videoEarlyAccess === true token.canAccessBrandCenter = await resolveBrandCenterAccessForUser(String(token.id), { role: currentUser.role, isSuperAdmin: currentUser.isSuperAdmin, @@ -209,6 +212,7 @@ export const authOptions: NextAuthOptions = { (session.user as any).department = (token as any).department || ''; (session.user as any).managedDepartment = (token as any).managedDepartment || null; (session.user as any).canAccessBrandCenter = (token as any).canAccessBrandCenter === true; + (session.user as any).videoEarlyAccess = (token as any).videoEarlyAccess === true; } return session; }, diff --git a/lib/video-download-policy.ts b/lib/video-download-policy.ts index f74fcfa..1ef0503 100644 --- a/lib/video-download-policy.ts +++ b/lib/video-download-policy.ts @@ -3,6 +3,14 @@ import { INSTALL_VIDEO_TYPE, isVideoMaterialType, normalizeVideoMaterialType } f 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 +} + export interface VideoDownloadAvailability { allowed: boolean reason?: 'VIDEO_WAIT_PERIOD' @@ -18,11 +26,20 @@ export interface VideoPreviewPolicyFields { videoPreviewUnavailableMessage?: string | null } +export function userHasVideoEarlyAccess(user?: unknown): boolean { + return Boolean((user as { videoEarlyAccess?: boolean } | null)?.videoEarlyAccess) +} + export function getVideoDownloadAvailability( materialType?: string | null, createdAt?: Date | string | null, now: Date = new Date(), + options?: VideoPolicyOptions, ): VideoDownloadAvailability { + if (options?.bypassWaitPeriod) { + return { allowed: true } + } + const normalizedType = normalizeVideoMaterialType(materialType) if (!isVideoMaterialType(normalizedType) || normalizedType === INSTALL_VIDEO_TYPE) { @@ -62,8 +79,9 @@ export function getVideoPreviewPolicyFields( materialType?: string | null, createdAt?: Date | string | null, now: Date = new Date(), + options?: VideoPolicyOptions, ): VideoPreviewPolicyFields { - const availability = getVideoDownloadAvailability(materialType, createdAt, now) + const availability = getVideoDownloadAvailability(materialType, createdAt, now, options) if (availability.allowed) { return { @@ -87,11 +105,13 @@ export function getVideoPreviewPolicyFields( export function applyVideoPreviewPolicy>( material: T, now: Date = new Date(), + options?: VideoPolicyOptions, ): T & VideoPreviewPolicyFields { const policyFields = getVideoPreviewPolicyFields( material?.materialType, material?.createdAt, now, + options, ) const nextMaterial: any = { ...material, diff --git a/lib/video-preview-access.ts b/lib/video-preview-access.ts index 9ea4788..d546ecb 100644 --- a/lib/video-preview-access.ts +++ b/lib/video-preview-access.ts @@ -3,6 +3,7 @@ import { getVideoDownloadAvailability, getVideoPreviewUnavailableMessage, type VideoDownloadAvailability, + type VideoPolicyOptions, } from './video-download-policy' const VIDEO_MEDIA_EXTENSIONS = ['.mp4', '.webm', '.ogg'] @@ -158,6 +159,7 @@ async function findVideoMaterialByUrlCandidates( export async function findBlockedVideoPreviewByUrl( values: Array, now: Date = new Date(), + options?: VideoPolicyOptions, ): Promise { const candidates = buildVideoUrlCandidates(values) const material = await findVideoMaterialByUrlCandidates(candidates) @@ -170,6 +172,7 @@ export async function findBlockedVideoPreviewByUrl( material.materialType, material.createdAt, now, + options, ) if (availability.allowed) { diff --git a/prisma/migrations/20260826_add_user_video_early_access/migration.sql b/prisma/migrations/20260826_add_user_video_early_access/migration.sql new file mode 100644 index 0000000..2ce6a5e --- /dev/null +++ b/prisma/migrations/20260826_add_user_video_early_access/migration.sql @@ -0,0 +1,2 @@ +-- 视频提前访问豁免:默认关闭,仅超级管理员可在用户管理中为个别用户开启。 +ALTER TABLE "users" ADD COLUMN "video_early_access" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e255cce..0269491 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,6 +18,7 @@ model User { role Role @default(USER) isActive Boolean @default(false) canDownload Boolean @default(true) + videoEarlyAccess Boolean @default(false) @map("video_early_access") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt department String @default("")