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
@@ -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'
@@ -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)
})
@@ -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<string, unknown> {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -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<string, unknown> | null {
const hasVideoKey = Object.prototype.hasOwnProperty.call(payload, 'video')
const rawValue = (payload as Record<string, unknown>).video
if (hasVideoKey && rawValue === null) {
return options.allowRemove ? null : readAcceptanceVideoLenient(fallback)
}
return resolveAcceptanceVideoFromPayload(payload) ?? readAcceptanceVideoLenient(fallback)
}
/** 只取本次请求里带的视频(没有则为 null),用于判断"这次是否真的提交了新视频"。 */
function resolveAcceptanceVideoFromPayload(payload: JsonObject): Record<string, unknown> | null {
const hasVideoKey = Object.prototype.hasOwnProperty.call(payload, 'video')
const rawValue = (payload as Record<string, unknown>).video
if (!hasVideoKey || rawValue === undefined || rawValue === null || rawValue === '') return null
return readAcceptanceVideoStrict(rawValue)
}
function readAcceptanceVideoStrict(value: unknown): Record<string, unknown> | 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<string, unknown> | 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,
})