把Java代码审查清单固化成Claude Code Skill:让AI自动规避并发安全与事务边界踩坑(附SKILL.md模板) 摘要你是否也曾将精心编写的代码审查清单贴在显示器旁却在AI飞速生成代码时“选择性失明”本文分享了我将血泪教训固化为Claude Code Skill的完整实践——将“五关审查清单”审需求、扫模式、追事务、盯外部输入、过安检卡编码成AI可执行的规则。通过构建包含决策树和正反例的Skill让AI在生成Java后端代码时自动规避并发、事务、空指针等已知漏洞。你将获得可直接复用的Skill目录结构、规则编写方法论以及从“人工审查”到“AI按规则生成”的研发流程升级方案。目录一、红线规则为什么要放进 Skill而不是 CLAUDE.md二、第一步搭骨架建一个跟着仓库走的 Skill三、第二步把踩坑经验翻译成 AI 能执行的规则四、第三步加载 Skill 之后AI 生成的代码差在哪五、第四步踩一个坑加一条规则六、重构研发流程CR 只聊架构不揪低级 bug附录code-review-gate 完整 SKILL.md上个月我精心整理了一份“五关审查清单”——审需求、扫模式、追事务、盯外部输入、过安检卡。打印出来郑重其事地贴在显示器左上角第三关“追事务”还用红笔重重圈了两道。“这下万无一失了。”我心想。结果第三周我就开始“跳过”它。准确地说不是主动跳过而是“写完代码才想起那张表”。那次一个外部短信调用被写在了事务方法体里而我第一遍review时完全没注意到。代码合入后线上安静运行了四天直到客服工单冒出来——有用户改了手机号旧号码居然还能收到验证码短信。排查发现更新用户表 调用短信服务被放在了同一个Transactional方法里。事务回滚时短信却已发出。这和上篇文章里Redis被提前删除的bug如出一辙只是换成了短信网关。同一个坑我踩了两次。同一张清单上明明列着第三条我却硬是没想起来检查。你多半也有过类似经历checklist写时满腔热血贴出来前两次还认真对照往后就渐渐“视而不见”。不是不想看而是注意力早被AI写代码的速度带跑了——30秒一段Service一分钟一个接口。你正全神贯注判断“这段逻辑对不对”清单却要求你同时思考“并发会不会炸”“事务边界对不对”“空指针有没有判”。这是两种完全不同的思维模式来回切换极其消耗意志力。意志力这东西靠不住。我花了十年才想明白这个道理。真正靠谱的路子只有一个别指望人每次都能想起来检查把踩过的坑直接写进AI的“岗前培训手册”里。这篇文章就是那本手册的诞生记。我会从零开始将五关清单一步步固化成Claude Code Skill让AI生成代码时自动规避那些你已经趟过的坑。文末附完整的Skill目录结构和SKILL.md你可以直接复制到项目里使用。一、红线规则为什么要放进 Skill而不是 CLAUDE.mdClaude Code 里塞自定义规则的方式一共有 7 种,但 90% 的人只用 CLAUDE.md 这一种。把检查规则直接写进 CLAUDE.md 行不行?我试过,不行。两者的差异用一张表就能说清楚所以路线很清晰- CLAUDE.md放我们是谁、用什么技术栈、项目怎么跑起来- Skill放写代码的时候绝对不能犯的错一句话总结核心逻辑通用介绍放 CLAUDE红线审查规则写 Skill。二、第一步搭骨架建一个跟着仓库走的 Skill一个 Skill 本质上就是一个目录里面至少要有一个 SKILL.md。Claude Code 会扫描若干个约定位置找到这个目录就会把它装进能力库。最简单的做法是放在项目根目录的 .claude/skills/ 下这样能跟着仓库一起走团队所有人都能复用。我建了一个叫 code-review-gate 的 Skill目录结构是这样的.claude/skills/code-review-gate/SKILL.mdreferences/requirements-review.md # 第一关需求评审concurrency.md # 第二关读—算—写模式transaction.md # 第三关事务边界null-safety.md # 第四关外部输入判空security.md # 第五关安全检查卡examples/cache-after-commit.md # 缓存一致性正反例select-for-update.md # 并发安全正反例ssrf-guard.md # SSRF 防护正反例SKILL.md 是入口负责告诉 Claude 什么情况下加载这个技能、加载之后要做什么。references/ 下的每个文件对应一条具体规则Claude 会在理解代码意图之后自动去查阅。注意这里不是 4 个 reference 文件是5 个。第一关审需求很容易被忽略——很多人觉得需求评审是 prompt 阶段的事跟代码没关系。但上一篇文章已经说过AI 写出的 bug 大部分根源不在代码里而在你写给它的需求里。所以需求评审也必须写成规则让 AI 动手之前先过一遍。三、第二步把踩坑经验翻译成 AI 能执行的规则这是整个 Skill 里最关键的一步也是最容易翻车的一步。直接照抄五关清单里的 checklist 是行不通的。比如你写检查并发安全AI 并不知道你的系统里到底哪些字段需要加锁、哪些不需要你写外部调用必须在事务提交后执行AI 也不知道你项目里哪些服务算外部。规则必须写到 AI 能直接执行的颗粒度。下面把五个 reference 文件的核心内容列出来你可以直接复制改改就用。### requirements-review.mdmarkdown# 需求评审规则在生成任何业务接口代码之前必须先向用户确认以下三点。如果用户未明确说明必须主动追问不得直接假设默认值。## 1. 并发模型- 同一时刻可能有多少请求访问同一份数据- 期望的处理方式幂等/行锁/乐观锁/Redis 预扣- 典型案例C 端用户连点 → 优先幂等B 端批量发放 → 行锁保底## 2. 上游输入- 每个字段的来源是谁HTTP 请求 / MQ / RPC / 内部计算- 哪些字段可能为 null 或空字符串- 必填字段用 Bean Validation非必填字段用 Optional## 3. 失败语义- 操作失败要不要回滚回滚到哪个边界- 流水、日志、通知算不算回滚范围- 外部通知类操作必须独立于数据库事务生命周期需求评审这一关问完后面四关才有得查。真正让我返工最多的是下一关——注意并发安全这句话对 AI 没用它需要的是一棵能一步步走下去的决策树。### concurrency.mdmarkdown# 并发安全规则## 适用条件当代码中出现以下模式时必须进行并发安全检查- 从数据库/Redis/内存读取一个值- 对该值做加减乘除或状态变更- 将新值写回数据源## 检查决策树1. 不同用户操作不同数据 → 无数据竞争不需要锁但需防重复提交2. 同一份数据、并发量低 → 用 SELECT FOR UPDATE 行锁3. 同一份数据、并发量高秒杀场景→ 用 Redis DECR/Lua 原子操作异步落库## 禁止写法- 不允许使用裸的 SELECT UPDATE非原子更新余额、库存、计数器类字段- 不明确并发量的场景先上行锁加监控看重试率再调整## 行锁示例SELECT FOR UPDATE注意必须在 Transactional 方法内调用否则 FOR UPDATE 不会持有到事务结束。java// Mapper 层加锁查询Select(SELECT * FROM account WHERE id #{id} FOR UPDATE)Account lockAndSelect(Param(id) Long id);// Service 层事务内使用Transactionalpublic void deductPoints(Long userId, Integer amount) {Account account accountMapper.lockAndSelect(userId);if (account.getPoints() amount) {throw new BusinessException(积分不足);}accountMapper.deductPoints(userId, amount);}## 乐观锁示例version 字段java// Mapper 层带版本号的 CAS 更新Update(UPDATE account SET points points - #{amount}, version version 1 WHERE id #{id} AND version #{version})int deductWithVersion(Param(id) Long id,Param(amount) Integer amount,Param(version) Integer version);// Service 层重试兜底Transactionalpublic void deductPoints(Long userId, Integer amount) {for (int i 0; i 3; i) {Account account accountMapper.selectById(userId);if (account.getPoints() amount) {throw new BusinessException(积分不足);}int affected accountMapper.deductWithVersion(userId, amount, account.getVersion());if (affected 1) return;}throw new BusinessException(扣减冲突请重试);}并发这一关管的是两个人同时改一份数据。下一关管的是文章开头那个短信 bug——数据只有一个人在改但事务提交和外部调用的先后顺序错了。### transaction.mdmarkdown# 事务边界规则## 核心原则不要在事务提交之前做那些事务回滚时撤不回来的事。## 哪些操作属于外部调用- RPC/HTTP 调用- MQ 发送- Redis 写入/删除- 短信、邮件、推送- 文件写入NAS、OSS、本地磁盘## 禁止写法javaTransactionalpublic void updateUserPhone(UserDTO dto) {userMapper.updateById(dto); // 可回滚smsService.sendVerifyCode(dto.getNewPhone()); // 不可回滚危险}## 正确写法afterCommit 回调javaTransactionalpublic void updateUserPhone(UserDTO dto) {userMapper.updateById(dto);TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {Overridepublic void afterCommit() {smsService.sendVerifyCode(dto.getNewPhone());}});}## 复杂场景本地事件表如果消息不能丢先把事件落本地事件表事务提交后由后台任务扫表投递。不要在同一段代码里同时处理数据库提交和消息投递两个生命周期。事务边界防住的是回滚不掉的动作。空指针防的是另一类坑——数据从哪来决定了 AI 敢不敢直接拿来用。### null-safety.mdmarkdown# 空指针防护规则## 只盯外部来源重点检查从以下来源进入的字段- MQ 消息体- HTTP 请求参数- RPC 返回值- 数据库查询结果里的关联字段## 禁止写法java// AI 容易写的String userId message.getUserId();userService.process(userId); // NPE测试消息可能没有 userIdListOrder orders orderService.queryByUser(userId);orders.stream().filter(...).collect(...); // NPE异常分支可能返回 null## 正确写法java// 外部字段用 Optional 编码可空语义Optional.ofNullable(message.getUserId()).ifPresent(userService::process);// Service 返回 List 不允许返回 nullListOrder orders orderService.queryByUser(userId);orders orders null ? Collections.emptyList() : orders;// 或者用 Optional 包装return Optional.ofNullable(orderMapper.selectById(orderId));## 校验策略- 必填字段用 NotNull / NotBlank- 非必填字段用 Optional 或显式默认值- 不要给所有字段无脑判 null前四关都是业务逻辑层面的坑松懈了顶多是个客服工单。最后一关不一样松懈的代价可能是一次安全事故。### security.mdmarkdown# 安全检查卡## SQL 注入搜 $ 符号。凡是 ${} 拼接前端参数的地方必须改成白名单或改为 #{}。java// 危险Select(select * from user order by ${sortField})// 正确sortField 用白名单校验private static final SetString ALLOWED_SORT Set.of(id, create_time);if (!ALLOWED_SORT.contains(sortField)) throw new IllegalArgumentException();## SSRF看到 HttpClient / RestTemplate / OkHttp先问URL 是用户传的吗java// 危险restTemplate.getForObject(url, byte[].class);// 正确URI uri URI.create(url);if (!ALLOWED_HOSTS.contains(uri.getHost())) throw new IllegalArgumentException();if (isPrivateIp(uri.getHost())) throw new IllegalArgumentException();## 日志脱敏log.info(user: {}, user) 前确认 toString() 不会打印手机号、身份证、Token。敏感字段加 Mask 注解或覆盖 toString()。## 越权看 userId 从哪来是从 token/session 解出来的当前用户 ID还是从请求参数里直接拿的java// 危险任何登录用户都能查别人的订单GetMapping(/orders/{orderId})public Order getOrder(PathVariable Long orderId) {return orderService.getById(orderId);}// 正确GetMapping(/orders/{orderId})public Order getOrder(PathVariable Long orderId,AuthenticationPrincipal Long currentUserId) {Order order orderService.getById(orderId);if (!order.getUserId().equals(currentUserId)) {throw new AccessDeniedException(无权访问);}return order;}这里有两条心得值得记下来。第一**规则里要有决策树不能只放一句原则**。注意并发安全不算规则不同并发量对应不同方案才算。你得把自己做这个判断时脑子里过的那些步骤显式写出来AI 才能照着做。第二**反例比正例更重要**。正例告诉 AI 该怎么写反例告诉 AI 不该怎么写。每条规则至少配两段代码一段是 AI 容易写出的错误版本一段是纠正后的版本。四、第三步加载 Skill 之后AI 生成的代码差在哪Skill 搭好之后实际效果到底怎么样上周有个真实案例。我让 Claude Code 写一个设备告警阈值配置的后端接口只说了一句创建一个管理后台接口支持查询和修改设备告警阈值没有给任何额外的并发提示。未加载 Skill 时原生代码自带 3 类高危漏洞javaServicepublic class DeviceAlertThresholdService {Autowiredprivate DeviceAlertThresholdMapper thresholdMapper;public DeviceAlertThreshold getThreshold(Long deviceId) {return thresholdMapper.selectByDeviceId(deviceId);}Transactionalpublic void updateThreshold(Long deviceId, Integer thresholdValue) {DeviceAlertThreshold threshold thresholdMapper.selectByDeviceId(deviceId);threshold.setThresholdValue(thresholdValue);threshold.setUpdateTime(LocalDateTime.now());thresholdMapper.updateById(threshold);// 写入变更历史ThresholdChangeLog log new ThresholdChangeLog();log.setDeviceId(deviceId);log.setOldValue(threshold.getThresholdValue());log.setNewValue(thresholdValue);changeLogMapper.insert(log);// 通知规则引擎刷新ruleEngineClient.refresh(deviceId);}}乍一看逻辑通顺但三个问题全都中招了1. **selectByDeviceId updateById 是典型的读—算—写**没有锁保护。两个管理后台同时改同一个设备的阈值可能互相覆盖。2. **ruleEngineClient.refresh(deviceId) 是外部 RPC 调用**直接塞在了 Transactional 方法体里。事务一旦回滚规则引擎其实已经被通知刷新过了数据就对不上了。3. **thresholdValue 没有做判空校验**也没有用 Bean Validation。加载 code-review-gate Skill 后自动修复javaServicepublic class DeviceAlertThresholdService {Autowiredprivate DeviceAlertThresholdMapper thresholdMapper;public DeviceAlertThreshold getThreshold(Long deviceId) {return thresholdMapper.selectByDeviceId(deviceId);}Transactionalpublic void updateThreshold(NotNull Long deviceId,NotNull Min(0) Integer thresholdValue) {// 第二关配置类数据并发管理后台场景用行锁保底DeviceAlertThreshold threshold thresholdMapper.lockByDeviceId(deviceId);Integer oldValue threshold.getThresholdValue();threshold.setThresholdValue(thresholdValue);threshold.setUpdateTime(LocalDateTime.now());thresholdMapper.updateById(threshold);// 变更历史写入数据库跟事务一起走ThresholdChangeLog log new ThresholdChangeLog();log.setDeviceId(deviceId);log.setOldValue(oldValue);log.setNewValue(thresholdValue);changeLogMapper.insert(log);// 第三关外部调用规则引擎刷新必须在事务提交后执行TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {Overridepublic void afterCommit() {ruleEngineClient.refresh(deviceId);}});}}三处关键修复- selectByDeviceId 改成了 lockByDeviceIdFOR UPDATE事务内持有行锁- RPC 调用被包进了 afterCommit()事务回滚时不会触发- 参数加上了 NotNull Min(0) 校验拦截非法入参以前我要花 5 分钟逐行排查并发、事务、判空、安全的活这次扫一遍就过了只花了一分钟。不是我的审查能力突然变强了而是 AI 不再犯我已经踩过的那些坑了。Skill 和一次性 Prompt 的区别你可能会想这跟把规则写进 CLAUDE.md 有什么不一样每次在 prompt 里说一遍不也行吗区别就在于**prompt 是一次性的Skill 是持久的。**每次在 prompt 里说注意并发安全它只在这一轮对话里管用换个新对话或者同事打开同一个项目它就全忘了。Skill 是项目仓库的一部分跟着代码一起走——把 name/description 配好之后Claude Code 理论上会在合适的场景把它自动加载进来即便某次没触发手动敲一句 /code-review-gate 也照样能让规则生效不会像 prompt 那样话赶话说过就忘。建议你装好之后自己实测一次确认在你的环境里是不是真的不用手动喊它就会加载。这才是团队级别的经验复用——不是你一个人记住了是整个仓库都记住了。五、第四步踩一个坑加一条规则Skill 不是一次性写完就能扔进抽屉的东西。我用的维护方式很简单**踩一个坑加一条规则。**前几天排查过一个 SSRF 漏洞源头是一个根据用户传入 URL 下载文件的管理后台功能。AI 生成的代码直接用了 restTemplate.getForObject(url, byte[].class)URL 没做任何校验。测试环境被人扫到了内网地址差点捅出大事。排查完之后我在 references/security.md 里补了一段markdown## 管理后台下载/转发类接口凡是用户传入 URL 的接口必须同时做两件事1. 白名单校验 host2. 禁止访问私有 IP 段10.x、172.16-31.x、192.168.x、127.x如果业务上无法列白名单直接拒绝该需求不要在代码层绕过去。就这么几句话代价是我排查花掉的两小时,加上一份安全报告邮件。但我能确定,下次 AI 在这个仓库里写类似的接口,不会再犯同样的错。持续维护三个月之后这个 Skill 会变成什么样子它不会是一篇教科书而是一份这个系统里曾经炸过的所有坑的合集。这才是真正值钱的东西——**你在这个项目里攒下的所有经验被编码成了 AI 的行为规则。**跟前两篇里说的一样AI 是个永远不会自己成长的 P6。你教它一次它不会自动记住下一次。但 Skill 不一样——它是人类经验的外挂记忆插上之后那个永远不会成长的 P6至少不会再犯已经有人替它趟过的错。---六、重构研发流程CR 只聊架构不揪低级 bug传统流程是AI 生成代码 → 人工 CR 兜底排查并发、事务、空指针、安全漏洞。新流程是**经验固化成 Skill 规则 → AI 生成代码时自动校验修复 → 人工 CR 只聚焦业务架构和复杂逻辑。**我以前写过不少代码规范文档但大部分最后都躺在 Confluence 里吃灰。不是写得不好是没人会在写代码之前先翻一遍规范——规范是写给人看的人靠不住。Skill 是写给 AI 看的AI 每次都会读。如果说第一篇文章的核心问题是AI 提高了效率但也放大了经验的价值第二篇的回答是你要建立一套审查体系来填补这个差距那这一篇的答案更往前迈了一步**别一直审查了让 AI 自己按你的规则去写。**这才是 AI 协作真正的正确打开方式不是你审 AI 的代码是你把经验编码成规则AI 按规则生成。你省下来的时间不是拿去摸鱼的是用来迭代这些规则的。下一篇文章已经在写了聊一个更彻底的想法如果每个 Java 团队都有一个这样的团队 SkillCR 会不会变成 AI 和 AI 之间的对话——你写的代码被你的 AI 审过我写的被我的 AI 审过然后我们只需要聊架构。---附录code-review-gate 完整 SKILL.md你可以直接复制下面这份 SKILL.md放到项目根目录的 .claude/skills/code-review-gate/SKILL.md 里。references/ 下的五个文件按前面第三节的内容补全即可。注意开头的 --- frontmatter 不是装饰——name 和 description 这两个字段正是 Claude Code 扫描技能库、判断这一刻要不要自动加载的依据。少了它这个 Skill 就只能靠你手动喊出来谈不上自动避坑。markdown---name: code-review-gatedescription: Use when generating or reviewing Java backend code (Service/Controller/Mapper) that touches a database, Redis, MQ, RPC, SMS/email, or file writes. Applies a 5-gate checklist (requirements review, concurrency safety, transaction boundaries, null-safety, security) distilled from real production incidents, so AI-generated code doesnt repeat known bugs.---# Code Review Gate为 Java 后端项目提供基于五关审查清单的代码生成与审查能力。## 适用场景- 用户要求生成 Java 后端业务代码- 用户要求审查已有 Java 代码- 涉及数据库、缓存、MQ、RPC、短信、邮件、文件写入等外部依赖的代码## 使用方式1. 在生成或审查代码前先加载本 Skill 的 references/ 目录2. 逐一对照以下五关检查代码- 第一关需求评审references/requirements-review.md- 第二关并发安全references/concurrency.md- 第三关事务边界references/transaction.md- 第四关空指针防护references/null-safety.md- 第五关安全检查卡references/security.md3. 每发现一处违反规则的地方必须给出修改后的代码4. 如果不确定项目中的外部调用范围或并发量必须向用户提问禁止猜测## 输出要求- 每个方法必须附带不超过 50 字的注释说明它守住了哪几关- 所有外部调用必须明确标注afterCommit或非事务内- 不允许返回 null List统一返回空集合完整的 5 个 reference 文件和 3 个示例我同步放在了 GitHub 仓库 wangheng19901021/skills 里的 code-review-gate/ 目录clone 下来复制到 .claude/skills/ 下就能直接用。---*不发教程只发踩坑记录。十年 Java 老兵用代码说话。*