add:新增验证视频可选上传功能
This commit is contained in:
@@ -42,6 +42,17 @@ STORAGE_ACCESS_KEY_ID=minioadmin
|
|||||||
STORAGE_SECRET_ACCESS_KEY=minioadmin
|
STORAGE_SECRET_ACCESS_KEY=minioadmin
|
||||||
STORAGE_REGION=us-east-1
|
STORAGE_REGION=us-east-1
|
||||||
STORAGE_MAX_UPLOAD_SIZE_MB=10
|
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_ENDPOINT=
|
||||||
STORAGE_OSS_BUCKET=
|
STORAGE_OSS_BUCKET=
|
||||||
STORAGE_OSS_ACCESS_KEY_ID=
|
STORAGE_OSS_ACCESS_KEY_ID=
|
||||||
|
|||||||
@@ -89,6 +89,17 @@ STORAGE_ACCESS_KEY_ID=change-me-storage-access-key
|
|||||||
STORAGE_SECRET_ACCESS_KEY=change-me-storage-secret-key
|
STORAGE_SECRET_ACCESS_KEY=change-me-storage-secret-key
|
||||||
STORAGE_REGION=us-east-1
|
STORAGE_REGION=us-east-1
|
||||||
STORAGE_MAX_UPLOAD_SIZE_MB=10
|
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 与桶同地域时用内网地址(免流量费、低延迟)
|
# 阿里云 OSS S3 兼容端点:ECS 与桶同地域时用内网地址(免流量费、低延迟)
|
||||||
STORAGE_OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com
|
STORAGE_OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com
|
||||||
STORAGE_OSS_BUCKET=change-me-oss-bucket
|
STORAGE_OSS_BUCKET=change-me-oss-bucket
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
dailyLimit: 10,
|
dailyLimit: 10,
|
||||||
},
|
},
|
||||||
orderTimelineVisible: false,
|
orderTimelineVisible: false,
|
||||||
|
acceptanceVideo: {
|
||||||
|
enabled: true,
|
||||||
|
maxDurationSeconds: 30,
|
||||||
|
maxSizeMb: 12,
|
||||||
|
targetHeight: 720,
|
||||||
|
bitrateKbps: 1200,
|
||||||
|
audioBitrateKbps: 64,
|
||||||
|
fps: 30,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,26 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
'skuMapping',
|
'skuMapping',
|
||||||
]),
|
]),
|
||||||
corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']),
|
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(
|
export function applyEnvOverrides(
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express'
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
import multer from 'multer'
|
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'
|
import { createHttpError, sendRouteError } from '../utils/http.js'
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: getMaxUploadSizeBytes(),
|
// 先按较大的验收视频上限收流,具体类型再在 service 内按图片/视频分别校验。
|
||||||
|
fileSize: Math.max(getMaxUploadSizeBytes(), getVideoUploadSizeLimitBytes()),
|
||||||
files: 1,
|
files: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getWorkerAfterSalesCaseSummary,
|
getWorkerAfterSalesCaseSummary,
|
||||||
|
getWorkerAcceptanceConfig,
|
||||||
getWorkerAnnouncement,
|
getWorkerAnnouncement,
|
||||||
getWorkerCancelRequestSummary,
|
getWorkerCancelRequestSummary,
|
||||||
getWorkerMyOrderEvents,
|
getWorkerMyOrderEvents,
|
||||||
@@ -54,6 +55,17 @@ router.get(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 验收弹窗的压缩参数(时长上限/目标高度/码率等),后台可改,打手端每次打开弹窗拉取。
|
||||||
|
router.get(
|
||||||
|
'/acceptance/config',
|
||||||
|
requireActiveWorker,
|
||||||
|
createRouteHandler(() => getWorkerAcceptanceConfig(), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取验收配置失败',
|
||||||
|
scope: '[worker/acceptance/config]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/announcement',
|
'/announcement',
|
||||||
requireActiveWorker,
|
requireActiveWorker,
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ router.post(
|
|||||||
scene: req.body?.scene || 'worker-acceptance',
|
scene: req.body?.scene || 'worker-acceptance',
|
||||||
uploaderType: 'worker',
|
uploaderType: 'worker',
|
||||||
uploaderId: String(session.workerId),
|
uploaderId: String(session.workerId),
|
||||||
|
// 验收视频:容器解析不到时长时用前端上报值兜底(见 resolveVideoDuration)。
|
||||||
|
durationSeconds: req.body?.durationSeconds,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { createFileAsset, type FileAssetRow } from '../../repositories/file-asse
|
|||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
|
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
|
||||||
|
import { readVideoDurationSeconds } from './video-metadata.js'
|
||||||
import { logWarn } from '../../utils/logger.js'
|
import { logWarn } from '../../utils/logger.js'
|
||||||
|
import { getWorkerAcceptanceConfig } from '../worker-platform/worker-acceptance-config-service.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* libvips 并发与操作缓存调优(进程级,必须早于任何 sharp 调用执行)。
|
* libvips 并发与操作缓存调优(进程级,必须早于任何 sharp 调用执行)。
|
||||||
@@ -41,7 +43,21 @@ const IMAGE_VARIANT_MEDIUM = 'medium'
|
|||||||
const THUMB_MAX_SIDE = 480
|
const THUMB_MAX_SIDE = 480
|
||||||
const MEDIUM_MAX_SIDE = 1280
|
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'])
|
const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||||
|
|
||||||
@@ -53,6 +69,8 @@ export type UploadedFileDto = {
|
|||||||
filename: string
|
filename: string
|
||||||
contentType: string
|
contentType: string
|
||||||
size: number
|
size: number
|
||||||
|
/** 仅视频:解析到的时长(秒);WebM 未写 Duration 时为客户端上报值 */
|
||||||
|
durationSeconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadFileAssetInput = {
|
type UploadFileAssetInput = {
|
||||||
@@ -60,6 +78,8 @@ type UploadFileAssetInput = {
|
|||||||
scene: unknown
|
scene: unknown
|
||||||
uploaderType: string
|
uploaderType: string
|
||||||
uploaderId: string
|
uploaderId: string
|
||||||
|
/** 前端读取到的视频时长(秒),仅在容器里解析不到时作为兜底 */
|
||||||
|
durationSeconds?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadFileAsset(input: UploadFileAssetInput) {
|
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)
|
const contentType = normalizeContentType(file.mimetype, file.buffer)
|
||||||
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
|
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
|
||||||
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP 或 PDF', {
|
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP、PDF 或 MP4/WebM 视频', {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
errorCode: 'upload_file_type_invalid',
|
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 objectKey = createObjectKey(scene, file.originalname, contentType)
|
||||||
const storage = getObjectStorage()
|
const storage = getObjectStorage()
|
||||||
const uploadedKeys: string[] = []
|
const uploadedKeys: string[] = []
|
||||||
@@ -147,6 +175,7 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
|||||||
metadataJson: JSON.stringify({
|
metadataJson: JSON.stringify({
|
||||||
originalMimeType: file.mimetype,
|
originalMimeType: file.mimetype,
|
||||||
originalSize: file.size,
|
originalSize: file.size,
|
||||||
|
...(isVideo ? { durationSeconds: videoDurationSeconds } : {}),
|
||||||
}),
|
}),
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
@@ -155,15 +184,18 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
file: mapUploadedFile(asset, {
|
file: {
|
||||||
objectKey,
|
...mapUploadedFile(asset, {
|
||||||
fileUrl,
|
objectKey,
|
||||||
thumbnailUrl,
|
fileUrl,
|
||||||
mediumUrl,
|
thumbnailUrl,
|
||||||
filename: file.originalname,
|
mediumUrl,
|
||||||
contentType,
|
filename: file.originalname,
|
||||||
size: file.buffer.length,
|
contentType,
|
||||||
}),
|
size: file.buffer.length,
|
||||||
|
}),
|
||||||
|
...(isVideo ? { durationSeconds: videoDurationSeconds } : {}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const cleanupResults = await Promise.allSettled(
|
const cleanupResults = await Promise.allSettled(
|
||||||
@@ -212,6 +244,11 @@ export function getMaxUploadSizeBytes() {
|
|||||||
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
|
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') {
|
if (data.length >= 4 && data.subarray(0, 4).toString('ascii') === '%PDF') {
|
||||||
return 'application/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 ''
|
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) {
|
function normalizeScene(value: unknown) {
|
||||||
const scene = String(value || '')
|
const scene = String(value || '')
|
||||||
.trim()
|
.trim()
|
||||||
@@ -380,6 +478,8 @@ function extensionForContentType(contentType: string) {
|
|||||||
if (contentType === 'image/png') return '.png'
|
if (contentType === 'image/png') return '.png'
|
||||||
if (contentType === 'image/webp') return '.webp'
|
if (contentType === 'image/webp') return '.webp'
|
||||||
if (contentType === 'application/pdf') return '.pdf'
|
if (contentType === 'application/pdf') return '.pdf'
|
||||||
|
if (contentType === 'video/mp4') return '.mp4'
|
||||||
|
if (contentType === 'video/webm') return '.webm'
|
||||||
return ''
|
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-product-match-config-service.js'
|
||||||
export * from './worker-announcement-config-service.js'
|
export * from './worker-announcement-config-service.js'
|
||||||
export * from './worker-hall-config-service.js'
|
export * from './worker-hall-config-service.js'
|
||||||
|
export * from './worker-acceptance-config-service.js'
|
||||||
export * from './sync-worker-orders-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 { createHttpError } from '../../utils/http.js'
|
||||||
import { addHours, nowIso } from '../../utils/time.js'
|
import { addHours, nowIso } from '../../utils/time.js'
|
||||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||||
|
import {
|
||||||
|
extractObjectKeyFromFileUrl,
|
||||||
|
normalizeStoredFileUrl,
|
||||||
|
} from '../file-storage/file-storage-service.js'
|
||||||
import {
|
import {
|
||||||
createWorkerAcceptanceAdminNotification,
|
createWorkerAcceptanceAdminNotification,
|
||||||
getWorkerAcceptanceReminder,
|
getWorkerAcceptanceReminder,
|
||||||
@@ -43,6 +47,7 @@ import {
|
|||||||
resolveWorkerPermissions,
|
resolveWorkerPermissions,
|
||||||
} from './mappers.js'
|
} from './mappers.js'
|
||||||
import { getWorkerFinanceConfig } from './worker-finance-config-service.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 { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js'
|
||||||
import {
|
import {
|
||||||
getRequiredWorkOrder,
|
getRequiredWorkOrder,
|
||||||
@@ -149,6 +154,7 @@ export async function submitWorkerOrderAcceptance(
|
|||||||
payload,
|
payload,
|
||||||
workOrder.draft_acceptance_json,
|
workOrder.draft_acceptance_json,
|
||||||
)
|
)
|
||||||
|
const video = resolveAcceptanceVideo(payload, workOrder.draft_acceptance_json)
|
||||||
if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
|
if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
|
||||||
throw createHttpError('请上传验收图片', {
|
throw createHttpError('请上传验收图片', {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
@@ -159,6 +165,7 @@ export async function submitWorkerOrderAcceptance(
|
|||||||
note,
|
note,
|
||||||
files,
|
files,
|
||||||
imageUrls: [...new Set(imageUrls)],
|
imageUrls: [...new Set(imageUrls)],
|
||||||
|
...(video ? { video } : {}),
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
...(canAutoAcceptWithoutEvidence
|
...(canAutoAcceptWithoutEvidence
|
||||||
? {
|
? {
|
||||||
@@ -185,6 +192,7 @@ export async function submitWorkerOrderAcceptance(
|
|||||||
toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||||
payloadJson: JSON.stringify({
|
payloadJson: JSON.stringify({
|
||||||
imageCount: imageUrls.length,
|
imageCount: imageUrls.length,
|
||||||
|
hasVideo: Boolean(video),
|
||||||
autoAccepted: canAutoAcceptWithoutEvidence,
|
autoAccepted: canAutoAcceptWithoutEvidence,
|
||||||
evidenceDueAt: acceptance.evidenceDueAt || null,
|
evidenceDueAt: acceptance.evidenceDueAt || null,
|
||||||
}),
|
}),
|
||||||
@@ -384,7 +392,10 @@ export async function supplementWorkerAcceptedOrderEvidence(
|
|||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const previous = safeParseJson(workOrder.acceptance_json)
|
const previous = safeParseJson(workOrder.acceptance_json)
|
||||||
const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files)
|
const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files)
|
||||||
if (!mergedEvidence.changed) {
|
// 已提交后只允许替换视频,不允许删除证据。
|
||||||
|
const payloadVideo = resolveAcceptanceVideoFromPayload(payload)
|
||||||
|
const video = payloadVideo || readAcceptanceVideoLenient(previous)
|
||||||
|
if (!mergedEvidence.changed && !payloadVideo) {
|
||||||
throw createHttpError('请上传新的验收图片', {
|
throw createHttpError('请上传新的验收图片', {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
errorCode: 'work_order_evidence_supplement_image_required',
|
errorCode: 'work_order_evidence_supplement_image_required',
|
||||||
@@ -394,6 +405,7 @@ export async function supplementWorkerAcceptedOrderEvidence(
|
|||||||
...previous,
|
...previous,
|
||||||
files: mergedEvidence.files,
|
files: mergedEvidence.files,
|
||||||
imageUrls: mergedEvidence.imageUrls,
|
imageUrls: mergedEvidence.imageUrls,
|
||||||
|
...(video ? { video } : {}),
|
||||||
evidenceStatus: 'complete',
|
evidenceStatus: 'complete',
|
||||||
evidenceSupplementedAt: now,
|
evidenceSupplementedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -413,6 +425,7 @@ export async function supplementWorkerAcceptedOrderEvidence(
|
|||||||
payloadJson: JSON.stringify({
|
payloadJson: JSON.stringify({
|
||||||
addedImageCount: mergedEvidence.addedFileCount,
|
addedImageCount: mergedEvidence.addedFileCount,
|
||||||
totalImageCount: mergedEvidence.imageUrls.length,
|
totalImageCount: mergedEvidence.imageUrls.length,
|
||||||
|
hasVideo: Boolean(video),
|
||||||
}),
|
}),
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
@@ -471,6 +484,7 @@ async function submitWorkerSharingAcceptance(
|
|||||||
payload,
|
payload,
|
||||||
sharingShare.draft_acceptance_json,
|
sharingShare.draft_acceptance_json,
|
||||||
)
|
)
|
||||||
|
const video = resolveAcceptanceVideo(payload, sharingShare.draft_acceptance_json)
|
||||||
if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
|
if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
|
||||||
throw createHttpError('请上传验收图片', {
|
throw createHttpError('请上传验收图片', {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
@@ -481,6 +495,7 @@ async function submitWorkerSharingAcceptance(
|
|||||||
note,
|
note,
|
||||||
files,
|
files,
|
||||||
imageUrls: [...new Set(imageUrls)],
|
imageUrls: [...new Set(imageUrls)],
|
||||||
|
...(video ? { video } : {}),
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
...(canAutoAcceptWithoutEvidence
|
...(canAutoAcceptWithoutEvidence
|
||||||
? {
|
? {
|
||||||
@@ -575,7 +590,9 @@ async function supplementWorkerAcceptedSharingEvidence(
|
|||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const previous = safeParseJson(sharingShare.acceptance_json)
|
const previous = safeParseJson(sharingShare.acceptance_json)
|
||||||
const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files)
|
const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files)
|
||||||
if (!mergedEvidence.changed) {
|
const payloadVideo = resolveAcceptanceVideoFromPayload(payload)
|
||||||
|
const video = payloadVideo || readAcceptanceVideoLenient(previous)
|
||||||
|
if (!mergedEvidence.changed && !payloadVideo) {
|
||||||
throw createHttpError('请上传新的验收图片', {
|
throw createHttpError('请上传新的验收图片', {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
errorCode: 'work_order_sharing_evidence_supplement_image_required',
|
errorCode: 'work_order_sharing_evidence_supplement_image_required',
|
||||||
@@ -585,6 +602,7 @@ async function supplementWorkerAcceptedSharingEvidence(
|
|||||||
...previous,
|
...previous,
|
||||||
files: mergedEvidence.files,
|
files: mergedEvidence.files,
|
||||||
imageUrls: mergedEvidence.imageUrls,
|
imageUrls: mergedEvidence.imageUrls,
|
||||||
|
...(video ? { video } : {}),
|
||||||
evidenceStatus: 'complete',
|
evidenceStatus: 'complete',
|
||||||
evidenceSupplementedAt: now,
|
evidenceSupplementedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -607,6 +625,7 @@ async function supplementWorkerAcceptedSharingEvidence(
|
|||||||
workerId: Number(session.workerId),
|
workerId: Number(session.workerId),
|
||||||
addedImageCount: mergedEvidence.addedFileCount,
|
addedImageCount: mergedEvidence.addedFileCount,
|
||||||
totalImageCount: mergedEvidence.imageUrls.length,
|
totalImageCount: mergedEvidence.imageUrls.length,
|
||||||
|
hasVideo: Boolean(video),
|
||||||
}),
|
}),
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
@@ -643,6 +662,133 @@ async function resolveWorkerTargetShare(
|
|||||||
return { share: null, error: 'required' }
|
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) {
|
function resolveAcceptanceImages(payload: JsonObject, draftValue: unknown) {
|
||||||
const files = normalizeUploadedFiles(payload.files)
|
const files = normalizeUploadedFiles(payload.files)
|
||||||
const imageUrls = [
|
const imageUrls = [
|
||||||
@@ -707,10 +853,13 @@ export async function saveWorkerOrderAcceptanceDraft(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const previous = safeParseJson(workOrder.acceptance_json)
|
const previous = safeParseJson(workOrder.acceptance_json)
|
||||||
|
// 已提交后只允许替换视频,不允许删除证据。
|
||||||
|
const video = resolveAcceptanceVideo(payload, previous)
|
||||||
const acceptance = {
|
const acceptance = {
|
||||||
note: String(payload.note ?? previous.note ?? '').trim(),
|
note: String(payload.note ?? previous.note ?? '').trim(),
|
||||||
files,
|
files,
|
||||||
imageUrls: [...new Set(imageUrls)],
|
imageUrls: [...new Set(imageUrls)],
|
||||||
|
...(video ? { video } : {}),
|
||||||
submittedAt: previous.submittedAt || workOrder.submitted_at || now,
|
submittedAt: previous.submittedAt || workOrder.submitted_at || now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -726,7 +875,7 @@ export async function saveWorkerOrderAcceptanceDraft(
|
|||||||
eventType: 'acceptance_updated',
|
eventType: 'acceptance_updated',
|
||||||
fromStatus: workOrder.status,
|
fromStatus: workOrder.status,
|
||||||
toStatus: workOrder.status,
|
toStatus: workOrder.status,
|
||||||
payloadJson: JSON.stringify({ imageCount: imageUrls.length }),
|
payloadJson: JSON.stringify({ imageCount: imageUrls.length, hasVideo: Boolean(video) }),
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||||
@@ -745,10 +894,12 @@ export async function saveWorkerOrderAcceptanceDraft(
|
|||||||
errorCode: 'work_order_draft_status_invalid',
|
errorCode: 'work_order_draft_status_invalid',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const draftVideo = resolveAcceptanceVideo(payload, {}, { allowRemove: true })
|
||||||
const draft = {
|
const draft = {
|
||||||
note: String(payload.note || '').trim(),
|
note: String(payload.note || '').trim(),
|
||||||
files,
|
files,
|
||||||
imageUrls: [...new Set(imageUrls)],
|
imageUrls: [...new Set(imageUrls)],
|
||||||
|
...(draftVideo ? { video: draftVideo } : {}),
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
const updated = await updateWorkOrderDraftAcceptance({
|
const updated = await updateWorkOrderDraftAcceptance({
|
||||||
@@ -763,7 +914,10 @@ export async function saveWorkerOrderAcceptanceDraft(
|
|||||||
eventType: 'acceptance_draft_saved',
|
eventType: 'acceptance_draft_saved',
|
||||||
fromStatus: workOrder.status,
|
fromStatus: workOrder.status,
|
||||||
toStatus: workOrder.status,
|
toStatus: workOrder.status,
|
||||||
payloadJson: JSON.stringify({ imageCount: imageUrls.length }),
|
payloadJson: JSON.stringify({
|
||||||
|
imageCount: imageUrls.length,
|
||||||
|
hasVideo: Boolean(draftVideo),
|
||||||
|
}),
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||||
@@ -797,10 +951,13 @@ async function saveWorkerSharingAcceptanceDraft(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const previous = safeParseJson(sharingShare.acceptance_json)
|
const previous = safeParseJson(sharingShare.acceptance_json)
|
||||||
|
// 已提交后只允许替换视频,不允许删除证据。
|
||||||
|
const video = resolveAcceptanceVideo(payload, previous)
|
||||||
const acceptance = {
|
const acceptance = {
|
||||||
note: String(payload.note ?? previous.note ?? '').trim(),
|
note: String(payload.note ?? previous.note ?? '').trim(),
|
||||||
files,
|
files,
|
||||||
imageUrls: [...new Set(imageUrls)],
|
imageUrls: [...new Set(imageUrls)],
|
||||||
|
...(video ? { video } : {}),
|
||||||
submittedAt: previous.submittedAt || sharingShare.submitted_at || now,
|
submittedAt: previous.submittedAt || sharingShare.submitted_at || now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -821,6 +978,7 @@ async function saveWorkerSharingAcceptanceDraft(
|
|||||||
payloadJson: JSON.stringify({
|
payloadJson: JSON.stringify({
|
||||||
workerId: Number(session.workerId),
|
workerId: Number(session.workerId),
|
||||||
imageCount: imageUrls.length,
|
imageCount: imageUrls.length,
|
||||||
|
hasVideo: Boolean(video),
|
||||||
}),
|
}),
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
@@ -836,10 +994,12 @@ async function saveWorkerSharingAcceptanceDraft(
|
|||||||
errorCode: 'work_order_sharing_draft_status_invalid',
|
errorCode: 'work_order_sharing_draft_status_invalid',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const draftVideo = resolveAcceptanceVideo(payload, {}, { allowRemove: true })
|
||||||
const draft = {
|
const draft = {
|
||||||
note: String(payload.note || '').trim(),
|
note: String(payload.note || '').trim(),
|
||||||
files,
|
files,
|
||||||
imageUrls: [...new Set(imageUrls)],
|
imageUrls: [...new Set(imageUrls)],
|
||||||
|
...(draftVideo ? { video: draftVideo } : {}),
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
const share = await updateWorkOrderShareDraftAcceptance({
|
const share = await updateWorkOrderShareDraftAcceptance({
|
||||||
@@ -859,6 +1019,7 @@ async function saveWorkerSharingAcceptanceDraft(
|
|||||||
payloadJson: JSON.stringify({
|
payloadJson: JSON.stringify({
|
||||||
workerId: Number(session.workerId),
|
workerId: Number(session.workerId),
|
||||||
imageCount: imageUrls.length,
|
imageCount: imageUrls.length,
|
||||||
|
hasVideo: Boolean(draftVideo),
|
||||||
}),
|
}),
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -117,6 +117,21 @@ export type RuntimeConfig = {
|
|||||||
}
|
}
|
||||||
/** 打手端订单详情是否展示流转记录(默认关闭,仅后台可见) */
|
/** 打手端订单详情是否展示流转记录(默认关闭,仅后台可见) */
|
||||||
orderTimelineVisible: boolean
|
orderTimelineVisible: boolean
|
||||||
|
/**
|
||||||
|
* 验收视频(打手端验收弹窗,选填)。
|
||||||
|
*
|
||||||
|
* 只用 `.env` 配置,不提供后台管理界面;前端按这些参数在本机压缩,
|
||||||
|
* 服务端用同一份参数做时长/体积兜底校验。
|
||||||
|
*/
|
||||||
|
acceptanceVideo: {
|
||||||
|
enabled: boolean
|
||||||
|
maxDurationSeconds: number
|
||||||
|
maxSizeMb: number
|
||||||
|
targetHeight: number
|
||||||
|
bitrateKbps: number
|
||||||
|
audioBitrateKbps: number
|
||||||
|
fps: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cors: {
|
cors: {
|
||||||
allowedOrigins: string[]
|
allowedOrigins: string[]
|
||||||
|
|||||||
@@ -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 <Typography.Text type="secondary">{emptyText}</Typography.Text>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="acceptance-video-preview">
|
||||||
|
<video
|
||||||
|
src={video.url}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
style={{ maxWidth: '100%', maxHeight, borderRadius: 8, background: '#000' }}
|
||||||
|
/>
|
||||||
|
{showMeta ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
<VideoCameraOutlined /> {formatAcceptanceVideoDuration(video.durationSeconds)}
|
||||||
|
{video.size ? ` · ${formatAcceptanceVideoSize(video.size)}` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement | null>(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<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="worker-acceptance-video">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={VIDEO_ACCEPT}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(event) => void handleSelect(event)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{value ? (
|
||||||
|
<div className="worker-acceptance-video-preview">
|
||||||
|
<video
|
||||||
|
src={value.url}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
className="worker-acceptance-video-player"
|
||||||
|
/>
|
||||||
|
<div className="worker-acceptance-video-meta">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{formatDuration(value.durationSeconds)} · {formatSize(value.size)}
|
||||||
|
</Typography.Text>
|
||||||
|
<Space size={8}>
|
||||||
|
<Button size="small" disabled={!canPick} onClick={() => inputRef.current?.click()}>
|
||||||
|
替换
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
onClick={() => onChange?.(null)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||||
|
<Button
|
||||||
|
icon={busy ? <LoadingOutlined /> : <VideoCameraOutlined />}
|
||||||
|
disabled={!canPick}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{busy ? '处理中…' : '选择验收视频'}
|
||||||
|
</Button>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{hint}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{busy ? (
|
||||||
|
<Progress
|
||||||
|
percent={Math.round(progress * 100)}
|
||||||
|
size="small"
|
||||||
|
status="active"
|
||||||
|
format={(percent) => `${phase === 'compressing' ? '压缩中' : '上传中'} ${percent ?? 0}%`}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
}
|
||||||
@@ -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 { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
@@ -26,7 +26,12 @@ import {
|
|||||||
repayAdminAfterSalesCaseDebt,
|
repayAdminAfterSalesCaseDebt,
|
||||||
resolveAdminAfterSalesCase,
|
resolveAdminAfterSalesCase,
|
||||||
} from '@/services/admin'
|
} 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 { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
import { uniqueUploadedFiles } from '@/utils/image-identity'
|
import { uniqueUploadedFiles } from '@/utils/image-identity'
|
||||||
@@ -259,6 +264,15 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
) : null}
|
) : null}
|
||||||
|
{getAfterSalesSnapshotVideo(item) ? (
|
||||||
|
<a
|
||||||
|
href={getAfterSalesSnapshotVideo(item)?.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<VideoCameraOutlined /> 查看固化验收视频
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -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
|
export default AfterSalesPanel
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
UserAddOutlined,
|
UserAddOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
RollbackOutlined,
|
RollbackOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popover,
|
||||||
Radio,
|
Radio,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
@@ -45,6 +47,7 @@ import JsonPreview from '@/components/admin/JsonPreview'
|
|||||||
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
|
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
|
||||||
import ImageUpload from '@/components/files/ImageUpload'
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
|
import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/VideoPreview'
|
||||||
import {
|
import {
|
||||||
acceptAdminWorkOrder,
|
acceptAdminWorkOrder,
|
||||||
acceptAdminWorkOrderShare,
|
acceptAdminWorkOrderShare,
|
||||||
@@ -2603,6 +2606,11 @@ export default function WorkOrdersPanel() {
|
|||||||
{getAcceptanceFiles(detailOrder).length +
|
{getAcceptanceFiles(detailOrder).length +
|
||||||
getAcceptanceImageUrls(detailOrder).length || '-'}
|
getAcceptanceImageUrls(detailOrder).length || '-'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="验收视频">
|
||||||
|
{detailOrder.acceptance?.video
|
||||||
|
? formatAcceptanceVideoDuration(detailOrder.acceptance.video.durationSeconds)
|
||||||
|
: '未上传'}
|
||||||
|
</Descriptions.Item>
|
||||||
{detailOrder.acceptance?.reviewMode === 'vip_auto' ? (
|
{detailOrder.acceptance?.reviewMode === 'vip_auto' ? (
|
||||||
<Descriptions.Item label="VIP 补图">
|
<Descriptions.Item label="VIP 补图">
|
||||||
{detailOrder.acceptance.evidenceStatus === 'pending'
|
{detailOrder.acceptance.evidenceStatus === 'pending'
|
||||||
@@ -2626,6 +2634,11 @@ export default function WorkOrdersPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<Typography.Text type="secondary">暂无验收图</Typography.Text>
|
<Typography.Text type="secondary">暂无验收图</Typography.Text>
|
||||||
)}
|
)}
|
||||||
|
<VideoPreview
|
||||||
|
video={detailOrder.acceptance?.video ?? null}
|
||||||
|
maxHeight={200}
|
||||||
|
emptyText="暂无验收视频(选填)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -3205,7 +3218,7 @@ export default function WorkOrdersPanel() {
|
|||||||
loading={sharingQuery.isFetching}
|
loading={sharingQuery.isFetching}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
scroll={{ x: 1528 }}
|
scroll={{ x: 1624 }}
|
||||||
rowClassName={(share) =>
|
rowClassName={(share) =>
|
||||||
share.problemMarked
|
share.problemMarked
|
||||||
? 'admin-share-row-problem'
|
? '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 (
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement="left"
|
||||||
|
title="验收视频"
|
||||||
|
content={<VideoPreview video={video} maxHeight={240} showMeta />}
|
||||||
|
>
|
||||||
|
<Button size="small" icon={<VideoCameraOutlined />}>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
width: 120,
|
width: 120,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Alert, Button, Card, Descriptions, Drawer, Space, Steps, Tag, Typograph
|
|||||||
|
|
||||||
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
|
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
|
||||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
|
import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/VideoPreview'
|
||||||
import type { WorkOrder } from '@/types/worker-platform'
|
import type { WorkOrder } from '@/types/worker-platform'
|
||||||
import { formatDateTime } from '@/utils/date-time'
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display'
|
import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display'
|
||||||
@@ -104,7 +105,7 @@ export function WorkerOrderDetailDrawer({
|
|||||||
icon={<PictureOutlined />}
|
icon={<PictureOutlined />}
|
||||||
onClick={() => onOpenAcceptance(order)}
|
onClick={() => onOpenAcceptance(order)}
|
||||||
>
|
>
|
||||||
{hasSubmittedAcceptance(order) ? '补充图片' : '上传图片 / 提交验收'}
|
{hasSubmittedAcceptance(order) ? '补充验收资料' : '上传图片/视频 · 提交验收'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canRequestCancel(order) ? (
|
{canRequestCancel(order) ? (
|
||||||
@@ -322,6 +323,7 @@ function GiftProgressCard({ order }: { order: WorkOrder }) {
|
|||||||
function AcceptanceSummary({ order }: { order: WorkOrder }) {
|
function AcceptanceSummary({ order }: { order: WorkOrder }) {
|
||||||
const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(order))
|
const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(order))
|
||||||
const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order))
|
const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order))
|
||||||
|
const acceptanceVideo = getWorkerAcceptance(order)?.video ?? getWorkerDraft(order)?.video ?? null
|
||||||
const hasAcceptance = acceptanceFiles.length > 0
|
const hasAcceptance = acceptanceFiles.length > 0
|
||||||
const hasDraft = draftFiles.length > 0 && !hasSubmittedAcceptance(order)
|
const hasDraft = draftFiles.length > 0 && !hasSubmittedAcceptance(order)
|
||||||
|
|
||||||
@@ -329,7 +331,12 @@ function AcceptanceSummary({ order }: { order: WorkOrder }) {
|
|||||||
<div className="worker-order-detail-stack">
|
<div className="worker-order-detail-stack">
|
||||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||||
<Descriptions.Item label="验收图片数量">{acceptanceFiles.length}</Descriptions.Item>
|
<Descriptions.Item label="验收图片数量">{acceptanceFiles.length}</Descriptions.Item>
|
||||||
<Descriptions.Item label="提交时间">
|
<Descriptions.Item label="验收视频">
|
||||||
|
{acceptanceVideo
|
||||||
|
? formatAcceptanceVideoDuration(acceptanceVideo.durationSeconds)
|
||||||
|
: '未上传'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="提交时间" span={2}>
|
||||||
{formatDateTime(getAcceptanceSubmittedAt(order))}
|
{formatDateTime(getAcceptanceSubmittedAt(order))}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="验收说明" span={2}>
|
<Descriptions.Item label="验收说明" span={2}>
|
||||||
@@ -345,6 +352,8 @@ function AcceptanceSummary({ order }: { order: WorkOrder }) {
|
|||||||
<Typography.Text type="secondary">暂无已提交验收图片</Typography.Text>
|
<Typography.Text type="secondary">暂无已提交验收图片</Typography.Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<VideoPreview video={acceptanceVideo} maxHeight={180} emptyText="暂无验收视频(选填)" />
|
||||||
|
|
||||||
{hasDraft ? (
|
{hasDraft ? (
|
||||||
<div className="acceptance-draft-badge-wrap" style={{ marginTop: 8 }}>
|
<div className="acceptance-draft-badge-wrap" style={{ marginTop: 8 }}>
|
||||||
<Tag color="processing" icon={<PictureOutlined />}>
|
<Tag color="processing" icon={<PictureOutlined />}>
|
||||||
|
|||||||
@@ -476,7 +476,7 @@ function WorkerOrderMobileCard({
|
|||||||
className="worker-order-mobile-action-submit"
|
className="worker-order-mobile-action-submit"
|
||||||
onClick={() => onOpenAcceptance(order)}
|
onClick={() => onOpenAcceptance(order)}
|
||||||
>
|
>
|
||||||
{hasSubmittedAcceptance(order) ? '补充图片' : '提交验收'}
|
{hasSubmittedAcceptance(order) ? '补充验收资料' : '上传图片/视频 · 提交验收'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canRemindAcceptance(order) ? (
|
{canRemindAcceptance(order) ? (
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { Button, Form, Input, Modal, Radio, Space, Tag, Typography } from 'antd'
|
|||||||
import type { FormInstance } from 'antd'
|
import type { FormInstance } from 'antd'
|
||||||
|
|
||||||
import ImageUpload from '@/components/files/ImageUpload'
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
|
import VideoUpload from '@/components/files/VideoUpload'
|
||||||
import type {
|
import type {
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
|
WorkerAcceptanceVideo,
|
||||||
WorkerCancelRequestRisk,
|
WorkerCancelRequestRisk,
|
||||||
WorkerOrderFeedback,
|
WorkerOrderFeedback,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
@@ -59,10 +61,12 @@ export function WorkerOrderModals({
|
|||||||
onSaveNote,
|
onSaveNote,
|
||||||
editingOrder,
|
editingOrder,
|
||||||
acceptanceFiles,
|
acceptanceFiles,
|
||||||
|
acceptanceVideo,
|
||||||
acceptanceForm,
|
acceptanceForm,
|
||||||
workerCanAutoAcceptWithoutEvidence,
|
workerCanAutoAcceptWithoutEvidence,
|
||||||
onCloseAcceptance,
|
onCloseAcceptance,
|
||||||
onAcceptanceFilesChange,
|
onAcceptanceFilesChange,
|
||||||
|
onAcceptanceVideoChange,
|
||||||
onSubmitAcceptance,
|
onSubmitAcceptance,
|
||||||
onSaveAcceptanceImages,
|
onSaveAcceptanceImages,
|
||||||
giftMaterialOrder,
|
giftMaterialOrder,
|
||||||
@@ -89,10 +93,12 @@ export function WorkerOrderModals({
|
|||||||
onSaveNote: (values: WorkerNoteFormValues) => void | Promise<void>
|
onSaveNote: (values: WorkerNoteFormValues) => void | Promise<void>
|
||||||
editingOrder: WorkOrder | null
|
editingOrder: WorkOrder | null
|
||||||
acceptanceFiles: UploadedFile[]
|
acceptanceFiles: UploadedFile[]
|
||||||
|
acceptanceVideo: WorkerAcceptanceVideo | null
|
||||||
acceptanceForm: FormInstance<AcceptanceFormValues>
|
acceptanceForm: FormInstance<AcceptanceFormValues>
|
||||||
workerCanAutoAcceptWithoutEvidence: boolean
|
workerCanAutoAcceptWithoutEvidence: boolean
|
||||||
onCloseAcceptance: () => void
|
onCloseAcceptance: () => void
|
||||||
onAcceptanceFilesChange: (files: UploadedFile[]) => void
|
onAcceptanceFilesChange: (files: UploadedFile[]) => void
|
||||||
|
onAcceptanceVideoChange: (video: WorkerAcceptanceVideo | null) => void
|
||||||
onSubmitAcceptance: (values: AcceptanceFormValues) => void | Promise<void>
|
onSubmitAcceptance: (values: AcceptanceFormValues) => void | Promise<void>
|
||||||
onSaveAcceptanceImages: (values: AcceptanceFormValues) => void | Promise<void>
|
onSaveAcceptanceImages: (values: AcceptanceFormValues) => void | Promise<void>
|
||||||
giftMaterialOrder: WorkOrder | null
|
giftMaterialOrder: WorkOrder | null
|
||||||
@@ -283,6 +289,12 @@ export function WorkerOrderModals({
|
|||||||
showPasteHint
|
showPasteHint
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="验收视频(选填)"
|
||||||
|
extra="选片后会在本机压到 720p 再上传;已提交的验收只能替换视频,不能删除。"
|
||||||
|
>
|
||||||
|
<VideoUpload value={acceptanceVideo} onChange={onAcceptanceVideoChange} />
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
submitWorkerOrderFeedback,
|
submitWorkerOrderFeedback,
|
||||||
supplementWorkerAcceptedOrderEvidence,
|
supplementWorkerAcceptedOrderEvidence,
|
||||||
} from '@/services/worker'
|
} from '@/services/worker'
|
||||||
import type { UploadedFile, WorkOrder } from '@/types/worker-platform'
|
import type { UploadedFile, WorkOrder, WorkerAcceptanceVideo } from '@/types/worker-platform'
|
||||||
import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders'
|
import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders'
|
||||||
import { formatDateTime } from '@/utils/date-time'
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||||
@@ -181,6 +181,9 @@ export default function WorkerOrdersPage() {
|
|||||||
const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null)
|
const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null)
|
||||||
const [feedbackOrder, setFeedbackOrder] = useState<WorkOrder | null>(null)
|
const [feedbackOrder, setFeedbackOrder] = useState<WorkOrder | null>(null)
|
||||||
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
|
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
|
||||||
|
/** 验收视频(单条,非必填);dirty 表示本次弹窗里换过视频,用于补图时允许"只补视频" */
|
||||||
|
const [acceptanceVideo, setAcceptanceVideo] = useState<WorkerAcceptanceVideo | null>(null)
|
||||||
|
const [acceptanceVideoDirty, setAcceptanceVideoDirty] = useState(false)
|
||||||
const [giftMaterialOrder, setGiftMaterialOrder] = useState<WorkOrder | null>(null)
|
const [giftMaterialOrder, setGiftMaterialOrder] = useState<WorkOrder | null>(null)
|
||||||
const [giftMaterialFiles, setGiftMaterialFiles] = useState<UploadedFile[]>([])
|
const [giftMaterialFiles, setGiftMaterialFiles] = useState<UploadedFile[]>([])
|
||||||
const [form] = Form.useForm<AcceptanceFormValues>()
|
const [form] = Form.useForm<AcceptanceFormValues>()
|
||||||
@@ -340,18 +343,20 @@ export default function WorkerOrdersPage() {
|
|||||||
const editingTarget = buildShareEditingOrder(order, shareId)
|
const editingTarget = buildShareEditingOrder(order, shareId)
|
||||||
setEditingShareId(shareId)
|
setEditingShareId(shareId)
|
||||||
setEditingOrder(editingTarget)
|
setEditingOrder(editingTarget)
|
||||||
|
setAcceptanceVideoDirty(false)
|
||||||
|
const submitted = getWorkerAcceptance(editingTarget)
|
||||||
if (canSupplementAcceptedEvidence(editingTarget)) {
|
if (canSupplementAcceptedEvidence(editingTarget)) {
|
||||||
// 已验收补图只上传新增文件,原有验收图片由服务端保留。
|
// 已验收补图只上传新增文件,原有验收图片由服务端保留;视频只能替换、不能删除。
|
||||||
setAcceptanceFiles([])
|
setAcceptanceFiles([])
|
||||||
|
setAcceptanceVideo(submitted?.video ?? null)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
setDetailOrder(null)
|
setDetailOrder(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容
|
// 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容
|
||||||
const source = hasSubmittedAcceptance(editingTarget)
|
const source = hasSubmittedAcceptance(editingTarget) ? submitted : getWorkerDraft(editingTarget)
|
||||||
? getWorkerAcceptance(editingTarget)
|
|
||||||
: getWorkerDraft(editingTarget)
|
|
||||||
setAcceptanceFiles(getUniqueAcceptanceFiles(source))
|
setAcceptanceFiles(getUniqueAcceptanceFiles(source))
|
||||||
|
setAcceptanceVideo(source?.video ?? null)
|
||||||
form.setFieldsValue({ note: String(source?.note || '') })
|
form.setFieldsValue({ note: String(source?.note || '') })
|
||||||
setDetailOrder(null)
|
setDetailOrder(null)
|
||||||
}
|
}
|
||||||
@@ -484,22 +489,25 @@ export default function WorkerOrdersPage() {
|
|||||||
if (!editingOrder) return
|
if (!editingOrder) return
|
||||||
const isAcceptedEvidenceSupplement = canSupplementAcceptedEvidence(editingOrder)
|
const isAcceptedEvidenceSupplement = canSupplementAcceptedEvidence(editingOrder)
|
||||||
if (isAcceptedEvidenceSupplement) {
|
if (isAcceptedEvidenceSupplement) {
|
||||||
if (acceptanceFiles.length === 0) {
|
if (acceptanceFiles.length === 0 && !acceptanceVideoDirty) {
|
||||||
message.error('请上传新的验收图片')
|
message.error('请上传新的验收图片,或替换验收视频')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await supplementWorkerAcceptedOrderEvidence(editingOrder.workOrderId, {
|
await supplementWorkerAcceptedOrderEvidence(editingOrder.workOrderId, {
|
||||||
files: acceptanceFiles,
|
files: acceptanceFiles,
|
||||||
|
...(acceptanceVideoDirty ? { video: acceptanceVideo } : {}),
|
||||||
...(editingShareId ? { shareId: editingShareId } : {}),
|
...(editingShareId ? { shareId: editingShareId } : {}),
|
||||||
})
|
})
|
||||||
message.success('验收图片已补充')
|
message.success('验收资料已补充')
|
||||||
setEditingOrder(null)
|
setEditingOrder(null)
|
||||||
setAcceptanceFiles([])
|
setAcceptanceFiles([])
|
||||||
|
setAcceptanceVideo(null)
|
||||||
|
setAcceptanceVideoDirty(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
await refreshAll()
|
await refreshAll()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : '补充验收图片失败')
|
message.error(error instanceof Error ? error.message : '补充验收资料失败')
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -518,11 +526,14 @@ export default function WorkerOrdersPage() {
|
|||||||
await submitWorkerAcceptance(editingOrder.workOrderId, {
|
await submitWorkerAcceptance(editingOrder.workOrderId, {
|
||||||
note: values.note,
|
note: values.note,
|
||||||
files: acceptanceFiles,
|
files: acceptanceFiles,
|
||||||
|
video: acceptanceVideo,
|
||||||
...(editingShareId ? { shareId: editingShareId } : {}),
|
...(editingShareId ? { shareId: editingShareId } : {}),
|
||||||
})
|
})
|
||||||
message.success(canAutoAccept ? '已自动验收,可在 24 小时内补充图片' : '已提交验收')
|
message.success(canAutoAccept ? '已自动验收,可在 24 小时内补充图片' : '已提交验收')
|
||||||
setEditingOrder(null)
|
setEditingOrder(null)
|
||||||
setAcceptanceFiles([])
|
setAcceptanceFiles([])
|
||||||
|
setAcceptanceVideo(null)
|
||||||
|
setAcceptanceVideoDirty(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
await refreshAll()
|
await refreshAll()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -540,13 +551,16 @@ export default function WorkerOrdersPage() {
|
|||||||
await saveWorkerAcceptanceDraft(editingOrder.workOrderId, {
|
await saveWorkerAcceptanceDraft(editingOrder.workOrderId, {
|
||||||
note: values.note,
|
note: values.note,
|
||||||
files: acceptanceFiles,
|
files: acceptanceFiles,
|
||||||
|
video: acceptanceVideo,
|
||||||
...(editingShareId ? { shareId: editingShareId } : {}),
|
...(editingShareId ? { shareId: editingShareId } : {}),
|
||||||
})
|
})
|
||||||
message.success(
|
message.success(
|
||||||
hasSubmittedAcceptance(editingOrder) ? '验收图片已更新' : '验收图片已暂存,可继续补充',
|
hasSubmittedAcceptance(editingOrder) ? '验收资料已更新' : '验收资料已暂存,可继续补充',
|
||||||
)
|
)
|
||||||
setEditingOrder(null)
|
setEditingOrder(null)
|
||||||
setAcceptanceFiles([])
|
setAcceptanceFiles([])
|
||||||
|
setAcceptanceVideo(null)
|
||||||
|
setAcceptanceVideoDirty(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
await refreshAll()
|
await refreshAll()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -754,14 +768,21 @@ export default function WorkerOrdersPage() {
|
|||||||
onSaveNote={saveOrderNote}
|
onSaveNote={saveOrderNote}
|
||||||
editingOrder={editingOrder}
|
editingOrder={editingOrder}
|
||||||
acceptanceFiles={acceptanceFiles}
|
acceptanceFiles={acceptanceFiles}
|
||||||
|
acceptanceVideo={acceptanceVideo}
|
||||||
acceptanceForm={form}
|
acceptanceForm={form}
|
||||||
workerCanAutoAcceptWithoutEvidence={workerCanAutoAcceptWithoutEvidence}
|
workerCanAutoAcceptWithoutEvidence={workerCanAutoAcceptWithoutEvidence}
|
||||||
onCloseAcceptance={() => {
|
onCloseAcceptance={() => {
|
||||||
setEditingOrder(null)
|
setEditingOrder(null)
|
||||||
setAcceptanceFiles([])
|
setAcceptanceFiles([])
|
||||||
|
setAcceptanceVideo(null)
|
||||||
|
setAcceptanceVideoDirty(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
}}
|
}}
|
||||||
onAcceptanceFilesChange={setAcceptanceFiles}
|
onAcceptanceFilesChange={setAcceptanceFiles}
|
||||||
|
onAcceptanceVideoChange={(video) => {
|
||||||
|
setAcceptanceVideo(video)
|
||||||
|
setAcceptanceVideoDirty(true)
|
||||||
|
}}
|
||||||
onSubmitAcceptance={submitAcceptance}
|
onSubmitAcceptance={submitAcceptance}
|
||||||
onSaveAcceptanceImages={saveAcceptanceImages}
|
onSaveAcceptanceImages={saveAcceptanceImages}
|
||||||
giftMaterialOrder={giftMaterialOrder}
|
giftMaterialOrder={giftMaterialOrder}
|
||||||
|
|||||||
@@ -18,3 +18,16 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope)
|
|||||||
const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form)
|
const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form)
|
||||||
return response.data.file
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
WorkerCancelRequest,
|
WorkerCancelRequest,
|
||||||
WorkerCancelRequestRisk,
|
WorkerCancelRequestRisk,
|
||||||
WorkerAnnouncementConfig,
|
WorkerAnnouncementConfig,
|
||||||
|
WorkerAcceptanceConfig,
|
||||||
|
WorkerAcceptanceVideo,
|
||||||
WorkerOrderFeedback,
|
WorkerOrderFeedback,
|
||||||
WorkerAfterSalesCase,
|
WorkerAfterSalesCase,
|
||||||
WorkerHallCategoriesResponse,
|
WorkerHallCategoriesResponse,
|
||||||
@@ -143,6 +145,11 @@ export function fetchWorkerAnnouncement() {
|
|||||||
return apiGet<WorkerAnnouncementConfig>('/api/v1/worker/announcement')
|
return apiGet<WorkerAnnouncementConfig>('/api/v1/worker/announcement')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 验收视频配置(后台可改):时长上限、目标高度、码率等,压缩前读取。 */
|
||||||
|
export function fetchWorkerAcceptanceConfig() {
|
||||||
|
return apiGet<WorkerAcceptanceConfig>('/api/v1/worker/acceptance/config')
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchWorkerWalletLedgers(params?: Record<string, unknown>) {
|
export function fetchWorkerWalletLedgers(params?: Record<string, unknown>) {
|
||||||
return apiGet<WorkerListResponse<WorkerWalletLedger>>(
|
return apiGet<WorkerListResponse<WorkerWalletLedger>>(
|
||||||
'/api/v1/worker/profile/wallet-ledgers',
|
'/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 })
|
return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/note`, { note })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitWorkerAcceptance(
|
export type WorkerAcceptancePayload = {
|
||||||
workOrderId: number,
|
note?: string
|
||||||
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number },
|
imageUrls?: string[]
|
||||||
) {
|
files?: UploadedFile[]
|
||||||
|
/** 验收视频(非必填);null 在草稿阶段表示删除,已提交阶段服务端只接受替换 */
|
||||||
|
video?: WorkerAcceptanceVideo | null
|
||||||
|
shareId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitWorkerAcceptance(workOrderId: number, payload: WorkerAcceptancePayload) {
|
||||||
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
||||||
`/api/v1/worker/orders/${workOrderId}/submit-acceptance`,
|
`/api/v1/worker/orders/${workOrderId}/submit-acceptance`,
|
||||||
payload,
|
payload,
|
||||||
@@ -328,10 +341,7 @@ export function remindWorkerAcceptance(workOrderId: number) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveWorkerAcceptanceDraft(
|
export function saveWorkerAcceptanceDraft(workOrderId: number, payload: WorkerAcceptancePayload) {
|
||||||
workOrderId: number,
|
|
||||||
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number },
|
|
||||||
) {
|
|
||||||
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
||||||
`/api/v1/worker/orders/${workOrderId}/acceptance-draft`,
|
`/api/v1/worker/orders/${workOrderId}/acceptance-draft`,
|
||||||
payload,
|
payload,
|
||||||
@@ -340,7 +350,7 @@ export function saveWorkerAcceptanceDraft(
|
|||||||
|
|
||||||
export function supplementWorkerAcceptedOrderEvidence(
|
export function supplementWorkerAcceptedOrderEvidence(
|
||||||
workOrderId: number,
|
workOrderId: number,
|
||||||
payload: { files: UploadedFile[]; shareId?: number },
|
payload: { files: UploadedFile[]; video?: WorkerAcceptanceVideo | null; shareId?: number },
|
||||||
) {
|
) {
|
||||||
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
||||||
`/api/v1/worker/orders/${workOrderId}/acceptance-evidence`,
|
`/api/v1/worker/orders/${workOrderId}/acceptance-evidence`,
|
||||||
|
|||||||
@@ -492,6 +492,31 @@ export type UploadedFile = {
|
|||||||
filename: string
|
filename: string
|
||||||
contentType: string
|
contentType: string
|
||||||
size: number
|
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 =
|
export type WorkOrderStatus =
|
||||||
@@ -516,6 +541,8 @@ export type WorkerAcceptance = {
|
|||||||
note?: string
|
note?: string
|
||||||
files?: UploadedFile[]
|
files?: UploadedFile[]
|
||||||
imageUrls?: string[]
|
imageUrls?: string[]
|
||||||
|
/** 验收视频(非必填):打手压缩后上传的单个视频 */
|
||||||
|
video?: WorkerAcceptanceVideo | null
|
||||||
submittedAt?: string
|
submittedAt?: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
reviewMode?: 'vip_auto' | string
|
reviewMode?: 'vip_auto' | string
|
||||||
|
|||||||
@@ -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<VideoProbeResult> {
|
||||||
|
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<CompressedVideo> {
|
||||||
|
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<void>((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<void>((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<void> {
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user