很多同学在毕业设计选题时会挑类似“某高校XX管理系统”一类的题目,这次要聊的“高校商铺管理系统”就是其中典型。它基于 SpringBoot + Vue + MySQL 这套成熟框架组合,表面上是普通的增删改查后台,实际上却包含角色权限、商铺租赁、合同管理、账单结算、通知公告等多个业务闭环。说白了,就是把校园里几十家门店的入驻、租金、合同、续约、退租管理全部塞进一个 Web 系统里,让管理员、运营人员和商户各司其职。
这类题目适合两类人:一是想用“全栈项目”体现综合能力、但又不想碰高并发和分布式复杂架构的本科生;二是刚入门 Java 后端、想找一个能完整落地、也能顺畅答辩的练手项目的开发者。它不会让你在技术上显得“炫技”,但能把基本功和业务逻辑展示得很扎实。下面我就按实际做毕设的流程,把这个项目从需求到部署、再到答辩避坑,完整拆一遍。
1. 动手前必须理清的事:把题目翻译成需求
1.1 角色划分与核心业务路径
很多同学拿到题目第一反应是“赶紧建表写代码”,但商铺管理系统不是单纯管“商铺”两个字,它真正管的是“经营关系”。我习惯先画一条业务主线:商铺建档 -> 合同签订 -> 账单生成 -> 费用缴纳 -> 状态变更。
围绕这条主线,系统至少要有三类角色:
- 系统管理员:管理后台配置,负责审核商铺入驻、管理运营人员账号、查看全局营收统计、处理异常数据。
- 运营专员:日常操盘手,录入商铺信息、发起租赁合同、录入抄表数据、生成账单、标记付款状态。
- 商户(租户):商铺实际使用者,登录后查看自己的商铺详情、租赁合同、历史账单,线上缴纳租金并申请续租或退租。
生活化的类比是,这套系统就像一个“校园物业中心”:管理员是物业负责人,运营专员是前台接待和收费员,商户就是各个门面房的租户。物业不能把合同和账单混在一张表里瞎记,系统也一样,角色和业务模块必须清晰分开。
1.2 技术选型为什么是 SpringBoot + Vue + MySQL,而不是更复杂的东西
先看一组对比:
| 对比维度 | SpringBoot 单体 | SSM 传统项目 | SpringCloud 微服务 |
|---|---|---|---|
| 配置成本 | 自动配置,极低 | XML 配置多,繁琐 | 服务治理组件多,复杂 |
| 部署方式 | 内置 Tomcat,直接跑 jar | 外置 Tomcat,麻烦 | 多服务部署,环境要求高 |
| 学习成本 | 低,适合毕设 | 偏高过时 | 很高,明显过度设计 |
| 毕设性价比 | 高 | 中 | 低 |
选 SpringBoot 不是因为它是“新技术”,而是因为它把开发效率拉满。它内置了 Tomcat,Maven 引入几个 starter 就能跑起来;配合 MyBatis-Plus 后连基础 SQL 都不用自己写。同样道理,前端选 Vue 是因为组件化和响应式数据模型非常适合后台管理系统:Element Plus 提供现成的表格、表单、弹窗、树形控件,能让界面在短时间内达到可演示的状态。MySQL 则有成熟的关系型事务支持,合同和账单这种强一致性数据恰恰需要事务兜底。
另外一个态度问题我也想说一下。现在网上动不动就建议在毕设里堆 Redis、RabbitMQ、Spring Cloud Alibaba,看起来“技术栈全”,实际上答辩时评委问一句“你为什么要用它”就很容易卡壳。商铺管理系统的并发量撑不到微服务的层级,事务放在单体里处理反而简单可靠。毕业设计的核心是展示你对业务的理解和代码的规范度,不是展示你堆了多少中间件。
2. 数据库设计:整个项目好不好做的分水岭
2.1 核心表结构与字段规划
这个项目至少要设计这么几张表:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| sys_user | 统一保存管理员、运营、商户账号 | user_id, username, password, real_name, role, phone, status |
| shop_type | 商铺分类,如餐饮、打印店 | type_id, type_name, sort_order |
| shop_info | 商铺档案 | shop_id, shop_name, type_id, owner_user_id, floor_no, position, area, rent, status |
| rental_contract | 租赁合同 | contract_id, shop_id, renter_id, start_date, end_date, rent_amount, deposit, status |
| payment_order | 账单流水 | payment_id, contract_id, period, amount, status, pay_time |
| notice_info | 通知公告 | notice_id, title, content, publish_time |
字段设计上有两个细节特别容易被忽视,我逐一说明。
第一,owner_user_id 到底关联谁?商铺在“在租”状态下属于某个商户,但在“空置”状态下没有 owner。所以不能用 owner_user_id 作为商铺的唯一归属,而是在合同表里维护“当前有效合同的商户”。查询商铺时通过“合同状态为生效中”去关联租户,而不是直接取商铺表的某个字段。
第二,金额字段不要用 double 或 float。这类项目里你会涉及房租、物业费、保证金,浮点数在累计计算时会丢失精度。建议用 decimal(10,2),在 Java 里对应 BigDecimal。这些细节虽然小,但答辩时拿出来讲会显得你很有工程经验。
2.2 状态字段、软删除与“为什么不用外键”的道理
商铺状态我建议用整数枚举存储:0 待审核,1 空置,2 在租,3 停用,4 到期未续。数据库里存 tinyint,Java 里用枚举做映射。不要直接存中文状态名,否则以后想改文案就会面临全表数据更新。
再说一个毕设常见的坑:不要建物理外键。物理外键会让数据插入顺序变得僵化,比如你先插入合同、后补商铺资料,外键直接就拦截你了。项目里逻辑外键就够了,关联关系在 Service 层控制,或者用 MyBatis-Plus 的关联查询来做。这样删除数据时也更可控,不会因为一条外键约束导致整串数据删不掉。
软删除也建议做上。给所有核心业务表增加 deleted 字段,默认 0,删除时改成 1。这么做不是为了炫技,是防止答辩现场演示“删除商铺”时把历史合同和账单一起物理删掉了,那数据就真的找不回来了。尤其在演示过程中误操作也是常见事故,软删除是低成本高收益的保险。
最后别忘了加 created_time、updated_time 两个公共字段。如果用 MyBatis-Plus,可以让公共字段自动填充。跑批查询或者分析时,时间线清晰会舒服很多。
3. 后端实现要点:认证、权限与账单逻辑
3.1 工程结构与统一返回体
创建 SpringBoot 工程时勾选 Spring Web、MySQL Driver、MyBatis-Plus、Lombok 就能起步。建议再引入 spring-boot-starter-validation 做参数校验,蛮实用的。目录结构我习惯按这种分层组织:
controller/ # 对接前端接口 service/ # 业务逻辑 mapper/ # 数据库访问层 entity/ # 数据表实体 dto/ # 接收前端参数的传输对象 vo/ # 返回给前端的视图对象 config/ # 跨域、拦截器、全局异常处理很多同学写完一个项目,controller 里直接塞一大坨业务代码,返回给前端的数据又是 Map 又是 String,答辩时一翻代码很容易被扣印象分。建议先做一个统一响应体:
@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> ok(T data) { ... } public static <T> Result<T> error(String msg) { ... } }另外每个实体最好都有对应的 VO,不要直接把数据库实体暴露给前端。比如商铺实体里有 deleted 字段,就不应该传给前端。VO 可以做字段裁剪和组合,这也是答辩时可以讲的规范点。
3.2 登录认证用 JWT 还是 Session
商铺管理系统属于前后端分离项目,我推荐用 JWT 做登录态管理。流程是:用户提交账号密码,后端校验通过后生成 token 返回,前端存到 localStorage 或 Pinia 里,随后每次请求都在 header 里带上 token。
实现上不必引入 Spring Security 全家桶,自己写一个拦截器足够。核心逻辑如下:
@PostMapping("/login") public Result<String> login(@RequestBody LoginDTO dto) { User user = userService.login(dto.getUsername(), dto.getPassword()); // 比对 BCrypt 密码,通过后生成 token String token = JwtUtil.generate(user.getId(), user.getRole()); return Result.ok(token); }密码存储不要用明文,后端使用 BCrypt 加密。很多学生图省事直接把密码以明文字符串存进数据库,一旦代码被评审展示,这就是严重隐患。BCrypt 加盐 hash 的效果非常可靠,Spring Security 里自带的 BCryptPasswordEncoder 可以直接用。
拦截器负责三件事:解析 token 确认用户存在、判断接口角色权限、设置当前用户上下文。代码很短,但这是整个后台系统的安全骨架。管理员接口和商户接口要分开控制,比如“删除商铺”只允许管理员,“查看我的合同”只允许商户本人,这种权限颗粒度在答辩中是加分点。
3.3 账单生成:最容易踩坑的业务核心
租赁合同生效后,每个月要生成一张账单。业务逻辑看起来简单,但有三个坑必须处理。
坑一:账单重复生成。如果运营人员在月结时连续点击了两次“生成账单”,系统不应产生两条记录。解决办法是给 payment_order 表加唯一索引,组合键是 (contract_id, period)。在代码里也要先做存在性检查,写成一个幂等操作。
坑二:金额计算的精度和拆分。一张账单通常包含月租、管理费、水电分摊等。建议在 Service 层分别计算,最后用 BigDecimal 相加。不要在前端做金额合计,后端必须重新计算一遍,防止接口被篡改。
坑三:缴费状态的变更。商户付款后,后端要更新 payment_order 的状态,同时可能推迟合同到期时间。这里要放到一个事务里,否则就会出现“钱已付但合同没延期”的不一致状态。
伪代码逻辑大概是:
@Transactional public void generateBill(Long contractId, String period) { // 1. 查询生效中的合同 // 2. 校验该周期是否已存在账单 // 3. 计算租金和管理费 // 4. 插入 payment_order 记录 }这部分写扎实后,整个系统的可信度就上来了。评委最喜欢问的就是“你这个账单是怎么保证不重复生成、数据不错乱的”,能答清楚这一层,基本等于稳了。
4. 前端 Vue 实现:从接口到可用页面
4.1 工程初始化与目录规划
前端我建议直接用 Vue 3 + Vite + Element Plus + Pinia。为什么不用 Vue 2?一是 Vue 3 已经是主流,新项目再用旧版没有意义;二是 Element Plus 对 Vue 3 的支持很好,表格和表单组件完善,做这种管理系统效率很高。
初始化工程后,目录大概这样:
src/ api/ # 后端接口封装 assets/ # 静态资源 components/ # 通用组件 layout/ # 后台整体布局(侧边栏、顶栏) router/ # 路由配置 stores/ # Pinia 状态 views/ # 页面 login/ dashboard/ shop/ contract/ payment/页面划分建议跟着后端模块走,每个模块包含列表页、表单页、详情页三种常见形态。
4.2 axios 拦截器与路由守卫
前后端分离后,所有接口请求都建议封装在 api 目录下,不要在每个页面里直接写 axios。统一封装的好处是拦截器只写一次:
// axios 请求拦截 service.interceptors.request.use(config => { config.headers.Authorization = `Bearer ${localStorage.getItem('token')}` return config }) // axios 响应拦截 service.interceptors.response.use(res => { if (res.data.code === 401) { router.push('/login') } return res.data })路由守卫也要按角色过滤。管理员可以进入系统管理页面,商户只能看到自己的合同和账单。实现方式是路由 meta 里标记 roles,在 beforeEach 里判断当前登录用户的角色,不匹配就直接跳转首页并给出提示。这种体验会让系统显得“有权限意识”,远胜于一个所有人都能看所有页面的 demo。
4.3 列表页和表单页的通用实现套路
商铺管理列表是整个系统的门面。Element Plus 里 el-table 绑定列表数据,上方放搜索栏,底部放分页组件。一个标准列表页的骨架是这样:
<el-table :data="shopList"> <el-table-column prop="shopName" label="商铺名称" /> <el-table-column prop="typeName" label="类型" /> <el-table-column prop="rent" label="月租金" /> <el-table-column label="状态"> <template #default="{ row }"> <el-tag :type="statusTag(row.status)">{{ statusText(row.status) }}</el-tag> </template> </el-table-column> <el-table-column label="操作"> <el-button link type="primary" @click="openDetail(row)">详情</el-button> </el-table-column> </el-table>表单页我建议用弹窗形式承载,使用 el-dialog 包裹 el-form,配合 rules 做校验。宽度控制在 600px 左右,标题就取“新增商铺”“编辑合同”“登记账单”这类直白文案。不要做得太复杂,小程序能用、好看、不啰嗦才最合适。
记住一个重要原则:页面里的数据不要用回调地狱方式一层层嵌套拿,而是通过 Pinia 或页面级的 onMounted 调用统一的 API 函数。比如进入合同列表页就调用 contractApi.list(params),拿到数据直接赋值给响应式变量,状态管理和页面渲染都会干净很多。
5. 联调、部署与演示环境搭建
5.1 开发环境的跨域处理
本地开发时,Vite 默认端口是 5173,SpringBoot 默认 8080,浏览器直接请求会产生跨域。最优雅的解决办法是配置 Vite 代理:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端请求 /api/shop/list 在开发环境下会自动转发到后端 8080 端口,浏览器层面不感知跨域。后端也可以配置 CORS 规则,但开发时以 Vite 代理为主即可。我碰到过很多项目明明后端 CORS 配好了,部署时还是突然 401 或 404,原因大都是没有区分“开发模式代理”和“生产模式反向代理”。
5.2 前后端两种打包部署方式
方式一:前后端合并部署。前端执行 npm run build,生成 dist 目录,把整个 dist 文件夹复制到 SpringBoot 的 src/main/resources/static 下,然后用 Maven 打包:
mvn clean package -DskipTests java -jar target/shop-manage.jar这样访问 http://localhost:8080 就能直接打开页面,后端接口也在同一个端口上。适合演示环境,流程短。
方式二:前后端分离部署。前端 dist 部署到 Nginx,后端 jar 单独跑在 8080 端口。Nginx 配置反向代理,把 /api 前缀的请求转发到后端:
location /api/ { proxy_pass http://127.0.0.1:8080/; }注意 proxy_pass 最后面的斜杠,少了它路径拼接时会出现 /api/api 的尴尬问题。这个细节我见过不下三次,导致前端页面接口大面积 404。
生产或答辩环境建议用方式二,因为更像真实项目部署。如果你担心答辩现场网络不稳定,就在笔记本本地用方式一跑通,再把后端 jar 和前端 dist 都准备好备用。
5.3 数据库连接配置与初始化
数据库连接配置不要硬编码在 application.yml 里。虽然本地开发硬编码最省事,但答辩演示往往要连另一台机器的数据库。更好的做法是使用环境变量:
spring: datasource: url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:shop_db}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: ${DB_USER:root} password: ${DB_PASSWORD:root}另外一定要准备一份 db/init.sql,里面包含建库、建表、插入初始账号和模拟数据。答辩前只要执行一遍 init.sql 就能还原一个可演示的环境,而不是依赖之前的测试数据。把初始化脚本当作交付物之一,是很多评审老师会看重的好习惯。
6. 答辩与避坑:这类毕设最容易翻车的四个地方
6.1 高频问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 登录后接口 401 | token 过期或拦截器放行路径没配好 | 检查拦截器放行 /login、/register 等路径 |
| MySQL 连接报时区错误 | 连接串缺少 serverTimezone | 在 JDBC URL 加 Asia/Shanghai |
| 账单金额对不上 | 使用 double 累加 | 全部改为 BigDecimal,数据库用 decimal |
| 删除商铺却连带账单没了 | 物理删除导致关联数据丢失 | 改用软删除,保留历史数据 |
| 前端页面接口 404 | Nginx 代理路径配置错误 | 检查 proxy_pass 末尾斜杠和 /api 前缀 |
| 分页数据紊乱 | 每页数量前端和后端不一致 | 统一 pageSize 参数,后端做兜底 maxPageSize |
我把这些情况整理成表格,是因为它们几乎覆盖了我见过的大部分毕设事故现场。尤其“物理删除导致数据丢失”这条,很多同学答辩前满怀信心,结果演示到“删除一间没签合同的商铺”时,把关联的历史提示记录全部删掉,页面直接崩溃,十分可惜。
6.2 让项目更有“灵魂”的加分点
如果时间充裕,我建议再做三件锦上添花的事。
第一,加一张 operation_log 操作日志表。管理员审核入驻、运营录入合同、商户提交退租请求,全部记录在案。答辩时你可以演示“查看操作日志”,瞬间有一个可追溯的审计链路,这在管理系统中非常加分。
第二,把“新建商铺 -> 签订合同 -> 按周期生成账单 -> 模拟缴费 -> 合同到期预警”做成一条完整演示路径。不要只介绍一个个孤立页面。让评委跟着你走完一个业务流程,他们对项目的理解深度会完全不同。
第三,做一个简单的租期预警。在合同列表页增加筛选条件,查询“距到期不足30天”的合同,然后推送给运营端提醒。这其实就是一个普通查询,但业务价值感很强。
6.3 心态与技术之外的提醒
最后聊点心态。答辩时不要背稿子,也不要把每一个接口都念一遍。评委更想听到的是你作为开发者如何拆解业务、如何取舍技术方案、如何解决实际问题。哪怕你说“这里我考虑了软删除,避免误操作导致数据丢失”都比“这里我用了一个 for 循环”得分高得多。
我个人带项目时的经验是:把代码读一遍,把数据库设计过一遍,把核心事务方法讲一遍,这三点比界面多漂亮都重要。界面整洁即可,真正决定成绩下限的是数据正确性和业务闭环,上限则看你的表达思路是否清楚。
这里也分享一个我自己的独门小技巧:答辩前一天,把数据库重新初始化一次,用 init.sql 跑一遍,然后完整操作一遍“合同到期后自动停用商铺”的流程。这个操作基本能查出来你是否漏配了定时任务、是否有人在正式环境依赖了手工清理的数据。我见过不止一个团队在演示前一晚发现自己漏了一条 SQL,提前跑底prepared是唯一能兜底的办法。