我差点把AI代码漏洞上线,有哪些教训可以避免重蹈覆辙?

摘要:三周前,我差点在生产环境搞出一个安全事故。 起因很简单:让Cursor写了一个文件上传接口,跑了一下,没报错,直接提交了。 结果被同事review的时候揪出来:AI生成的代码 String savePath = uploadDir
三周前,我差点在生产环境搞出一个安全事故。 起因很简单:让Cursor写了一个文件上传接口,跑了一下,没报错,直接提交了。 结果被同事review的时候揪出来: // AI生成的代码 String savePath = uploadDir + fileName; // fileName来自用户输入 File file = new File(savePath); file.transferFrom(channel); fileName是用户传进来的,如果传../../etc/passwd,文件就写到服务器根目录去了。 这是一个经典的路径穿越漏洞。 功能完全正常,测试全部通过,但任何一个懂安全的人看一眼就知道这是定时炸弹。 那天之后,我把过去3个月用Cursor踩过的坑,全部整理了一遍。 一共10个。每一个都是真实交过的学费。 第一组:认知层的坑 这组坑最普遍。几乎每个刚开始用AI编程工具的人都会踩。 坑1:把Cursor当"更好的补全"在用 我最开始的用法是这样的: 写几个字 → 等AI补全 → 不满意就删 → 重写 用了两周,感觉"也就那样",效率提升有限,还经常被AI的补全打断思路。 后来才明白:这个用法完全用错了方向。 Cursor的核心能力不是补全,是生成。 正确的用法是: 描述清楚需求 → AI生成完整代码块 → 我来review和调整 两种用法的效率差距,不是10%,是10倍。 一句话改法: 不要等AI补全,要主动用Ctrl+K描述需求,让AI一次生成完整的函数或模块。 坑2:Prompt写得太模糊,然后怪AI不好用 这是我听到最多的抱怨: "AI写的代码太烂了,还不如自己写。" 我每次听到这个,都想问一句:你的Prompt写的是什么? 模糊Prompt: 写一个登录接口 AI生成的: public String login(String username, String password) { User user = userMapper.findByUsername(username); if (user != null && user.getPassword().equals(password)) { return "success"; } return "fail"; } 密码明文比较,没有token,没有异常处理,没有日志。能跑,但没法用。 清楚的Prompt: 写一个用户登录接口,要求: 1. 用户名+密码登录,密码用BCrypt校验 2. 登录失败超过5次,锁定账户30分钟(用Redis存失败次数) 3. 登录成功返回JWT token,有效期7天 4. 记录登录日志:IP、User-Agent、时间、成功/失败 5. 异常统一用BusinessException抛出,返回Result<T>包装 6. 技术栈:Spring Boot 3, MyBatis-Plus, Redis AI生成的: 80%可以直接用。 AI的质量上限,取决于你Prompt的质量上限。 一句话改法: 写Prompt之前,先想清楚如果让一个新同事来做这个功能,你会告诉他哪些信息。把这些信息全写进去。 坑3:以为AI"懂"你的项目 这个坑很隐蔽。 Cursor确实能读取你的项目文件,但它不知道你们团队的规范、你的代码风格、你的命名习惯。 结果就是: // 你的项目规范 public class UserService implements IUserService { ... } // 返回值统一用 Result<T> // 异常统一用 BusinessException // AI生成的(没有规范约束时) public class UserServiceImpl implements UserService { ... } // 直接返回实体类 // 用 RuntimeException 抛异常 每次生成完都要手动调整,改来改去,效率反而变低了。 解决方案是在项目根目录建一个 .cursorrules 文件: # 项目规范 ## 命名规范 - 接口以I开头:IUserService - 实现类不加Impl后缀:UserService - DTO类名以DTO结尾 ## 代码规范 - 所有接口返回 Result<T> - 异常统一用 BusinessException 抛出 - 日志用 @Slf4j,不用 System.out.println - 常量抽到对应模块的 Constants 类 ## 技术栈 - Spring Boot 3.x / JDK 17 - MyBatis-Plus 3.5.x - Redis(缓存和分布式锁) 配置之后,AI生成的代码直接符合规范,review工作量少了一半。 一句话改法: 第一次用Cursor接入新项目,先花30分钟写好 .cursorrules,后面省的时间是10倍。 第二组:安全和质量的坑 这组坑最危险。踩了可能直接影响线上。 坑4:AI生成的代码能跑,但有安全漏洞 开头的故事就是这个坑。 我把它完整复盘一遍。 AI生成的文件上传接口(问题版本): @PostMapping("/upload") public Result<String> upload(@RequestParam MultipartFile file) { String fileName = file.getOriginalFilename(); // 直接用用户传入的文件名 String savePath = uploadDir + fileName; // 路径直接拼接 file.transferTo(new File(savePath)); return Result.success(savePath); } 三个问题: getOriginalFilename() 返回的是用户控制的值,可以是 ../../etc/passwd 没有文件大小限制,可以上传几个G的文件把磁盘撑爆 没有文件类型校验,可以上传可执行文件 修复之后: @PostMapping("/upload") public Result<String> upload(@RequestParam MultipartFile file) { // 1. 文件大小校验 if (file.getSize() > MAX_FILE_SIZE) { throw new BusinessException("文件大小超过限制"); } // 2. 文件类型校验 String contentType = file.getContentType(); if (!ALLOWED_TYPES.contains(contentType)) { throw new BusinessException("不支持的文件类型"); } // 3. 文件名用UUID重新生成,不用用户传入的 String ext = FilenameUtils.getExtension(file.getOriginalFilename()); String safeFileName = UUID.randomUUID() + "." + ext; String savePath = uploadDir + safeFileName; file.transferTo(new File(savePath)); return Result.success(safeFileName); } AI不会主动帮你考虑安全性,除非你在Prompt里明确要求。 一句话改法: 涉及文件操作、用户输入、权限校验的代码,Prompt里加一句"需要考虑安全性,列出可能的安全风险并处理"。 坑5:并发场景AI经常给你挖坑 AI写出来的代码,在单线程下完全没问题。一到并发,就出幺蛾子。 我上个月做消息通知系统时,AI生成了一个重试逻辑: // AI生成的(有问题) public void retryFailed() { List<NotifyRecord> failedList = notifyRecordMapper.selectFailed(); for (NotifyRecord record : failedList) { if (record.getRetryCount() < 3) { doNotify(record); record.setRetryCount(record.getRetryCount() + 1); notifyRecordMapper.updateById(record); } } } 问题:如果这个方法被多个线程同时执行(比如定时任务多实例部署),同一条记录会被重复发送。 修复后: // 加分布式锁 + 数据库乐观锁 public void retryFailed() { List<NotifyRecord> failedList = notifyRecordMapper.selectFailed(); for (NotifyRecord record : failedList) { String lockKey = "notify:retry:" + record.getId(); // 抢锁,抢不到说明已有其他实例在处理 Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 5, TimeUnit.MINUTES); if (Boolean.TRUE.equals(locked)) { try { doNotify(record); notifyRecordMapper.incrementRetryCount(record.getId()); } finally { redisTemplate.delete(lockKey); } } } } 一句话改法: 定时任务、库存扣减、余额变更、状态流转这类场景,Prompt里明确加上"需要考虑并发安全,使用分布式锁或乐观锁"。 坑6:AI不会主动帮你考虑性能 AI的目标是"功能正确",不是"性能最优"。 最常见的是N+1查询问题。 // AI生成的(N+1查询) public List<OrderVO> getOrders(Long userId) { List<Order> orders = orderMapper.selectByUserId(userId); return orders.stream().map(order -> { OrderVO vo = new OrderVO(); BeanUtils.copyProperties(order, vo); // 每个订单都查一次用户信息 User user = userMapper.selectById(order.getUserId()); vo.setUserName(user.getName()); // 每个订单都查一次商品信息 Product product = productMapper.selectById(order.getProductId()); vo.setProductName(product.getName()); return vo; }).collect(Collectors.toList()); } 100个订单 = 201次数据库查询。 一句话改法: 涉及列表查询,Prompt里加"注意避免N+1查询,使用批量查询"。或者review时重点看循环内部有没有数据库调用。 坑7:让AI写测试,但测试是假的 这个坑最难发现。 我让Cursor写单元测试,它生成了一大堆,覆盖率看起来很高。 但仔细看: @Test void testLogin_success() { // Mock了所有依赖 when(userMapper.findByUsername("test")).thenReturn(mockUser); when(passwordEncoder.matches(any(), any())).thenReturn(true); when(jwtUtil.generateToken(any())).thenReturn("mock-token"); Result<String> result = userService.login("test", "123456"); // 只验证了返回值不为空 assertNotNull(result); } 测试通过了,但什么都没有真正验证: 没有验证返回的token是不是预期格式 没有验证登录日志是否记录了 没有验证失败次数是否被重置 这种测试,写了等于没写,但覆盖率数字很好看。 一句话改法: 让AI写完测试后,追问一句:"这些测试有没有只验证了mock的返回值,而没有验证真实的业务逻辑?"让AI自己检查。 第三组:习惯层的坑 这组坑最隐蔽,也是从"会用"到"用好"之间最难跨越的鸿沟。 坑8:一次让AI写太多,代码直接失控 刚开始用Cursor的时候,我会这么写Prompt: 帮我实现整个用户模块:注册、登录、修改密码、找回密码、 注销账户、用户信息管理、头像上传、第三方登录 AI生成了一大坨代码,洋洋洒洒800行。 然后我发现: 注册和登录的密码校验逻辑写了两套 日志记录风格不一致,有的用中文有的用英文 异常处理方式三种混用 我根本没办法整体review,只能硬着头皮提交 正确做法: 第一步:帮我实现用户注册功能 [review完,满意了] 第二步:帮我实现用户登录,密码校验复用注册里的逻辑 [review完,满意了] 第三步:继续,修改密码功能 分步骤生成,每步都是可控的,每步都能review,最终质量好得多。 一句话改法: 单次Prompt生成的代码,不要超过100行。超过了就拆。 坑9:看不懂AI的代码,但直接用了 这个习惯很危险。 AI有时候会生成一些你没见过的写法: // AI生成的 CompletableFuture.allOf( CompletableFuture.runAsync(() -> sendEmail(user)), CompletableFuture.runAsync(() -> sendSms(user)) ).join(); 如果你不熟悉CompletableFuture,可能看了一眼觉得"能跑就行"就提交了。 但这里有个问题:.join() 会阻塞当前线程,如果email或者短信服务超时,整个请求就卡死了。正确做法是加超时控制。 你不理解这段代码,你就发现不了这个问题。 我现在遇到看不懂的写法,一定会先问清楚: 你这里用了CompletableFuture.allOf,能解释一下: 1. 这样写的原因是什么? 2. 有没有潜在的风险? 3. 有没有更简单的替代方案? 一句话改法: 设立一条铁律:看不懂的代码,不提交。 坑10:效率上去了,但停止了深度思考 这是最隐蔽的坑,也是我认为最致命的。 用了AI之后,写代码确实快了。但我发现自己开始懒得思考了。 以前设计一个接口,我会想: 这个接口的入参合理吗? 返回值够不够用? 将来扩展会不会有问题? 现在有时候是: 让AI生成一个接口 能跑了 提交 然后两周后,业务需求一变,发现接口设计有根本性的问题,改起来成本极高。 AI提升的是执行速度,但思考这件事,没有任何捷径。 效率高了,省出来的时间,应该用来思考更深的问题: 这个方案是最好的吗? 还有没有更简单的实现? 三个月后再看这段代码,我还能看懂吗? 一句话改法: 每天留出一段"无AI时间",专门用来想架构、想设计、想需求背后的问题。 写在最后 总结一下这10个坑: 认知层: 把Cursor当补全工具 → 要当成"程序员"用 Prompt太模糊 → 像给新同事交代任务一样写清楚 没配 .cursorrules → 花30分钟配好,省几十小时 安全和质量: 4. 没考虑安全性 → Prompt里明确要求,代码里重点review 5. 并发场景没处理 → 明确要求加锁,识别并发场景 6. 没考虑性能 → review时重点看N+1和大数据量 7. 测试是假的 → 让AI自我检查测试的有效性 习惯层: 8. 一次生成太多 → 单次不超过100行,分步骤来 9. 看不懂的代码直接用 → 看不懂就问,不提交 10. 停止深度思考 → 保留"无AI时间" AI让写代码变容易了,但让写好代码变得更考验人了。 以前代码写得慢,你有足够的时间思考。 现在代码生成很快,反而需要你更主动地去思考那些AI不会替你想的问题。 这10个坑,我全踩过。 希望你少踩几个。 后端AI实验室 不讲概念,只谈实战 代码开源,每周更新