Spring Boot 毕设源码 · 论文 · 数据库 · 部署文档

拆一套 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 &gt;= ?) -->

<!--                        ↑ 这个 ? 是预编译占位符 -->

所以「用了 ${} 就是不安全」是误解 —— 关键看拼进去的是结构还是用户可控的值。

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。答辩问「上传大小怎么控制」时答得出「配置项 + 异常处理」两层就够了。


四、三套用户表设计

用户与鉴权 ER 图

这套有三张用户表,对应三种身份:


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 字段:

  1. 字段集合完全不同 —— 管理员不需要姓名/身份证,会员不需要 role
  2. 挤一张表会有大量空字段 —— 一个管理员的 xingming、shouji 全是 NULL
  3. 权限边界清晰 —— 查管理员只查 users,不会因为 role 判断写错而泄露
  4. 业务逻辑不共享 —— 三个 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 统计,命令见第五节,可自行复核。


这套工程在下载中心,付款后订单页直接给网盘链接和提取码。

« IT技术交流和分享平台(springboot1o52x)源码技术走读 Spring Boot 读书笔记共享平台部署与踩坑记录 »