简介:这是一套基于Spring Boot和MyBatis Plus的代码作业查重系统完整源码,面向需要实现作业提交、自动查重与评分管理场景的各类开发者,适合作为毕业设计或企业级教学平台的基础。系统覆盖学生、教师、管理员三类角色,支持作业发布、代码/PDF提交、JPlag查重、评分反馈及小组分组管理,后端集成JWT认证、Redis缓存与Swagger接口文档。压缩包内共168个文件,包含95个Java后端源码、21个Vue前端页面、16个XML配置、11个SQL脚本及1个JPlag查重工具JAR包,整体大小仅2.36MB,目录结构清晰,便于二次开发。通过该资源可完整了解Spring Boot与MyBatis Plus的整合实践、JWT无状态认证的实现方式,以及Redis在用户会话与缓存场景下的运用,并可直接复用代码查重、分组管理等核心模块。目前已有127人浏览学习,适合计算机专业学生、初中级Java开发者在实战中快速上手。
1. 代码作业查重系统:Spring Boot 与 MyBatis Plus 组合的真实落地项目
期末收了三百份 Java 课程作业,靠肉眼比对找出互相抄的代码,这种事干过一次就不想干第二次。这份基于 Spring Boot + MyBatis Plus 的代码作业查重系统,把「提交 → 查重 → 出报告」整条链路做成了可运行的服务,适合做 Java 课程设计、或者想把这套能力接到内部评审流程的开发者直接复现。系统本身不花哨,但该有的都有:文件上传、数据库落库、查重算法、相似度报告。它把最烦的两两比对交给了程序,而不是交给眼睛——这也是我做课程设计时最看重的点。
2. 架构与数据模型:为什么选 Spring Boot + MyBatis Plus 做查重服务
2.1 模块划分与工程目录
拿到源码先别急着跑。先看工程结构,你才知道后面配置改哪里。这个系统按标准 Controller-Service-Mapper 三层拆开,包名是com.example.plagiarism,我拆过的查重系统里,九成都是这个骨架:
src/main/java/com/example/plagiarism ├── common # 统一返回结果、异常处理 ├── controller # 作业接口、提交接口、报告接口 ├── service # 业务逻辑与查重调度 │ ├── SubmissionService.java │ └── PlagiarismService.java ├── mapper # MyBatis Plus 的 Mapper 接口 ├── entity # 与表结构对应的实体类 ├── dto # 接口入参与出参模型 └── PlagiarismApplication.java # 启动类这个分层很直白,接口层只做参数校验和结果封装,查重的核心逻辑全在PlagiarismService里。你接手之后如果需要加功能,比如新增「按小组查重」或「导出 PDF 报告」,不改动 Controller 也能往里塞,因为服务层和接口层是解耦的。
2.2 四张核心业务表的设计
查重系统绕不开四张表:学生表、作业表、提交记录表、查重结果表。这个设计几乎是固定范式,因为查重的最小单位是「同一份作业下两个学生的提交记录」:
CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(32) NOT NULL UNIQUE COMMENT '学号', student_name VARCHAR(64) NOT NULL COMMENT '姓名', class_name VARCHAR(128) DEFAULT '' COMMENT '班级', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE homework ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL COMMENT '作业标题', teacher_id BIGINT NOT NULL COMMENT '布置老师ID', check_threshold DECIMAL(5,2) DEFAULT 80.00 COMMENT '相似度阈值, 超过则告警', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE submission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, homework_id BIGINT NOT NULL COMMENT '所属作业ID', student_id BIGINT NOT NULL COMMENT '提交学生ID', file_path VARCHAR(255) NOT NULL COMMENT '源码文件存储路径', content_hash VARCHAR(64) NOT NULL COMMENT '源码SHA-256哈希', submit_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_homework_student (homework_id, student_id) ); CREATE TABLE plagiarism_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, homework_id BIGINT NOT NULL, submission_a_id BIGINT NOT NULL, submission_b_id BIGINT NOT NULL, similarity_score DECIMAL(5,2) NOT NULL COMMENT '相似度百分比', status TINYINT DEFAULT 0 COMMENT '0=待老师复核 1=已确认 2=误判', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_homework (homework_id) );注意submission表里那个唯一键uk_homework_student,它保证同一个学生只能提交一次,防止重复提交刷数据。查重结果表里存的是「对称的两条记录」——我不建议为了避免重复只存一份,因为后续要按维度聚合,对称存反而好写 SQL。另外homework表里的check_threshold很关键,它把「多少算抄袭」的判定权交给老师,而不是写死在代码里。
2.3 MyBatis Plus 的选型理由与使用边界
数据层用 MyBatis Plus 而不是原生 MyBatis,核心诉求就一条:单表 CRUD 不用写 SQL。比如新增一条查重结果记录,直接用BaseMapper接口就完了:
@Mapper public interface PlagiarismResultMapper extends BaseMapper<PlagiarismResult> { }查重结果入库时,不需要手写INSERT INTO语句:
PlagiarismResult result = new PlagiarismResult(); result.setHomeworkId(homeworkId); result.setSubmissionAId(submissionA.getId()); result.setSubmissionBId(submissionB.getId()); result.setSimilarityScore(score); result.setStatus(0); plagiarismResultMapper.insert(result);insert方法是 BaseMapper 自带的,字段自动映射。MyBatis Plus 在这里的边界是「单表操作用自带的,多表关联再写自定义 SQL」。比如列出某次作业的查重报告,要关联学生姓名和作业标题,这时候就别硬用 Wrapper 强行拼,直接在 Mapper 里写@Select注解还更直观:
@Select("SELECT r.id, s1.student_name AS student_a_name, s2.student_name AS student_b_name, " + "r.similarity_score, r.status " + "FROM plagiarism_result r " + "LEFT JOIN submission a ON r.submission_a_id = a.id " + "LEFT JOIN student s1 ON a.student_id = s1.id " + "LEFT JOIN submission b ON r.submission_b_id = b.id " + "LEFT JOIN student s2 ON b.student_id = s2.id " + "WHERE r.homework_id = #{homeworkId} " + "ORDER BY r.similarity_score DESC") List<ReportVO> selectReportByHomeworkId(@Param("homeworkId") Long homeworkId);这是我实际用下来最顺手的分工。凡是单表操作,依赖 MyBatis Plus 的LambdaQueryWrapper,比如查某门作业下所有提交记录:
List<Submission> submissions = submissionMapper.selectList( new LambdaQueryWrapper<Submission>() .eq(Submission::getHomeworkId, homeworkId) .orderByAsc(Submission::getStudentId) );LambdaQueryWrapper的好处是类型安全,字段名写错了编译期就报错,不像拼字符串那样,运行时才发现列名不存在。注意orderByAsc一定要在eq之后链式调用,顺序反了会有告警,不影响结果但日志看着难受。
3. 查重算法实现:从代码归一化到相似度计算
3.1 预处理:去注释去空白,别让格式干扰判断
查重最忌讳的是直接拿原文件做字符串比对。同一个逻辑,一个人用 Tab 缩进、另一个人用空格,或者一个注释写满了、另一个删干净了,就直接比对会得到截然不同的结果。所以第一步永远是代码归一化。
我一般按三条规则做预处理——去注释、去空白、去字符串字面量:
public class CodeNormalizer { public static String normalize(String source) { if (source == null || source.isEmpty()) { return ""; } // 1. 去掉单行注释 // 开头到行尾 source = source.replaceAll("//[^\\n]*", ""); // 2. 去掉多行注释 /* ... */ source = source.replaceAll("/\\*.*?\\*/", "", Pattern.DOTALL); // 3. 去掉字符串字面量, 统一替换成空字符串 source = source.replaceAll("\"[^\"]*\"", "\"\"", Pattern.DOTALL); // 4. 统一空白: 连续空白变成单个空格 source = source.replaceAll("\\s+", " "); return source.trim(); } }逻辑说明:第一步正则//[^\n]*匹配从双斜杠到行尾的内容,Java 代码里最常见的注释格式。第二步/*.*?*/配Pattern.DOTALL是因为多行注释可能跨行,.*?非贪婪匹配,避免把两个注释之间的代码也吞掉。第三步字符串字面量替换成空字符串,是因为"登录成功"这种提示语,学生抄的时候往往只改字面量、不改逻辑,留着反而干扰相似度判断。
参数说明:Pattern.DOTALL是这里最容易漏的,不加这个,.*?默认匹配不到换行符,跨行注释就过滤不干净。我遇到过预处理完还有残留注释的翻车现场,排查半天发现是这里写成了/\\*.*\\*/,不是非贪婪匹配,导致两个注释块之间的代码全被吞了,相似度虚高。
3.2 相似度算法选型:余弦、SimHash、编辑距离怎么选
预处理做完,才轮到计算相似度。这块有几种常用方案,各有各的适用场景:
| 算法 | 思路 | 适合场景 | 缺点 |
|---|---|---|---|
| 余弦相似度 | 把代码分词后映射成词频向量,计算夹角 | 文本级别对比,效果稳定 | 分词质量影响大,需要做词频统计 |
| SimHash | 用哈希把文本降维成 64 位指纹,对比汉明距离 | 海量文本去重,速度快 | 短代码效果差,阈值难调 |
| 编辑距离 | 算两个字符串最少编辑次数 | 代码量小、逻辑相似度极高 | 大文件两两比对 O(n²),慢到怀疑人生 |
做代码作业查重这个场景,我最终采用的是「余弦相似度为主、SimHash 做粗筛」的组合方案。
为什么不是直接用 SimHash 一把梭?这是血泪经验。SimHash 对 300 字以上的长文本效果好,但课程作业的代码文件通常在几十行到几百行,经常只有五六个for循环加一堆if,文本长度不足以让 SimHash 的特征向量稳定收敛。结果就是两个完全不同的作业也可能算出 60% 相似,产生大量误报。所以我把 SimHash 放在第一层,先把相似度明显低于 50% 的直接排除,剩下的再用余弦相似度精细计算,兼顾速度与准确率。
3.3 核心实现代码与参数调优
先说 SimHash 粗筛的实现。拿到归一化后的代码,先按空格切分成 token,再对每个 token 做 MD5 哈希,结合每个 token 的词频做加权:
import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.List; import java.util.Map; public class SimHash { private static final int FINGERPRINT_BITS = 64; public static BigInteger computeSimHash(List<String> tokens) { Map<String, Integer> tokenWeight = new HashMap<>(); for (String token : tokens) { tokenWeight.put(token, tokenWeight.getOrDefault(token, 0) + 1); } int[] vector = new int[FINGERPRINT_BITS]; for (Map.Entry<String, Integer> entry : tokenWeight.entrySet()) { String token = entry.getKey(); int weight = entry.getValue(); BigInteger hash = hashToken(token); for (int i = 0; i < FINGERPRINT_BITS; i++) { // hash 的每一位都参与加权 BigInteger bit = hash.shiftRight(i).and(BigInteger.ONE); if (bit.equals(BigInteger.ONE)) { vector[i] += weight; } else { vector[i] -= weight; } } } BigInteger fingerprint = BigInteger.ZERO; for (int i = 0; i < FINGERPRINT_BITS; i++) { if (vector[i] > 0) { fingerprint = fingerprint.setBit(i); } } return fingerprint; } public static BigInteger hashToken(String token) { try { MessageDigest digest = MessageDigest.getInstance("MD5"); byte[] bytes = digest.digest(token.getBytes(StandardCharsets.UTF_8)); return new BigInteger(1, bytes); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("MD5 algorithm not available", e); } } }逻辑说明:tokenWeight统计每个 token 出现的次数,作为哈希加权依据。随后把每个 token 的 64 位哈希拆成 64 个 bit,每一位上如果 hash 是 1 就vector[i] += weight,是 0 就-= weight。最后vector[i] > 0的位置指纹位设为 1,否则保持 0。这样,高频 token 对指纹的贡献更大,低频 token 的影响被稀释。
参数说明:FINGERPRINT_BITS固定为 64,这是 SimHash 的标准位数。算完指纹后用下面的汉明距离判断相似:
public static double similarity(BigInteger hashA, BigInteger hashB) { int hamming = hashA.xor(hashB).bitCount(); // 汉明距离越小越相似, 距离 10 以内约等于 85% 以上相似 return 1.0 - (hamming / 64.0); }注意这里的相似度是「归一化后的值」。我一般拿0.5当粗筛阈值,低于 0.5 的直接跳过,不进入精细比对。这个值可以按课程任务难度调整:如果作业是填空题式的,阈值提到 0.6;如果是大项目,0.4 就够,防止漏网之鱼。
接下来是余弦相似度。实现方式是先分词、统计词频、构造词频向量,然后按余弦公式计算:
public class CosineSimilarity { public static double compute(Map<String, Integer> vectorA, Map<String, Integer> vectorB) { if (vectorA.isEmpty() || vectorB.isEmpty()) { return 0.0; } double dotProduct = 0.0; double normA = 0.0; double normB = 0.0; // 计算点积: 只在两个向量都包含的维度上累加 for (Map.Entry<String, Integer> entry : vectorA.entrySet()) { if (vectorB.containsKey(entry.getKey())) { dotProduct += entry.getValue() * vectorB.get(entry.getKey()); } normA += Math.pow(entry.getValue(), 2); } for (Integer value : vectorB.values()) { normB += Math.pow(value, 2); } if (normA == 0.0 || normB == 0.0) { return 0.0; } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); } }逻辑说明:余弦相似度要求两个向量的维度必须一致,但两段代码分词后词表可能不同。这里只在vectorB.containsKey(entry.getKey())的维度上计算点积,是「稀疏向量点积」的常见做法,避免为不存在的维度创建零值项。分母用两个向量的模长乘积,结果范围是 -1 到 1,代码取正数部分,越接近 1 越相似。
参数说明:词频向量构造时我是「程序关键词 + 标识符名」一起纳入,但标识符名会被替换成VAR_0、VAR_1这样的占位符再做统计。不做这步的话,两个人定义了studentName和student_name,语义相同但文本不同,余弦相似度会被拉低。替换规则很简单:
public static String replaceIdentifiers(String code) { // 把变量名统一替换成 VAR_ 前缀, 消除命名差异 Pattern identifierPattern = Pattern.compile("\\b[a-z][A-Za-z0-9_]*\\b"); Matcher matcher = identifierPattern.matcher(code); StringBuilder result = new StringBuilder(); int index = 0; while (matcher.find()) { String matched = matcher.group(); if (!isKeyword(matched)) { result.append(code, index, matcher.start()).append("VAR_").append(index); index = matcher.end(); } } result.append(code.substring(index)); return result.toString(); }参数说明:isKeyword维护一张 Java 关键词表,包含public、class、void、for、if、else等。关键词不替换,因为两个学生都写for是正常的,都写VAR_123才是可疑信号。这是把「语义相似但名字不同」的情况拉回到同一尺度。
4. 完整落地部署:从配置到跑通一次作业查重
4.1 环境准备与配置文件
这套源码跑起来需要的东西不多:JDK 8 及以上、Maven 3.6+、MySQL 5.7+。先用 Maven 打的包是jar,直接放服务器跑完全没问题。
数据库初始化在源码里的sql目录下,先执行建表脚本,再插一条测试老师账号:
INSERT INTO student (student_no, student_name, class_name) VALUES ('2024001', '张伟', '软工2401'), ('2024002', '李娜', '软工2401'), ('2024003', '王晓', '软工2401');然后改application.yml里的数据源配置,这是整个项目里唯一必须动的地方:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/code_check?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 5MB max-request-size: 20MB mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto参数说明:map-underscore-to-camel-case一定要开,不加的话数据库里的student_no映射不到实体类的studentNo字段,查询结果全是 null,这个问题排查起来非常隐蔽。multipart.max-file-size我设成 5MB,超过这个大小的源码文件很可能是工程包不是单文件,直接拒绝比较合理。
4.2 启动与初始化数据
启动类上有个容易被忽略的注解@MapperScan,没有它所有 Mapper 都注入不进去:
@SpringBootApplication @MapperScan("com.example.plagiarism.mapper") public class PlagiarismApplication { public static void main(String[] args) { SpringApplication.run(PlagiarismApplication.class, args); } }启动前先确认 MySQL 里建好了数据库,不然启动会报连接失败。执行:
mysql -u root -p > CREATE DATABASE code_check DEFAULT CHARACTER SET utf8mb4;然后导入建表脚本。启动服务:
mvn spring-boot:run看到Started PlagiarismApplication就说明起来了。接着用 cURL 创建一个作业、上传作业文件,验证整套链路通没通。
4.3 接口调用完整示例
部署完必须亲手跑通一次完整流程,不然心里没底。第一步建作业:
curl -X POST http://localhost:8080/api/v1/homework \ -H "Content-Type: application/json" \ -d '{"title": "Java课设-图书管理系统", "teacherId": 1, "checkThreshold": 80.0}'返回的 JSON 里包含作业 ID,记下来,后面提交作业要用。第二步上传代码文件:
curl -X POST http://localhost:8080/api/v1/submissions/upload \ -F "file=@/path/to/Student1_BookManager.java" \ -F "homeworkId=1" \ -F "studentId=1"这一步的 Controller 实现里做了三个我在意的事情——文件扩展名校验、大小校验、重复提交校验:
@RestController @RequestMapping("/api/v1/submissions") public class SubmissionController { private final SubmissionService submissionService; public SubmissionController(SubmissionService submissionService) { this.submissionService = submissionService; } @PostMapping("/upload") public Result<Long> upload(@RequestParam("file") MultipartFile file, @RequestParam("homeworkId") Long homeworkId, @RequestParam("studentId") Long studentId) { // 1. 校验扩展名, 只允许 .java .cpp .py .c String originalFilename = file.getOriginalFilename(); if (originalFilename == null || !isAllowedExtension(originalFilename)) { throw new BizException("不支持的文件类型, 仅允许 .java .cpp .py .c"); } // 2. 校验文件大小, 不超过 5MB if (file.getSize() > 5 * 1024 * 1024) { throw new BizException("文件大小超过 5MB 限制"); } // 3. 调用服务层, 内部会做重复提交校验 Long submissionId = submissionService.upload(file, homeworkId, studentId); return Result.success(submissionId); } }逻辑说明:三个校验各自拦截一类提交错误。扩展名校验防止传exe或zip包,大小校验防止有人把整个项目目录打进来,重复提交校验靠submission表上的唯一键兜底。服务层里有一个细节,我建议保留——计算并存储content_hash:
@Service public class SubmissionService { private final SubmissionMapper submissionMapper; public Long upload(MultipartFile file, Long homeworkId, Long studentId) { // 检查该学生是否已提交过该作业 Submission existing = submissionMapper.selectOne( new LambdaQueryWrapper<Submission>() .eq(Submission::getHomeworkId, homeworkId) .eq(Submission::getStudentId, studentId) ); if (existing != null) { throw new BizException("该学生已提交过此作业, 如需重新提交请联系老师重置"); } // 存文件 String filePath = saveToLocal(file, homeworkId, studentId); // 计算 SHA-256 String contentHash = sha256(file); Submission submission = new Submission(); submission.setHomeworkId(homeworkId); submission.setStudentId(studentId); submission.setFilePath(filePath); submission.setContentHash(contentHash); submissionMapper.insert(submission); return submission.getId(); } private String sha256(MultipartFile file) { try (InputStream is = file.getInputStream()) { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { digest.update(buffer, 0, len); } StringBuilder hex = new StringBuilder(); for (byte b : digest.digest()) { hex.append(String.format("%02x", b)); } return hex.toString(); } catch (Exception e) { throw new RuntimeException("计算文件哈希失败", e); } } }参数说明:buffer为 8192 字节,这是流式读取的一个平衡值,太小频繁 IO、太大浪费内存。content_hash的用途是快速排除完全相同的文件——如果两份提交的哈希一致,说明连预处理都不需要做,可以直接标 100% 相似。注意用 SHA-256 而不是 MD5,MD5 碰撞概率虽然低,但做查重这种对抗性场景不能不防。
跑通上传之后,第三步才是重头戏——触发查重:
curl -X POST http://localhost:8080/api/v1/plagiarism/run?homeworkId=1这个接口会读取该作业下所有提交记录,两两组合计算相似度,把结果写进plagiarism_result表。
5. 避坑指南:查重系统常见的五个事故与排查
这套系统我前前后后部署过三轮,踩过的坑比文档里写得多。每一条都值得你提前知道,不然出了问题很难定位。
坑 1:所有对比结果都接近 100%,报告没法看
现象:跑完查重,similarity_score全在 95% 以上,连两个完全不同思路的实现都显示高度相似。
原因:预处理阶段没排除课程提供的公共代码。实训课基本都会发一个包含Main.java、CommonUtils.java的框架包,每个学生都在其上做开发,框架代码占据大半个文件,相似度被无意义拉高。
解决:在预处理前做一个「公共代码剔除」步骤。先把框架代码归一化之后存进common_code表,查重前从学生提交中把公共部分去掉。具体做法是分词后,凡是出现在公共代码里的 token 串直接从向量里移除。
坑 2:OOM,内存直接被查重进程吃满
现象:300 人的作业提交后触发查重,跑了五分钟直接OutOfMemoryError: Java heap space。
原因:查重实现里把所有提交的全文读进List<String>,再嵌套两层循环做两两比对,内存峰值是「提交数 × 单文件大小」,300 份文件平均每份 50KB 就有 15MB 峰值,加上分词产生的大量中间对象,老年代直接被塞满。
解决:先在保存提交时就算好指纹存入库,查重阶段只读取指纹和哈希,不重新读原文。SimHash 粗筛环节只加载指纹指纹的 64 位字节,内存占用从 O(n) 降到常量级别。精细对比阶段再做流式读取,逐行处理且用完即释放。
坑 3:MyBatis Plus 的分页不生效,总数永远返回 0
现象:写查询报告的分页接口,Page对象能返回记录,但total一直是 0,导致前端分页组件渲染不出页码。
原因:MyBatis Plus 3.x 的分页功能不是默认开的,它依赖PaginationInnerInterceptor注册到MybatisPlusInterceptor里。没注册的时候,selectPage只会执行普通查询,总数统计会被忽略。
解决:新建一个配置类把插件注册进去:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 第一个参数是数据库类型, 不同数据库分页 SQL 不同 PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }参数说明:DbType.MYSQL告诉插件生成 MySQL 的LIMIT ?语法,换成 Oracle 就要DbType.ORACLE。setMaxLimit(500L)防止有人传超大页码把数据库打崩,是推荐设置。
坑 4:代码里的中文注释全变成乱码
现象:上传的.java文件里中文注释在预处理后变成???个一段,查重相似度整体偏低。
原因:文件读取用的默认字符集是平台编码。Windows 下是 GBK,上传的文件是 UTF-8 编码,GBK 解码把中文字符读成乱码。
解决:统一强制 UTF-8 读取,别依赖new String(bytes)这种默认行为:
public static String readFileUtf8(Path path) throws IOException { return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); }注意,Files.readAllBytes适合单文件不超过 2MB 的场景,这个查重系统里足够用了。如果将来要做大文件处理,要改成FileReader配合BufferedReader指定编码。
坑 5:同一份代码, 一次查重 65%, 一次查重 90%, 结果不稳定
现象:同一份 A/B 提交,重启服务后再查一次,结果和上次差 25 个百分点。
原因:把变量名替换成VAR_占位符时用了自增序号,而序号取决于分词顺序。Java 的HashMap遍历顺序不保证稳定,两次运行时选中的同一个变量可能被赋予不同的VAR_123,导致余弦向量计算出现巨大差异。
解决:把所有涉及 token 顺序的容器换成LinkedHashMap或TreeMap,保证遍历顺序稳定。我后来把标识符替换逻辑彻底改成「按首次出现顺序命名」,且顺序由LinkedHashMap确定,问题根除。这类问题最吓人,因为结果表面上是正常的,但一深挖就露馅,属于查重系统里祸害最深的坑。
6. 进阶技巧:让批量查重性能翻倍,报告不再被口水淹没
当你把基础跑通之后,接下来要解决的是两个现实问题:几百人同时提交后查重要跑多久?老师看到几百条相似度记录从哪看起?
第一个问题用多线程解决。查重任务天然适合并行——提交 A 和提交 B 的比对完全不依赖其他任务。做法是引入线程池,按「同作业提交对」拆分子任务:
ExecutorService executor = Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors() - 1 );这个参数可以这么理解:四核机器留一个核心给主线程,其余三个核跑查重子任务。任务拆完后用Future收集结果。但有一点要提前想好——线程池的共享数据要线程安全。如果把待比对的对组合放进ArrayList而多个线程同时读,会有并发问题。我一般用ConcurrentLinkedQueue或直接不共享,每个线程领一个独立文件列表。
第二个问题靠阈值过滤。homework表里的check_threshold字段就是干这个的。查询报告时只拉取超过阈值的数据:
@Select("SELECT ... FROM plagiarism_result r " + "WHERE r.homework_id = #{homeworkId} " + "AND r.similarity_score >= #{threshold} " + "ORDER BY r.similarity_score DESC") List<ReportVO> selectSuspiciousReport(@Param("homeworkId") Long homeworkId, @Param("threshold") BigDecimal threshold);应用层把阈值从配置读出来,老师可以在管理界面动态调。默认设 80%,低于这个的都不展示,报告自然清爽。
另外一个值得做的验证是「用已知抄袭样本自检」。我的习惯是:每次改完算法,先人工构造三组测试样例——一组原样复制、一组改变量名复制、一组换注释复制。阈值定在能抓出前两组、放走第三组的水平。从那以后,每次改完查重代码,我都强制走一遍这三组样例,再放真实数据进来。这个习惯帮我挡住了两次「改注释导致误判率暴涨」的回归。希望帮到你。
本文还有配套的精品资源,点击获取