简介一份基于Spring Boot的相册管理系统是面向Java学习者、Spring Boot初学者及需要完成课程设计或毕业设计的开发者的完整源码包。系统基于Spring Boot与MyBatis Plus构建以MySQL为存储涵盖用户注册、登录、注销相册创建、删除、封面上传照片上传、下载、按相册查询以及CorsFilter跨域处理、日期转换配置等完整功能展示了常见的Web开发场景与编码规范。压缩包共96个文件包含33个Java源文件、33个编译后的class文件、7个XML映射/配置、2个YML配置文件、18张示例图片及1份README说明文档整体仅4.24MB目录按Maven标准结构组织非常适合直接导入IDE研读运行。已有363人浏览学习读者可对照源码快速理解Spring Boot整合MyBatis Plus的项目搭建方法、文件上传下载工具类的实现方式以及日期处理和跨域配置的具体写法。整体实用性强能有效支撑项目实训或二次开发需求。1. 为什么「基于 Spring Boot 的相册管理系统」是练手项目的最佳粒度拿到「基于Spring Boot的相册管理系统.zip」这个标题大多数人的第一反应是把它当成一个现成的毕设源码包。但真正值得做的是反过来把这个 zip 当作一份需求清单从零搭出上传、存储、展示、回收站这一整条链路。它比图书管理多一个文件存储维度比商城少了订单和支付的状态机复杂度恰好卡在「能练到真实工程问题」和「不会把时间耗死在业务规则」之间的位置。适合准备 Spring Boot 毕设的学生、想快速交付内部工具的全栈开发、以及刚学完 SSM 想验证框架整合能力的人。我之所以推荐这个方向是因为它看起来简单做起来却要碰 Spring Boot 最常踩的几个坑静态资源映射、上传大小限制、逻辑删除的边界。把这些坑趟完你再去写别的管理系统骨架都是现成的。2. 先立技术选型存储策略和 ORM 决定整个系统的骨架2.1 Spring Boot 版本与 ORM 选型别一上来就纠结相册管理系统这种业务本质是「用户—相册—照片」三张表加文件存储不存在复杂的关系数据模型。ORM 层面常见做法是 MyBatis-Plus理由很直接单表 CRUD 不用手写 SQL分页插件开箱即用逻辑删除是注解解决的问题而不是设计负担。JPA 在这个体量下也能跑但一旦你要做批量更新、自定义统计 SQLJPQL 的调试成本明显高于 MyBatis-Plus 直接写一条 XML。Spring Boot 版本建议直接选 3.x。以当前时间点Spring Boot 3.5 搭配 Java 21 是稳妥组合默认支持虚拟线程的部分场景如果还停留在 2.7javax.servlet 迁移会给你后续换依赖埋雷。对于相册系统JDK 版本对代码量的影响几乎为零但选新不选旧能少处理一堆过时 API 的兼容问题。快速创建一个 Spring Boot 项目直接用 IDEA 的 Spring Initializr选 Web、MySQL Driver、Lombok 三个依赖起步后面按需补充。2.2 图片存哪本地磁盘还是 MinIO先想清楚再写代码相册系统的核心资产不是代码是用户上传的照片。存储策略必须在写上传接口之前定下来因为它的选择直接改变 Controller 层的返回值类型——本地磁盘返回相对路径MinIO 返回桶内 object key前端拼接的 URL 完全不同。维度本地磁盘 静态资源映射MinIO部署成本零Spring Boot 一个进程搞定需要单独部署 MinIO 服务学习 S3 API备份与迁移复制目录即可但横向扩展困难自带桶复制和多节点同步访问方式/uploads/** 静态映射到本地目录预签名 URL 或公开读访问走 HTTP适用场景课程设计、内部小工具、单机部署团队协作、需要考虑扩容的生产系统我的建议是自己练手和毕设直接本地磁盘这是 Spring Boot 相册系统最简单可靠的方案。把用户的 zip 工程里的 MinIO 配置去掉换成本地映射反而能让项目更快跑起来。要接入 MinIO 也不难Spring Boot 集成 MinIO 的 starter 社区已经维护得相当成熟核心改动就是加依赖、配 endpoint 和凭证、把存储逻辑抽成一个FileStorageService接口。如果你想在毕设答辩里体现工程意识抽这个接口就够了不必真去部署 MinIO。2.3 数据库表设计去掉外键用逻辑删除和索引扛住业务相册系统的表结构相当稳定user、album、photo 三张核心表。用户和相册是一对多相册和照片是一对多。表设计的一个关键决策是不加物理外键。物理外键在批量导入照片、调整相册归属时会带来不必要的约束检查而且相册系统的数据一致性压力远没有订单系统高应用层通过事务和状态字段足够保证正确性。下面是建表 SQL字段命名和索引设计直接按这套走可以减少很多后续问题CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(64) NOT NULL COMMENT 用户名唯一, password varchar(128) NOT NULL COMMENT BCrypt 加密后的密码, nickname varchar(64) DEFAULT COMMENT 昵称, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, is_deleted tinyint NOT NULL DEFAULT 0 COMMENT 逻辑删除0正常 1删除, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE album ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 所属用户, album_name varchar(128) NOT NULL COMMENT 相册名称, description varchar(512) DEFAULT COMMENT 描述, cover_photo_id bigint DEFAULT NULL COMMENT 封面照片ID, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, is_deleted tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT相册表; CREATE TABLE photo ( id bigint NOT NULL AUTO_INCREMENT, album_id bigint NOT NULL COMMENT 所属相册, user_id bigint NOT NULL COMMENT 上传者方便按用户维度回收, original_name varchar(255) NOT NULL COMMENT 原始文件名, storage_path varchar(512) NOT NULL COMMENT 相对存储路径如 /uploads/2025/02/uuid.jpg, size bigint NOT NULL DEFAULT 0 COMMENT 文件大小字节, content_type varchar(128) DEFAULT COMMENT MIME 类型, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, is_deleted tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_album_id (album_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT照片表;这套 DDL 有几个细节值得说。photo.storage_path存的是相对路径而不是完整 URL目的是让存储层可切换——本地磁盘时前面拼http://localhost:8080切 MinIO 时拼 MinIO 的 endpoint 就行。user_id同时出现在 photo 表而非只依赖 album 反查是因为回收站功能通常需要「查某用户所有已删除照片」这个查询如果只靠album_id要回三张表单独冗余一列就能用idx_user_id直接命中。is_deleted字段是所有表都带的基础设施配合 MyBatis-Plus 的TableLogic注解删除接口写的还是普通的removeById底层自动拼接WHERE is_deleted 0。这里要提醒一个常被忽略的点album.cover_photo_id不要在 DDL 阶段就急着写外键关联 photo 表。封面逻辑很可能演进成「找相册里最新的一张」而不是「存储固定的封面」如果设计了物理外键后续改封面还得先处理照片删除时的外键约束。用纯业务字段代码里自行维护自由度更高。2.4 后端工程分层Controller 薄、Service 重、存储独立工程结构直接决定这个系统后续扩展的顺畅程度。我会按 4 个主包划分com.example.album ├── controller # 接收请求、参数校验、返回 JSON ├── service # 业务逻辑上传编排、回收站、分页查询 ├── mapper # MyBatis-Plus 的 Mapper 接口 └── config # 静态资源映射、分页插件、全局异常处理Controller 只做三件事接收MultipartFile、调用 service、返回统一响应对象。所有文件存储细节都在 service 里。这样做的好处是如果你后面把本地磁盘换成 MinIOController 层一行都不用改。3. 打通上传与访问链路相册系统的核心代码怎么落3.1 照片上传接口UUID 重命名、子目录落地、防重防毒上传接口是整个系统的核心代码看起来只有几十行但要注意的点全在处理流程里。下面是完整可运行的上传方法PostMapping(/api/photo/upload) public ResultString upload(RequestParam(file) MultipartFile file, RequestParam(albumId) Long albumId, RequestParam(userId) Long userId) { if (file.isEmpty()) { return Result.error(上传文件不能为空); } // 1. 校验扩展名白名单而非黑名单 String originalName file.getOriginalFilename(); String ext StringUtils.substringAfterLast(originalName, .); if (!Arrays.asList(jpg, jpeg, png, gif, webp).contains(ext.toLowerCase())) { return Result.error(不支持的图片格式); } // 2. 生成存储路径按年月分子目录文件用 UUID 重命名 LocalDate today LocalDate.now(); String datePath today.getYear() / String.format(%02d, today.getMonthValue()); String storedName UUID.randomUUID().toString().replace(-, ) . ext.toLowerCase(); String storagePath /uploads/ datePath / storedName; // 3. 物理写入磁盘这里从配置文件读取 upload.root String dest uploadRoot storagePath; File destFile new File(dest); if (!destFile.getParentFile().exists()) { destFile.getParentFile().mkdirs(); } try { file.transferTo(destFile); } catch (IOException e) { log.error(照片写入磁盘失败: {}, e.getMessage(), e); return Result.error(文件保存失败请稍后重试); } // 4. 保存照片元数据到数据库 Photo photo new Photo(); photo.setAlbumId(albumId); photo.setUserId(userId); photo.setOriginalName(originalName); photo.setStoragePath(storagePath); photo.setSize(file.getSize()); photo.setContentType(file.getContentType()); photoService.save(photo); return Result.success(storagePath); }这段代码里的三个设计决策值得展开。第一文件名用 UUID 重命名而不是保留原始文件名这直接解决了两个问题恶意构造../../etc/passwd的路径穿越请求以及中文文件名在 URL 里需要百分号编码的显示问题。第二按年月建子目录是为了避免单目录塞爆 inode——一台普通 Linux 服务器单目录下超过几万文件后ls和读取性能会肉眼可见地下降日期分目录是成本最低的横向拆法。第三扩展名用白名单校验而不是黑名单因为黑名单永远堵不住新伪装白名单一刀切反而干净。这里没做图片内容校验比如读文件头对小体量系统而言扩展名加 MIME 判断已经够用。3.2 静态资源映射让上传的照片能直接用 URL 访问图片存到磁盘只是第一步用户要能通过浏览器访问到它。Spring Boot 默认只映射classpath:/static/你写到/uploads/目录的文件是无法通过 HTTP 访问的。这一步需要写一个配置类Configuration public class WebConfig implements WebMvcConfigurer { Value(${upload.root}) private String uploadRoot; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // /uploads/** 映射到磁盘实际目录注意末尾必须带 / registry.addResourceHandler(/uploads/**) .addResourceLocations(file: uploadRoot /); } }对应application.yml里的配置upload: root: /data/album/photosaddResourceHandler的角色是 URL 层的前缀匹配addResourceLocations是物理路径定位。一个经典翻车点是uploadRoot末尾没拼/导致路径拼接错另一个是 Windows 下路径写成file:E:/photos忘记了 file 协议从盘符开始就是file:E:/不是file:/E:/。这两个问题玄学得很报错时报的不是路径不存在而是 404排查时会浪费不少时间。数据库里storage_path存的是/uploads/2025/02/uuid.jpg前端拿到后直接拼上域名就能访问完整 URL 是http://localhost:8080/uploads/2025/02/uuid.jpg。这也是为什么要存相对路径的原因——图片访问域名和接口域名可以是一致的但如果你把图片搬到 CDN改返回值的拼接逻辑就行数据库不用动。3.3 上传大小限制与全局异常这两个参数不配好传一张 5MB 的照片就翻车Spring Boot 对上传文件的默认限制是 1MB。相册系统传手机照片动辄 2MB、5MB不调整配置一定会翻车。需要改两个参数spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MBmax-file-size限制单个文件大小max-request-size限制一整个请求体的大小。前端如果是批量选择多个文件同时提交后者往往先被触发。值设多少取决于场景手机直传原图 20MB 足够如果后续要做原图和大图分离可以把上限压到 5MB服务端生成缩略图供列表展示。配置改了之后还有一个配套动作——捕获MaxUploadSizeExceededException。这个异常如果不处理前端拿到的是一段默认错误 HTML 而不是 JSON前端解析必炸。在全局异常处理器里加一个分支ExceptionHandler(MaxUploadSizeExceededException.class) public ResultVoid handleMaxUpload(MaxUploadSizeExceededException e) { return Result.error(文件大小超出限制单张图片不能超过 20MB); }这样前端fetch收到的还是统一 JSON 格式提示信息也友好得多。这个全局异常处理方案是相册系统里性价比最高的一个配置十几分钟写完能让测试阶段省下大量联调时间。4. 相册管理的业务闭环分页查询、批量移动与回收站4.1 分页查询与分页插件MyBatis-Plus 的 Page 对象怎么用照片列表不能一次性全查出来一个相册放一千张照片全量返回的 JSON 体积就能把移动端内存拖垮。分页是必须的。MyBatis-Plus 的分页需要先注册拦截器Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 括号里的参数是单页最大条数限制防止有人故意调大 pageSize 拖垮数据库 PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; } }然后在 service 层直接查public PagePhoto getPhotoPage(Long albumId, int pageNum, int pageSize) { PagePhoto page new Page(pageNum, pageSize); LambdaQueryWrapperPhoto wrapper new LambdaQueryWrapper(); wrapper.eq(Photo::getAlbumId, albumId) .eq(Photo::getIsDeleted, 0) // 虽然是 TableLogic 自动过滤但显式写上更直观 .orderByDesc(Photo::getCreateTime); return photoMapper.selectPage(page, wrapper); }selectPage返回的Page对象里自带records、total、pages三个字段前端拿到后直接渲染分页条。这里要强调setMaxLimit(100L)这个参数它限制单页最大条数防止有人用pageSize99999把全表拉出来。相册系统虽然数据量不大但养成这个习惯后面做任何分页业务都不会踩性能地雷。分页字段排序用orderByDesc(Photo::getCreateTime)按上传时间倒序展示。如果后续照片量大这个查询会慢加一个(album_id, create_time)联合索引就行。这个从分库分表到索引调优的演进路径是相册系统里最有答辩价值的扩展点之一。4.2 回收站机制逻辑删除的注释与批量删除的事务边界相册系统的删除功能有个天然需求——删错了要能恢复。所以 photo 表的删除不能走物理DELETE要走逻辑删除。MyBatis-Plus 里一行注解就解决public class Photo { TableLogic private Integer isDeleted; }加了TableLogic后photoMapper.deleteById(id)会自动变成UPDATE photo SET is_deleted 1 WHERE id ? AND is_deleted 0。所有默认查询也会自动追加AND is_deleted 0无需手写条件省心且防漏。回收站的批量操作要注意事务问题。批量删除多张照片时常见的做法是循环Transactional(rollbackFor Exception.class) public void batchDelete(ListLong photoIds) { photoIds.forEach(id - { Photo photo photoMapper.selectById(id); if (photo ! null) { photoMapper.deleteById(id); } }); }Transactional必须加在 public 方法上且不能自调用——也就是说batchDelete不能被类内部其他方法调用否则事务注解不生效。这是 JDK 动态代理的机制决定的也是事务失效最常见的踩坑点。如果不想用循环MyBatis-Plus 也提供了deleteBatchIds方法直接走IN条件批量逻辑删除性能更好事务里一条 SQL 就够了Transactional(rollbackFor Exception.class) public void batchDelete(ListLong photoIds) { photoMapper.deleteBatchIds(photoIds); }恢复照片就是反操作UPDATE photo SET is_deleted 0 WHERE id IN (...)。MyBatis-Plus 的 3.5.x 版本需要自己写一条 Update SQL 来清除逻辑删除标记用LambdaUpdateWrapper配合set(Photo::getIsDeleted, 0)就可以完成。这套逻辑删除方案让「照片回收站」这个功能比物理删除方案还简单不需要额外建一张回收站表所有数据还在原表里。4.3 相册封面从表字段到实时计算的设计取舍相册表里的cover_photo_id字段是设计阶段最容易改编成「坑位」的地方。我的建议是不要相信用户会主动设置封面直接实时取该相册最新一张照片作为封面。理由很实际用户上传完照片期望相册列表立刻能看到新封面如果走手动设置还需要额外做一个「设为封面」功能交互复杂度和后端接口量都上去了。实时取封面在代码上很简单public Photo getAlbumCover(Long albumId) { LambdaQueryWrapperPhoto wrapper new LambdaQueryWrapper(); wrapper.eq(Photo::getAlbumId, albumId) .eq(Photo::getIsDeleted, 0) .orderByDesc(Photo::getCreateTime) .last(LIMIT 1); return photoMapper.selectOne(wrapper); }.last(LIMIT 1)是 MyBatis-Plus 的拼接手法注意只能用常量不能拼接前端传参否则有 SQL 注入风险。如果相册数量大、封面调用频繁给album表加一个cover_photo_url缓存字段上传或删除照片时更新它这是后话。先跑通实时计算的方案逻辑最简、坑最少等真的出现性能瓶颈再优化——这是我这几年做小系统最坚持的原则先做能跑的再做最快的。5. Spring Boot 相册系统避坑清单5 个高频翻车点5.1 上传 404 但不是接口的问题静态资源映射的玄学现象照片上传成功、数据库里有记录但浏览器访问/uploads/2025/02/uuid.jpg返回 404。原因WebConfig里的addResourceHandlers没生效。排查方式是按优先级逐层确认配置类是否被 Spring 扫描到放在了启动类的子包吗、addResourceLocations的路径有没有以file:开头、物理目录是否存在、Linux 下目录权限是否是 755。解决最有效的办法是在 Linux 上用这个命令校验 root 目录可读ls -ld /data/album/photos ls /data/album/photos/2025/02/uuid.jpg如果文件存在但 HTTP 404再把registry.addResourceHandler(/uploads/**)里的前缀改成/static/uploads/**试试有时是你项目的 intercept 配置拦截了/uploads路径。把日志级别调成 DEBUG看 Spring Boot 启动时打印的 RequestMapping 和 ResourceHandler 注册信息能直接看到映射是否生效。5.2 传中文文件名的图片保存后访问不了现象文件名是「毕业照.jpg」上传成功URL 访问 404或者前端拿到 URL 后图片加载失败。原因原始文件名含中文file.getOriginalFilename()拿到的是 UTF-8 编码的字符串浏览器请求 URL 时又会做一次编码两次编码不一致导致 404。这正是我在上传接口里用 UUID 重命名的原因之一彻底绕开了原始文件名的编码问题。但如果你保留了原始文件名做展示original_name字段存中文没问题storage_path千万别存中文。解决已经存了中文路径的写个小脚本批量重命名物理文件并更新数据库storage_path。代码层面的预防就是永远用 UUID 扩展名作为存储名原始文件名只放在original_name字段里用于前端展示。5.3 上传大图一直报错前端看不出原因现象传手机照片偶尔失败接口返回 500后台日志报MaxUploadSizeExceededException或FileSizeLimitExceededException。原因spring.servlet.multipart.max-file-size默认 1MB没改配置或者改了但max-request-size没同步调大。前端一次发多张图时多个文件的总大小超过max-request-size也会失败。解决把两个参数都调大并加上 3.3 节说的全局异常捕获。补充一个细节如果你用了 Spring Cloud Gateway 或 Nginx 做反向代理代理层也有client_max_body_size限制默认 1MB需要同步改http { client_max_body_size 20m; }否则 Spring Boot 配置改到 100MB 也没用请求在代理层就被拦了。5.4 逻辑删除字段导致唯一索引失效现象用户删了一个相册又创建了一个同名相册此时新相册插不进去报 duplicate key。原因album表对album_name建了唯一索引逻辑删除后旧数据还在表里新记录插同名的就撞了索引。is_deleted是 0/1 两值无法区分多条已删除的同名记录。解决把唯一索引从物理唯一改成逻辑唯一删除时把is_deleted置为主键自增的值比如已删除记录is_deleted id这样每次删除的 deleted 值各不相同物理上不再重复。MyBatis-Plus 的TableLogic默认只认 0/1需要自定义逻辑删除的全局配置mybatis-plus: global-config: db-config: logic-delete-value: 1 # 删除后的值 logic-not-delete-value: 0 # 未删除的值这个方案并不优雅但确实是相册系统这种「允许同名相册」业务下最经济的取舍。如果你不想碰这个复杂度更直接的做法是去掉album_name的物理唯一索引在应用层查询时先做一次重复名校验通过事务隔离来防并发下的重复。5.5 批量删除照片但磁盘空间没释放现象回收站功能完成后用户大量删除照片数据库里is_deleted 1的记录越来越多但服务器磁盘空间还是被占满。原因逻辑删除只改了数据库标记物理文件还躺在/uploads目录里。回收站设计里如果少了「永久删除」这一步空间永远不会释放。解决加一个定时清理任务每天扫一遍超过 30 天仍处于is_deleted 1的旧照片先删物理文件再删数据库记录Scheduled(cron 0 0 3 * * ?) Transactional(rollbackFor Exception.class) public void cleanExpiredPhotos() { LocalDateTime deadline LocalDateTime.now().minusDays(30); ListPhoto expiredPhotos photoMapper.selectList( new LambdaQueryWrapperPhoto() .eq(Photo::getIsDeleted, 1) .lt(Photo::getUpdateTime, deadline) .last(LIMIT 500) ); expiredPhotos.forEach(photo - { File file new File(uploadRoot photo.getStoragePath()); if (file.exists()) { file.delete(); } photoMapper.deleteById(photo.getId()); // 这次是物理删除因为 TableLogic 会自动放行修改 }); }这里的坑在最后一行deleteById走的是逻辑删除而我们要做的是物理清理。MyBatis-Plus 里物理删除需要用photoMapper.delete配上自定义 SQL或者用Delete注解写在 Mapper 方法里。更稳妥的做法是定义一个独立 Mapper 方法手动执行DELETE FROM photo WHERE id ?避免误用逻辑删除。我当时第一次做定时清理就踩了这个坑跑了一周发现数据库干净的记录越来越少磁盘却一点没省下来——整个清理任务等于白跑。6. 从最小验证到增量扩展跑通后的下一步先给一份最小验证路径。项目 clone 或自己写完代码后按这个顺序跑mvn clean package -DskipTests java -jar target/album-system.jar启动后拿 curl 走一遍全链路。第一步注册新用户第二步建相册第三步上传图片curl -X POST http://localhost:8080/api/photo/upload \ -F file/path/to/test.jpg \ -F albumId1 \ -F userId1返回的 JSON 里拿到storagePath拼上http://localhost:8080用浏览器访问。能显示图片说明静态资源映射和文件落盘都没问题。最后测分页curl http://localhost:8080/api/photo/list?albumId1pageNum1pageSize10。这套验证跑完核心链路才算真正闭环。跑通之后再谈扩展。相册系统最值得做的三个增量方向一是照片缩略图——上传时用 Thumbnailator 或 Java 自带 ImageIO 生成一张宽 480px 的小图列表页展示缩略图、原图在点击时才加载这是移动端体验质的区别二是读 EXIF 信息——照片里自带拍摄时间和 GPS 坐标上传时解析出来存到 photo 表分页查询按拍摄时间排序会比按上传时间排序更符合相册场景三是把本地磁盘换成 MinIO——先抽FileStorageService接口再用 MinIO 实现接口层面完全无缝迁移。我自己的习惯是每做一个管理系统都留一张「我能跑到哪一步」的清单相册系统这套做下来你会发现 Spring Boot 的那些配置项从「背过」变成了「真的理解」。比如虚拟线程和 Tomcat 默认线程池的关系你没有实际压过上传接口看了就忘。从注释一个依赖开始每个问题都要明白它在替谁兜底这个项目才算没白做。希望帮到你。本文还有配套的精品资源点击获取