JavaWeb电子相册毕设实战:Servlet文件上传、MySQL建表与避坑指南
简介一套基于JavaWeb的电子相册系统源码包面向计算机相关专业正在准备毕业设计的学生以及需要项目实战练习的Java学习者。系统采用B/S结构后台基于JSP、Servlet、JDBC数据库使用MySQL包含前台用户界面与后台管理系统。前台支持用户注册、网站介绍、站场动态、用户相册、空间共享与在线交流后台涵盖成员管理、公告管理、图像管理、照片管理、相册管理、管理员信息管理等功能模块项目经过严格调试可直接作为毕设运行。压缩包共3个文件包含项目源码、数据库脚本和项目说明文档整体大小17.28MB部署配置方便。目前已有1303人学习下载这套代码结构清晰、界面简洁适合快速理解JavaWeb开发流程并在此基础上二次扩展是完成电子相册类毕设的高性价比资源。1. 从 0 到答辩 JavaWeb 电子相册先把“能跑”和“能讲清”分开JavaWeb 电子相册这类毕设实际解决的问题很具体用户注册登录后能建相册、上传照片、按列表浏览、点开看大图。你拿到的所谓“项目源码数据库脚本”核心不外乎三样东西——一套 Servlet/JSP 页面、一份 MySQL 建表脚本、一条文件上传与预览的请求链路。别把它想象成需要推荐算法或图像识别的东西它本质上是“把文件落到磁盘把元数据落到数据库再用页面把两者关联起来”。动手之前先想清楚三件事后面几周会省掉大量返工第一图片文件和数据库存什么必须分开第二上传请求要以 multipart 编码解析不能用普通表单那套 getParameter第三图片回显要走单独的二进制输出 Servlet而不是直接拼接磁盘路径。这篇文章按这三条线拆开讲再给一套能直接照抄的最小实现。适合正在做毕设、课程设计的同学也适合想快速补 JavaWeb 后端主链路的人。2. 数据库与存储设计三张表撑起相册文件落磁盘而不是数据库2.1 建表脚本用户、相册、照片三张表的最简字段电子相册的业务关系很清楚一个用户有多个相册一个相册里有多张照片。所以数据库只需要三张表再加外键把它们串起来。不要一上来就设计评论表、点赞表、标签表那些是加分项不是必备项把核心链路跑通之后再考虑扩展否则 ER 图画得漂亮实现的时候会被外键和事务问题反复拖住。下面这份 SQL 是常见的基础版本字段名和注释都贴近课程设计的阅读习惯CREATE DATABASE IF NOT EXISTS electronic_album DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE electronic_album; DROP TABLE IF EXISTS photos; DROP TABLE IF EXISTS albums; DROP TABLE IF EXISTS users; CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(50), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE albums ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, album_name VARCHAR(100) NOT NULL, description VARCHAR(255), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_albums_user FOREIGN KEY (user_id) REFERENCES users(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE photos ( id INT PRIMARY KEY AUTO_INCREMENT, album_id INT NOT NULL, photo_name VARCHAR(150) NOT NULL, photo_path VARCHAR(255) NOT NULL, thumbnail_path VARCHAR(255), file_size INT, content_type VARCHAR(50), upload_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_photos_album FOREIGN KEY (album_id) REFERENCES albums(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个字段值得说明。photo_name存的是用户上传时的原始文件名比如“毕业照.jpg”只用来在页面上展示真正落盘的文件名会在后端重写成 UUID所以photo_path存的是服务器内部的相对路径。thumbnail_path存缩略图相对路径列表页加载缩略图而不是原图页面的响应速度会明显不一样。file_size用 INT 就够了单张照片上限通常控制在 10MB 以内如果以后要支持原图压缩包再考虑改成 BIGINT。提示外键我刻意没用ON DELETE CASCADE。毕设阶段删除相册功能可以做事务先删 photos 再删 albums最后把磁盘文件一起清理。级联删除虽然省事但在物理文件清理这个环节上帮不了你删完数据库记录留下一堆孤儿文件反而更难看。2.2 文件存储方案为什么照片文件落磁盘而不是写 base64这是答辩时最容易被追问的点也是新手最容易踩坑的地方。常见做法是数据库里只存照片的路径和元数据真正的图片字节流放在服务器的本地目录里。有些人图省事把图片读成 base64 字符串塞进数据库的 TEXT 字段前几次上传看着没问题照片一多就原形毕露。base64 方案至少有四个问题一是数据库体积膨胀三成以上一张 5MB 照片变成约 6.7MB 字符串二是 InnoDB 的聚簇索引不适合存大字段大文本会让索引页变大随机读写性能明显下降三是应用层每次要整段读出字符串再解码内存占用高四是数据库备份和迁移时一个几个 GB 的 .sql 文件会让人崩溃。文件落磁盘数据库只存相对路径备份时把磁盘目录拷走、导出 SQL 时排除照片目录两边都轻松。目录结构按“月份分目录”管理后续排查和维护都方便upload_root/ 2026/ 03/ a1b2c3d4....jpg thumb_a1b2c3d4....jpg 2026/ 04/ ...代码里不要出现写死的绝对路径我把上传根目录放在配置文件里Servlet 启动时读一次public class UploadConfig { public static final String UPLOAD_ROOT; static { // 从 classpath 下的 config.properties 读取 // upload.rootD:/album_upload UPLOAD_ROOT ConfigLoader.get(upload.root); } }开发环境配D:/album_upload部署到 Linux 就改成/home/app/album_upload重启即可生效。把这个点写进论文的“系统部署”一节答辩老师会认为你考虑过真实运行环境而不仅仅是在 IDEA 里能跑。2.3 数据库脚本导出与导入mysqldump 与 IDEA 面板两种方式很多同学拿到源码第一步就卡在数据库脚本上。脚本一般分两种给法一种是schema.sql建表、data.sql插初始数据另一种是一整份 .sql 文件里面既有建库语句又有 INSERT。执行顺序不要错先建库再建表再插数据。数据库名默认按脚本里的CREATE DATABASE来如果你本地已经有一个同名库注意别把之前的数据覆盖了。命令行导出是最可靠的方式也推荐你最终提交源码时用这种方式生成一份干净脚本mysqldump -uroot -p --default-character-setutf8mb4 --no-tablespaces electronic_album electronic_album.sql--default-character-setutf8mb4保证中文不乱码--no-tablespaces避免因权限不足导出时报错。导入到本地时用mysql -uroot -p --default-character-setutf8mb4 electronic_album electronic_album.sql如果你习惯用 IDEA 可视化操作在 Database 面板里右键数据库选择 Dump with mysqldump工具会调用你配置的 mysqldump 路径生成脚本。用 IDEA 导出时有一点要注意默认勾选会把视图、函数、事件一起带上对毕设项目没有必要只选表和基础数据就够了。导出之后一定在一个全新数据库上导入验证一遍确认脚本没有依赖你本地独有的库名或账号。3. 后端与页面落地Servlet 处理上传与查询JSP 负责渲染3.1 用 ServletFileUpload 解析上传请求文件落盘与元数据入库传统 JavaWeb 项目里图片上传最常用的方案是 Apache Commons FileUpload配合 Commons IO 使用。先加依赖Maven 项目在 pom.xml 里加两行dependency groupIdcommons-fileupload/groupId artifactIdcommons-fileupload/artifactId version1.4/version /dependency dependency groupIdcommons-io/groupId artifactIdcommons-io/artifactId version2.11.0/version /dependency处理上传的 Servlet doPost 方法完整逻辑如下protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 判断是否 multipart 请求 boolean isMultipart ServletFileUpload.isMultipartContent(request); Integer albumId null; if (!isMultipart) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, 请求不是文件上传格式); return; } try { // 2. 构造解析器并设置限制 DiskFileItemFactory factory new DiskFileItemFactory(); factory.setSizeThreshold(1024 * 1024); // 超过 1MB 写临时文件 ServletFileUpload upload new ServletFileUpload(factory); upload.setHeaderEncoding(UTF-8); // 文件名编码 upload.setFileSizeMax(10 * 1024 * 1024); // 单张照片最大 10MB upload.setSizeMax(50 * 1024 * 1024); // 一次请求总大小 50MB // 3. 解析请求体遍历每个表单项 ListFileItem items upload.parseRequest(request); for (FileItem item : items) { if (item.isFormField()) { // 普通表单字段例如 albumId if (albumId.equals(item.getFieldName())) { albumId Integer.valueOf(item.getString(UTF-8)); } } else { // 文件字段 if (albumId null) { throw new RuntimeException(缺少相册 ID); } savePhoto(albumId, item); } } response.sendRedirect(request.getContextPath() /album/list?id albumId); } catch (FileUploadException e) { request.setAttribute(msg, 上传失败文件可能超过大小限制); request.getRequestDispatcher(/upload.jsp).forward(request, response); } catch (Exception e) { request.setAttribute(msg, 上传失败 e.getMessage()); request.getRequestDispatcher(/upload.jsp).forward(request, response); } }这段代码里setSizeThreshold和setFileSizeMax是两个不同层级的限制前者是内存阈值小文件直接驻留内存大文件先落临时目录后者是单文件的大小上限。parseRequest返回的FileItem列表里普通表单字段和文件字段混在一起必须用isFormField()区分。另外注意item.getString(UTF-8)这里显式指定编码否则中文表单值容易乱码这一点第 4 章排查部分会展开。savePhoto方法负责落盘和入库private void savePhoto(Integer albumId, FileItem item) throws Exception { // 1. 拿到原始文件名并提取扩展名 String fileName FilenameUtils.getName(item.getName()); String ext fileName.substring(fileName.lastIndexOf(.)).toLowerCase(); ListString allowExt Arrays.asList(.jpg, .jpeg, .png, .gif, .bmp); if (!allowExt.contains(ext)) { throw new RuntimeException(不支持的图片格式: ext); } // 2. 生成 UUID 文件名按 yyyy/MM 目录存放 String uuidName UUID.randomUUID().toString().replace(-, ) ext; String dateDir new SimpleDateFormat(yyyy/MM).format(new Date()); File dir new File(UploadConfig.UPLOAD_ROOT, dateDir); if (!dir.exists()) { dir.mkdirs(); } File storeFile new File(dir, uuidName); item.write(storeFile); // 3. 生成缩略图 String thumbName thumb_ uuidName; File thumbFile new File(dir, thumbName); createThumbnail(storeFile, thumbFile, 300, 300); // 4. 数据库只存相对路径 String relPath dateDir / uuidName; String thumbRel dateDir / thumbName; PhotoDao dao new PhotoDao(); dao.insert(albumId, fileName, relPath, thumbRel, item.getSize(), item.getContentType()); }FilenameUtils.getName用来去掉 IE 或旧版 Chrome 在上传时附带的完整客户端路径否则存进库里的名字会变成C:\fakepath\毕业照.jpg。落盘文件名用 UUID 重命名目的有三个避免中文名在跨平台部署时编码出问题避免不同用户上传同名文件相互覆盖避免文件名里带路径分隔符或特殊字符。按月份分目录则是为了让物理文件不至于全部堆在一个目录里文件数量大了之后文件系统访问效率会下降。3.2 用 ImageIO 生成缩略图列表页不卡的关键一步如果列表页直接加载原图一张 4000×3000 像素、5MB 的照片在网络和浏览器端都会被拖慢。缩略图本质上就是“服务端先把大图压成小图浏览器只拿小图”。Java 标准库里的ImageIO和Graphics2D足够完成这件事不需要额外引入 Thumbnailator 等第三方库。private void createThumbnail(File src, File dest, int maxWidth, int maxHeight) throws IOException { BufferedImage image ImageIO.read(src); if (image null) { throw new IOException(无法解析该图片可能不是标准图片格式); } int w image.getWidth(); int h image.getHeight(); if (w maxWidth h maxHeight) { // 原图本身很小直接复制 Files.copy(src.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING); return; } double scale Math.min((double) maxWidth / w, (double) maxHeight / h); int newW (int) (w * scale); int newH (int) (h * scale); BufferedImage thumb new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB); Graphics2D g2d thumb.createGraphics(); // 先把底色填白避免透明 PNG 转 JPG 后变黑底 g2d.setColor(Color.WHITE); g2d.fillRect(0, 0, newW, newH); g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2d.drawImage(image, 0, 0, newW, newH, null); g2d.dispose(); ImageIO.write(thumb, jpg, dest); }缩略图的尺寸我一般设 300×300注意这里不是硬性裁剪而是按比例缩放后取较小边所以宽的横图和高的竖图都不会变形。ImageIO.read返回 null 的情况经常出现在“把 txt 改后缀成 png”这种伪装文件上正好作为格式校验的一层防线。这里统一把缩略图写成 jpg是为了让 Content-Type 固定前端好处理代价是带透明通道的 PNG 会丢透明层所以我先 fillRect 铺白底。注意ImageIO是标准库但缩略图生成是 CPU 密集型操作。上传时同步生成缩略图在毕设场景完全够用如果以后照片量大了再考虑上传后异步生成那是后话。3.3 用 EL JSTL 渲染相册列表缩略图走独立 Servlet页面部分不需要写复杂的前端框架JSP 配 JSTL 和 EL 表达式是 JavaWeb 项目完整案例里最常见的组合。列表页核心就是一个 forEach 循环% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % c:forEach items${photoList} varp div classphoto-card a href${pageContext.request.contextPath}/photo/view?id${p.id} title查看原图 img src${pageContext.request.contextPath}/photo/thumbnail?id${p.id} alt${p.photoName} /a p classphoto-name${p.photoName}/p p classphoto-time fmt:formatDate value${p.uploadTime} patternyyyy-MM-dd HH:mm/ /p /div /c:forEach这里的关键设计是img的 src 不直接指向磁盘路径而是指向一个 Servlet由 Servlet 从数据库查到该照片的记录再读磁盘文件输出二进制流。前端永远接触不到服务端的目录结构也避免了把数据库里的相对路径直接拼进 URL 可能导致的路径穿越问题。对应的 Servlet 核心逻辑protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int id Integer.parseInt(request.getParameter(id)); Photo p photoDao.findById(id); if (p null || p.getThumbnailPath() null) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } File f new File(UploadConfig.UPLOAD_ROOT, p.getThumbnailPath()); if (!f.exists()) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } response.setContentType(image/jpeg); response.setHeader(Cache-Control, max-age86400); try (FileInputStream in new FileInputStream(f); OutputStream out response.getOutputStream()) { IOUtils.copy(in, out); } }这样一个/photo/thumbnail?id1的请求先查库、再读文件、输出流逻辑非常直白。Cache-Control缓存头加上之后浏览器第二次查看同一张缩略图会优先用本地缓存列表翻页不会频繁请求服务器。此处省略了登录权限拦截要留到第 5 章自测时补上只按 id 读文件意味着任何登录用户都能拼 id 看其他人的照片这是越权漏洞毕设虽然没人攻击你但答辩老师问到权限问题时容易露怯。4. JavaWeb 电子相册避坑与排查从上传 404 到数据库脚本报错的 5 个现场4.1 现象上传后照片显示正常重启项目或执行 clean 后全部消失照片列表里显示一张张裂图去磁盘目录一看upload 文件夹是空的但数据库里记录还在。原因几乎都是把上传根目录放在了项目输出目录内比如写在webapp/upload/下。IDEA 以 war exploded 方式部署时每次 rebuild 或 clean 都会把 target/ 下的产物清空重来物理文件就这么没了。解决方法是把上传目录放到项目外部。我在config.properties里配一个绝对路径代码里只依赖UploadConfig.UPLOAD_ROOT开发用D:/album_upload生产环境改配置即可。明确一点上传目录不该随应用包走它属于运行数据不属于程序本身。这个处理方式同时解决了另一个问题——以后导出 war 包时不会因为误打包本地测试图片把包体积撑到几百 MB。4.2 现象上传“毕业照.jpg”页面上显示的名字变成乱码乱码的锅不在数据库多半在请求解析阶段。multipart 请求的表单体是二进制流request.setCharacterEncoding(UTF-8)对普通表单字段有效但对文件名字段不生效。Commons FileUpload 解析文件名时需要显式告诉它编码。解决分两步第一在ServletFileUpload上调用upload.setHeaderEncoding(UTF-8)这会让浏览器传来的文件名按 UTF-8 解码第二读取普通字段时写item.getString(UTF-8)不要用无参版本。另外库里字符集是 utf8mb4JSP 页面头部也要声明pageEncodingUTF-8。三处编码一致中文名才能从头到尾不乱。提示排查乱码时最快的定位方式是先看 MySQL 里的photo_name字段如果库里正常、页面乱码问题在 JSP 响应编码如果库里就是乱码问题在请求解析或 JDBC 连接串。连接串记得加characterEncodingutf8参数。4.3 现象上传一张 3MB 照片还没进 doPost 就被 Tomcat 拒绝后台日志常见这么一句The request was rejected because its size ... exceeds the configured maximum。很多人的第一反应是去调 commons-fileupload 的setFileSizeMax调到 100MB 也没用因为请求在到达 Servlet 之前就被 Tomcat 的 Connector 拦下了。Tomcat 的maxPostSize默认是 2MB超过就直接拒绝整个请求。解决方法是改 Tomcatconf/server.xml在 Connector 上加一个参数Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 maxPostSize0 /maxPostSize0表示不限制请求体大小不同版本对负数支持不一致一律用 0 最稳。关掉这一层之后再让 commons-fileupload 的setFileSizeMax去限制单张照片大小这样层级才正确Tomcat 只负责接请求业务层负责业务规则。改完 server.xml 要重启 Tomcat这个坑最隐蔽很多同学折腾半天代码结果问题在容器配置上。4.4 现象IDEA 里运行正常导出 war 部署到另一台机器就类找不到报错通常是ClassNotFoundException: com.mysql.cj.jdbc.Driver或者NoClassDefFoundError。IDEA 运行项目时它会自动把依赖 jar 加到 classpath所以你本地跑得欢但导出 war 包时如果依赖没有打进去部署到外置 Tomcat 就现原形了。排查从 war 包内容入手。用压缩工具打开 war看WEB-INF/lib/下有没有 mysql-connector 的 jar。Maven 项目最隐蔽的坑是依赖写了scopeprovided/scope比如想当然地把 JDBC 驱动标成 provided它就不会被装进 war。手动管理 jar 的项目检查 IDEA 的 Artifacts 配置确认 Output Layout 里有没有把 commons-fileupload、mysql-connector 这些 jar 拖进 WEB-INF/lib。另外注意驱动类名MySQL 8.x 的驱动是com.mysql.cj.jdbc.Driver如果你的 jar 是 5.x 而类名写成了 cj同样会类找不到反过来jar 是 8.x 却沿用了 5.x 的com.mysql.jdbc.Driver会被警告。4.5 现象导入数据库脚本报错 Unknown collation: utf8mb4_0900_ai_ci从一台 MySQL 8.0 导出的建表脚本拿到本地 MySQL 5.7 上导入经常报这个错。utf8mb4_0900_ai_ci是 MySQL 8.0 默认的排序规则5.7 根本不认识这个名称。解决有两个方向。最快的用文本编辑器打开 .sql 文件把utf8mb4_0900_ai_ci全局替换成utf8mb4_general_ci保存后再导。更稳的统一两台机器的 MySQL 大版本导出时在 mysqldump 命令里指定--default-character-setutf8mb4生成脚本里就不会带 0900 的排序规则前提是目标库版本也别太旧。用 IDEA 导出面板时选库注意看右上方的高级选项字符集选 utf8mb4 再导出。5. 从能跑到好讲电子相册自测清单与答辩追问5.1 功能自测清单把“我做完了”换成“我测过了”很多项目当场翻车不是因为哪个难点没过而是最基本的边界没测。我习惯在交之前跑一遍自测表每一项都按真实用户的操作路径来测试项操作步骤预期结果注册登录注册新用户、退出、再登录错误密码被拦截正确密码进入列表页创建相册新建名为“旅行 2026”的相册列表出现记录刷新后仍在单图上传选择 jpg 照片上传列表出现缩略图点开可看原图中文文件名上传“我的毕业照(1).jpg”页面正常显示中文名后台无乱码超限文件上传 15MB 文件页面提示大小超限列表不出现残记录伪装图片把 txt 改为 png 后缀上传被拒绝磁盘不落文件数据库无记录分页边界造 25 张照片每页 10 条一共 3 页第 3 页正好 5 条未登录访问退出后直接访问 /photo/thumbnail?id1被 Filter 拦截跳转登录页数据库还原在新库执行 sql 后启动项目默认账号可登录三个表数据完整执行顺序也有讲究先测正常流程再测异常流程。超限文件、伪装图片这两条能暴露你在savePhoto里写的校验到底有没有拦到位。分页边界则验证 page 参数和总页数计算的数学有没有写错。数据库还原那条建议放到最后做因为要清掉当前库重新导入但它是源码包完整性的最终验证。5.2 答辩高频追问文件路径、分页 SQL、登录态答辩老师不一定会把你的项目跑起来看但他一定会问几个“为什么”。第一个高频问题是为什么文件不存数据库你要答出两层数据库里的 BLOB/TEXT 大字段会让 InnoDB 聚簇索引膨胀随机 IO 性能下降文件存磁盘后系统备份策略清晰——数据库做逻辑备份照片目录做文件备份两边互不影响。能提到“聚簇索引”这四个字比干巴巴说“数据库会很大”要有说服力得多。第二个问题是分页怎么做。基础回答是LIMIT offset, size但这还不够把页数计算公式一并讲出来总页数(int) Math.ceil(totalCount * 1.0 / pageSize)然后指出 offset (currentPage - 1) * pageSize。如果老师追问“数据量大了性能怎么优化”你要答深分页不推荐继续加大 offset可以改成 WHERE id ? 的主键游标方式。这一句是明显的加分回答。第三个问题是登录态。Session 配合 Filter 拦截是最正统的 JavaWeb 方案用户登录后把 userId 放进 sessionFilter 检查每个受保护路径session 里没有就跳登录页。密码不能明文存库存加盐后的哈希值。如果答辩老师手快点开数据库看到一列明文密码印象分会立刻掉一截——这个细节比功能本身更显工程素养。5.3 两个不依赖外网的加分扩展批量上传与 zip 打包下载毕设想从“能跑”升到“能讲”不建议引入太重的中间件两个小而实用的扩展就够了。批量上传的实现成本极低前端把input typefile加上multiple属性后端代码完全不用改因为parseRequest返回的ListFileItem本来就天然支持一个请求里多个文件。唯一要注意的是事务粒度每张照片各自落盘各自 insert如果第 3 张失败前面 2 张的照片文件和数据库记录都已经提交了。要处理一致性就在循环里包一层事务失败时回滚并删除已落盘的文件保证“要么全部成功要么全部不出现”。zip 打包下载是另一个被答辩老师喜欢的扩展核心代码很集中String zipName URLEncoder.encode(albumName .zip, UTF-8); response.setContentType(application/zip); response.setHeader(Content-Disposition, attachment; filename\ zipName \); try (ZipOutputStream zos new ZipOutputStream(response.getOutputStream())) { ListPhoto photos photoDao.listByAlbumId(albumId); for (Photo p : photos) { File f new File(UploadConfig.UPLOAD_ROOT, p.getPhotoPath()); zos.putNextEntry(new ZipEntry(p.getPhotoName())); Files.copy(f.toPath(), zos); zos.closeEntry(); } }Content-Disposition的 filename 要经过 URL 编码否则中文相册名下载下来可能乱码。这个扩展的优点是它把前面所有设计串起来了——查数据库拿路径、拼磁盘文件、写输出流整条主链路再复习一遍。6. 一个具体技巧用 EXIF 拍摄时间生成时间线相册6.1 读 DateTimeOriginal 与分组展示的落地写法相册如果按照片的拍摄时间排列比按上传时间排列更贴近使用场景。手机和数码相机在拍照时会写入 EXIF 信息其中DateTimeOriginal就是拍摄时间它藏在 JPEG 的元数据里用 JRE 自带的 ImageIO 就能读出来不需要引入第三方库。读取拍摄时间的工具方法private String readExifDate(File file) { try (ImageInputStream iis ImageIO.createImageInputStream(file)) { IteratorImageReader readers ImageIO.getImageReaders(iis); if (!readers.hasNext()) { return null; } ImageReader reader readers.next(); try { reader.setInput(iis, true, true); IIOMetadata meta reader.getImageMetadata(0); Element root (Element) meta.getAsTree(javax_imageio_exif_1.0); return findTag(root, DateTimeOriginal); } finally { reader.dispose(); } } catch (Exception e) { return null; } } private String findTag(Element node, String tagName) { if (tagName.equals(node.getNodeName())) { return node.getAttribute(value); } NodeList children node.getChildNodes(); for (int i 0; i children.getLength(); i) { Node n children.item(i); if (n instanceof Element) { String v findTag((Element) n, tagName); if (v ! null) { return v; } } } return null; }拿到的时间字符串是2026:03:15 09:30:00这种格式注意分隔符是冒号不能直接用SimpleDateFormat(yyyy-MM-dd HH:mm:ss)解析要写yyyy:MM:dd HH:mm:ss。数据库里可以给 photos 表加一列take_time上传时读 EXIF 写入读不到的那就回退成upload_time。时间线页面按天分组的核心逻辑是取拍摄时间的字符串截到yyyy-MM-dd作为 group key页面用一个 Map 来放组名和照片列表。排序时优先用拍摄时间排序而不是 id。这里有一个很坑的细节手机上抓到的 EXIF 时间通常是本地时间但某些设备写入的是 UTC如果发现别人的时间线和实际对不上要检查是不是差了 8 小时。之前我给本地相册做时间线时就被这个问题坑过后来统一在读取时尝试判断时区偏移拿不准就直接以手机相册显示的时间为准做偏移值。希望帮到你。本文还有配套的精品资源点击获取