最近把手头一套基于SpringBoot的合同信息管理系统完整整理了一遍,源码、部署文档、代码讲解全都配齐了。这套系统最初是我在公司内部从零搭起来的,起因很简单——合同台账全靠Excel和邮件来回传,一份合同改三版,几个部门各自手里一个"最新版",等对账的时候才发现版本早就乱了。后来决定用SpringBoot做一套能自己掌控、能持续改动的合同管理系统,从需求梳理到上线部署前后折腾了两个多月,中间踩了不少坑,也沉淀了很多值得展开说的细节。
这套系统的完整内容(源码、部署文档、代码讲解)整理好之后我发给了几个正在做毕设和刚转Java开发的朋友,他们照着部署文档一步步跑通之后反馈都不错,但同时也暴露出一个普遍问题:很多人拿到源码只会启动项目,遇到配置报错就卡住,代码看一遍似懂非懂,问"这段逻辑为什么这么写"就答不上来。所以写这篇博文的目的很简单——把一套真实的SpringBoot合同信息管理系统从技术选型、数据库设计、核心代码、部署上线到排错思路,一条线讲清楚。无论你是刚学SpringBoot准备做毕设,还是初级开发想搞懂一个完整项目的结构,这篇内容都能给你拿来即用的参考。
1. 为什么选合同管理系统作为SpringBoot练手项目
1.1 合同管理场景在真实业务里的典型性
很多人练手喜欢做"图书管理系统"或者"学生管理系统",说实话,这类项目用来熟悉CRUD没问题,但离真实业务太远。合同信息管理系统不一样,它是几乎所有企业都绕不开的系统。
它的核心场景很清晰:合同从起草、审批、签署到归档,中间涉及多个角色和多种状态;合同本身有关键字段(合同编号、甲方乙方、金额、起止日期、到期提醒);合同往往附带扫描件或电子文档需要上传下载;不同角色(销售、法务、财务、管理员)对合同的操作权限不同。这四点拆开看,分别对应了业务建模、状态流转、文件处理、权限控制,恰好是后端开发最常碰到的四类问题。
用这套系统学习SpringBoot,你不会只停留在"写个CRUD接口"的层面,而是会接触到项目结构怎么分层、表关系怎么设计、定时任务怎么用、文件存储怎么做、权限怎么控制,这些才是工作中真正天天打交道的东西。
1.2 为什么选SpringBoot而不是其他技术栈
这里有必要多说一句技术选型的原因。市面上做管理系统可以选SpringBoot、Django、Flask、Node.js,但如果目标是掌握国内企业级开发的主流技能,SpringBoot几乎是最稳妥的选择。
SpringBoot强大的自动配置能力把过去SpringMVC时代繁琐的XML配置大大简化了,你写一个Controller就能直接跑起来,学习曲线比传统SSH平滑很多。加上内置Tomcat,打包成jar之后一条java -jar命令就能启动,部署成本也低。另外国内绝大多数中小型公司的Java后端就是SpringBoot+MyBatis这套组合,学会之后找工作或者接手老项目都非常顺畅。
而且SpringBoot生态里可以平滑引入Spring Security、Quartz、Redis、Actuator这些组件,同一个项目里就能把权限、定时任务、缓存、监控都带上,这对个人成长来说性价比很高。
1.3 一套好的合同管理系统应该长什么样
在做这套系统之前我先把需求理清楚,避免边写边改。最终确认的核心功能模块如下:
| 模块 | 核心功能 | 涉及技术点 |
|---|---|---|
| 合同管理 | 合同信息增删改查、状态流转、条件检索 | CRUD、分页、逻辑删除、枚举状态机 |
| 审批管理 | 合同提交审批、通过/驳回 | 状态机、操作记录、AOP |
| 附件管理 | 合同文件上传、下载、预览 | 文件存储、静态资源映射、上传大小配置 |
| 到期提醒 | 到期合同自动扫描并提醒 | Quartz定时任务、邮件/钉钉通知 |
| 系统管理 | 用户、角色、权限 | RBAC权限模型、Spring Security |
大家注意,这套系统没有做很重的功能,比如电子签章、合同模板在线编辑、对接第三方OCR,这些都是后话。初学者做项目最大的问题是贪大求全,一上来就想做十几个模块,最后每个模块都很浅。我更推荐的做法是:先把上面这五个模块做扎实,再根据业务需要逐步扩展。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与数据库设计:这些模块别拍脑袋定
2.1 技术栈清单和各组件版本搭配
这套系统的技术栈如下:
| 组件 | 版本 | 用途说明 |
|---|---|---|
| JDK | 1.8(8u202+) | 生产环境主流选择,兼容性最好 |
| SpringBoot | 2.7.18 | 最后的JDK8支持版本,稳定且资料丰富 |
| MyBatis-Plus | 3.5.3 | 增强MyBatis,内置分页和CRUD方法 |
| MySQL | 8.0.x | 核心业务数据存储 |
| Redis | 6.x/7.x | 缓存、登录Token,可选但推荐 |
| Druid | 1.2.x | 数据库连接池和监控 |
| Quartz | 2.3.2 | 合同到期定时任务调度 |
| Spring Security | 随SpringBoot管理 | 登录认证和接口权限控制 |
| Lombok | 随SpringBoot管理 | 简化实体类代码 |
这里要特别提醒一个坑:SpringBoot 3.x 出来之后,很多人直接新建项目选了3.0以上版本,然后发现JDK8根本起不来——因为SpringBoot 3.x强制要求JDK17+。很多部署环境和公司的老项目还停留在JDK8,所以如果你想要更稳妥的学习和生产体验,我建议选SpringBoot 2.7.x系列搭配JDK8,这也是目前网上能找到最多踩坑案例的版本组合。
2.2 数据库表结构怎么设计才够用
数据库设计是很多新手最容易糊弄过去的部分。记住一个原则:表结构不是越复杂越好,而是要能支撑业务流转。这套系统我设计了六张核心表:
contract:合同主表,存合同编号、名称、类型、甲方乙方、合同金额、签订日期、生效日期、到期日期、状态等contract_attachment:合同附件表,存附件名、存储路径、上传人、上传时间、关联合同IDcontract_approval:合同审批记录表,存审批人、审批动作(提交/通过/驳回)、审批意见、操作时间sys_user:用户表,存用户名、密码(BCrypt加密)、姓名、部门、状态sys_role:角色表sys_user_role:用户角色关联表
主表建表SQL的简化版本如下:
sql复制CREATE TABLE `contract` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`contract_no` varchar(64) NOT NULL COMMENT '合同编号',
`contract_name` varchar(200) NOT NULL COMMENT '合同名称',
`contract_type` varchar(32) DEFAULT NULL COMMENT '合同类型:采购/销售/租赁/其他',
`party_a` varchar(200) DEFAULT NULL COMMENT '甲方名称',
`party_b` varchar(200) DEFAULT NULL COMMENT '乙方名称',
`amount` decimal(18,2) DEFAULT NULL COMMENT '合同金额(元)',
`sign_date` date DEFAULT NULL COMMENT '签订日期',
`effective_date` date DEFAULT NULL COMMENT '生效日期',
`expire_date` date DEFAULT NULL COMMENT '到期日期',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1草稿,2审批中,3已生效,4已驳回,5已终止',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除:0未删,1已删',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_contract_no` (`contract_no`),
KEY `idx_expire_date` (`expire_date`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='合同信息主表';
几个细节说明一下。
第一,deleted逻辑删除字段非常重要。合同数据是业务数据,物理删除风险太大。万一被误删,合同审计和对账就没法做了。MyBatis-Plus内置了@TableLogic注解,配置好之后删除操作自动变成UPDATE更新逻辑删除字段。
第二,expire_date和status分别建了索引。因为核心业务场景就是"按状态查合同"和"扫到期合同",这两个条件是高频查询,必须有索引。
第三,金额字段用DECIMAL(18,2),不要用FLOAT或DOUBLE。金额这种东西一旦出现精度误差,财务那边是要吵架的。
2.3 项目目录结构
目录结构遵循常见的分层架构:
code复制src/main/java/com/example/contract/
├── ContractApplication.java // 启动类
├── config/
│ ├── MybatisPlusConfig.java // MyBatis-Plus分页插件配置
│ ├── SecurityConfig.java // Spring Security安全配置
│ ├── WebMvcConfig.java // 静态资源映射、拦截器配置
│ └── QuartzConfig.java // 定时任务配置
├── controller/
│ ├── ContractController.java
│ ├── AttachmentController.java
│ └── AuthController.java
├── service/
│ ├── ContractService.java
│ ├── AttachmentService.java
│ └── NoticeService.java
├── mapper/
│ ├── ContractMapper.java
│ └── AttachmentMapper.java
├── entity/
│ ├── Contract.java
│ └── ContractAttachment.java
├── dto/
│ ├── ContractQueryDTO.java
│ └── ContractApprovalDTO.java
├── quartz/
│ └── ContractExpireJob.java
├── common/
│ ├── Result.java
│ └── ResultCode.java
└── utils/
└── UserContext.java
这套结构的好处是职责边界非常清楚:controller只接收请求和返回结果,service只写业务逻辑,mapper只做数据库操作,config管理各种配置,dto负责接口参数的传递。刚开始写代码的人很容易在Controller里写几百行业务逻辑,看起来是省事了,但后续维护绝对想哭。我在重构第一版的时候花了很大力气把Controller里的业务代码全部挪到Service层,从那以后改需求轻松了很多。
3. 核心模块实现细节与代码讲解
3.1 合同CRUD主流程:分页查询和状态流转
合同管理的核心是列表查询和状态流转。列表查询用MyBatis-Plus的Page分页插件,查询条件用LambdaQueryWrapper动态拼接:
java复制@Override
public Page<Contract> pageQuery(ContractQueryDTO queryDTO) {
// 先校验并填充分页参数
if (queryDTO.getPageNum() == null) {
queryDTO.setPageNum(1);
}
if (queryDTO.getPageSize() == null) {
queryDTO.setPageSize(10);
}
// 构造查询条件
LambdaQueryWrapper<Contract> wrapper = Wrappers.<Contract>lambdaQuery()
.eq(StringUtils.hasText(queryDTO.getContractNo()), Contract::getContractNo, queryDTO.getContractNo())
.like(StringUtils.hasText(queryDTO.getContractName()), Contract::getContractName, queryDTO.getContractName())
.eq(queryDTO.getStatus() != null, Contract::getStatus, queryDTO.getStatus())
.ge(queryDTO.getExpireDateStart() != null, Contract::getExpireDate, queryDTO.getExpireDateStart())
.le(queryDTO.getExpireDateEnd() != null, Contract::getExpireDate, queryDTO.getExpireDateEnd())
.orderByDesc(Contract::getCreateTime);
Page<Contract> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
return contractMapper.selectPage(page, wrapper);
}
这里有几个细节值得新手注意。
第一,eq和like方法的前面都加了一个条件判断,比如StringUtils.hasText(...)。这样做的目的是:前端传什么条件我就拼接什么条件,没传的参数自动忽略,不需要为每一种情况写一个Mapper XML。这种动态查询写法是MyBatis-Plus最常见的用法,同时也是最容易写错的地方——很多人直接在wrapper里eq(Contract::getStatus, 0),然后发现前端不传状态时查出来是空的,因为null被当成条件传进去了。
第二,分页插件需要单独配置,不是引入依赖就自动生效的。MybatisPlusConfig里要注册分页拦截器:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
大家注意,这个拦截器要设置数据库类型DbType.MYSQL。如果不设置的话,分页SQL的生成可能有问题,尤其在不同数据库方言切换的时候。
3.2 轻量状态机:审批用代码实现而不是用工作流引擎
合同审批流是这个系统最值得重点讲解的部分。很多初学者一听到"审批流"就想到Activiti、Flowable这些重量级工作流引擎,说实话,在一个毕业设计或者中小型项目的合同审批场景里,引入工作流引擎往往是杀鸡用牛刀——配置复杂、学习成本高、出问题还不好排查。
我这里选的是轻量级状态机方案。思路很简单:
- 合同有5种状态:草稿(1)、审批中(2)、已生效(3)、已驳回(4)、已终止(5)
- 定义一个
ContractAction枚举表示操作:提交审批、审批通过、审批驳回、终止合同 - 每个操作对应一个状态转移规则,写成枚举代码
java复制public enum ContractStatus {
DRAFT(1, "草稿", Arrays.asList(SUBMIT)),
APPROVING(2, "审批中", Arrays.asList(APPROVE_PASS, APPROVE_REJECT)),
EFFECTIVE(3, "已生效", Arrays.asList(TERMINATE)),
REJECTED(4, "已驳回", Arrays.asList(SUBMIT)),
TERMINATED(5, "已终止", Collections.emptyList());
private final int code;
private final String desc;
private final List<ContractAction> allowedActions;
// 校验某个操作在当前状态下是否允许
public boolean canDo(ContractAction action) {
return allowedActions.contains(action);
}
}
对应ContractService里的审批方法:
java复制@Transactional(rollbackFor = Exception.class)
public void approve(ContractApprovalDTO approvalDTO) {
// 1. 查询合同信息
Contract contract = contractMapper.selectById(approvalDTO.getContractId());
if (contract == null) {
throw new BusinessException("合同不存在");
}
ContractStatus currentStatus = ContractStatus.of(contract.getStatus());
// 2. 校验当前状态是否允许该操作
if (!currentStatus.canDo(approvalDTO.getAction())) {
throw new BusinessException("当前状态不能执行该操作");
}
// 3. 更新合同状态
Contract update = new Contract();
update.setId(contract.getId());
update.setStatus(approvalDTO.getAction().getTargetStatus().getCode());
// 4. 记录审批痕迹
contractApprovalMapper.insert(buildApprovalRecord(contract, approvalDTO));
// 5. 更新主表
contractMapper.updateById(update);
}
这套方案的优点有两个。首先,状态和操作的关系在代码里一目了然,不会因为某个人改了数据库状态值导致逻辑混乱。其次,校验放在Service层统一处理,Controller只需要调用,不会出现接口被绕过的情况。
有人会问:那如果审批要支持多级审批流程怎么办?我的答案是,合同管理系统的复杂度通常用"审批层级"来描述,但如果确实有多级审批需求,可以在contract_approval表里加一个approval_step字段,每次审批记录当前步骤,再在Service层校验当前步骤的审批人和结果。这些都是基于这套轻量状态机的扩展,不一定要引入完整的工作流引擎。
3.3 合同附件上传下载:文件存哪、怎么映射、大小限制
合同管理系统里附件上传下载是最容易被低估的模块。很多人以为就是sout一个文件,实际上坑非常多。
第一,文件存储位置。开发的时候很多人直接存到某个磁盘路径,比如D:/upload/,一部署到Linux服务器就出问题。这套系统里我把存储路径设计成可配置的,放在application.yml里:
yaml复制contract:
file:
upload-path: /data/contract-files/
max-size: 50MB
上传接口的核心逻辑:
java复制@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file,
@RequestParam("contractId") Long contractId) {
// 1. 文件为空校验
if (file.isEmpty()) {
return Result.error("上传文件不能为空");
}
// 2. 校验文件大小(防止绕过Spring配置直接上传大文件)
if (file.getSize() > maxSize) {
throw new BusinessException("文件大小不能超过" + maxSize / (1024 * 1024) + "MB");
}
// 3. 构造存储路径,按年月分目录
String dateDir = new SimpleDateFormat("yyyyMM").format(new Date());
String originalFilename = file.getOriginalFilename();
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
String fileName = UUID.randomUUID().toString().replace("-", "") + extName;
File targetDir = new File(uploadPath + dateDir);
if (!targetDir.exists()) {
targetDir.mkdirs();
}
// 4. 保存文件到本地磁盘
File targetFile = new File(targetDir, fileName);
file.transferTo(targetFile);
// 5. 保存附件记录到数据库
ContractAttachment attachment = new ContractAttachment();
attachment.setContractId(contractId);
attachment.setOriginalName(originalFilename);
attachment.setStoragePath(dateDir + "/" + fileName);
attachment.setFileSize(file.getSize());
attachmentMapper.insert(attachment);
return Result.success(attachment.getId().toString());
}
这里建议用UUID重命名文件,而不是直接使用原始文件名。两个原因:一是原始文件名可能带特殊字符,在不同系统上存储容易出问题;二是如果用户上传两个同名文件,直接覆盖存储就会出现数据丢失的严重事故。
第二,文件访问的静态资源映射。存储路径是在磁盘上的,但用户要通过URL访问下载。这时需要配置资源映射,把请求路径转发到磁盘目录:
java复制@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 将 /files/** 映射到本地的上传目录
registry.addResourceHandler("/files/**")
.addResourceLocations("file:" + uploadPath);
}
这样用户就能通过/files/202506/xxxxxxxx.pdf直接访问文件了。这个配置是WebMvcConfig里最容易漏掉的一环,很多项目部署之后发现上传成功但文件访问404,绝大多数就是这个映射没配。
第三,上传大小限制。SpringBoot默认上传大小只有1MB,超过就报错。需要同时配置spring.servlet.multipart的三个参数:
yaml复制spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 100MB
注意max-request-size要大于等于max-file-size,因为一个请求可能同时上传多个文件。之前有个同事在生产环境上传文件一直报错,排查了半天发现是我只配了单文件大小没配请求总大小。
3.4 合同到期提醒:Quartz定时任务的正确用法
合同到期提醒是合同管理系统里很提气的一个功能。定时任务这玩意儿,写个定时执行的方法不难,难的是如何保证任务可靠执行、不重复执行、不因异常中断。
我用的是Quartz,集成方式在SpringBoot里非常简单。先定义一个任务类:
java复制@Component
public class ContractExpireJob extends QuartzJobBean {
@Autowired
private ContractService contractService;
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
// 扫描未来30天内到期且状态为"已生效"的合同
List<Contract> expireContracts = contractService.listExpiringContracts(30);
if (CollectionUtils.isEmpty(expireContracts)) {
return;
}
// 发送通知:邮件/钉钉/站内信
for (Contract contract : expireContracts) {
noticeService.sendExpireNotice(contract);
}
}
}
然后在配置类里注册触发器和JobDetail:
java复制@Configuration
public class QuartzConfig {
@Bean
public JobDetail contractExpireJobDetail() {
return JobBuilder.newJob(ContractExpireJob.class)
.withIdentity("contractExpireJob")
.storeDurably()
.build();
}
@Bean
public Trigger contractExpireTrigger(@Autowired JobDetail contractExpireJobDetail) {
// 每天凌晨1点执行一次
CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule("0 0 1 * * ?");
return TriggerBuilder.newTrigger()
.forJob(contractExpireJobDetail)
.withIdentity("contractExpireTrigger")
.withSchedule(scheduleBuilder)
.build();
}
}
几个要点说一下。
第一,扫描到期合同要用"今天到未来30天之间"这个条件,而不是"今天到期"那一天才提醒。因为合同到期是个渐进过程,提前30天提醒给业务留出续签时间,那才是用户真正需要的功能。
第二,Quartz默认是单机内存调度,如果在集群环境部署多副本,可能出现同一个定时任务被重复执行的问题。解决思路通常有两种:一是用@SchedulerLock(基于分布式锁)规避;二是配置Quartz的JDBC持久化模式,让集群模式下的任务调度状态存到数据库里。对于中小型项目,单机部署的情况下用默认内存模式就够了。
第三,定时任务一定要做好异常兜底。executeInternal里要用try-catch包住核心业务逻辑,否则一次通知发送失败会导致整个Job中断,后面的合同就全漏掉了。
4. 部署文档怎么写才能真正"拿来就能用"
4.1 部署前的环境清单
很多新手写部署文档就是"第一步装JDK、第二步装MySQL、第三步运行jar",这种文档说了等于没说。真正能让人照着走一遍就成功的部署文档,第一步应该是环境清单。
以这套系统为例,部署前需要确认以下环境:
| 环境项 | 版本要求 | 说明 |
|---|---|---|
| JDK | 1.8+ | SpringBoot 2.7.x要求JDK8及以上 |
| Maven | 3.6+ | 本地构建打包用 |
| MySQL | 5.7+ | 推荐8.0,注意数据库字符集 |
| Redis | 无强制要求,如有缓存需求则需安装 | 本系统可以不用Redis先跑起来 |
| Nginx | 可选 | 生产环境做反向代理和静态资源分发 |
部署文档里必须写清楚这些版本要求,否则用户环境是JDK17,项目用SpringBoot 2.7.x虽然也能兼容,但有些老版本的Lombok插件在JDK17下会直接报InaccessibleObjectException,这会让新手直接卡死。
4.2 多环境配置文件拆分
部署最怕环境差异。开发环境连本地数据库、测试环境连测试库、生产环境连线上库,如果只用一套配置,部署一次改一次代码,非常痛苦。这套系统采用多环境配置文件方案:
application.yml:主配置,存放公共配置(端口、上下文路径、应用名等)application-dev.yml:开发环境,数据源指向本地application-prod.yml:生产环境,数据源指向线上
启动时通过spring.profiles.active指定使用哪个环境:
bash复制java -jar contract-system.jar --spring.profiles.active=prod
生产环境的application-prod.yml核心配置:
yaml复制server:
port: 8080
spring:
datasource:
url: jdbc:mysql://生产数据库IP:3306/contract_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: contract_app
password: 环境变量注入
driver-class-name: com.mysql.cj.jdbc.Driver
druid:
initial-size: 10
min-idle: 5
max-active: 50
max-wait: 60000
contract:
file:
upload-path: /data/contract-files/
注意serverTimezone=Asia/Shanghai和characterEncoding=utf8mb4这两项。前者解决数据库连接时的时区问题,否则可能出现日期差了8小时;后者解决中文乱码问题。
关于数据库密码,强烈建议不要直接写在配置文件里。可以用环境变量注入:
yaml复制spring:
datasource:
password: ${DB_PASSWORD}
启动时传入环境变量即可:
bash复制export DB_PASSWORD=your_strong_password
java -jar contract-system.jar --spring.profiles.active=prod
4.3 两种打包部署路径:jar包与Docker
jar包部署是最传统也最直接的方式。本地开发完成后,在项目根目录执行:
bash复制mvn clean package -DskipTests
生成的target/contract-system.jar就是可运行的产物,上传到服务器后:
bash复制# 前台运行(测试用)
java -jar contract-system.jar --spring.profiles.active=prod
# 后台运行(生产推荐)
nohup java -jar contract-system.jar \
--spring.profiles.active=prod \
--server.port=8080 \
> /app/logs/contract.log 2>&1 &
这里有个小技巧:日志输出路径一定要在启动命令里指定到项目目录,否则默认在jar所在目录的同级生成nohup.out,时间久了文件会非常大,找日志的时候特别费劲。
Docker部署则是另一种更规范化的路径。如果服务器上装了Docker,可以参考下面的Dockerfile:
dockerfile复制# 使用Eclipse Temurin JDK8基础镜像
FROM eclipse-temurin:8-jre
# 设置时区,避免容器内时间差8小时
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 创建应用目录
WORKDIR /app
# 复制jar包到容器内
COPY target/contract-system.jar /app/contract-system.jar
# 暴露8080端口
EXPOSE 8080
# 启动命令
ENTRYPOINT ["java", "-jar", "/app/contract-system.jar", "--spring.profiles.active=prod"]
构建Docker镜像并运行:
bash复制docker build -t contract-system:1.0.0 .
docker run -d \
--name contract-system \
-p 8080:8080 \
-v /data/contract-files:/data/contract-files \
-e DB_PASSWORD=your_strong_password \
contract-system:1.0.0
这里需要特别提醒:-v /data/contract-files:/data/contract-files这个挂载卷一定要加。文件上传如果写到容器内部,容器一删文件就全没了。把宿主机的/data/contract-files目录挂载进容器,才能保证文件持久化。这个坑我见过很多次,文件存了,容器一升级,所有合同附件全部丢失,相当惨痛。
4.4 数据库初始化流程
部署文档里数据库初始化这部分也要写清楚。默认提供一个docs/sql/init.sql,包含建库、建表、初始化用户和字典数据的语句。如果后续有表结构变更,建议用数据库迁移工具管理,比如Flyway或Liquibase。
对于这套系统,最简单可靠的方式是在部署文档中说明:
- 创建数据库:
CREATE DATABASE contract_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; - 导入初始数据:
mysql -u用户名 -p contract_db < docs/sql/init.sql - 检查初始账号:默认管理员账号admin/123456(首次登录后必须强制改密)
部署文档里把这三步写清楚,基本可以避免大部分数据库相关的部署问题。账号初值这一点也要写明白,不然用户启动后登录不了,排查半天发现是不知道初始密码。
5. 阅读源码的正确顺序与排错实录
5.1 读源码的推荐顺序
把代码讲清楚,不是从Controller开始逐个文件过一遍,而是按"启动->配置->数据->业务"的顺序来读,效率最高。
第一步看启动类ContractApplication.java。整个类就几行,关键是上面的@SpringBootApplication注解,它背后是自动配置的入口。看不懂自动配置没关系,先记住这个类是SpringBoot项目的起点。
第二步看配置文件。application.yml、application-dev.yml、application-prod.yml,把数据源、端口、文件路径这些配置项过一遍。配置文件是一个项目的"说明书",先知道项目连了什么数据库、启动了哪些组件,后面读代码才不慌。
第三步看实体类Contract.java和Mapper层。实体类对应数据库表结构,看完就知道系统有哪些核心数据。Mapper接口本身代码不多,但要注意MyBatis-Plus的BaseMapper提供了哪些内置方法,以及哪些方法是自己写的SQL。
第四步看Service层。Service是业务逻辑最集中的地方,比如审批状态流转、合同列表分页查询、定时扫描到期合同。这一步要慢下来,结合我前面讲的状态机设计,理解每一步为什么这么写。
第五步看Controller层。Controller一般最简单,接收参数、调用Service、返回Result。到了这一步就可以把整个请求链路串起来了。
按照这个顺序读全套源码,基本上两天可以过完。如果反过来先从Controller看起,很容易陷入"每个接口都看懂了一点、但整体业务逻辑串不起来"的状态。
5.2 常见报错与排查链路
下面把这份源码运行过程中最容易踩到的几个报错整理出来,全部是我实际遇到过并且逐一排查过的。
| 报错场景 | 错误信息示例 | 排查链和解决方案 |
|---|---|---|
| 端口被占用 | Port 8080 was already in use. |
用`netstat -tlnp |
| 数据库连接失败 | Access denied for user 'root'@'localhost' |
检查用户名/密码是否配置对;如果是MySQL8还注意allowPublicKeyRetrieval=true参数 |
| 登录时密码错误 | There is no PasswordEncoder mapped for the id "null" |
Spring Security的密码编码问题,确认用户表的密码是BCrypt加密格式,注册接口要用BCryptPasswordEncoder |
| 分页不生效 | 返回所有数据而非分页后的数据 | 确认MybatisPlusInterceptor注册了PaginationInnerInterceptor,不要遗漏 |
| 静态资源404 | No resource found |
确认WebMvcConfig里的addResourceHandlers是否正确映射到本地磁盘路径 |
| 循环依赖报错 | The dependencies of some of the beans in the application context form a cycle |
SpringBoot 2.6+默认禁用了循环依赖,检查Service之间的互相注入关系。一般通过拆Service或加@Lazy解决,但最好重构代码消除循环依赖 |
| 大文件上传超时 | 上传请求一直转圈然后报超时 | 除了配置multipart大小限制,还要确认Nginx的client_max_body_size是否也调大了 |
| 时区问题 | 查出来的日期比实际时间早8小时 | 检查数据库连接URL是否加了serverTimezone=Asia/Shanghai,还有JVM时区是否一致 |
重点说说循环依赖报错。SpringBoot 2.6版本之后默认禁止循环依赖,很多老项目升级后启动直接报错。这类报错出现在两个Service互相注入的场景,比如ContractService依赖NoticeService,而NoticeService又依赖ContractService。处理方式有三种:拆Service把公共逻辑抽出来;用@Lazy注解延迟注入;把循环依赖的一方改成通过ApplicationContext动态获取Bean。大部分情况下我建议第一种,代码结构清晰才是长期维护的基础。
5.3 如何在此基础上继续扩展
这套系统的可扩展点非常明确,如果你学了基础之后想进阶,下面是几个方向:
第一,把文件存储从本地磁盘切换为云存储,比如阿里云OSS、腾讯云COS。代码层只需要把AttachmentService里上传文件那段逻辑替换为调用云SDK,数据库里存URL而不是磁盘路径。这样可以支撑大文件、跨地域访问。
第二,引入工作流引擎。如果审批流程真的复杂到需要多级审批、加签、会签,可以学习Activit或Flowable,把合同审批从轻量状态机升级为完整工作流。contract_approval表里的数据可以平滑迁移。
第三,对接企业微信或钉钉。到期提醒目前是站内信/邮件形式,可以改为通过企业微信群机器人推送,在NoticeService里加一个方法,把到期合同列表拼成Markdown消息通过Webhook推给指定群。
第四,加入电子签章。合同管理系统的最后一环是拿到签署完成的电子合同,这需要对接第三方电子签章平台的API,属于商业级功能,但核心的合同信息管理逻辑完全可以复用现有的表结构。
“这套SpringBoot合同信息管理系统,从开发到现在已经跑了一年多,陆陆续续改了很多版。我自己最大的体会是:项目本身不难,难得是把状态流转、文件存储、定时任务这些细节想清楚,并且能把部署文档写到别人照着做就能跑起来的程度。源码和文档的价值不仅仅在代码本身,而在于你遇到问题时知道去哪找、找完能对照排查。如果你拿到这套系统准备跑通或者继续改造,先照着部署文档过一遍,再按我上面推荐的顺序读一遍代码,遇到报错回到第5节的排查表对照,这一步不跳过,你学到的东西会比单纯看十篇教程都多。”
