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

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);

    }

}

两个工程特征值得注意:

  1. 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,

        ...

    });

}

  1. 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 &gt;= #{faburiqi_start}

        </if>

        <if test="faburiqi_end != null">

            AND faburiqi &lt;= #{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 黑名单一起清理,工程质量会有实质提升 —— 而且每一条都恰好是答辩时讲”我审查过系统的安全性”最有料的素材。


下载中心。


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

拆一套 161 文件的 SpringBoot 毕设源码:双前端单体、三套用户表、token 带表名 »