在这里插入图片描述

接手 Spring Boot 老项目时,真正麻烦的往往不是编译报错,而是分散在业务代码中的空指针、N+1 查询、异常吞没和并发隐患。本文记录一次使用 Claude Code 辅助代码 Review 的完整流程,并给出提示词、Java 示例、修改方案和人工复核方法。


项目能运行,不代表代码没有问题

前段时间接手一个维护多年的 Spring Boot 项目,编译正常,接口也能访问,但团队里几乎没人敢轻易修改核心模块。

原因并不复杂。这个项目经历过多轮需求迭代,同一个业务规则散落在 Controller、Service、定时任务和消息消费者中。修改一个判断条件,可能影响另一个几个月没人碰过的流程。

更麻烦的是,项目里的大部分问题不会在编译阶段暴露。空指针只有遇到特殊数据才会出现,N+1 查询要等数据量增加后才会变慢,并发问题甚至可能只在线上高峰期偶发。

我决定先用 Claude Code 做一轮辅助代码 Review,但没有让它直接修改项目,而是要求它完成三件事:

  • 理清项目结构和核心调用关系;
  • 找出有明确代码证据的风险;
  • 按严重程度生成待人工确认的问题清单。

这次排查最终找出了四类值得处理的问题。


在这里插入图片描述

不要直接输入“帮我检查整个项目”

刚开始使用 AI 做代码审查时,很多人会输入一句:

帮我检查一下这个项目有哪些问题。

这样的任务范围太大,输出结果通常会变成一组正确但没有执行价值的建议:

建议完善异常处理
建议增加单元测试
建议优化数据库查询
建议提高代码可读性

这些建议放在任何 Java 项目中都成立,却不能直接告诉开发者应该修改哪个文件、什么情况下会出错。

我后来把任务拆成了两轮。

第一轮:只理解项目,不修改代码

请读取当前 Spring Boot 项目的目录结构、pom.xml 和主要配置文件。

需要输出:
1. 项目使用的主要技术栈;
2. Controller、Service、Mapper、定时任务和消息消费者的分布;
3. 核心业务模块及依赖关系;
4. 可能涉及数据库写入、事务和外部接口调用的模块。

暂时不要修改任何文件,也不要输出通用优化建议。

这一步的目的不是发现 Bug,而是检查 AI 有没有理解项目。

如果它连核心模块、技术栈和调用关系都判断错了,后面的代码审查结果也很难可信。

第二轮:按风险类型逐项扫描

请对当前项目进行第一轮代码风险审查,暂时不要修改代码。

重点检查:
1. 可能出现空指针异常的位置;
2. 循环内执行数据库查询的代码;
3. 捕获异常后没有记录、没有转换、没有继续抛出的代码;
4. 单例对象中共享非线程安全变量的代码;
5. 文件流、数据库连接或线程池未正确关闭的问题。

每个问题必须包含:
- 严重级别
- 文件路径
- 类名和方法名
- 相关代码
- 触发条件
- 可能影响
- 修改建议

无法确认的问题请标记为“需要人工验证”,不要直接下结论。

任务被限制后,AI 输出的内容会更像一份可以执行的代码审查报告,而不是泛泛而谈的项目建议。


问题一:测试数据正常,历史数据却可能触发空指针

项目中有一个订单详情查询方法:

public OrderVO getOrderDetail(Long orderId) {
    Order order = orderMapper.selectById(orderId);

    OrderVO vo = new OrderVO();
    vo.setId(order.getId());
    vo.setUserName(order.getUser().getName());

    return vo;
}

这段代码在测试环境里一直没有报错,因为测试订单都有对应的用户数据。

但生产环境中存在部分历史订单。用户信息已经被清理,订单记录仍然保留。这时 order.getUser() 可能返回 null

此外,代码还存在另外两个风险:

  • 调用方可能传入空的 orderId
  • 数据库中可能不存在对应订单。

可以先把参数和查询结果处理清楚:

public OrderVO getOrderDetail(Long orderId) {
    if (orderId == null) {
        throw new IllegalArgumentException("orderId must not be null");
    }

    Order order = orderMapper.selectById(orderId);
    if (order == null) {
        throw new OrderNotFoundException(
                "Order does not exist, orderId=" + orderId
        );
    }

    OrderVO vo = new OrderVO();
    vo.setId(order.getId());

    User user = order.getUser();
    vo.setUserName(user == null ? "未知用户" : user.getName());

    return vo;
}

这段修改只能作为结构示例,不能不看业务规则直接复制。

当关联用户不存在时,有些系统允许订单继续展示,只把用户名标记为“未知用户”;有些系统则需要把订单识别为异常数据,并通知运营人员修复。

AI 可以发现空指针风险,却无法只凭一个方法确定最终业务语义。这类问题必须由熟悉订单规则的人确认。


问题二:列表接口里隐藏着 N+1 查询

订单列表接口中还有一段常见写法:

List<Order> orders = orderMapper.selectByCondition(condition);
List<OrderVO> result = new ArrayList<>();

for (Order order : orders) {
    User user = userMapper.selectById(order.getUserId());

    OrderVO vo = new OrderVO();
    vo.setOrderId(order.getId());
    vo.setUserName(user == null ? "" : user.getName());

    result.add(vo);
}

代码逻辑很直观,却会随着订单数量增加产生大量 SQL。

假设列表查询返回 100 条订单,程序会执行:

1 次订单列表查询
+
100 次用户查询
=
101 次数据库查询

本地测试数据只有几条时,接口响应可能没有明显异常。生产环境数据增加后,数据库连接占用和接口延迟都会上升。

一种处理方式是先收集用户 ID,再批量查询:

List<Order> orders = orderMapper.selectByCondition(condition);

Set<Long> userIds = orders.stream()
        .map(Order::getUserId)
        .filter(Objects::nonNull)
        .collect(Collectors.toSet());

Map<Long, User> userMap;

if (userIds.isEmpty()) {
    userMap = Collections.emptyMap();
} else {
    userMap = userMapper.selectBatchIds(userIds)
            .stream()
            .collect(Collectors.toMap(
                    User::getId,
                    Function.identity(),
                    (oldValue, newValue) -> oldValue
            ));
}

List<OrderVO> result = orders.stream()
        .map(order -> {
            OrderVO vo = new OrderVO();
            vo.setOrderId(order.getId());

            User user = userMap.get(order.getUserId());
            vo.setUserName(user == null ? "" : user.getName());

            return vo;
        })
        .collect(Collectors.toList());

这样可以把查询数量从 1+N 降到两次:

1 次订单列表查询
+
1 次用户批量查询

另一种方案是直接使用关联查询:

SELECT
    o.id AS order_id,
    o.user_id,
    u.name AS user_name
FROM orders o
LEFT JOIN users u
    ON u.id = o.user_id
WHERE o.status = #{status}
ORDER BY o.created_at DESC;

不能看到 N+1 就机械地改成 JOIN。

当关联表字段较多、查询条件复杂,或者业务需要分别缓存数据时,批量查询后在内存中组装可能更容易维护。最终方案还要结合分页方式、索引设计、数据规模和数据库执行计划判断。


问题三:异常被捕获了,但调用方完全不知道任务失败

文件导入模块中有一段代码:

public void importFile(Path filePath) {
    try {
        InputStream inputStream = Files.newInputStream(filePath);
        parseFile(inputStream);
    } catch (Exception e) {
        System.out.println("导入失败");
    }
}

这里不只是日志不规范,还同时存在两个问题。

一是 InputStream 没有明确关闭。二是异常被捕获后没有继续抛出,调用方会误以为导入已经成功。

可以改成:

public void importFile(Path filePath, String taskId) {
    try (InputStream inputStream = Files.newInputStream(filePath)) {
        parseFile(inputStream);
    } catch (FileFormatException e) {
        log.warn(
                "File format is invalid, taskId={}, filePath={}, reason={}",
                taskId,
                filePath,
                e.getMessage()
        );

        throw e;
    } catch (IOException e) {
        log.error(
                "Failed to read import file, taskId={}, filePath={}",
                taskId,
                filePath,
                e
        );

        throw new FileImportException("读取导入文件失败", e);
    } catch (Exception e) {
        log.error(
                "Unexpected import error, taskId={}, filePath={}",
                taskId,
                filePath,
                e
        );

        throw new FileImportException("文件导入失败", e);
    }
}

try-with-resources 可以确保当前方法创建的流被正确关闭,异常也会继续传递给上层。

不过,异常处理还要避免另一个极端:每一层都打印一次完整堆栈。

如果底层 Service、任务调度层和全局异常处理器都记录同一个异常,日志中可能出现三条几乎相同的报错。更合理的做法是在拥有业务上下文的位置记录日志,并明确哪一层负责最终异常输出。


问题四:Spring 单例 Service 中共享 HashMap

价格服务中使用了一个成员变量保存计算结果:

@Service
public class PriceService {

    private final Map<Long, BigDecimal> priceCache = new HashMap<>();

    public BigDecimal calculate(Long productId) {
        if (priceCache.containsKey(productId)) {
            return priceCache.get(productId);
        }

        BigDecimal price = queryPrice(productId);
        priceCache.put(productId, price);

        return price;
    }

    private BigDecimal queryPrice(Long productId) {
        // 查询数据库或调用外部服务
        return BigDecimal.ZERO;
    }
}

Spring 默认创建的 Service 通常是单例对象,多个请求会同时访问同一个 priceCache

HashMap 不是线程安全容器,containsKeygetput 也不是一个完整的原子操作。并发量上升后,可能出现重复计算、缓存状态异常或不可预测的读取结果。

基础修改可以使用 ConcurrentHashMap

@Service
public class PriceService {

    private final ConcurrentMap<Long, BigDecimal> priceCache =
            new ConcurrentHashMap<>();

    public BigDecimal calculate(Long productId) {
        if (productId == null) {
            throw new IllegalArgumentException(
                    "productId must not be null"
            );
        }

        return priceCache.computeIfAbsent(
                productId,
                this::queryPrice
        );
    }

    private BigDecimal queryPrice(Long productId) {
        BigDecimal price = loadPriceFromDatabase(productId);

        if (price == null) {
            throw new PriceNotFoundException(
                    "Price does not exist, productId=" + productId
            );
        }

        return price;
    }

    private BigDecimal loadPriceFromDatabase(Long productId) {
        // 根据实际项目实现
        return BigDecimal.ZERO;
    }
}

这只能解决基础线程安全问题,还不算一套完整缓存方案。

在这里插入图片描述

继续往下排查时,需要确认:

  • 缓存是否需要过期时间;
  • 商品价格更新后如何主动失效;
  • 缓存数量是否可能无限增长;
  • 查询异常时是否允许缓存旧值;
  • 多实例部署后是否要求缓存一致;
  • 是否应该使用 Caffeine、Redis 等专业缓存方案。

AI 能发现“单例 Service 共享 HashMap”这个风险,但无法自动确定系统需要本地缓存还是分布式缓存。


我最终采用的代码 Review 工作流

为了避免 AI 一次性修改大量代码,我把整个流程拆成六个阶段。

读取项目结构

识别核心模块

按风险类型扫描

生成问题清单

人工确认业务语义

小范围修改

测试与 Git Diff 检查

读取项目结构

先分析目录、依赖、配置和模块关系,不急着找 Bug。

这一阶段需要确认 AI 是否识别出项目使用了 Spring Boot、MyBatis、消息队列、定时任务或其他关键组件。

按风险类型分批检查

空指针、SQL 性能、异常处理、并发安全和资源释放最好分开检查。

任务越具体,越容易获得包含文件位置、触发条件和修改建议的结果。

要求提供代码证据

没有文件路径、类名、方法名和相关代码的问题,不应该直接进入修改清单。

一条可执行的审查结果应该接近下面的格式:

严重级别:高

文件:
src/main/java/com/example/service/PriceService.java

方法:
calculate

问题:
Spring 单例 Service 中共享使用 HashMap

触发条件:
多个请求同时查询不同或相同商品价格

可能影响:
重复计算、缓存状态异常、并发读取结果不可预测

建议:
根据缓存生命周期评估 ConcurrentHashMap、Caffeine 或 Redis

人工确认业务语义

人工复核不只是检查 AI 有没有看错代码,还要确认修改是否符合真实业务。

订单、库存、金额、权限、事务和状态流转等模块,尤其不能只根据局部代码直接修改。

每次只修一类问题

不要同时处理空指针、SQL、缓存和异常体系。

一次只改一种问题,Git Diff 更容易检查,出现回归时也更容易定位原因。

使用测试验证结果

至少需要执行:

mvn clean test

如果项目区分单元测试和集成测试,还应根据项目配置运行对应命令。

代码通过编译不等于业务正确。涉及数据库、消息队列和外部接口时,还需要在测试环境验证真实调用链。


Claude Code、ChatGPT 和 Cursor 应该怎么配合

这三类工具在开发流程中的作用并不完全相同。

Claude Code 更适合读取项目目录、分析跨文件调用关系、整理风险清单和处理需要较长上下文的代码任务。

ChatGPT Plus 更适合解释代码逻辑、讨论重构方案、生成测试思路,以及把复杂问题拆成容易执行的排查步骤。

Cursor Pro 更贴近编辑器工作流,适合在具体文件中定位代码、完成局部修改、查看上下文和检查代码差异。

实际使用时,我更倾向于把任务拆开:

Claude Code:
项目级分析和大范围问题扫描

ChatGPT:
问题解释、方案讨论和测试设计

Cursor:
文件级修改和编辑器内操作

开发者:
业务判断、代码复核和最终决策

工具之间不需要强行选出唯一答案。关键在于让每个工具负责它擅长的环节,而不是把整个项目交给单一工具自动处理。


ChatGPT Plus、Claude Pro、Cursor 等会员充值需求怎么处理

当 ChatGPT、Claude、Cursor 等工具逐渐进入日常开发流程后,开发者除了需要比较功能,还会遇到会员订阅和充值问题。

如果你有 ChatGPT Plus充值、Claude Pro订阅、Cursor会员充值、Grok订阅、Gemini Advanced会员或Kiro订阅 等需求,可以了解 gpt108.com。它是第三方 AI会员充值平台,覆盖 ChatGPT Plus、Claude Pro、Grok、Gemini Advanced、Cursor、Kiro 等 AI 会员和工具充值需求。

gpt108.com 解决的是 AI 会员订阅充值流程问题,不替代 ChatGPT、Claude、Cursor 等工具本身,也不是 OpenAI、Anthropic、Google、xAI、Cursor 或 Kiro 的官方网站及授权合作方。使用前建议看清套餐说明、账号要求和售后规则,再结合自己的实际使用频率选择合适的会员方案。

会员只是工具准备的一部分。代码审查是否有效,仍然取决于项目上下文是否完整、审查问题是否具体,以及修改后有没有经过人工复核和测试验证。


AI 代码审查不能代替哪些工作

不能代替业务判断

AI 可以发现 user == null,但不能确定用户不存在时应该显示默认值、阻止订单展示,还是触发数据修复流程。

不能代替性能测试

发现循环查询不等于已经证明它是当前系统的性能瓶颈。优化前后还要结合 SQL 日志、执行计划和接口耗时验证。

不能代替安全审查

权限校验、敏感数据、文件上传、SQL 注入和依赖漏洞需要专门的安全审查流程,不能只依赖通用代码 Review。

不能代替自动化测试

AI 给出的修改可能语法正确,但仍然可能改变事务边界、返回值或异常类型。没有测试覆盖时,修改风险依旧存在。


容易踩的四个坑

让 AI 直接修改整个项目

项目越大,隐藏的业务约束越多。一次修改几十个文件,看起来效率很高,实际很难检查每个变化是否合理。

接受没有证据的结论

“这里可能有性能问题”并不足以创建修复任务。至少需要文件位置、相关代码、触发条件和预期影响。

不检查 Git Diff

修改完成后,要重点检查方法签名、事务注解、异常类型、空值处理和返回结构是否发生变化。

把“测试通过”理解成“完全正确”

测试只能验证已有用例覆盖到的行为。历史数据、并发请求和外部服务异常仍然需要额外验证。


复盘

这次 Claude Code 辅助代码 Review 找出的四类问题都不算罕见:

  • 空指针风险;
  • N+1 数据库查询;
  • 异常吞没和资源未关闭;
  • 单例对象中的并发安全问题。

真正节省时间的地方,不是让 AI 自动写了多少代码,而是它先把分散在多个模块中的风险集中整理出来,并给出了文件位置、触发条件和修改方向。

更可靠的使用方式是:

AI 负责扩大检查范围,开发者负责判断业务语义,测试负责验证修改结果。

接手老项目时,可以先选择一个风险较低的模块,按照“分析结构—生成清单—人工确认—局部修改—测试验证”的流程跑一遍。确认结果可靠后,再逐步扩大审查范围。


你在维护 Java 老项目时,遇到过最难定位的问题是什么?是空指针、慢 SQL、事务异常,还是并发场景下的偶发 Bug?

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐