diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 56de090c..a21954ec 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -42,6 +42,17 @@ STORAGE_ACCESS_KEY_ID=minioadmin STORAGE_SECRET_ACCESS_KEY=minioadmin STORAGE_REGION=us-east-1 STORAGE_MAX_UPLOAD_SIZE_MB=10 + +# 验收视频(打手端验收上传,选填):只在这里配置,后台不提供配置界面。 +# 超过时长上限直接拒绝(不自动裁剪);体积上限同时是上传接口硬上限。 +WORKER_ACCEPTANCE_VIDEO_ENABLED=true +WORKER_ACCEPTANCE_VIDEO_MAX_DURATION_SECONDS=30 +WORKER_ACCEPTANCE_VIDEO_MAX_SIZE_MB=12 +# 压缩参数:目标高度 360/480/540/720/1080、视频码率、音频码率、帧率 +WORKER_ACCEPTANCE_VIDEO_TARGET_HEIGHT=720 +WORKER_ACCEPTANCE_VIDEO_BITRATE_KBPS=1200 +WORKER_ACCEPTANCE_VIDEO_AUDIO_BITRATE_KBPS=64 +WORKER_ACCEPTANCE_VIDEO_FPS=30 STORAGE_OSS_ENDPOINT= STORAGE_OSS_BUCKET= STORAGE_OSS_ACCESS_KEY_ID= diff --git a/.env.server.example b/.env.server.example index e4110f1e..e2e93acd 100644 --- a/.env.server.example +++ b/.env.server.example @@ -89,6 +89,17 @@ STORAGE_ACCESS_KEY_ID=change-me-storage-access-key STORAGE_SECRET_ACCESS_KEY=change-me-storage-secret-key STORAGE_REGION=us-east-1 STORAGE_MAX_UPLOAD_SIZE_MB=10 + +# 验收视频(打手端验收上传,选填):只在这里配置,后台不提供配置界面。 +# 超过时长上限直接拒绝(不自动裁剪);体积上限同时是上传接口硬上限。 +WORKER_ACCEPTANCE_VIDEO_ENABLED=true +WORKER_ACCEPTANCE_VIDEO_MAX_DURATION_SECONDS=30 +WORKER_ACCEPTANCE_VIDEO_MAX_SIZE_MB=12 +# 压缩参数:目标高度 360/480/540/720/1080、视频码率、音频码率、帧率 +WORKER_ACCEPTANCE_VIDEO_TARGET_HEIGHT=720 +WORKER_ACCEPTANCE_VIDEO_BITRATE_KBPS=1200 +WORKER_ACCEPTANCE_VIDEO_AUDIO_BITRATE_KBPS=64 +WORKER_ACCEPTANCE_VIDEO_FPS=30 # 阿里云 OSS S3 兼容端点:ECS 与桶同地域时用内网地址(免流量费、低延迟) STORAGE_OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com STORAGE_OSS_BUCKET=change-me-oss-bucket diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 761c2382..1126932a 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -122,6 +122,15 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { dailyLimit: 10, }, orderTimelineVisible: false, + acceptanceVideo: { + enabled: true, + maxDurationSeconds: 30, + maxSizeMb: 12, + targetHeight: 720, + bitrateKbps: 1200, + audioBitrateKbps: 64, + fps: 30, + }, }, } } diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index a46557fa..d43669ec 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -103,6 +103,26 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ 'skuMapping', ]), corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']), + // 验收视频(选填):只走 .env,后台没有配置界面。 + booleanEnv('WORKER_ACCEPTANCE_VIDEO_ENABLED', ['worker', 'acceptanceVideo', 'enabled']), + integerEnv('WORKER_ACCEPTANCE_VIDEO_MAX_DURATION_SECONDS', [ + 'worker', + 'acceptanceVideo', + 'maxDurationSeconds', + ]), + integerEnv('WORKER_ACCEPTANCE_VIDEO_MAX_SIZE_MB', ['worker', 'acceptanceVideo', 'maxSizeMb']), + integerEnv('WORKER_ACCEPTANCE_VIDEO_TARGET_HEIGHT', [ + 'worker', + 'acceptanceVideo', + 'targetHeight', + ]), + integerEnv('WORKER_ACCEPTANCE_VIDEO_BITRATE_KBPS', ['worker', 'acceptanceVideo', 'bitrateKbps']), + integerEnv('WORKER_ACCEPTANCE_VIDEO_AUDIO_BITRATE_KBPS', [ + 'worker', + 'acceptanceVideo', + 'audioBitrateKbps', + ]), + integerEnv('WORKER_ACCEPTANCE_VIDEO_FPS', ['worker', 'acceptanceVideo', 'fps']), ] export function applyEnvOverrides( diff --git a/apps/backend/src/routes/file-upload.ts b/apps/backend/src/routes/file-upload.ts index 726bc354..54c98b74 100644 --- a/apps/backend/src/routes/file-upload.ts +++ b/apps/backend/src/routes/file-upload.ts @@ -1,13 +1,17 @@ import type { NextFunction, Request, Response } from 'express' import multer from 'multer' -import { getMaxUploadSizeBytes } from '../services/file-storage/file-storage-service.js' +import { + getMaxUploadSizeBytes, + getVideoUploadSizeLimitBytes, +} from '../services/file-storage/file-storage-service.js' import { createHttpError, sendRouteError } from '../utils/http.js' const upload = multer({ storage: multer.memoryStorage(), limits: { - fileSize: getMaxUploadSizeBytes(), + // 先按较大的验收视频上限收流,具体类型再在 service 内按图片/视频分别校验。 + fileSize: Math.max(getMaxUploadSizeBytes(), getVideoUploadSizeLimitBytes()), files: 1, }, }) diff --git a/apps/backend/src/routes/worker/orders.ts b/apps/backend/src/routes/worker/orders.ts index 910114c0..d9928bef 100644 --- a/apps/backend/src/routes/worker/orders.ts +++ b/apps/backend/src/routes/worker/orders.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { getWorkerAfterSalesCaseSummary, + getWorkerAcceptanceConfig, getWorkerAnnouncement, getWorkerCancelRequestSummary, getWorkerMyOrderEvents, @@ -54,6 +55,17 @@ router.get( }), ) +// 验收弹窗的压缩参数(时长上限/目标高度/码率等),后台可改,打手端每次打开弹窗拉取。 +router.get( + '/acceptance/config', + requireActiveWorker, + createRouteHandler(() => getWorkerAcceptanceConfig(), { + successMessage: 'ok', + errorMessage: '读取验收配置失败', + scope: '[worker/acceptance/config]', + }), +) + router.get( '/announcement', requireActiveWorker, diff --git a/apps/backend/src/routes/worker/profile.ts b/apps/backend/src/routes/worker/profile.ts index 8b378459..4edc94d4 100644 --- a/apps/backend/src/routes/worker/profile.ts +++ b/apps/backend/src/routes/worker/profile.ts @@ -134,6 +134,8 @@ router.post( scene: req.body?.scene || 'worker-acceptance', uploaderType: 'worker', uploaderId: String(session.workerId), + // 验收视频:容器解析不到时长时用前端上报值兜底(见 resolveVideoDuration)。 + durationSeconds: req.body?.durationSeconds, }) }, { diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts index 65c8d970..d2d2444b 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -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 '' } diff --git a/apps/backend/src/services/file-storage/video-metadata.test.ts b/apps/backend/src/services/file-storage/video-metadata.test.ts new file mode 100644 index 00000000..146c55ab --- /dev/null +++ b/apps/backend/src/services/file-storage/video-metadata.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/file-storage/video-metadata.ts b/apps/backend/src/services/file-storage/video-metadata.ts new file mode 100644 index 00000000..6a73cfaf --- /dev/null +++ b/apps/backend/src/services/file-storage/video-metadata.ts @@ -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 +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index a24ef7ef..35d3c5ac 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -58,4 +58,5 @@ export { export * from './worker-product-match-config-service.js' export * from './worker-announcement-config-service.js' export * from './worker-hall-config-service.js' +export * from './worker-acceptance-config-service.js' export * from './sync-worker-orders-service.js' diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-config-service.test.ts b/apps/backend/src/services/worker-platform/worker-acceptance-config-service.test.ts new file mode 100644 index 00000000..1d16de7a --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-acceptance-config-service.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + createDefaultWorkerAcceptanceConfig, + normalizeWorkerAcceptanceConfig, +} from './worker-acceptance-config-service.js' + +test('验收视频配置:空值回落到默认值(30 秒 / 12MB / 720p)', () => { + const config = normalizeWorkerAcceptanceConfig(undefined) + assert.deepEqual(config, createDefaultWorkerAcceptanceConfig()) + assert.equal(config.enabled, true) + assert.equal(config.maxDurationSeconds, 30) + assert.equal(config.maxSizeMb, 12) + assert.equal(config.targetHeight, 720) +}) + +test('验收视频配置:数值被夹在允许区间内', () => { + const tooSmall = normalizeWorkerAcceptanceConfig({ + maxDurationSeconds: 1, + maxSizeMb: 0, + targetHeight: 100, + bitrateKbps: 10, + audioBitrateKbps: 1, + fps: 1, + }) + assert.equal(tooSmall.maxDurationSeconds, 5) + assert.equal(tooSmall.maxSizeMb, 1) + assert.equal(tooSmall.targetHeight, 360) + assert.equal(tooSmall.bitrateKbps, 300) + assert.equal(tooSmall.audioBitrateKbps, 32) + assert.equal(tooSmall.fps, 15) + + const tooLarge = normalizeWorkerAcceptanceConfig({ + maxDurationSeconds: 999, + maxSizeMb: 999, + targetHeight: 4320, + bitrateKbps: 99999, + audioBitrateKbps: 999, + fps: 120, + }) + assert.equal(tooLarge.maxDurationSeconds, 120) + assert.equal(tooLarge.maxSizeMb, 50) + assert.equal(tooLarge.targetHeight, 1080) + assert.equal(tooLarge.bitrateKbps, 4000) + assert.equal(tooLarge.audioBitrateKbps, 128) + assert.equal(tooLarge.fps, 30) +}) + +test('验收视频配置:支持字符串开关与数字字符串(.env 读出来都是字符串)', () => { + const config = normalizeWorkerAcceptanceConfig({ + enabled: 'false', + maxDurationSeconds: '45', + maxSizeMb: '8', + targetHeight: '540', + }) + assert.equal(config.enabled, false) + assert.equal(config.maxDurationSeconds, 45) + assert.equal(config.maxSizeMb, 8) + assert.equal(config.targetHeight, 540) +}) diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-config-service.ts b/apps/backend/src/services/worker-platform/worker-acceptance-config-service.ts new file mode 100644 index 00000000..92914067 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-acceptance-config-service.ts @@ -0,0 +1,99 @@ +import { runtimeConfig } from '../../config/runtime.js' + +/** + * 打手端「验收视频」配置。 + * + * 只从 `.env`(`WORKER_ACCEPTANCE_VIDEO_*`)读取,**后台不提供配置界面**: + * 视频压缩参数属于部署/运维级参数,改动频率低,放在配置文件里更容易随环境一起版本化。 + * + * 前端按这些参数在本机压缩,服务端用同一份配置做时长/体积兜底校验,避免两边口径不一致。 + */ +export type WorkerAcceptanceConfig = { + enabled: boolean + maxDurationSeconds: number + maxSizeMb: number + targetHeight: number + bitrateKbps: number + audioBitrateKbps: number + fps: number +} + +export const WORKER_ACCEPTANCE_CONFIG_LIMITS = { + maxDurationSeconds: { min: 5, max: 120 }, + maxSizeMb: { min: 1, max: 50 }, + targetHeight: { min: 360, max: 1080 }, + bitrateKbps: { min: 300, max: 4000 }, + audioBitrateKbps: { min: 32, max: 128 }, + fps: { min: 15, max: 30 }, +} as const + +export function createDefaultWorkerAcceptanceConfig(): WorkerAcceptanceConfig { + return { + enabled: true, + maxDurationSeconds: 30, + maxSizeMb: 12, + targetHeight: 720, + bitrateKbps: 1200, + audioBitrateKbps: 64, + fps: 30, + } +} + +export function getWorkerAcceptanceConfig(): WorkerAcceptanceConfig { + return normalizeWorkerAcceptanceConfig(runtimeConfig.worker.acceptanceVideo) +} + +export function normalizeWorkerAcceptanceConfig(rawValue: unknown): WorkerAcceptanceConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + const fallback = createDefaultWorkerAcceptanceConfig() + return { + enabled: readBoolean(source.enabled, fallback.enabled), + maxDurationSeconds: readInt( + source.maxDurationSeconds, + WORKER_ACCEPTANCE_CONFIG_LIMITS.maxDurationSeconds, + fallback.maxDurationSeconds, + ), + maxSizeMb: readInt( + source.maxSizeMb, + WORKER_ACCEPTANCE_CONFIG_LIMITS.maxSizeMb, + fallback.maxSizeMb, + ), + targetHeight: readInt( + source.targetHeight, + WORKER_ACCEPTANCE_CONFIG_LIMITS.targetHeight, + fallback.targetHeight, + ), + bitrateKbps: readInt( + source.bitrateKbps, + WORKER_ACCEPTANCE_CONFIG_LIMITS.bitrateKbps, + fallback.bitrateKbps, + ), + audioBitrateKbps: readInt( + source.audioBitrateKbps, + WORKER_ACCEPTANCE_CONFIG_LIMITS.audioBitrateKbps, + fallback.audioBitrateKbps, + ), + fps: readInt(source.fps, WORKER_ACCEPTANCE_CONFIG_LIMITS.fps, fallback.fps), + } +} + +function readBoolean(value: unknown, fallback: boolean) { + if (value === undefined || value === null || value === '') return fallback + if (typeof value === 'boolean') return value + const normalized = String(value).trim().toLowerCase() + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true + if (['0', 'false', 'no', 'off'].includes(normalized)) return false + return fallback +} + +function readInt(value: unknown, range: { min: number; max: number }, fallback: number): number { + // 空值走默认值;有值(哪怕是 0)则夹到合法区间,避免误填 0 导致"上传全部被拒"。 + if (value === undefined || value === null || String(value).trim() === '') return fallback + const parsed = Number(value) + if (!Number.isFinite(parsed)) return fallback + return Math.min(range.max, Math.max(range.min, Math.floor(parsed))) +} + +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts index fd3e4632..07b5cb1e 100644 --- a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts @@ -25,6 +25,10 @@ import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { addHours, nowIso } from '../../utils/time.js' import { safeParseJson } from '../admin/admin-query-utils.js' +import { + extractObjectKeyFromFileUrl, + normalizeStoredFileUrl, +} from '../file-storage/file-storage-service.js' import { createWorkerAcceptanceAdminNotification, getWorkerAcceptanceReminder, @@ -43,6 +47,7 @@ import { resolveWorkerPermissions, } from './mappers.js' import { getWorkerFinanceConfig } from './worker-finance-config-service.js' +import { getWorkerAcceptanceConfig } from './worker-acceptance-config-service.js' import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js' import { getRequiredWorkOrder, @@ -149,6 +154,7 @@ export async function submitWorkerOrderAcceptance( payload, workOrder.draft_acceptance_json, ) + const video = resolveAcceptanceVideo(payload, workOrder.draft_acceptance_json) if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) { throw createHttpError('请上传验收图片', { statusCode: 400, @@ -159,6 +165,7 @@ export async function submitWorkerOrderAcceptance( note, files, imageUrls: [...new Set(imageUrls)], + ...(video ? { video } : {}), submittedAt: now, ...(canAutoAcceptWithoutEvidence ? { @@ -185,6 +192,7 @@ export async function submitWorkerOrderAcceptance( toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, payloadJson: JSON.stringify({ imageCount: imageUrls.length, + hasVideo: Boolean(video), autoAccepted: canAutoAcceptWithoutEvidence, evidenceDueAt: acceptance.evidenceDueAt || null, }), @@ -384,7 +392,10 @@ export async function supplementWorkerAcceptedOrderEvidence( const now = nowIso() const previous = safeParseJson(workOrder.acceptance_json) const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files) - if (!mergedEvidence.changed) { + // 已提交后只允许替换视频,不允许删除证据。 + const payloadVideo = resolveAcceptanceVideoFromPayload(payload) + const video = payloadVideo || readAcceptanceVideoLenient(previous) + if (!mergedEvidence.changed && !payloadVideo) { throw createHttpError('请上传新的验收图片', { statusCode: 400, errorCode: 'work_order_evidence_supplement_image_required', @@ -394,6 +405,7 @@ export async function supplementWorkerAcceptedOrderEvidence( ...previous, files: mergedEvidence.files, imageUrls: mergedEvidence.imageUrls, + ...(video ? { video } : {}), evidenceStatus: 'complete', evidenceSupplementedAt: now, updatedAt: now, @@ -413,6 +425,7 @@ export async function supplementWorkerAcceptedOrderEvidence( payloadJson: JSON.stringify({ addedImageCount: mergedEvidence.addedFileCount, totalImageCount: mergedEvidence.imageUrls.length, + hasVideo: Boolean(video), }), now, }) @@ -471,6 +484,7 @@ async function submitWorkerSharingAcceptance( payload, sharingShare.draft_acceptance_json, ) + const video = resolveAcceptanceVideo(payload, sharingShare.draft_acceptance_json) if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) { throw createHttpError('请上传验收图片', { statusCode: 400, @@ -481,6 +495,7 @@ async function submitWorkerSharingAcceptance( note, files, imageUrls: [...new Set(imageUrls)], + ...(video ? { video } : {}), submittedAt: now, ...(canAutoAcceptWithoutEvidence ? { @@ -575,7 +590,9 @@ async function supplementWorkerAcceptedSharingEvidence( const now = nowIso() const previous = safeParseJson(sharingShare.acceptance_json) const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files) - if (!mergedEvidence.changed) { + const payloadVideo = resolveAcceptanceVideoFromPayload(payload) + const video = payloadVideo || readAcceptanceVideoLenient(previous) + if (!mergedEvidence.changed && !payloadVideo) { throw createHttpError('请上传新的验收图片', { statusCode: 400, errorCode: 'work_order_sharing_evidence_supplement_image_required', @@ -585,6 +602,7 @@ async function supplementWorkerAcceptedSharingEvidence( ...previous, files: mergedEvidence.files, imageUrls: mergedEvidence.imageUrls, + ...(video ? { video } : {}), evidenceStatus: 'complete', evidenceSupplementedAt: now, updatedAt: now, @@ -607,6 +625,7 @@ async function supplementWorkerAcceptedSharingEvidence( workerId: Number(session.workerId), addedImageCount: mergedEvidence.addedFileCount, totalImageCount: mergedEvidence.imageUrls.length, + hasVideo: Boolean(video), }), now, }) @@ -643,6 +662,133 @@ async function resolveWorkerTargetShare( return { share: null, error: 'required' } } +const ACCEPTANCE_VIDEO_SCENE = 'worker-acceptance-video' +const ACCEPTANCE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm']) + +/** + * 归一化验收视频(可选、单个)。 + * + * - 只接受本系统上传到 `worker-acceptance-video/` 的对象,避免把其它场景的文件当验收证据; + * - 格式/时长/体积按后台配置再校验一次(上传接口已校验,这里防篡改 payload); + * - `allowRemove` 只在草稿阶段为 true:显式传 null 表示删除;已提交/补图阶段只能替换,不能删证据。 + */ +function resolveAcceptanceVideo( + payload: JsonObject, + fallback: unknown, + options: { allowRemove?: boolean } = {}, +): Record | null { + const hasVideoKey = Object.prototype.hasOwnProperty.call(payload, 'video') + const rawValue = (payload as Record).video + if (hasVideoKey && rawValue === null) { + return options.allowRemove ? null : readAcceptanceVideoLenient(fallback) + } + return resolveAcceptanceVideoFromPayload(payload) ?? readAcceptanceVideoLenient(fallback) +} + +/** 只取本次请求里带的视频(没有则为 null),用于判断"这次是否真的提交了新视频"。 */ +function resolveAcceptanceVideoFromPayload(payload: JsonObject): Record | null { + const hasVideoKey = Object.prototype.hasOwnProperty.call(payload, 'video') + const rawValue = (payload as Record).video + if (!hasVideoKey || rawValue === undefined || rawValue === null || rawValue === '') return null + return readAcceptanceVideoStrict(rawValue) +} + +function readAcceptanceVideoStrict(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw createHttpError('验收视频参数无效,请重新上传', { + statusCode: 400, + errorCode: 'work_order_acceptance_video_invalid', + }) + } + const source = value as JsonObject + const rawUrl = String(source.url || '').trim() + const objectKey = + String(source.objectKey || source.object_key || '').trim() || + extractObjectKeyFromFileUrl(rawUrl) + if (!objectKey || !objectKey.startsWith(`${ACCEPTANCE_VIDEO_SCENE}/`)) { + throw createHttpError('验收视频来源无效,请重新上传', { + statusCode: 400, + errorCode: 'work_order_acceptance_video_invalid', + }) + } + const contentType = String(source.contentType || source.content_type || '') + .trim() + .toLowerCase() + if (!ACCEPTANCE_VIDEO_CONTENT_TYPES.has(contentType)) { + throw createHttpError('验收视频格式不支持,请重新上传', { + statusCode: 400, + errorCode: 'work_order_acceptance_video_invalid', + }) + } + const config = getWorkerAcceptanceConfig() + if (!config.enabled) { + throw createHttpError('验收视频功能已关闭', { + statusCode: 400, + errorCode: 'work_order_acceptance_video_disabled', + }) + } + const durationSeconds = Number(source.durationSeconds ?? source.duration_seconds ?? 0) + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { + throw createHttpError('无法读取视频时长,请重新上传', { + statusCode: 400, + errorCode: 'work_order_acceptance_video_invalid', + }) + } + if (durationSeconds > config.maxDurationSeconds + 0.5) { + throw createHttpError(`视频时长不能超过 ${config.maxDurationSeconds} 秒`, { + statusCode: 400, + errorCode: 'work_order_acceptance_video_too_long', + }) + } + const size = Number(source.size ?? 0) + if (Number.isFinite(size) && size > config.maxSizeMb * 1024 * 1024) { + throw createHttpError(`验收视频不能超过 ${config.maxSizeMb}MB`, { + statusCode: 400, + errorCode: 'work_order_acceptance_video_too_large', + }) + } + return { + objectKey, + url: normalizeStoredFileUrl(rawUrl, objectKey), + filename: String(source.filename || '').trim(), + contentType, + size: Number.isFinite(size) && size > 0 ? Math.floor(size) : 0, + durationSeconds: Math.round(durationSeconds * 10) / 10, + width: readPositiveInt(source.width), + height: readPositiveInt(source.height), + } +} + +/** 读已落库的视频(不再校验,避免历史数据因配置收紧而读不出来)。 */ +function readAcceptanceVideoLenient(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const source = value as JsonObject + const rawUrl = String(source.url || '').trim() + const objectKey = + String(source.objectKey || source.object_key || '').trim() || + extractObjectKeyFromFileUrl(rawUrl) + const durationSeconds = Number(source.durationSeconds ?? source.duration_seconds ?? 0) + if (!objectKey || !Number.isFinite(durationSeconds) || durationSeconds <= 0) return null + const size = Number(source.size ?? 0) + return { + objectKey, + url: normalizeStoredFileUrl(rawUrl, objectKey), + filename: String(source.filename || '').trim(), + contentType: String(source.contentType || '') + .trim() + .toLowerCase(), + size: Number.isFinite(size) && size > 0 ? Math.floor(size) : 0, + durationSeconds: Math.round(durationSeconds * 10) / 10, + width: readPositiveInt(source.width), + height: readPositiveInt(source.height), + } +} + +function readPositiveInt(value: unknown) { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0 +} + function resolveAcceptanceImages(payload: JsonObject, draftValue: unknown) { const files = normalizeUploadedFiles(payload.files) const imageUrls = [ @@ -707,10 +853,13 @@ export async function saveWorkerOrderAcceptanceDraft( }) } const previous = safeParseJson(workOrder.acceptance_json) + // 已提交后只允许替换视频,不允许删除证据。 + const video = resolveAcceptanceVideo(payload, previous) const acceptance = { note: String(payload.note ?? previous.note ?? '').trim(), files, imageUrls: [...new Set(imageUrls)], + ...(video ? { video } : {}), submittedAt: previous.submittedAt || workOrder.submitted_at || now, updatedAt: now, } @@ -726,7 +875,7 @@ export async function saveWorkerOrderAcceptanceDraft( eventType: 'acceptance_updated', fromStatus: workOrder.status, toStatus: workOrder.status, - payloadJson: JSON.stringify({ imageCount: imageUrls.length }), + payloadJson: JSON.stringify({ imageCount: imageUrls.length, hasVideo: Boolean(video) }), now, }) await publishWorkOrderParticipantChange(Number(workOrder.id), { @@ -745,10 +894,12 @@ export async function saveWorkerOrderAcceptanceDraft( errorCode: 'work_order_draft_status_invalid', }) } + const draftVideo = resolveAcceptanceVideo(payload, {}, { allowRemove: true }) const draft = { note: String(payload.note || '').trim(), files, imageUrls: [...new Set(imageUrls)], + ...(draftVideo ? { video: draftVideo } : {}), updatedAt: now, } const updated = await updateWorkOrderDraftAcceptance({ @@ -763,7 +914,10 @@ export async function saveWorkerOrderAcceptanceDraft( eventType: 'acceptance_draft_saved', fromStatus: workOrder.status, toStatus: workOrder.status, - payloadJson: JSON.stringify({ imageCount: imageUrls.length }), + payloadJson: JSON.stringify({ + imageCount: imageUrls.length, + hasVideo: Boolean(draftVideo), + }), now, }) await publishWorkOrderParticipantChange(Number(workOrder.id), { @@ -797,10 +951,13 @@ async function saveWorkerSharingAcceptanceDraft( }) } const previous = safeParseJson(sharingShare.acceptance_json) + // 已提交后只允许替换视频,不允许删除证据。 + const video = resolveAcceptanceVideo(payload, previous) const acceptance = { note: String(payload.note ?? previous.note ?? '').trim(), files, imageUrls: [...new Set(imageUrls)], + ...(video ? { video } : {}), submittedAt: previous.submittedAt || sharingShare.submitted_at || now, updatedAt: now, } @@ -821,6 +978,7 @@ async function saveWorkerSharingAcceptanceDraft( payloadJson: JSON.stringify({ workerId: Number(session.workerId), imageCount: imageUrls.length, + hasVideo: Boolean(video), }), now, }) @@ -836,10 +994,12 @@ async function saveWorkerSharingAcceptanceDraft( errorCode: 'work_order_sharing_draft_status_invalid', }) } + const draftVideo = resolveAcceptanceVideo(payload, {}, { allowRemove: true }) const draft = { note: String(payload.note || '').trim(), files, imageUrls: [...new Set(imageUrls)], + ...(draftVideo ? { video: draftVideo } : {}), updatedAt: now, } const share = await updateWorkOrderShareDraftAcceptance({ @@ -859,6 +1019,7 @@ async function saveWorkerSharingAcceptanceDraft( payloadJson: JSON.stringify({ workerId: Number(session.workerId), imageCount: imageUrls.length, + hasVideo: Boolean(draftVideo), }), now, }) diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index 6eed50dc..327e9b1f 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -117,6 +117,21 @@ export type RuntimeConfig = { } /** 打手端订单详情是否展示流转记录(默认关闭,仅后台可见) */ orderTimelineVisible: boolean + /** + * 验收视频(打手端验收弹窗,选填)。 + * + * 只用 `.env` 配置,不提供后台管理界面;前端按这些参数在本机压缩, + * 服务端用同一份参数做时长/体积兜底校验。 + */ + acceptanceVideo: { + enabled: boolean + maxDurationSeconds: number + maxSizeMb: number + targetHeight: number + bitrateKbps: number + audioBitrateKbps: number + fps: number + } } cors: { allowedOrigins: string[] diff --git a/apps/frontend/src/components/files/VideoPreview.tsx b/apps/frontend/src/components/files/VideoPreview.tsx new file mode 100644 index 00000000..8362872f --- /dev/null +++ b/apps/frontend/src/components/files/VideoPreview.tsx @@ -0,0 +1,57 @@ +import { VideoCameraOutlined } from '@ant-design/icons' +import { Typography } from 'antd' + +import type { WorkerAcceptanceVideo } from '@/types/worker-platform' + +type VideoPreviewProps = { + video?: WorkerAcceptanceVideo | null + maxHeight?: number + emptyText?: string + showMeta?: boolean +} + +/** 验收视频只读预览(打手端详情、后台工单详情、售后固化快照共用)。 */ +export default function VideoPreview({ + video = null, + maxHeight = 220, + emptyText = '未上传验收视频', + showMeta = true, +}: VideoPreviewProps) { + if (!video?.url) { + return {emptyText} + } + return ( +
+
+ ) +} + +export function formatAcceptanceVideoDuration(seconds?: number) { + const total = Math.max(0, Math.round(Number(seconds) || 0)) + if (!total) return '时长未知' + const minutes = Math.floor(total / 60) + const rest = total % 60 + return minutes > 0 ? `${minutes}分${String(rest).padStart(2, '0')}秒` : `${rest}秒` +} + +function formatAcceptanceVideoSize(bytes: number) { + const size = Number(bytes || 0) + if (!size) return '' + if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}MB` + return `${Math.max(1, Math.round(size / 1024))}KB` +} diff --git a/apps/frontend/src/components/files/VideoUpload.tsx b/apps/frontend/src/components/files/VideoUpload.tsx new file mode 100644 index 00000000..658b7c31 --- /dev/null +++ b/apps/frontend/src/components/files/VideoUpload.tsx @@ -0,0 +1,199 @@ +import { DeleteOutlined, LoadingOutlined, VideoCameraOutlined } from '@ant-design/icons' +import { App, Button, Progress, Space, Typography } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useRef, useState, type ChangeEvent } from 'react' + +import { uploadVideoFile } from '@/services/files' +import { fetchWorkerAcceptanceConfig } from '@/services/worker' +import type { WorkerAcceptanceVideo } from '@/types/worker-platform' +import { + compressVideoForUpload, + isVideoCompressionSupported, + probeVideoFile, + resolveVideoDurationLimitError, + type AcceptanceVideoConfig, +} from '@/utils/video-compress' + +type VideoUploadProps = { + value?: WorkerAcceptanceVideo | null + onChange?: (video: WorkerAcceptanceVideo | null) => void + disabled?: boolean +} + +const VIDEO_ACCEPT = 'video/mp4,video/webm,video/quicktime,video/*' + +/** + * 验收视频上传(非必填,单个)。 + * + * 流程:选片 → 读时长/分辨率 → 超过配置上限直接拒绝(不裁剪)→ Canvas 重编码为 + * 720p/1.2Mbps(见 utils/video-compress)→ 上传 → 回填视频信息。 + * 浏览器不支持 MediaRecorder / captureStream 时直接提示更换浏览器,不上传原片。 + */ +export default function VideoUpload({ + value = null, + onChange, + disabled = false, +}: VideoUploadProps) { + const { message } = App.useApp() + const inputRef = useRef(null) + const [phase, setPhase] = useState<'idle' | 'compressing' | 'uploading'>('idle') + const [progress, setProgress] = useState(0) + + const configQuery = useQuery({ + queryKey: ['worker-acceptance-config'], + queryFn: fetchWorkerAcceptanceConfig, + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + retry: false, + }) + const config = configQuery.data?.data as AcceptanceVideoConfig | undefined + const supported = isVideoCompressionSupported() + const busy = phase !== 'idle' + const canPick = Boolean(config?.enabled) && supported && !disabled && !busy + + async function handleSelect(event: ChangeEvent) { + const file = event.target.files?.[0] + // 让"选择同一个文件"也能再次触发 change。 + event.target.value = '' + if (!file || !config) return + if (!config.enabled) { + message.error('验收视频功能暂未开启') + return + } + if (!supported) { + message.error('当前浏览器不支持视频压缩,请使用 Chrome / Edge,或先裁剪视频后再上传') + return + } + + setPhase('compressing') + setProgress(0) + try { + const probe = await probeVideoFile(file) + const limitError = resolveVideoDurationLimitError( + probe.durationSeconds, + config.maxDurationSeconds, + ) + if (limitError) throw new Error(limitError) + + const compressed = await compressVideoForUpload(file, config, setProgress) + if (compressed.blob.size > config.maxSizeMb * 1024 * 1024) { + throw new Error(`压缩后仍超过 ${config.maxSizeMb}MB,请缩短视频后重试`) + } + + setPhase('uploading') + setProgress(0) + const uploadName = `acceptance-${Date.now()}.${compressed.extension}` + const uploaded = await uploadVideoFile( + new File([compressed.blob], uploadName, { type: compressed.contentType }), + 'worker-acceptance-video', + compressed.durationSeconds, + ) + onChange?.({ + objectKey: uploaded.objectKey, + url: uploaded.url, + filename: uploaded.filename || uploadName, + contentType: uploaded.contentType || compressed.contentType, + size: uploaded.size || compressed.blob.size, + durationSeconds: uploaded.durationSeconds || compressed.durationSeconds, + width: compressed.width, + height: compressed.height, + }) + message.success('验收视频已上传') + } catch (error) { + message.error(error instanceof Error ? error.message : '视频处理失败,请重试') + } finally { + setPhase('idle') + setProgress(0) + } + } + + const hint = config + ? `选填,最长 ${config.maxDurationSeconds} 秒;上传前会自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内` + : '选填;正在读取验收视频配置…' + + return ( +
+ void handleSelect(event)} + /> + + {value ? ( +
+
+ ) : ( + + + + {hint} + + + )} + + {busy ? ( + `${phase === 'compressing' ? '压缩中' : '上传中'} ${percent ?? 0}%`} + style={{ marginTop: 8 }} + /> + ) : null} +
+ ) +} + +function estimatedSizeMb(config: AcceptanceVideoConfig) { + const bytesPerSecond = ((config.bitrateKbps + config.audioBitrateKbps) * 1000) / 8 + const estimated = (bytesPerSecond * config.maxDurationSeconds) / (1024 * 1024) + return Math.max(1, Math.ceil(estimated)) +} + +function formatDuration(seconds: number) { + const total = Math.max(0, Math.round(Number(seconds) || 0)) + const minutes = Math.floor(total / 60) + const rest = total % 60 + return minutes > 0 ? `${minutes}分${String(rest).padStart(2, '0')}秒` : `${rest}秒` +} + +function formatSize(bytes?: number) { + const size = Number(bytes || 0) + if (!size) return '大小未知' + if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}MB` + return `${Math.max(1, Math.round(size / 1024))}KB` +} diff --git a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx index 2f865870..db7e1626 100644 --- a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx @@ -1,4 +1,4 @@ -import { ReloadOutlined } from '@ant-design/icons' +import { ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' import { App, @@ -26,7 +26,12 @@ import { repayAdminAfterSalesCaseDebt, resolveAdminAfterSalesCase, } from '@/services/admin' -import type { UploadedFile, WorkerAfterSalesCase, WorkOrder } from '@/types/worker-platform' +import type { + UploadedFile, + WorkerAcceptanceVideo, + WorkerAfterSalesCase, + WorkOrder, +} from '@/types/worker-platform' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { formatAdminDateTime } from '@/utils/admin-time' import { uniqueUploadedFiles } from '@/utils/image-identity' @@ -259,6 +264,15 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan /> ) : null} + {getAfterSalesSnapshotVideo(item) ? ( + + 查看固化验收视频 + + ) : null} ), }, @@ -570,4 +584,21 @@ function getAfterSalesSnapshotFiles(item: WorkerAfterSalesCase): UploadedFile[] ) } +/** 售后固化快照里的验收视频(有则给出可点击链接)。 */ +function getAfterSalesSnapshotVideo(item: WorkerAfterSalesCase): WorkerAcceptanceVideo | null { + const snapshot = asRecord(item.acceptanceSnapshot) + const acceptance = asRecord(snapshot.acceptance) + const video = asRecord(acceptance.video) + const url = String(video.url || '').trim() + if (!url) return null + return { + objectKey: String(video.objectKey || video.object_key || ''), + url, + filename: String(video.filename || ''), + contentType: String(video.contentType || video.content_type || ''), + size: Number(video.size || 0), + durationSeconds: Number(video.durationSeconds || video.duration_seconds || 0), + } +} + export default AfterSalesPanel diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 628f24fa..8f5b6cc4 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -14,6 +14,7 @@ import { UserAddOutlined, WarningOutlined, RollbackOutlined, + VideoCameraOutlined, } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' import { @@ -28,6 +29,7 @@ import { Input, InputNumber, Modal, + Popover, Radio, Select, Space, @@ -45,6 +47,7 @@ import JsonPreview from '@/components/admin/JsonPreview' import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' import ImageUpload from '@/components/files/ImageUpload' import ImagePreviewList from '@/components/files/ImagePreviewList' +import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/VideoPreview' import { acceptAdminWorkOrder, acceptAdminWorkOrderShare, @@ -2603,6 +2606,11 @@ export default function WorkOrdersPanel() { {getAcceptanceFiles(detailOrder).length + getAcceptanceImageUrls(detailOrder).length || '-'} + + {detailOrder.acceptance?.video + ? formatAcceptanceVideoDuration(detailOrder.acceptance.video.durationSeconds) + : '未上传'} + {detailOrder.acceptance?.reviewMode === 'vip_auto' ? ( {detailOrder.acceptance.evidenceStatus === 'pending' @@ -2626,6 +2634,11 @@ export default function WorkOrdersPanel() { ) : ( 暂无验收图 )} + @@ -3205,7 +3218,7 @@ export default function WorkOrdersPanel() { loading={sharingQuery.isFetching} pagination={false} tableLayout="fixed" - scroll={{ x: 1528 }} + scroll={{ x: 1624 }} rowClassName={(share) => share.problemMarked ? 'admin-share-row-problem' @@ -3316,6 +3329,26 @@ export default function WorkOrdersPanel() { /> ), }, + { + title: '验收视频', + width: 96, + render: (_, share) => { + const video = share.acceptance?.video + if (!video?.url) return '-' + return ( + } + > + + + ) + }, + }, { title: '备注', width: 120, diff --git a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx index ebab51c9..074d350d 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx @@ -3,6 +3,7 @@ import { Alert, Button, Card, Descriptions, Drawer, Space, Steps, Tag, Typograph import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' import ImagePreviewList from '@/components/files/ImagePreviewList' +import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/VideoPreview' import type { WorkOrder } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display' @@ -104,7 +105,7 @@ export function WorkerOrderDetailDrawer({ icon={} onClick={() => onOpenAcceptance(order)} > - {hasSubmittedAcceptance(order) ? '补充图片' : '上传图片 / 提交验收'} + {hasSubmittedAcceptance(order) ? '补充验收资料' : '上传图片/视频 · 提交验收'} ) : null} {canRequestCancel(order) ? ( @@ -322,6 +323,7 @@ function GiftProgressCard({ order }: { order: WorkOrder }) { function AcceptanceSummary({ order }: { order: WorkOrder }) { const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(order)) const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order)) + const acceptanceVideo = getWorkerAcceptance(order)?.video ?? getWorkerDraft(order)?.video ?? null const hasAcceptance = acceptanceFiles.length > 0 const hasDraft = draftFiles.length > 0 && !hasSubmittedAcceptance(order) @@ -329,7 +331,12 @@ function AcceptanceSummary({ order }: { order: WorkOrder }) {
{acceptanceFiles.length} - + + {acceptanceVideo + ? formatAcceptanceVideoDuration(acceptanceVideo.durationSeconds) + : '未上传'} + + {formatDateTime(getAcceptanceSubmittedAt(order))} @@ -345,6 +352,8 @@ function AcceptanceSummary({ order }: { order: WorkOrder }) { 暂无已提交验收图片 )} + + {hasDraft ? (
}> diff --git a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx index 537abb5e..beb0228e 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx @@ -476,7 +476,7 @@ function WorkerOrderMobileCard({ className="worker-order-mobile-action-submit" onClick={() => onOpenAcceptance(order)} > - {hasSubmittedAcceptance(order) ? '补充图片' : '提交验收'} + {hasSubmittedAcceptance(order) ? '补充验收资料' : '上传图片/视频 · 提交验收'} ) : null} {canRemindAcceptance(order) ? ( diff --git a/apps/frontend/src/pages/worker/WorkerOrderModals.tsx b/apps/frontend/src/pages/worker/WorkerOrderModals.tsx index cdcd2ecb..ed7397c0 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderModals.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderModals.tsx @@ -3,8 +3,10 @@ import { Button, Form, Input, Modal, Radio, Space, Tag, Typography } from 'antd' import type { FormInstance } from 'antd' import ImageUpload from '@/components/files/ImageUpload' +import VideoUpload from '@/components/files/VideoUpload' import type { UploadedFile, + WorkerAcceptanceVideo, WorkerCancelRequestRisk, WorkerOrderFeedback, WorkOrder, @@ -59,10 +61,12 @@ export function WorkerOrderModals({ onSaveNote, editingOrder, acceptanceFiles, + acceptanceVideo, acceptanceForm, workerCanAutoAcceptWithoutEvidence, onCloseAcceptance, onAcceptanceFilesChange, + onAcceptanceVideoChange, onSubmitAcceptance, onSaveAcceptanceImages, giftMaterialOrder, @@ -89,10 +93,12 @@ export function WorkerOrderModals({ onSaveNote: (values: WorkerNoteFormValues) => void | Promise editingOrder: WorkOrder | null acceptanceFiles: UploadedFile[] + acceptanceVideo: WorkerAcceptanceVideo | null acceptanceForm: FormInstance workerCanAutoAcceptWithoutEvidence: boolean onCloseAcceptance: () => void onAcceptanceFilesChange: (files: UploadedFile[]) => void + onAcceptanceVideoChange: (video: WorkerAcceptanceVideo | null) => void onSubmitAcceptance: (values: AcceptanceFormValues) => void | Promise onSaveAcceptanceImages: (values: AcceptanceFormValues) => void | Promise giftMaterialOrder: WorkOrder | null @@ -283,6 +289,12 @@ export function WorkerOrderModals({ showPasteHint /> + + + (null) const [feedbackOrder, setFeedbackOrder] = useState(null) const [acceptanceFiles, setAcceptanceFiles] = useState([]) + /** 验收视频(单条,非必填);dirty 表示本次弹窗里换过视频,用于补图时允许"只补视频" */ + const [acceptanceVideo, setAcceptanceVideo] = useState(null) + const [acceptanceVideoDirty, setAcceptanceVideoDirty] = useState(false) const [giftMaterialOrder, setGiftMaterialOrder] = useState(null) const [giftMaterialFiles, setGiftMaterialFiles] = useState([]) const [form] = Form.useForm() @@ -340,18 +343,20 @@ export default function WorkerOrdersPage() { const editingTarget = buildShareEditingOrder(order, shareId) setEditingShareId(shareId) setEditingOrder(editingTarget) + setAcceptanceVideoDirty(false) + const submitted = getWorkerAcceptance(editingTarget) if (canSupplementAcceptedEvidence(editingTarget)) { - // 已验收补图只上传新增文件,原有验收图片由服务端保留。 + // 已验收补图只上传新增文件,原有验收图片由服务端保留;视频只能替换、不能删除。 setAcceptanceFiles([]) + setAcceptanceVideo(submitted?.video ?? null) form.resetFields() setDetailOrder(null) return } // 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容 - const source = hasSubmittedAcceptance(editingTarget) - ? getWorkerAcceptance(editingTarget) - : getWorkerDraft(editingTarget) + const source = hasSubmittedAcceptance(editingTarget) ? submitted : getWorkerDraft(editingTarget) setAcceptanceFiles(getUniqueAcceptanceFiles(source)) + setAcceptanceVideo(source?.video ?? null) form.setFieldsValue({ note: String(source?.note || '') }) setDetailOrder(null) } @@ -484,22 +489,25 @@ export default function WorkerOrdersPage() { if (!editingOrder) return const isAcceptedEvidenceSupplement = canSupplementAcceptedEvidence(editingOrder) if (isAcceptedEvidenceSupplement) { - if (acceptanceFiles.length === 0) { - message.error('请上传新的验收图片') + if (acceptanceFiles.length === 0 && !acceptanceVideoDirty) { + message.error('请上传新的验收图片,或替换验收视频') return } try { await supplementWorkerAcceptedOrderEvidence(editingOrder.workOrderId, { files: acceptanceFiles, + ...(acceptanceVideoDirty ? { video: acceptanceVideo } : {}), ...(editingShareId ? { shareId: editingShareId } : {}), }) - message.success('验收图片已补充') + message.success('验收资料已补充') setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() await refreshAll() } catch (error) { - message.error(error instanceof Error ? error.message : '补充验收图片失败') + message.error(error instanceof Error ? error.message : '补充验收资料失败') } return } @@ -518,11 +526,14 @@ export default function WorkerOrdersPage() { await submitWorkerAcceptance(editingOrder.workOrderId, { note: values.note, files: acceptanceFiles, + video: acceptanceVideo, ...(editingShareId ? { shareId: editingShareId } : {}), }) message.success(canAutoAccept ? '已自动验收,可在 24 小时内补充图片' : '已提交验收') setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() await refreshAll() } catch (error) { @@ -540,13 +551,16 @@ export default function WorkerOrdersPage() { await saveWorkerAcceptanceDraft(editingOrder.workOrderId, { note: values.note, files: acceptanceFiles, + video: acceptanceVideo, ...(editingShareId ? { shareId: editingShareId } : {}), }) message.success( - hasSubmittedAcceptance(editingOrder) ? '验收图片已更新' : '验收图片已暂存,可继续补充', + hasSubmittedAcceptance(editingOrder) ? '验收资料已更新' : '验收资料已暂存,可继续补充', ) setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() await refreshAll() } catch (error) { @@ -754,14 +768,21 @@ export default function WorkerOrdersPage() { onSaveNote={saveOrderNote} editingOrder={editingOrder} acceptanceFiles={acceptanceFiles} + acceptanceVideo={acceptanceVideo} acceptanceForm={form} workerCanAutoAcceptWithoutEvidence={workerCanAutoAcceptWithoutEvidence} onCloseAcceptance={() => { setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() }} onAcceptanceFilesChange={setAcceptanceFiles} + onAcceptanceVideoChange={(video) => { + setAcceptanceVideo(video) + setAcceptanceVideoDirty(true) + }} onSubmitAcceptance={submitAcceptance} onSaveAcceptanceImages={saveAcceptanceImages} giftMaterialOrder={giftMaterialOrder} diff --git a/apps/frontend/src/services/files.ts b/apps/frontend/src/services/files.ts index db31fddd..59aa00e4 100644 --- a/apps/frontend/src/services/files.ts +++ b/apps/frontend/src/services/files.ts @@ -18,3 +18,16 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope) const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form) return response.data.file } + +/** + * 验收视频上传:视频已在浏览器内压好,这里不再做图片压缩。 + * 带上 durationSeconds 供服务端在容器解析不到时长时兜底(见 resolveVideoDuration)。 + */ +export async function uploadVideoFile(file: File, scene: string, durationSeconds: number) { + const form = new FormData() + form.append('file', file) + form.append('scene', scene) + form.append('durationSeconds', String(durationSeconds)) + const response = await apiPostForm<{ file: UploadedFile }>('/api/v1/worker/files/upload', form) + return response.data.file +} diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 31a484bf..d553c1cd 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -9,6 +9,8 @@ import type { WorkerCancelRequest, WorkerCancelRequestRisk, WorkerAnnouncementConfig, + WorkerAcceptanceConfig, + WorkerAcceptanceVideo, WorkerOrderFeedback, WorkerAfterSalesCase, WorkerHallCategoriesResponse, @@ -143,6 +145,11 @@ export function fetchWorkerAnnouncement() { return apiGet('/api/v1/worker/announcement') } +/** 验收视频配置(后台可改):时长上限、目标高度、码率等,压缩前读取。 */ +export function fetchWorkerAcceptanceConfig() { + return apiGet('/api/v1/worker/acceptance/config') +} + export function fetchWorkerWalletLedgers(params?: Record) { return apiGet>( '/api/v1/worker/profile/wallet-ledgers', @@ -301,10 +308,16 @@ export function saveWorkerOrderNote(workOrderId: number, note: string) { return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/note`, { note }) } -export function submitWorkerAcceptance( - workOrderId: number, - payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number }, -) { +export type WorkerAcceptancePayload = { + note?: string + imageUrls?: string[] + files?: UploadedFile[] + /** 验收视频(非必填);null 在草稿阶段表示删除,已提交阶段服务端只接受替换 */ + video?: WorkerAcceptanceVideo | null + shareId?: number +} + +export function submitWorkerAcceptance(workOrderId: number, payload: WorkerAcceptancePayload) { return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/submit-acceptance`, payload, @@ -328,10 +341,7 @@ export function remindWorkerAcceptance(workOrderId: number) { ) } -export function saveWorkerAcceptanceDraft( - workOrderId: number, - payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number }, -) { +export function saveWorkerAcceptanceDraft(workOrderId: number, payload: WorkerAcceptancePayload) { return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/acceptance-draft`, payload, @@ -340,7 +350,7 @@ export function saveWorkerAcceptanceDraft( export function supplementWorkerAcceptedOrderEvidence( workOrderId: number, - payload: { files: UploadedFile[]; shareId?: number }, + payload: { files: UploadedFile[]; video?: WorkerAcceptanceVideo | null; shareId?: number }, ) { return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/acceptance-evidence`, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index ce2309d8..b2cefb52 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -492,6 +492,31 @@ export type UploadedFile = { filename: string contentType: string size: number + /** 仅验收视频:服务端解析(或客户端上报)的时长,单位秒 */ + durationSeconds?: number +} + +/** 验收视频(单条,非必填)。 */ +export type WorkerAcceptanceVideo = { + objectKey: string + url: string + filename?: string + contentType?: string + size?: number + durationSeconds: number + width?: number + height?: number +} + +/** 验收视频配置(来自 .env 的 WORKER_ACCEPTANCE_VIDEO_*,后台不提供配置界面)。 */ +export type WorkerAcceptanceConfig = { + enabled: boolean + maxDurationSeconds: number + maxSizeMb: number + targetHeight: number + bitrateKbps: number + audioBitrateKbps: number + fps: number } export type WorkOrderStatus = @@ -516,6 +541,8 @@ export type WorkerAcceptance = { note?: string files?: UploadedFile[] imageUrls?: string[] + /** 验收视频(非必填):打手压缩后上传的单个视频 */ + video?: WorkerAcceptanceVideo | null submittedAt?: string updatedAt?: string reviewMode?: 'vip_auto' | string diff --git a/apps/frontend/src/utils/video-compress.ts b/apps/frontend/src/utils/video-compress.ts new file mode 100644 index 00000000..8f287785 --- /dev/null +++ b/apps/frontend/src/utils/video-compress.ts @@ -0,0 +1,306 @@ +/** + * 验收视频:浏览器内探测 + 重编码(Canvas + MediaRecorder)。 + * + * 为什么不用 ffmpeg.wasm:核心包 31MB、需要 COOP/COEP,iOS 内存易崩; + * 这里只是把「游戏中录屏的 30 秒片段」压到 720p/1.2Mbps,WebM(VP8) 足够, + * 且零依赖、不上传原片(打手多为手机流量)。 + * + * 时长策略(产品已确认):**超过上限直接拒绝,不自动裁剪**。 + */ +export type AcceptanceVideoConfig = { + enabled: boolean + maxDurationSeconds: number + maxSizeMb: number + targetHeight: number + bitrateKbps: number + audioBitrateKbps: number + fps: number +} + +export type VideoProbeResult = { + durationSeconds: number + width: number + height: number +} + +export type CompressedVideo = { + blob: Blob + durationSeconds: number + width: number + height: number + contentType: string + extension: 'webm' | 'mp4' +} + +/** 按兼容性从高到低尝试;实际可用性由 MediaRecorder.isTypeSupported 决定。 */ +const MIME_CANDIDATES = [ + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp9,opus', + 'video/webm', + 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', + 'video/mp4', +] + +export function pickSupportedVideoMimeType( + isSupported: (mime: string) => boolean = defaultMimeSupport, +): string { + for (const candidate of MIME_CANDIDATES) { + if (isSupported(candidate)) return candidate + } + return '' +} + +/** 时长是否超出配置上限;返回空串表示通过。 */ +export function resolveVideoDurationLimitError( + durationSeconds: number, + maxDurationSeconds: number, +): string { + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) + return '无法读取视频时长,请更换视频' + if (durationSeconds > maxDurationSeconds + 0.5) { + return `视频时长不能超过 ${maxDurationSeconds} 秒,请先裁剪后再上传` + } + return '' +} + +/** 按目标高度等比缩放(取偶数值,编码器要求宽高为偶数)。 */ +export function resolveVideoTargetSize( + width: number, + height: number, + targetHeight: number, +): { width: number; height: number } { + const safeWidth = Math.max(2, Math.round(width || 0)) + const safeHeight = Math.max(2, Math.round(height || 0)) + const limit = Math.max(2, Math.round(targetHeight || 720)) + const scale = Math.min(1, limit / safeHeight) + return { + width: ensureEven(safeWidth * scale), + height: ensureEven(safeHeight * scale), + } +} + +export function isVideoCompressionSupported(): boolean { + if (typeof window === 'undefined' || typeof document === 'undefined') return false + if (typeof MediaRecorder === 'undefined') return false + if (typeof HTMLCanvasElement === 'undefined') return false + if (typeof HTMLCanvasElement.prototype.captureStream !== 'function') return false + return Boolean(pickSupportedVideoMimeType()) +} + +/** 读取视频时长/分辨率;部分 WebM 的 duration 为 Infinity,这里做一次定位探测。 */ +export function probeVideoFile(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file) + const video = document.createElement('video') + let settled = false + const cleanup = () => { + URL.revokeObjectURL(url) + video.removeAttribute('src') + video.load() + } + const finish = () => { + if (settled) return + settled = true + const durationSeconds = Number.isFinite(video.duration) ? video.duration : 0 + const width = video.videoWidth + const height = video.videoHeight + cleanup() + if (!durationSeconds || durationSeconds <= 0 || !width || !height) { + reject(new Error('无法读取视频信息,请更换视频或先裁剪')) + return + } + resolve({ durationSeconds, width, height }) + } + + video.preload = 'metadata' + video.muted = true + video.src = url + video.onloadedmetadata = () => { + if (Number.isFinite(video.duration) && video.duration > 0) { + finish() + return + } + // WebM 常见:duration = Infinity。跳到极远处逼浏览器计算真实时长。 + video.currentTime = 1e101 + const timer = setTimeout(finish, 1500) + video.ondurationchange = () => { + clearTimeout(timer) + finish() + } + } + video.onerror = () => { + if (settled) return + settled = true + cleanup() + reject(new Error('无法读取视频信息,请更换视频或先裁剪')) + } + }) +} + +/** + * 压缩到配置参数并返回新的 Blob;超过时长上限直接抛错(不裁剪)。 + * onProgress 回调 0~1,用于展示"压缩中"进度。 + */ +export async function compressVideoForUpload( + file: File, + config: AcceptanceVideoConfig, + onProgress?: (ratio: number) => void, +): Promise { + if (!isVideoCompressionSupported()) { + throw new Error('当前浏览器不支持视频压缩,请使用 Chrome / Edge,或先裁剪视频后再上传') + } + const mimeType = pickSupportedVideoMimeType() + const probe = await probeVideoFile(file) + const limitError = resolveVideoDurationLimitError( + probe.durationSeconds, + config.maxDurationSeconds, + ) + if (limitError) throw new Error(limitError) + + const target = resolveVideoTargetSize(probe.width, probe.height, config.targetHeight) + const url = URL.createObjectURL(file) + const video = document.createElement('video') + const canvas = document.createElement('canvas') + let audioContext: AudioContext | null = null + let stopped = false + let frameHandle = 0 + + const stopRecording = () => { + if (stopped) return + stopped = true + try { + video.pause() + } catch { + // ignore + } + } + + try { + video.src = url + video.muted = true + video.playsInline = true + video.preload = 'auto' + await waitForEvent(video, 'loadeddata') + + canvas.width = target.width + canvas.height = target.height + const context = canvas.getContext('2d') + if (!context) throw new Error('当前浏览器不支持视频压缩,请更换浏览器') + + const stream = canvas.captureStream(Math.max(15, Math.min(30, config.fps || 30))) + try { + const AudioContextCtor = + window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + if (AudioContextCtor) { + audioContext = new AudioContextCtor() + const source = audioContext.createMediaElementSource(video) + const destination = audioContext.createMediaStreamDestination() + source.connect(destination) + for (const track of destination.stream.getAudioTracks()) stream.addTrack(track) + } + } catch { + // 视频无音轨或浏览器限制:退化为无声视频,不阻断压缩。 + } + + const recorder = new MediaRecorder(stream, { + mimeType, + videoBitsPerSecond: Math.max(300, Number(config.bitrateKbps) || 1200) * 1000, + audioBitsPerSecond: Math.max(32, Number(config.audioBitrateKbps) || 64) * 1000, + }) + const chunks: Blob[] = [] + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) chunks.push(event.data) + } + const recorderStopped = new Promise((resolve) => { + recorder.onstop = () => resolve() + }) + + const drawFrame = () => { + if (stopped) return + if (video.readyState >= 2) { + context.drawImage(video, 0, 0, target.width, target.height) + } + onProgress?.(Math.max(0, Math.min(1, video.currentTime / probe.durationSeconds))) + if (video.ended || video.currentTime >= config.maxDurationSeconds) { + stopRecording() + return + } + frameHandle = requestAnimationFrame(drawFrame) + } + + recorder.start(1000) + await video.play() + frameHandle = requestAnimationFrame(drawFrame) + video.addEventListener('ended', stopRecording, { once: true }) + + // 防呆:视频卡住时最多等「时长 + 10 秒」,避免压缩界面永久转圈。 + const timeoutHandle = setTimeout(stopRecording, (probe.durationSeconds + 10) * 1000) + await new Promise((resolve) => { + const interval = setInterval(() => { + if (stopped) { + clearInterval(interval) + resolve() + } + }, 200) + }) + clearTimeout(timeoutHandle) + if (recorder.state !== 'inactive') recorder.stop() + await recorderStopped + if (frameHandle) cancelAnimationFrame(frameHandle) + + const blob = new Blob(chunks, { type: mimeType.split(';')[0] }) + if (!blob.size) throw new Error('视频压缩失败,请重试或更换视频') + const contentType = blob.type || 'video/webm' + onProgress?.(1) + return { + blob, + durationSeconds: Math.min(probe.durationSeconds, config.maxDurationSeconds), + width: target.width, + height: target.height, + contentType, + extension: contentType.includes('mp4') ? 'mp4' : 'webm', + } + } finally { + if (frameHandle) cancelAnimationFrame(frameHandle) + try { + audioContext?.close() + } catch { + // ignore + } + URL.revokeObjectURL(url) + video.removeAttribute('src') + video.load() + } +} + +function defaultMimeSupport(mime: string): boolean { + if (typeof MediaRecorder === 'undefined') return false + return typeof MediaRecorder.isTypeSupported === 'function' + ? MediaRecorder.isTypeSupported(mime) + : false +} + +function ensureEven(value: number): number { + const rounded = Math.max(2, Math.round(value)) + return rounded % 2 === 0 ? rounded : rounded - 1 +} + +function waitForEvent(target: HTMLMediaElement, eventName: string): Promise { + return new Promise((resolve, reject) => { + const onEvent = () => { + cleanup() + resolve() + } + const onError = () => { + cleanup() + reject(new Error('视频读取失败,请更换文件')) + } + const cleanup = () => { + target.removeEventListener(eventName, onEvent) + target.removeEventListener('error', onError) + } + target.addEventListener(eventName, onEvent, { once: true }) + target.addEventListener('error', onError, { once: true }) + }) +} diff --git a/apps/frontend/tests/video-compress.test.ts b/apps/frontend/tests/video-compress.test.ts new file mode 100644 index 00000000..2eb84d90 --- /dev/null +++ b/apps/frontend/tests/video-compress.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + pickSupportedVideoMimeType, + resolveVideoDurationLimitError, + resolveVideoTargetSize, +} from '../src/utils/video-compress.ts' + +test('时长策略:超过上限直接拒绝(不自动裁剪)', () => { + assert.equal(resolveVideoDurationLimitError(30, 30), '') + assert.equal(resolveVideoDurationLimitError(29.4, 30), '') + assert.match(resolveVideoDurationLimitError(31, 30), /不能超过 30 秒/) + assert.match(resolveVideoDurationLimitError(30.6, 30), /不能超过 30 秒/) +}) + +test('时长策略:读不到时长也要拒绝,避免上传未知长度视频', () => { + assert.match(resolveVideoDurationLimitError(0, 30), /无法读取视频时长/) + assert.match(resolveVideoDurationLimitError(Number.NaN, 30), /无法读取视频时长/) +}) + +test('压缩尺寸:按目标高度等比缩放且宽高为偶数', () => { + assert.deepEqual(resolveVideoTargetSize(1920, 1080, 720), { width: 1280, height: 720 }) + assert.deepEqual(resolveVideoTargetSize(1080, 1920, 720), { width: 404, height: 720 }) + // 原视频比目标更小:不放大 + assert.deepEqual(resolveVideoTargetSize(640, 360, 720), { width: 640, height: 360 }) + const odd = resolveVideoTargetSize(1081, 721, 720) + assert.equal(odd.width % 2, 0) + assert.equal(odd.height % 2, 0) +}) + +test('容器选择:优先 WebM(VP8),其次 VP9/WebM,最后 MP4', () => { + assert.equal( + pickSupportedVideoMimeType((mime) => true), + 'video/webm;codecs=vp8,opus', + ) + assert.equal( + pickSupportedVideoMimeType((mime) => mime === 'video/webm' || mime.startsWith('video/mp4')), + 'video/webm', + ) + assert.equal( + pickSupportedVideoMimeType((mime) => mime.startsWith('video/mp4')), + 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', + ) + assert.equal( + pickSupportedVideoMimeType(() => false), + '', + ) +})