目录
前言
此前撰写了一篇阐述合同管理模块设计思路的文章,此次打算换个视角,与大家分享开发过程中遭遇的那些饶有趣味的 bug。
说实话,在负责这个项目期间,排查问题的经历相较于编写新功能,给我留下了更为深刻的印象。有些问题一目了然,而有些则耗费了我好几个夜晚才寻得根源。将这些记录下来,不仅是为自己留存一份备忘,也期望后来者能够少走些弯路。
初期开发日志
一、数据关联与删除逻辑
在删除合同时,需要同步删除对应的 task,并且仅存在"删除 task 连带删除合同"的单向联动关系。由于 task 与合同均采用软删除机制,所以无需额外处理复杂的关联逻辑,后续统一清理软删除数据即可。例如,就好比图书馆中,当一本书被标记为"下架(软删除)"时,对应的借阅记录(类似 task)也会跟着被"下架",但不需要额外去处理它们之间复杂的关系,之后定期清理这些"下架"的数据就行。
二、接口与字段优化
在编写更新接口参数时,同步精简了两张表的重复字段。此次优化属于较大改动,耗时 1 个多小时。在这个过程中,暴露出了 DDD 架构的弊端:底层改动会引发上层代码大面积调整。本质上,这是由于初期规划不够周全,从而导致返工成本增加。打个比方,这就像建造一座房子,在建造过程中突然发现地基的某个设计不合理,需要改动,结果就可能导致上面的楼层很多地方都要跟着调整,而这就是因为一开始规划房子的时候不够仔细全面。
三、创建流程调整与架构反思
1. 流程修正
原 contractTask 创建逻辑(先实例化 contract 再创建 task)与实际业务不符。真实流程为:上传文件→用文件 uuid 生成 contract→创建 contractTask。基于此,需要调整 contract 创建接口,将入参改为文件 uuid,contractName 可通过 uuid 查询获取,同时需对外开放更新接口。
2. 架构合理性考量
为实现模块解耦拆分了 contract 类,但实际业务中 task 与 contract 耦合度极高。反思后认为初期将 contract 整合进 task 可能更合理。不过目前架构已成型,暂按现有方案推进。这就好像把原本紧密相连的两个零件(task 和 contract)拆开了,后来发现它们其实联系非常紧密,一开始不拆开可能更好,但现在已经组装成一个大结构了,只能先按这个来。
四、基础功能落地
已完成对应 Feign 接口暴露,至此 task 与 contract 的增删改查基础功能全部实现,下一步核心工作为业务流程组装。
五、条款抽取流程规划
1. 核心流程梳理
进入条款抽取环节前,先明确整体流程:判断合同类型→抽取条款→配置规则与提示词→提交审查。该过程本质是补全 contractTask 与 contract 的缺失参数,确保流程闭环。
2. 实现逻辑与配套接口
合同创建后将自动触发条款抽取,通过向大模型传入预置提示词、合同文本(或 PDF),将模型返回结果反序列化后入库。配套开发两类接口:
- 条款抽取状态查询接口 :入参为
contractId,出参为当前抽取进度状态; - 日志查询接口:集成条款抽取相关日志,便于问题排查与追溯。
开发过程中遇到的问题
这个模块主要是业务问题,所以技术上能展示的内容并不多。
1. Long传到前端浏览器精度丢失
我直接采用 16 位主键避免溢出,因为项目体量并不需要过大的主键。倘若真有此需求,改造一下也并非不可。
若依解决方案
以下代码是若依框架中针对此问题的 Jackson 配置,用于解决 Long 类型数据传到前端浏览器精度丢失的问题,通过将 Long 类型序列化为 String 类型来避免精度问题。
java
package com.ruoyi.common.security.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
/**
* Jackson配置
*
* @author ruoyi
*/
@Configuration
public class JacksonConfig {
@SuppressWarnings("deprecation")
@Bean
public MappingJackson2HttpMessageConverter jackson2HttpMessageConverter() {
final Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.serializationInclusion(JsonInclude.Include.NON_NULL);
final ObjectMapper objectMapper = builder.build();
SimpleModule simpleModule = new SimpleModule();
// Long 转为 String 防止 js 丢失精度
simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
objectMapper.registerModule(simpleModule);
// 忽略 transient 关键词属性
objectMapper.configure(MapperFeature.PROPAGATE_TRANSIENT_MARKER, true);
return new MappingJackson2HttpMessageConverter(objectMapper);
}
}
2. JSONB类型的那些事
问题表现
这是最早遇到的一批问题,主要集中在数据存储和读取环节。当时刚把 PostgreSQL 的 JSONB 类型引入项目,用于存储合同相关的复杂配置信息。最开始的情况是:存进去的数据结构完整,读出来的时候某些字段就丢失了。更麻烦的是,这个问题并非每次都会复现,时而运行正常,时而就会出现字段缺失。当时为了定位问题,在代码里添加了各种日志,打印转换前后的 JSON 字符串。最后发现是 Jackson 在反序列化的时候,如果没有明确的类型信息,就会跳过某些字段。这就好比你把东西放进一个有很多格子的箱子(数据库),取出来的时候发现有些东西(字段)不见了,而且有时候又能全部取出来,后来发现是因为取东西的规则(反序列化)不太明确,导致有些东西被忽略了。
第一次尝试:通用TypeHandler
一开始想着开发一个通用的 JSONB TypeHandler,用 Jackson 直接把对象转成 JSON 字符串存进去。以下是代码示意,此代码为伪代码,主要展示思路。
java
// 伪代码示意
public class JsonbTypeHandler extends BaseTypeHandler<Object> {
private ObjectMapper mapper = new ObjectMapper();
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Object param, JdbcType jdbcType) throws SQLException {
ps.setString(i, mapper.writeValueAsString(param));
}
@Override
public Object getNullableResult(ResultSet rs, String columnName) throws SQLException {
String json = rs.getString(columnName);
return mapper.readValue(json, Object.class);
}
}
问题在于,Object.class 这个类型太宽泛了。反序列化的时候 Jackson 不知道要转成什么类型,只能按 LinkedHashMap 来处理,结果就是类型信息丢失了。
解决方案:专用TypeHandler
后来意识到,既然每个领域对象的结构都不一样,不如为每个类单独编写一个 TypeHandler。虽然代码量有所增加,但类型安全能够得到保障。例如针对 OperationDetails 这个值对象,专门编写了一个 OperationDetailsTypeHandler。
java
public class OperationDetailsTypeHandler extends BaseTypeHandler<OperationDetails> {
private ObjectMapper mapper = new ObjectMapper();
@Override
public void setNonNullParameter(PreparedStatement ps, int i, OperationDetails param, JdbcType jdbcType) throws SQLException {
ps.setString(i, mapper.writeValueAsString(param));
}
@Override
public OperationDetails getNullableResult(ResultSet rs, String columnName) throws SQLException {
String json = rs.getString(columnName);
return mapper.readValue(json, OperationDetails.class);
}
}
这样 Jackson 就明确知道要反序列化成什么类型了,字段信息也就能完整保留。为了验证修复效果,当时还编写了专门的单元测试,模拟各种边界情况------空值、复杂嵌套、特殊字符等等。测试通过之后,这批问题算是彻底解决了。
乐观锁更新失效之谜
问题发现
这个问题是在一个周五的下午被察觉到的。当时正在进行并发测试,模拟多个用户同时编辑同一个合同。预期行为是:第一个用户的更新能够成功,后续的用户应该收到版本冲突的错误提示。但实际测试时,所有更新均成功了,而且后续的更新直接覆盖了前面的数据。当时首先怀疑的是乐观锁配置存在问题。检查了数据库表结构,version 字段确实存在,并且默认值为 1。再查看 MyBatis 的配置,乐观锁插件也已启用。
排查过程
为找出问题根源,编写了一个简单的测试用例,以下为伪代码示意。
java
// 伪代码示意
Contract contract1 = contractRepository.findById(id);
Contract contract2 = contractRepository.findById(id);
contract1.updateStatus(NEW_STATUS);
contractRepository.save(contract1);
contract2.updateStatus(ANOTHER_STATUS);
contractRepository.save(contract2); // 这里应该抛异常,但没有
通过调试发现,第二次 save 的时候,SQL 语句里的 WHERE 条件确实携带了 version,但这个 version 的值与数据库里实际的并不一致。继续深入追查,发现是在更新操作之前,实体的 version 字段没有被正确递增。MyBatis Plus 的乐观锁机制依赖于实体对象中的 version 字段,如果这个字段没有更新,乐观锁就会失效。
根因分析
问题出在对象转换的逻辑上。我们的代码层次较为复杂,涉及 Domain Model→Application DTO→Infrastructure Entity,每一层都存在转换逻辑。在某些转换器中,直接使用了 BeanUtils.copyProperties() 这样的工具方法,结果导致把"旧"的 version 值也复制过去了。从数据库查出来的对象 version 是 2,但转换后的 entity version 又变回了 1。
修复方案
最终的解决方案是在所有 Repository 实现类中,显式地处理 version 字段,以下为伪代码示意。
java
// 伪代码示意
public void update(Contract contract) {
ClauseEntity entity = converter.toEntity(contract);
// 确保使用数据库中的当前版本
ClauseEntity existing = mapper.selectById(entity.getId());
if (existing != null) {
entity.setVersion(existing.getVersion());
}
mapper.updateById(entity); // MyBatis Plus 会自动递增 version
}
如此一来,在更新之前,先从数据库读取当前的 version 值,确保乐观锁能够正常工作。修复之后,之前的测试用例终于能够按预期运行------第一个更新成功,第二个更新抛出了 OptimisticLockerException。如果有更好的方式,希望能不吝赐教!
操作日志记录不完整
问题背景
合同管理系统的一项重要功能是记录操作日志,每次状态变更都需留下记录。该功能上线一段时间后,在一次排查中发现有些操作无法查到日志。
定位问题
首先到数据库中进行查询,发现确实存在部分操作没有对应的日志记录。然而代码逻辑看似并无问题,每次更新之前都会调用日志服务记录操作。添加了一些日志后发现,有时候日志记录的方法被调用了,但数据库里却查不到数据。进一步追查发现,是因为事务回滚了,连带着日志也被回滚了。当时的代码逻辑大致如下。
java
@Transactional
public void updateContract(UpdateCommand command) {
// 1. 更新合同
contractRepository.update(contract);
// 2. 记录操作日志
operationLogDomainService.recordLog(log);
// 3. 如果这里抛异常,上面两步都会回滚
doSomethingElse();
}
如果步骤 3 出现异常,整个事务回滚,日志也就不存在了。这就好比你在记账(记录日志),但是因为某个事情没做好(步骤 3 异常),之前记的账都不算数了(日志被回滚)。
解决思路
这个问题本质上是事务边界不清晰。日志记录不应该与业务操作处于同一个事务中,即便业务操作失败,日志也应当保留下来。重构时将日志记录放到了事务外面,代码如下。
java
public void updateContract(UpdateCommand command) {
OperationLog operationLog = null;
try {
// 业务操作在事务内
transactionTemplate.execute(status -> {
contractRepository.update(contract);
operationLog = prepareOperationLog(contract);
return null;
});
// 日志记录在事务外
if (operationLog != null) {
operationLogDomainService.recordLog(operationLog);
}
} catch (Exception e) {
// 即使失败也记录日志
if (operationLog != null) {
operationLog.markAsFailed(e.getMessage());
operationLogDomainService.recordLog(operationLog);
}
throw e;
}
}
如此修改后,即便业务操作失败,操作日志也能正常记录,便于后续排查问题。
自增ID的陷阱
问题现象
这个 bug 表现得颇为诡异:新建的记录时而能正常保存,时而会报主键冲突错误。而且错误信息提示的 ID 与我们代码里设置的 ID 并不相同。
原因分析
flyway 插入初始数据的时候没有考虑主键序列。PostgreSQL 的自增序列和表的字段是两个相互独立的部分。当我们手动插入数据并指定 ID 时,数据库并不会自动更新序列的当前值。例如,序列的当前值为 100,但手动插入了一条 ID 为 200 的数据。下次让序列自动生成 ID 时,它会从 101 开始,而非 201。如果序列生成的 ID 与手动插入的 ID 发生冲突,就会报主键冲突错误。在我们的场景中,某些测试数据需要指定 ID,从而导致序列和实际数据不同步。这就好像你有一个计数的本子(序列),和一个放东西的盒子(表),你往盒子里放东西的时候自己标了个序号(手动指定 ID),但本子的计数没跟上,下次本子计数的时候就和你标的序号乱套了。
解决方案
针对这个问题有几种解决思路:
- 避免手动指定ID :让数据库完全接管
ID生成; - 同步序列值 :手动插入后,把序列的当前值调整到最大
ID; - 使用ID生成器 :引入分布式
ID生成方案。
我们采用的是第一种方案,在代码层面确保不手动设置 ID,以下为伪代码示意。
java
// 伪代码示意
public ClauseExtraction createExtraction(ExtractionRequest request) {
ClauseExtraction extraction = new ClauseExtraction();
// 不再手动设置 ID,让数据库自动生成
// extraction.setId(new ExtractionId(UUID.randomUUID().toString())); // 删除这行
extraction.setContractId(request.getContractId());
// ...
return extractionRepository.save(extraction);
}
对于确实需要指定 ID 的测试场景,编写了一个单独的工具方法在测试结束后同步序列值,如下为 SQL 语句。
sql
-- 测试清理时执行
SELECT setval('clause_extraction_id_seq', (SELECT MAX(id) FROM clause_extraction));
状态流转的边界问题
问题表现
有个功能是支持合同状态的流转,例如从草稿到生效,从生效到终止。用户反馈称在某些状态下无法执行预期的操作。
排查过程
查看代码后发现,状态校验的逻辑写得较为僵化,如下为伪代码示意。
java
// 伪代码示意
if (contract.getStatus() == ContractStatus.DRAFT) {
// 允许某些操作
} else if (contract.getStatus() == ContractStatus.ACTIVE) {
// 允许另一些操作
}
这样的代码存在几个问题:
- 新增状态时需要修改所有
if - else逻辑; - 状态之间的转换规则不清晰,散落在各处;
- 容易遗漏某些边界情况的处理。
优化方案
后来进行了一次重构,引入了状态机模式。每个状态都定义了允许的转换,以及转换时需要执行的逻辑,如下为伪代码示意。
java
// 伪代码示意
public enum ContractStatus {
DRAFT {
@Override
public boolean canTransitionTo(ContractStatus target) {
return target == ACTIVE || target == TERMINATED;
}
},
ACTIVE {
@Override
public boolean canTransitionTo(ContractStatus target) {
return target == EXPIRED || target == TERMINATED;
}
},
//...
}
这样状态转换的规则就集中在每个状态的枚举值中,维护起来方便很多。
几点反思
回顾这些 bug 的排查过程,有几条经验值得总结:
1. 类型安全很重要
JSONB 类型的问题,归根结底是因为弱类型导致的。如果一开始就定义了明确的类型,很多问题就不会出现。这也让我更加坚信,在业务系统中,类型安全带来的收益远大于它的成本。就像你给每个物品都贴上准确的标签(明确类型),找东西(处理数据)的时候就会更顺利,不容易出错。
2. 事务边界要清晰
日志记录那件事给我上了一课。在设计事务边界的时候,要搞清楚哪些操作必须在同一个事务里,哪些可以独立执行。把所有东西都塞到一个大事务里,反而会带来问题。这好比规划一次旅行(事务),要明确哪些活动(操作)必须一起进行,哪些可以分开,不然都挤在一个行程安排里,可能会出乱子。
3. 工具要慎用
像 BeanUtils.copyProperties() 这样的工具确实能减少代码量,但它隐藏了很多细节。在关键的业务逻辑中,显式地写出字段转换逻辑,虽然繁琐一点,但出问题的时候容易排查。就如同走一条有隐藏陷阱的捷径(使用工具方法),看似快,但万一出问题不好找原因,而走大路(显式写逻辑)虽然慢点,但出问题能清楚是哪段路的问题。
4. 测试要覆盖边界
很多 bug 都是在边界场景下暴露出来的。写测试的时候不能只考虑"正常路径",各种异常情况、并发场景、边界值,都应该有对应的测试用例。比如测试一辆车,不能只看它在平坦大道上行驶,还要看看在坑洼路面(边界情况)、多车并行(并发场景)等情况下的表现。
5. 日志是救命稻草
每次遇到问题的时候,最感谢的就是之前打的那些日志。虽然写日志的时候觉得麻烦,但真出问题的时候,它就是最直接的线索来源。这就像探险家在森林里留下的标记,迷路(遇到问题)时靠这些标记找到方向。
写在最后
这篇文章记录的都是在合同管理模块开发过程中真实遇到的 bug。每个问题都有它的特殊性,解决问题的思路也各不相同。
但回过头来看,这些问题的根因往往都能追溯到一些基本的设计原则:类型安全、事务边界、状态管理、工具使用的恰当性。把这些基础打好,很多问题就能在萌芽阶段就被消灭。
希望这些经验能对正在做类似系统的你有所帮助。如果你也遇到过类似的问题,或者有更好的解决方案,欢迎交流。