add:新增验证视频可选上传功能
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user