在移动端或一体机终端里,视频上传经常会同时踩中三个坑:
- 文件体积大,直接走应用服务器中转会带来明显的带宽和内存压力。
- 上传链路长,任何一个环节失败,都可能导致"对象存储里有文件,但业务系统里查不到记录"。
- 前后端契约一旦漂移,往往不是编译期报错,而是运行时才发现"接口能调通,但口子没对上"。
本文基于一个真实 Flutter + Node.js 项目的当前仓库代码,拆解"录制视频 -> 直传 MinIO -> 完成合并 -> 元数据落库"的完整实现方式。
本文对业务信息做了主动脱敏:
- 仓库名、业务页面名、具体接口路径、对象存储域名、真实对象前缀均做了泛化处理。
- 接口名统一用"初始化接口 / 完成接口 / 取消接口"表示。
- 数据表和字段名使用"媒体记录表""业务主键""对象键"等通用表达。
- 少量代码片段采用"基于真实实现抽象后的伪代码",但实现思路、模块分工、边界处理均对应当前仓库真实代码。
1. 功能背景与要解决的问题
这个功能点的目标很明确:让终端可以在本地录制视频,然后稳定地把视频上传到 S3 兼容对象存储,并在后端保存一条可查询、可追踪的元数据记录。
它要解决的核心技术问题有三个:
- 终端如何把录制好的本地视频文件安全、稳定地传到对象存储,而不是先把整文件压到应用服务器。
- 后端如何在上传前、上传中、上传后维护一条"上传状态可追踪"的记录,避免文件和数据库状态脱节。
- 前后端如何把 multipart 上传契约收敛到同一套字段和状态流转,避免出现"接口看起来都实现了,但实际无法联调"的情况。
抽象后的使用场景是:
- 用户在某个功能入口录制一段视频。
- 终端把视频写入本地临时目录。
- 终端调用后端初始化接口,拿到 multipart 上传会话和每个分片的预签名地址。
- 终端直接 PUT 到对象存储。
- PUT 完成后,终端把每片的
ETag和分片号回传给后端。 - 后端合并分片、更新数据库状态、返回最终结果。
- 终端确认成功后,再删除本地临时文件。
这个功能值得单独实现,而不是塞进某个"通用上传接口",原因也很直接:
- 视频文件明显比图片、表单附件更大。
- 上传时长更长,更需要显式的状态机和取消能力。
- 需要保留录制时间、时长、设备标识、上传状态等元数据。
- 需要把对象存储结果和数据库记录做强一致的业务收口。
2. 功能实现的技术背景
本文只保留与这个功能点直接相关的技术背景。
| 分类 | 技术/机制 | 在该功能中的作用 | 依据 |
|---|---|---|---|
| 前端录制 | Flutter camera 插件 |
负责摄像头初始化、开始/停止录制 | lib/features/video_record_upload/media/video_record_camera_source.dart |
| 前端文件管理 | path_provider + 临时目录 |
将录制结果搬运到可控的临时目录,避免直接依赖插件临时文件 | video_record_camera_source.dart |
| 前端状态管理 | ChangeNotifier + 显式状态枚举 |
管理录制、上传、取消、失败、成功等阶段 | controller/video_record_upload_controller.dart、controller/video_record_upload_state.dart |
| 前端网络 | dio + 轻量 HTTP 封装 |
调后端初始化/完成/取消接口,并直接 PUT 到 MinIO | data/video_upload_service.dart、core/http/http_client.dart |
| 后端 HTTP | Fastify + JSON Schema | 严格校验请求字段,避免契约漂移 | src/routes/video.ts |
| 对象存储 | AWS SDK v3 S3 Client + Presigned URL | 创建 multipart 上传会话、生成分片签名、完成/取消上传 | src/services/videoStorageService.ts |
| 后端业务层 | Service + Repo 分层 | 负责上传初始化、状态校验、落库更新 | src/services/videoService.ts、src/repos/videoRepo.ts |
| 数据持久化 | PostgreSQL | 存放上传元数据、状态、URL、ETag、设备信息 | src/repos/videoRepo.ts、docker/postgres/init/002_video_upload.sql |
| 自动化验证 | 路由/服务层测试 | 校验 multipart 排序和路由 schema 行为 | tests/videoRoutes.test.ts、tests/videoService.test.ts |
从实现方式上看,这套方案本质上是:
- 前端负责"录制 + 切片 + PUT + 回传结果"。
- 后端负责"签名 + 状态约束 + 合并 + 落库"。
- 对象存储只做文件承载,不承担业务状态判断。
3. 设计思路与整体方案
3.1 功能目标
这个功能最终要达成四件事:
- 视频录制结果能落到本地受控目录。
- 上传不经过应用服务器转发,而是前端直传对象存储。
- 后端能显式记录上传状态,并在完成后生成最终文件地址。
- 上传失败、取消、重试时,状态和临时文件都可控。
3.2 设计原则
当前实现遵循了四个原则。
-
大文件不经应用服务器中转。
原因很现实:视频流量大、上传时间长,中转会放大 API 服务器的内存和出口压力。
-
上传状态必须持久化。
初始化时就先插入一条记录,状态设为"初始化完成";完成时再更新为"上传成功";取消或终态失败也要落库。
-
前后端契约以后端 schema 为准,前端显式映射。
不是依赖"大家约好",而是把字段风格、必填项、额外字段限制写进后端 JSON Schema,再由前端模型输出严格对齐的请求体。
-
客户端状态机要和文件生命周期绑定。
本地文件什么时候保留、什么时候删除,不能靠"请求成功了就删",而必须和"后端完成合并成功"绑定。
3.3 为什么采用 multipart + 预签名直传
相比"前端把整文件直接 POST 给应用服务器",当前实现更适合视频场景:
- 前端只拿到每个分片的临时上传地址,不需要暴露对象存储密钥。
- 后端只负责轻量的签名和完成确认,不承担大文件流量转发。
- 上传过程可以取消,也更容易做进度追踪。
- 对象存储原生支持按
ETag + PartNumber合并,协议成熟。
3.4 整体方案图
开始录制
停止录制并写入本地临时文件
调用初始化接口
后端创建 multipart 会话
返回业务主键/对象键/分片大小/每片上传地址
前端按分片大小切片并顺序 PUT 到对象存储
收集每片 ETag
调用完成接口
后端 CompleteMultipartUpload
更新数据库状态与最终 URL
前端删除本地临时文件
4. 相关代码结构与关键文件
这里只列与该功能强相关的代码,不展开整个项目目录。
text
frontend/
lib/features/video_record_upload/
ui/video_record_upload_demo_screen.dart
controller/video_record_upload_controller.dart
controller/video_record_upload_state.dart
data/video_upload_service.dart
data/models/video_upload_models.dart
media/video_record_camera_source.dart
media/video_record_permissions.dart
video_record_upload_error.dart
lib/core/http/http_client.dart
assets/env/.env.dev
backend/
src/app.ts
src/routes/video.ts
src/services/videoService.ts
src/services/videoStorageService.ts
src/repos/videoRepo.ts
src/types/video.ts
src/config/env.ts
docker/postgres/init/002_video_upload.sql
tests/videoRoutes.test.ts
tests/videoService.test.ts
关键文件职责如下。
| 文件 | 角色 | 关键职责 |
|---|---|---|
ui/video_record_upload_demo_screen.dart |
功能入口 | 提供录制、上传、取消、清空文件的手工验证页面 |
controller/video_record_upload_controller.dart |
前端编排层 | 串起录制、初始化、PUT、完成、取消、清理 |
controller/video_record_upload_state.dart |
前端状态模型 | 管理阶段、进度、错误、调试信息、本地文件引用 |
data/video_upload_service.dart |
前端上传服务 | 调后端接口、执行 PUT、收集 ETag |
data/models/video_upload_models.dart |
前端契约模型 | 输出 strict request,解析初始化响应 |
media/video_record_camera_source.dart |
本地录制与文件搬运 | 调相机插件并把录制结果转成受控临时文件 |
src/routes/video.ts |
后端接口层 | 声明上传相关 schema,限制字段和类型 |
src/services/videoService.ts |
后端业务层 | 初始化 multipart、完成上传、取消上传、状态保护 |
src/services/videoStorageService.ts |
对象存储适配层 | 创建会话、生成 presigned URL、合并和取消 |
src/repos/videoRepo.ts |
数据访问层 | 插入初始化记录、更新上传状态、查询结果 |
002_video_upload.sql |
数据库迁移 | 建表、约束、索引 |
5. 整体实现链路
5.1 从入口到落库的真实链路
这条链路在当前仓库中是完整可追踪的。
- 调试页创建控制器并初始化摄像头。
- 控制器调用相机源开始录制。
- 停止录制后,相机源把插件返回的文件搬运到应用临时目录,并生成本地文件元数据。
- 控制器进入上传阶段,先调用初始化接口。
- 后端创建 multipart 上传会话,计算分片数,生成每片的 presigned URL,同时插入一条"初始化完成"记录。
- 前端拿到
part_size和parts[]后,按分片大小切片读取本地文件,并顺序 PUT 到对象存储。 - 每片上传完成后,前端从响应头收集
ETag。 - 全部分片完成后,前端调用完成接口,把业务主键、上传会话 ID、对象键、分片列表回传给后端。
- 后端再次校验这次完成请求是否和初始化记录匹配,然后调用
CompleteMultipartUpload。 - 对象存储合并成功后,后端把数据库状态更新为"上传成功",写入最终 URL 和 ETag。
- 控制器确认完成接口成功后,才删除本地临时文件。
5.2 模块协作关系
数据库 对象存储 后端接口/业务层 前端上传服务 前端控制器 Flutter 入口页 数据库 对象存储 后端接口/业务层 前端上传服务 前端控制器 Flutter 入口页 loop [每个分片] 停止录制并点击上传 uploadInit(localFile) 初始化上传请求 创建 multipart 会话 生成每片 presigned URL 插入初始化记录 biz_id/upload_id/object_key/part_size/parts[] PUT part ETag uploadComplete(parts) 完成上传请求 CompleteMultipartUpload 更新最终状态、URL 与 ETag 完成结果 删除本地临时文件
5.3 前端状态流转
前端的状态枚举在 video_record_upload_state.dart 中是显式声明的,而不是靠布尔值拼装。
保留本地文件后可重试
保留本地文件后可重试
完成清理后可重新录制
idle
preparingCamera
ready
cameraError
recording
recorded
uploading
uploadSuccess
uploadFailed
uploadCancelled
这套状态机的价值在于:
- UI 是否允许"停止录制""上传""取消上传""清理文件",都可以基于
stage判断。 - 错误和清理结果可以跟状态一起展示。
- 本地文件是否应该保留,也和状态直接关联。
6. 核心实现拆解
6.1 功能入口:一个独立的调试页 + 控制器编排
前端入口不是直接把上传逻辑塞进页面,而是做了两层拆分:
- 页面负责按钮和状态展示。
- 控制器负责实际业务编排。
当前仓库里的入口页会在 initState 中创建控制器,并立即触发摄像头初始化。页面通过 AnimatedBuilder 订阅控制器状态。
这样拆的好处是:
- 页面足够薄,适合做调试与演示。
- 录制逻辑、上传逻辑、异常处理可以独立测试和复用。
- 后续如果把这个能力接到别的页面,主要复用控制器和 service 即可。
从控制器的职责看,它并不是"再包一层 service",而是明确负责这三件事:
- 管理录制生命周期。
- 串联 upload-init、PUT、upload-complete、upload-abort。
- 管理本地文件何时保留、何时删除。
6.2 本地录制与临时文件管理
这个功能点里有一个很重要但常被忽视的设计:录制完成后,文件不会直接继续使用插件产生的原始路径,而是会被搬运到应用自己可控的临时目录。
真实实现落点:
- 权限:
media/video_record_permissions.dart - 录制与搬运:
media/video_record_camera_source.dart
关键流程如下:
- 先请求相机和麦克风权限。
- 使用
camera插件开始录像。 - 停止录像后拿到插件返回的
XFile。 - 将其移动或复制到应用临时目录,例如
.../tmp/video_record_upload/。 - 生成一个
VideoRecordedFile,里面包含路径、文件名、大小、时长、录制完成时间、内容类型。
基于真实实现抽象后的伪代码如下:
dart
Future<RecordedFile> stopRecording() async {
final rawFile = await camera.stopVideoRecording();
final managedFile = await moveToManagedTempDir(rawFile);
return RecordedFile(
path: managedFile.path,
fileName: basename(managedFile.path),
sizeBytes: await managedFile.length(),
duration: elapsed,
recordedAt: DateTime.now(), // 录制完成时间
contentType: 'video/mp4',
);
}
这里有两个细节很关键。
-
recordedAt使用的是"录制完成时间"。当前仓库里控制器在停止录制时不再把"开始录制时间"当成
recorded_at传下去,而是由CameraSource.stopRecording()默认使用DateTime.now()作为录制完成时间。 -
文件先保留在本地,不会录制完就立刻删。
本地临时文件会一直保留到"后端完成合并成功"之后才删除,这样失败时才能真正支持重试。
6.3 数据建模与状态管理:先把契约收敛,再谈上传
当前前端模型层做得很对的一点,是把"请求输出"和"响应解析"都收到了专门的 model 文件里,而不是在页面里临时拼 JSON。
真实实现落点:
- 请求/响应模型:
data/models/video_upload_models.dart - 状态模型:
controller/video_record_upload_state.dart
6.3.1 初始化请求为什么要显式建模
后端路由层使用了严格的 JSON Schema,并开启了 additionalProperties: false。这意味着:
- 字段名风格不一致会直接被拒绝。
- 多发任何一个非 schema 内字段,也会直接被拒绝。
因此前端没有走"现拼一个 Map 试试看"的做法,而是通过 UploadInitRequest.toJson() 明确输出 snake_case 顶层结构。
抽象后的伪代码如下:
dart
class UploadInitRequest {
Map<String, dynamic> toJson() => {
'file_name': fileName,
'content_type': contentType,
'size_bytes': sizeBytes,
'duration_sec': durationSec,
'recorded_at': recordedAt.toUtc().toIso8601String(),
'media_type': mediaType,
'device_id': deviceId,
if (sessionId != null) 'session_id': sessionId,
};
}
当前真实代码里,duration_sec 也明确要求是整数,而不是浮点秒数。这一点后端 service 同样做了整数约束。
6.3.2 为什么响应解析要"严格主用 + 兼容兜底"
从当前模型实现可以看出,这个功能经历过一次前后端契约收口:
- 现在的前端解析逻辑优先读取 snake_case。
- 但仍保留了对旧 camelCase 响应的兼容兜底。
这类代码痕迹很有代表性:它说明"最终约定已经统一",但为了平滑过渡,客户端仍保留了容错层。
当前初始化响应模型最关键的字段有:
- 业务主键
- 上传会话 ID
- 对象键
- 分片大小
- 分片列表
- 每片的分片号与上传地址
这里尤其重要的是两项:
part_sizeparts[].part_number
如果前端拿不到 part_size,本地切片边界就会错;如果不显式识别 part_number,即使数组顺序看起来对,也可能在兼容场景里埋坑。
6.4 上传初始化:后端创建 multipart 会话并先落一条记录
这一步是整个设计里最关键的"状态收口点"。
真实实现落点:
- 路由层:
src/routes/video.ts - 业务层:
src/services/videoService.ts - 对象存储适配层:
src/services/videoStorageService.ts - Repo:
src/repos/videoRepo.ts
当前后端的初始化流程不是"只返回 presigned URL",而是同时做了四件事:
- 校验请求字段。
- 创建 multipart 上传会话。
- 生成每个 part 的 presigned URL。
- 先插入一条数据库记录,状态设为"初始化完成"。
抽象后的伪代码如下:
ts
async function initUpload(payload) {
assertStorageConfigured();
const bizId = generateBizId();
const objectKey = buildObjectKey({
prefix: MEDIA_PREFIX,
recordedAt: payload.recorded_at,
bizId,
fileName: payload.file_name,
contentType: payload.content_type,
});
const partSize = env.upload.partSize;
const partCount = Math.ceil(payload.size_bytes / partSize);
const session = await storage.createMultipartUpload(objectKey);
const parts = await storage.createPresignedPartUrls({
objectKey,
uploadId: session.uploadId,
partCount,
});
await repo.insertInitRecord({
bizId,
objectKey,
uploadId: session.uploadId,
status: 'initialized',
url: '',
...payloadMeta,
});
return { bizId, uploadId: session.uploadId, objectKey, partSize, parts };
}
这一步里有三个设计点值得注意。
6.4.1 统一对象键命名
对象键不是前端上传时随便拼的,而是后端统一生成。
当前真实实现按"固定前缀 + 年/月/日目录 + 业务 ID + 扩展名"的方式构造对象键。好处是:
- 存储结构稳定。
- 后端可控,前端不必感知内部命名规则。
- 后续做清理、归档、问题定位都更方便。
6.4.2 初始化即落库
数据库插入发生在上传前,而不是上传完才插入。这样做能解决两个实际问题:
- 上传过程中如果用户取消,系统仍然有一条可追踪记录。
- 上传失败时,后端能把状态明确标成失败或取消,而不是"对象存储里有半截数据,业务侧却毫无记录"。
6.4.3 part 数量有上限保护
后端 service 里显式限制了 multipart part 数量不能超过 10000,这是 S3 multipart 的硬限制。这个保护很必要,否则前端再努力切片,最终也无法完成合并。
6.5 前端直传 MinIO:按 part_size 切片并顺序 PUT
初始化成功后,真正的大文件流量就不再经过应用服务器,而是前端直接 PUT 到对象存储。
真实实现落点:
data/video_upload_service.dart
当前服务里同时保留了单文件 PUT 和 multipart 两套分支,但从后端当前实现来看,初始化接口统一返回的是 parts[],因此真实链路走的是 multipart 分支。
这段实现的核心是四步:
- 按
part_number排序。 - 按后端返回的
part_size计算每片的start/end。 - 用
file.openRead(start, end)直接流式 PUT。 - 从响应头里读取每片
ETag。
抽象后的伪代码如下:
dart
Future<List<PartResult>> uploadMultipart(File file, InitResponse init) async {
final sortedParts = [...init.parts]..sort(byPartNumber);
final results = <PartResult>[];
for (final part in sortedParts) {
final start = (part.partNumber - 1) * init.partSize;
final end = min(start + init.partSize, file.lengthSync());
final response = await dio.putUri(
Uri.parse(part.url),
data: file.openRead(start, end),
options: Options(headers: {
'Content-Type': 'video/mp4',
'Content-Length': end - start,
}),
cancelToken: cancelToken,
);
final etag = readHeader(response.headers, 'etag');
if (etag == null || etag.isEmpty) {
throw ProtocolError('part upload succeeded but ETag is missing');
}
results.add(PartResult(part.partNumber, etag));
}
return results;
}
6.5.1 为什么前端要信任后端的 part_size
当前前端不会自己猜测分片边界,而是强依赖后端返回的 part_size。这是正确做法。
原因是:
- 分片大小是对象存储协议的一部分,不应该由两端各自"差不多算一下"。
- 一旦分片边界错位,完成接口即使发出去,也可能因为
InvalidPart或EntityTooSmall失败。
6.5.2 为什么要显式收集 ETag
multipart 上传不是"PUT 完就完了",后端完成合并时必须知道每片的 PartNumber + ETag。
当前实现直接从 MinIO 的响应头读取 ETag,并原样回传。这里有一个很重要的细节:
- 当前实现不会主动去掉
ETag两侧的引号。
这不是疏忽,而是有意保持"以对象存储返回值为准"。后端在归一化分片信息时也只做了 trim,不会移除引号。这样最稳妥。
6.6 完成上传并落库:后端做最后一次状态校验
上传完成并不是"前端看到 200 就算完",而是还要再过一道后端业务校验。
真实实现落点:
- 前端完成请求:
data/video_upload_service.dart - 后端完成处理:
src/services/videoService.ts - 存储合并:
src/services/videoStorageService.ts - 数据更新:
src/repos/videoRepo.ts
前端完成请求体的关键内容是:
- 业务主键
- 上传会话 ID
- bucket
- 对象键
parts: [{ PartNumber, ETag }]
这里大小写非常关键:PartNumber 和 ETag 都是大写开头。当前后端 schema 就按这个结构收。
后端完成阶段做了四层保护。
- 校验记录是否存在。
- 校验当前状态是不是已经处于终态。
- 校验业务主键、上传会话 ID、bucket、对象键是否和初始化记录一致。
- 统一排序
parts,再调用CompleteMultipartUpload。
这一步很有价值,因为它把"谁来保证请求没有串会话"的责任留在了后端,而不是假设前端不会出错。
抽象后的伪代码如下:
ts
async function completeUpload(payload) {
const record = await repo.findByBizId(payload.biz_id);
assertRecordExists(record);
assertStatusIsCompletable(record.status);
assertSessionMatches(record, payload.upload_id, payload.bucket, payload.object_key);
const parts = normalizeAndSortParts(payload.parts);
const completed = await storage.completeMultipartUpload({
objectKey: payload.object_key,
uploadId: payload.upload_id,
parts,
});
return repo.markUploaded({
bizId: payload.biz_id,
url: completed.url,
etag: completed.etag,
});
}
当前后端还有一个值得肯定的细节:
- 如果
CompleteMultipartUpload失败,而且错误属于 S3 的终态错误,例如EntityTooSmall / InvalidPart / InvalidPartOrder / NoSuchUpload,服务层会尝试把数据库状态最佳努力更新为"已失败"。
这能避免出现"上传实际上已经不可能成功了,但数据库还停留在初始化状态"的假象。
6.7 取消上传、异常处理与本地文件清理
这个功能点里最容易做糙的,其实不是主链路,而是失败链路。
当前实现对失败链路处理得比较完整。
6.7.1 前端取消:CancelToken + abort 接口
前端上传 PUT 使用的是 dio 的 CancelToken。一旦用户点击取消:
- 当前 PUT 请求会被取消。
- 控制器捕获到
cancelled错误。 - 如果已经拿到初始化响应,就继续调用取消接口。
- 后端执行
AbortMultipartUpload,并把数据库状态更新为"已取消"。
6.7.2 本地文件只在真正完成后才删除
这是当前前端实现里最重要的兜底之一:
uploadComplete()成功之前,本地临时文件绝不删除。uploadComplete()成功后,才调用_deleteLocalFile()。- 即使删除失败,也只是留下清理提示,不会把"上传成功"误判成"上传失败"。
也就是说,文件生命周期和业务完成状态是绑定的,而不是和"某一次网络请求返回 200"绑定。
6.7.3 页面销毁时的兜底
控制器在 dispose() 中做了两件事:
- 取消当前上传。
- 如果当前不处于 uploading 状态,则尝试删除本地临时文件。
这能避免调试页退出后残留过多临时文件,但又不会在上传进行中误删文件。
7. 改造思路与关键改动
从当前仓库代码的结构和兼容层痕迹可以明确看出,这个功能并不是"从零写完就一次成功",而是经历过一轮前后端契约收口。
最直接的证据有两个:
- 前端请求模型现在输出的是严格 snake_case,但响应解析仍保留了对旧 camelCase 的兼容兜底。
- 后端路由层通过
additionalProperties: false明确把 schema 变成了单一事实源。
这意味着改造重点并不在"换技术",而在"把前后端口子彻底收口"。
7.1 改造前的典型问题
基于当前代码可以确认的改造点如下。
| 改造前问题 | 风险 | 当前收口后的方案 |
|---|---|---|
| 前端请求体字段风格不稳定,存在 camelCase/嵌套结构 | 后端 strict schema 直接拒绝请求 | 前端 model 层统一输出 snake_case 顶层字段 |
| 初始化响应解析优先读旧字段 | 前端拿不到 upload_id / object_key / part_size,后续 PUT 和 complete 无法继续 |
响应解析改为 snake_case 优先,camelCase 仅保留兼容 |
| 完成接口里的分片字段大小写不统一 | 后端拿不到 PartNumber / ETag |
前端显式映射为大写首字母字段,后端继续做二次排序 |
| 上传取消依赖初始化结果是否正确解析 | 一旦 upload_id/object_key 解析失败,abort 实际不会触发 |
canAbort 与初始化关键字段绑定,解析成功后才允许走取消链路 |
recorded_at 语义容易漂移 |
目录组织、数据库记录时间都可能失真 | 当前实现以"录制完成时间"为准 |
| 前端存在多个 HTTP 基地址 | 容易把视频上传错发到另一套服务 | 当前实现把视频上传固定到主 API 基地址,其他独立能力继续走自己的地址 |
7.2 为什么选择"前端显式映射,后端严格校验"
这里有一个常见分歧:到底是让后端尽量兼容各种字段风格,还是让前端完全对齐后端 contract?
当前实现选择的是:
- 前端对外严格映射。
- 后端继续严格校验。
- 客户端只在"解析旧响应"这一侧保留过渡兼容。
这个取舍更合适,原因是:
- 后端 schema 是真正的单点真相,不能因为兼容过多风格而失去约束力。
- 前端模型层改动成本小,且最接近调用现场。
- 兼容层保留在响应解析侧,既能平滑过渡,又不会污染最终请求契约。
7.3 为什么没有改成"整文件直传一次就完"
原因不是"技术上做不到",而是收益不划算。
当前真实代码里已经具备了 multipart 所需的完整链路:
- 后端会生成每片 presigned URL。
- 前端会按
part_size切片。 - 前后端都处理了
ETag + PartNumber。 - 数据库状态也围绕 multipart 完整设计。
在这种前提下,改回"整文件一次性上传"既不能降低复杂度,还会丢掉大文件场景的稳定性。
8. 关键代码片段解析
这一节的代码都做了脱敏,但和仓库中的真实实现是一一对应的。
8.1 前端请求模型:把 contract 收到 model 层
落点对应:data/models/video_upload_models.dart
dart
class UploadInitRequest {
const UploadInitRequest({
required this.fileName,
required this.sizeBytes,
required this.contentType,
required this.durationSec,
required this.recordedAt,
required this.mediaType,
required this.deviceId,
this.sessionId,
});
Map<String, dynamic> toJson() {
return {
'file_name': fileName,
'size_bytes': sizeBytes,
'content_type': contentType,
'duration_sec': durationSec,
'recorded_at': recordedAt.toUtc().toIso8601String(),
'media_type': mediaType,
'device_id': deviceId,
if (sessionId != null) 'session_id': sessionId,
};
}
}
这段代码解决的不是"少写几行 Map",而是:
- 确保请求结构和后端 strict schema 始终一致。
- 把字段风格转换收口到一个地方。
- 避免页面层到处散落字符串常量。
8.2 前端 PUT 分片:严格依赖 part_size
落点对应:data/video_upload_service.dart
dart
for (final part in sortedParts) {
final start = (part.partNumber - 1) * init.partSize;
final end = min(start + init.partSize, totalBytes);
final response = await dio.putUri(
Uri.parse(part.url),
data: file.openRead(start, end),
cancelToken: cancelToken,
);
final etag = readHeader(response.headers, 'etag');
if (etag == null || etag.isEmpty) {
throw ProtocolError('missing ETag');
}
uploadedParts.add({
'PartNumber': part.partNumber,
'ETag': etag,
});
}
这段逻辑解决了三个问题:
- 客户端怎样按服务端规定的边界切片。
- 怎样在不把整文件读进内存的情况下做流式 PUT。
- 怎样为后续 complete 阶段准备必要的
PartNumber + ETag。
8.3 后端初始化:先签名,再落"初始化完成"状态
落点对应:src/services/videoService.ts
ts
const bizId = generateBizId();
const objectKey = buildObjectKey({
recordedAt,
bizId,
fileName,
contentType,
});
const partSize = env.upload.partSize;
const partCount = Math.ceil(sizeBytes / partSize);
const upload = await storage.createMultipartUpload({ objectKey, contentType });
const parts = await storage.createPresignedPartUrls({
objectKey,
uploadId: upload.uploadId,
partCount,
});
await repo.insertInitRecord({
bizId,
objectKey,
uploadId: upload.uploadId,
status: 'initialized',
url: '',
...meta,
});
这段逻辑的关键不是"会调用几个 API",而是它明确了状态顺序:
- 先创建上传会话。
- 再生成签名。
- 再插入初始化记录。
一旦中途失败,还会做回滚或最佳努力清理,而不是把半成品留给下游。
8.4 后端完成:不是盲目 complete,而是先做会话一致性校验
落点对应:src/services/videoService.ts
ts
const record = await repo.findByBizId(payload.bizId);
assertRecordExists(record);
assertCompletable(record.status);
assertSessionMatches(record, payload.uploadId, payload.bucket, payload.objectKey);
const parts = normalizeAndSortParts(payload.parts);
const completed = await storage.completeMultipartUpload({
objectKey: payload.objectKey,
uploadId: payload.uploadId,
parts,
});
await repo.markUploaded({
bizId: payload.bizId,
url: completed.url,
etag: completed.etag,
});
这段代码解决的是"完成请求能不能被串改"的问题。
如果没有 assertSessionMatches() 这一步,理论上只要有人拿到了别人的 upload_id 和对象键,就可能错误地完成一条不属于自己的上传。
9. 如何运行与验证这个功能
这里只写与这个功能点直接相关的运行和验证方式。
9.1 必要配置
前端至少需要配置:
- 主 API 基地址
- 设备标识
后端至少需要配置:
- 对象存储 endpoint
- 对象存储对外访问基地址
- bucket
- 访问密钥
- multipart 分片大小
- 数据库连接
还要确认数据库里已经执行了媒体上传相关迁移。当前仓库中的迁移文件是:
docker/postgres/init/002_video_upload.sql
如果数据库是老数据卷,当前仓库里没有自动补跑历史迁移的逻辑,这一点需要结合实际运行环境确认。
9.2 前端手工验证路径
当前仓库里已经有一个独立的调试页,适合做真机验证。验证顺序建议如下:
- 进入调试页,确认摄像头初始化成功。
- 录制一段小于单片大小的视频,验证单片 multipart 能成功完成。
- 录制一段大于单片大小的视频,验证多片 PUT 和 complete 流程。
- 上传中主动取消,验证对象存储 multipart 被中止,数据库状态进入"已取消"。
- 人为断网或制造 PUT 失败,确认本地临时文件仍保留,可再次重试。
- 上传完成后,确认本地临时文件被删除,数据库状态为"上传成功",并能查到最终 URL。
9.3 仓库内可直接执行的静态检查与测试
前端静态检查:
bash
flutter analyze lib/features/video_record_upload
后端构建:
bash
npm run build
后端上传相关测试:
bash
npx tsx --test tests/videoRoutes.test.ts tests/videoService.test.ts
当前仓库里可以明确看到的自动化验证主要覆盖了两类能力:
- 路由 schema 是否会拦截非法请求。
- multipart 分片排序和对象键构造是否符合预期。
当前仓库中暂未看到"移动端录制 + 真机 PUT + 后端 complete"的自动化 E2E,因此终端侧主要还是依赖独立调试页做联调验证。
10. 实现中的坑点与注意事项
10.1 契约风格统一比"多兼容几种写法"更重要
后端路由层使用 strict schema 之后,前端就不能再靠"反正字段差不多"来凑请求。
这一点的经验是:
- 请求体要显式映射。
- 响应解析可以做兼容,但兼容层要放在 model 层,不要散落在业务代码里。
10.2 PartNumber 和 ETag 的大小写不能随便改
在 complete 阶段,这两个字段本身就是协议的一部分。
当前实现用了"双保险":
- 前端发送前先排序。
- 后端收到后再排序一次。
这能大幅降低 InvalidPartOrder 一类问题。
10.3 part_size 必须以后端为准
不要在客户端自己"平均切一下"。当前真实前端已经改成如果拿不到有效 part_size 就直接报协议错误,这个决定是对的。
10.4 本地文件删除时机非常关键
删除过早是视频上传最容易出事故的地方之一。
当前实现的经验可以直接复用:
complete成功前不删。- 取消或失败时保留文件。
- 删除失败不影响"上传成功"的业务结论。
10.5 控制台地址和 S3 API 地址不能想当然地混用
当前仓库中,对象存储签名 URL 直接使用的是 MINIO_ENDPOINT。这意味着:
- 这个地址必须是真机可访问的。
- 它必须真的是 S3 兼容 API 地址,而不只是某个控制台页面。
如果这里配成 127.0.0.1、内网不可达地址,或者误填成只提供 Web Console 的入口,前端拿到 presigned URL 后也无法 PUT 成功。
10.6 老数据卷可能缺迁移
当前仓库把媒体上传表定义在单独的 SQL 迁移文件中。如果数据库卷早于这次功能上线就已经初始化过,运行时最可能出错的地方是"初始化落库"这一步。
当前仓库中暂未体现自动补执行老迁移的机制,因此这个问题需要在实际部署环境中单独检查。
10.7 当前后端统一走 multipart,单文件 PUT 分支只是兼容层
前端服务里仍然保留了单文件 PUT 模式分支,但以当前后端实现来看,初始化接口统一返回的是分片列表,因此真实链路走的是 multipart。
这意味着:
- 如果将来真的要支持单 PUT,需要后端显式返回另一种初始化结构。
- 在此之前,不要以为"前端有 singlePut 分支,所以后端天然支持"。
11. 可复用的思路总结
这次实现里,最值得迁移到其他项目的不是某个 API,而是下面这些方法。
11.1 大文件上传要把"数据流"和"状态流"拆开
数据流走对象存储,状态流走业务后端。这样既减轻了应用服务器压力,也让状态约束回到后端掌控。
11.2 初始化即落库,是对失败链路最友好的设计
很多项目只在上传成功后写库,结果一旦失败就没有上下文。当前实现先插入初始化记录,再更新最终状态,更容易排障和追踪。
11.3 契约对齐要靠模型层和 schema,而不是靠约定
前端 model 显式输出请求体、后端 route 显式声明 schema,这一套比任何口头约定都可靠。
11.4 本地文件生命周期要和业务完成状态绑定
"后端 complete 成功后再删本地文件",这个原则几乎适用于所有带离线重试需求的媒体上传场景。
11.5 分片顺序要双端兜底
前端排序一次,后端再排序一次,成本极低,但能避免一类非常隐蔽的 multipart 合并错误。
12. 边界说明与脱敏说明
本文有三类内容做了主动脱敏:
- 接口路径、数据库表名、对象存储前缀、环境地址都替换成了通用表达。
- 代码片段保留真实结构,但变量名和业务名做了泛化。
- 仓库名、页面名、具体业务场景没有公开展开。
同时也说明三点边界。
- 文中关于模块分工、状态流转、错误处理、数据库更新时机,都直接来自当前仓库真实实现。
- 文中关于部署方式、对象存储域名、密钥管理策略,只保留了理解这个功能所需的最小背景,具体环境值需按你的项目调整。
- 当前仓库中暂未明确体现断点续传、后台继续上传、弱网自动重试队列等更重的能力,因此本文不把这些能力描述成"已实现"。
13. 总结
这套"Flutter 录制视频并上传 MinIO 落库"的实现,核心不是"会调几个接口",而是把整个链路拆成了四个职责明确的层次:
- 终端负责录制和分片 PUT。
- 后端负责签名、状态保护和最终落库。
- 对象存储负责承载分片和合并结果。
- 数据库负责记录这次上传从初始化到完成的全生命周期。
真正让这套方案稳定可用的,不是某个单点技巧,而是这些组合起来的细节:
- 初始化即落库
- strict schema 契约收口
- 按服务端
part_size切片 - 双端排序
PartNumber - 完成成功后再删本地文件
- 取消和失败也走完整状态更新
如果你也在做"移动端录制大文件 -> 对象存储直传 -> 业务系统留痕"的场景,这套思路是非常值得直接复用的。它不依赖某个特定业务,只依赖一套清晰的职责拆分和严谨的状态设计。