
做过几个SpringBoot的个人项目之后我越来越觉得“游迹共享”这类系统是最适合拿来练手、也最能体现全栈掌握程度的选题之一。它的核心逻辑不复杂就是用户记录自己去过的地方、传图、配文字然后把这些足迹按时间和地理位置展示出来供其他用户浏览、点赞、评论。但麻雀虽小五脏俱全从用户登录到文件上传从数据库设计到前端Vue打包部署该踩的坑一个都跑不掉。这篇文章就围绕这个项目的完整落地过程把设计思路、后端编写、前后端联调、部署排错这些环节逐个拆开讲希望对正在做SpringBoot毕设或想搞一个完整全栈作品的朋友有实际帮助。1. 项目整体设计与技术选型1.1 这类系统到底在解决什么问题游迹共享系统说白了就是“旅行版朋友圈”或“地理化日记本”。市面上类似的产品有不少比如记录航班轨迹的、记录骑行路线的但多数只解决“记录”这一点。我们这个系统的侧重点在“共享”二字用户发布的每一段足迹都不是躺在自己手机里的私有数据而是可以按时间线、城市、热度被其他用户发现和互动的内容。拆解下来核心需求其实就三类。第一类是个人创作用户需要能记录地点、写文字、上传图片、标注经纬度第二类是社区浏览首页要能按时间倒序展示全部公开足迹支持按城市、关键词做筛选第三类是轻社交点赞、评论、关注用户这些不需要做得很重但要有。把一个完整闭环跑通比堆砌十几个华而不实的模块更有价值。从开发视角看这个项目也特别适合体现SpringBoot的价值。SpringBoot擅长的是快速搭建Web服务、统一管理依赖、自动化配置而这些跟“复杂逻辑”关系不大更多是“组织代码”的能力。换句话说SpringBoot帮你把地基打好了你只需要专心盖业务楼层。自动装配原理、starter机制、内嵌Tomcat这些知识点在这个项目里能真实地感受到而不是停留在面试题层面。1.2 技术选型不是跟风是把账算清楚很多同学一上来就纠结用SpringBoot还是SSH用JPA还是MyBatis用不用Redis我的建议是先算账。单人开发、周期两三个月、毕业设计或简历项目稳定性、可控性、排查问题的容易程度才是第一位的。选SpringBoot不需要太多理由它已经成为Java Web开发的事实标准。不过具体版本要留心我见过不少人在“springboot版本太高”上吃过亏。比如SpringBoot 3.x要求JDK 17很多教程还是基于2.x写的照抄代码直接编译报错。这里建议用SpringBoot 2.7.x配合JDK 8/11生态最成熟网上资料多遇到问题容易搜到答案。如果你非要用3.x那就要接受javax变成jakarta、部分配置项迁移等变动后面我会专门讲这些坑。持久层选了MyBatis而非JPA原因也很直接JPA虽然写CRUD省事但复杂查询、动态条件拼接、SQL调优时你很难看到和控制最终执行的SQL。MyBatis把SQL明确摆在XML里你清楚每一句查询长什么样出问题时定位也快。配合MyBatis Generator或手写通用Mapper开发效率并不低。前端用Vue但最终不搞前后端分离部署而是把Vue打包后的静态文件直接放进SpringBoot的resources目录由同一个Tomcat提供服务。别觉得这种方案“土”对于中小型项目和毕设部署它能省掉Nginx配置、跨域处理和双进程维护成就感来得更快。1.3 功能模块划分与用户流转链路我习惯先画用户流转而不是先建表。把页面和接口对应起来模块边界就清楚了游客可以看公开足迹列表和详情不能点赞评论注册/登录用户发布足迹、管理自己的足迹、点赞评论、修改个人信息管理侧用户管理、内容审核、统计数据这部分可以通过普通接口加角色字段实现不用拆独立后台一条完整的业务链路是这样的用户打开首页浏览足迹 - 看到感兴趣的内容点击详情 - 决定自己也发一条 - 填写城市、地点描述、上传图片 - 后端解析经纬度并落库 - 新足迹出现在个人主页和首页时间线 - 其他用户点赞评论。这整条链路从数据库设计到接口编写再到前端页面是环环相扣的所以前期设计时不能只盯着某一个接口要站在全流程角度想清楚每个环节的数据从哪来、到哪去。2. 数据库表结构与MyBatis动态SQL2.1 四张核心表的设计思路数据库设计决定了一个项目能走多远。游迹共享系统不需要复杂的表关系三张核心业务表加一张用户表就足够了。用户表t_user不需要太多字段主键、用户名、密码、昵称、头像URL、个性签名、创建时间就够了。这里要提醒一句密码千万不要存明文用BCrypt加密Spring Security的crypto包里直接有BCryptPasswordEncoder单拿出来用也很方便。足迹表t_footprint是这个系统的核心表。字段包括id、用户id、标题、正文内容、城市、经度、纬度、状态公开/私密、点赞数、评论数、创建时间。很多人会忽略经纬度字段觉得用不到但实际上经纬度是后续做地图展示、城市聚合的关键。最开始设计时最好就把冗余字段加上比如城市名称可以单独存一个字段而不是每次通过经纬度反查否则数据库压力大而且调用第三方接口有延迟。图片表t_image独立出来一张表是有必要的因为一条足迹可能对应多张图片。字段就是id、足迹id、图片URL、排序号。单独建表的另一个好处是将来如果做图片懒加载或者CDN迁移只需要改这一张表的数据不需要动足迹主表。评论表t_comment字段也不多id、足迹id、用户id、评论内容、父评论id、创建时间。注意父评论id是为了支持楼中楼效果哪怕现在不做先把这个字段留出来以后扩展会省很多事。点赞这种高频数据不建议单独建表直接在足迹表里维护一个count字段就好用户是否点赞用Redis存一个set很轻松这个项目如果不想引入Redis也可以用一个t_like_record表硬扛只是查询时多一次DB访问而已。2.2 MyBatis动态SQL是查询模块的命脉游迹系统的首页不可能只有一个无条件查询。用户会按城市筛、按关键词搜、按时间范围筛选如果这些条件都写死成不同的SQL那代码会膨胀得非常难看。MyBatis的where和if标签就是为这种场景设计的。以足迹列表查询为例核心XML大概是这样的select idselectFootprintPage resultTypecom.example.vo.FootprintVO SELECT f.id, f.title, f.content, f.city, f.longitude, f.latitude, f.like_count, f.comment_count, f.create_time, u.nickname, u.avatar_url, GROUP_CONCAT(i.image_url) AS imageUrls FROM t_footprint f LEFT JOIN t_user u ON f.user_id u.id LEFT JOIN t_image i ON f.id i.footprint_id where f.status 1 if testkeyword ! null and keyword ! AND (f.title LIKE CONCAT(%, #{keyword}, %) OR f.content LIKE CONCAT(%, #{keyword}, %)) /if if testcity ! null and city ! AND f.city #{city} /if if teststartTime ! null AND f.create_time gt; #{startTime} /if /where GROUP BY f.id ORDER BY f.create_time DESC LIMIT #{offset}, #{pageSize} /select这里有几个实际经验。第一GROUP_CONCAT可以把多条图片记录合并成一个字段返回给前端时再按逗号拆分比循环查图片表效率高很多。第二时间比较要用gt;而不是XML里尖括号会被转义解析出错这个坑几乎每个写MyBatis的人都会踩一次。第三分页直接用LIMIT在数据量小的时候完全够用没必要为了一个毕业设计引入PageHelper但如果你要写论文引入PageHelper会显得更专业也没问题。动态SQL还有一个容易被忽略的点where标签会自动处理第一个AND但如果你在if内部写条件时格式不规范比如多了一个空格或漏了空格拼接出来的SQL会很奇怪。我的习惯是每个条件前都写AND交给where去优化而不是在第一个条件上纠结要不要加WHERE关键字。2.3 关联查询的VO设计实体类和数据库表字段一一对应但接口返回给前端的JSON不应该把数据库字段原样返回。比如说足迹列表页需要显示用户的昵称和头像但t_footprint表里只有user_id这时候就需要一个FootprintVO来承载组合数据。VO里除了足迹本身的信息还可以直接把imageUrls拆成一个List省得前端再处理字符串。这种查询在MyBatis里很自然因为resultType可以直接映射到VO类的驼峰属性。前提是在application.yml里配置好mybatis: configuration: map-underscore-to-camel-case: true不配这个就是经典灾难现场数据库字段create_time映射不到VO里的createTime查出来全是null排查半天还以为是SQL写错了。这个配置建议默认就打开。3. 核心功能代码实战3.1 登录注册与JWT鉴权登录这块很多人喜欢直接引入Spring Security或者Shiro但说实话对一个API服务来说用拦截器加JWT是最轻量、最可控的方案也是项目里最能讲清楚逻辑的代码。流程是这样的用户注册时后端用BCryptPasswordEncoder对密码加密存库登录时用matches方法比对明文和密文比对成功后用jjwt库生成一个包含用户id和过期时间的Token返回给前端。前端把Token存在localStorage里每次请求在Authorization头带上。后端有个拦截器统一拦截除登录注册外的所有接口解析Token并校验有效期然后把当前用户信息放入ThreadLocal或请求域中供Controller直接取用。拦截器的核心代码并不长public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(未登录); } Claims claims JwtUtil.parseToken(token.replace(Bearer , )); request.setAttribute(userId, claims.get(userId)); return true; } }然后通过WebMvcConfigurer注册拦截器并设置排除路径registry.addInterceptor(new JwtInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register, /api/footprint/list);这里有个细节拦截器里直接抛出异常时SpringBoot默认会返回一个错误状态码页面而不是JSON。为了前后端好对接需要再写一个RestControllerAdvice全局异常处理器把异常统一转换成{code: 401, message: 未登录}这种格式。JWT的密钥不要硬编码在代码里放配置文件里并且生产环境要用足够长的随机字符串。过期时间一般设成2到24小时太短用户频繁被踢下线太长有安全隐患。真要做多端保持登录后续可以引入Redis做Token刷新这个属于扩展不是当前阶段的重点。3.2 图片上传与静态资源映射图片上传是游迹系统最容易出问题的点因为涉及配置项多、路径容易出错、跨域也可能来添乱。我用的方案是前端通过multipart/form-data上传文件SpringBoot用MultipartFile接收保存到服务器本地指定目录然后返回一个可访问的URL。保存文件的代码非常标准PostMapping(/api/upload) public ResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件不能为空); } String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String newFileName UUID.randomUUID().toString().replace(-, ) suffix; String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); File dir new File(uploadPath datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir.getAbsolutePath(), newFileName)); String url /upload/ datePath / newFileName; return Result.success(url); }按日期分目录存储一方面避免单个文件夹文件过多另一方面方便以后按时间清理或做备份。文件名用UUID重命名防止用户上传的“风景.jpg”这种文件名重复或包含中文导致URL乱码。但这里有一个必须配置的坑文件上传后SpringBoot默认不会把本地磁盘目录映射成URL访问。你要在配置类里加一个虚拟路径映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); }否则前端访问/upload/2024/06/01/xxx.jpg会直接404。另外spring.servlet.multipart.max-file-size和max-request-size默认只有1MB和10MB移动端拍的照片动辄3MB起步不改配置的话上传必失败。配置文件里加上spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB最后提醒一个关于“springboot版本太高”的旧坑早期的SpringBoot用spring.http.multipart配置后来改成了spring.servlet.multipart如果你复制了老项目的配置粘贴到2.x版本启动时配置不报错但上传限制还是默认值表现就是小图能传、大图就报错非常迷惑。3.3 定时任务与热门足迹统计游迹系统有个不起眼但很能体现SpringBoot能力的功能定时任务。我用来做什么呢每天凌晨统计一次所有足迹的“热度分”热度分根据近7天点赞量、评论量、浏览量加权计算然后更新到一张t_footprint_hot表中。首页的“热门足迹”板块只查这张热表不需要实时跑复杂的聚合查询。SpringBoot开启定时任务非常简单启动类上加EnableScheduling然后在方法上写Scheduled(cron 0 0 2 * * ?)意思就是每天凌晨2点执行。这里面的时间换算要特别注意SpringBoot的scheduled默认使用服务器的本地时区如果你部署的服务器是UTC时间那就变成了北京时间早上10点执行所以写cron之前先date -R看一下服务器时区。定时任务的代码逻辑大致是Component public class HotFootprintTask { Autowired private FootprintMapper footprintMapper; Scheduled(cron 0 0 2 * * ?) public void calculateHotScore() { ListFootprint list footprintMapper.selectRecentWeek(); for (Footprint f : list) { int score f.getLikeCount() * 3 f.getCommentCount() * 5 f.getViewCount(); footprintMapper.updateHotScore(f.getId(), score); } } }这里建议给定时任务本身加日志和异常捕获。因为定时任务不像接口一旦中途报错你没有任何用户请求能触发错误暴露日志就是唯一的抓手。没有日志的定时任务就像无人值守的机器坏了你都不知道。4. Vue打包与SpringBoot整合部署4.1 为什么最终选择Vue打包放进SpringBoot现在很多教程都推崇前后端彻底分离Vue跑在Node服务上SpringBoot跑在8080端口中间用Nginx做反向代理。但对于游迹共享这个体量的系统来说前后端分离开发没问题分离部署反而多此一举。我把Vue项目通过npm run build打包产出dist目录整个扔进SpringBoot的src/main/resources/static目录下重启项目所有前端页面就和接口同源了。这样做有三个好处。第一部署简单服务器只需要装一个JDK跑一个jar包不用装Nginx和Node。第二不存在跨域问题前端页面和后端接口都是同一个域名同一个端口浏览器的同源策略自然放行。第三Cookie或者Token处理会变得非常顺因为不需要考虑跨域携带凭据这种麻烦事。但有一个前端改造要点必须记得Vue默认的路由模式是hash打包之后放在SpringBoot里访问/没问题但访问/login这种路径刷新时会404因为SpringBoot的DispatcherServlet找不到对应的Controller。我的做法是路由用history模式然后写一个Controller把所有非API且非静态资源的路径转发到index.htmlRequestMapping(value {/, /login, /register, /profile, /detail/**}) public String forward() { return forward:/index.html; }还有Vue里所有请求地址开发环境可能写的是http://localhost:8080/api/xxx打包前要把baseURL改成相对于根路径的/api/xxx否则页面部署上去后所有请求还会指向本地的8080造成白屏和接口报错。4.2 IDEA启动配置与端口修改细节开发阶段最常遇到的一个问题是本机8080端口被其他项目占用了或者你想同时启动两个实例做联调。改进程端口并不需要改代码在IDEA里右键运行配置在Program arguments里填上--server.port8081这样SpringBoot启动时就会覆盖配置文件里的端口。如果你还想改其他配置比如数据库地址、上传路径也可以这样追加--server.port8081 --app.upload-path/tmp/upload。SpringBoot的命令行参数优先级是最高的会覆盖application.yml里的同名配置这一点在排查“为什么改了配置文件没生效”时经常用到。在IDEA里配置SpringBoot启动还有一个隐藏坑如果你之前是用命令行java -jar或Maven插件启动过项目IDEA的Run配置里可能会残留环境变量SPRING_PROFILES_ACTIVE导致启动了错误的profile表现就是你改了application-dev.yml但死活不生效。解决方法是打开Run Configuration在Environment variables里检查并清空变量。另外如果你用的是“springboot版本太高”的3.x项目还需要在IDEA里确认JDK是17或更高并且Maven的Compiler Level也对应调上去否则代码一启动就会报UnsupportedClassVersionError。检查顺序建议是Project Structure - Project SDK - Maven Settings - Java Compiler。4.3 开发模式下的跨域处理虽然最终打包后是同源访问但开发过程中Vue跑在5173端口Vite默认SpringBoot跑在8080端口两者之间就存在跨域。如果你不在开发时解决就得每次打包才能调试效率太低。开发模式我推荐直接用Vite的proxy代理只需要在vite.config.js里配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端代码里所有/api开头的请求在开发环境下会自动转发到8080浏览器看到的是同源请求没有任何跨域困扰。上线打包后因为静态资源和接口同源proxy配置也用不上了完美兼容。有同学非要在后端用CrossOrigin注解或者加CorsFilter我建议别这么做。因为一旦后端设置了固定允许的来源http://localhost:5173以后打包部署换了域名或者端口你还得回来改代码重新打包。用Vite proxy把跨域问题兜在前端开发服务器上后端保持纯净这是最省心的路径。5. 开发中踩过的坑与问题排查速查5.1 版本升级带来的连锁反应“springboot版本太高”是很多同学挂在嘴边的抱怨这背后其实是生态迁移的问题。SpringBoot从2.7升到3.x最明显的变化是javax.*全部换成了jakarta.*。你从网上复制的代码如果还是import javax.servlet.http.HttpServletRequest在3.x环境下直接编译报错改成jakarta.servlet.http.HttpServletRequest就好了。第二个变化是Spring Security的配置方式SpringBoot 3.0以后Security 6把原来重写WebSecurityConfigurerAdapter的方式改成了基于SecurityFilterChain的Bean配置。网上大量老的Spring Security教程直接失效。第三个变化是spring.factories自动装配机制变成了AutoConfiguration.imports如果你要写自定义starter会受这个影响但普通业务代码感知不强。我的建议是除非你有明确的JDK17需求和性能考量否则老老实实停在SpringBoot 2.7.x把精力放在业务本身。技术选型不追新是个人项目一个很实际的原则。5.2 文件上传与路径访问的连环坑之前提到过图片上传后要通过虚拟路径映射才能访问但这个配置还有一个时区陷阱你保存文件时用了LocalDate.now()服务器时区如果是UTC那么存储路径里的日期会跟北京时间差一天。表现就是早上上传的图片目录却是昨天的。解决办法是在配置里显式指定JDBC和Jackson的时区更通用的做法是设置JVM时区在启动参数加-Duser.timezoneAsia/Shanghai。还有一个很常见的“文件上传成功但返回下载”的坑如果MultipartFile.transferTo()抛出了FileSystemException多半是目标目录没有写权限。不要用/root/upload这种权限受限路径放在应用的相对目录下或者专门建一个可写目录通过chmod给权限。5.3 常见问题速查表把我在这个项目上遇到的高频问题和解决办法整理成一张表方便你排查时直接对照。现象可能原因解决方式启动即报Port 8080 was already in use端口被其他进程占用lsof -i:8080找PIDkill -9 PID或改用--server.port换端口页面能打开接口404静态资源被DispatcherServlet拦截配置Controller转发index.html或检查URL是否缺少/api前缀上传图片接口报MaxUploadSizeExceededExceptionmultipart限制太小检查spring.servlet.multipart.max-file-size配置上传成功但浏览器访问图片404没有配置静态资源映射实现addResourceHandlers映射/upload/**到本地目录数据库时间比北京时间少8小时JDBC连接时区未指定URL加serverTimezoneAsia/Shanghai或指定JVM时区MyBatis查询返回所有字段为null驼峰映射未开启mybatis.configuration.map-underscore-to-camel-case: trueVue打包后刷新页面404路由history模式缺少转发写一个转发Controller指向index.html前端接口请求403拦截器把静态资源或OPTIONS请求也拦了排除静态资源路径放行OPTIONS请求spring-boot-maven-plugin打包后无法启动缺少mainClass或jar包损坏指定mainClass执行mvn clean package重打接口返回的JSON里时间格式是时间戳Jackson序列化格式未配置spring.jackson.date-format和time-zone一起设置我特别想强调一下最后一行那个时间格式问题。默认情况下SpringBoot会把LocalDateTime序列化成数组或者时间戳前端展示非常不友好。最好在配置文件里统一做全局格式化spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai这样所有接口返回的时间字段都会自动变成可读字符串省去每个VO上加JsonFormat注解的麻烦。5.4 关于自动装配的两个观察整个项目做完你可能对自动装配原理有了更直观的感受。SpringBoot启动时通过EnableAutoConfiguration扫描META-INF/spring/spring.factories文件加载了一堆xxxAutoConfiguration类。这些自动配置类上都有ConditionalOnClass、ConditionalOnMissingBean等条件注解只有满足条件才会生效。比如你引入spring-boot-starter-web后DispatcherServletAutoConfiguration检测到Servlet相关类存在就会自动配置DispatcherServlet。这个机制的好处是你用MyBatis时就加MyBatis starter用Redis时就加Redis starterSpringBoot会自动把这些组件的Bean注册好。真正的难点在于当你需要覆盖默认配置时要知道该在application.yml里改哪个key、或者自己定义哪个类型的Bean。排查这类问题时我会打开IDEA的自动配置报告运行的时候加一个--debug参数启动日志里就会输出所有自动配置类的匹配结果哪些生效哪些被排除一目了然。最后再分享一个小建议这个项目做完之后我最大的一个体会是别急着动手写代码先把数据流转的链路画出来把表结构定下来后面写接口就是流水线作业。如果硬要我提供一个后续扩展的方向我会建议在现有基础上加一个“足迹地图”页面调用高德或百度地图的JavaScript API把足迹数据按经纬度标记在地图上。这个功能视觉冲击力很强但技术上并不难对简历或毕设答辩来说都是很亮眼的加分项。