给园区监控系统加录像/截图线上存储:后端设计与踩坑记录

园区监控平台里,用户看实时视频时的截图和录像,原本是浏览器直接下载到本地。这次把它改成上传线上存储,并补齐了分页列表、查看、改名、批量删除和审计日志。这篇复盘记录几个关键的设计决策,以及过程中踩到的五个真实的坑——分页插件的 NULL 参数、通用 mapper 的 OGNL 陷阱、单测测不到的 XML 语法错误、被 distinct 折叠的测试数据,还有 PowerShell 拼 URL 的变量名陷阱。

需求

监控页面上,用户看某个摄像头的实时画面时,可以手动截图或录一小段视频(最长 5 分钟)。旧逻辑是浏览器拿到 canvas / MediaRecorder 的数据后直接触发本地下载,文件只存在用户自己的电脑上,无法统一管理。

新需求:文件存到线上,每个摄像头有自己的截图列表和录像列表,支持查看、改名、批量删除,所有动作要留审计日志。本次只做后端。

约束条件:

  • 平台是 Spring Cloud 微服务,安防业务在独立的 security 服务里;
  • 文件统一走附件服务,业务服务不直接管文件存储;
  • 多园区多租户,业务表普遍带 park_id / tenant_id
  • 持久层是 MyBatis,配一套框架自带的通用 mapper。

设计决策

独立建表,不复用告警图片表

系统里已有一张 AI 告警截图表,但那是告警业务的产物:字段、生命周期、权限口径都不一样。强行复用会把两个业务耦在一起,往后谁都改不动。所以新建 security_video_recordsecurity_screenshot_record 两张表,只存业务关系——摄像头快照、保存名、业务时间、园区租户和审计字段。文件本体仍由附件服务管理,业务表通过 file_id / file_url 关联,不重复承担存储职责。

权限:创建者 + 园区 + 租户,超管兜底

普通用户只能看到并操作「本人在当前园区、当前租户下创建」的记录;超级管理员全开。创建时还要校验用户对该摄像头的实时查看权限,直接复用监控树同一套可见性规则(默认可见范围与显式授权取并集),不另起炉灶。

删除接口:单条和批量合并成一个

删除第一版只支持单条 delete?id=。后来要加批量时,没有新开端点,而是把原端点改成:

1
POST /videoRecord/delete?ids=id1,id2,id3

逗号分隔,单条删除就是 1 个元素的批量。理由是前端尚未集成、没有兼容性包袱;一个入口两种用法,删掉了一批重复代码。

批量语义定成 all-or-nothing:任一条不存在、已删除或当前用户无权操作,整批失败,一条都不删。实现上只有两条 SQL(IN 查询校验 + IN 软删更新),加上 @Transactional,并设了单次 500 条的上限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Transactional(rollbackFor = Exception.class)
public void deleteBatch(String ids) {
List<String> idList = parseIds(ids); // 去空、去重、上限 500
// ...
}

void deleteBatch(List<String> idList, BaseUser user) {
List<VideoRecordDTO> records = videoRecordMapper.selectActiveByIds(idList);
Assert.isTrue(records.size() == idList.size(), "录像不存在或已删除");
boolean allOwned = records.stream()
.allMatch(record -> isOwnerInCurrentScope(record, user));
Assert.isTrue(allOwned || isSuperAdmin(user.getUserId()), "无权操作该录像");

Assert.state(videoRecordMapper.softDeleteByIds(
idList, user.getUserId(), user.getRealName(), new Date()) == idList.size(),
"录像删除失败");
}

一个顺手的优化:allMatch || isSuperAdmin 短路——全部都是本人记录时,一次远程超管校验都不用发。

「查看」也值得一个接口

分页列表返回的数据里本来就带文件地址,前端理论上可以直接打开。但产品要求查看动作留审计日志,所以单独做了 GET /view/{id}:列表只管展示,点开详情必须过 view 接口,权限校验和日志都收在这里。

整体调用链:

浏览器经网关访问安防服务,文件走附件服务,记录走 MySQL
图 1:创建/查看一条记录的调用链。文件本体与业务记录分离,软删除只动数据库标记。

接口与表

录像和截图两组接口完全对称,各 5 个:

功能 方法与路径 说明
分页列表 POST /xxxRecord/paging 按摄像头、名称、时间范围过滤
创建 POST /xxxRecord/create multipart 上传,校验摄像头权限
查看 GET /xxxRecord/view/{id} 写查看日志
改名 POST /xxxRecord/updateName 只改保存名
删除(单/批) POST /xxxRecord/delete?ids= 软删除,写删除日志

保存名不传时后端自动生成「摄像头名称_时间」,并对超长摄像头名做了截断保护,避免顶到 255 列宽。

踩坑记录

放大镜下检查 XML 括号与数据库连接的调试场景插画
图 2:这次的问题几乎都藏在「测试覆盖不到的地方」。

1. 分页拦截器遇到 NULL 参数直接炸

现象:超管分页正常,普通用户分页 500「系统繁忙」。

根因:普通用户查询时部分过滤参数为 NULL,分页插件重绑参数时推断不出 JDBC 类型,抛 TypeException

修复:手写 XML 里所有可空参数补 jdbcType:

1
AND `tenant_id` &lt;=&gt; #{tenantId,jdbcType=VARCHAR}

教训:超管不炸的分支,普通用户可能炸——权限分支不同,参数为空的情况也不同,测试必须覆盖普通用户路径。

2. 通用 mapper 的 OGNL 老坑

框架通用 insert 的 XML 用 OGNL 判空,非空 Date 字段会被渲染成 date != '',直接解析报错(项目老坑,之前已经咬过人)。这次新增记录干脆不用通用 insert,自己在 XML 里写 insert 绕开。团队约定也因此加了一条:任何 service 入口,动手前先一路 trace 到 mapper XML 再确认保存安全。

3. 单测全绿,服务起不来

改 mapper XML 时,一次编辑吞掉了 </update> 闭合标签。单元测试(Mockito)根本不加载 XML,21 个测试全部通过;Spring 启动解析 XML 时才抛 SAXParseException

教训:单测覆盖不到 XML 语法。现在改完 mapper XML,先做一次 well-formed 校验,再重启服务。

4. distinct() 把测试数据折叠了

给「单次最多 500 条」写测试时,用 Collections.nCopies(501, "id") 造了 501 个 ID——结果被测方法里有个 distinct(),501 个相同 ID 去重成 1 个,上限校验根本不触发,测试假性通过。改成 501 个互不相同的 ID 才真正测到拒绝路径。构造测试数据时,要顺着被测代码的每一步处理想一想数据会变成什么样。

5. PowerShell 变量名吞掉了问号

E2E 脚本里拼 URL:

1
curl "http://.../view/$id?devDebug=127.0.0.1"

PowerShell 把 id?devDebug 整体当成一个变量名(? 是合法变量名字符),插值结果是空,实际请求打到了 view/=127.0.0.1,返回「记录不存在」。一度以为是权限 bug,翻操作日志才发现请求路径本身就不对。PowerShell 里拼 URL 必须写成 ${id}?...

验证

  • 单测 21 个:时长校验、默认名生成与截断、权限分支、批量删除各失败路径、ID 解析去重与上限;
  • E2E 走真实网关:超管全功能矩阵、普通用户越权矩阵、批量原子性(混入不存在的 ID,整批失败且已有记录原样保留)、501 条超限拒绝、文件落盘可访问、审计日志落库,全部通过。

遗留项

  • 5 分钟时长限制只看接口传入的起止时间,不解析视频元数据,防不住伪造,要防需引入媒体探测;
  • 文件上传成功但数据库写入失败时会留下孤儿附件,暂无补偿清理任务;
  • 附件服务单文件上限 50MB,高码率录像可能顶到;
  • 附件服务的类型白名单暂不含 webm,后端虽放行,实际会被存储侧拒绝,需要改配置中心的白名单。

音频导读: