简介:这是一套基于Spring Boot开发的相册管理系统实战项目源码,面向Java后端初学者与Web全栈学习者,聚焦照片与相册的全流程管理需求,适用于课程设计、毕业设计及中小型Web应用开发实践。资源包共96个文件,含33个Java源码(涵盖Controller、Service、Mapper及配置类)、33个编译后class文件、18张JPG格式封面与示例图片、7个XML配置与映射文件、2个YML配置文件(含数据库与全局配置),以及README.md等说明文档,整体压缩包仅4.24MB,轻量易部署。已有364人下载学习,项目结构清晰规范,采用标准Maven分层架构,包含用户注册登录、相册CRUD、照片上传下载、跨域支持、日期格式统一处理等完整功能模块,并集成MyBatis Plus简化数据操作,附带FileUpUtil等实用工具类,开箱即用,便于理解Spring Boot企业级开发模式与常见问题解决方案。
1. 为什么一个「相册管理系统」值得用 Spring Boot 重做一遍?——不是为了存图,而是练透 Web 工程的完整闭环
你手头可能早就有个 Java Web 项目:用 Servlet + JSP 写过上传、用 JDBC 拼过 SQL、用 Tomcat 部署过 WAR 包。但当你真正要交付一个「能上线、可维护、有权限、带缩略图、支持多用户上传且不崩」的相册系统时,你会发现——那些零散技术点根本拼不成一条可用的流水线。而「基于 Spring Boot 的相册管理系统」这个标题,本质是一套被压缩进 ZIP 包里的 Web 工程最小可行闭环训练场:它不追求高并发或分布式,但必须覆盖文件上传与存储路径隔离、数据库事务级元数据管理(照片名/时间/所属用户/标签)、RESTful 接口设计规范、前端资源静态托管、登录态鉴权(哪怕只是 Session)、以及最关键的——本地磁盘写入安全边界控制。我带新人做实战时,90% 的人卡在「上传大图后服务假死」「删图时连带删了别人的照片」「缩略图生成失败却没报错日志」这三类问题上。这个 ZIP 包的价值,不在功能多炫,而在它把所有「看似简单、实则一踩就翻车」的工程细节,全塞进一个可运行、可调试、可打断点的 Spring Boot 项目里。适合刚学完 Spring MVC 想落地、或准备跳槽想补全 Web 全链路经验的 Java 后端开发者。
2. 从 ZIP 解压到启动成功:5 分钟跑通最小可运行版本
拿到基于Spring Boot的相册管理系统.zip后,别急着看源码。先验证它是否真能跑起来——这是后续所有调试和改造的前提。常见误区是直接导入 IDEA 就开干,结果卡在依赖下载失败或端口冲突上。我一般会按以下顺序推进,确保每一步都有明确反馈。
2.1 解压与目录结构确认:识别核心模块位置
解压 ZIP 后,典型目录结构如下(不同作者略有差异,但主干一致):
album-system/ ├── pom.xml ← Maven 核心配置,重点看 spring-boot-starter-web 版本 ├── src/ │ ├── main/ │ │ ├── java/com/example/album/ ← 主包名,注意是否含 mybatis 或 jpa │ │ │ ├── AlbumApplication.java ← 启动类,@SpringBootApplication 注解所在 │ │ │ ├── controller/ ← REST 接口入口,如 PhotoController │ │ │ ├── service/ ← 业务逻辑层,含上传/删除/分页等实现 │ │ │ ├── mapper/ ← MyBatis XML 或注解映射(若用 MyBatis) │ │ │ └── entity/ ← 实体类,如 Photo.java,含 id/name/path/size/userId 等字段 │ │ └── resources/ │ │ ├── application.yml ← 配置文件,关注 server.port、spring.servlet.context-path、spring.datasource │ │ └── static/ ← 前端静态资源(HTML/CSS/JS),通常含 upload.html 和 list.html │ └── test/ ← 单元测试,可暂忽略 └── data/ ← (部分版本含)初始化 SQL 脚本,如 init.sql提示:如果
pom.xml中<parent>指向spring-boot-starter-parent,且版本为2.7.18或3.1.12,说明项目兼容主流 JDK 17/21;若为2.3.12.RELEASE,则需 JDK 8 —— 这直接影响你本地 JDK 版本选择,别硬配错。
2.2 修改 application.yml:绕过数据库和端口两大拦路虎
默认配置往往指向本地 MySQL,但新手常因未装 MySQL 或密码错误导致启动失败。先让服务跑起来,再连数据库。修改src/main/resources/application.yml:
server: port: 8081 servlet: context-path: /album spring: # 关闭数据库自动初始化,避免启动时连不上就崩溃 datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: "" h2: console: enabled: true path: /h2-console # 用 H2 内存库替代 MySQL,启动即建表,无需额外安装 jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop # 每次启动重建表,开发阶段最省心 show-sql: true properties: hibernate: format_sql: true # 文件上传路径设为项目根目录下的 uploads/,避免跨盘符权限问题 album: upload-dir: ${user.dir}/uploads这段配置做了三件事:
- 把端口从默认
8080改为8081,避开常见冲突; - 用 H2 内存数据库替代 MySQL,
ddl-auto: create-drop保证每次启动清空旧表、重建新表,彻底规避建表语句错误; upload-dir使用${user.dir}(即当前工作目录),确保 Windows/macOS/Linux 下路径拼接一致,且无需管理员权限即可写入。
2.3 启动并验证基础接口:用 curl 快速测通链路
在项目根目录执行:
mvn spring-boot:run看到控制台输出Tomcat started on port(s): 8081即成功。立刻用 curl 测试最简接口(不依赖前端页面):
# 1. 查看所有照片(此时应为空数组) curl -X GET "http://localhost:8081/album/api/photos" # 2. 上传一张测试图(需准备一张小于 2MB 的 JPG) curl -X POST "http://localhost:8081/album/api/photos/upload" \ -F "file=@./test.jpg" \ -F "title=测试上传" \ -F "description=第一张测试图" # 3. 再查列表,应返回含 id/title 的 JSON 对象 curl -X GET "http://localhost:8081/album/api/photos"✅ 成功标志:
- 第 1 次返回
[]; - 第 2 次返回
{"code":200,"msg":"上传成功","data":{"id":1,"title":"测试上传",...}}; - 第 3 次返回
[{"id":1,"title":"测试上传",...}],且data/目录下出现uploads/xxx.jpg文件。
参数说明:
-F "file=@./test.jpg"中的@符号表示读取本地文件,路径必须存在;-F参数名需与 Controller 中@RequestParam("file") MultipartFile file的value严格一致(常见坑:前端传photo,后端收file,导致 400 错误)。
3. 文件上传与存储:为什么你的相册总在「删图时删错」、「缩略图不生成」?
相册系统的核心不是 CRUD,而是对二进制文件生命周期的精准控制。Spring Boot 默认的MultipartConfigElement配置极简,但生产环境稍一压测就暴雷。这个 ZIP 包的上传模块,恰恰暴露了三个高频翻车点:路径拼接越界、缩略图线程阻塞、删除时未校验所有权。我们逐个击破。
3.1 上传路径安全加固:防止../路径遍历攻击
原始代码中常见写法:
// ❌ 危险!用户传 filename=../../etc/passwd,将写入系统关键目录 String fullPath = uploadDir + "/" + originalFilename; FileUtils.writeByteArrayToFile(new File(fullPath), bytes);正确做法是剥离原始文件名中的路径符号,强制使用 UUID 重命名:
@Service public class PhotoService { @Value("${album.upload-dir}") private String uploadDir; public String savePhoto(MultipartFile file, String title) throws IOException { // 1. 生成唯一文件名,丢弃原始 name 中所有路径信息 String ext = StringUtils.getFilenameExtension(file.getOriginalFilename()); String newFilename = UUID.randomUUID().toString() + "." + ext.toLowerCase(); // 2. 构建绝对安全路径:uploadDir + File.separator + newFilename Path targetPath = Paths.get(uploadDir, newFilename); // 3. 确保父目录存在(自动创建多级目录) Files.createDirectories(targetPath.getParent()); // 4. 写入文件(推荐用 Files.write,比 FileUtils 更底层可控) Files.write(targetPath, file.getBytes(), StandardOpenOption.CREATE); return newFilename; // 返回相对路径,供数据库存储 } }逻辑说明:
StringUtils.getFilenameExtension()来自 Spring 的org.springframework.util.StringUtils,比originalFilename.substring(originalFilename.lastIndexOf("."))更安全(防无扩展名);Files.createDirectories()自动创建uploads/2024/06/这类子目录,避免单目录下文件过多;StandardOpenOption.CREATE明确指定仅创建新文件,防止覆盖。
3.2 缩略图异步生成:别让上传接口卡住 3 秒
同步生成缩略图会导致上传接口响应变慢,用户点击上传后干等。ZIP 包中若用BufferedImage直接处理,大概率在 Controller 里写:
// ❌ 同步阻塞,上传接口耗时 = 原图读取 + 缩略图生成 + 原图写入 BufferedImage original = ImageIO.read(file.getInputStream()); BufferedImage thumbnail = Scalr.resize(original, 200); // 200px 宽 ImageIO.write(thumbnail, "jpg", new File(thumbPath));改为异步任务,用 Spring 的@Async:
@Service public class ThumbnailService { @Async // 必须配合 @EnableAsync 在启动类上启用 public void generateThumbnail(String originalPath, String thumbPath) { try { BufferedImage original = ImageIO.read(new File(originalPath)); if (original == null) return; // 保持宽高比缩放,最大边长 200px int width = original.getWidth(); int height = original.getHeight(); double ratio = Math.min(200.0 / width, 200.0 / height); int newWidth = (int) (width * ratio); int newHeight = (int) (height * ratio); BufferedImage thumbnail = Scalr.resize(original, Scalr.Method.QUALITY, newWidth, newHeight, Scalr.OP_ANTIALIAS); // 写入缩略图(格式强制为 jpg,避免 png 透明通道问题) ImageIO.write(thumbnail, "jpg", new File(thumbPath)); } catch (Exception e) { log.error("生成缩略图失败: {}", originalPath, e); } } }在PhotoService中调用:
// 上传原图后,立即触发异步任务 String thumbPath = uploadDir + "/thumbs/" + newFilename; thumbnailService.generateThumbnail(fullPath, thumbPath);参数说明:
Scalr.Method.QUALITY比SPEED画质更好,适合相册;Scalr.OP_ANTIALIAS开启抗锯齿,文字边缘更平滑;thumbPath必须提前创建thumbs/目录,否则ImageIO.write会抛FileNotFoundException。
3.3 删除操作所有权校验:防止 A 用户删掉 B 的照片
这是权限漏洞的重灾区。ZIP 包中若只根据photoId删除,没校验userId,后果严重:
// ❌ 危险!仅凭 ID 删除,无用户身份绑定 photoMapper.deleteById(photoId); // 任何人传 photoId=123 就能删必须在 Service 层加入用户上下文校验:
@Service @Transactional public class PhotoService { @Autowired private PhotoMapper photoMapper; // 从 SecurityContext 获取当前登录用户(假设已集成 Spring Security) public void deletePhoto(Long photoId, Long currentUserId) { // 1. 先查出该照片所属用户 Photo photo = photoMapper.selectById(photoId); if (photo == null) { throw new RuntimeException("照片不存在"); } // 2. 校验所有权 if (!photo.getUserId().equals(currentUserId)) { throw new RuntimeException("无权删除他人照片"); } // 3. 删除文件(先删文件,再删 DB 记录,避免 DB 存在但文件丢失) String fullPath = Paths.get(uploadDir, photo.getFilePath()).toString(); String thumbPath = Paths.get(uploadDir, "thumbs", photo.getFilePath()).toString(); try { Files.deleteIfExists(Paths.get(fullPath)); Files.deleteIfExists(Paths.get(thumbPath)); } catch (IOException e) { log.warn("删除文件失败,继续删除数据库记录: {}", photoId, e); } // 4. 删除数据库记录 photoMapper.deleteById(photoId); } }关键点:
@Transactional保证文件删除和 DB 删除的原子性;Files.deleteIfExists()比file.delete()更可靠(支持符号链接、跨文件系统);异常捕获后仍执行 DB 删除,避免「文件删了但 DB 还留着」的脏数据。
4. 数据库与权限:MyBatis 多表关联怎么写才不 N+1?登录态如何轻量级实现?
相册系统必然涉及「用户-相册-照片」三级关系。ZIP 包若用 MyBatis,常因@Select硬拼 SQL 或@One嵌套查询导致性能雪崩。而权限模块若用 Shiro 过重,用 Spring Security 又嫌复杂——这里给出一个平衡方案:JWT Token + MyBatis 多表 JOIN 一次查全。
4.1 MyBatis 多表查询优化:用<resultMap>替代嵌套查询
假设实体关系:User(id, username)←Album(id, user_id, name)←Photo(id, album_id, title)。常见错误是:
<!-- ❌ N+1 查询:查 10 张照片,触发 10 次 Album 查询 --> <select id="selectPhotosWithAlbum" resultType="Photo"> SELECT * FROM photo WHERE user_id = #{userId} </select> <!-- Photo 类中定义 Album album; 然后在 Mapper 接口里写 @Select("SELECT * FROM album WHERE id=#{albumId}") -->正确写法是单 SQL JOIN 查全,用<resultMap>映射嵌套对象:
<!-- PhotoMapper.xml --> <resultMap id="PhotoWithAlbumMap" type="Photo"> <id property="id" column="p_id"/> <result property="title" column="p_title"/> <result property="filePath" column="p_file_path"/> <!-- 关联 Album 对象 --> <association property="album" javaType="Album"> <id property="id" column="a_id"/> <result property="name" column="a_name"/> </association> </resultMap> <select id="selectPhotosByUserId" resultMap="PhotoWithAlbumMap"> SELECT p.id AS p_id, p.title AS p_title, p.file_path AS p_file_path, a.id AS a_id, a.name AS a_name FROM photo p LEFT JOIN album a ON p.album_id = a.id WHERE p.user_id = #{userId} </select>Java 调用:
List<Photo> photos = photoMapper.selectPhotosByUserId(currentUserId); // photos.get(0).getAlbum().getName() 可直接取值,无额外 SQL参数说明:
LEFT JOIN确保即使照片未关联相册也能查出;AS p_id别名避免字段名冲突;<association>中javaType必须写全限定类名(如com.example.album.entity.Album),否则 MyBatis 找不到类。
4.2 JWT 登录态轻量实现:不用 Spring Security 也能防未授权访问
ZIP 包若未集成权限框架,可手动实现 JWT 鉴权。核心是三个类:JwtUtil(生成/解析 Token)、JwtAuthenticationFilter(拦截请求)、LoginController(登录接口)。
@Component public class JwtUtil { private static final String SECRET = "your-secret-key-change-in-prod"; // 生产环境务必换 private static final long EXPIRATION_TIME = 86400000; // 24 小时 public String generateToken(User user) { return Jwts.builder() .setSubject(user.getId().toString()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) .signWith(SignatureAlgorithm.HS512, SECRET) .compact(); } public Long getUserIdFromToken(String token) { Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); return Long.parseLong(claims.getSubject()); } }拦截器检查 Token:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { Long userId = jwtUtil.getUserIdFromToken(token); // 将用户 ID 存入 ThreadLocal,后续 Service 可获取 UserContextHolder.setUserId(userId); filterChain.doFilter(request, response); return; } catch (Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } } response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); } }在PhotoController中使用:
@RestController @RequestMapping("/api/photos") public class PhotoController { @PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file, @RequestParam("title") String title) { Long userId = UserContextHolder.getUserId(); // 从 ThreadLocal 取 String filePath = photoService.savePhoto(file, title, userId); return Result.success(filePath); } }避坑点:
UserContextHolder是一个简单的ThreadLocal<Long>工具类,确保同一线程内可传递用户 ID;doFilterInternal中filterChain.doFilter()必须在try块内,否则异常时不会进入catch;SECRET绝不能硬编码在代码里,应从application.yml读取。
5. 避坑指南:这 4 个血泪经验,让我重装了 3 次开发环境
别笑,这些坑我真踩过,而且不止一次。它们不显眼,但足以让你卡在「明明代码没错,就是跑不通」的玄学状态里。以下是 ZIP 包开箱即用过程中,最常触发的 4 类故障,按现象→原因→解决整理:
5.1 现象:上传图片后,浏览器显示 400 Bad Request,控制台无日志
原因:application.yml中未配置spring.servlet.multipart.max-file-size,Spring Boot 2.5+ 默认限制为 1MB,而用户上传的手机原图常超 3MB。
解决:在application.yml中显式设置(单位可为 MB 或 KB):
spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB5.2 现象:H2 控制台能打开(/h2-console),但连接时报Database "mem:testdb" not found
原因:spring.jpa.hibernate.ddl-auto: create-drop在应用关闭时会删库,而 H2 控制台是独立连接,重启应用后内存库已销毁。
解决:开发阶段改用create(启动时建表,不删表),或在 H2 连接 URL 后加;DB_CLOSE_DELAY=-1(延长内存库存活时间):
spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE jpa: hibernate: ddl-auto: create # 改为 create5.3 现象:缩略图生成后是纯黑或全白,原图正常
原因:ImageIO.read()读取某些 PNG 或 WebP 格式时返回null,后续Scalr.resize(null, ...)抛NullPointerException,但日志被吞没。
解决:增加格式兼容性判断,并 fallback 到Toolkit.getDefaultToolkit().getImage():
BufferedImage original; try { original = ImageIO.read(inputStream); if (original == null) { // fallback:适用于 WebP、某些 PNG Image image = Toolkit.getDefaultToolkit().getImage(inputStream); original = convertToBufferedImage(image); } } catch (IOException e) { throw new RuntimeException("图片格式不支持", e); }5.4 现象:Linux 服务器部署后,上传文件报java.nio.file.AccessDeniedException
原因:ZIP 包中upload-dir配置为./uploads,在 Linux 下实际路径为/root/uploads(若用 root 启动),而普通用户无写权限。
解决:在application.yml中用绝对路径,并确保目录存在且权限正确:
album: upload-dir: /var/www/album/uploads然后执行:
sudo mkdir -p /var/www/album/uploads sudo chown -R $USER:$USER /var/www/album sudo chmod -R 755 /var/www/album注意:
$USER是当前登录用户名,非root;chmod 755保证组和其他用户可读可执行,但不可写,符合安全基线。
6. 进阶技巧:用 Actuator + Prometheus 做轻量级运行监控,一眼看出「谁在狂传大图」
相册系统上线后,最怕的不是功能 bug,而是资源耗尽型慢病:用户批量上传 50MB 视频(虽然后端有大小限制,但总有绕过手段)、缩略图生成线程池打满、H2 内存库撑爆 JVM。Spring Boot Actuator 是免费的「健康探针」,配合 Prometheus 抓取指标,能让你在 Grafana 里一眼定位问题源头。
6.1 激活 Actuator 端点:暴露关键运行时指标
在pom.xml中添加依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>修改application.yml,开放必要端点:
management: endpoints: web: exposure: include: health,info,metrics,threaddump,heapdump endpoint: health: show-details: when_authorized metrics: export: prometheus: enabled: true server: port: 8082 # 监控端口独立于业务端口,更安全启动后,访问http://localhost:8082/actuator/metrics可看到所有指标名,如jvm.memory.used、http.server.requests。
6.2 定制业务指标:监控「上传文件大小分布」和「缩略图生成耗时」
在PhotoService中注入MeterRegistry,打点关键业务:
@Service public class PhotoService { private final MeterRegistry meterRegistry; public PhotoService(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; } public String savePhoto(MultipartFile file, String title, Long userId) throws IOException { // 1. 记录上传文件大小(单位:KB) long fileSizeKB = file.getSize() / 1024; Counter.builder("photo.upload.size.kb") .tag("user_id", userId.toString()) .register(meterRegistry) .increment(fileSizeKB); // 2. 记录上传耗时(毫秒) Timer timer = Timer.builder("photo.upload.duration.ms") .tag("user_id", userId.toString()) .register(meterRegistry); long startTime = System.currentTimeMillis(); // ... 执行上传逻辑 ... long duration = System.currentTimeMillis() - startTime; timer.record(duration, TimeUnit.MILLISECONDS); return newFilename; } }6.3 Prometheus 抓取配置:聚焦相册系统专属指标
新建prometheus.yml,添加 job:
global: scrape_interval: 15s scrape_configs: - job_name: 'album-system' static_configs: - targets: ['localhost:8082'] # Actuator 端口 metrics_path: '/actuator/prometheus'启动 Prometheus 后,在表达式浏览器输入:
sum(rate(http_server_requests_seconds_count{uri="/api/photos/upload"}[1m])) by (status)→ 查看上传接口每分钟成功率histogram_quantile(0.95, rate(photo_upload_duration_ms_bucket[1m]))→ 查看上传耗时 P95avg_over_time(photo_upload_size_kb_sum[1h]) / avg_over_time(photo_upload_size_kb_count[1h])→ 计算过去 1 小时平均上传大小
真实场景价值:某次线上告警发现
photo_upload_size_kb_sum突增 10 倍,排查发现是运营同学误将 1GB 视频拖进上传框。我们立刻在savePhoto方法开头加了if (file.getSize() > 20 * 1024 * 1024) { throw new RuntimeException("文件不能超过 20MB"); },并推送前端限制。没有监控,这事得等用户投诉才发现。
我习惯在每个新项目启动时,花 20 分钟配好 Actuator + Prometheus,不是为了炫技,而是给系统装上「听诊器」。下次你再打开那个 ZIP 包,别急着改功能,先跑通监控链路——因为真正的工程能力,不在于写多少行代码,而在于你能否在千行日志里,30 秒定位到那一行致命的NullPointerException。希望帮到你。
本文还有配套的精品资源,点击获取