毕设选题的时候,我其实很纠结。Java Web方向的题目翻来覆去就那么几类,图书管理、商城、订餐系统,看着都像“Hello World换皮”。后来我敲定了“SpringBoot+Vue 靓车汽车销售网站平台”这个方向,不是因为它名字听起来够响亮,而是因为它同时覆盖了商品展示、交易流程、预约服务、后台管理这几条完整业务线,前后端分离的沟通成本也足够真实。做完之后,手上沉淀下来三样东西:能直接跑的源码、一套初始化SQL脚本、一份可以交给下一个开发者看的接口文档。这篇文章就把这三样东西背后的设计和踩过的坑,原原本本讲一遍。
1. 为什么选汽车销售作为Java Web毕设:不止是“有界面”这么简单
1.1 选题时的需求分析思路
选题的时候,我给自己定了一条标准:题目不能小到没有故事可讲,也不能大到半年做不完。汽车销售网站恰好落在中间。它表面上是卖车,实际上是一个包含用户、商品、订单、服务、内容管理等基础业务域的综合性系统。用户在前台浏览车型、搜索筛选、收藏、预约试驾、下单支付;管理员在后台维护车辆信息、处理订单、管理用户和公告。这一整套流程,既需要前端做交互体验,又需要后端做业务状态流转,还要数据库设计支撑多张表之间的关联查询。
从技术深度来看,这个题目可以做得非常浅,只写增删改查;也可以做得有层次,比如在动态条件查询里用上拼接条件、在跨表统计里用聚合函数、在权限控制里引入JWT。毕业设计答辩时,老师关心的不是你用了多少新技术,而是你有没有把“业务怎么跑”这件事讲清楚。汽车销售的订单状态不是一锤子买卖,它从待支付到已完成之间有多步流转,这就是天然的深度。
再考虑工作量。如果做成纯后端管理的“汽车信息管理”会很单薄;如果做成包含完整电商支付的系统又太复杂。我最终把支付环节做成模拟支付,把核心精力放在车型展示、订单流转、后台审核这三块。一个多月时间,每天保持稳定的开发节奏,完全来得及。
1.2 三大交付物如何界定
做毕设和做公司项目不一样,公司项目只要代码能上线就行,但毕设要交材料、要答辩、要被检查。我提前把交付物拆成了三样:
第一是源码,分为server端和web端两个工程。server端是SpringBoot工程,负责提供REST接口;web端是Vue工程,负责页面渲染和交互。源码里必须包含完整的业务代码,不能只有Demo级别的空壳。
第二是SQL脚本,这一步很多人会忽略。评审老师如果拿到你的项目却跑不起来,第一反应就是看SQL脚本是不是完整、能不能直接初始化。我写的SQL脚本里包含了建库、建表、添加外键约束、插入基础数据四部分,保证在全新数据库上执行一次就能得到一个可运行的环境。
第三是接口文档,它解决的问题是“别人拿到你的后端代码后,不知道怎么调”。我用了Swagger自动生成接口列表,同时补了一份Markdown格式的手写说明,把每个接口的请求方式、入参、出参、业务含义写清楚。这样一来,就算前端不是我写的,其他同学也能独立对接。
把交付物定义清楚了,后面的开发节奏就不容易乱。我的习惯是先把数据库脚本写出来,再写后端接口,最后搭前端页面。顺序反过来的话,后期改表结构会非常痛苦。
2. 技术栈和数据库设计:把“能跑”变成“能答辩”
2.1 为什么是SpringBoot + Vue这对组合
技术选型肯定是答辩时躲不过的问题。我选SpringBoot + Vue,核心原因是这两个框架在各自领域都足够主流,且学习资料齐全。SpringBoot解决了传统SSH项目配置地狱的问题,内嵌Tomcat,打包后一个jar直接跑;Vue用组件化结构组织页面,数据双向绑定让表单交互写起来很顺手。
后端我额外用了MyBatis Plus,不是因为它“高级”,而是因为它能把单表CRUD的样板代码省掉一半,让我把时间花在业务逻辑上。权限方案选了JWT,因为前后端分离的项目里,基于Session的登录方案要处理跨域Cookie问题,JWT把用户身份信息放在Token里,后端只需要在拦截器里校验签名和有效期。
数据库用MySQL 8.0,前端UI框架用Element UI,网络请求用Axios。这套组合在当年已经是被验证过无数次的“标准答案”,好处是碰到任何问题,搜索一下都能找到对应的解决方案。答辩时如果被问“为什么不用其他技术”,我的回答是:稳定比新奇更重要,毕设的重点是完整交付,不是当新技术试验田。
2.2 九张核心表如何串起汽车销售闭环
数据库设计是整个项目的地基。我一开始画了十几张表,后来发现很多表其实可以合并。最终保留九张核心表,它们之间的关系足够表达业务闭环。
| 表名 | 用途 | 关键字段 |
|---|---|---|
| user | 前台注册用户 | id、username、password、phone |
| admin | 后台管理员 | id、account、password、role |
| brand | 汽车品牌 | id、name、country |
| car | 车型信息 | id、brand_id、model、price、fuel_type、image、status |
| order | 订单表 | id、order_no、user_id、car_id、total_price、status、create_time |
| test_drive | 试驾预约 | id、user_id、car_id、appointment_time、contact_name、phone、status |
| favorite | 收藏记录 | id、user_id、car_id |
| message | 在线留言 | id、user_id、content、reply、create_time |
| notice | 公告信息 | id、title、content、create_time |
user和admin用两张表分开,是因为权限完全不一样。car表里的brand_id关联brand表,价格字段用decimal而不是float,避免金额精度出问题。order表用order_no做唯一订单号,方便在业务上追踪;status字段保存订单当前状态。
设计表结构时最有价值的一个决定是:把“用户收藏车辆”和“用户下单购买”拆成了favorite和order两张表。乍看都是关联用户和车辆,但语义完全不同。收藏是意向行为,不涉及价格;订单是交易行为,必须记录快照价格。后续扩展的时候,我可以给favorite加备注字段,给order加支付方式字段,互不影响。
2.3 前后端分离下的权限模型
权限这块是很多毕设容易做得含糊的地方。我采用的是“JWT + 登录拦截器”的方案。用户在登录接口提交账号密码,后端验证成功之后生成一个Token,Token里存放用户id和角色信息,并用密钥签名。前端拿到Token之后存在localStorage里,后续请求在Axios拦截器中统一加上Authorization头。
后端有一个AuthInterceptor,对所有/api/**请求进行拦截。白名单之外的请求如果没带Token或者Token校验失败,直接返回401状态码和统一错误信息。对于管理员接口,我会在Controller层加一个@RequireRole("admin")手动校验,或者直接在拦截器里根据请求路径前缀判断。
这里踩过一个坑:一开始我用JWT里只放userId,每次请求都去数据库查用户信息,导致拦截逻辑里多了一次查询。后来改成在Token里同时放userId和role,虽然Token载荷变大了一点,但省掉了频繁查库,整体响应更快。对于毕设来说,这已经足够了,不需要引入Redis做Token黑名单之类的复杂方案。
3. 功能模块实现细节:从用户看车到下单的全链路
3.1 车辆列表的动态条件查询
车辆展示是前台最重要的模块。用户可以在列表页根据品牌、价格区间、燃油类型三个条件筛选车型,同时还要支持分页。如果给每一种组合都写一条SQL,代码会变得又臭又长。我用MyBatis Plus的LambdaQueryWrapper动态拼接条件,核心逻辑就是用if判断前端传过来的参数是否为空,不为空才加入查询条件。
LambdaQueryWrapper<Car> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(brandId)) { wrapper.eq(Car::getBrandId, brandId); } if (minPrice != null) { wrapper.ge(Car::getPrice, minPrice); } if (maxPrice != null) { wrapper.le(Car::getPrice, maxPrice); } if (StringUtils.hasText(fuelType)) { wrapper.eq(Car::getFuelType, fuelType); } wrapper.eq(Car::getStatus, 1); // 只展示上架车型 Page<Car> page = carMapper.selectPage(new Page<>(current, size), wrapper);这样做的好处是不需要为每个筛选条件组合写SQL,代码清晰,万一以后新增“按座位数筛选”,只需要再加一个if分支。还有一个细节:查询列表时默认只查status为1的上架车辆,防止用户看到管理员还没审核通过的车型。
这个模块最容易出的问题是分页插件没配置,导致selectPage返回的数据不正确。我在配置类里加了MybatisPlusInterceptor并注册PaginationInnerInterceptor,同时把数据库连接地址的时区参数加上,否则日期字段会差8个小时。
3.2 订单状态机的设计
订单状态如果处理不好,后期改起来会劝退自己。我定义了五个状态:待支付、已支付、已确认、已完成、已取消。用户下单生成待支付订单,模拟支付后变成已支付;管理员在后台确认订单后变成已确认;车辆交付完成后变成已完成;用户在支付前可以取消订单变成已取消。
在代码里我没有用魔法数字,而是建了一个枚举类:
public enum OrderStatus { UNPAID(0, "待支付"), PAID(1, "已支付"), CONFIRMED(2, "已确认"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"); }每个状态流转都写在Service层的方法里,比如pay(orderId)方法里先判断当前状态是不是UNPAID,如果是才允许改为PAID,否则抛出业务异常。这样做虽然多写几行校验代码,但能避免“从已完成状态又改回待支付”这种荒诞情况。
订单号的生成也不建议用自增id直接当订单号,那样容易被猜测。我用的方案是yyyyMMddHHmmss + 随机数拼成20位以内的唯一字符串。测试阶段为了方便,我还保留了一个幂等校验:同一个用户对同一辆车如果已经存在未取消的订单,就提示“您已提交过该车型的订单”,防止用户重复下单。
3.3 试驾预约与留言模块的边界处理
试驾预约和订单是两个容易混淆的业务。我之前设计时差点把它当成订单的一种,后来想明白了:试驾预约不涉及支付,只是记录用户意向和预约时间,所以它的状态只有待联系、已完成、已取消。
预约表单里有姓名、手机号、预约时间三个必填字段。后端要做的事除了保存记录,还要做手机号格式校验和预约时间不能是过去时间这两条规则。我在Controller层用参数校验注解,在Service层再手动校验业务规则,双保险。
留言模块更加简单,但有一个点值得注意:用户留言之后,管理员需要能在后台回复。如果把回复内容直接存到message表里,就会出现“回复”和“留言”混在一起的情况。我采用的是在message表里增加一个reply字段,用户留言时reply为空,管理员回复时更新这个字段。前端展示留言列表时,如果reply不为空就显示回复内容,否则显示“等待回复”。这比单独建一张回复表更适合小体量项目。
3.4 Vue前端的关键封装
前端部分如果每个页面都重复写请求代码,项目会非常散乱。我做了两个关键封装。
第一个是Axios实例封装。在src/utils/request.js里创建Axios实例,设置baseURL为/api,然后在请求拦截器里把本地存储的Token塞进Header,在响应拦截器里统一处理状态码。后端返回的格式是{ code: 0, message: "success", data: {...} },如果code不为0,就弹出对应的错误提示,前端业务代码不用每个接口都写错误处理。
第二个是路由守卫。在router/index.js里设置beforeEach导航守卫,如果访问需要登录的页面且本地没有Token,就跳转到登录页。后台管理路由额外加一层角色判断,普通用户即使手动输入地址也进不去。这样前后端的权限就形成了呼应,答辩时可以讲“前端守卫只是优化体验,真正的安全在后端接口校验”。
页面复用上,我把车型卡片抽成了公共组件CarCard.vue,在首页、列表页、收藏页都能用。每次只传一个car对象进去,组件内部处理图片、价格、品牌的展示,点击卡片跳转到详情页。写Vue组件最重要的一点是props定义要清晰,不要把接口返回的整个对象直接塞进去,最好在父组件里做一层数据转换,避免子组件绑定到不存在的字段。
4. SQL脚本和接口文档:不起眼但决定成败
4.1 SQL脚本的版本化与初始化策略
很多同学写SQL脚本就是随手导出一下,结果评审老师执行的时候要么报外键错误,要么缺基础数据。我花了整整一个下午来整理SQL脚本,标准是“从零环境到可运行只需一步”。
我的脚本文件结构固定分四段。第一段是CREATE DATABASE和USE,第二段是建表语句,第三段是外键约束和索引,第四段是INSERT测试数据。建表语句全部用IF NOT EXISTS,避免重复执行报错。外键约束放在表创建之后而不是直接写在建表语句里,这样更清晰,也方便修改。
基础数据不能只是随便填几条。我至少放了一百条测试数据,包括六个品牌、每个品牌下多款车型、一个默认管理员账号、一个测试用户账号、几条订单和留言。这样启动项目后马上能看到完整效果,不需要用户自己造数据。
执行SQL脚本也踩过坑:MySQL 8.0默认字符集是utf8mb4,如果脚本里用了utf8,中文还是没问题;但如果是通过在服务器命令行导入,会经常遇到Unknown database错误,原因就是脚本数据库名和当前连接的库名不一致。我的解决办法是在脚本最前面加上CREATE DATABASE语句,并用mysql -u root -p < script.sql全量导入,确保不依赖当前连接状态。
4.2 接口文档的三种呈现方式
接口文档我做了三层,分别满足不同场景。第一层是Swagger的在线文档,后端引入springfox-swagger2和springfox-swagger-ui,启动项目后访问/swagger-ui.html就能看到所有接口。为了让文档更规范,我在Controller层的每个方法上都加了@ApiOperation注释,在实体类的字段上加了@ApiModelProperty注释。
第二层是Markdown格式的手写接口文档,我把它放在项目根目录的docs/接口文档.md里。这份文档不是为了凑字数,而是真正写清楚了每个接口的业务用途和调用示例。比如订单创建接口,我会写“前端先调用车辆详情接口获取车型id,再调用订单创建接口,传参userToken自动识别用户身份”。这种灵活性说明是Swagger自动生成的文档不会包含的。
第三层是Postman导出的Collections文件。前端和我在联调阶段共用一个Postman集合,里面保存了每个接口的请求示例和测试数据。这样即使Swagger因为环境问题打不开,也能用Postman快速验证接口。三个方式覆盖了不同场景,也让我在答辩时“接口文档怎么保证可维护性”这个问题上有的讲。
4.3 联调时常见的接口问题
前后端联调是这个项目最磨人的阶段,我总结出三个高频问题。
第一个是跨域问题。前端访问http://localhost:8080/api/car/list,后端跑在http://localhost:8081,浏览器的同源策略直接把请求拦了。解决办法是后端写一个CorsFilter配置类,允许指定来源、指定方法、暴露Header。不要写allowedOrigins("*"),这样和携带Cookie的请求会冲突,最好明确写外卖前端地址。
第二个是时间格式化问题。后端LocalDateTime默认序列化成2024-01-01T10:00:00,前端却期望2024-01-01 10:00:00。我一开始用@JsonFormat挨个字段加注解,后来发现太麻烦,直接在application.yml里配置了全局Jackson格式,一劳永逸。
第三个是参数名不一致问题。前端传carId,后端接口参数写的是id,结果每次绑定都为null。原因是后端没加@RequestParam("carId"),SpringBoot的默认参数名推断又因为编译配置没生效而失灵。后来我强制要求所有请求参数都显式标注@RequestParam或@RequestBody,不依赖参数名推断,彻底解决了这个问题。
5. 部署上线与踩坑记录:本地能跑真的只是开始
5.1 JDK/Maven版本与依赖冲突
本地开发时我的JDK是1.8,Maven是3.6,一切正常。后来换了一台电脑想复现环境,结果SpringBoot工程一启动就报Invalid or unreadable WAR file。查了半天发现是JDK版本变成了17,而我用的SpringBoot 2.7虽然支持到JDK17,但某些依赖的类库仍然有兼容问题。最后老老实实把本机JDK切成1.8,世界才安静下来。
依赖冲突也比较典型:MyBatis Plus 3.x与Swagger的某个旧版本会冲突,启动时报Failed to introspect Class。解决办法是把Swagger版本升到2.9.2以上,并且排除掉它自带的旧classmate依赖。这些坑如果你不看日志,永远想不到是两个开源库打架。我的建议是写一个dependencies闭包的统一版本管理,能显著减少冲突。
5.2 跨域问题的三种解决方案
跨域问题前面提过,但实际部署时还会遇到新形态:本地联调没问题,部署到服务器后前端和后端不在同一台机器上,跨域又冒出来了。我梳理了三种解决办法,按适用场景排列。
第一种是后端全局限流放松跨域,适合前后端分离的开发环境。第二种是前端开发环境用Vue的proxy代理,在vue.config.js里配置proxy: { '/api': 'http://localhost:8081' },这样浏览器请求的是同源的/api,实际后端在代理转发。这种方式只解决开发环境,打包后就没用了。
第三种是生产环境用Nginx反向代理,把同一个端口下的/api路径转发到后端服务。比如前端dist部署在Nginx的html目录里,访问/api/car/list时Nginx将请求转发到http://127.0.0.1:8081/api/car/list,前端代码里的baseURL保持/api不变,这样就不存在跨域了,因为浏览器访问的是服务器上的同源地址。
最终我选的就是第三种,它最接近真实项目上线场景,答辩的时候也很有说服力。唯一的坑是Nginx的proxy_pass路径拼接问题,如果配置为proxy_pass http://127.0.0.1:8081;不加路径,后端接口仍然需要保留/api前缀;如果加了/api路径,后端对应的Controller上就可能不要再带/api,两边的匹配规则必须想清楚。
5.3 打包部署的完整流程
部署这一步如果做得顺,会让人觉得整个项目很完整。我的操作流程是这样的:
后端:在根目录执行mvn clean package -DskipTests,生成target/xxx.jar。用java -jar xxx.jar启动,注意生产环境的数据库地址、用户名、密码通过启动参数覆盖,不要写死在配置文件里。这样打包的war可以直接部署到云服务器上,无图模式也能外网访问。
前端:执行npm run build,生成dist目录。把dist目录下的文件上传到服务器,放到Nginx配置的根目录下。注意Vue项目如果是history模式,刷新页面会出现404,需要在Nginx配置中加入try_files $uri $uri/ /index.html;。这个坑特别隐蔽,我第一次部署后从首页跳转到列表页没问题,但手动刷新就白屏,排查了好一会才想起是history模式。
数据库初始化:在服务器上执行mysql -u root -p < init.sql,然后确认基础数据插入成功。如果服务器MySQL版本是5.7而不是8.0,脚本里用了utf8mb4_0900_ai_ci这种8.0专属排序规则就会报错。我后来把排序规则改成通用的utf8mb4_general_ci,兼容性更好。
6. 答辩时的讲解思路与项目复盘
6.1 怎么讲这个项目才不像念PPT
答辩时间通常只有几分钟,所以讲解顺序很重要。我的经验是“先讲业务闭环,再讲技术亮点,最后讲交付物”。开场不要一上来就放ER图,而是先描述“用户进入系统后可以看到哪些车,怎么筛选,怎么预约试驾,管理员在后台如何审核订单”,让老师脑海里先建立起系统图像。
然后挑三个技术点重点讲:动态条件查询怎么避免SQL注入和冗余代码、订单状态机怎么保证业务流程严谨、JWT权限怎么在前后端分离下生效。这三个点都是我实际写过并在调试中碰到过问题的,讲解时有真实验证细节,比背概念自然得多。
最后展示SQL脚本和接口文档。我直接打开脚本文件,指出“这里是初始化数据,那里是外键约束”,再打开Swagger页面演示调用一个接口给老师看。这一套流程下来,老师基本不会怀疑项目是不是自己做的,因为每个交付物之间都是能互相印证的整体。
6.2 答辩常见问题预演
有几个问题几乎必问,我提前准备了答案。比如“这个项目你遇到的最大难点是什么?”我会选动态条件查询+分页那一块,因为涉及到MyBatis Plus插件的配置和条件拼接,是实打实解决过的问题。又比如“为什么订单状态不用int而是要枚举?”我会说枚举让代码可读性变好,并且状态流转的判断逻辑集中在Service层,比散落在Controller里更容易维护。
还有一个常见问题:“如果同时有一百个人下单,你的系统会不会崩?”我承认这个项目确实没有做高并发设计,但我会补充说,如果真的要考虑,可以在下单接口加Redis分布式锁或者用乐观锁版本号机制,目前因为是毕设,优先保证业务完整性和数据一致性。坦率承认不足并给出改进方向,比硬吹效果更好。
6.3 个人复盘与后续扩展
做完这个项目,我最深的感受是:完整交付比新奇技术重要得多。很多同学喜欢在中途不断换技术栈,最后连一个能稳定运行的版本都没做出来。我的做法是先确定一个“最小完整闭环”,从用户登录、看车、下单到后台确认,走通之后再逐步加收藏、留言、公告这些边角功能,每一步都能运行,心里才不慌。
这个项目后续还可以顺理成章地扩展:给车辆表加库存字段,把模拟支付换成真实支付;在试驾预约中加一个到店提醒;把静态CRUD改成带统计报表的管理首页。但这些都是后话,毕设阶段能把当前这套流程做扎实、文档写清楚,已经足够有价值。
最后再分享一个小习惯:我在编码过程中坚持每次修改完在Git提交一次,commit message写清楚“完成接口”“修复跨域”之类的关键词。答辩前一天,我把提交记录从头翻了一遍,很快理清了整个项目的开发脉络。这份提交历史比任何PPT都能证明你的工作量,也让你在讲“我在这个项目中做了什么”的时候,心里非常有底。