每年的毕业设计季,都会有一大批"基于SpringBoot的XX管理系统"课题涌出来,其中合同信息管理系统的出场率相当高。原因很简单:合同管理是企业刚需,业务逻辑清晰,CRUD加文件上传就能撑起一个完整课题,又不涉及高并发、复杂算法,特别适合做课程设计或毕业设计。这类课题的交付包通常长这样:源码 + lw(论文/课程设计报告)+ 部署文档 + 讲解视频,听起来很齐全,但很多同学拿到手之后,第一反应是打开IDEA直接跑,跑不起来就慌,跑起来也不知道每一层代码在干嘛,等答辩老师一问就露馅。
这篇文章我结合带过多届课程设计的经验,把这套系统从"为什么这么设计"到"怎么部署跑通",再到"论文怎么写、答辩怎么讲"完整拆一遍。适合两类人看:一类是拿到这套课题包、需要交作业并通过答辩的学生;另一类是真的想做一个合同管理小系统用于实际办公场景的开发者。看完你至少能回答三个问题:这套系统到底做了什么?每段核心代码为什么这么写?部署和答辩环节最容易在哪些地方栽跟头?
1. 别急着敲代码:先搞清楚合同管理系统到底要管什么
1.1 传统合同管理方式的三个真实痛点
很多人以为合同管理就是"记录一下合同的基本信息",其实做系统之前,得先理解企业为什么需要它。我接触过不少中小企业,它们的合同管理大多是这个状态:
第一,合同信息散落在Excel和多张纸面上,业务员手里一份、财务一份、行政一份,版本对不上,查一份合同要翻半天聊天记录和邮箱。第二,合同原件(PDF、Word、扫描件)存在个人电脑里,人走了文件也跟着走了,后续续签、付款、举证时找不到依据。第三,合同到期和付款节点完全靠人记,忙起来就忘,续签遗漏可能造成事实合同,付款逾期会产生违约金。
这三个痛点对应到系统上,就是三块核心功能:合同台账(统一录入、查询、编辑)、合同附件(上传、下载、归档)、到期提醒(按日期自动扫描并提示)。别小看这三件事,很多看似功能复杂的系统,实际高频使用的也就是这三板斧。做课题时把这三块做扎实,比堆一堆花哨但无用的功能更有说服力。
1.2 系统的功能边界:哪些是该做的,哪些是画蛇添足
不少同学会陷入一个误区:觉得功能越多越好,于是硬塞了一堆"电商式"能力,比如在线支付、电子签章、流程引擎。结果代码量爆炸,自己也说不清楚,答辩时被问几个细节就崩了。我建议这套系统守住以下边界:
- 用户登录与权限区分:管理员和普通用户,普通用户只管自己名下的合同,管理员能看到全部。
- 合同信息管理:新增、修改、删除、分页查询、关键字模糊搜索、按状态和日期筛选。
- 合同附件管理:单文件上传、下载、删除,文件与合同记录一一关联。
- 合同状态管理:草稿 -> 审批中 -> 已生效 -> 已到期 -> 已归档,用状态字段驱动,不过度设计。
- 到期提醒:定时任务扫描30天内到期的合同,在首页和列表页用状态标识出来。
- 首页统计:合同总数、总金额、各状态数量,几个聚合查询搞定。
把这几块做好了,就是一个逻辑完整、能演示、能答辩的系统。电子签章、多级审批这类扩展,写进论文的"未来展望"就够了,不要硬塞进前端页面。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与表结构设计:这套系统的骨架怎么搭的
2.1 为什么是SpringBoot + MyBatis-Plus + MySQL + Layui
这套课题包的技术栈几乎是固定的,但它能成为标配是有原因的。SpringBoot最大的价值是"约定大于配置",内嵌Tomcat,一个Application类就能启动,不需要把WAR部署到外部容器,对课程设计非常友好——你只需要关注业务代码,不会被一堆XML配置淹没。MyBatis-Plus在MyBatis基础上封装了单表CRUD,Mapper XML都不用写,自带分页插件,分页查询这种高频操作几行代码就能搞定。MySQL是通用性最强的数据库,答辩老师基本都会用,不会因为数据库太冷门被质疑。
前端为什么用Layui或者Thymeleaf,而不上Vue全家桶?如果搞前后端分离,需要处理跨域、Token、路由守卫一堆问题,对课题项目来说复杂度反而上去了。Layui是国内老牌简洁UI框架,表格、分页、弹窗都封装好了,直接用HTML加JS就能实现管理后台,演示时还很流畅。用Thymeleaf则走服务端渲染,每个页面就是一个模板,代码更直观。当然,如果你本身Vue很熟,用Vue + Element UI写前端也完全没问题,切记别为了炫技给自己挖坑。
2.2 核心表结构:合同表和用户表怎么设计
数据库设计是论文里必写的一章,也是答辩老师最爱问的地方。以我的经验,这套系统两张核心表就够了,如果需要,再加一张附件表也行,但通常没必要——把文件路径直接存在合同表里更简单直观。
sql复制CREATE TABLE `sys_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '登录名',
`password` varchar(100) NOT NULL COMMENT '密码(MD5加密存储)',
`real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名',
`role` tinyint(4) DEFAULT '1' COMMENT '角色:1普通用户,2管理员',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制CREATE TABLE `contract` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`contract_no` varchar(50) NOT NULL COMMENT '合同编号',
`contract_name` varchar(200) NOT NULL COMMENT '合同名称',
`party_a` varchar(100) DEFAULT NULL COMMENT '甲方',
`party_b` varchar(100) DEFAULT NULL COMMENT '乙方',
`amount` decimal(12,2) DEFAULT '0.00' COMMENT '合同金额(元)',
`sign_date` date DEFAULT NULL COMMENT '签订日期',
`start_date` date DEFAULT NULL COMMENT '生效日期',
`end_date` date DEFAULT NULL COMMENT '到期日期',
`status` tinyint(4) DEFAULT '0' COMMENT '状态:0草稿,1审批中,2已生效,3已到期,4已归档',
`file_path` varchar(255) DEFAULT NULL COMMENT '合同附件存储路径',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里有几个字段设计的经验,写论文和答辩时都能拿出来讲:金额用decimal(12,2)而不是float或double,因为浮点数有精度误差,涉及钱必须用精确十进制;状态用tinyint而不是varchar,因为条件查询和统计更高效,可读性交给代码里的枚举类解决;日期用date类型而非datetime,存的是"日"而不是"时刻",到期提醒只比较date字段就够了;文件路径存相对路径而不是文件名,避免以后迁移目录时数据库里的路径全部失效。这些细节不起眼,但有经验的老师一听就知道你真正动手做过。
2.3 工程目录:拿到源码先看包结构
拿到源码后第一步不是去逐个读文件,而是先看包结构是否规范。一个合格的SpringBoot项目一般长这样:
text复制com.example.contract
├── controller # 控制层,接收请求、回传JSON
├── service # 业务层,核心业务规则
│ └── impl # 业务实现类
├── mapper # 数据访问层,MyBatis-Plus的Mapper接口
├── entity # 实体类,对应数据库表
├── config # 配置类,拦截器、跨域、定时任务等
├── common # 统一返回结果Result、工具类
└── ContractApplication.java
controller层只做参数接收和结果封装,不写业务逻辑;service层放真正的业务规则;mapper层只做数据库交互。如果源码里把一堆SQL写在controller里,那项目的质量就要打个问号。拿到手先检查分层是否清晰,这也是答辩时最有把握的提问点之一。
3. 核心功能落地的关键代码与设计理由
3.1 登录与权限控制:拦截器比过滤器更适合这个场景
登录模块看起来简单,但它是整个系统安全的第一道门。密码存库前必须加密,明文存库是最低级的错。课程设计里用MD5加盐就够了,生产环境才需要考虑BCrypt。拦截器这边,核心逻辑是检查Session里有没有用户:
java复制@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession();
if (session.getAttribute("loginUser") == null) {
response.sendRedirect("/login");
return false;
}
return true;
}
}
为什么用拦截器而不是过滤器?因为拦截器是SpringMVC体系内的组件,可以拿到Handler对象,能做更细粒度的路径匹配,还能注入Bean;过滤器是Servlet层面的,更底层,适合处理编码、跨域这类通用事情。对于"判断用户是否登录"这种业务行为,拦截器是更合适的选择。注册拦截器时要特别注意放行静态资源和登录接口,否则你会看到登录页的CSS全部失效:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/login", "/doLogin",
"/css/**", "/js/**", "/images/**", "/layui/**");
}
}
3.2 分页查询:MyBatis-Plus的Page加LambdaQueryWrapper
合同列表页是整个系统使用频率最高的页面,分页查询必须做对。MyBatis-Plus的分页插件用起来极其顺手,三件事:配置分页拦截器、构造Page对象、用QueryWrapper拼条件。
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
java复制public Page<Contract> pageContracts(int pageNum, int pageSize, String keyword, Integer status) {
Page<Contract> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<Contract> wrapper = new LambdaQueryWrapper<>();
// keyword不为空时才拼接模糊查询
wrapper.like(StringUtils.hasText(keyword), Contract::getContractName, keyword)
.eq(status != null, Contract::getStatus, status)
.orderByDesc(Contract::getCreateTime);
return contractMapper.selectPage(page, wrapper);
}
这里有两个细节值得说。第一,条件拼接用like(boolean condition, ...)这种重载,条件不成立时自动忽略,避免你在代码里写一堆if判断;第二,排序默认按创建时间倒序,新录入的合同排前面,这是用户最直观的预期。前端Layui的table组件请求参数是page和limit,后端接收时直接用这两个参数名即可,返回时给Layui一个{code:0, count:总数, data:列表}结构,表格就能正常渲染。
3.3 合同附件上传:文件名、路径、大小限制一个都不能少
附件上传是合同系统里最容易出问题也最实用的功能。核心逻辑分三步:校验文件、生成唯一文件名、保存文件并更新数据库路径。
java复制@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file,
@RequestParam("contractId") Long contractId) {
if (file.isEmpty()) {
return Result.error("请选择文件");
}
String originalName = file.getOriginalFilename();
String ext = originalName.substring(originalName.lastIndexOf("."));
// 用UUID防止重名,也防止用户文件名里的中文和特殊字符
String newName = UUID.randomUUID().toString().replace("-", "") + ext;
File dir = new File(uploadPath);
if (!dir.exists()) {
dir.mkdirs();
}
file.transferTo(new File(dir, newName));
// 更新合同记录的file_path字段
contractService.updateFilePath(contractId, "/uploads/" + newName);
return Result.success();
}
为什么文件名不用原始名称?因为不同用户上传的文件可能重名,而且中文文件名在某些环境会乱码,UUID命名可以彻底规避这两个问题。扩展名要保留,因为下载时要靠它设置Content-Type。路径不要写死成绝对路径,配置到yml里,部署到不同机器时只改一处。transferTo底层是移动临时文件,如果在multipart配置里不限制大小,大文件会把临时目录占满,所以要在application.yml里做好限制:
yaml复制spring:
servlet:
multipart:
max-file-size: 20MB
max-request-size: 50MB
3.4 到期提醒与首页统计:定时任务和聚合查询
到期提醒是这套系统里最像一个"系统"的功能。实现方式很直白:启动类加@EnableScheduling,写一个定时任务组件,每天固定时间扫描即将到期的合同:
java复制@Component
public class ContractRemindTask {
@Scheduled(cron = "0 0 9 * * ?") // 每天9点执行
public void remindExpiringContracts() {
LocalDate today = LocalDate.now();
LocalDate deadline = today.plusDays(30);
// 查询 end_date 在 today 和 deadline 之间、且状态为"已生效"的合同
// 生成提醒记录或在列表标记,也可以直接写入通知表
}
}
定时任务的价值不在于技术难度,而在于业务理解——为什么是提前30天而不是当天提醒?因为续签、付款审批都需要周期,当天提醒往往来不及。这个"提前量"的设计在论文里可以写一小段,答辩时也有的可说。
首页统计更简单,用Mapper里几个聚合查询就够了:
java复制@Select("SELECT COUNT(*) FROM contract")
long countAll();
@Select("SELECT COALESCE(SUM(amount),0) FROM contract WHERE status = 2")
BigDecimal sumEffectiveAmount();
@Select("SELECT status, COUNT(*) FROM contract GROUP BY status")
List<Map<String, Object>> countByStatus();
注意SUM可能返回null,要用COALESCE兜底,这是SQL里很经典的小坑。统计结果在首页用卡片或柱状图展示,演示时观感很好。
4. 从源码到跑通:本地搭建与三种部署方式实录
4.1 环境版本匹配:先把三件套搞对
拿到源码跑不起来的,八成是环境版本问题。我建议直接按这个组合来:JDK 1.8、Maven 3.6.x、MySQL 5.7或8.0、IDEA 2020以上。SpringBoot 2.x要求JDK 1.8以上,但不要一上来就装JDK 21,很多老项目的依赖和语法会不兼容;SpringBoot 3.x需要JDK 17,如果你拿到的源码是SpringBoot 2.x,装了JDK 17以上反而会启动报错。
检查版本三步走:
bash复制java -version
mvn -version
mysql --version
如果Maven是IDEA自带的,建议在IDEA的Settings里设置成自己安装的Maven,并把settings.xml里的镜像源配上阿里云仓库,不然下载依赖能卡到你怀疑人生。
4.2 数据库初始化和配置文件修改
源码包一般带一个contract_db.sql文件,这就是数据库初始化的全部内容。操作顺序:先用Navicat或命令行创建一个数据库,名字和配置文件里保持一致,再导入SQL:
bash复制mysql -u root -p -e "CREATE DATABASE contract_db DEFAULT CHARACTER SET utf8mb4;"
mysql -u root -p contract_db < contract_db.sql
导入成功后,打开src/main/resources/application.yml,核对这几项:
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/contract_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 你自己的数据库密码
这里有个高频坑:如果数据库是MySQL 5.7,驱动类写com.mysql.cj.jdbc.Driver(MySQL 8.x的驱动)也能用,因为新版驱动做了兼容;但如果是MySQL 8.0而配置里写的是老驱动com.mysql.jdbc.Driver,启动会直接报Loading class 'com.mysql.jdbc.Driver' is deprecated或者连不上。URL里的serverTimezone=Asia/Shanghai必须带,否则默认时区偏差会导致时间字段差8个小时。
4.3 三种部署方式:IDEA、jar包、Docker
第一种,开发调试用IDEA。直接右键ContractApplication.java运行,控制台看到Started ContractApplication就说明起来了,浏览器访问http://localhost:8080。
第二种,交付演示用jar包。先打包:
bash复制mvn clean package -DskipTests
在target目录下会生成contract-system.jar,然后:
bash复制java -jar target/contract-system.jar
这种方式的优势是轻量,一台装了JDK的机器就能跑,非常适合答辩现场演示或者拷给老师。
第三种,服务器部署用Docker。先写一个Dockerfile:
dockerfile复制FROM openjdk:8-jdk-alpine
WORKDIR /app
COPY target/contract-system.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
然后构建并运行:
bash复制docker build -t contract-system .
docker run -d -p 8080:8080 --name contract-system contract-system
如果数据库不在镜像里,记得用--network host或者把数据库连接地址改成宿主机的IP。Docker部署的坑主要在数据库连接,容器里localhost指向的是容器自己,不是你电脑上的MySQL,这一点务必注意。
5. 部署和自测过程中最容易踩的五个坑
5.1 数据库连不上:驱动、时区、密码三项逐个查
启动报错Cannot create PoolableConnectionFactory,不用慌,按顺序排查:第一,MySQL服务有没有启动,Windows下按Win+R输入services.msc看MySQL服务状态;第二,密码对不对,把yml里的密码复制到Navicat里试一遍;第三,驱动和URL的写法对不对,重点确认serverTimezone和useSSL=false。90%的启动失败都集中在第三项,尤其是MySQL 8.0强制要求时区参数。
5.2 中文乱码:从IDEA到数据库全链路排查
中文乱码是课程设计里的万金油问题,出现一次能折腾半小时。排查链路从外到内:先看数据库和表的字符集,SHOW CREATE TABLE contract确认是utf8mb4;再看URL里有没有characterEncoding=utf8;最后看IDEA的File Encoding设置,把Project Encoding和Properties Files的编码统一改成UTF-8。还有一个容易被忽略的地方:Layui表格请求的JSON返回如果是乱码,检查SpringBoot的@ResponseBody默认编码,通常在yml里设置server.servlet.encoding.force=true就能解决。
5.3 登录页CSS全丢:静态资源被拦截器拦住了
现象很典型:能打开登录页,但页面没有样式,F12一片红。原因就是拦截器只放行了/login和/doLogin,没有放行/css/**、/js/**这些静态资源路径。我在前面给的配置里特意写了excludePathPatterns,这里再说一次:凡是前端页面引用的静态资源路径,都必须排除在拦截器之外。如果用了Layui的组件库,记得把/layui/**也放进去,否则组件样式和脚本同样会被拦截。
5.4 文件上传失败:临时目录、大小限制、路径权限
文件上传报错最常见的是FileSizeLimitExceededException,这是大小限制没配置,把yml里multipart配置加上就行。第二种是java.io.IOException: java.io.FileNotFoundException,通常是上传目录不存在或者没有写权限,代码里dir.mkdirs()要在保存前执行,目录最好配置到项目外部,比如D:/contract-uploads,而不是项目内部,否则重新打包后文件会丢失。第三种是Linux下部署时报权限不足,用chmod给上传目录加权限即可。
5.5 端口被占用:8080的问题
启动时报Port 8080 was already in use,说明8080被别的进程占了。Windows下查占用并清理:
bash复制netstat -ano | findstr 8080
taskkill /PID 进程号 /F
如果不想杀进程,直接改yml里的server.port为9090之类的端口也行。
6. 配套lw文档与答辩:论文怎么写、演示怎么讲
6.1 lw文档的章节结构:直接对号入座
很多同学拿到lw模板不知道该写什么,其实课程设计报告和毕业论文都有固定套路,章节结构大概是这个顺序:
- 摘要与关键词:中文摘要300字左右,写清楚系统完成了什么、用了什么技术、解决了什么问题;英文摘要逐句翻译即可。
- 绪论:研究背景与意义、国内外现状、主要工作。背景写企业合同管理的痛点和信息化趋势,现状写几句国内外合同管理系统发展情况,主要工作把功能列表罗列一遍。
- 需求分析:可行性分析(技术、经济、操作)、功能需求、用例图。用例图画用户和管理员两个角色,标注每个角色能干什么。
- 系统设计:总体架构图、功能模块划分、数据库设计。数据库设计这章写E-R图和表结构说明,把你前面建的表逐个字段解释清楚。
- 系统实现:按功能模块贴核心代码和页面截图。代码不要全贴,贴关键方法,比如分页查询、上传处理、定时任务,每个模块配一两张截图,写清楚实现思路。
- 系统测试:测试环境、测试用例表、测试结果。设计几个典型用例覆盖登录、增删改查、上传下载、权限控制。
- 总结与展望:总结系统完成情况,展望写电子签章、多级审批、移动端适配这些扩展方向。
- 参考文献:10篇左右,格式按学校要求来。
写lw时有个技巧:页面截图不要干巴巴只放一张,要在截图上方写一句"如图示,实现了XXX功能",并在正文里呼应。老师翻论文的速度很快,图文对应会让观感提升很多。
6.2 答辩演示顺序与高频问题
演示不要从登录讲起,太流水账。我建议按这个节奏:先讲系统解决的痛点(30秒),再登录进入首页展示统计卡片(证明系统有数据支撑),然后演示合同的增删改查和分页搜索,接着现场上传一个附件再下载回来,最后把到期提醒的定时任务展示出来。核心原则是:每个演示动作配一句"为什么这样做",而不是闷头点鼠标。
答辩老师高频问题基本集中在这些点:数据库为什么用tinyint存状态而不是varchar;金额为什么用decimal;分页插件的工作原理是什么;上传文件怎么防止重名和路径穿越;定时任务用的是什么表达式,多久执行一次;如果你把拦截器排除了静态资源,那安全问题怎么解决。这些问题在前面几节的实现里都能找到答案,把原理吃透比背代码更重要。
6.3 在源码基础上加一个亮点,避免全组雷同
同一批课题包通常会被好几组人拿到,如果不做改动,答辩现场两台电脑开出来页面一模一样,场面非常尴尬。加功能不用太复杂,我推荐几个性价比高的方向:加一个合同提醒列表页,把到期合同单独列出来并标注剩余天数;或者加一个简单的操作日志表,记录谁在什么时间新增、修改、删除了哪条合同;又或者在导出功能上做文章,用EasyPoi把列表导出成Excel。任何一个都只涉及一张表和两三个接口,一天能搞定,但答辩时能让老师觉得你有独立扩展能力。
最后说点实在的体会。这类课题包的价值不在于"能跑",而在于你愿意花两天时间去搞懂它。我最常跟学生讲的是:拿到源码后,先别急着启动,把三层代码从controller到mapper串一遍,用笔画出请求从浏览器到数据库再返回的完整链路;然后挑一个字段,比如给合同表加一个"负责人"字段,从数据库改到页面,全程走一遍。这个动作做完,你基本就不会再怕追问了。系统本身不复杂,真正值钱的是你动手改过、踩过坑之后积累的那份踏实感。
