add:新增验证视频可选上传功能

This commit is contained in:
2026-10-24 16:12:56 +08:00
parent 25756901cc
commit 0ee5554f57
28 changed files with 1588 additions and 51 deletions
@@ -8,7 +8,9 @@ import { createFileAsset, type FileAssetRow } from '../../repositories/file-asse
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
import { readVideoDurationSeconds } from './video-metadata.js'
import { logWarn } from '../../utils/logger.js'
import { getWorkerAcceptanceConfig } from '../worker-platform/worker-acceptance-config-service.js'
/**
* libvips 并发与操作缓存调优(进程级,必须早于任何 sharp 调用执行)。
@@ -41,7 +43,21 @@ const IMAGE_VARIANT_MEDIUM = 'medium'
const THUMB_MAX_SIDE = 480
const MEDIUM_MAX_SIDE = 1280
const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])
/**
* 验收视频:前端已用 Canvas + MediaRecorder 压到 720p/1.2Mbps,这里只接受这两种容器。
* 对象 key 统一落在 `worker-acceptance-video/` 前缀下,验收服务据此校验来源。
*/
const VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm'])
const ACCEPTANCE_VIDEO_SCENE = 'worker-acceptance-video'
const ALLOWED_CONTENT_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
'video/mp4',
'video/webm',
])
const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
@@ -53,6 +69,8 @@ export type UploadedFileDto = {
filename: string
contentType: string
size: number
/** 仅视频:解析到的时长(秒);WebM 未写 Duration 时为客户端上报值 */
durationSeconds?: number
}
type UploadFileAssetInput = {
@@ -60,6 +78,8 @@ type UploadFileAssetInput = {
scene: unknown
uploaderType: string
uploaderId: string
/** 前端读取到的视频时长(秒),仅在容器里解析不到时作为兜底 */
durationSeconds?: unknown
}
export async function uploadFileAsset(input: UploadFileAssetInput) {
@@ -71,23 +91,31 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
})
}
const maxUploadSize = getMaxUploadSizeBytes()
if (file.buffer.length > maxUploadSize) {
throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, {
statusCode: 400,
errorCode: 'upload_file_too_large',
})
}
const contentType = normalizeContentType(file.mimetype, file.buffer)
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP 或 PDF', {
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP、PDF 或 MP4/WebM 视频', {
statusCode: 400,
errorCode: 'upload_file_type_invalid',
})
}
const scene = normalizeScene(input.scene)
const isVideo = VIDEO_CONTENT_TYPES.has(contentType)
// 视频按验收配置单独限制体积/时长;图片与 PDF 仍走全局体积上限。
const videoDurationSeconds = isVideo
? resolveVideoDuration(file.buffer, contentType, input.durationSeconds)
: 0
if (!isVideo) {
const maxUploadSize = getMaxUploadSizeBytes()
if (file.buffer.length > maxUploadSize) {
throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, {
statusCode: 400,
errorCode: 'upload_file_too_large',
})
}
}
// 视频统一落到验收视频目录,验收服务据此校验来源,避免引用到其它场景的对象。
const scene = isVideo ? ACCEPTANCE_VIDEO_SCENE : normalizeScene(input.scene)
const objectKey = createObjectKey(scene, file.originalname, contentType)
const storage = getObjectStorage()
const uploadedKeys: string[] = []
@@ -147,6 +175,7 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
metadataJson: JSON.stringify({
originalMimeType: file.mimetype,
originalSize: file.size,
...(isVideo ? { durationSeconds: videoDurationSeconds } : {}),
}),
now,
})
@@ -155,15 +184,18 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
}
return {
file: mapUploadedFile(asset, {
objectKey,
fileUrl,
thumbnailUrl,
mediumUrl,
filename: file.originalname,
contentType,
size: file.buffer.length,
}),
file: {
...mapUploadedFile(asset, {
objectKey,
fileUrl,
thumbnailUrl,
mediumUrl,
filename: file.originalname,
contentType,
size: file.buffer.length,
}),
...(isVideo ? { durationSeconds: videoDurationSeconds } : {}),
},
}
} catch (error) {
const cleanupResults = await Promise.allSettled(
@@ -212,6 +244,11 @@ export function getMaxUploadSizeBytes() {
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
}
/** 验收视频的体积上限(按后台配置),multer 需要用它做流式兜底。 */
export function getVideoUploadSizeLimitBytes() {
return Math.max(1, Number(getWorkerAcceptanceConfig().maxSizeMb || 12)) * 1024 * 1024
}
/**
* 文件链接:不再带签名与有效期。
*
@@ -354,9 +391,70 @@ function detectContentType(data: Buffer) {
if (data.length >= 4 && data.subarray(0, 4).toString('ascii') === '%PDF') {
return 'application/pdf'
}
// MP4/MOV 家族:偏移 4 处是 4 字节 box 类型 `ftyp`。
if (
data.length >= 12 &&
data.subarray(4, 8).toString('ascii') === 'ftyp' &&
data.subarray(8, 10).toString('ascii') !== 'qt'
) {
return 'video/mp4'
}
// WebM/Matroska:EBML 头 1A 45 DF A3。
if (
data.length >= 4 &&
data[0] === 0x1a &&
data[1] === 0x45 &&
data[2] === 0xdf &&
data[3] === 0xa3
) {
return 'video/webm'
}
return ''
}
/**
* 验收视频兜底校验:功能开关、体积、时长。
*
* 时长优先用容器解析值(硬证据),解析不到(MediaRecorder 的 WebM 常常不写 Duration)
* 才回退客户端上报值;两者都没有则拒绝,避免放过完全未知长度的视频。
*/
function resolveVideoDuration(
buffer: Buffer,
contentType: string,
declaredDuration: unknown,
): number {
const config = getWorkerAcceptanceConfig()
if (!config.enabled) {
throw createHttpError('验收视频功能已关闭', {
statusCode: 400,
errorCode: 'upload_video_disabled',
})
}
const maxBytes = config.maxSizeMb * 1024 * 1024
if (buffer.length > maxBytes) {
throw createHttpError(`验收视频不能超过 ${config.maxSizeMb}MB`, {
statusCode: 400,
errorCode: 'upload_video_too_large',
})
}
const parsed = readVideoDurationSeconds(buffer, contentType)
const declared = Number(declaredDuration)
const duration = parsed ?? (Number.isFinite(declared) && declared > 0 ? declared : 0)
if (!duration) {
throw createHttpError('无法读取视频时长,请重新选择视频', {
statusCode: 400,
errorCode: 'upload_video_duration_unknown',
})
}
if (duration > config.maxDurationSeconds + 0.5) {
throw createHttpError(`视频时长不能超过 ${config.maxDurationSeconds} 秒`, {
statusCode: 400,
errorCode: 'upload_video_too_long',
})
}
return Math.round(duration * 10) / 10
}
function normalizeScene(value: unknown) {
const scene = String(value || '')
.trim()
@@ -380,6 +478,8 @@ function extensionForContentType(contentType: string) {
if (contentType === 'image/png') return '.png'
if (contentType === 'image/webp') return '.webp'
if (contentType === 'application/pdf') return '.pdf'
if (contentType === 'video/mp4') return '.mp4'
if (contentType === 'video/webm') return '.webm'
return ''
}
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readVideoDurationSeconds } from './video-metadata.js'
/** MP4 box:size(4) + type(4) + payload */
function mp4Box(type: string, payload: Buffer): Buffer {
const header = Buffer.alloc(8)
header.writeUInt32BE(payload.length + 8, 0)
header.write(type, 4, 4, 'ascii')
return Buffer.concat([header, payload])
}
function mvhdV0(timescale: number, duration: number): Buffer {
// version/flags(4) + creation(4) + modification(4) + timescale(4) + duration(4)
const payload = Buffer.alloc(20)
payload.writeUInt32BE(timescale, 12)
payload.writeUInt32BE(duration, 16)
return mp4Box('mvhd', payload)
}
function ebmlId(id: number, length: number): Buffer {
const buffer = Buffer.alloc(length)
let rest = id
for (let index = length - 1; index >= 0; index -= 1) {
buffer[index] = rest % 256
rest = Math.floor(rest / 256)
}
return buffer
}
/** 只支持 1 字节长度(测试数据都很小)。 */
function ebmlElement(id: number, idLength: number, payload: Buffer): Buffer {
return Buffer.concat([ebmlId(id, idLength), Buffer.from([0x80 | payload.length]), payload])
}
test('MP4:从 moov/mvhd 读出时长(30 秒)', () => {
const mp4 = mp4Box('moov', mvhdV0(1000, 30_000))
const seconds = readVideoDurationSeconds(mp4, 'video/mp4')
assert.ok(seconds !== null)
assert.equal(Math.round(seconds as number), 30)
})
test('MP4:mvhd 缺失或损坏时返回 null(交给客户端上报值兜底)', () => {
assert.equal(readVideoDurationSeconds(Buffer.from('not-a-video'), 'video/mp4'), null)
const brokenMvhd = mp4Box('moov', mp4Box('mvhd', Buffer.alloc(4)))
assert.equal(readVideoDurationSeconds(brokenMvhd, 'video/mp4'), null)
})
test('WebM:从 Segment/Info 的 TimecodeScale + Duration 读出时长(30 秒)', () => {
const duration = Buffer.alloc(8)
duration.writeDoubleBE(30_000, 0) // 30000 * 1ms
const timecodeScale = Buffer.from([0x0f, 0x42, 0x40]) // 1,000,000ns
const info = ebmlElement(
0x1549a966,
4,
Buffer.concat([ebmlElement(0x2ad7b1, 3, timecodeScale), ebmlElement(0x4489, 2, duration)]),
)
const segment = ebmlElement(0x18538067, 4, info)
const seconds = readVideoDurationSeconds(segment, 'video/webm')
assert.ok(seconds !== null)
assert.equal(Math.round(seconds as number), 30)
})
test('WebM:MediaRecorder 常见的"没有 Duration"返回 null', () => {
const info = ebmlElement(0x1549a966, 4, ebmlElement(0x2ad7b1, 3, Buffer.from([0x0f, 0x42, 0x40])))
const segment = ebmlElement(0x18538067, 4, info)
assert.equal(readVideoDurationSeconds(segment, 'video/webm'), null)
})
test('非视频类型不做解析', () => {
assert.equal(readVideoDurationSeconds(Buffer.from('%PDF-1.4'), 'application/pdf'), null)
})
@@ -0,0 +1,191 @@
/**
* 视频时长解析(不依赖 ffprobe)。
*
* 验收视频由前端 Canvas + MediaRecorder 重编码后上传,服务端仍要做一次兜底校验:
* 时长超过配置上限直接拒绝,避免"前端改了参数/绕过页面"后上传超长视频。
*
* 覆盖两种交付格式:
* - MP4:moov/mvhd 里的 timescale + duration(最可靠);
* - WebM:Segment/Info 下的 TimecodeScale + Duration。
* MediaRecorder 生成的 WebM 在部分浏览器里不写 Duration(值为 0 或缺失),
* 此时返回 null,由调用方回退到客户端上报的时长 + 体积上限。
*/
export function readVideoDurationSeconds(data: Buffer, contentType: string): number | null {
const normalized = String(contentType || '')
.trim()
.toLowerCase()
try {
if (normalized === 'video/mp4') return readMp4DurationSeconds(data)
if (normalized === 'video/webm') return readWebmDurationSeconds(data)
} catch {
// 损坏/截断的文件按"时长未知"处理,交给调用方决定。
}
return null
}
type Mp4Box = { payloadStart: number; payloadEnd: number }
function readMp4DurationSeconds(data: Buffer): number | null {
const moov = findMp4Box(data, 0, data.length, 'moov')
if (!moov) return null
const mvhd = findMp4Box(data, moov.payloadStart, moov.payloadEnd, 'mvhd')
if (!mvhd) return null
const payload = mvhd.payloadStart
const version = data[payload]
let timescale = 0
let duration = 0
if (version === 1) {
if (payload + 32 > mvhd.payloadEnd) return null
timescale = data.readUInt32BE(payload + 4 + 8 + 8)
duration = Number(data.readBigUInt64BE(payload + 4 + 8 + 8 + 4))
} else {
if (payload + 20 > mvhd.payloadEnd) return null
timescale = data.readUInt32BE(payload + 4 + 4 + 4)
duration = data.readUInt32BE(payload + 4 + 4 + 4 + 4)
}
if (!timescale || !Number.isFinite(duration) || duration <= 0) return null
const seconds = duration / timescale
return Number.isFinite(seconds) && seconds > 0 ? seconds : null
}
function findMp4Box(data: Buffer, start: number, end: number, type: string): Mp4Box | null {
let offset = start
while (offset + 8 <= end) {
let size = data.readUInt32BE(offset)
const boxType = data.toString('ascii', offset + 4, offset + 8)
let headerSize = 8
if (size === 1) {
if (offset + 16 > end) return null
size = Number(data.readBigUInt64BE(offset + 8))
headerSize = 16
} else if (size === 0) {
size = end - offset
}
if (size < headerSize || offset + size > end) return null
if (boxType === type) {
return { payloadStart: offset + headerSize, payloadEnd: offset + size }
}
offset += size
}
return null
}
const EBML_ID_SEGMENT = 0x18538067
const EBML_ID_INFO = 0x1549a966
const EBML_ID_TIMECODE_SCALE = 0x2ad7b1
const EBML_ID_DURATION = 0x4489
const DEFAULT_TIMECODE_SCALE_NS = 1_000_000
type EbmlElement = { payloadStart: number; payloadEnd: number }
function readWebmDurationSeconds(data: Buffer): number | null {
const segment = findEbmlChild(data, 0, data.length, EBML_ID_SEGMENT)
if (!segment) return null
const info = findEbmlChild(data, segment.payloadStart, segment.payloadEnd, EBML_ID_INFO)
if (!info) return null
const durationElement = findEbmlChild(data, info.payloadStart, info.payloadEnd, EBML_ID_DURATION)
if (!durationElement) return null
const durationValue = readEbmlFloat(data, durationElement)
if (!Number.isFinite(durationValue) || durationValue <= 0) return null
const scaleElement = findEbmlChild(
data,
info.payloadStart,
info.payloadEnd,
EBML_ID_TIMECODE_SCALE,
)
const scaleNs = scaleElement
? readEbmlUint(data, scaleElement) || DEFAULT_TIMECODE_SCALE_NS
: DEFAULT_TIMECODE_SCALE_NS
// Duration 的单位是 TimecodeScale(纳秒),换算成秒。
const seconds = (durationValue * scaleNs) / 1_000_000_000
return Number.isFinite(seconds) && seconds > 0 ? seconds : null
}
function findEbmlChild(
data: Buffer,
start: number,
end: number,
targetId: number,
): EbmlElement | null {
let offset = start
while (offset < end) {
const idInfo = readEbmlId(data, offset, end)
if (!idInfo) return null
const sizeInfo = readEbmlSize(data, idInfo.next, end)
if (!sizeInfo) return null
const payloadStart = sizeInfo.next
const payloadEnd = sizeInfo.unknown ? end : Math.min(end, payloadStart + sizeInfo.size)
if (idInfo.id === targetId) return { payloadStart, payloadEnd }
if (payloadEnd <= payloadStart) return null
offset = payloadEnd
}
return null
}
function readEbmlId(
data: Buffer,
offset: number,
end: number,
): { id: number; next: number } | null {
if (offset >= end) return null
const first = data[offset] as number
let length = 0
for (let index = 0; index < 4; index += 1) {
if (first & (0x80 >> index)) {
length = index + 1
break
}
}
if (!length || offset + length > end) return null
let id = 0
for (let index = 0; index < length; index += 1) {
id = id * 256 + (data[offset + index] as number)
}
return { id, next: offset + length }
}
function readEbmlSize(
data: Buffer,
offset: number,
end: number,
): { size: number; next: number; unknown: boolean } | null {
if (offset >= end) return null
const first = data[offset] as number
let length = 0
for (let index = 0; index < 8; index += 1) {
if (first & (0x80 >> index)) {
length = index + 1
break
}
}
if (!length || offset + length > end) return null
const mask = 0xff >> length
let value = first & mask
let unknown = value === mask
for (let index = 1; index < length; index += 1) {
const byte = data[offset + index] as number
if (byte !== 0xff) unknown = false
value = value * 256 + byte
}
return { size: value, next: offset + length, unknown }
}
function readEbmlFloat(data: Buffer, element: EbmlElement): number {
const size = element.payloadEnd - element.payloadStart
if (size === 4) return data.readFloatBE(element.payloadStart)
if (size === 8) return data.readDoubleBE(element.payloadStart)
return Number.NaN
}
function readEbmlUint(data: Buffer, element: EbmlElement): number {
const size = element.payloadEnd - element.payloadStart
if (size <= 0 || size > 6) return 0
let value = 0
for (let index = 0; index < size; index += 1) {
value = value * 256 + (data[element.payloadStart + index] as number)
}
return value
}