做毕设最怕什么?不是不会写代码,而是拿到题目之后不知道从哪下手,做到一半发现功能串不起来、答辩时说不出设计理由。汽车销售网站这个题看上去是标准的“增删改查”,实际上要把用户端看车、预约试驾、在线下单,加上后台的车辆维护、订单审核、销售统计全部串起来,工作量已经接近一个小型商业项目。这篇文就以“靓车汽车销售平台”为例,把基于 SpringBoot + Vue + MySQL 的完整实现思路拆开讲清楚:表怎么建、接口怎么写、页面怎么搭、论文怎么组织、最后怎么打包部署。正在做毕业设计选题、准备拿这个项目练手的同学,或者已经被导师问过“这个项目有什么亮点”却不知道该怎么回答的人,都可以直接对照着来。
1. 先看懂题目:这套系统到底要解决什么问题
1.1 两个角色的使用场景
很多同学一上来就写代码,这是最要命的。写代码之前必须先分清系统里有哪些角色,每个角色打开系统之后能看到什么、能做什么。靓车平台在设计上就两个角色,一个是普通用户,一个是管理员,权限边界非常清晰。
普通用户的使用路径是:打开首页看轮播图和推荐车型,进入列表页按品牌、价格、车型筛选,点进详情页看图、看配置、看价格,然后注册登录,提交预约试驾或者直接下单。整个流程对应的是现实中“逛4S店”的行为——用户先看车,再咨询,最后成交,而不是像淘宝那样直接付款。这一点在业务设计上很重要,汽车这种大宗商品通常不走在线支付,所以订单模块的核心是状态流转管理,不是支付接口对接。
管理员的使用路径是:登录后台,在统计面板看销量走势,维护品牌和车型信息,上传车辆图片,查看用户提交的订单并确认或驳回,处理留言咨询,发布公告。后台所有操作都是对前台数据的实时驱动——管理员上架一台车,前台立刻可见;管理员确认一笔订单,用户端的状态马上变化。
1.2 功能清单与边界
我在需求分析阶段会把功能拆成一张表,这张表其实就是论文第三章需求分析的核心素材:
| 角色 | 功能模块 | 具体功能点 |
|---|---|---|
| 普通用户 | 车辆浏览 | 首页轮播、品牌分类、列表分页、价格筛选、关键词搜索 |
| 普通用户 | 车辆详情 | 多图展示、参数信息、价格、预约试驾入口 |
| 普通用户 | 账号体系 | 注册、登录、个人信息修改、密码找回 |
| 普通用户 | 订单服务 | 在线订购、预约试驾、订单列表、取消订单 |
| 普通用户 | 互动功能 | 留言咨询、查看公告 |
| 管理员 | 统计面板 | 销量趋势图、品牌占比图、订单总数 |
| 管理员 | 车辆管理 | 车型增删改查、图片上传、上下架 |
| 管理员 | 品牌管理 | 品牌增删改查 |
| 管理员 | 订单管理 | 订单查询、确认、驳回、完成 |
| 管理员 | 用户管理 | 用户列表、禁用/启用 |
| 管理员 | 内容管理 | 公告发布、留言回复 |
从这张表能看出来,系统没有做支付、没有做优惠券、没有做多级分销,刻意保持了业务边界。毕设最忌讳的就是功能贪多,支付接口涉及到第三方对接和资质问题,做进来只会给自己添麻烦,答辩时也讲不明白。把上面这些功能做扎实,工作量已经足够撑起一篇合格的毕业论文。
1.3 为什么这个体量的题最适合当毕业设计
汽车销售网站属于典型的“业务真实、技术常规、规模可控”题目。业务真实意味着论文有东西可写——研究背景、需求分析、业务流程都能落到实际场景里;技术常规意味着所用技术栈是主流的 SpringBoot、Vue、MySQL,导师熟悉、参考资料多、网上能搜到大量同类问题解决方案;规模可控意味着一个人在一个学期内能独立完成,不需要团队协作也不依赖特殊硬件。
更重要的是,这个题目有明确的扩展空间。答辩时如果被问“你的系统还能怎么改进”,可以顺着说:接入在线支付完成真正交易、加一个智能推荐模块做相似车型推送、把单机部署改造成集群部署。这些话不需要真的实现,但说明你对系统的未来方向有思考,这在评审里非常加分。
2. 技术选型不是跟风:每个组件都是为毕设服务的
2.1 后端选型与版本考量
项目标题写的是 SpringBoot + Vue + MySQL,这是大方向,但具体版本和配套组件才是真正决定开发体验的地方。
SpringBoot 我建议选 2.7.x,而不是最新的 3.x。原因很简单:2.7.x 支持 JDK 8,绝大多数同学的电脑上装的就是 JDK 8,而且网上的教程、博客、报错解决方案绝大部分都是基于 2.7 或更早版本写的,遇到问题一搜就有答案。SpringBoot 3.x 强制要求 JDK 17,如果你不是特别熟悉新版特性,没必要在这个环节给自己挖坑。
ORM 框架用 MyBatis-Plus 而不是原生 MyBatis 或者 JPA。MyBatis-Plus 在单表操作上基本不用写 SQL,内置分页插件、逻辑删除、字段填充,能把开发效率提升一大截。毕设的核心精力应该花在业务流程上,而不是花在写十几行重复的 insert 语句上。当然,涉及连表查询或者复杂条件时,还是直接用@Select注解写原生 SQL 更直观。
权限认证这里有个关键选择:用 Spring Security 还是自己写拦截器。我的建议是自己写,JWT + 拦截器就够了。Spring Security 的过滤器链对于初学者来说太黑盒,一旦配置出问题很难排查,而自己写拦截器整个逻辑就三五个类,答辩时你能从头到尾讲清楚每个环节在干什么,这种“讲得清”的能力比炫技重要得多。
2.2 前端选型与版本考量
前端现在主流的组合是 Vue 3 + Element Plus + Vite。如果你拿到的是老教程,可能是 Vue 2 + Element UI + Vue CLI,功能上也能实现,但既然是从零开始,建议直接用 Vue 3:组合式 API 写起来代码更紧凑,Element Plus 组件库在视觉上更现代,Vite 启动速度比 Webpack 快好几倍,开发体验完全不在一个档次。
组件库建议选 Element Plus,表格、表单、弹窗、分页这些后台管理界面需要的东西它都有,而且默认样式规整,不需要额外写 CSS 也能见人。状态管理用 Pinia,比 Vuex 精简很多,实际项目中可能只在用户信息存储时用一下。图表用 ECharts,管理端统计面板的两个图就是靠它实现的。
2.3 工程目录结构设计与接口风格约定
前后端分离的项目,第一步就是把工程目录定好。我习惯这样组织:
car-sales-platform/ ├── backend/ # SpringBoot后端 │ ├── src/main/java/com/liangche/ │ │ ├── controller/ # 控制层 │ │ ├── service/ # 业务层 │ │ ├── mapper/ # MyBatis-Plus的Mapper接口 │ │ ├── entity/ # 实体类 │ │ ├── dto/ # 接收参数的DTO │ │ ├── vo/ # 返回前端的视图对象 │ │ ├── config/ # 跨域、拦截器、资源映射等配置 │ │ └── common/ # 统一返回结果、异常处理、JWT工具 │ └── src/main/resources/ # application.yml、mapper.xml ├── frontend/ # Vue前端 │ ├── src/api/ # 按模块封装的请求方法 │ ├── src/router/ # 路由配置 │ ├── src/views/ # 页面组件 │ │ ├── front/ # 前台页面 │ │ └── admin/ # 后台页面 │ ├── src/components/ # 公共组件 │ └── src/utils/ # axios封装、工具函数 └── sql/ # 数据库初始化脚本接口风格统一走 RESTful:车辆资源是/api/car,订单资源是/api/order,用 HTTP 方法区分操作——GET 查询、POST 新增、PUT 修改、DELETE 删除。所有接口返回统一的 JSON 格式:{ code: 200, message: "操作成功", data: {...} }。前端的 axios 封装会对这个格式做统一处理,这样后端只需要关心业务,不需要每个接口单独写返回逻辑。
3. 数据库设计:先把“车卖出去”的业务模型想清楚
3.1 核心表结构:用户、车型、订单
数据库是整个系统的地基,表设计错了后面全要返工。靓车平台的核心表就五张:用户表 t_user、品牌表 t_brand、车型表 t_car、订单表 t_car_order、公告表 t_notice。留言咨询表如果要做可以再加,但那属于锦上添花,先把核心跑通。
用户表要区分管理员和普通用户,用role字段就行,不需要拆成两张表。车型表是信息量最大的表,但也没必要把所有参数都做成字段,像油耗、变速箱这种展示型参数可以直接塞进 description 文本里,前台详情页整块渲染,这样既灵活又省事。
3.2 建表SQL示例(MySQL 8.0)
直接给一份能用的建表脚本,你在 MySQL 里执行就行:
CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密后)', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `role` tinyint NOT NULL DEFAULT '1' COMMENT '0-管理员 1-普通用户', `avatar` varchar(255) DEFAULT NULL COMMENT '头像路径', `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `t_brand` ( `id` bigint NOT NULL AUTO_INCREMENT, `brand_name` varchar(50) NOT NULL COMMENT '品牌名称', `logo` varchar(255) DEFAULT NULL COMMENT '品牌Logo路径', `sort` int DEFAULT '0' COMMENT '排序权重', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='品牌表'; CREATE TABLE `t_car` ( `id` bigint NOT NULL AUTO_INCREMENT, `brand_id` bigint NOT NULL COMMENT '所属品牌ID', `title` varchar(100) NOT NULL COMMENT '车型标题,如2024款 2.0T 旗舰版', `price` decimal(12,2) NOT NULL COMMENT '价格(元)', `car_type` varchar(20) DEFAULT NULL COMMENT '轿车/SUV/MPV/新能源', `color` varchar(20) DEFAULT NULL COMMENT '颜色', `displacement` varchar(30) DEFAULT NULL COMMENT '排量或动力描述', `seats` int DEFAULT NULL COMMENT '座位数', `cover` varchar(255) DEFAULT NULL COMMENT '封面图路径', `images` text COMMENT '详情图路径,逗号分隔', `status` tinyint NOT NULL DEFAULT '1' COMMENT '0-下架 1-在售 2-已售', `description` text COMMENT '详细描述', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_brand_status` (`brand_id`,`status`), KEY `idx_price` (`price`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车型表'; CREATE TABLE `t_car_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `user_id` bigint NOT NULL COMMENT '下单用户ID', `car_id` bigint NOT NULL COMMENT '车型ID', `car_title` varchar(100) DEFAULT NULL COMMENT '下单时车型标题快照', `price` decimal(12,2) DEFAULT NULL COMMENT '下单时价格快照', `order_type` tinyint DEFAULT '1' COMMENT '1-在线订购 2-预约试驾', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-待处理 1-已确认 2-已完成 3-已取消', `contact_name` varchar(50) DEFAULT NULL COMMENT '联系人', `contact_phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `remark` varchar(255) DEFAULT NULL COMMENT '用户备注', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`), KEY `idx_car` (`car_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; CREATE TABLE `t_notice` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '公告标题', `content` text COMMENT '公告内容', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公告表';3.3 容易被忽略的字段级决策
密码字段长度设 100 而不是 50,因为 BCrypt 加密后的字符串固定是 60 位,你用 varchar(50) 存,一注册就报数据过长,这是初学者踩得最多的坑。
价格字段用decimal(12,2)而不是double。double 是浮点数,累计运算会出现精误差,比如 0.1 + 0.2 不等于 0.3。金额是敏感数据,必须用定点数。
订单表里我特意加了car_title和price两个“冗余字段”,存的是下单那一刻的车型标题和价格快照。为什么?因为管理员有可能改价格、改标题,如果订单只关联 car_id,前台显示的历史订单信息就会跟着变,这不符合业务逻辑。这种“下单快照”设计在电商系统里是标准做法,论文里写一句“通过冗余字段保证订单历史数据的稳定性”,就是加分项。
所有表都带逻辑删除标记deleted,而不是物理删除。用户误删了车型,管理员在后台还能恢复,这个功能在答辩演示时很出效果。
3.4 订单状态与预约试驾的业务闭环
订单状态我用 tinyint 存数字,代码里定义一个常量类做映射,不建状态表,因为状态流转简单且固定。在线订购的状态路径是:待处理 → 已确认 → 已完成,中间可以随时取消,取消后车辆重新回到在售状态。预约试驾的状态路径稍微不同:用户提交后是待处理,管理员确认后生成试驾安排,试驾完成就标记已完成,并不涉及车辆库存变化。
这里有一个业务上必须想清楚的点:在线订购一旦创建,这辆车就不能再被其他用户下单。实现方式不是先查再改——那样两个用户同时下单会都查到“在售”,而是用一条带条件更新的 SQL 直接抢占,谁能把status从 1(在售)改成 2(已售),谁就下单成功。这个细节我放在第四章详细说,它是整个系统里最值得在答辩时讲的亮点。
4. 后端落地:登录鉴权、车辆检索与订单防超卖
4.1 注册登录与JWT拦截器
后端从用户模块写起最顺手,因为它简单,能帮你把工程骨架跑通。注册接口的逻辑是:先检查用户名是否已存在,再对密码做 BCrypt 加密,最后插入用户记录。登录接口的逻辑是:根据用户名查用户,用BCrypt.checkpw比对密码,比对成功就签发一个 JWT——我把 userId 和 role 塞进 token 里,有效期设 24 小时,返回给前端存到 localStorage。
@Service public class UserService { @Autowired private UserMapper userMapper; public String login(String username, String password) { User user = userMapper.selectOne(new LambdaQueryWrapper<User>() .eq(User::getUsername, username) .eq(User::getDeleted, 0)); if (user == null || !BCrypt.checkpw(password, user.getPassword())) { throw new BusinessException("用户名或密码错误"); } return JwtUtil.createToken(user.getId(), user.getRole()); } public void register(RegisterDTO dto) { Long count = userMapper.selectCount(new LambdaQueryWrapper<User>() .eq(User::getUsername, dto.getUsername())); if (count > 0) { throw new BusinessException("用户名已存在"); } User user = new User(); user.setUsername(dto.getUsername()); user.setPassword(BCrypt.hashpw(dto.getPassword(), BCrypt.gensalt())); user.setNickname(dto.getNickname()); userMapper.insert(user); } }最核心的部分是拦截器。我用一个 HandlerInterceptor 拦截所有/api/**请求,把登录注册、车辆查询、图片访问这几个白名单放行,其余请求都强制检查 token。token 校验通过后,把 userId 塞进 ThreadLocal,后面任何 service 里都能取到当前登录用户,不需要每个接口都传 userId 参数。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !JwtUtil.verify(token)) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } UserContext.set(JwtUtil.getUserId(token)); return true; } }JWT 的密钥写在 application.yml 里,用一个足够长的随机字符串,不要写在代码里写死过期时间。
4.2 车辆列表与多条件检索
车辆列表是前台访问量最大的接口,要支持品牌、车型、价格区间、关键词四个条件组合筛选,还要分页。用 MyBatis-Plus 的 LambdaQueryWrapper 写条件,代码非常干净:
public PageResult<CarVO> page(CarQueryDTO query) { LambdaQueryWrapper<Car> wrapper = new LambdaQueryWrapper<>(); if (StrUtil.isNotBlank(query.getKeyword())) { wrapper.like(Car::getTitle, query.getKeyword()); } if (query.getBrandId() != null) { wrapper.eq(Car::getBrandId, query.getBrandId()); } if (query.getMinPrice() != null) { wrapper.ge(Car::getPrice, query.getMinPrice()); } if (query.getMaxPrice() != null) { wrapper.le(Car::getPrice, query.getMaxPrice()); } wrapper.eq(Car::getStatus, 1); // 只查在售 wrapper.orderByDesc(Car::getCreateTime); Page<Car> page = carMapper.selectPage( new Page<>(query.getPageNum(), query.getPageSize()), wrapper); // 把 brand_id 翻译成 brand_name,组装成 VO 返回 }注意分页一定要配置 MyBatis-Plus 的分页插件,不配置的话 selectPage 只是内存分页,数据量一大就出问题。配置方式是在 config 包里加一个MybatisPlusInterceptor,注册PaginationInnerInterceptor,这三行代码就能解决。
详情页的接口更简单,直接按 id 查,但要把images字段按逗号拆成数组返回,方便前端用轮播图展示。浏览量统计我放在详情接口里,每次访问views + 1,前台首页的“热门车型”排序就靠这个字段。
4.3 下单环节的防超卖处理
这是整个后端最值得讲清楚的一段。一张车型表里一量库存为 1,两个用户同时下单,如果代码是这样写的——先查 status,发现在售,再 update status 改成已售——那么两个用户都能查到“在售”,最终都下单成功,但车只有一台,这就是超卖。
解决思路是把“查”和“改”合成一条 SQL,用数据库的行锁保证只有一个事务能改成功:
@Transactional public String createOrder(OrderCreateDTO dto) { // 尝试将车辆从“在售”改为“已售”,条件自带 status=1 int updated = carMapper.update(null, new LambdaUpdateWrapper<Car>() .set(Car::getStatus, 2) .eq(Car::getId, dto.getCarId()) .eq(Car::getStatus, 1)); if (updated != 1) { throw new BusinessException("该车型已售出,请刷新后重试"); } String orderNo = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + RandomUtil.randomNumbers(4); CarOrder order = new CarOrder(); order.setOrderNo(orderNo); order.setUserId(UserContext.get()); order.setCarId(dto.getCarId()); order.setOrderType(dto.getOrderType()); order.setStatus(0); order.setContactName(dto.getContactName()); order.setContactPhone(dto.getContactPhone()); orderMapper.insert(order); return orderNo; }updated != 1这个判断是整个防超卖的核心。当多条请求同时执行这条 update 时,数据库的行锁会让它们串行执行,只有第一条影响行数为 1,后面的请求影响行数都是 0,直接被拦截。这个方法加了@Transactional,如果订单插入失败,前面车辆状态的修改也会回滚,不会出现“车改了已售但没有订单”的数据不一致。
答辩时如果老师问“你的系统怎么处理并发下单”,你把这段逻辑讲清楚,基本就过关了。
4.4 图片上传与静态资源映射
车辆图片上传在后端就是一个接收 MultipartFile、保存到本地磁盘、返回访问路径的接口。我在配置里指定了一个上传目录,比如D:/car-upload/,文件名用 UUID 或时间戳生成,避免中文名和重名问题,然后通过 WebMvcConfigurer 把这个本地目录映射成/uploads/**这个 URL 前缀:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:" + uploadDir + "/"); }这样前端拿到/uploads/xxx.jpg就能直接访问图片。这个设计对毕设足够用了,不需要引入对象存储服务。要注意的是生产环境部署时,上传目录的绝对路径要改成服务器上的实际路径,权限要保证可写,否则会报 FileNotFoundException。
5. 前端联调:Vue页面体系与接口对接的那些细节
5.1 前台与后台的页面/路由划分
前端页面我按两个区域切分:views/front和views/admin。前台包括首页、车辆列表、车辆详情、登录注册、个人中心、我的订单六个页面;后台包括统计面板、车辆管理、品牌管理、订单管理、用户管理、公告管理六个页面。路由整体结构:
/ -> 前台首页 /cars -> 车辆列表(支持query参数筛选) /cars/:id -> 车辆详情 /login -> 登录 /register -> 注册 /user -> 个人中心(需登录) /admin -> 后台布局(需管理员权限) /admin/dashboard -> 统计面板 /admin/cars -> 车辆管理 /admin/orders -> 订单管理路由守卫是前端权限控制的重点。我在路由配置的 meta 里标记哪些页面需要登录,哪些需要管理员权限,然后在全局前置守卫里统一判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else if (to.meta.requiresAdmin && role !== '0') { next({ path: '/' }) } else { next() } })这一步能保证用户不登录点“我的订单”会跳去登录页,普通用户手动访问/admin会被弹回首页,而不仅仅是“按钮不显示”。前端的跳转拦截是体验问题,后端的接口鉴权才是安全底线,两者都要做。
5.2 axios封装、Token注入与跨域处理
axios 封装是整个前端联调的基础。我建了一个utils/request.js,创建 axios 实例的时候设置 baseURL,然后在请求拦截器里统一把 token 加到请求头:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) service.interceptors.response.use( res => { if (res.data.code === 200) { return res.data.data } ElMessage.error(res.data.message || '操作失败') return Promise.reject(res.data) }, err => { if (err.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(err.response?.data?.message || '网络请求失败') return Promise.reject(err) } )统一处理后,业务代码里调用接口就非常干净,比如const data = await getCarList(params)拿到的直接就是 data 部分,不用每处都解析一次外层结构。
跨域问题我推荐用 Vite 的 proxy 方案解决,而不是在后端写 CORS。在vite.config.js里配置:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端请求/api/car/list会被 Vite 代理转发到后端的 8080 端口,浏览器看起来是同源请求,cookie、会话、请求头都不用额外处理。开发环境用 proxy,生产环境用 Nginx 反代,这是前后端分离项目最标准的联调方案。
5.3 列表页、详情页与管理端表单的实现套路
前台车辆列表页的结构几乎是固定的:顶部是筛选区,中间是表格或卡片列表,底部是分页。我用 Element Plus 的el-form做筛选区,el-card包裹每辆车的信息,点击卡片跳详情。分页组件绑定 pageNum 和 pageSize,变化时重新请求接口,标准套路。
详情页有一个小细节容易做砸——车型图片的切换。我在数据库里存了多张图,前端拿到数组后,左边用el-carousel做轮播,右边是价格和参数。参数区域直接用el-descriptions组件渲染,两列布局,看着整齐,代码量也小。
管理端做得最多的是“表格 + 弹窗表单”模式。以车辆管理为例:el-table展示车型列表,每行操作列有编辑和删除按钮;新增和编辑共用一个el-dialog弹窗,里面是el-form表单,表单里有图片上传组件。图片上传组件用el-upload的http-request属性覆盖默认行为,手动调用我们后端封装的上传接口,拿到返回的 URL 再回填给表单。
这个模式贯穿整个后台,品牌管理、公告管理、用户管理全部复用同一套交互逻辑,写熟了之后一天能搞定两个页面。
5.4 统计图表怎么加才有说服力
管理端统计面板我用 ECharts 做了两个图:一个是近七天的订单量趋势柱状图,一个是各品牌车型数量或销量占比饼图。数据来源于后端/api/admin/statistics接口,返回两个数组,前端echarts.init之后直接 setOption。
ECharts 在这里的技术含量不高,但“有没有统计功能”在导师眼里的分水岭非常明显。一个只有增删改查的后台和一个带图表看板的后台,给评审老师的印象完全不同。如果你时间有限,优先做订单量趋势图,它是销售网站的天然核心指标。
6. 论文不是抄出来的:目录、图表与控制答辩节奏
6.1 论文目录结构参考
大部分学校的毕业设计论文格式要求大同小异,靓车平台的论文框架我建议按这个走:
第1章 绪论 1.1 课题研究背景与意义 1.2 国内外研究现状 1.3 论文主要工作与章节安排 第2章 相关技术介绍 2.1 SpringBoot框架 2.2 Vue前端框架 2.3 MySQL数据库 2.4 JWT身份认证技术 第3章 系统分析 3.1 可行性分析 3.2 角色分析 3.3 功能需求分析 3.4 非功能需求分析 第4章 系统设计 4.1 系统总体架构设计 4.2 功能模块设计 4.3 数据库设计 第5章 系统实现 5.1 用户模块的实现 5.2 车辆信息模块的实现 5.3 订单模块的实现 5.4 后台管理模块的实现 5.5 统计模块的实现 第6章 系统测试 6.1 测试环境 6.2 功能测试 6.3 测试结果分析 第7章 总结与展望每一章的侧重点要清楚:第3章写清楚用户、管理员各自的业务流程,第4章重点是数据库表设计和 E-R 图,第5章是全文最长的部分——每个模块至少放两三张运行截图,配上核心代码片段和文字解释“这段代码实现了什么、为什么这么写”。
6.2 图表资源与测试数据准备
论文里必须有图,而且不能只有截图。最基础的三类图是:用例图(画清两个角色与各功能的关系)、E-R 图(画清五张核心表及表间关系)、功能结构图(把系统按模块拆成树状)。这些图可以用常规绘图工具画,不用追求漂亮,结构清晰最重要。
运行截图要在系统功能全部完成后统一补拍,注意三点:一是数据量要够,车型至少录入二十台,订单至少十条,这样列表页和统计图才不显空;二是图片要清晰,窗口尺寸统一,截完不要拉伸变形;三是每个模块的截图配上编号,比如“图5-1 用户登录界面”,论文里引用时写编号即可。
功能测试也要做成表格,这是论文第六章的主体素材:
| 编号 | 测试功能 | 操作步骤 | 预期结果 | 实际结果 |
|---|---|---|---|---|
| TC01 | 用户注册 | 输入新用户名、密码并提交 | 提示注册成功并跳转登录 | 通过 |
| TC02 | 用户登录 | 输入错误密码 | 提示“用户名或密码错误” | 通过 |
| TC03 | 车辆筛选 | 选择品牌SUV、价格区间 | 列表只显示符合条件的车型 | 通过 |
| TC04 | 车型下单 | 对在售车辆提交订购 | 订单状态为待处理,车辆变为已售 | 通过 |
| TC05 | 重复下单 | 对已售车辆再次提交订购 | 提示“该车型已售出” | 通过 |
TC04 和 TC05 这两条测试用例是全文的亮点,它们验证了防超卖逻辑真的有效。
6.3 答辩高频问题与回答思路
答辩老师的问题通常集中在三个方向:技术选型理由、核心业务逻辑、系统扩展方向。
“你为什么用 SpringBoot?”标准答法是:它简化了 Spring 的配置,自带内嵌服务器,起步依赖让依赖管理变简单,自动化配置适合快速开发中小型项目。“你怎么保证密码安全?”答:BCrypt 加盐哈希,即使数据库泄露,密文也无法直接还原。“并发下单怎么处理?”这就是 4.3 节的加分题,把条件更新 SQL 的逻辑复述一遍。
最怕的一种情况是老师问“你这个系统最大的难点是什么”。很多同学说“没有难点”,这等于把话题聊死。正确的答法是把订单防超卖说成难点,把 jwt 无状态鉴权说成难点,把数据表快照设计说成难点——每个都把自己真实解决过的问题包装成挑战,反而显得有思考。
7. 环境搭建与上线部署:从本机跑通到服务器演示
7.1 本地联调环境的搭建顺序
我见过太多同学卡在环境搭建阶段,其实按顺序来十分钟就能跑通。第一步装 MySQL,版本 5.7 或 8.0 都行,执行sql目录下的建库脚本,注意先建库再建表。数据库连接配置写在 application.yml 里,连接串一定要带时区参数:
spring: datasource: url: jdbc:mysql://localhost:3306/car_sales?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver第二步启动后端:用 IDEA 打开 backend 目录,等 Maven 依赖下载完,直接运行启动类。看到 “Started Application” 日志说明启动成功。第三步启动前端:在 frontend 目录执行npm install,装完依赖执行npm run dev,Vite 会打印一个本地访问地址,浏览器打开就能看到首页。
7.2 两种打包部署方式对比与Nginx配置
部署方式有两种,各有适用场景。
第一种是前后端分离部署:前端npm run build生成 dist 静态目录,部署到 Nginx;后端mvn clean package -DskipTests生成 jar 包,部署到服务器上用java -jar运行。Nginx 同时承担静态文件服务和接口反向代理:
server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html这行是关键——前端路由如果用了 History 模式,刷新/cars/3这种页面时若没有它,Nginx 会直接 404。加上这行,所有路由都会回退到 index.html,由前端路由接管。
第二种是合并部署:把前端 build 出来的 dist 目录复制到后端项目的resources/static目录下,再重新打包 jar,这样一个 jar 同时包含前后端,直接java -jar app.jar就能访问整个系统。这种方案牺牲了一点灵活性,但演示时特别省事,不需要启动 Nginx、不需要配置端口,对于毕业答辩这种单机演示场景是首选。
7.3 部署实测中常见的5类报错与处理
我帮同学调试环境时发现,来来回回就是这几类问题,提前自查能省下大量时间:
| 报错现象 | 根因 | 解决方案 |
|---|---|---|
接口报Communications link failure | 数据库连接串缺时区参数或密码错误 | 检查 url 的serverTimezone=Asia/Shanghai,检查用户名密码 |
前端请求/api/...404 | Vite proxy 没配或 Nginx 反代路径不对 | 开发环境检查 vite.config.js,生产环境检查 location /api 配置 |
| Vue 路由刷新 404 | Nginx 没配 try_files | 在静态服务 location 中加入try_files $uri $uri/ /index.html; |
| 图片上传后访问 404 | 后端没有映射/uploads/**到本地目录 | 检查 WebMvcConfigurer 的 addResourceHandlers 配置 |
| 前端 build 后白屏 | 静态资源访问路径错误 | 在 vite.config.js 设置base: './',让资源使用相对路径加载 |
最后一个白屏问题最有迷惑性。本地开发正常,build 完扔到 Nginx 上白屏,打开控制台发现 JS、CSS 资源全是绝对路径/assets/xxx.js,而项目部署在子目录或 SSL 证书目录下,路径就全错了。把base: './'配好,这个问题就消失了。
还有端口问题,SpringBoot 默认 8080,如果本机被占用,在 application.yml 里改server.port。MySQL 8 的认证方式和老客户端不兼容的坑现在少见,但如果你装的是 8.0 且用老版数据库工具连接失败,记得在 mysql 命令行里更新用户的插件为caching_sha2_password兼容项。
这几年看下来,同学之间做同一个题目,最后的差别往往不在能力上,而在是否愿意把每一步想透。拿到源码先别急着跑,把表结构和接口清单通读一遍,再照着部署文档走通流程,最后自己动手把防超卖、JWT 授权这两个核心点重新写一遍——做到这一步,你对这个项目的理解深度已经远超大多数同期同学。论文答辩看的不只是系统跑没跑起来,更是你能不能把每一个设计决策背后的道理讲清楚。抓住数据库建模和订单状态流转这两条主线,你手里的就是一个经得起追问的毕业设计。