视频15天保护期支持按用户豁免,仅站内生效且超管可开关

This commit is contained in:
chenpei 2026-08-27 14:45:26 +08:00
parent 09328d776d
commit 53b19ccd5a
16 changed files with 209 additions and 17 deletions

View File

@ -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/20260820_add_ding_workflow_snapshots/migration.sql" && \
test -f "/app/prisma/migrations/20260824_expand_ding_workflow_insights/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/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。这里提前失败,避免再把缺路由的镜像发出去。 # 线上音频/视频就会直接 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/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/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/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/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/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 COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs

View File

@ -22,6 +22,7 @@ export async function GET() {
isActive: true, // 保留,向后兼容 isActive: true, // 保留,向后兼容
status: true, // 新增:用户状态(阶段一) status: true, // 新增:用户状态(阶段一)
canDownload: true, canDownload: true,
videoEarlyAccess: true, // 视频提前访问豁免(仅超管可开关)
department: true, // 保留,向后兼容 department: true, // 保留,向后兼容
departmentId: true, // 新增:部门ID(阶段二) departmentId: true, // 新增:部门ID(阶段二)
managedDepartment: true, // 管理员负责的部门 managedDepartment: true, // 管理员负责的部门

View File

@ -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 }
);
}
}

View File

@ -14,7 +14,7 @@ import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { isWebFileLike } from '@/lib/web-file'; import { isWebFileLike } from '@/lib/web-file';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors'; 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 { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper';
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard'; import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard';
import { import {
@ -308,6 +308,8 @@ export async function GET(
const videoPreviewPolicy = getVideoPreviewPolicyFields( const videoPreviewPolicy = getVideoPreviewPolicyFields(
(material as any).materialType, (material as any).materialType,
(material as any).createdAt, (material as any).createdAt,
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) },
); );
const buildVideoPreviewBlockedResponse = () => const buildVideoPreviewBlockedResponse = () =>

View File

@ -24,6 +24,7 @@ import { resolveStoredUploadPath } from '@/lib/upload-paths';
import { import {
getVideoDownloadAvailability, getVideoDownloadAvailability,
getVideoDownloadUnavailableMessage, getVideoDownloadUnavailableMessage,
userHasVideoEarlyAccess,
} from '@/lib/video-download-policy'; } from '@/lib/video-download-policy';
import { nodeReadableToWebStream } from '@/lib/node-readable-stream'; import { nodeReadableToWebStream } from '@/lib/node-readable-stream';
@ -101,6 +102,8 @@ export async function GET(request: NextRequest) {
const videoDownloadAvailability = getVideoDownloadAvailability( const videoDownloadAvailability = getVideoDownloadAvailability(
material.materialType, material.materialType,
material.createdAt, material.createdAt,
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(session.user) },
); );
if (!videoDownloadAvailability.allowed) { if (!videoDownloadAvailability.allowed) {
return NextResponse.json( return NextResponse.json(

View File

@ -14,7 +14,7 @@ import {
import { getMaterialCoverUrl } from '@/lib/product-package'; import { getMaterialCoverUrl } from '@/lib/product-package';
import { isProductPackageType } from '@/lib/material-type-mapper'; import { isProductPackageType } from '@/lib/material-type-mapper';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { applyVideoPreviewPolicy } from '@/lib/video-download-policy'; import { applyVideoPreviewPolicy, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
// 🔒 R45修复:数据库查询限制常量(防止OOM) // 🔒 R45修复:数据库查询限制常量(防止OOM)
const MAX_PAGE_SIZE = 100; // 单页最大100条记录 const MAX_PAGE_SIZE = 100; // 单页最大100条记录
@ -845,7 +845,7 @@ export async function GET(request: NextRequest) {
assets: formattedAssets, assets: formattedAssets,
}; };
return applyVideoPreviewPolicy(rewrittenMaterial); return applyVideoPreviewPolicy(rewrittenMaterial, new Date(), { bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) });
}); });
return NextResponse.json({ return NextResponse.json({

View File

@ -15,7 +15,7 @@ import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { canManageMaterial } from '@/lib/material-authorization'; import { canManageMaterial } from '@/lib/material-authorization';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors'; import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy'; import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
// 解析 displayImages 字段 // 解析 displayImages 字段
function parseDisplayImages(displayImages: any): string[] { function parseDisplayImages(displayImages: any): string[] {
@ -160,6 +160,8 @@ export async function GET(request: NextRequest) {
const videoPreviewPolicy = getVideoPreviewPolicyFields( const videoPreviewPolicy = getVideoPreviewPolicyFields(
(material as any).materialType, (material as any).materialType,
(material as any).createdAt, (material as any).createdAt,
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) },
); );
if (videoPreviewPolicy.videoPreviewBlocked) { if (videoPreviewPolicy.videoPreviewBlocked) {

View File

@ -1,5 +1,7 @@
// LEGACY_READ_ONLY: 旧域名仅用于代理读取历史素材,禁止作为新写入目标。 // LEGACY_READ_ONLY: 旧域名仅用于代理读取历史素材,禁止作为新写入目标。
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { DownloadErrorCode } from '@/lib/download-errors' import { DownloadErrorCode } from '@/lib/download-errors'
import { import {
assertProxyMediaFetchTargetIsSafe, assertProxyMediaFetchTargetIsSafe,
@ -12,6 +14,7 @@ import {
safeProxyMediaUrlForLog, safeProxyMediaUrlForLog,
} from '@/lib/proxy-media-security' } from '@/lib/proxy-media-security'
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access' import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'
import { userHasVideoEarlyAccess } from '@/lib/video-download-policy'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@ -154,10 +157,16 @@ export async function GET(request: NextRequest) {
try { try {
if (isVideoMediaPath(targetUrl.pathname)) { if (isVideoMediaPath(targetUrl.pathname)) {
const blockedPreview = await findBlockedVideoPreviewByUrl([ // 懒获取登录态:豁免用户经数据接口拿到的播放地址必须能通过代理层,否则自相矛盾。
targetUrl.toString(), const videoSession = await getServerSession(authOptions)
targetUrl.pathname, const blockedPreview = await findBlockedVideoPreviewByUrl(
]) [
targetUrl.toString(),
targetUrl.pathname,
],
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) },
)
if (blockedPreview) { if (blockedPreview) {
return NextResponse.json( return NextResponse.json(

View File

@ -14,6 +14,7 @@ import {
import { DownloadErrorCode } from '@/lib/download-errors' import { DownloadErrorCode } from '@/lib/download-errors'
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper' import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper'
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access' import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'
import { userHasVideoEarlyAccess } from '@/lib/video-download-policy'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@ -265,12 +266,18 @@ async function handleFileRequest(
} }
if (isVideoMediaPath(normalizedRelativePath)) { if (isVideoMediaPath(normalizedRelativePath)) {
const blockedPreview = await findBlockedVideoPreviewByUrl([ // 懒获取登录态:只有视频文件才需要判定 15 天保护期豁免。
request.url, const videoSession = await getServerSession(authOptions)
`/api/uploads/${normalizedRelativePath}`, const blockedPreview = await findBlockedVideoPreviewByUrl(
`/uploads/${normalizedRelativePath}`, [
normalizedRelativePath, request.url,
]) `/api/uploads/${normalizedRelativePath}`,
`/uploads/${normalizedRelativePath}`,
normalizedRelativePath,
],
new Date(),
{ bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) },
)
if (blockedPreview) { if (blockedPreview) {
return headOnly return headOnly

View File

@ -52,7 +52,9 @@ describe('video download policy integration', () => {
const routePath = path.join(process.cwd(), 'app/api/materials/route.ts') const routePath = path.join(process.cwd(), 'app/api/materials/route.ts')
const content = fs.readFileSync(routePath, 'utf8') const content = fs.readFileSync(routePath, 'utf8')
expect(content).toContain('applyVideoPreviewPolicy(rewrittenMaterial)') // 列表必须应用策略,且豁免标记来自当前登录用户(站内豁免,外部接口不传)。
expect(content).toContain('applyVideoPreviewPolicy(rewrittenMaterial')
expect(content).toContain('bypassWaitPeriod: userHasVideoEarlyAccess(')
}) })
test('本地上传文件代理应拦截等待期内的视频预览文件', () => { test('本地上传文件代理应拦截等待期内的视频预览文件', () => {

View File

@ -3,6 +3,7 @@ import {
getVideoDownloadAvailability, getVideoDownloadAvailability,
getVideoDownloadUnavailableMessage, getVideoDownloadUnavailableMessage,
getVideoPreviewPolicyFields, getVideoPreviewPolicyFields,
userHasVideoEarlyAccess,
VIDEO_DOWNLOAD_WAIT_DAYS, VIDEO_DOWNLOAD_WAIT_DAYS,
} from '@/lib/video-download-policy' } from '@/lib/video-download-policy'
import { DownloadErrorCode, parseDownloadError } from '@/lib/download-errors' import { DownloadErrorCode, parseDownloadError } from '@/lib/download-errors'
@ -114,3 +115,72 @@ describe('video download policy', () => {
expect(material.videoCoverUrl).toBe('https://example.com/cover.jpg') 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)
})
})

View File

@ -154,6 +154,7 @@ export const authOptions: NextAuthOptions = {
console.error('读取品牌中心访问范围失败,已按无权限处理:', error) console.error('读取品牌中心访问范围失败,已按无权限处理:', error)
return false return false
}) })
token.videoEarlyAccess = (user as any).videoEarlyAccess === true
} else if (token.id) { } else if (token.id) {
const lastCheckedAt = Number((token as any).authCheckedAt || 0) const lastCheckedAt = Number((token as any).authCheckedAt || 0)
const accessClaimMissing = typeof (token as any).canAccessBrandCenter !== 'boolean' const accessClaimMissing = typeof (token as any).canAccessBrandCenter !== 'boolean'
@ -168,6 +169,7 @@ export const authOptions: NextAuthOptions = {
department: true, department: true,
managedDepartment: true, managedDepartment: true,
isSuperAdmin: true, isSuperAdmin: true,
videoEarlyAccess: true,
}, },
}) })
@ -182,6 +184,7 @@ export const authOptions: NextAuthOptions = {
token.isSuperAdmin = currentUser.isSuperAdmin token.isSuperAdmin = currentUser.isSuperAdmin
token.department = currentUser.department || '' token.department = currentUser.department || ''
token.managedDepartment = currentUser.managedDepartment || null token.managedDepartment = currentUser.managedDepartment || null
token.videoEarlyAccess = currentUser.videoEarlyAccess === true
token.canAccessBrandCenter = await resolveBrandCenterAccessForUser(String(token.id), { token.canAccessBrandCenter = await resolveBrandCenterAccessForUser(String(token.id), {
role: currentUser.role, role: currentUser.role,
isSuperAdmin: currentUser.isSuperAdmin, isSuperAdmin: currentUser.isSuperAdmin,
@ -209,6 +212,7 @@ export const authOptions: NextAuthOptions = {
(session.user as any).department = (token as any).department || ''; (session.user as any).department = (token as any).department || '';
(session.user as any).managedDepartment = (token as any).managedDepartment || null; (session.user as any).managedDepartment = (token as any).managedDepartment || null;
(session.user as any).canAccessBrandCenter = (token as any).canAccessBrandCenter === true; (session.user as any).canAccessBrandCenter = (token as any).canAccessBrandCenter === true;
(session.user as any).videoEarlyAccess = (token as any).videoEarlyAccess === true;
} }
return session; return session;
}, },

View File

@ -3,6 +3,14 @@ import { INSTALL_VIDEO_TYPE, isVideoMaterialType, normalizeVideoMaterialType } f
export const VIDEO_DOWNLOAD_WAIT_DAYS = 15 export const VIDEO_DOWNLOAD_WAIT_DAYS = 15
export const VIDEO_DOWNLOAD_WAIT_MS = VIDEO_DOWNLOAD_WAIT_DAYS * 24 * 60 * 60 * 1000 export const VIDEO_DOWNLOAD_WAIT_MS = VIDEO_DOWNLOAD_WAIT_DAYS * 24 * 60 * 60 * 1000
/**
* 15 天保护期的显式豁免。仅站内用户经超级管理员开通后生效;
* 外部 apiKey 接口不传此项,维持完整保护期语义。
*/
export interface VideoPolicyOptions {
bypassWaitPeriod?: boolean
}
export interface VideoDownloadAvailability { export interface VideoDownloadAvailability {
allowed: boolean allowed: boolean
reason?: 'VIDEO_WAIT_PERIOD' reason?: 'VIDEO_WAIT_PERIOD'
@ -18,11 +26,20 @@ export interface VideoPreviewPolicyFields {
videoPreviewUnavailableMessage?: string | null videoPreviewUnavailableMessage?: string | null
} }
export function userHasVideoEarlyAccess(user?: unknown): boolean {
return Boolean((user as { videoEarlyAccess?: boolean } | null)?.videoEarlyAccess)
}
export function getVideoDownloadAvailability( export function getVideoDownloadAvailability(
materialType?: string | null, materialType?: string | null,
createdAt?: Date | string | null, createdAt?: Date | string | null,
now: Date = new Date(), now: Date = new Date(),
options?: VideoPolicyOptions,
): VideoDownloadAvailability { ): VideoDownloadAvailability {
if (options?.bypassWaitPeriod) {
return { allowed: true }
}
const normalizedType = normalizeVideoMaterialType(materialType) const normalizedType = normalizeVideoMaterialType(materialType)
if (!isVideoMaterialType(normalizedType) || normalizedType === INSTALL_VIDEO_TYPE) { if (!isVideoMaterialType(normalizedType) || normalizedType === INSTALL_VIDEO_TYPE) {
@ -62,8 +79,9 @@ export function getVideoPreviewPolicyFields(
materialType?: string | null, materialType?: string | null,
createdAt?: Date | string | null, createdAt?: Date | string | null,
now: Date = new Date(), now: Date = new Date(),
options?: VideoPolicyOptions,
): VideoPreviewPolicyFields { ): VideoPreviewPolicyFields {
const availability = getVideoDownloadAvailability(materialType, createdAt, now) const availability = getVideoDownloadAvailability(materialType, createdAt, now, options)
if (availability.allowed) { if (availability.allowed) {
return { return {
@ -87,11 +105,13 @@ export function getVideoPreviewPolicyFields(
export function applyVideoPreviewPolicy<T extends Record<string, any>>( export function applyVideoPreviewPolicy<T extends Record<string, any>>(
material: T, material: T,
now: Date = new Date(), now: Date = new Date(),
options?: VideoPolicyOptions,
): T & VideoPreviewPolicyFields { ): T & VideoPreviewPolicyFields {
const policyFields = getVideoPreviewPolicyFields( const policyFields = getVideoPreviewPolicyFields(
material?.materialType, material?.materialType,
material?.createdAt, material?.createdAt,
now, now,
options,
) )
const nextMaterial: any = { const nextMaterial: any = {
...material, ...material,

View File

@ -3,6 +3,7 @@ import {
getVideoDownloadAvailability, getVideoDownloadAvailability,
getVideoPreviewUnavailableMessage, getVideoPreviewUnavailableMessage,
type VideoDownloadAvailability, type VideoDownloadAvailability,
type VideoPolicyOptions,
} from './video-download-policy' } from './video-download-policy'
const VIDEO_MEDIA_EXTENSIONS = ['.mp4', '.webm', '.ogg'] const VIDEO_MEDIA_EXTENSIONS = ['.mp4', '.webm', '.ogg']
@ -158,6 +159,7 @@ async function findVideoMaterialByUrlCandidates(
export async function findBlockedVideoPreviewByUrl( export async function findBlockedVideoPreviewByUrl(
values: Array<string | URL | null | undefined>, values: Array<string | URL | null | undefined>,
now: Date = new Date(), now: Date = new Date(),
options?: VideoPolicyOptions,
): Promise<BlockedVideoPreviewMatch | null> { ): Promise<BlockedVideoPreviewMatch | null> {
const candidates = buildVideoUrlCandidates(values) const candidates = buildVideoUrlCandidates(values)
const material = await findVideoMaterialByUrlCandidates(candidates) const material = await findVideoMaterialByUrlCandidates(candidates)
@ -170,6 +172,7 @@ export async function findBlockedVideoPreviewByUrl(
material.materialType, material.materialType,
material.createdAt, material.createdAt,
now, now,
options,
) )
if (availability.allowed) { if (availability.allowed) {

View File

@ -0,0 +1,2 @@
-- 视频提前访问豁免:默认关闭,仅超级管理员可在用户管理中为个别用户开启。
ALTER TABLE "users" ADD COLUMN "video_early_access" BOOLEAN NOT NULL DEFAULT false;

View File

@ -18,6 +18,7 @@ model User {
role Role @default(USER) role Role @default(USER)
isActive Boolean @default(false) isActive Boolean @default(false)
canDownload Boolean @default(true) canDownload Boolean @default(true)
videoEarlyAccess Boolean @default(false) @map("video_early_access")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
department String @default("") department String @default("")