做了这么多年毕业设计指导,每年都会遇到一批选"商铺管理系统"这个题目的学生。说实话,这个题目属于典型的"看起来简单、做起来琐碎"的类型——你说它难吧,无非就是增删改查加几张表;你说它简单吧,真要做出一套能答辩、敢演示、扛得住老师追问的系统,里面涉及的坑一点都不少。这篇文章我打算把整套思路掰开揉碎,从业务建模到表结构设计,从代码分层到部署上线,把我会怎么带学生做这套系统、每一步为什么这么做,全部写出来。
这篇文章适合几类人看:正在做SpringBoot+Vue毕设的学生、想把管理系统类项目做得更规范一点的开发者、以及时间紧张但不想糊弄毕业设计的同学。我会尽量把原理和实操都讲到,争取让基础一般的同学也能照着落地。
1. 先把业务想明白:商铺管理系统到底在管什么
很多同学拿到题目,第一反应是建表、写接口、糊页面。这是典型的"手比脑子快"。做管理系统类的毕设,最忌讳一上来就写代码。你连这个系统要解决什么问题、角色有哪些、业务流转路径是什么都没想清楚,写出来的东西必然是散的,答辩时一问三不知。
1.1 核心角色与业务闭环
商铺管理系统,本质上是为高校或园区的商业空间提供一套数字化管理工具。以太原学院这样的场景为例,系统面向的核心角色有三类:系统管理员、商铺管理人员(招商/运营岗)、访客或普通用户(比如校内师生)。
围绕这三类角色,业务可以拆成一个闭环:
- 商铺入驻:商铺信息登记、资质审核、入驻时间登记
- 合同管理:租期设定、租金标准、合同起止时间、续约或退租
- 费用管理:租金计算、费用记录、收缴状态跟踪
- 日常运营:商铺状态变更(营业中/停业/装修)、巡检记录、投诉反馈
- 数据统计:商铺数量、出租率、租金收缴率、经营状态分布
这个闭环听起来不复杂,但它是整个系统的"业务骨架"。你的数据库表设计、后端接口划分、前端页面路由,全部要围绕这个骨架展开。很多同学做出来的系统"页面很多但逻辑不通",就是因为没有先画这个闭环。
1.2 功能模块怎么切,才不会给自己挖坑
给毕设项目切功能模块,我有一条铁律:宁可少做几个功能,也不要把每个功能做成半成品。答辩老师翻你系统的时候,最反感的不是功能少,而是点了某个菜单弹出一堆报错,或者一个按钮点了没反应。
基于这条铁律,我建议把功能切成两大部分:
管理端(面向管理员和运营人员):
- 商铺管理:商铺信息CRUD、状态流转、入驻审核
- 租户管理:租户/商户信息维护,与商铺建立关联
- 合同管理:合同登记、到期提醒、续约操作
- 收费管理:费用项配置、账单生成、缴费状态更新
- 巡检管理:巡检记录登记、异常上报
- 数据概览:核心指标的图表展示
用户端/展示端(面向普通访客):
- 商铺列表:按分类、状态浏览商铺
- 商铺详情:查看经营内容、位置、营业时间
- 公告信息:园区通知、临时调整信息
我见过不少学生想做一个"商户自助入驻"的流程,就是商户自己注册账号、提交资料、等待审核。想法很好,但这个功能会牵扯到多角色权限、工作流引擎、消息通知,工作量瞬间翻倍。我的建议是:如果时间充裕、基础好,可以做成加分项;如果时间紧,就把这个功能砍掉,改成管理员代录信息,效果一样能说清楚。
1.3 技术栈选型的实话
题目限定了SpringBoot+Vue+MySQL,这本身就是当前国内高校毕设的主流套餐,也是企业里中小型管理系统最常用的一套组合。这里有个心态问题要摆正:毕设不追求技术多新,追求的是你在规定时间内把一套需求明确的东西跑通,并且每个环节都能讲出道理来。
版本选择上,我推荐:
- SpringBoot用2.7.x系列,不要一上来追3.x
- Vue用2.x版本配Element UI,或者Vue3配Element Plus
- MySQL用8.0以上版本,5.7也能跑,但8.0是趋势
SpringBoot 3.x相比2.x,最大的变化是底层基于Jakarta命名空间,很多老教程里的代码直接迁移会报错。你对SpringBoot还没熟到一定程度的时候,用3.x是给自己增加排查成本。Vue这边同理,Vue2的生态文档、Element UI的组件案例多得是,遇到问题搜一下就出来了。Vue3+Element Plus也很成熟,个人偏好决定即可。
2. 数据库设计:表结构就是系统的地基
表结构设计是整篇毕设里最见功底的部分。为什么这么说?因为答辩老师一眼就能看出你的表是"赶工乱画的"还是"认真设计过的"。表与表之间的关系、字段命名规范、索引使用、时间字段的处理,全是可以看出门道的地方。
2.1 核心表结构落地方案
给商铺管理系统建表,我建议至少覆盖以下核心表。这里给出MySQL的建表核心字段思路,实际创建时还需要根据你的功能扩展微调。
商铺信息表(shop)
字段:id, shop_name, shop_code, category_id, floor, location_desc, area_size, rent_price, shop_status, owner_id, start_date, end_date, create_time, update_time, deletedshop_code给商铺编一个唯一编码,方便合同、收费模块引用shop_status建议用数字字典:0空置、1已入驻、2装修中、3停业、4已退租deleted是做逻辑删除的标志位。管理系统里,物理删除是禁忌,万一删错数据没法恢复,逻辑删除还能留底
租户/商户信息表(tenant)
字段:id, tenant_name, contact_person, contact_phone, id_card, business_license, audit_status, create_time, update_time, deleted租户和商铺的关系:一个租户可以租多个商铺,一个商铺在某个时间段只能对应一个租户。这是一对多关系,在商铺表里加owner_id即可,不用单独建关联表。
合同表(contract)
字段:id, shop_id, tenant_id, contract_no, start_date, end_date, rent_amount, deposit_amount, payment_cycle, status, create_time, update_time, deleted合同表是整个系统的枢纽表。shop_id和tenant_id都指向各自的表,status可以用来区分正常履约、到期、提前终止等状态。支付宝里的"账单-合同-商铺"链路,以后扩展开票、缴费记录都从这里出发。
费用记录表(bill_record)
字段:id, contract_id, shop_id, bill_type, amount, status, pay_date, remark, create_time, update_timebill_type涵盖租金、物业费、水电费等。为什么要单独建表而不直接写在合同表里?因为一个合同会产生多期费用,每次缴费都是一条独立记录,这是典型的"主表-明细表"结构。
巡检记录表(inspection_record)
字段:id, shop_id, inspector, inspection_date, content, result, issue_desc, create_time巡检算是管理系统的"加分模块"。很多同学不知道这个表该怎么设计才能兼顾信息完整和简洁,这里给一个参考:result字段用0/1表示正常/异常,异常时issue_desc必填。前端可以据此做联动校验,这是一个很小的细节,但答辩时拿出来说很有说服力。
系统用户表(sys_user)
字段:id, username, password, nickname, role, avatar, status, create_time, update_time密码存储不要用明文,至少用MD5加盐或者BCrypt加密。Spring Security和Sa-Token都内置了BCrypt支持,基本是现成的。
2.2 表设计的三个实战经验
**时间字段统一处理。**我在指导毕设时反复强调:所有表必须有create_time和update_time两个字段。MySQL可以使用DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP来自动维护,或者由MyBatis-Plus的自动填充功能处理。统一时间字段的命名规范,后面写按时间排序、按月统计查询会舒服很多。
**金额字段统一用decimal。**租金、押金这类涉及金额的字段,千万别用double或float。二进制浮点数在精度上有天生缺陷,0.1+0.2算出来是0.30000000000000004。钱这种东西容不得误差,用DECIMAL(10,2)最稳妥。这个知识点很简单,但每次讲到都有学生踩坑。
**外键约束能不加就不加。**这是企业开发里一条比较反直觉的经验。数据库物理外键会导致高并发场景下的锁竞争,还会增加删除和更新数据的复杂度。现在的主流做法是在逻辑层(Service层)维护引用关系,也就是所谓"逻辑外键"。一张表的shop_id引用另一张表的id,但数据库层面不建FOREIGN KEY约束。你的数据完整性靠代码保证,靠事务保证,而不是靠数据库约束。毕设答辩时,老师如果问为什么没有外键,你就可以把这套逻辑讲出来,绝对比"忘了加"好得多。
3. 后端从零搭建:SpringBoot项目的骨架和核心接口
后端项目的搭建,我见过太多学生栽在"配置"上爬不起来。其实SpringBoot的设计哲学就是"约定大于配置",你只需要把关键的地方配置对,剩下的交给框架自动搞定。
3.1 项目初始化与分层结构
创建项目我用的是Spring Initializr(可以idea自带的,也可以start.spring.io网页端)。关键依赖勾选:
- Spring Web(构建Web接口)
- MySQL Driver(数据库驱动)
- MyBatis-Plus或Spring Data JPA(数据访问层)
这里建议优先MyBatis-Plus。原因很实在:JPA的懒加载、级联操作、实体关系映射比较难掌握,一旦没用好,查询N+1次、级联删除失控这类问题够你排查好几天的。MyBatis-Plus的CRUD近乎傻瓜式,BaseMapper里把增删改查全给你写好了,还自带分页插件和条件构造器,配合毕设项目的CRUD高频场景,效率拉满。
后端包结构我习惯这样分:
com.taiyuan.shop ├── common // 通用类:统一返回结果、异常处理、常量定义 ├── config // 配置类:跨域配置、MyBatis-Plus分页插件配置、拦截器配置 ├── controller // 接口层,只做参数接收和结果返回 ├── service // 业务逻辑层,核心代码都在这里 │ └── impl // Service实现类 ├── mapper // 数据访问层,继承BaseMapper ├── entity // 实体类,对应数据库表 └── vo // 视图对象:给前端返回的组合数据这样的分包形式在毕设答辩中非常讨喜——它直接展示了你对分层架构的理解。每一层各司其职,哪一层出问题就去哪一层排查,这是长期开发积累下来的工程经验。
3.2 商铺管理CRUD完整链路
对一个管理系统来说,CRUD就是地基中的地基。这部分我拆细一点,以商铺管理为例,把完整链路走一遍。
实体类(entity/Shop.java)
@Data @TableName("shop") public class Shop { @TableId(type = IdType.AUTO) private Long id; private String shopName; private String shopCode; private Long categoryId; private String floor; private String locationDesc; private BigDecimal areaSize; private BigDecimal rentPrice; private Integer shopStatus; private Long ownerId; private LocalDateTime startDate; private LocalDateTime endDate; private LocalDateTime createTime; private LocalDateTime updateTime; @TableLogic private Integer deleted; }@TableLogic是MyBatis-Plus的逻辑删除注解。加了它之后,你调deleteById时,框架自动改成UPDATE语句把deleted置为1,查询时自动加WHERE deleted = 0。讲这个注解的用法,比自己封一层拦截器强得多。
Controller层
@RestController @RequestMapping("/api/shop") public class ShopController { @Resource private ShopService shopService; @PostMapping("/page") public Result<IPage<ShopVO>> page(@RequestBody ShopQueryDTO queryDTO) { return Result.success(shopService.getShopPage(queryDTO)); } @GetMapping("/detail/{id}") public Result<ShopVO> detail(@PathVariable Long id) { return Result.success(shopService.getShopDetail(id)); } @PostMapping("/save") public Result<Void> save(@RequestBody Shop shop) { shopService.saveShop(shop); return Result.success(); } @PutMapping("/update") public Result<Void> update(@RequestBody Shop shop) { shopService.updateShop(shop); return Result.success(); } @DeleteMapping("/delete/{id}") public Result<Void> delete(@PathVariable Long id) { shopService.deleteShop(id); return Result.success(); } }用POST /page而不是GET /page做分页查询,是因为查询条件可能包含对象嵌套、数组参数,GET带一堆复杂参数在URL里既丑又容易触发长度限制。POST+JSON是全行业的主流做法。
Service实现类
@Service @Slf4j public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements ShopService { @Resource private ShopMapper shopMapper; @Override public IPage<ShopVO> getShopPage(ShopQueryDTO queryDTO) { Page<Shop> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize()); LambdaQueryWrapper<Shop> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(queryDTO.getShopName()), Shop::getShopName, queryDTO.getShopName()) .eq(queryDTO.getShopStatus() != null, Shop::getShopStatus, queryDTO.getShopStatus()) .orderByDesc(Shop::getCreateTime); // 注意分页插件要提前配置,不然这里的分页不会生效 IPage<Shop> shopPage = shopMapper.selectPage(page, wrapper); return convertToVO(shopPage); } }**为什么用LambdaQueryWrapper?**它的类型安全是真的香。字段名写错了,编译期就能报出来,而不是运行期告诉你SQLException: Unknown column。使用like(条件, 字段, 值)这种重载,第一个boolean参数控制这个条件是否拼接,查询参数为空时自动忽略,不用写一堆if判断拼字符串SQL了。
3.3 文件上传:一个不显眼但人人都会问的考点
商铺系统里,租户的营业执照、商铺的实拍图、巡检拍的现场照片,都涉及文件上传功能。这个功能看似简单,但做不好会在答辩时露怯。
后端核心实现:
@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件不能为空"); } // 校验文件大小,这里限制为5MB if (file.getSize() > 5 * 1024 * 1024) { return Result.error("文件大小不能超过5MB"); } // 校验文件后缀 String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); List<String> allowExt = Arrays.asList(".jpg", ".jpeg", ".png", ".pdf"); if (!allowExt.contains(ext.toLowerCase())) { return Result.error("不支持的文件类型"); } // 重命名文件,防止文件名冲突 String fileName = UUID.randomUUID().toString().replace("-", "") + ext; // 存储路径按日期分目录,方便后续管理 String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date()); String filePath = uploadDir + "/" + datePath + "/" + fileName; File dest = new File(filePath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); return Result.success("http://localhost:8080/files/" + datePath + "/" + fileName); }要踩过的坑这里提前说一下:跨域问题。页面在8081端口,文件服务在8080端口,直接<img src>访问8080端口的图片,浏览器会出于安全策略拦截。解决办法在config里加一个CorsConfig,或者在SpringBoot里写个WebMvcConfigurer配置addCorsMappings,允许跨域访问。这个坑以前的学生几乎每个人都踩过。
再有一个细节:不要用原文件名直接存服务器。除了防止重名覆盖,更重要的是防止路径穿越攻击——用户上传一个../../../../etc/passwd名字的文件,服务端如果直接拼接路径,就相当于给别人留了个后门。用UUID重命名是最稳妥的。
3.4 Excel导出:毕设里性价比最高的仪式感
管理系统里谁也躲不过报表导出。把商铺列表、合同台账导出成Excel,哪怕只是几十行数据,给答辩老师演示的时候,那个"专业技能展示"的观感,比你在嘴上说"我会POI"要强得多。
推荐用EasyExcel,阿里巴巴开源的,就是为了"极简"两个字:
@PostMapping("/export") public void export(HttpServletResponse response) throws IOException { List<ShopExportDTO> list = shopService.listAllForExport(); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String fileName = URLEncoder.encode("商铺信息", "UTF-8").replace("+", "%20"); response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), ShopExportDTO.class) .sheet("商铺列表") .doWrite(list); }ShopExportDTO里加@ExcelProperty("商铺名称")之类的注解,定义每一列的标题。导出的列顺序、列名全由注解控制,几十行代码搞定一个工序繁杂的功能。这是典型的"小体量,大价值",强烈建议加进毕设里。
3.5 统一结果返回与全局异常处理
所有Controller的返回类型不要各写各的,定义好统一的结构体:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success() { return new Result<>(200, "操作成功", null); } public static <T> Result<T> success(T data) { return new Result<>(200, "操作成功", data); } public static <T> Result<T> error(String message) { return new Result<>(500, message, null); } }再配合@RestControllerAdvice做全局异常处理。这样统一的好处:前端axios拦截器只需要判断code是不是200就能决定弹成功提示还是错误提示。前端代码整洁,后端也不用手忙脚乱地到处try-catch。
4. 前端工程化:Vue项目从搭建到功能的完整路径
前端的工程量并不比后端少,只是墙倒众人推,很多人以为Vue就是套模板。真正写起来,路由设计、状态管理、组件通信、接口封装处处都有门道。
4.1 创建项目和目录规划
前端我用Vite作为构建工具,命令很简单:
npm create vite@latest shop-admin -- --template vue cd shop-admin npm install npm install axios vue-router pinia element-pluspinia是Vue3生态里的状态管理库,比Vuex的API简单太多,想存store直接defineStore一下就行。Vite相比Webpack最大的优势是"快",冷启动秒开,热更新基本无感,这对开发体验的提升非常明显。
目录结构:
src ├── api // 接口请求封装,每一类请求单独一个文件 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── store // 状态管理 ├── views // 页面组件 │ ├── shop │ ├── contract │ ├── dashboard │ └── login ├── utils // 工具函数 ├── App.vue └── main.js4.2 axios封装:请求拦截器就这么写
接口封装是前端工程化的第一步。所有请求统一走一个axios实例,好处是可以在拦截器里统一处理token、统一处理报错提示:
// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:每个请求自动带上token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }) // 响应拦截器:统一处理返回码 request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } else if (res.code === 401) { ElMessage.error('登录已过期,请重新登录') router.push('/login') return Promise.reject(new Error('unauthorized')) } else { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )这样做最大的收益是:业务页面里完全不需要再写try-catch处理报错提示。弹错误提示、跳登录页这种横切逻辑,全部收敛在拦截器里。页面代码只关心成功之后做什么,代码阅读体验直线上升。
有同学问token怎么放。现在主流做法是用localStorage或pinia存。我建议通过pinia存一份、localStorage存一份,刷新页面时从localStorage恢复,保证状态不丢。
4.3 动态路由和权限控制
毕设系统必然有角色之分,管理员看的页面和普通用户看的页面不能一样。这就要用到动态路由:根据登录用户角色,动态往路由表里添加菜单。
router/index.js里先定义基础路由(login、404),然后addRoute动态拼接权限路由:
// 动态注册路由 const modules = { admin: [ { path: '/shop', component: () => import('../views/shop/index.vue'), meta: { title: '商铺管理' } }, { path: '/contract', component: () => import('../views/contract/index.vue'), meta: { title: '合同管理' } }, // ...其他管理员菜单 ], user: [ { path: '/shop/list', component: () => import('../views/shop/list.vue'), meta: { title: '商铺列表' } } ] } export function setupDynamicRoutes(role) { const routes = modules[role] || [] routes.forEach(route => { router.addRoute('layout', route) }) }路由守卫配合使用:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else { next() } })动态路由+路由守卫+按钮级权限控制,这三个层次组合起来,在答辩时可以讲出一个完整的权限控制方案:菜单是动态生成的、请求有token校验、按钮有权限指令控制显示隐藏。哪怕你写的代码不复杂,但这一套逻辑完整自洽,很能体现思考深度。
4.4 核心页面:商铺列表页实现要点
列表页是管理系统里出镜率最高的页面。以商铺管理为例,一个标准列表页包含:搜索区、操作区、数据表格、分页器。
<template> <div class="shop-container"> <el-card> <!-- 搜索区 --> <el-form :model="queryParams" inline> <el-form-item label="商铺名称"> <el-input v-model="queryParams.shopName" placeholder="请输入商铺名称" clearable /> </el-form-item> <el-form-item label="状态"> <el-select v-model="queryParams.shopStatus" clearable placeholder="请选择状态"> <el-option label="空置" :value="0" /> <el-option label="已入驻" :value="1" /> <el-option label="装修中" :value="2" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleQuery">搜索</el-button> <el-button @click="resetQuery">重置</el-button> </el-form-item> </el-form> <!-- 操作区 --> <el-button type="primary" @click="openDialog()">新增商铺</el-button> <el-button type="danger" @click="handleBatchDelete">批量删除</el-button> <!-- 表格区 --> <el-table :data="shopList" border stripe> <el-table-column type="selection" width="55" /> <el-table-column prop="shopName" label="商铺名称" /> <el-table-column prop="floor" label="楼层" /> <el-table-column prop="areaSize" label="面积(m²)" /> <el-table-column prop="rentPrice" label="租金(元/月)" /> <el-table-column label="状态"> <template #default="{ row }"> <el-tag :type="statusMap[row.shopStatus].type"> {{ statusMap[row.shopStatus].label }} </el-tag> </template> </el-table-column> <el-table-column label="操作" width="200"> <template #default="{ row }"> <el-button link type="primary" @click="openDialog(row)">编辑</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table> <!-- 分页器 --> <el-pagination v-model:current-page="queryParams.pageNum" v-model:page-size="queryParams.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @change="getList" /> </el-card> </div> </template>写筛选条件时有一道常见的考题:后端用eq判断状态精确匹配,用like模糊匹配名称,这两者组合起来怎么不冲突?答案很简单,条件构造器里like和eq可以链式调用,只要每个条件前面那个boolean参数做好判空,框架会自动帮你在SQL里决定加不加这个条件。
4.5 数据统计大屏
毕设的演示视频或现场展示,第一个打开的页面最好是个漂亮的数据概览页。如果你打开系统看到一堆增删改查表格,评委的兴趣直接减半。用ECharts做数据可视化是我们的秘密武器之一,它跟Vue的适配做得很好,有现成的vue-echarts封装。
我建议图表和数据要对应后端真实数据,不要写死。具体做法是:后端提供统计接口,例如按照商铺状态统计总数、按照楼层统计出租率、近半年费用收缴趋势等。前端用axios拿到数据后,动态更新ECharts的option。这样做的好处有两点:一是答辩时老师随便改一条数据,图表跟着变,真实感拉满;二是展现了你前后端数据流通的整体设计,不只是"套了个echarts模板"。
5. 锦上添花的进阶选择:认证方案和脚手架
如果你的毕设周期还有富余,或者指导老师明确提出"要有点技术含量",那么下面几个方向值得花一点时间。注意我的用词——"花一点时间",不是让你把毕设变成科研项目,控制投入产出比很重要。
5.1 登录认证:Sa-Token还是Spring Security?
管理系统必然要有登录功能,但"登录"也分三六九等。最基础的是session登录,前端cookie带着sessionId,后端查session判断是否登录。稍微进阶一点的是JWT方案:用户登录成功后,后端签发一个token,前端存在localStorage里,每次请求带到请求头,后端校验token合法性。这两种方案的原理差异,本身就是答辩时的高频问题。
我的推荐是Sa-Token。理由:它把登录认证、权限认证、踢人下线、账号封禁这些功能封装好了,API极其简单,登录的时候StpUtil.login(userId),判断登录用StpUtil.isLogin(),校验权限用@SaCheckPermission("shop:add")。相比Spring Security那庞大的过滤器链和繁琐的配置,Sa-Token对初学者友好得多。答辩时你能讲清楚token怎么签发、怎么校验、拦截器怎么配置,就已经超过多数同学了。
如果你用Spring Security,那你要搞明白UserDetailsService、SecurityFilterChain、AuthenticationManager这一堆概念,这些不带几个月的实战经验真不好消化。同样的功能,Sa-Token十分钟配完,Spring Security可能要半天。不是你不行,是这框架本来对新手就不算友好,它功能全但抽象度高。
5.2 前后端分离部署的一个坑
很多同学的毕设是前后端分开跑:后端8080,前端8081,部署时各跑各的。如果你的时间足够,我强烈建议学一下"前端打包放进SpringBoot"这一套。命令很简单:
npm run build然后dist目录下生成一堆静态文件。把dist目录的东西复制到SpringBoot的src/main/resources/static下,重新打包SpringBoot工程,你就会发现:一个jar包搞定所有,前端页面、后端接口、静态资源全部在一个端口上运行。演示的时候一个命令java -jar xxx.jar启动,干净利落。
这个做法有个需要配置的地方:SpringBoot默认只扫描/static目录下的静态资源,你直接访问http://localhost:8080/应该能看到首页,但如果你用vue-router的history模式,直接访问/shop这种路径会404,因为后端没有对应的Controller处理它。解决方法是写一个WebMvcConfigurer,把非API的所有路径都转发到index.html。
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{path:[^\\.]*}").setViewName("forward:/index.html"); } }这段配置配合history模式路由,是前后端分离项目打成单包部署的标准玩法。细节是:正则[^\\.]*把带点的路径排除掉,这样静态资源文件(js、css、图片)不会被误转发。
5.3 Servlet路径的静态资源映射
上面提到的文件上传功能,如果你把图片存放路径放在项目之外的磁盘目录(比如/Users/xxx/uploads),SpringBoot默认不扫描这个外部目录。需要在config里配置资源映射:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadDir + "/"); }这个配置干了一件事:浏览器访问http://localhost:8080/files/xxx.jpg时,SpringBoot去磁盘上找对应文件返回给前端。很多学生上传成功但显示不了图片,就是因为差这一步配置。
6. 部署、性能排查、答辩Hold场:最后的决胜环节
代码写完了不代表万事大吉。部署是对系统的最终验收,答辩是对你的最终验收。这两个环节的坑,我见过的比代码里的还多。
6.1 MySQL安装和配置的高频坑
热搜词里一堆"MySQL安装教程"不是没有原因的。在Windows 10上安装MySQL确实有太多细节能绊人:
- 下载的安装包版本太新,安装中途卡住没反应
- 端口3306被占用,服务起不来,或者能装但起不来服务
- 安装时设置了密码,连接时又提示Access denied
- 字符集没设置utf8mb4,存中文变成问号
我给出一个最稳的路线:用MySQL Community Server的ZIP版,解压后手动初始化,适合熟悉命令行的人;完全没经验的人,装8.0.x的MSI安装包,一路Next,设置root密码时注意记住密码。装好之后一定要检查两件事:
-- 查看字符集是不是utf8mb4 SHOW VARIABLES LIKE 'character_set%'; -- 查看时区设置 SHOW VARIABLES LIKE '%time_zone%';MySQL 8.0默认时区是系统时区,而SpringBoot的JDBC连接串里serverTimezone如果不配,或者配错,就会出现日期差8小时之类很头痛的问题。我习惯在JDBC连接串上显式声明:
jdbc:mysql://localhost:3306/shop_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai6.2 SpringBoot项目常见启动连锁问题
启动不了或者启动后访问不通,90%集中在以下几个点:
端口被占用。8080起不来,用netstat -ano | findstr 8080查一下是谁占着,然后任务管理器结束进程,或者直接在application.yml里把端口换掉。server.port: 8080改一改就是了,这不是什么大事,不用慌。
MyBatis-Plus分页插件没配置。你兴高采烈调selectPage,结果所有数据一次查出来,翻页完全没效果。这时候去看你的config,是不是没有@Configuration加MybatisPlusInterceptor并注册PaginationInnerInterceptor。
依赖版本冲突。这个排查起来最费劲。我的建议是:尽量在项目的初始阶段就把主要依赖的版本配对固定好,像SpringBoot、MyBatis-Plus这些核心依赖不要随意升级。另外如果你是跟着网上的教程一步步敲的,且视频更新时间比较早,里面的版本号和依赖标定方式可能已经变了,需要结合官方文档做适配。
6.3 答辩高概率问题清单:提前想好答案
答辩的恐惧往往来自未知。我把这么多年高频出现的追问问题列出来,你可以对着自己的项目逐个过一遍:
"你这个项目有哪些亮点?"
这是最经典的开场问题。不要说"用了前后端分离"这种话,这种默认配置不算亮点。要说就说具体的:比如"我用了动态路由做权限控制,不同角色登录后看到不同的菜单";"我用EasyExcel实现了数据导出,支持大数据量场景";"我在文件上传时做了文件类型和大小双重校验,并且把文件名重命名为UUID,避免路径穿越攻击"。每一项都要能展开讲,别只丢一句名词。
"你的表结构为什么这么设计?"
准备好讲清楚表与表之间的关联关系。比如为什么把合同和费用分开建表、为什么用逻辑删除而不是物理删除、为什么金额用decimal不用double。不建议背课本原话,就用你做项目时实际遇到的情况讲,比如"我一开始用double算租金,后来发现0.1+0.2精度有问题,租金会差几分钱,所以换成了decimal",这种真实的踩坑故事比教科书回答可信十倍。
"你的项目还有什么不足?"
这个问题不是让你承认项目一无是处,而是考你对自己项目的认知深度。我建议这么回:目前数据量较小,没有引入缓存;文件服务用的是本地磁盘,如果部署到生产环境应该用对象存储或Nginx做静态资源服务;系统目前只支持单机部署,未来如果需要高可用可以引入负载均衡和分布式事务方案。重点是"我知道它会遇到什么问题、我知道该怎么改",而不是"我不知道有什么问题"。
"JWT和Session有什么区别?"
这是原理类必考题。核心答法:Session是服务端存储用户状态的方案,Cookie里存SessionId;JWT是无状态方案,服务端不存储用户会话,token里本身包含用户信息和过期时间,通过签名验证真伪。再说说各自的优劣:Session需要服务端存储、集群场景需要做Session共享;JWT天然适合分布式、跨域,但token一旦签发在过期前无法主动失效,可能被劫持重放。这两个知识点能各讲一分钟,这个问题就稳过。
"你遇到的最大的技术难点是什么?怎么解决的?"
建议提前打好草稿。可以从你开发过程中挑一个真实的难题来讲。比如动态路由配置时刷新页面路由丢失、跨域携带token失效、Excel导出后前端下载文件格式损坏。不要怕问题幼稚——如果你能讲清楚"报了什么错、怎么排查的、最终如何解决",这本身就证明了你具备解决问题的能力。
7. 写在最后的实在话
系统开发完之后,毕设还剩两个容易被低估的环节:文档撰写和演示视频录制。
论文和文档这块,把"系统设计"一章写清楚比什么都重要——需求分析、表结构设计、接口设计、核心代码逻辑说明,这四块每块写足,你的论文篇幅和厚度基本就稳了。画图用Visio或ProcessOn画清楚流程图和架构图,不要随手截图糊上去。写代码的时候随手记几个核心逻辑的备注,或者留着当时的git提交记录,会让论文的"实现过程"增加许多可信细节。
现场演示前,务必检查:演示数据是否足够饱满(数据列表没有几十条都显得很空)、有没有提前录制好兜底视频(现场网络抽风、数据库连接失败时用)、F12控制台有没有红色报错(哪怕这个报错不影响功能,被老师瞄到还是会减分)。
我在带项目过程中反复说一句话:**"先跑通,再完美,最后才是写论文。"**顺序不要颠倒——很多人一上来就埋头写论文,导致代码全部是网上粘贴的,自己没真正跑通,答辩时一点底气都没有。而先把整个流程走一遍,把每一步的坑都踩了填了,你在答辩时讲出来的内容自然有底气、有细节、有画面。
这篇内容算是我多年带毕设项目的一点经验沉淀。按照这个路线走下来,你的商铺管理系统即使做不到惊艳,但做到"完整、能跑、说得清",让答辩老师挑不出大毛病,是完全可以实现的。如果操作过程中有哪个环节卡住了,按照文章里的排查思路一步步过,基本都能解决。祝顺利完成。