03-文件管理模块-一个看似简单的模块藏着多少细节

文件管理:从一张元数据表,到离线逆地理与纯前端预览

文件管理这个模块,刚接手时我心里的预期是:上传、列表、下载,三天搞定。真做起来才知道,"文件管理"和"网盘"之间差的就是"元数据"三个字。如果只是把文件存下来,它永远是一堆叫不出名字的字节;只有当文件带着"什么时候拍的、用什么设备、在哪、归哪个类"进来,它才变成一个能被检索、被理解的资料库。

数巢私有云的文件模块(dn_file_*)我把它拆成三个支点:文件夹树负责组织结构,文件元数据负责结构化信息,标签负责横向分类。在这三样之上,又叠了 EXIF 自动抽取、离线逆地理、纯前端预览。下面把这趟ai写代码的过程从头捋一遍,重点放在几个容易被忽略、但决定了"好不好用"的设计点上。

一、三张表,但标签不拆关联表

三张表分工很清晰:

  • dn_file_folder

    文件夹,经典的 parent_id + ancestors 树结构(和 RuoYi 自带的部门表同款)。它比普通树表多了一个 dir_path 字段------存的是服务器上的真实目录路径,专门给"扫描导入"用(第五节能看到)。

  • dn_file_meta

    文件元数据,核心表。除了文件名、大小、MIME、类型这些基本项,我还特意留了一组"照片属性"字段:camera_modelgps_lat/gps_lngimage_width/image_heightfile_time,以及 province/city/district/address

  • dn_file_tag

    标签主数据,很薄,只管 name 和展示色 color

这里有个我纠结过的取舍:一个文件可以打多个标签,但我没用"文件-标签"中间表,而是直接在dn_file_meta.tags里用逗号分隔存标签1,标签2。

当初也犹豫过------中间表才是教科书答案。但想清楚查询场景后我选了冗余:

  1. 这个系统里"按标签筛文件"是高频操作,中间表意味着 join + group by,而冗余存储一行就能搞定:

    sql and concat(',', coalesce(tags, ''), ',') like concat('%,', #{tagName}, ',%')

    前后包逗号是为了避免 旅游 命中 旅游照 这种前缀误伤。

  2. 代价是标签改名要更新多行、也没法高效做"每个标签各有多少文件"的聚合统计。但这个系统几乎不做这类统计,写入时顺手维护一下 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(文件修改时间),所以取不到就回退。

  • 设备名:ExifIFD0DirectoryMake(品牌)和 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 加载进内存。逆地理的算法是个有意思的点:

  1. 射线法(point-in-polygon)判定落在哪个省:从目标点向右发一条水平射线,与省边界多边形交点数为奇数就在省内。

  2. 一旦命中某省,只在省内找最近的地级市(按平方距离比较,够用且省算力)。

  3. 如果落在所有省之外(比如境外坐标),回退到"几何中心最近的省"------这样像深圳靠近香港这种点,不会被人误判成"香港"或邻省城市,而是稳妥归广东。

为什么不直接用"全局最近城市"?因为最近的城市会跨行政区误判。射线法先定省、再省内找最近市,层级清晰,也更符合人们对"归属地"的直觉。

五、扫描导入:把现成的目录"登记"进来

上传是"把文件交给我",扫描导入是"你服务器上那个目录,我登记一下"。它递归扫指定目录,自动建文件夹、建文件记录、抽 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/otherguessMimeType 同理推真实 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 才是这节的重点,它得像个正经的静态文件服务一样专业:

  1. 支持 HTTP Range:pdf.js 这类组件会按字节区间分段拉取,必须返回 206 Partial Content + Content-Range,否则它拉到一半就主动断开连接。代码里用 RandomAccessFilestart/end seek 后只写那段。

  2. 预览用 inline、下载用 attachment:Content-Disposition 区分开;MIME 优先用记录里的 mimeType,避免一路 octet-stream 让预览组件认不出类型。

  3. 一个踩出来的雷:客户端拉够所需字节就断开 → 服务端写出阶段抛 ClientAbortException → 它从 void 接口逃逸 → RuoYi 全局异常处理器想以 application/pdf 的 Content-Type 把一个 AjaxResult 序列化出去 → 又抛 HttpMessageNotWritableException。两步修:支持 Range 让断开变干净;写出阶段的 IOException 直接 catch 吞掉(只打 debug 日志),绝不让它跑到异常处理器。

  4. 安全边界:/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​​​​​​​

下一篇内容发布------怎么让不懂技术的同事也能往你的私有云上挂一个网站。

相关推荐
gugucoding1 小时前
46. 【Java】JUC并发工具:让并发更简单
java·开发语言
Nebula_g2 小时前
JavaSE基础语法:面向对象高级(代码中的成分)
java·开发语言·编程·javase·技术栈·高级语法
AC赳赳老秦2 小时前
公开图片 OCR 数据提取:OpenClaw 合规采集公开信息图,识别文字与表格并转化为结构化数据
java·大数据·前端·数据库·python·php·openclaw
小强库计算机毕业设计4 小时前
SpringBoot+Vue3 学生宿舍管理系统实战
java·spring boot·后端·vue·学生宿舍管理系统
李冰然4 小时前
深入Java集合框架:HashMap源码解析(JDK 8)
java
人道领域5 小时前
【0-1的agent进阶篇】RAG:从Embedding到检索增强生成的底层逻辑
java·embedding·agent·rag
桦说编程6 小时前
盘点并发集合里那些容易误判的行为
java·后端·性能优化
IT爱学堂7 小时前
java版数据结构和算法+AI算法和技能学习指南
java·开发语言
evans在进步7 小时前
LeetCode 394:字符串解码——Java 单栈模拟与嵌套解析详解
java·python·leetcode