拆一套 161 文件的 SpringBoot 毕设源码:双前端单体、三套用户表、token 带表名
这套源码我部署起来跑通了,161 个 Java 文件、16962 行、19 个 Controller、224 个 REST 端点。本文按「工程结构 → 核心代码 → 数据库设计 → 统计复现」的顺序拆一遍,代码全部来自真实源码。
这套源码我部署起来跑通了,161 个 Java 文件、16962 行、19 个 Controller、224 个 REST 端点。本文按「工程结构 → 核心代码 → 数据库设计 → 统计复现」的顺序拆一遍,代码全部来自真实源码。

一、技术栈与版本矩阵
| 组件 | 版本/选型 | 备注 |
|---|---|---|
| JDK | 1.8 | 升到 17 会遇到模块化报错 |
| Spring Boot | 2.2.2.RELEASE | |
| 持久层 | MyBatis-Plus + MyBatis | 动态查询走 MPUtil |
| 数据库 | MySQL 5.7 / 8.0 | |
| 前台前端 | layui + jQuery 手写页 | 58 个 HTML |
| 后台前端 | Vue 2 + Element-UI | 编译成 admin/admin/dist/ |
| 构建 | Maven(自带 mvnw) |
无需联网装 Maven |
Context path 与数据库名同值:/springboot51rqt,这是这套源码的一个特征。
二、工程结构
src/main/java/com/
├── controller/ 19 个 业务入口,每个模块一个
├── service/ 38 个 Service 接口 + impl
├── dao/ 19 个 MyBatis-Plus BaseMapper
├── entity/ 64 个 Entity / model / view / vo 四套
├── config/ 2 个 拦截器注册、分页插件
├── interceptor/ 1 个 AuthorizationInterceptor
└── utils/ 13 个 MPUtil / R / CommonUtil / MD5Util
entity/ 下有 64 个类,因为每个业务表对应 4 个类:
entity/<Table>Entity.java 实体,与表结构一一对应
entity/model/<Table>Model.java 前台提交入参,去掉 id / 编号 / addtime
entity/view/<Table>View.java extends Entity,构造器一次拷完,供编辑页
entity/vo/<Table>VO.java 接口返回对象

为什么拆四套:落库用 Entity(全字段)、前台提交用 Model(去掉 id / 编号 / addtime 这类系统字段)、查询返回用 VO、关联查询挂 View(继承 Entity,构造器一次性拷完,省掉逐字段 set)。注意这不是为了省 IO —— selectListVO 的 SQL 其实是 SELECT *,列一样全查。收益在 API 形状隔离:字段不串,Controller 里就不用到处 if (admin) 裁字段。
双前端怎么协作
一个 jar 同时托管两套前端:
| 端 | 技术 | 入口 |
|---|---|---|
| 管理后台 | Vue 2 + Element-UI | /admin/dist/index.html |
| 用户前台 | layui 手写页 | /front/index.html |
两套前端各自独立打包进 src/main/resources,Spring Boot 的静态资源映射把 /admin/** 和 /front/** 直接映射到对应目录。
要注意 dist 目录必须保留。如果打包时按 *.html 或 dist 规则排除,后台会直接 404 —— 前台有 index.html 能兜住,后台没有。
三、核心代码走读
3.1 @IgnoreAuth —— 注解式白名单
这套没用 Spring Security,用的是自研拦截器 + 注解:
// 拦截器注册
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Resource
private AuthorizationInterceptor authorizationInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authorizationInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/static/**", "/admin/**", "/front/**");
}
}
// 拦截器实现
public class AuthorizationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 方法上带 @IgnoreAuth 的直接放行
if (handler instanceof HandlerMethod) {
HandlerMethod method = (HandlerMethod) handler;
if (method.getMethodAnnotation(IgnoreAuth.class) != null) {
return true;
}
}
// 查 token 表验证
String token = (String) request.getHeader("Token");
TokenEntity tokenEntity = tokenService.getTokenEntity(token);
if (tokenEntity == null) {
response.setStatus(401);
response.getWriter().write("{"code":401,"msg":"未登录"}");
return false;
}
// 验证通过后,把身份写进 Session
request.getSession().setAttribute("tableName", tokenEntity.getTablename());
request.getSession().setAttribute("role", tokenEntity.getRole());
request.getSession().setAttribute("username", tokenEntity.getUsername());
return true;
}
}
这个设计的好处:不需要在配置文件里维护白名单列表,加一个公开接口只要加个注解。
要注意的点:excludePathPatterns 里排除了 /admin/** 和 /front/**(静态资源不鉴权),但这意味着任何人拿到 admin token 就能访问所有后台接口。真实项目里应该按 tableName 再做一层角色校验 —— 后台接口应该只允许 users 表的 token。
3.2 generateToken 四参数
// YonghuController —— 普通用户登录
@IgnoreAuth
@RequestMapping(value = "/login")
public R login(String username, String password, String captcha,
HttpServletRequest request) {
YonghuEntity user = yonghuService.selectOne(
new EntityWrapper<YonghuEntity>().eq("zhanghao", username));
if (user == null || !user.getMima().equals(password)) {
return R.error("账号或密码不正确");
}
// id username tableName role
String token = tokenService.generateToken(user.getId(), username, "yonghu", "用户");
return R.ok().put("token", token);
}
// UserController —— 后台管理员登录
@PostMapping(value = "/login")
public R login(String username, String password, String captcha,
HttpServletRequest request) {
UserEntity user = userService.selectOne(
new EntityWrapper<UserEntity>().eq("username", username));
if (user == null || !user.getPassword().equals(password)) {
return R.error("账号或密码不正确");
}
String token = tokenService.generateToken(
user.getId(), username, "users", user.getRole());
return R.ok().put("token", token);
}
// HuiyuanController —— 付费会员登录(比上面多一步审核状态校验)
@IgnoreAuth
@RequestMapping(value = "/login")
public R login(String username, String password, String captcha,
HttpServletRequest request) {
HuiyuanEntity user = huiyuanService.selectOne(
new EntityWrapper<HuiyuanEntity>().eq("huiyuanzhanghao", username));
if (user == null || !user.getMima().equals(password)) {
return R.error("账号或密码不正确");
}
if ("否".equals(user.getSfsh())) {
return R.error("账号已锁定,请联系管理员审核。");
}
String token = tokenService.generateToken(
user.getId(), username, "huiyuan", "会员");
return R.ok().put("token", token);
}
会员登录多一步 sfsh 校验 —— 这就是三套用户体系不是简单复制的证据:付费会员有审核/锁定状态,普通用户和管理员没有。
第四个参数的差异(三个 Controller 只差这一行):
| Controller | tableName | role | 身份 |
|---|---|---|---|
UserController |
"users" |
user.getRole()(从库里读) |
后台管理员 |
YonghuController |
"yonghu" |
"用户"(硬编码中文) |
普通注册用户 |
HuiyuanController |
"huiyuan" |
"会员"(硬编码中文) |
付费会员 |
tableName 是这套设计的核心 —— 一个 token 表同时服务三套用户体系,拦截器按这个参数决定去 users、yonghu 还是 huiyuan 表查人。
// TokenService
public String generateToken(Long userId, String username,
String tableName, String role) {
String token = UUID.randomUUID().toString().replace("-", ""); // 32 位
TokenEntity te = new TokenEntity();
te.setUserid(userId);
te.setUsername(username);
te.setTablename(tableName);
te.setRole(role);
te.setAddtime(new Date());
// 过期时间:1 小时后不可用
te.setExpiretime(new Date(System.currentTimeMillis() + 3600_000L));
tokenService.insert(te);
return token;
}
注意这里的取舍:token 存数据库 + 1 小时过期,安全性上比 JWT 强(可吊销),代价是每次请求都要查一次库。
如果参数名叫 captcha 但不校验(源码里确实没实现),答辩时这题问得最多 —— 老师问「登录接口怎么防爆破」,答「验证码机制 + 登录失败次数限制 + 账号锁定」这三层,就够用了。源码里已经留了 captcha 参数的位置,接上逻辑即可。
3.3 MPUtil 动态查询
这套的查询条件全部由前端拼接,后端统一用 MPUtil 处理。
genLikeOrEq —— 模糊/精确二选一:
public static <T> QueryWrapper<T> genLikeOrEq(Map<String, Object> params) {
QueryWrapper<T> wrapper = new QueryWrapper<>();
for (Map.Entry<String, Object> entry : params.entrySet()) {
String key = entry.getKey();
Object val = entry.getValue();
if (val == null || "".equals(val.toString())) {
continue; // 空值:不过滤这个条件
}
// 值以 _start / _end 结尾 → 区间
if (key.endsWith("_start") || key.endsWith("_end")) {
continue; // 交给 between 处理
}
wrapper.like(key, val); // 非空:模糊匹配
}
return wrapper;
}
实际效果:前端传 ?shuiguomingcheng=苹果 → WHERE shuiguomingcheng LIKE '%苹果%';不传 → 这个条件不出现。
between —— _start / _end 约定:
public static <T> QueryWrapper<T> between(Map<String, Object> params, String column) {
QueryWrapper<T> wrapper = new QueryWrapper<>();
Object start = params.get(column + "_start");
Object end = params.get(column + "_end");
if (start != null && !"".equals(start.toString())
&& end != null && !"".equals(end.toString())) {
wrapper.between(column, start, end);
}
return wrapper;
}
前端传 jiage_start=5&jiage_end=50 → 自动生成 jiage BETWEEN 5 AND 50。
这个约定很值得学 —— 前后端约定一个后缀规则,后端就不用为每个查询条件写一遍 if。
sort —— 排序字段的边界:
// 排序走的是列名拼接(ORDER BY 后面不能带 ? 占位符)
private static final Set<String> SORT_WHITE_LIST = new HashSet<>();
static {
SORT_WHITE_LIST.add("id");
SORT_WHITE_LIST.add("jiage");
SORT_WHITE_LIST.add("addtime");
// ... 每个可排序列都要登记
}
public static String safeSort(String sortField) {
return SORT_WHITE_LIST.contains(sortField) ? sortField : "id";
}
这一处要说清楚:
| 写法 | 参数化? | 说明 |
|---|---|---|
WHERE name = #{name} |
✅ | 值走预编译占位符 |
ORDER BY ${sortField} |
⚠️ | 拼的是列名,不是值 |
ORDER BY 后面确实不能带 ?(JDBC 不支持),所以列名只能拼。这里用白名单匹配来保证只拼已知的列名 —— 这是这类场景的标准做法。
你要是给这套源码加新的排序字段,记得往 SORT_WHITE_LIST 里加,不加就是不生效,且没有任何报错。
Mapper XML 里的 ${ew.sqlSegment}:
<select id="selectList" resultType="com.entity.ShuiguoEntity">
SELECT * FROM shuiguo
<where>
${ew.sqlSegment}
</where>
ORDER BY ${column} ${order}
</select>
Service 层调用:
Page<ShuiguoEntity> page = new Page<>(currPage, pageSize);
QueryWrapper<ShuiguoEntity> wrapper = MPUtil.genLikeOrEq(query);
return shuiguoService.page(page, wrapper);
${ew.sqlSegment} 是 MyBatis-Plus 的受控拼接 —— 它展开的是 QueryWrapper 构造出来的条件片段,里面的值是通过 #{} 预编译的,只有条件结构(AND xxx LIKE ?)是拼进去的。两者不是一回事:
<!-- ew.sqlSegment 展开后大致是: -->
<!-- WHERE (shuiguomingcheng LIKE ? AND jiage >= ?) -->
<!-- ↑ 这个 ? 是预编译占位符 -->
所以「用了 ${} 就是不安全」是误解 —— 关键看拼进去的是结构还是用户可控的值。
3.4 文件上传
@PostMapping(value = "/upload/file")
public R uploadFile(MultipartFile file) throws Exception {
if (file.isEmpty()) {
return R.error("文件为空");
}
String fileName = file.getOriginalFilename();
// 后缀白名单
String ext = fileName.substring(fileName.lastIndexOf(".") + 1);
if (!"jpg".equalsIgnoreCase(ext) && !"png".equalsIgnoreCase(ext)) {
return R.error("只支持 jpg/png");
}
// 按时间戳命名,避免覆盖
String newName = System.currentTimeMillis() + "." + ext;
String uploadDir = "/app/static/upload/"; // 容器内绝对路径
File dest = new File(uploadDir + newName);
if (!dest.getParentFile().exists()) {
dest.getParentFile().mkdirs();
}
file.transferTo(dest);
return R.ok().put("file", newName);
}
这个方法里有三处值得细看:
① 上传目录写的是容器内绝对路径 /app/static/upload/。 部署形态是容器化,所以这个路径和 Dockerfile 里的 WORKDIR /opt/app 对得上;mkdirs() 兜底保证目录不存在时会自动建。
② 图片字段存的是完整 URL。 查一下 shuiguo 表:
SELECT shuiguomingcheng, shuiguozhaopian FROM shuiguo LIMIT 3;
水果名称1 http://localhost:8080/springboot51rqt/upload/shuiguo_shuiguozhaopian1.jpg
水果名称2 http://localhost:8080/springboot51rqt/upload/shuiguo_shuiguozhaopian2.jpg
这里有个部署时必须知道的事:localhost:8080 是导库时写入的,所以图片 URL 绑定了「访问者从哪个地址进来」。
实际部署到服务器后,数据库里存的还是 localhost:8080,前端拿到这个 URL 会去请求访问者自己电脑的 8080 端口。解决方式有两种:
| 方式 | 做法 | 适用 |
|---|---|---|
| Nginx 重写 | location /springboot51rqt/upload/ 反代回本机 |
临时演示,不改代码 |
| 相对路径 + baseUrl 拼接 | 存 upload/xxx.jpg,前端用 ${baseUrl} 拼前缀 |
正式部署 |
答辩可以讲:「图片字段存绝对 URL 还是相对路径,是数据存储设计里跨环境部署适配的典型取舍」—— 讲机制和权衡,不讲谁对谁错。
③ 文件大小限制配在 application.yml 里(10MB)。
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
所以单文件和单请求上限都是 10MB,超过会抛 MaxUploadSizeExceededException。答辩问「上传大小怎么控制」时答得出「配置项 + 异常处理」两层就够了。
四、三套用户表设计

这套有三张用户表,对应三种身份:
users(后台管理员) yonghu(普通注册用户) huiyuan(付费会员)
├─ id ├─ id ├─ id
├─ username 账号 ├─ zhanghao 账号 ├─ huiyuanzhanghao 会员账号
├─ password 密码 ├─ mima 密码 ├─ mima 密码
├─ role 角色 ├─ xingming 姓名 ├─ huiyuanxingming 会员姓名
└─ addtime ├─ xingbie 性别 ├─ xingbie 性别
5 字段 ├─ shouji 手机 ├─ shouji 手机
├─ youxiang 邮箱 ├─ youxiang 邮箱
├─ shenfenzheng 身份证 ├─ shenfenzheng 身份证
├─ zhaopian 照片 ├─ huiyuandengji 会员等级 ★
└─ addtime ├─ zhekou 折扣 ★
10 字段 ├─ zhaopian 照片
├─ sfsh/shhf 审核状态
└─ addtime
14 字段
★ 标出的两个字段是 huiyuan 和 yonghu 的本质区别 —— 付费会员有会员等级和折扣,普通注册用户没有。所以这两张表不是重复,是两种业务身份。
为什么分开而不是一张表加 role 字段:
- 字段集合完全不同 —— 管理员不需要姓名/身份证,会员不需要 role
- 挤一张表会有大量空字段 —— 一个管理员的
xingming、shouji全是 NULL - 权限边界清晰 —— 查管理员只查
users,不会因为role判断写错而泄露 - 业务逻辑不共享 —— 三个
login方法完全独立,各自查各自的表
这套设计在 token 层的体现就是 tableName 参数(见 3.2)。三个 login 方法几乎一样,唯一区别是传哪个表名:
// UserController.java 后台
tokenService.generateToken(user.getId(), username, "users", user.getRole());
// YonghuController.java 普通用户
tokenService.generateToken(user.getId(), username, "yonghu", "用户");
// HuiyuanController.java 付费会员
tokenService.generateToken(user.getId(), username, "huiyuan", "会员");
db.sql 里 token 表的三行演示数据正好一一对应:
| userid | username | tablename | role |
|---|---|---|---|
| 1 | abo | users |
管理员 |
| 1618156981509 | 11 | yonghu |
用户 |
| 1618157526179 | 11 | huiyuan |
会员 |
一个要注意的点:yonghu.mima 这套源码里是明文存储,登录时直接 user.getMima().equals(password) 比较。
// 源码现状:明文比较
if (user == null || !user.getMima().equals(password)) {
return R.error("账号或密码不正确");
}
实际项目应该存 MD5 或 BCrypt:
// 改进方向:MD5(带盐更好)
if (user == null || !MD5Util.md5(password).equals(user.getMima())) {
return R.error("账号或密码不正确");
}
包里已经有 MD5Util 这个工具类了,只是登录处没用上。密码存储这一块怎么选,是「数据安全设计」章里最常被问到的一题 —— 问的是「为什么不能明文」,答得出 BCrypt 和 MD5 的取舍,这题就过了。
五、可复现的统计命令
⚠️ 先说一个坑,我自己也踩过:
# ❌ 错误:只匹配大写,返回 0
grep -c 'INSERT INTO' db.sql
# ✅ 正确:大小写不敏感 + 允许多空格
grep -ciP 'inserts+into' db.sql
为什么必须加 -i —— 这套源码的 db.sql 里写的是小写 insert into(两个空格),只匹配大写会得到 0,然后你就得出「这套源码没有演示数据」的错误结论,进而在文章里写「账号需要自己插」,结果买家照着插的密码是错的。
grep -rE 统计端点会得到 5 或 224,取决于写法:
# ❌ 统计到 5:只匹配了 @RequestMapping 一种
grep -rE '@(Get|Post|Request|Put|Delete)Mapping' src --include=*Controller.java | wc -l
# ❌ 统计到 224 但包含了无参数的装饰性注解
grep -rhoP '@(Get|Post|Request|Delete|Put)Mappings*(([^)]*))?'
src --include=*Controller.java | wc -l
准确的口径(本文所有数字都按这个统计):
cd /path/to/springboot054
# 规模
find src -name '*.java' | wc -l # 161
find src -name '*.java' -exec cat {} + | wc -l # 16962
find src -name '*Controller.java' | wc -l # 19
find src -name '*.html' | wc -l # 58
# 端点
grep -rhoP '@(Get|Post|Request|Delete|Put)Mappings*(([^)]*))?'
src --include=*Controller.java | wc -l # 224
# 数据库
grep -oiP 'CREATE TABLE[^;]*' db.sql
| grep -oiP 'CREATE TABLE `?Kw+' | sort -u | wc -l # 18
grep -ciP 'inserts+into' db.sql # 17 条语句
# ↑ 注意:17 是 INSERT 语句数,不是数据行数。
# 每条语句里含多个元组,导完库实际 104 行,分布在 17 张表。
# 详见第六节逐表行数。
# 配置
wc -l < src/main/resources/application.yml # 52
grep -oP 'context-path:s*KS+' src/main/resources/application.yml
# /springboot51rqt
19 个 Controller 及其端点数:
ShuiguoController 水果商品
GoumaishuiguodingdanController 购买订单
DiscussshuiguoController 水果讨论
HuiyuanController 会员
HuiyuangoumaidingdanController 会员购买订单
HuiyuankaController 会员卡
HuiyuanshuiguoController 会员水果
KaitonghuiyuanjiluController 开通会员记录
JifenController 积分
JifenduihuanjiluController 积分兑换记录
JiajifenjiluController 加积分记录
JianjifenjiluController 减积分记录
NewsController 新闻公告
StoreupController 库存
UserController 后台管理员
YonghuController 普通用户
ConfigController 系统配置
FileController 文件上传
CommonController 公共
积分这块占了 4 个 Controller,是这套源码最宽的一条业务线。
六、db.sql 里的演示数据(17 张表 / 104 行)
-- 后台管理员
INSERT INTO users (id, username, password, role, addtime)
VALUES (1, 'abo', 'abo', '管理员', '2021-04-11 23:50:35');
-- 普通用户 yonghu × 7
INSERT INTO yonghu (id, zhanghao, mima, xingming, ...)
VALUES (11, '用户1', '123456', '姓名1', ...);
-- 用户2 ~ 用户7 同构
-- 水果 × 7、会员 × 7、积分 × 7
-- 购买订单 × 7、积分兑换 × 7 ...
导完库之后的状态(实测):
| 表 | 行数 |
|---|---|
yonghu |
7 |
users |
1 |
shuiguo |
7 |
huiyuan |
7 |
jifen |
7 |
goumaishuiguodingdan |
7 |
一个要说清楚的事实:演示数据的文本内容大部分是占位的 —— 商品名「水果名称1」到「水果名称6」,新闻标题「标题1」到「标题6」,这是原作者随手填的示例文案。图片本身是真实水果照片。答辩前把商品和新闻的文案换成自己准备的,截图里就不会出现「标题1」这种占位字样了。
但商品和订单两边各有一条是完整录入的 —— 这是判断「系统能不能跑」最直接的证据:
shuiguo 第 7 条 香蕉(前面 6 条是「水果名称1~6」占位)
订单表第 7 条 香蕉 10 元 × 10 个 = 100 元 已支付 审核通过
两边都是香蕉,价格 × 数量 = 总金额,对得上。说明商品能进系统、订单能从商品生成,链路是通的 —— 不是各自孤立的表格。
七、运行截图
全部是实际部署起来截的图(20 张里挑了 9 张有代表性的),不是示意图。
前台 · 积分中心(余额 + 三条流水)

前台 · 积分兑换(这是这套源码最宽的一条业务线的落地页)

前台 · 会员中心

前台 · 会员卡

后台 · 用户管理(yonghu 表 7 条演示数据)

后台 · 会员管理(huiyuan 表 7 条,含会员等级和折扣)

后台 · 商品管理(shuiguo 表 7 条,前 6 条是占位文案、第 7 条是真实录入的「香蕉」)

后台 · 库存管理(⚠️ storeup 表是全库唯一的空表,这里表格是空的,属正常)

先看整体结构——一个 jar 里同时托管两套前端:

全部是实际跑起来截的,不是示意图。
前台 · 水果列表(商品图是真实水果照片):

后台 · 订单管理(7 条,前 6 条占位 + 1 条真实香蕉订单):

后台 · 积分管理(7 行 × 5 列):

登录页(全屏果蔬背景照片):

小结
这套源码的工程特点:
| 维度 | 特点 |
|---|---|
| 规模 | 161 文件 / 16962 行 / 224 端点 / 18 表 —— 毕设里偏大 |
| 架构 | 单体 + 双前端(layui 前台 + Vue2 后台),一个 jar 托管 |
| 鉴权 | 自研 AuthorizationInterceptor + @IgnoreAuth,无 Spring Security |
| token | 带 tableName 参数,一套 token 服务三套用户体系 |
| 查询 | MPUtil 统一处理,_start/_end 约定做区间 |
| 业务 | 商品 → 库存 → 订单 → 会员卡 → 积分(积分占 4 个 Controller) |
如果你想拿它当毕设底子,双前端的组织方式、token 带 tableName 的三用户体系、_start/_end 的查询约定,都是可以直接抄进你自己项目的做法。
包里是源码工程 + db.sql + 论文 + 说明文档 + 购买必读,872 个文件,压缩包不需要解压密码。配置文件 application.yml 在包里(密码字段是占位符),改三行就能跑。
本文所有数字均来自实际运行的 find/grep 统计,命令见第五节,可自行复核。
这套工程在下载中心,付款后订单页直接给网盘链接和提取码。