Spring Boot相册管理系统实战:环境搭建、权限隔离与文件上传全解析
简介基于Spring Boot的相册管理系统压缩包适合Java后端学习者、毕业设计或课程设计场景直接解决照片与相册的增删改查、用户注册登录及文件上传下载等常见需求。项目整合MyBatis Plus、MySQL和Java 8日期处理并利用CorsFilter完成跨域配置围绕用户、相册、照片三条业务线展示了一个完整可运行的Web应用搭建过程。压缩包共包含96个文件其中33个java源码与33个编译后class文件对应项目主体18张jpg图片用作相册封面和演示素材另有7个xml映射文件、2个yml配置以及1份README说明整体大小仅4.24MB方便快速部署和学习。已有363人参与学习下载。读者可以借此理解Spring Boot分层结构、Mapper接口与XML映射的配合方式掌握CommonsMultipartFile处理上传下载、按相册ID查询照片等实现细节资源目录划分清晰便于按模块阅读也适合在真实编译运行前先对照源码梳理业务逻辑。1. 相册管理系统为什么不是“又一个Spring Boot CRUD”先看这套.zip能解决什么拿到《基于Spring Boot的相册管理系统.zip》这种命名风格的项目包很多人的第一反应是“这不就是一个增删改查的作业嘛”。但真正用Spring Boot做过完整项目的人会告诉你相册管理系统恰恰是少数几个能把Spring Boot技术栈里“存储、缓存、鉴权、文件处理、异步任务”串起来的业务场景。一个相册不只是把图片路径存进数据库它涉及文件上传的格式校验、缩略图生成、照片权限隔离、批量操作、浏览统计甚至人脸聚类——这些都是在真实业务里一定会遇到的难点。如果你正处在“第一个Spring Boot程序已经跑通想找一个能把整个技术栈串起来的完整项目”的阶段或者你是带着“Spring Boot设计题目商城、地址簿管理、校园讲座预约系统”这类题目在看实现方案那这类相册项目正好是一个不算太大、但五脏俱全的练手样本。这篇笔记我会站在“拿到这个zip包之后”的角度把从环境搭建、数据库设计、核心代码结构到部署验证的整条链路拆开讲。这个方案适合三类人准备做毕业设计的学生想给简历加一个完整项目经验的初级开发以及想快速验证Spring Boot 3.x MyBatis-Plus Redis MinIO这套组合能不能稳定扛住图片类业务的工程师。我不会给你贴一个“完美源码”因为项目包里的代码一定需要按你的运行环境做调整但我可以告诉你每块代码应该在什么位置、参数怎么改、挂了之后看什么日志。2. 把相册管理系统的环境先立住JDK、MySQL、Redis与最小启动命令2.1 JDK版本与Spring Boot版本怎么配对先做减法拿到项目包第一件事不是看代码而是确认编译环境。大多数相册管理系统以Spring Boot 2.x为基础构建少数新一点的zip包会用Spring Boot 3.x。这两者的运行要求差别很大Spring Boot 2.7最多支持到JDK 17Spring Boot 3.x则强制要求JDK 17起步你如果用的是JDK 8那就只能在2.x的相册项目里选。判断方法很简单先看压缩包里的pom.xml或build.gradleparent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent如果看到2.7.x结尾的版本就按JDK 11或JDK 17来配如果是3.2.x、3.3.x就直接上JDK 17。这里有个容易踩的坑很多人电脑里装了JDK 8和JDK 17两套环境IDE自动选了JDK 8来编译Spring Boot 3项目结果报一堆java.lang.UnsupportedClassVersionError这种错误不是代码问题纯粹是环境错配。我一般会在项目的.mvn/jvm.config或者IDE的Project Structure里固定JDK路径不让它自动切换。2.2 数据库初始化相册系统的表结构为什么一定要看SQL脚本相册系统的数据模型通常是这样的用户表user、相册表album、照片表photo、照片标签表tag、照片与标签关联表photo_tag可能还有浏览记录表。绝大多数zip包会在sql/目录下放一个init.sql或schema.sql启动前必须先执行。不要偷懒让JPA或MyBatis-Plus自动建表因为相册系统的外键关系和索引设计是手工优化过的自动生成往往缺索引或者字段类型不对。常见的做法是先用命令行或Navicat建一个空库再执行脚本mysql -uroot -p sql/init.sql执行完看一眼photo表的主键策略和upload_time字段的默认值CREATE TABLE photo ( id bigint NOT NULL AUTO_INCREMENT, album_id bigint DEFAULT NULL, user_id bigint DEFAULT NULL, file_path varchar(255) NOT NULL, thumbnail_path varchar(255) DEFAULT NULL, file_size bigint DEFAULT NULL, upload_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_album_id (album_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意CHARSET必须是utf8mb4如果脚本里写的是utf8后面存中文标签和emoji文件名时会报Incorrect string value错误。这个坑在相册项目里非常常见因为图片的原始文件名往往带中文或空格。如果你打开init.sql发现全是utf8先全局替换成utf8mb4再执行别等到上传第一张照片才后悔。2.3 最小启动命令先关掉Redis和MinIO跑通“瘦身版”相册管理系统如果接入了Redis做缓存、MinIO做文件存储那么它的application.yml里通常会有两段配置。第一次启动时我建议先把这些外部中间件的依赖全部注释掉用本地文件存储和内存缓存跑一个最小版本。不是说Redis和MinIO不重要而是先让Spring Boot容器起来把SpringBootApplication主类跑通排除掉“项目本身代码有问题”的可能性再逐步打开外部服务。spring: datasource: url: jdbc:mysql://localhost:3306/album_system?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 20MB max-request-size: 100MB redis: host: localhost port: 6379 # 第一次跑通可以注释掉 redis 相关依赖和配置 app: storage: type: local local-path: ./upload/ minio-endpoint: http://localhost:9000 minio-bucket-name: album把Redis和MinIO配置注释或修改后直接执行mvn spring-boot:run看到Tomcat started on port(s): 8080就算第一步通关。这个“瘦身版”启动的意义在于帮你建立基准后面每加一个中间件如果挂了你知道是哪个环节引入的。很多人第一次跑相册项目一上来就把MySQL、Redis、MinIO、Elasticsearch全开了结果日志刷了1000行也不知道是哪连不上。2.4 连不上MySQL时的处理顺序时区、驱动、密码三个高频问题接入MySQL时大概率会遇到三个问题按顺序排查基本能解决90%的情况。第一个是Server returns invalid timezone这是serverTimezone参数没配加上Asia/Shanghai就行。第二个是ClassNotFoundException: com.mysql.jdbc.Driver这是因为项目里用的是旧驱动名Spring Boot 2.7后的MySQL驱动类名是com.mysql.cj.jdbc.Driver检查一下pom里mysql-connector-java的版本5.x的驱动配老类名8.x的驱动配新类名。第三个是Access denied for user这纯粹是密码错误或者账号权限问题在MySQL里执行GRANT ALL PRIVILEGES ON album_system.* TO rootlocalhost;可以解决。3. 相册系统的核心是“文件进了数据库”MyBatis-Plus与MinIO上传链路3.1 为什么相册系统几乎必用MyBatis-Plus而不选JPA这其实是一个“谁用谁知道”的选型问题。JPA在实体关系映射上很优雅但相册系统的查询场景以“按相册查照片列表”“按标签查照片”“按时间范围统计上传数量”为主全都是动态拼接条件的SQL。JPA的Specification能写但写出来的代码很啰嗦MyBatis-Plus的LambdaQueryWrapper可以直接用链式调用把条件拼出来代码量和可读性都好很多。尤其是分页查询MyBatis-Plus的Page对象一行代码就能搞定不需要手动写LIMIT和OFFSET。Service public class PhotoServiceImpl implements PhotoService { Autowired private PhotoMapper photoMapper; public IPagePhoto getPhotosByAlbum(Long albumId, int pageNum, int pageSize) { LambdaQueryWrapperPhoto wrapper new LambdaQueryWrapper(); wrapper.eq(Photo::getAlbumId, albumId) .orderByDesc(Photo::getUploadTime); return photoMapper.selectPage(new Page(pageNum, pageSize), wrapper); } }这段代码逻辑很直白eq等于相册IDorderByDesc按上传时间倒序selectPage自动做分页。注意Photo::getAlbumId这种写法是Java 8方法引用比写字段字符串安全得多——写album_id字符串如果拼错了运行时才报错用方法引用编译期就能发现问题。相册系统的查询还有一个高频需求统计某个用户总共上传了多少照片、占用多少空间。这个用MyBatis-Plus的Select注解写在Mapper接口里更直观Mapper public interface PhotoMapper extends BaseMapperPhoto { Select(SELECT COUNT(*) FROM photo WHERE user_id #{userId}) long countByUserId(Long userId); Select(SELECT COALESCE(SUM(file_size), 0) FROM photo WHERE user_id #{userId}) long sumFileSizeByUserId(Long userId); }3.2 文件上传接口MultipartFile进来之后发生了什么相册系统最核心的一段代码是上传接口。它不只是把文件丢到磁盘而是要经过格式校验、重命名、存储、缩略图生成、数据库落库几个阶段。PostMapping(/api/photos/upload) public Result upload(RequestParam(file) MultipartFile file, RequestParam(albumId) Long albumId, HttpServletRequest request) { // 1. 格式校验只允许图片 String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.) 1).toLowerCase(); if (!Arrays.asList(jpg, jpeg, png, gif, bmp, webp).contains(ext)) { return Result.error(不支持的文件格式: ext); } // 2. 大小校验单张照片不超过20MBapplication.yml里也配了 if (file.getSize() 20 * 1024 * 1024) { return Result.error(单张照片不能超过20MB); } // 3. 生成唯一文件名避免中文名和重名 String newFileName UUID.randomUUID().toString().replace(-, ) . ext; // 4. 存储文件本地存储模式 String uploadDir appProperties.getStorage().getLocalPath(); File dir new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } File dest new File(dir, newFileName); try { file.transferTo(dest); } catch (IOException e) { log.error(文件保存失败, e); return Result.error(文件保存失败); } // 5. 缩略图生成用ImageIO String thumbnailName generateThumbnail(dest, uploadDir, newFileName); // 6. 落库 Photo photo new Photo(); photo.setAlbumId(albumId); photo.setUserId(getCurrentUserId()); photo.setFilePath(/upload/ newFileName); photo.setThumbnailPath(/upload/ thumbnailName); photo.setFileSize(file.getSize()); photoMapper.insert(photo); return Result.success(photo); }这段代码里值得细看的是几个设计决策。第一文件名用UUID而不是保留原始文件名否则两个人都传风景.jpg会互相覆盖。第二transferTo是Spring封装的方法底层会根据CommonsMultipartFile或StandardMultipartFile自动选择临时文件转储方式不需要手写FileOutputStream。第三缩略图生成不是必须的但相册列表页如果加载原图几百张照片会让浏览器崩溃我的做法是统一生成200x200的缩略图列表页加载缩略图点击后加载原图。3.3 本地存储切到MinIO三种常见配置方式项目包如果接入了MinIO那么存储逻辑通常会抽象出一个StorageService接口本地和MinIO各有一个实现类。切换时不要改业务代码只改application.yml里的app.storage.type就行。MinIO的配置有四个必填项endpoint、accessKey、secretKey、bucketName。Component(minioStorageService) ConditionalOnProperty(name app.storage.type, havingValue minio) public class MinioStorageService implements StorageService { private MinioClient minioClient; PostConstruct public void init() { this.minioClient MinioClient.builder() .endpoint(appProperties.getStorage().getMinioEndpoint()) .credentials(appProperties.getStorage().getMinioAccessKey(), appProperties.getStorage().getMinioSecretKey()) .build(); try { boolean exists minioClient.bucketExists( BucketExistsArgs.builder().bucket(appProperties.getStorage().getMinioBucketName()).build()); if (!exists) { minioClient.makeBucket( MakeBucketArgs.builder().bucket(appProperties.getStorage().getMinioBucketName()).build()); } } catch (Exception e) { throw new RuntimeException(MinIO初始化失败, e); } } Override public String upload(String objectName, InputStream inputStream, long size, String contentType) { try { minioClient.putObject(PutObjectArgs.builder() .bucket(appProperties.getStorage().getMinioBucketName()) .object(objectName) .stream(inputStream, size, -1) .contentType(contentType) .build()); return / appProperties.getStorage().getMinioBucketName() / objectName; } catch (Exception e) { throw new RuntimeException(MinIO上传失败, e); } } }ConditionalOnProperty是Spring Boot的按条件装配注解意思是只有app.storage.type为minio时才创建这个Bean。这样在本地开发时用local部署时改成minio代码不用动。这个设计我觉得是这个zip包最值得学习的一点。3.4 MyBatis-Plus分页插件配置少了这一步分页就是假的很多人在相册项目里发现selectPage查出来的数据不对——总数是对的但列表只有一页的内容翻页没效果。这多半是没配置分页插件。MyBatis-Plus的分页查询依赖一个拦截器不配置的话分页查询不会拼接LIMIT语句等于全表加载。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这是Spring Boot 3 MyBatis-Plus 3.5.x的标准写法。注意DbType.MYSQL一定要和你的数据库对应如果你换成了PostgreSQL还写MySQL分页SQL的LIMIT语法会出错。检查项目包时看一眼这个配置类是否存在很多网上下载的zip包把这步省略了跑起来列表页总是“看起来正常但翻不了页”。4. 登录鉴权与照片权限Spring Security放进相册项目的正确姿势4.1 相册系统的安全模型是什么用户隔离与相册归属相册系统的权限模型比普通CRUD多一层。用户登录后只能看到自己创建的相册和照片公共相册可以浏览但不能修改。这套模型用Spring Security做起来很顺手但前提是你理解要拦截什么。常见的做法是定义三种角色ADMIN管理员、USER普通用户、GUEST访客。接口按权限分级/api/auth/**允许匿名访问/api/photos/**需要登录/api/admin/**需要管理员角色。Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf - csrf.disable()) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/register).permitAll() .requestMatchers(/upload/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这段配置里最值得注意的就是STATELESS。传统Spring Security默认用Session保持登录状态但相册系统如果是前后端分离Vue Spring Boot走Session会有跨域问题所以项目包通常会改成JWT令牌认证。你把SessionCreationPolicy设为STATELESS后服务器不保存会话每次请求带一个Authorization: Bearer token头由jwtAuthenticationFilter来解析。4.2 基于JWT的登录流程登录、拿Token、带Token访问JWT登录的核心代码是一个AuthController负责校验用户名密码签发Token。这个Token里只放必要信息——用户ID、用户名、角色不放照片地址之类的业务数据。PostMapping(/api/auth/login) public Result login(RequestBody LoginRequest loginRequest) { User user userService.findByUsername(loginRequest.getUsername()); if (user null || !passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } String token jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole()); return Result.success(new LoginResponse(token, user)); }passwordEncoder.matches这一步是必须的禁止把数据库里的密码拿出来做equals比较因为注册时存的是BCrypt加密后的哈希值不是明文密码。jwtUtil.generateToken的内部实现是public String generateToken(Long userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 86400000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }有效时间设24小时86400000毫秒意味着一天后Token过期用户需要重新登录。这个值因人而异管理系统可以设12小时C端产品建议设7天但配合刷新Token方案更好。4.3 照片权限校验别让A用户访问到B用户的照片这是相册系统里安全漏洞最高发的位置。很多zip包里的代码只做了“是否登录”的校验没做“是否是本人”的校验。也就是说登录用户A如果知道照片ID为100直接访问/api/photos/detail/100就能看到用户B的私密照片。修复方法是在Service层做归属校验public Photo getPhotoDetail(Long photoId, Long currentUserId) { Photo photo photoMapper.selectById(photoId); if (photo null) { throw new BusinessException(照片不存在); } Album album albumMapper.selectById(photo.getAlbumId()); if (!album.getUserId().equals(currentUserId) !PUBLIC.equals(album.getVisibility())) { throw new BusinessException(无权访问该照片); } return photo; }这段代码加了一个关键判断照片所属的相册若不是公开的且相册的归属用户不是当前登录用户就拒绝访问。很多人会问为什么不直接拿photo.getUserId()判断因为照片的归属有可能在“共享相册”场景下发生变化一张照片在合作相册里可能是多个用户共同编辑的所以判断的锚点应该是相册的可见性和归属而不是照片本身的归属字段。4.4 Spring Security在Spring Boot 3.x的配置迁移坑如果你拿到的是Spring Boot 3.2的相册项目Spring Security已经升级到6.x配置方式和2.x有些变化网上的老教程大概率用的是WebSecurityConfigurerAdapter这个类在Spring Security 6里已经被移除了。常见报错是The method requestMatchers() is undefined或者Cannot resolve symbol and()。正确写法是直接在SecurityFilterChain里用Lambda风格链式调用就像上面的示例代码那样。还有一点Spring Security 6的PasswordEncoder推荐使用BCryptPasswordEncoder已经有DelegatingPasswordEncoder做兼容但项目中如果同时存在{noop}前缀的明文密码记录会直接报错。5. 相册项目最容易翻车的五个位置避坑排查手册5.1 上传图片后浏览器访问返回404这个坑在所有文件上传相关项目里都排第一。现象是接口返回上传成功数据库里也有一条记录但浏览器打开/upload/xxx.jpg直接404。原因通常是静态资源映射没配置。Spring Boot默认只把classpath:/static/下的文件当作静态资源你上传到磁盘上的./upload/目录并不在扫描范围里。解决方法是添加一个配置类Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: appProperties.getStorage().getLocalPath()); } }注意addResourceLocations必须以file:开头否则Spring会把它当作classpath路径处理。路径末尾的斜杠也不能漏file:./upload/和file:./upload的区别会导致拼接URL时目录被吞掉。5.2 上传20MB文件被拒绝现象是上传稍大一点的照片就返回FileSizeLimitExceededException小文件正常。原因是Spring Boot的spring.servlet.multipart.max-file-size默认只有1MB。有人会只改multipart.max-file-size但请求的总大小max-request-size没改如果一次上传多张照片总大小还是会被拦。spring: servlet: multipart: max-file-size: 20MB max-request-size: 100MB5.3 MySQL出现 PacketTooBigException现象是插入照片记录时报Packet for query is too large。这是MySQL服务端参数max_allowed_packet的默认值太小通常是4MB而照片的base64字符串或大字段塞进了一条SQL里。如果你在photo表里存储了base64编码的图片内容这是设计缺陷这个问题100%会出现。解决方法是把存储改成“存路径不存内容”同时调整MySQL参数SET GLOBAL max_allowed_packet 64 * 1024 * 1024;不过这个设置重启MySQL后失效。要永久生效需要修改MySQL配置文件my.cnf或my.ini里的max_allowed_packet。5.4 Base64上传导致内存溢出很多相册项目会提供Base64上传接口方便移动端调用。但如果代码里直接Base64.decodeToByteArray(base64Str)一个20MB的图片拆成的Base64字符串大约是27MB在线转byte[]时又会再拷一份并发一高就容易OOM。我一般会在Controller层加一个限制Base64字符串长度超过25MB直接拒绝同时把图片解码后的写入方式改成流式而不是一次性把整个byte[]都放进内存。Memory和IO的取舍是相册系统的一个长期话题。5.5 Redis缓存不一致照片列表和实际不一致如果项目包里有Redis缓存照片列表你会遇到这样的现象上传了一张新照片列表还是旧的。原因是项目通常只缓存了列表数据但没做缓存更新。最简单的处理是在上传成功后删除对应相册的缓存KeyredisTemplate.delete(album:photos: albumId);下次查询时发现缓存不存在就会查数据库并重建缓存。这就是常见的Cache Aside Pattern。如果你在读项目代码时看到缓存Key是album:photos:{albumId}这种格式建议顺手加上删除逻辑避免用户以为传上去的照片丢了。6. 从作业到能写进简历验证这个相册系统值不值得投入6.1 用三个维度判断这个zip包的水准打开zip包后不要急着启动先用三个维度评估这套代码值不值得你花时间修改。第一看配置是否可分离——数据库连接、Redis地址、文件存储路径是否写在application.yml里而不是硬编码在Java类中如果看到DriverManager.getConnection(jdbc:mysql://localhost...)这种裸露代码这个项目的工程化水平比较低。第二看异常处理是否统一——有没有一个RestControllerAdvice全局异常处理器还是每个Controller都自己写try-catch。第三看颗粒度——Service层是直接把Mapper的操作拼在一起还是有独立的业务逻辑。6.2 值得照着改的两个进阶方向如果你决定把这个相册系统深入学习下去我建议做两个增强改造。第一个是接入Caffeine本地缓存把相册列表这种“读多写少”的接口挂上二级缓存测一下QPS能从多少提升到多少。这不是炫技而是在系统里真实需要做的性能优化。第二个是把上传逻辑改成异步消息队列比如用Spring的Async或集成一个轻量级deer-flow工作流来做上传后的图片处理管道——原图压缩、缩略图生成、OCR识别文字都可以作为工作流节点。Async(photoTaskExecutor) EventListener(PhotoUploadEvent.class) public void handlePhotoUpload(PhotoUploadEvent event) { // 生成中等尺寸图 // 生成缩略图 // 写入照片索引 log.info(异步处理完成: {}, event.getPhotoId()); }6.3 最后扔一个压测方式给你项目跑通后不要只想着“能跑就行”。你是做了“管理系统”的人要拿性能说话。用wrk或JMeter压一下照片列表接口先不加缓存压一轮看Tomcat默认线程池能扛到多少QPS再加缓存压一轮记录两项数据的差值。能在面试里把这个曲线讲明白比背十道八股文有效得多。我在第一次跑通相册类项目时最大的教训就是上来就想着把所有功能都实现——人脸识别、定时清理、外链分享结果真正把“上传照片到MinIO、缩略图落库、权限隔离”这一条主链路调通已经花掉了我一大半时间。这个项目真正的价值不在功能多而在于让你从前端的“页面思维”切换到“资源与权限的思维”明白一个文件从进入系统到被安全地展示出来中间隔了多少道关卡。希望这些踩坑经验能帮你少走几段弯路希望帮到你。本文还有配套的精品资源点击获取