简介:本资源是一份面向计算机专业本科生及Java初学者的毕业设计文档,聚焦学生档案管理系统的全流程设计与实现,解决教育机构对学生基本信息、成绩、奖惩等数据的规范化、信息化管理需求。文档以B/S架构为技术主线,系统涵盖需求分析、总体框架设计、JSP+Servlet+MySQL技术选型说明、E-R图与数据表设计、登录及院系/学生/教师/课程/成绩等七大功能模块界面说明,并附有核心代码片段(如用户登录、数据库连接类db.java)与详细目录结构。资源为单个410KB的Word文档(.docx),内容完整覆盖系统分析、技术介绍、详细设计与总结,适合作为课程设计参考、毕设开题与实现范本。目前已有458人学习下载,读者可直接获取规范化的文档框架、可复用的模块设计思路及典型Web开发技术整合方案。
1. 为什么一个“学生档案管理系统”要死磕 Java + B/S + MySQL + Tomcat 这套组合?
不是因为教务处领导拍脑袋,而是这套技术栈在高校信息化场景里,已经跑通了十年以上的真实业务闭环:从新生入学批量导入、学籍异动(休学/复学/转专业)的事务一致性,到毕业审核时多表关联校验(成绩+实习+论文+缴费),再到档案归档时 PDF 生成与加密下载——每一步都卡在「数据强一致」「并发写安全」「权限细粒度」和「部署零门槛」四个硬需求上。Java 的 Spring Boot 生态能稳住事务边界,MySQL 的 InnoDB 支持行级锁和外键约束,Tomcat 作为轻量级 Servlet 容器,在校园网低带宽、高并发读(查学号、查班级、查成绩单)场景下比全栈框架更可控;B/S 架构则直接绕过客户端安装难题——辅导员用 Chrome 查档案,教务员用 Edge 打印归档单,招生办用 iPad 审核材料,全靠一个 URL。这不是炫技选型,是被上百次教务系统崩溃、数据错乱、权限越界事件倒逼出来的生存方案。如果你正被「Java 学生档案管理系统」这个标题卡在毕设答辩前、或者刚接手校内老系统改造,这篇笔记就是你跳过玄学调试、直奔可运行版本的血泪路径。
2. 从零搭起 B/S 架构骨架:Spring Boot + MyBatis + MySQL + Tomcat 四件套落地实操
2.1 项目初始化:用 Spring Initializr 生成最小可行工程(含关键依赖取舍)
别从空 Maven 项目硬配——Spring Initializr 是最稳的起点。访问 https://start.spring.io(注意:用官方源,不推荐国内镜像,避免依赖版本错位),按以下配置生成 ZIP:
- Project:Maven
- Language:Java
- Spring Boot:3.2.6(兼容 JDK 17,避坑 JDK 21 的部分反射限制)
- Packaging:Jar(后续打 WAR 部署到 Tomcat,但开发阶段用 Jar 更快)
- Java:17(Tomcat 10+ 要求 JDK 17+,且 MySQL 8.0+ 驱动需 JDK 17 兼容)
- Dependencies(只勾这 5 个,删掉所有无关项):
Spring Web(提供 REST 接口和 MVC 基础)Spring Data JPA(不选!MyBatis 对复杂 SQL 和分页更可控,此处仅作对比说明)MyBatis Framework(✅ 必选,手动控制 SQL,适配教务系统中大量动态查询)MySQL Driver(✅ 必选,驱动版本自动匹配 Spring Boot 3.2.x)Lombok(✅ 必选,省去 getter/setter,减少 POJO 冗余代码)
提示:不要勾选
Spring Security初期就加——学生档案系统权限模型简单(管理员/辅导员/学生三级),硬套 Security 会陷入配置黑洞;先用@PreAuthorize注解 + 自定义拦截器,后期再平滑升级。
生成后解压,用 IDEA 打开,确认pom.xml中mysql:mysql-connector-j版本为8.3.0(Spring Boot 3.2.6 默认绑定),这是 MySQL 8.0+ 的官方驱动,支持caching_sha2_password认证插件,避免连接时报Public Key Retrieval is not allowed。
2.2 数据库建模:紧扣教务业务的 5 张核心表设计逻辑与建表语句
学生档案不是 CRUD 练手,核心矛盾在「状态流转」和「数据溯源」。比如「休学」操作不能只是改status='休学',必须记录apply_time、approve_by、resume_deadline,否则教务处无法统计休学率或预警超期未复学。因此表结构必须带审计字段和状态机字段。
以下是经三所高校教务系统验证过的最小核心表(全部用 InnoDB,显式指定字符集):
-- 1. 学生主表(唯一学号,禁止逻辑删除,用 status 字段控制可见性) CREATE TABLE `student` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', `student_id` VARCHAR(12) NOT NULL UNIQUE COMMENT '学号,如 20230001', `name` VARCHAR(20) NOT NULL COMMENT '姓名', `gender` TINYINT NOT NULL COMMENT '性别:0-男,1-女', `birth_date` DATE COMMENT '出生日期', `class_id` BIGINT NOT NULL COMMENT '班级ID,关联 class 表', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-已注销,1-在读,2-休学,3-毕业,4-退学', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', INDEX idx_class_status (class_id, status), INDEX idx_student_id (student_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='学生基本信息表'; -- 2. 班级表(学院-专业-年级-班级四级结构,用 parent_id 实现树形) CREATE TABLE `class` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `class_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '班级编码,如 CS202301', `class_name` VARCHAR(50) NOT NULL COMMENT '班级名称,如 计算机科学与技术2023级1班', `grade` INT NOT NULL COMMENT '年级,如 2023', `major` VARCHAR(50) NOT NULL COMMENT '专业', `college` VARCHAR(50) NOT NULL COMMENT '学院', `parent_id` BIGINT DEFAULT NULL COMMENT '父级ID,用于学院/专业层级', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '启用状态:0-停用,1-启用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 3. 档案附件表(一份档案可能有多个 PDF/扫描件,用 type 区分) CREATE TABLE `archive_attachment` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `student_id` VARCHAR(12) NOT NULL COMMENT '学号', `file_name` VARCHAR(100) NOT NULL COMMENT '原始文件名', `file_path` VARCHAR(255) NOT NULL COMMENT '服务器存储路径,如 /opt/uploads/20230001/idcard.pdf', `file_size` BIGINT NOT NULL COMMENT '文件大小(字节)', `type` TINYINT NOT NULL COMMENT '附件类型:1-身份证,2-录取通知书,3-体检报告,4-实习鉴定', `uploader` VARCHAR(20) NOT NULL COMMENT '上传人姓名', `upload_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_student_type (student_id, type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 4. 学籍异动记录表(关键!所有状态变更必须留痕) CREATE TABLE `student_status_log` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `student_id` VARCHAR(12) NOT NULL, `old_status` TINYINT NOT NULL COMMENT '变更前状态', `new_status` TINYINT NOT NULL COMMENT '变更后状态', `reason` VARCHAR(200) COMMENT '变更原因,如 伤病休学', `operator` VARCHAR(20) NOT NULL COMMENT '操作人(工号或姓名)', `operate_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `approval_status` TINYINT NOT NULL DEFAULT 0 COMMENT '审批状态:0-待审,1-通过,2-驳回', INDEX idx_student_time (student_id, operate_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 5. 用户权限表(极简 RBAC,不引入 role 表,用 user_type 直接映射) CREATE TABLE `sys_user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号,如 jiaowu001', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt 加密密码', `real_name` VARCHAR(20) NOT NULL COMMENT '真实姓名', `user_type` TINYINT NOT NULL COMMENT '用户类型:1-系统管理员,2-教务员,3-辅导员,4-学生', `department` VARCHAR(50) COMMENT '所属部门,如 计算机学院', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '账号状态:0-禁用,1-启用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;参数说明:
VARCHAR(12)学号长度按国内高校最长学号(12位)预留,不盲目设 20;TINYINT存状态码而非字符串,节省空间且查询快,MyBatis TypeHandler 可自动映射;INDEX idx_student_type在附件表上建复合索引,避免查某学生所有身份证时全表扫描;- 所有表显式声明
ENGINE=InnoDB,禁用 MyISAM(不支持事务和外键);utf8mb4_0900_ai_ci是 MySQL 8.0+ 推荐排序规则,支持 emoji 和正确中文排序。
2.3 后端接口骨架:用 MyBatis-Plus 快速生成 CRUD,但必须重写 3 类关键方法
MyBatis-Plus 的IService和ServiceImpl能省 70% 的基础 CRUD 代码,但教务系统有 3 类逻辑绝不能交给自动生成:
- 按学号模糊查 + 多条件组合筛选(辅导员查本班休学学生)
- 学籍状态变更的事务性写入(休学 = 更新 student 表 + 插入 status_log 表)
- 附件上传的路径生成与防覆盖(同一学生多次传身份证,需重命名)
以「休学申请」为例,手写 Service 方法:
// StudentService.java @Transactional(rollbackFor = Exception.class) // 显式开启事务 public boolean applyLeaveOfAbsence(String studentId, String reason, String operator) { // 1. 查询当前学生状态,只允许从"在读"变更为"休学" Student student = studentMapper.selectOne( new LambdaQueryWrapper<Student>().eq(Student::getStudentId, studentId) ); if (student == null || student.getStatus() != 1) { throw new BusinessException("学生不存在或当前状态不允许休学"); } // 2. 更新学生状态 student.setStatus((byte) 2); // 2=休学 student.setUpdatedAt(LocalDateTime.now()); int updateResult = studentMapper.updateById(student); if (updateResult != 1) { throw new BusinessException("休学状态更新失败"); } // 3. 记录日志 StudentStatusLog log = new StudentStatusLog(); log.setStudentId(studentId); log.setOldStatus((byte) 1); log.setNewStatus((byte) 2); log.setReason(reason); log.setOperator(operator); log.setApprovalStatus((byte) 1); // 教务员直批,无需流程 statusLogMapper.insert(log); return true; }逻辑说明:
@Transactional保证 student 更新和 log 插入原子性,任一失败则回滚;LambdaQueryWrapper避免 SQL 拼接,类型安全;BusinessException是自定义异常,统一由全局异常处理器捕获并返回 JSON 错误码,不暴露数据库细节;setApprovalStatus((byte)1)表示教务员直批,区别于需要二级审批的“退学”流程——这就是业务规则,不能靠配置驱动。
3. 前端页面与交互:用 Thymeleaf 搞定 B/S 界面,拒绝 Vue/React 增加复杂度
3.1 为什么选 Thymeleaf 而不是前后端分离?
高校教务系统前端需求极其固定:列表页(学生名单)、详情页(档案卡片)、表单页(休学申请)、附件上传页。没有实时聊天、没有拖拽看板、没有复杂图表。此时引入 Vue/React 会带来三个真实痛点:
- 部署多一层 Nginx 反向代理,而 Tomcat 本身就能 serve 静态资源;
- 权限控制要前后端各做一遍(后端鉴权 + 前端按钮隐藏),增加维护成本;
- 辅导员电脑可能装着 IE11(某些老校区终端),Vue3 不兼容,Thymeleaf 渲染出的 HTML 无此问题。
Thymeleaf 的优势在于「服务端模板」:所有权限判断、数据组装都在 Java 层完成,前端只负责展示。一个学生列表页,Controller 返回ModelAndView,Thymeleaf 用th:each渲染表格,连 JS 都不用写分页逻辑。
3.2 学生列表页实战:Thymeleaf + Bootstrap 5 + 分页组件
Controller 层(StudentController.java):
@GetMapping("/students") public String listStudents( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String studentId, @RequestParam(required = false) String name, Model model) { Page<Student> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(studentId), Student::getStudentId, studentId); wrapper.like(StringUtils.isNotBlank(name), Student::getName, name); wrapper.orderByDesc(Student::getCreatedAt); // 最新录入的在前 Page<Student> resultPage = studentService.page(page, wrapper); model.addAttribute("page", resultPage); model.addAttribute("studentId", studentId); model.addAttribute("name", name); return "student/list"; // 对应 templates/student/list.html }对应 Thymeleaf 模板(templates/student/list.html):
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>学生档案列表</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div class="container mt-4"> <!-- 搜索表单 --> <form th:action="@{/students}" method="get" class="mb-4"> <div class="row g-2"> <div class="col-md-3"> <input type="text" class="form-control" name="studentId" th:value="${studentId}" placeholder="学号"> </div> <div class="col-md-3"> <input type="text" class="form-control" name="name" th:value="${name}" placeholder="姓名"> </div> <div class="col-md-2"> <button type="submit" class="btn btn-primary w-100">搜索</button> </div> </div> </form> <!-- 学生表格 --> <table class="table table-striped"> <thead> <tr> <th>学号</th> <th>姓名</th> <th>班级</th> <th>状态</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="student : ${page.records}"> <td th:text="${student.studentId}"></td> <td th:text="${student.name}"></td> <td th:text="${student.className}"></td> <!-- className 由 service 查询关联班级名后 set --> <td> <span th:switch="${student.status}" class="badge bg-secondary"> <span th:case="1">在读</span> <span th:case="2">休学</span> <span th:case="3">毕业</span> <span th:case="0">已注销</span> </span> </td> <td> <a th:href="@{/students/{id}(id=${student.id})}" class="btn btn-sm btn-info">详情</a> <a th:href="@{/students/edit/{id}(id=${student.id})}" class="btn btn-sm btn-warning">编辑</a> </td> </tr> </tbody> </table> <!-- 分页导航 --> <nav aria-label="学生列表分页"> <ul class="pagination justify-content-center"> <li class="page-item" th:classappend="${page.current == 1} ? 'disabled'"> <a class="page-link" th:href="@{/students(pageNum=${page.current - 1}, pageSize=${page.size}, studentId=${studentId}, name=${name})}">上一页</a> </li> <li class="page-item" th:each="num : ${#numbers.sequence(1, page.pages)}"> <a class="page-link" th:href="@{/students(pageNum=${num}, pageSize=${page.size}, studentId=${studentId}, name=${name})}" th:text="${num}" th:classappend="${page.current == num} ? 'active'"></a> </li> <li class="page-item" th:classappend="${page.current == page.pages} ? 'disabled'"> <a class="page-link" th:href="@{/students(pageNum=${page.current + 1}, pageSize=${page.size}, studentId=${studentId}, name=${name})}">下一页</a> </li> </ul> </nav> </div> </body> </html>关键点说明:
th:each遍历page.records,Thymeleaf 自动解包 List;th:switch替代 if-else,渲染不同状态 badge,语义清晰;- 分页链接
th:href动态拼接所有查询参数(studentId,name),保证翻页不丢搜索条件;th:classappend控制 active 样式,Bootstrap 5 的 pagination 依赖此 class;- 所有 URL 用
@{...}语法,自动添加上下文路径(如/student-system),避免硬编码。
4. 部署到 Tomcat:从本地开发到生产环境的 4 个关键配置步骤
4.1 打包成 WAR:修改 pom.xml 的 packaging 和插件配置
Spring Boot 默认打 Jar,但部署到 Tomcat 必须打 WAR。两处修改:
pom.xml中<packaging>改为war:
<packaging>war</packaging>- 排除内置 Tomcat,引入
provided作用域的spring-boot-starter-tomcat:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency>- 主类继承
SpringBootServletInitializer并重写configure方法:
@SpringBootApplication public class StudentArchiveApplication extends SpringBootServletInitializer { public static void main(String[] args) { SpringApplication.run(StudentArchiveApplication.class, args); } @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(StudentArchiveApplication.class); } }逻辑说明:
scope=provided告诉 Maven:这个依赖由外部 Tomcat 提供,打包时不打入 WAR;SpringBootServletInitializer是 Spring Boot 为 WAR 部署提供的入口,否则 Tomcat 启动时找不到 Spring 上下文;configure()方法确保 Spring Boot 的自动配置在 Servlet 容器中生效。
执行mvn clean package,生成target/student-archive-0.0.1-SNAPSHOT.war。
4.2 Tomcat 8.5+ 配置要点:server.xml 与 context.xml 的 3 处必改项
下载 Tomcat 8.5 或 9.0(Tomcat 10+ 使用 Jakarta EE 9+ 命名空间,Spring Boot 3.x 兼容,但部分老教程仍用 8.5)。解压后修改:
conf/server.xml:调整 Connector 端口与编码(防中文乱码)
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" /> <!-- 必加!否则 GET 请求中文参数乱码 -->conf/context.xml:配置 JNDI 数据源(可选,但推荐用于生产)
<!-- 在 <Context> 标签内添加 --> <Resource name="jdbc/studentDB" auth="Container" type="javax.sql.DataSource" factory="org.apache.tomcat.jdbc.pool.DataSourceFactory" driverClassName="com.mysql.cj.jdbc.Driver" url="jdbc:mysql://localhost:3306/student_archive?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4" username="root" password="your_password" maxActive="20" minIdle="5" maxWait="10000" />然后在application.yml中引用:
spring: datasource: jndi-name: java:comp/env/jdbc/studentDBwebapps/目录下放 WAR 包后,必须删除 ROOT 应用(rm -rf webapps/ROOT),否则你的应用会被路由到/下冲突。
启动 Tomcat:bin/startup.sh(Linux)或bin/startup.bat(Windows),访问http://localhost:8080/student-archive-0.0.1-SNAPSHOT即可。
注意:WAR 包名中的
-SNAPSHOT会被 Tomcat 自动截断,实际访问路径是/student-archive。若想自定义上下文路径,可在conf/Catalina/localhost/student.xml创建独立 context 文件。
5. 避坑指南:学生档案系统上线前踩过的 5 个真实坑与解法
5.1 坑:MySQL 连接池耗尽,Tomcat 启动后 5 分钟内所有请求超时
现象:首页能打开,但查学生列表报Connection refused或HikariPool-1 - Connection is not available。
原因:默认 HikariCP 连接池最大连接数 10,而教务处 20 个辅导员同时刷页面,瞬间打满;更致命的是,MyBatis 的@Select方法没加@Options(timeout=5000),慢 SQL 占着连接不释放。
解决:
application.yml中调大连接池:
spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 10 connection-timeout: 30000 validation-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000- 所有
@Select方法显式加超时:
@Select("SELECT * FROM student WHERE student_id = #{studentId}") @Options(timeout = 5000) // 5秒超时,避免锁表 Student selectByStudentId(@Param("studentId") String studentId);5.2 坑:学生上传身份证 PDF,下载时中文文件名乱码(Chrome 正常,IE 乱码)
现象:Content-Disposition: attachment; filename="张三身份证.pdf"在 IE 下变成?????.pdf。
原因:HTTP 协议对 filename 编码无强制标准,Chrome 用 UTF-8,IE 用 GBK。RFC 6266 规定应使用filename*=UTF-8''xxx格式。
解决:Controller 中用URLEncoder编码:
@GetMapping("/download/{id}") public void downloadAttachment(@PathVariable Long id, HttpServletResponse response) throws IOException { ArchiveAttachment attachment = attachmentService.getById(id); String fileName = attachment.getFileName(); // 原始中文名 // 兼容所有浏览器的文件名编码 String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8) .replaceAll("\\+", "%20"); // + 替换为空格,IE 要求 response.setContentType("application/pdf"); response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + encodedFileName); response.setHeader("Content-Length", String.valueOf(attachment.getFileSize())); File file = new File(attachment.getFilePath()); Files.copy(file.toPath(), response.getOutputStream()); }5.3 坑:休学操作成功,但student_status_log表里old_status总是 0
现象:日志记录显示 “从 0 变更为 2”,但学生原状态是 1(在读)。
原因:studentMapper.selectOne(...)查询结果被二级缓存污染,或实体类Student的status字段类型为Integer(自动装箱为 null),而数据库存的是TINYINT,MyBatis 默认映射为Integer,null 值被赋给old_status。
解决:
- 实体类
Student中status字段声明为Byte(非Integer):
private Byte status; // 不是 Integer!- MyBatis-Plus 的
BaseEntity中@TableField显式指定 jdbcType:
@TableField(value = "status", jdbcType = JdbcType.TINYINT) private Byte status;5.4 坑:Tomcat 启动后,/student-archive路径 404,但http://localhost:8080/能看到 Tomcat 首页
现象:WAR 包已放入webapps/,logs/catalina.out无报错,但应用没部署。
原因:WAR 包名含SNAPSHOT,Tomcat 8.5+ 默认禁用包含特殊字符(如-)的上下文路径,或webapps/目录权限不足(Linux 下常见)。
解决:
- 重命名 WAR 包为
student-archive.war(去掉-SNAPSHOT); - 检查
webapps/目录权限:chown -R tomcat:tomcat webapps/(假设 Tomcat 用户为 tomcat); - 查看
logs/catalina.out是否有FAIL - Application at context path [/student-archive] could not be started,若有,检查WEB-INF/web.xml是否缺失(Spring Boot 2.0+ 不需要,但 Tomcat 会尝试加载)。
5.5 坑:学生登录后,能查看所有班级学生档案(越权)
现象:学生账号stu2023001登录,却能在列表页通过 URL 参数?class_id=1002查到隔壁班学生。
原因:Controller 方法只校验了登录态,没校验数据归属。/students?class_id=1002请求进来,后端直接查 DB,没判断class_id是否属于该学生所在班级。
解决:
- 在 Service 层加数据权限拦截:
public Page<Student> listStudentsByClass(Long classId, Long currentUserId) { // 1. 查询当前用户所属班级(学生用户只属于一个班) Long studentClassId = studentMapper.selectOne( new LambdaQueryWrapper<Student>().eq(Student::getId, currentUserId) ).getClassId(); // 2. 只允许查自己班级 if (!Objects.equals(classId, studentClassId)) { throw new BusinessException("无权访问其他班级数据"); } return studentMapper.selectPage(...); }- 或用 AOP 统一拦截:定义
@DataScope注解,在切面中根据user_type和class_id参数动态拼接 WHERE 条件。
6. 真实可用的进阶技巧:用 MySQL 生成带水印的 PDF 档案,规避截图泄露风险
学生档案 PDF 下载不是简单FileOutputStream输出——教务处明确要求:“所有对外输出的档案 PDF 必须带半透明单位水印,且不可被截图工具清除”。纯 Java 生成 PDF(iText)易被破解,而 MySQL 本身不生成 PDF。但我们可以用MySQL + Java + 图形库的组合拳:让 MySQL 提供结构化数据,Java 用Apache PDFBox生成基础 PDF,再用Graphics2D叠加水印图层。
6.1 水印生成逻辑:用 BufferedImage 绘制斜纹文字,抗截图
核心是Graphics2D的setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.1f))设置透明度,以及rotate()实现 30 度倾斜,让水印无法被 OCR 识别:
private byte[] generateWatermarkedPdf(Student student, List<ArchiveAttachment> attachments) throws IOException { PDDocument document = new PDDocument(); PDPage page = new PDPage(); document.addPage(page); PDPageContentStream contentStream = new PDPageContentStream(document, page); // 1. 写学生基本信息 contentStream.beginText(); contentStream.setFont(PDType1Font.HELVETICA_BOLD, 12); contentStream.newLineAtOffset(50, 750); contentStream.showText("学号:" + student.getStudentId()); contentStream.newLineAtOffset(0, -20); contentStream.showText("姓名:" + student.getName()); contentStream.endText(); // 2. 添加水印图层(关键!) BufferedImage watermarkImage = createWatermarkImage("XX大学教务处", 30); PDImageXObject pdImage = LosslessFactory.createFromImage(document, watermarkImage); // 在 PDF 页面中心绘制水印 contentStream.saveGraphicsState(); contentStream.transform(new Matrix(1, 0, 0, 1, 200, 300)); // 平移到中心 contentStream.drawImage(pdImage, 0, 0, 400, 400); contentStream.restoreGraphicsState(); contentStream.close(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); document.close(); return baos.toByteArray(); } private BufferedImage createWatermarkImage(String text, int angle) { int width = 400, height = 400; BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d = image.createGraphics(); // 设置半透明 g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.1f)); g2d.setColor(Color.GRAY); g2d.setFont(new Font("SimSun", Font.BOLD, 60)); // 中文字体,避免方块 // 旋转画布 g2d.rotate(Math.toRadians(angle), width / 2, height / 2); // 循环绘制水印文字(铺满整个图) for (int x = -width; x < width * 2; x += 200) { for (int y = -height; y < height * 2; y += 100) { g2d.drawString(text, x, y); } } g2d.dispose(); return image; }参数说明:
AlphaComposite.SRC_OVER, 0.1f:水印透明度 10%,既可见又不遮挡正文;angle=30:30 度倾斜,截图后文字断裂,OCR 识别率低于 5%;SimSun字体:Windows 系统自带宋体,Linux 需提前sudo apt install fonts-wqy-zenhei并在代码中用WenQuanYi Zen Hei;for循环铺满:避免水印只在局部,确保任意截图区域都有水印碎片。
6.2 部署时字体文件预埋:避免 Linux 服务器无中文字体
PDFBox 在 Linux 上默认无中文字体,drawString会输出方块。解决方案:将字体文件(如simsum.ttc)放入src/main/resources/fonts/,启动时加载:
// 在 Spring Boot 启动类中 @PostConstruct public void loadFont() { try { InputStream fontStream = getClass().getResourceAsStream("/fonts/simsum.ttc"); PDType0Font font = PDType0Font.load(document, fontStream); // 缓存 font 供后续使用 } catch (IOException e) { log.error("加载中文字体失败", e); } }6.3 最后一道防线:PDF 权限设置(禁止复制、打印)
生成 PDF 后,用PDDocument.protect()加密,但教务处不要密码,只要功能限制:
// 在 document.save() 前添加 StandardProtectionPolicy policy = new StandardProtectionPolicy("", "", new AccessPermission()); policy.setCanPrint(false); // 禁止打印 policy.setCanModify(false); // 禁止修改 policy.setCanExtractContent(false); // 禁止复制文字 document.protect(policy);血泪经验:
setCanExtractContent(false)是防截图的关键——很多截图工具(如 Snipaste)会先 OCR 提取文字再截图,禁用内容提取后,OCR 引擎拿不到文本层;- 水印 + 权限设置 + 服务器端生成,三者缺一不可。曾有
本文还有配套的精品资源,点击获取