IT技术交流和分享平台(springboot1o52x)源码技术走读
这是一套完整可运行的 SpringBoot 毕设工程,我把它拆开讲一遍。所有代码片段摘自源码原文,所有统计数字都给出可复现命令——你可以自己跑一遍对答案。
这是一套完整可运行的 SpringBoot 毕设工程,我把它拆开讲一遍。所有代码片段摘自源码原文,所有统计数字都给出可复现命令——你可以自己跑一遍对答案。
工程规模:81 个 Java 文件 / 6803 行代码 / 100 个映射注解(92 个方法级 + 8 个类级)/ 9 个 Controller / 9 个 Mapper XML / 8 张数据表 / 32 行演示数据。
如果你正在找毕设源码、想拿这套工程练手、或者想在论文里写”系统实现”这一章,这份拆解可以当参考。末尾有我整理的三个练手切入点。
一、技术栈与版本矩阵
版本信息全部来自 pom.xml,没有推测:
| 层次 | 组件 | 版本 |
|---|---|---|
| 框架 | spring-boot-starter-parent | 2.2.2.RELEASE |
| JDK | java.version | 1.8 |
| ORM | mybatis-plus | 2.3 |
| ORM 启动器 | mybatisplus-spring-boot-starter | 1.0.5 |
| MyBatis 桥接 | mybatis-spring-boot-starter | 2.1.1 |
| 声明式安全 | shiro-spring | 1.3.2(依赖已声明但代码零引用,可作为练手切入点之一) |
| JSON | fastjson | 1.2.8 |
| 工具库 | hutool-all | 4.0.12 |
| 通用工具 | commons-lang3 / commons-io | 3.0 / 2.5 |
| 数据库 | mysql-connector-java | 随 Boot 管理 |
| 分页 | PaginationInterceptor | 声明在 MybatisPlusConfig |
后台前端 Vue 2.6.10 + Element-UI 2.13.0(dist/ 保留编译产物),前台 layui + jQuery + TinyMCE 5。
MyBatis-Plus 2.3 是这套代码的硬约束:全部业务代码使用 com.baomidou.mybatisplus.mapper.EntityWrapper、com.baomidou.mybatisplus.mapper.Wrapper、com.baomidou.mybatisplus.plugins.Page、ServiceImpl 全家桶。3.x 把这些类型迁到 mybatis-plus-extension 并重写了 API,直接升 3.x 会满屏编译错误,必须先做一次 2.x → 3.x 的 Wrapper 迁移。
二、工程结构
src/main/java/com/
├── SpringbootSchemaApplication.java # @SpringBootApplication + @MapperScan("com.dao")
├── annotation/ IgnoreAuth / LoginUser / APPLoginUser
├── config/ InterceptorConfig / MybatisPlusConfig
├── controller/ 9 个
├── dao/ 9 个(extends BaseMapper<T>)
├── entity/ 24 个文件 = 9 核心 Entity + 5 VO + 5 View + 5 Model
├── interceptor/ AuthorizationInterceptor
├── service/ 9 接口 + impl/9 实现
└── utils/ MPUtil / Query / PageUtils / R / SQLFilter / CommonUtil ...
src/main/resources/
├── mapper/ 9 个 *Dao.xml
├── admin/admin/ src/ 36 个 .vue + dist/ 12 个已编译文件
├── front/front/ 25 个 HTML
└── static/upload/ 22 个演示图片
SpringbootSchemaApplication 继承 SpringBootServletInitializer,可打 war 包;配套的 pom-war.xml 里 <packaging>war</packaging>、<finalName>springboot1o52x</finalName>。
InterceptorConfig 继承的是 WebMvcConfigurationSupport 而非 WebMvcConfigurer,这会关闭 SpringBoot 的 MVC 自动配置。源码里已经用重写 addResourceHandlers 补回了静态资源映射,否则前台后台会 404 —— 这是这类工程最典型的自坑点。
三、核心代码走读
3.1 鉴权:@IgnoreAuth + AuthorizationInterceptor
没有用 Spring Security,用的是自定义注解 + 拦截器。注解本体只声明方法级、运行时保留:
package com.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IgnoreAuth {
}
拦截器的完整 preHandle 逻辑:
@Component
public class AuthorizationInterceptor implements HandlerInterceptor {
public static final String LOGIN_TOKEN_KEY = "Token";
@Autowired
private TokenService tokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 支持跨域
response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers",
"x-requested-with,request-source,Token, Origin,imgType, Content-Type, ...");
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
// 预检请求直接短路
if (request.getMethod().equals(RequestMethod.OPTIONS.name())) {
response.setStatus(HttpStatus.OK.value());
return false;
}
IgnoreAuth annotation;
if (handler instanceof HandlerMethod) {
annotation = ((HandlerMethod) handler).getMethodAnnotation(IgnoreAuth.class);
} else {
return true;
}
String token = request.getHeader(LOGIN_TOKEN_KEY);
// 打了 @IgnoreAuth 直接放行
if (annotation != null) {
return true;
}
TokenEntity tokenEntity = null;
if (StringUtils.isNotBlank(token)) {
tokenEntity = tokenService.getTokenEntity(token);
}
if (tokenEntity != null) {
request.getSession().setAttribute("userId", tokenEntity.getUserid());
request.getSession().setAttribute("role", tokenEntity.getRole());
request.getSession().setAttribute("tableName", tokenEntity.getTablename());
request.getSession().setAttribute("username", tokenEntity.getUsername());
return true;
}
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter writer = response.getWriter();
writer.print(JSONObject.toJSONString(R.error(401, "请先登录")));
writer.close();
return false;
}
}
注册方式是 addPathPatterns("/**") 排除 /static/**,默认全部拦截,靠 @IgnoreAuth 逐个开洞:
@Configuration
public class InterceptorConfig extends WebMvcConfigurationSupport {
@Bean
public AuthorizationInterceptor getAuthorizationInterceptor() {
return new AuthorizationInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(getAuthorizationInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/static/**");
super.addInterceptors(registry);
}
// WebMvcConfigurationSupport 会覆盖默认配置,必须重写才能访问静态资源
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/resources/")
.addResourceLocations("classpath:/static/")
.addResourceLocations("classpath:/admin/")
.addResourceLocations("classpath:/front/")
.addResourceLocations("classpath:/public/");
super.addResourceHandlers(registry);
}
}
两个工程特征值得注意:
- Token 走
Token请求头,不是标准Authorization,也不带Bearer前缀。Access-Control-Allow-Headers里显式列了Token,说明这个非标准头是刻意设计的。前台front/front/modules/http/http.js靠localStorage.getItem("Token")注入:
request: function(url, type, data, callback) {
var index = layer.load(1, { shade: [0.1, '#fff'] });
url = baseurl + url;
data['t'] = jquery.now();
jquery.ajax({
url: url,
beforeSend: function(request) {
request.setRequestHeader("Token", localStorage.getItem("Token"));
},
contentType: 'application/x-www-form-urlencoded',
data: data,
dataType: 'json',
type: type,
...
});
}
preHandle里同时用了请求头 Token 和HttpSession。Token 是无状态的,但每次请求又把身份回写进 Session,Controller 里再用request.getSession().getAttribute("userId")取值(如YonghuController.getCurrUser)。这是两套会话机制并存,属于冗余设计。
@IgnoreAuth 全文 17 处,分布:CommonController 6、UserController 3、YonghuController 3、ConfigController 2、BijifenxiangController 1、DiscussbijifenxiangController 1、FileController 1。
3.2 Token:四参数 generateToken 与双用户表
这是整套代码里设计得最清晰的一处。token 表的字段设计直接服务于”两套用户体系”:管理员和前台普通用户分属 users / yonghu 两张表,Token 用 tablename 字段记录归属。
@Service("tokenService")
public class TokenServiceImpl extends ServiceImpl<TokenDao, TokenEntity>
implements TokenService {
@Override
public String generateToken(Long userid, String username, String tableName, String role) {
// 同一 userid + role 只保留一条记录,重复登录即覆盖
TokenEntity tokenEntity = this.selectOne(
new EntityWrapper<TokenEntity>()
.eq("userid", userid)
.eq("role", role));
String token = CommonUtil.getRandomString(32);
Calendar cal = Calendar.getInstance();
cal.setTime(new Date());
cal.add(Calendar.HOUR_OF_DAY, 1); // 固定 1 小时有效期
if (tokenEntity != null) {
tokenEntity.setToken(token);
tokenEntity.setExpiratedtime(cal.getTime());
this.updateById(tokenEntity);
} else {
this.insert(new TokenEntity(userid, username, tableName, role, token, cal.getTime()));
}
return token;
}
@Override
public TokenEntity getTokenEntity(String token) {
TokenEntity tokenEntity = this.selectOne(
new EntityWrapper<TokenEntity>().eq("token", token));
if (tokenEntity == null
|| tokenEntity.getExpiratedtime().getTime() < new Date().getTime()) {
return null; // 不存在或已过期
}
return tokenEntity;
}
}
签名 generateToken(Long userid, String username, String tableName, String role) 的四个参数里,第三、第四个参数是这套双用户模型的关键。两个登录接口的调用差异:
// UserController —— 管理员
@IgnoreAuth
@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);
}
// YonghuController —— 前台普通用户
@IgnoreAuth
@RequestMapping(value = "/login")
public R login(String username, String password, String captcha, HttpServletRequest request) {
YonghuEntity user = yonghuService.selectOne(
new EntityWrapper<YonghuEntity>().eq("yonghuming", username));
if (user == null || !user.getMima().equals(password)) {
return R.error("账号或密码不正确");
}
String token = tokenService.generateToken(user.getId(), username, "yonghu", "用户");
return R.ok().put("token", token);
}
管理员传 ("users", user.getRole()),前台用户传 ("yonghu", "用户")。Token 里同时记下了 tablename 与 role,鉴权时 AuthorizationInterceptor 把它们写进 Session,因此后端在任何请求里都能回答”当前是谁、属于哪张表”。
三个实现细节值得指出:
- 管理员
role从库里读,普通用户角色硬编码字符串"用户"。 - 重复登录覆盖而非累加:
selectOne(userid + role)保证一人一条 Token,异地登录会踢掉前一次会话。 - Token 随机源不是密码学安全的:
public static String getRandomString(Integer num) {
String base = "abcdefghijklmnopqrstuvwxyz0123456789";
Random random = new Random(); // java.util.Random,非 SecureRandom
StringBuffer sb = new StringBuffer();
for (int i = 0; i < num; i++) {
int number = random.nextInt(base.length());
sb.append(base.charAt(number));
}
return sb;
}
java.util.Random 的种子可预测(Mersenne Twister,可由少量输出反推内部状态),用于登录凭证是不合适的,应替换为 SecureRandom。
另外 login 方法接收了 captcha 参数却从不校验,两个 Controller 都是如此,图形验证码形同虚设。
3.3 动态查询:MPUtil + EntityWrapper
后台列表查询没有一行硬编码 WHERE,全靠 MPUtil 把前端条件翻译成 Wrapper。以业务核心 BijifenxiangController 为例:
@RequestMapping("/page")
public R page(@RequestParam Map<String, Object> params, BijifenxiangEntity bijifenxiang,
HttpServletRequest request) {
EntityWrapper<BijifenxiangEntity> ew = new EntityWrapper<BijifenxiangEntity>();
PageUtils page = bijifenxiangService.queryPage(params,
MPUtil.sort(MPUtil.between(MPUtil.likeOrEq(ew, bijifenxiang), params), params));
return R.ok().put("data", page);
}
三层嵌套:likeOrEq 负责模糊/精确匹配 → between 负责 _start / _end 区间 → sort 负责排序。6 个 Controller(Bijifenxiang / Bijileixing / Discussbijifenxiang / Storeup / Yonghu / User)的 page 与 list 都是这个形状,差别只在用 allLike 还是 likeOrEq;Common / Config / File 三个不涉及列表查询。
MPUtil 关键实现:
public static Wrapper allLike(Wrapper wrapper, Object bean) {
Map result = BeanUtil.beanToMap(bean, true, true); // 忽略 null 与默认值
return genLike(wrapper, result);
}
public static Wrapper genLike(Wrapper wrapper, Map param) {
Iterator<Map.Entry<String, Object>> it = param.entrySet().iterator();
int i = 0;
while (it.hasNext()) {
if (i > 0) wrapper.and();
Map.Entry<String, Object> entry = it.next();
String key = entry.getKey();
String value = (String) entry.getValue();
wrapper.like(key, value);
i++;
}
return wrapper;
}
public static Wrapper likeOrEq(Wrapper wrapper, Object bean) {
Map result = BeanUtil.beanToMap(bean, true, true);
return genLikeOrEq(wrapper, result);
}
public static Wrapper genLikeOrEq(Wrapper wrapper, Map param) {
Iterator<Map.Entry<String, Object>> it = param.entrySet().iterator();
int i = 0;
while (it.hasNext()) {
if (i > 0) wrapper.and();
Map.Entry<String, Object> entry = it.next();
String key = entry.getKey();
// 值里带 % 走模糊,否则走精确
if (entry.getValue().toString().contains("%")) {
wrapper.like(key, entry.getValue().toString().replace("%", ""));
} else {
wrapper.eq(key, entry.getValue());
}
i++;
}
return wrapper;
}
between 与 sort 是直接消费请求参数 Map 的两个方法,也是本工程最需要关注的安全面(下一节详述):
public static Wrapper between(Wrapper wrapper, Map<String, Object> params) {
for (String key : params.keySet()) { // key 来自请求,可被构造
String columnName = "";
if (key.endsWith("_start")) {
columnName = key.substring(0, key.indexOf("_start"));
if (StringUtils.isNotBlank(params.get(key).toString())) {
wrapper.ge(columnName, params.get(key));
}
}
if (key.endsWith("_end")) {
columnName = key.substring(0, key.indexOf("_end"));
if (StringUtils.isNotBlank(params.get(key).toString())) {
wrapper.le(columnName, params.get(key));
}
}
}
return wrapper;
}
public static Wrapper sort(Wrapper wrapper, Map<String, Object> params) {
String order = "";
if (params.get("order") != null
&& StringUtils.isNotBlank(params.get("order").toString())) {
order = params.get("order").toString();
}
if (params.get("sort") != null
&& StringUtils.isNotBlank(params.get("sort").toString())) {
if (order.equalsIgnoreCase("desc")) {
wrapper.orderDesc(Arrays.asList(params.get("sort"))); // 列名来自请求
} else {
wrapper.orderAsc(Arrays.asList(params.get("sort"))); // 列名来自请求
}
}
return wrapper;
}
驼峰转下划线由 camelToUnderline 完成,供 allEQMapPre 生成带表前缀的条件:
public static String camelToUnderline(String param) {
if (param == null || "".equals(param.trim())) {
return "";
}
int len = param.length();
StringBuilder sb = new StringBuilder(len);
for (int i = 0; i < len; i++) {
char c = param.charAt(i);
if (Character.isUpperCase(c)) {
sb.append(UNDERLINE);
sb.append(Character.toLowerCase(c));
} else {
sb.append(c);
}
}
return sb.toString();
}
同一个 Controller 里还有两种不同形态的列表接口,容易混淆。/list 带分页参数,走 MPUtil 三层嵌套;/lists 不分页,走 allEq 精确匹配并加表前缀:
// /list —— 分页,条件由 MPUtil 拼装
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params, BijifenxiangEntity bijifenxiang,
HttpServletRequest request) {
EntityWrapper<BijifenxiangEntity> ew = new EntityWrapper<BijifenxiangEntity>();
PageUtils page = bijifenxiangService.queryPage(params,
MPUtil.sort(MPUtil.between(MPUtil.likeOrEq(ew, bijifenxiang), params), params));
return R.ok().put("data", page);
}
// /lists —— 不分页,全等匹配,列名带表前缀
@RequestMapping("/lists")
public R list(BijifenxiangEntity bijifenxiang) {
EntityWrapper<BijifenxiangEntity> ew = new EntityWrapper<BijifenxiangEntity>();
ew.allEq(MPUtil.allEQMapPre(bijifenxiang, "bijifenxiang"));
return R.ok().put("data", bijifenxiangService.selectListView(ew));
}
/list 与 /page 的方法体实质等价(同一段 MPUtil 链式调用 + 同一个 queryPage 重载),差别仅在空行排版,是代码生成器的重复产物;/lists 才是 allEq + 前缀的真正落点。
EntityWrapper 2.x 的 API 特征值得单独说明:它是可变对象,like / eq / and / orderAsc 都是就地追加并返回自身,所以 MPUtil 才能用链式写法把条件灌进同一个实例。3.x 改成了不可变的 LambdaQueryWrapper,这些方法返回新实例,上面所有 MPUtil 都要重写。
Service 层是标准的两段式分页:
@Service("bijifenxiangService")
public class BijifenxiangServiceImpl extends ServiceImpl<BijifenxiangDao, BijifenxiangEntity>
implements BijifenxiangService {
@Override
public PageUtils queryPage(Map<String, Object> params) {
Page<BijifenxiangEntity> page = this.selectPage(
new Query<BijifenxiangEntity>(params).getPage(),
new EntityWrapper<BijifenxiangEntity>());
return new PageUtils(page);
}
@Override
public PageUtils queryPage(Map<String, Object> params, Wrapper<BijifenxiangEntity> wrapper) {
Page<BijifenxiangView> page = new Query<BijifenxiangView>(params).getPage();
page.setRecords(baseMapper.selectListView(page, wrapper)); // 走自定义 XML
PageUtils pageUtil = new PageUtils(page);
return pageUtil;
}
}
重载的 queryPage(params, wrapper) 走 baseMapper.selectListView(自定义 XML),单参的 queryPage(params) 走 this.selectPage(BaseMapper 内置),Controller 用的都是前者。
3.4 Mapper XML:${ew.sqlSegment} 的落点
Dao 接口用 @Param("ew") 把 Wrapper 交给 XML:
public interface BijifenxiangDao extends BaseMapper<BijifenxiangEntity> {
List<BijifenxiangVO> selectListVO(@Param("ew") Wrapper<BijifenxiangEntity> wrapper);
BijifenxiangVO selectVO(@Param("ew") Wrapper<BijifenxiangEntity> wrapper);
List<BijifenxiangView> selectListView(@Param("ew") Wrapper<BijifenxiangEntity> wrapper);
List<BijifenxiangView> selectListView(Pagination page,
@Param("ew") Wrapper<BijifenxiangEntity> wrapper);
BijifenxiangView selectView(@Param("ew") Wrapper<BijifenxiangEntity> wrapper);
}
XML 全文只有 47 行,四个 select 结构完全一致:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.dao.BijifenxiangDao">
<resultMap type="com.entity.BijifenxiangEntity" id="bijifenxiangMap">
<result property="bijimingcheng" column="bijimingcheng"/>
<result property="bijileixing" column="bijileixing"/>
<result property="bijibiaoti" column="bijibiaoti"/>
<result property="bijitupian" column="bijitupian"/>
<result property="bijifujian" column="bijifujian"/>
<result property="bijineirong" column="bijineirong"/>
<result property="faburiqi" column="faburiqi"/>
<result property="yonghuming" column="yonghuming"/>
<result property="xingming" column="xingming"/>
<result property="thumbsupnum" column="thumbsupnum"/>
<result property="crazilynum" column="crazilynum"/>
<result property="clicktime" column="clicktime"/>
<result property="clicknum" column="clicknum"/>
</resultMap>
<select id="selectListVO" resultType="com.entity.vo.BijifenxiangVO">
SELECT * FROM bijifenxiang bijifenxiang
<where> 1=1 ${ew.sqlSegment}</where>
</select>
<select id="selectVO" resultType="com.entity.vo.BijifenxiangVO">
SELECT bijifenxiang.* FROM bijifenxiang bijifenxiang
<where> 1=1 ${ew.sqlSegment}</where>
</select>
<select id="selectListView" resultType="com.entity.view.BijifenxiangView">
SELECT bijifenxiang.* FROM bijifenxiang bijifenxiang
<where> 1=1 ${ew.sqlSegment}</where>
</select>
<select id="selectView" resultType="com.entity.view.BijifenxiangView">
SELECT * FROM bijifenxiang bijifenxiang
<where> 1=1 ${ew.sqlSegment}</where>
</select>
</mapper>
${ew.sqlSegment} 全文 22 处,分布:BijifenxiangDao.xml 4、BijileixingDao.xml 4、DiscussbijifenxiangDao.xml 4、StoreupDao.xml 4、YonghuDao.xml 4、TokenDao.xml 1、UserDao.xml 1,CommonDao.xml 与 ConfigDao.xml 为 0。
3.5 文件上传:配置表 + 时间戳重命名
上传路径不是硬编码,而是读 config 表:
@RestController
@RequestMapping("file")
public class FileController {
@Autowired
private ConfigService configService;
@RequestMapping("/upload")
public R upload(@RequestParam("file") MultipartFile file, String type) throws Exception {
if (file.isEmpty()) {
throw new EIException("上传文件不能为空");
}
// 只取扩展名,不做白名单校验
String fileExt = file.getOriginalFilename().substring(
file.getOriginalFilename().lastIndexOf(".") + 1);
File path = new File(ResourceUtils.getURL("classpath:static").getPath());
if (!path.exists()) {
path = new File("");
}
File upload = new File(path.getAbsolutePath(), "/upload/");
if (!upload.exists()) {
upload.mkdirs();
}
String fileName = new Date().getTime() + "." + fileExt; // 时间戳命名
File dest = new File(upload.getAbsolutePath() + "/" + fileName);
file.transferTo(dest);
if (StringUtils.isNotBlank(type) && type.equals("1")) {
// type=1 时把文件名写回 config 表作为系统头像
ConfigEntity configEntity = configService.selectOne(
new EntityWrapper<ConfigEntity>().eq("name", "faceFile"));
if (configEntity == null) {
configEntity = new ConfigEntity();
configEntity.setName("faceFile");
configEntity.setValue(fileName);
} else {
configEntity.setValue(fileName);
}
configService.insertOrUpdate(configEntity);
}
return R.ok().put("file", fileName);
}
}
配套的下载接口标了 @IgnoreAuth,允许匿名下载:
@IgnoreAuth
@RequestMapping("/download")
public ResponseEntity<byte[]> download(@RequestParam String fileName) {
try {
File path = new File(ResourceUtils.getURL("classpath:static").getPath());
if (!path.exists()) {
path = new File("");
}
File upload = new File(path.getAbsolutePath(), "/upload/");
if (!upload.exists()) {
upload.mkdirs();
}
File file = new File(upload.getAbsolutePath() + "/" + fileName);
if (file.exists()) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDispositionFormData("attachment", fileName);
return new ResponseEntity<byte[]>(
FileUtils.readFileToByteArray(file), headers, HttpStatus.CREATED);
}
} catch (IOException e) {
e.printStackTrace();
}
return new ResponseEntity<byte[]>(HttpStatus.INTERNAL_SERVER_ERROR);
}
这段代码有三个独立问题:没有任何扩展名白名单(.jsp / .html 可直接落盘)、fileName 未做路径穿越校验(../ 可跳出 upload 目录)、用 new Date().getTime() 命名在同毫秒并发下会覆盖同名文件。
四、可复现的统计命令
在工程根目录(01-源码工程/)执行。每条命令我都实跑过,输出即左列。
# Java 文件数:81(含 .mvn/wrapper 下的 1 个 Maven 官方类)
find src -name '*.java' | wc -l
# 业务代码文件数:80(排除 wrapper)
find src/main/java -name '*.java' | wc -l
# 代码行数:6803 / 业务行数 6790
find src -name '*.java' -exec cat {} + | wc -l
find src/main/java -name '*.java' -exec cat {} + | wc -l
# 映射注解总数:100
grep -a -rEo '@(Request|Get|Post|Put|Delete)Mapping' --include='*.java' src/main/java | wc -l
# 其中类级 @RequestMapping:8,方法级 92
grep -a -rEo '^@RequestMapping' --include='*.java' src/main/java | wc -l
# Controller / ServiceImpl / Mapper XML:9 / 9 / 9
find src -name '*Controller.java' | wc -l
find src -path '*service/impl*' -name '*.java' | wc -l
find src -name '*Dao.xml' | wc -l
# entity 包文件:24(9 核心 + 5 VO + 5 View + 5 Model)
find src/main/java/com/entity -name '*.java' | wc -l
# 前台 25 个 HTML、后台 36 个 .vue、dist 12 个文件
find src/main/resources/front -name '*.html' | wc -l
find src/main/resources/admin -name '*.vue' | wc -l
find src/main/resources/admin -path '*dist*' -type f | wc -l
# ${ew.sqlSegment} 出现次数:22
grep -a -ro 'ew.sqlSegment' src/main/resources/mapper | wc -l
# 逐文件分布
grep -a -rc 'ew.sqlSegment' src/main/resources/mapper/*.xml
# @IgnoreAuth:17 处
grep -a -ro '@IgnoreAuth' --include='*.java' src/main/java | wc -l
# 声明了 shiro 依赖但代码零引用:0
grep -a -rl 'org.apache.shiro' --include='*.java' src/main/java | wc -l
# 无任何方法级权限注解:0
grep -a -ro 'RequiresPermissions' --include='*.java' src/main/java | wc -l
数据库侧(02-数据库脚本/db.sql/):
# 8 张表
grep -ci 'CREATE TABLE' db.sql
# 8 条 INSERT 语句,共 32 行演示数据
grep -ci 'insert' db.sql
各表数据行数:bijifenxiang 7、bijileixing 3、config 4、discussbijifenxiang 7、storeup 1、token 2、users 1、yonghu 7,合计 32。
一个必须提醒的坑:不要照抄常见的 grep 统计写法
网上常见的这条命令是错的:
# 错误写法,输出 5(严重偏小)
grep -rE '@(RequestMapping|Get|Post|Put|Delete)Mapping' --include='*.java' src/main/java | wc -l
原因是正则展开后成了 @RequestMappingMapping、@GetMapping,永远匹配不到源码里的 @RequestMapping。它只命中了 5 个 @GetMapping / @PostMapping(UserController 4 个 + ConfigController 1 个),看起来像个合理的小数字,很容易被当成真实结果发出去。
正确写法是把 Mapping 提到公共后缀:
grep -a -rEo '@(Request|Get|Post|Put|Delete)Mapping' --include='*.java' src/main/java | wc -l # 100
另外两点:加 -a 防止源文件含非 UTF-8 字节时被判定为 binary 而整文件跳过;加 -o 让每次匹配单独成行,否则一行里有多个注解只会算一次。
五、${} 和 #{} 的区别:把整条动态查询链路拆开看
5.1 ${} 与 #{} 到底差在哪
MyBatis 处理 #{} 时会把它替换成 JDBC 预编译占位符 ?,值以参数绑定方式送进驱动,结构与数据彻底分离。${} 则是纯文本替换,在 SQL 解析之前就把内容拼进去,拼接进去的东西会被 SQL 引擎当作语句结构解析。
${ew.sqlSegment} 的作用是把 EntityWrapper 内部累积的条件片段(形如 AND (bijimingcheng LIKE ?))整体注入到 <where> 里。因为 Wrapper 生成的是完整 SQL 片段而非单个值,MyBatis 无法用预编译占位符承载,只能用 ${}。
5.2 哪些位置实际是安全的,哪些需要额外校验
“${} 拼接 = 有注入”这个说法过于粗糙。这里要分三种情况看:列名从哪来,决定了安全边界在哪:
情况一:MPUtil.genLike / genEq / genLikeOrEq —— 实际安全。
public static Wrapper genLike(Wrapper wrapper, Map param) {
...
String key = entry.getKey(); // key 来自实体类字段名
String value = (String) entry.getValue();
wrapper.like(key, value); // 列名硬编码,值走 ? 绑定
这里的 param 来自 BeanUtil.beanToMap(bean, true, true),bean 是 Spring 从请求绑定出来的 Entity 对象。Map 的 key 只能是实体类里已声明的字段名 —— 前端无法凭空构造出一个不存在的字段名,Spring 绑定阶段就不接受未知参数。值则由 MyBatis-Plus 内部转成 ? 占位符。列名编译期确定,值参数化,这一段没有问题。
情况二:MPUtil.between —— 列名直接来自请求参数。
for (String key : params.keySet()) { // params 是 @RequestParam Map
if (key.endsWith("_start")) {
columnName = key.substring(0, key.indexOf("_start")); // 列名直接来自请求 key
wrapper.ge(columnName, params.get(key));
}
params 是 @RequestParam Map<String, Object> params,它的 key 完全由请求方决定。请求里带一个形如 xxx_start=1 的参数,columnName 就变成 xxx,被塞进 sqlSegment 后经 ${} 原样拼进 SQL。
列名位置不接受 ? 绑定,所以这一处需要在进入 Wrapper 之前做列名白名单校验——把允许排序/筛选的列名写成一个常量数组,请求里的 key 不在数组里就丢弃。这是 MyBatis-Plus 官方文档里明确提到的用法,答辩时讲清楚「哪些位置天然安全、哪些位置要加白名单」,比笼统说一句「要用预编译」更能体现你读过框架文档。
情况三:MPUtil.sort —— 参数名和过滤器对不上。
public static Wrapper sort(Wrapper wrapper, Map<String, Object> params) {
String order = params.get("sort") == null ? "desc" : params.get("sort"); // 读的是 sort
wrapper.orderBy(convertToCamelCase(params.get("sidx") + " " + order)); // 拼接
}
工程里有个 SQLFilter 拦截器,专门拦 sidx 这个参数防注入:
// 源码原文注释
//防止SQL注入(因为sidx、order是通过拼接SQL实现排序的,会有SQL注入风险)
但 MPUtil.sort 读的是 params.get("sort"),SQLFilter 拦的是 sidx,两个参数名对不上。这一处如果想用,需要把过滤器的拦截名单补上 sort。
三处对比下来:情况一(genLike/genEq)列名来自实体类字段,编译期确定,天然安全;情况二(between)和情况三(sort)列名来自请求,需要白名单。
5.3 更稳妥的写法
方案一(推荐):列名白名单。 这是唯一能从根本上解决 sort / between 的办法 —— 不去检测危险字符,而是只接受事先声明过的列名。
public class ColumnWhitelist {
/** 显式声明该实体允许被前端排序 / 过滤的列 */
private static final Set<String> ALLOWED = new HashSet<>(Arrays.asList(
"id", "bijimingcheng", "bijileixing", "faburiqi",
"yonghuming", "thumbsupnum", "crazilynum", "clicknum"));
public static String require(String column) {
if (column == null) {
throw new EIException("排序字段不能为空");
}
String c = column.trim().toLowerCase();
if (!ALLOWED.contains(c)) {
throw new EIException("非法排序字段:" + column);
}
return c;
}
}
MPUtil.sort 改为:
public static Wrapper sort(Wrapper wrapper, Map<String, Object> params) {
String order = params.get("order") == null ? "" : params.get("order").toString();
Object sortObj = params.get("sort");
if (sortObj != null && StringUtils.isNotBlank(sortObj.toString())) {
// 关键:列名先过白名单,再交给 Wrapper
String column = ColumnWhitelist.require(sortObj.toString());
if ("desc".equalsIgnoreCase(order)) {
wrapper.orderDesc(Arrays.asList(column));
} else {
wrapper.orderAsc(Arrays.asList(column));
}
}
return wrapper;
}
between 同样加一道:columnName = ColumnWhitelist.require(columnName);。白名单也可以进一步收敛为”只允许出现在实体里的列”(用 TableInfoHelper.getTableInfo(XxxEntity.class).getFieldList() 拿到真实列集合),这样新增字段时自动纳入维护。
方案二:消除 ${},改用 <if> 显式列条件。 如果这些 XML 只服务固定几个列表页,最彻底的做法是不要动态片段,手写条件:
<select id="selectListView" resultType="com.entity.view.BijifenxiangView">
SELECT bijifenxiang.* FROM bijifenxiang bijifenxiang
<where>
<if test="bijimingcheng != null and bijimingcheng != ''">
AND bijimingcheng LIKE CONCAT('%', #{bijimingcheng}, '%')
</if>
<if test="bijileixing != null and bijileixing != ''">
AND bijileixing = #{bijileixing}
</if>
<if test="faburiqi_start != null">
AND faburiqi >= #{faburiqi_start}
</if>
<if test="faburiqi_end != null">
AND faburiqi <= #{faburiqi_end}
</if>
</where>
ORDER BY
<choose>
<when test="sort == 'thumbsupnum' and order == 'desc'">thumbsupnum DESC</when>
<when test="sort == 'clicknum' and order == 'desc'">clicknum DESC</when>
<otherwise>id DESC</otherwise>
</choose>
</select>
所有值走 #{} 参数化,排序用 <choose> 硬编码分支。代价是每加一个查询条件要改 XML,MPUtil 那套”前端传什么就查什么”的便利性就没了。这就是安全与灵活性的权衡:动态查询的便利本身就是风险的来源。
方案三(折中):保留 ${ew.sqlSegment} 但锁死来源。 如果不想大改,把 sort / between 的入参在 Controller 入口处就过滤成白名单子集再传下去,改动量最小。注意这条只对列名有效,sort 的值仍需配合 order 的固定枚举。
方案四:删掉 SQLFilter。 它的关键字黑名单给出的是虚假安全感(sort 没被它拦住,反而让人以为已经防护过),同时又是”真的拦住了什么”的错误暗示。改成白名单后,SQLFilter 的调用点(Query 与 JQPageInfo 构造器)应一并移除。
六、application.yml 详解
这份配置在 src/main/resources/application.yml,共 52 行,是整个工程跑起来的开关所在。资料包里也带了它(只有 password 被替换成占位符 你的数据库密码,你改成自己的即可)。
先看完整内容,再逐段讲它在干什么。
# Tomcat
server:
tomcat:
uri-encoding: UTF-8
port: 8080
servlet:
context-path: /springboot1o52x
spring:
datasource:
driverClassName: com.mysql.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/springboot1o52x?useUnicode=true&characterEncoding=utf-8&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8
username: root
password: 你的数据库密码
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
resources:
static-locations: classpath:static/,file:static/
#mybatis
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
typeAliasesPackage: com.entity
global-config:
id-type: 1
field-strategy: 2
db-column-underline: true
refresh-mapper: true
logic-delete-value: -1
logic-not-delete-value: 0
sql-injector: com.baomidou.mybatisplus.mapper.LogicSqlInjector
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
call-setters-on-nulls: true
jdbc-type-for-null: 'null'
6.1 context-path 是这套工程的关键
server:
servlet:
context-path: /springboot1o52x
这一行决定了所有访问地址都带这个前缀:
前台 http://localhost:8080/springboot1o52x/front/index.html
后台 http://localhost:8080/springboot1o52x/admin/dist/index.html
pom.xml 里既没有 server.servlet.context-path,打包插件也没配 finalName——springboot1o52x 这个名字只出现在配置里。漏掉这一行,mvn spring-boot:run 启动后所有前端 ajax 都会 404,而报错信息只是「404」,很难往 context-path 上想。
答辩时这是个能讲的点:SpringBoot 的上下文根和 jar 包名是两件事,工程目录叫什么不影响访问路径,配置说了算。
6.2 MyBatis-Plus 全局配置逐项
| 配置 | 值 | 作用 |
|---|---|---|
mapper-locations |
classpath*:mapper/*.xml |
9 个 Mapper XML 的扫描路径 |
typeAliasesPackage |
com.entity |
实体类包扫描,XML 里可以写短类名 |
id-type: 1 |
用户输入 ID | 主键由客户端生成,不依赖数据库自增 |
field-strategy: 2 |
非空判断 | 更新时忽略 null 字段(MyBatis-Plus 2.x 的写法) |
db-column-underline: true |
驼峰转下划线 | bijibiaoti ↔ bijibiaoti 自动映射 |
refresh-mapper: true |
开发期热加载 | 改 XML 不用重启,生产环境要关掉 |
logic-delete-value: -1 |
逻辑删除标记 | 配合 LogicSqlInjector 做软删除 |
logic-not-delete-value: 0 |
未删除标记 | 同上 |
sql-injector |
LogicSqlInjector |
注入逻辑删除 SQL |
map-underscore-to-camel-case |
true | 下划线字段映射到驼峰属性 |
cache-enabled: false |
关闭二级缓存 | 默认关,避免脏读 |
call-setters-on-nulls: true |
null 也调 setter | 前端能拿到字段但不显示 undefined |
jdbc-type-for-null: 'null' |
null 的 JDBC 类型 | 兼容部分数据库(如 Oracle) |
id-type: 1 值得单独说:这套的主键是用户输入的,不是数据库自增。看 db.sql 里的 bijifenxiang 数据,id 是 1~21 的连续值,作者当年手工维护的。生产项目一般用 id-type: 0(数据库自增)或 ASSIGN_ID(雪花算法),这里是为了演示数据好管理。
logic-delete-value: -1 配 LogicSqlInjector 是逻辑删除:storeup(收藏)表删一条记录时,实际是把某字段置成 -1 而不是 DELETE 行。答辩时问「你怎么做软删除」,直接指这两行。
6.3 数据源的三个 URL 参数
url: jdbc:mysql://127.0.0.1:3306/springboot1o52x?useUnicode=true&characterEncoding=utf-8&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8
| 参数 | 为什么需要 |
|---|---|
useUnicode=true&characterEncoding=utf-8 |
中文不乱码 |
useJDBCCompliantTimezoneShift=true |
JDBC 规范要求校准时区差 |
useLegacyDatetimeCode=false |
兼容 5.7 以下的老行为 |
serverTimezone=GMT%2B8 |
GMT+8 的 URL 编码,时区差 8 小时的解药 |
MySQL 8.0 还要补两个(Connector/J 8.x 默认行为变了):
url: ...&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
不加 allowPublicKeyRetrieval=true 会报 Public Key Retrieval is not allowed,这是 MySQL 8 用 caching_sha2_password 认证导致的。
另外注意 driverClassName 写的是 com.mysql.jdbc.Driver(老驱动类名),MySQL 8 也能用,但新写法是 com.mysql.cj.jdbc.Driver。
6.4 静态资源和上传大小
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
resources:
static-locations: classpath:static/,file:static/
static-locations 的 file:static/ 是给上传文件留的口子——FileController 把上传的文件写到工程的 static/upload/ 目录,靠这条配置才能访问到。
⚠️ 10MB 是上限,答辩演示传大文件会失败。 改这里就行。
七、拿这套源码练手,我能想到的三个切入点
前面把技术骨架拆完了。如果你打算在这套工程上动手改,下面三处是我认为最值得练的——改动量可控,论文里能写成一节。
切入点 1:把 role 字段接到鉴权链路上。 现在 users 表有 role,但所有后台接口只要登录就能访问。工程里已经引入了 Shiro 依赖却一行没用到,你可以在 AuthorizationInterceptor 里加一段角色判断,或者用 Shiro 的注解体系重写。改动集中在拦截器一个类,几十行代码,但论文”系统安全设计”那一章能从空的变成实的。
切入点 2:bijifenxiang 表的作者字段冗余。 表里有 yonghuming 和 xingming 两个字段,是发布时冗余存进去的。用户改了昵称,历史笔记的作者名不会跟着变。你可以加同步逻辑,也可以改成 join yonghu 表动态查。这个取舍本身就是论文”数据库设计”章的好素材。
切入点 3:${ew.sqlSegment} 换成白名单写法。 现在 Wrapper 的条件是字符串拼进 XML 的 ${}。MyBatis-Plus 官方推荐的是在 XML 里用 <if> 显式列条件,配合 @Param 严格校验 key。你把 selectListVO 改成白名单版本,再在论文里对比两种写法的取舍,这是”我读过框架文档并且能正确应用”的最好证明。
这三个切入点的共同点是:改动都不大,但每一处都能在论文里对应一个章节。导师问你”你这个系统有什么可以改进的”,你不是在说套话,而是能指出具体在哪个类、哪一行、为什么这么改。
八、真实运行效果
前台(layui + jQuery,25 个页面)首页:

后台(Vue 2.6 + Element-UI,36 个 .vue)笔记分享管理:

笔记列表与笔记类型(前台用 layui 表格分页,后台用 Element-UI 表格 + 分页):

怎么拿到这套源码
整套工程(源码 + db.sql + 毕业论文 + 说明文档)打包好了。关注公众号「学长毕设笔记」,回复 042 即可领取。
如果你想拿这套代码讲 MyBatis-Plus 动态查询、SpringBoot 拦截器、双表设计,或者想在上面做二次开发当毕设的加分项,都可以直接用。
九、小结
这套工程的技术骨架是清晰的:注解 + 拦截器做鉴权、四参数 Token 服务对齐双用户表、MPUtil 把动态查询收敛到一处、双前端由 WebMvcConfigurationSupport 的资源映射统一挂载。代码组织规整,9 个模块同构,改造任意一个业务模块都能照着其余 8 个的现成模式写。
真正需要动手术的是三处:MPUtil.sort / MPUtil.between 里未经白名单校验的列名拼接(这是可直接利用的注入点,而非理论风险)、免登录的 resetPass 后门、以及全站明文密码。这三处改掉,再把 Shiro 这个无效依赖和 Shiro 式的 SQLFilter 黑名单一起清理,工程质量会有实质提升 —— 而且每一条都恰好是答辩时讲”我审查过系统的安全性”最有料的素材。
下载中心。
这套工程在下载中心,付款后订单页直接给网盘链接和提取码。