文件管理:从一张元数据表,到离线逆地理与纯前端预览
文件管理这个模块,刚接手时我心里的预期是:上传、列表、下载,三天搞定。真做起来才知道,"文件管理"和"网盘"之间差的就是"元数据"三个字。如果只是把文件存下来,它永远是一堆叫不出名字的字节;只有当文件带着"什么时候拍的、用什么设备、在哪、归哪个类"进来,它才变成一个能被检索、被理解的资料库。
数巢私有云的文件模块(dn_file_*)我把它拆成三个支点:文件夹树负责组织结构,文件元数据负责结构化信息,标签负责横向分类。在这三样之上,又叠了 EXIF 自动抽取、离线逆地理、纯前端预览。下面把这趟ai写代码的过程从头捋一遍,重点放在几个容易被忽略、但决定了"好不好用"的设计点上。





一、三张表,但标签不拆关联表
三张表分工很清晰:

-
dn_file_folder文件夹,经典的
parent_id + ancestors树结构(和 RuoYi 自带的部门表同款)。它比普通树表多了一个dir_path字段------存的是服务器上的真实目录路径,专门给"扫描导入"用(第五节能看到)。 -
dn_file_meta文件元数据,核心表。除了文件名、大小、MIME、类型这些基本项,我还特意留了一组"照片属性"字段:
camera_model、gps_lat/gps_lng、image_width/image_height、file_time,以及province/city/district/address。 -
dn_file_tag标签主数据,很薄,只管
name和展示色color。
这里有个我纠结过的取舍:一个文件可以打多个标签,但我没用"文件-标签"中间表,而是直接在dn_file_meta.tags里用逗号分隔存标签1,标签2。
当初也犹豫过------中间表才是教科书答案。但想清楚查询场景后我选了冗余:
-
这个系统里"按标签筛文件"是高频操作,中间表意味着
join + group by,而冗余存储一行就能搞定:sql and concat(',', coalesce(tags, ''), ',') like concat('%,', #{tagName}, ',%')前后包逗号是为了避免
旅游命中旅游照这种前缀误伤。 -
代价是标签改名要更新多行、也没法高效做"每个标签各有多少文件"的聚合统计。但这个系统几乎不做这类统计,写入时顺手维护一下
dn_file_tag主数据、保证筛选下拉不缺项就行。
取舍的本质是:查询模式决定了存储结构。标签查询远多于标签统计,那就往"查得快"那头倾斜。
二、上传不只是落盘,顺手把元数据抽了
上传前端用的是 RuoYi 现成的 bootstrap-fileinput,uploadAsync:false 整批提交,maxFileCount 设到 200,上传时把当前选中的 folderId 一起带过去。
后端收到文件后,先走通用封装 FileUploadUtils.upload 落到 /profile/dn/... 目录,然后做了一件很多人会漏的事------对所有文件统一调一次ExifUtils.readMeta抽元数据,不管它是不是图片:
ExifUtils.Meta exif = ExifUtils.readMeta(abs);if (exif.fileTime != null) fileTime = exif.fileTime;if (exif.cameraModel != null) cameraModel = exif.cameraModel;gpsLat = exif.gpsLat; gpsLng = exif.gpsLng;// ...分辨率、省市
为什么"所有文件都抽"而不是"只在图片上抽"?因为带元数据的远不止 jpg:mp4 有拍摄时间和 GPS,pdf 有创建时间。抽不到就留空,异常全部静默吞掉------这点很关键,否则一个损坏的缩略图就能让整批上传失败。上传完成后 file_time 默认取拍摄时间,抽不到才回落到 new Date()(入库时间)。
三、EXIF 提取:metadata-extractor 的几个坑
抽元数据用的是 com.drew.metadata(metadata-extractor)。代码不长,但有好几个容易写错的地方:
-
拍摄时间:优先取
ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL(拍照那一刻),很多相机只写了TAG_DATETIME(文件修改时间),所以取不到就回退。 -
设备名:
ExifIFD0Directory里Make(品牌)和Model(型号)经常重复,比如某些机型会拼出 "Canon Canon EOS...",所以代码里判断model是否已包含make,包含就只留model,否则make + " " + model。 -
分辨率:先取 EXIF 里的宽高,取不到再用
JpegDirectory兜底(有些 jpg 的分辨率只在 JPEG 段里)。 -
GPS:
GpsDirectory.getGeoLocation()直接给经纬度,比自己解析度分秒省事太多。
最稳的一点设计是:readMeta 对任何异常都返回"字段全空的对象"而不是抛错。文件管理这种"来者不拒"的场景,健壮性比"精确报错"重要。

四、坐标变地名:一个不联网的逆地理
GPS 有了,但 22.55, 114.06 远不如"广东 / 深圳"有用。这一步我本来想接高德或百度的逆地理 API,转念一想不对------私有云的底色就是离线、不把数据传出去,把用户照片的坐标发给第三方地图服务,既违背定位也引入外部依赖。
于是内置了一份 china-geo.dat(全国省级行政区 + 数百个地级市的边界多边形数据),GeoUtils 做成单例,首次调用时从 classpath 加载进内存。逆地理的算法是个有意思的点:
-
射线法(point-in-polygon)判定落在哪个省:从目标点向右发一条水平射线,与省边界多边形交点数为奇数就在省内。
-
一旦命中某省,只在省内找最近的地级市(按平方距离比较,够用且省算力)。
-
如果落在所有省之外(比如境外坐标),回退到"几何中心最近的省"------这样像深圳靠近香港这种点,不会被人误判成"香港"或邻省城市,而是稳妥归广东。
为什么不直接用"全局最近城市"?因为最近的城市会跨行政区误判。射线法先定省、再省内找最近市,层级清晰,也更符合人们对"归属地"的直觉。
五、扫描导入:把现成的目录"登记"进来
上传是"把文件交给我",扫描导入是"你服务器上那个目录,我登记一下"。它递归扫指定目录,自动建文件夹、建文件记录、抽 EXIF、做逆地理------但不复制原文件,file_path 直接存服务器绝对路径。
为了上传和扫描两套路径能共用一套下载/预览逻辑,我写了个 DnFileUtils.resolveAbsolute:相对路径(/profile/...)拼上 RuoYiConfig.getProfile(),绝对路径(扫描导入产生)原样返回。下载和预览都走它,不需要两套代码。
两个小设计值得记一下:
-
去重:
checkFileExists(folderId, origName)同一文件夹下同名文件跳过,重复点"扫描"不会刷出一堆重复记录。 -
文件夹查重后递归:扫到子目录先按
parent + name查,没有才建,再往下递归。
踩过的一个坑现在还记着:扫描完成后表格卡在"正在加载",左侧树也不刷新。根因是我太勤快------自定义回调里手滑调了 .table.search(),而 .operate.post 成功后会先跑你的回调、再无条件跑自己的ajaxSuccess(里面也会刷新表格)。两次刷新竞争,把 bootstrap-table 的遮罩锁死了。修法就一句:回调里别去刷新表格,框架的活交给框架。

六、多条件检索:把"找文件"做成带索引的查询
列表页的筛选给了五个维度:文件名、文件类型、标签、文件时间、省市。后端 selectDnFileMetaList 用 MyBatis 动态 SQL 拼,几个写法是我特意定的:
-
标签:就是第一节那个逗号包裹法。
-
省市:
province / city / address三列OR模糊匹配,输"深圳"能命中省为空但市为深圳的,也能命中地址里含深圳的。 -
时间范围:
file_time >= concat(#{beginTime}, ' 00:00:00'),拼上时分秒,concat写法在 H2 和 MySQL 都通用(不拼时间就只能比日期)。 -
排序:
order by coalesce(file_time, create_time) desc------EXIF 时间为空时回落到入库时间,列表永远有合理的 chronological 顺序。 -
类型分类:
guessFileCat按扩展名把文件归到image/video/audio/doc/archive/installer/other,guessMimeType同理推真实 MIME。纯查表式实现,没引入任何类型探测库。
七、在线预览:纯前端组件 + 一个会发 Range 的后端
最后是预览。需求很朴素:浏览器里直接看 PDF/图片/Office,别让用户下载下来再打开。
前端接的是 flyfish-file-viewer 这个纯前端预览组件(在 Worker 里渲染,不依赖后端转格式)。引入它的 iife 包后,挂载就一行:
window.FlyfishFileViewerWebFull.mountViewer(container, { url: prefix + "/view/" + row.id + "/" + encodeURIComponent(name), options: { theme: 'light' }});
预览地址 /view/{id}/{fileName} 有个小心机:URL 末段带原文件名(含扩展名),是给预览组件按类型识别用的;真实文件只靠 id 从库里定位。前端永远拿不到真实路径,从根上杜绝路径遍历。
后端 streamFile 才是这节的重点,它得像个正经的静态文件服务一样专业:
-
支持 HTTP Range:pdf.js 这类组件会按字节区间分段拉取,必须返回
206 Partial Content+Content-Range,否则它拉到一半就主动断开连接。代码里用RandomAccessFile按start/endseek 后只写那段。 -
预览用 inline、下载用 attachment:
Content-Disposition区分开;MIME 优先用记录里的mimeType,避免一路octet-stream让预览组件认不出类型。 -
一个踩出来的雷:客户端拉够所需字节就断开 → 服务端写出阶段抛
ClientAbortException→ 它从void接口逃逸 → RuoYi 全局异常处理器想以application/pdf的 Content-Type 把一个AjaxResult序列化出去 → 又抛HttpMessageNotWritableException。两步修:支持 Range 让断开变干净;写出阶段的IOException直接catch吞掉(只打 debug 日志),绝不让它跑到异常处理器。 -
安全边界:
/view/**在 Shiro 里配成匿名(anon)。原因很现实------预览组件在 Worker 内fetch不携带会话 cookie,若要求登录会被 302 到登录页,前端表现成net::ERR_FAILED。安全不靠登录态,而靠"只能按 id 取文件、前端无路径"这条硬约束。
八、这个模块教会我的
回头看,文件管理真正难的不是"存文件",而是让文件"可被找到、可被理解"。几个落地的体会:
-
元数据是第一公民。把 EXIF、GPS、标签在入库那一刻就结构化好,后面检索和展示都是白捡的。
-
离线优先。EXIF、逆地理、预览都尽量不依赖外网,这是私有云该有的样子。
-
框架的钩子别抢活。扫描导入那个卡死,根子是我没吃透
$.operate.post的回调机制,多此一举刷新了一次。
下载安装包shuchao.zip(windows)解压后一键运行体验:
https://pan.baidu.com/s/1qdaodlVBz-hVom27OJcFcA?pwd=ezym
下一篇内容发布------怎么让不懂技术的同事也能往你的私有云上挂一个网站。