Flutter 录制视频+大文件上传 MinIO + NodeJS落库

在移动端或一体机终端里,视频上传经常会同时踩中三个坑:

  1. 文件体积大,直接走应用服务器中转会带来明显的带宽和内存压力。
  2. 上传链路长,任何一个环节失败,都可能导致"对象存储里有文件,但业务系统里查不到记录"。
  3. 前后端契约一旦漂移,往往不是编译期报错,而是运行时才发现"接口能调通,但口子没对上"。

本文基于一个真实 Flutter + Node.js 项目的当前仓库代码,拆解"录制视频 -> 直传 MinIO -> 完成合并 -> 元数据落库"的完整实现方式。

本文对业务信息做了主动脱敏:

  • 仓库名、业务页面名、具体接口路径、对象存储域名、真实对象前缀均做了泛化处理。
  • 接口名统一用"初始化接口 / 完成接口 / 取消接口"表示。
  • 数据表和字段名使用"媒体记录表""业务主键""对象键"等通用表达。
  • 少量代码片段采用"基于真实实现抽象后的伪代码",但实现思路、模块分工、边界处理均对应当前仓库真实代码。

1. 功能背景与要解决的问题

这个功能点的目标很明确:让终端可以在本地录制视频,然后稳定地把视频上传到 S3 兼容对象存储,并在后端保存一条可查询、可追踪的元数据记录。

它要解决的核心技术问题有三个:

  1. 终端如何把录制好的本地视频文件安全、稳定地传到对象存储,而不是先把整文件压到应用服务器。
  2. 后端如何在上传前、上传中、上传后维护一条"上传状态可追踪"的记录,避免文件和数据库状态脱节。
  3. 前后端如何把 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.dartcontroller/video_record_upload_state.dart
前端网络 dio + 轻量 HTTP 封装 调后端初始化/完成/取消接口,并直接 PUT 到 MinIO data/video_upload_service.dartcore/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.tssrc/repos/videoRepo.ts
数据持久化 PostgreSQL 存放上传元数据、状态、URL、ETag、设备信息 src/repos/videoRepo.tsdocker/postgres/init/002_video_upload.sql
自动化验证 路由/服务层测试 校验 multipart 排序和路由 schema 行为 tests/videoRoutes.test.tstests/videoService.test.ts

从实现方式上看,这套方案本质上是:

  • 前端负责"录制 + 切片 + PUT + 回传结果"。
  • 后端负责"签名 + 状态约束 + 合并 + 落库"。
  • 对象存储只做文件承载,不承担业务状态判断。

3. 设计思路与整体方案

3.1 功能目标

这个功能最终要达成四件事:

  1. 视频录制结果能落到本地受控目录。
  2. 上传不经过应用服务器转发,而是前端直传对象存储。
  3. 后端能显式记录上传状态,并在完成后生成最终文件地址。
  4. 上传失败、取消、重试时,状态和临时文件都可控。

3.2 设计原则

当前实现遵循了四个原则。

  1. 大文件不经应用服务器中转。

    原因很现实:视频流量大、上传时间长,中转会放大 API 服务器的内存和出口压力。

  2. 上传状态必须持久化。

    初始化时就先插入一条记录,状态设为"初始化完成";完成时再更新为"上传成功";取消或终态失败也要落库。

  3. 前后端契约以后端 schema 为准,前端显式映射。

    不是依赖"大家约好",而是把字段风格、必填项、额外字段限制写进后端 JSON Schema,再由前端模型输出严格对齐的请求体。

  4. 客户端状态机要和文件生命周期绑定。

    本地文件什么时候保留、什么时候删除,不能靠"请求成功了就删",而必须和"后端完成合并成功"绑定。

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 从入口到落库的真实链路

这条链路在当前仓库中是完整可追踪的。

  1. 调试页创建控制器并初始化摄像头。
  2. 控制器调用相机源开始录制。
  3. 停止录制后,相机源把插件返回的文件搬运到应用临时目录,并生成本地文件元数据。
  4. 控制器进入上传阶段,先调用初始化接口。
  5. 后端创建 multipart 上传会话,计算分片数,生成每片的 presigned URL,同时插入一条"初始化完成"记录。
  6. 前端拿到 part_sizeparts[] 后,按分片大小切片读取本地文件,并顺序 PUT 到对象存储。
  7. 每片上传完成后,前端从响应头收集 ETag
  8. 全部分片完成后,前端调用完成接口,把业务主键、上传会话 ID、对象键、分片列表回传给后端。
  9. 后端再次校验这次完成请求是否和初始化记录匹配,然后调用 CompleteMultipartUpload
  10. 对象存储合并成功后,后端把数据库状态更新为"上传成功",写入最终 URL 和 ETag。
  11. 控制器确认完成接口成功后,才删除本地临时文件。

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 功能入口:一个独立的调试页 + 控制器编排

前端入口不是直接把上传逻辑塞进页面,而是做了两层拆分:

  1. 页面负责按钮和状态展示。
  2. 控制器负责实际业务编排。

当前仓库里的入口页会在 initState 中创建控制器,并立即触发摄像头初始化。页面通过 AnimatedBuilder 订阅控制器状态。

这样拆的好处是:

  • 页面足够薄,适合做调试与演示。
  • 录制逻辑、上传逻辑、异常处理可以独立测试和复用。
  • 后续如果把这个能力接到别的页面,主要复用控制器和 service 即可。

从控制器的职责看,它并不是"再包一层 service",而是明确负责这三件事:

  1. 管理录制生命周期。
  2. 串联 upload-init、PUT、upload-complete、upload-abort。
  3. 管理本地文件何时保留、何时删除。

6.2 本地录制与临时文件管理

这个功能点里有一个很重要但常被忽视的设计:录制完成后,文件不会直接继续使用插件产生的原始路径,而是会被搬运到应用自己可控的临时目录。

真实实现落点:

  • 权限:media/video_record_permissions.dart
  • 录制与搬运:media/video_record_camera_source.dart

关键流程如下:

  1. 先请求相机和麦克风权限。
  2. 使用 camera 插件开始录像。
  3. 停止录像后拿到插件返回的 XFile
  4. 将其移动或复制到应用临时目录,例如 .../tmp/video_record_upload/
  5. 生成一个 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',
  );
}

这里有两个细节很关键。

  1. recordedAt 使用的是"录制完成时间"。

    当前仓库里控制器在停止录制时不再把"开始录制时间"当成 recorded_at 传下去,而是由 CameraSource.stopRecording() 默认使用 DateTime.now() 作为录制完成时间。

  2. 文件先保留在本地,不会录制完就立刻删。

    本地临时文件会一直保留到"后端完成合并成功"之后才删除,这样失败时才能真正支持重试。

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
  • 对象键
  • 分片大小
  • 分片列表
  • 每片的分片号与上传地址

这里尤其重要的是两项:

  1. part_size
  2. parts[].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",而是同时做了四件事:

  1. 校验请求字段。
  2. 创建 multipart 上传会话。
  3. 生成每个 part 的 presigned URL。
  4. 先插入一条数据库记录,状态设为"初始化完成"。

抽象后的伪代码如下:

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 初始化即落库

数据库插入发生在上传前,而不是上传完才插入。这样做能解决两个实际问题:

  1. 上传过程中如果用户取消,系统仍然有一条可追踪记录。
  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 分支。

这段实现的核心是四步:

  1. part_number 排序。
  2. 按后端返回的 part_size 计算每片的 start/end
  3. file.openRead(start, end) 直接流式 PUT。
  4. 从响应头里读取每片 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。这是正确做法。

原因是:

  • 分片大小是对象存储协议的一部分,不应该由两端各自"差不多算一下"。
  • 一旦分片边界错位,完成接口即使发出去,也可能因为 InvalidPartEntityTooSmall 失败。

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 }]

这里大小写非常关键:PartNumberETag 都是大写开头。当前后端 schema 就按这个结构收。

后端完成阶段做了四层保护。

  1. 校验记录是否存在。
  2. 校验当前状态是不是已经处于终态。
  3. 校验业务主键、上传会话 ID、bucket、对象键是否和初始化记录一致。
  4. 统一排序 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 使用的是 dioCancelToken。一旦用户点击取消:

  1. 当前 PUT 请求会被取消。
  2. 控制器捕获到 cancelled 错误。
  3. 如果已经拿到初始化响应,就继续调用取消接口。
  4. 后端执行 AbortMultipartUpload,并把数据库状态更新为"已取消"。

6.7.2 本地文件只在真正完成后才删除

这是当前前端实现里最重要的兜底之一:

  • uploadComplete() 成功之前,本地临时文件绝不删除。
  • uploadComplete() 成功后,才调用 _deleteLocalFile()
  • 即使删除失败,也只是留下清理提示,不会把"上传成功"误判成"上传失败"。

也就是说,文件生命周期和业务完成状态是绑定的,而不是和"某一次网络请求返回 200"绑定。

6.7.3 页面销毁时的兜底

控制器在 dispose() 中做了两件事:

  1. 取消当前上传。
  2. 如果当前不处于 uploading 状态,则尝试删除本地临时文件。

这能避免调试页退出后残留过多临时文件,但又不会在上传进行中误删文件。

7. 改造思路与关键改动

从当前仓库代码的结构和兼容层痕迹可以明确看出,这个功能并不是"从零写完就一次成功",而是经历过一轮前后端契约收口。

最直接的证据有两个:

  1. 前端请求模型现在输出的是严格 snake_case,但响应解析仍保留了对旧 camelCase 的兼容兜底。
  2. 后端路由层通过 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?

当前实现选择的是:

  • 前端对外严格映射。
  • 后端继续严格校验。
  • 客户端只在"解析旧响应"这一侧保留过渡兼容。

这个取舍更合适,原因是:

  1. 后端 schema 是真正的单点真相,不能因为兼容过多风格而失去约束力。
  2. 前端模型层改动成本小,且最接近调用现场。
  3. 兼容层保留在响应解析侧,既能平滑过渡,又不会污染最终请求契约。

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,
  });
}

这段逻辑解决了三个问题:

  1. 客户端怎样按服务端规定的边界切片。
  2. 怎样在不把整文件读进内存的情况下做流式 PUT。
  3. 怎样为后续 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 前端手工验证路径

当前仓库里已经有一个独立的调试页,适合做真机验证。验证顺序建议如下:

  1. 进入调试页,确认摄像头初始化成功。
  2. 录制一段小于单片大小的视频,验证单片 multipart 能成功完成。
  3. 录制一段大于单片大小的视频,验证多片 PUT 和 complete 流程。
  4. 上传中主动取消,验证对象存储 multipart 被中止,数据库状态进入"已取消"。
  5. 人为断网或制造 PUT 失败,确认本地临时文件仍保留,可再次重试。
  6. 上传完成后,确认本地临时文件被删除,数据库状态为"上传成功",并能查到最终 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

当前仓库里可以明确看到的自动化验证主要覆盖了两类能力:

  1. 路由 schema 是否会拦截非法请求。
  2. multipart 分片排序和对象键构造是否符合预期。

当前仓库中暂未看到"移动端录制 + 真机 PUT + 后端 complete"的自动化 E2E,因此终端侧主要还是依赖独立调试页做联调验证。

10. 实现中的坑点与注意事项

10.1 契约风格统一比"多兼容几种写法"更重要

后端路由层使用 strict schema 之后,前端就不能再靠"反正字段差不多"来凑请求。

这一点的经验是:

  • 请求体要显式映射。
  • 响应解析可以做兼容,但兼容层要放在 model 层,不要散落在业务代码里。

10.2 PartNumberETag 的大小写不能随便改

在 complete 阶段,这两个字段本身就是协议的一部分。

当前实现用了"双保险":

  1. 前端发送前先排序。
  2. 后端收到后再排序一次。

这能大幅降低 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. 边界说明与脱敏说明

本文有三类内容做了主动脱敏:

  1. 接口路径、数据库表名、对象存储前缀、环境地址都替换成了通用表达。
  2. 代码片段保留真实结构,但变量名和业务名做了泛化。
  3. 仓库名、页面名、具体业务场景没有公开展开。

同时也说明三点边界。

  1. 文中关于模块分工、状态流转、错误处理、数据库更新时机,都直接来自当前仓库真实实现。
  2. 文中关于部署方式、对象存储域名、密钥管理策略,只保留了理解这个功能所需的最小背景,具体环境值需按你的项目调整。
  3. 当前仓库中暂未明确体现断点续传、后台继续上传、弱网自动重试队列等更重的能力,因此本文不把这些能力描述成"已实现"。

13. 总结

这套"Flutter 录制视频并上传 MinIO 落库"的实现,核心不是"会调几个接口",而是把整个链路拆成了四个职责明确的层次:

  1. 终端负责录制和分片 PUT。
  2. 后端负责签名、状态保护和最终落库。
  3. 对象存储负责承载分片和合并结果。
  4. 数据库负责记录这次上传从初始化到完成的全生命周期。

真正让这套方案稳定可用的,不是某个单点技巧,而是这些组合起来的细节:

  • 初始化即落库
  • strict schema 契约收口
  • 按服务端 part_size 切片
  • 双端排序 PartNumber
  • 完成成功后再删本地文件
  • 取消和失败也走完整状态更新

如果你也在做"移动端录制大文件 -> 对象存储直传 -> 业务系统留痕"的场景,这套思路是非常值得直接复用的。它不依赖某个特定业务,只依赖一套清晰的职责拆分和严谨的状态设计。

相关推荐
独特的螺狮粉3 小时前
雾色配色器:鸿蒙Flutter框架 实现的配色方案生成工具
flutter·华为·架构·开源·harmonyos
李宏伟~3 小时前
大文件分片案例html + nodejs + 视频上传案例
javascript·html·音视频
浮芷.4 小时前
Flutter 框架跨平台鸿蒙开发 - 旧物改造灵感库应用
科技·flutter·华为·harmonyos·鸿蒙
一直在想名4 小时前
Flutter 框架跨平台鸿蒙开发 - 宠物远程互动
flutter·华为·harmonyos·宠物
2401_839633914 小时前
Flutter 框架跨平台鸿蒙开发 - AR城市历史穿越
flutter·华为·ar·harmonyos
VOOHU-沃虎4 小时前
沃虎电子:音频变压器在信号隔离与音频接口中的选型与应用解析
算法·音视频
Likeadust4 小时前
智能会议管理系统EasyDSS构建企业视频全场景解决方案
人工智能·音视频
SoaringHeart4 小时前
Flutter组件封装:Sliver 中的 Container 对应组件NSliverContainer
前端·flutter
墨染倾城殇5 小时前
FSC-BW5028MV适配车载多场景方案:WiFi7+蓝牙5.4 让音频与数据并发稳定输出
网络·音视频·wifi 7·蓝牙5.4·车载蓝牙模块