从需求到上线:用 AI 完成一个 Spring Boot 订单导出功能
假设产品只给了我们一句话:
给订单列表加一个导出功能。如果直接把这句话交给 AI,它很快就能生成一个 Controller,查询数据库并返回 CSV 文件。代码看起来完整,甚至能通过编译。
但“能生成文件”和“能够上线”完全是两件事。
这个需求至少还隐藏着下面这些问题:
- 用户可以导出哪些订单?
- 时间范围是否有限制?
- 一次最多允许导出多少条?
- CSV 包含哪些字段,顺序是什么?
- 金额和时间使用什么格式?
- 中文在 Excel 中打开是否乱码?
- 单元测试和接口测试覆盖哪些边界?
- 数据量太大时,接口是否会拖垮数据库?
这篇文章不讨论某个 AI 工具的按钮怎么点,而是完整记录一次更接近真实开发的协作过程:
模糊需求 ↓让 AI 阅读项目 ↓编写 Spec ↓Review 实现方案 ↓分阶段修改代码 ↓检查 Diff 和权限 ↓运行测试与构建 ↓上线前检查示例使用 Spring Boot 和 CSV 导出。代码只保留 Controller、Service、CSV Writer 和关键测试,Repository 基础实现、实体类以及项目已有的通用异常处理会适当省略。
1. 为什么不能直接让 AI 开始写
最省事的 Prompt 是:
帮我给订单模块增加一个 CSV 导出接口。AI 可能会生成类似这样的代码:
@GetMapping("/orders/export")public void export(HttpServletResponse response) throws IOException { List<Order> orders = orderRepository.findAll();
response.setContentType("text/csv"); PrintWriter writer = response.getWriter(); writer.println("订单号,用户,金额,状态");
for (Order order : orders) { writer.println( order.getOrderNo() + "," + order.getUserName() + "," + order.getAmount() + "," + order.getStatus() ); }}这段代码有很多风险:
findAll()会导出整张订单表;- 没有按照当前用户限制数据范围;
- 没有时间范围和数量上限;
- 用户名包含逗号、双引号或换行时会破坏 CSV;
- 没有明确字符编码,中文可能乱码;
- Controller 同时负责查询、权限和文件生成;
- 没有任何测试证明结果正确。
AI 并不是故意写出这些问题。真正的原因是:需求没有给出边界,它只能自行补全空白。
所以第一步不是让 AI 写代码,而是让它先理解项目。
2. 先让 AI 阅读项目
我会先给出一个只读任务:
请先阅读当前项目中与订单查询、登录用户、权限控制、异常处理和测试有关的代码。
请输出:1. 订单列表目前的调用链;2. 如何获取当前登录用户;3. 数据权限在哪一层实现;4. 可以复用的 DTO、Repository 和异常类型;5. 新增导出功能可能影响哪些文件;6. 你发现的风险或不确定项。
当前阶段只分析,不要修改任何文件。这一步的目的不是让 AI 写一篇项目总结,而是避免它凭经验虚构项目结构。
例如,项目可能已经通过 OrderQueryService 统一处理数据权限。如果 AI 没有先阅读代码,可能重新写一套查询逻辑,导致订单列表和订单导出的权限规则不一致。
拿到分析结果后,需要人工确认三件事:
- AI 阅读的是不是真正相关的文件;
- 它描述的调用链是否和代码一致;
- 它提出的不确定项是否需要产品或后端负责人确认。
如果分析已经跑偏,不应该继续让它实现。错误上下文产生的代码通常只会错得更完整。
3. 把一句需求整理成 Spec
确认项目结构后,把模糊需求整理成一份轻量规格。
## 背景
用户需要将订单列表导出为 CSV,用于个人对账。
## 接口
GET /api/orders/export?startDate=2026-05-01&endDate=2026-05-31
## 权限
- 普通用户只能导出自己有权查看的订单。- 数据权限必须进入数据库查询条件,不能查询后再在内存中过滤。
## 参数规则
- startDate 和 endDate 必填,格式为 yyyy-MM-dd。- startDate 不能晚于 endDate。- 时间范围最多为 31 天,包含开始和结束日期。
## CSV 字段
字段顺序固定为:1. 订单号2. 下单时间3. 用户名称4. 订单状态5. 支付金额
## 格式
- UTF-8 编码,并写入 BOM。- 时间格式为 yyyy-MM-dd HH:mm:ss。- 金额保留两位小数。- 正确转义逗号、双引号、回车和换行。
## 数据限制
- 一次最多导出 10,000 条。- 超过限制时返回明确业务错误,不生成不完整文件。
## 不在本次范围
- 不修改数据库表结构。- 不新增异步任务或文件存储。- 不支持 XLSX。- 不重构现有订单列表功能。
## 验收
- 无权限用户不能导出他人订单。- 非法日期和超过 31 天的范围返回 400。- CSV 字段顺序、金额格式和特殊字符转义正确。- 补充 Service 单元测试和 Controller 接口测试。- mvn test 和 mvn package 通过。Spec 最重要的作用不是让文档变长,而是减少 AI 必须猜测的内容。
尤其要明确“不做什么”。如果没有范围限制,AI 可能顺手增加异步导出、对象存储、消息队列和新的数据库表。技术方案看起来更高级,却把一个小需求变成了新系统。
4. 先 Review 方案,再允许修改
有了 Spec 以后,仍然不要立刻让 AI 改代码。下一步让它输出实现方案:
请根据已经确认的订单导出 Spec,输出实现方案。
需要说明:1. 准备新增或修改哪些文件;2. 每个文件的职责;3. 数据权限如何进入查询条件;4. 如何限制 10,000 条;5. CSV 转义放在哪一层;6. 准备补充哪些测试;7. 如何验证没有影响现有订单查询。
不要修改代码,等我确认方案后再开始。假设 AI 给出了这样的方案:
在 OrderController 中查询当前用户的全部订单,然后过滤日期并拼接 CSV。这时就应该阻止它继续,因为这个方案有两个明显问题:
- 查询全部订单后过滤,会读取不必要的数据;
- 如果 Repository 没有包含用户权限条件,就可能先把无权查看的数据加载到内存。
更合理的边界是:
Controller 负责参数和 HTTP 响应 ↓OrderExportService 负责规则校验和导出流程 ↓OrderRepository 使用 userId + 时间范围查询 ↓CsvOrderWriter 只负责 CSV 编码与转义方案阶段发现问题,只需要修改几段文字;代码生成后才发现问题,往往需要删除实现、重写测试并重新 Review。
5. 把实现拆成三个阶段
不要让 AI 一次修改十几个文件。这个需求可以拆成三个独立阶段:
- 查询和业务规则;
- CSV 生成与 HTTP 下载;
- 测试、构建和回归检查。
每个阶段结束后都查看 Diff,再决定是否继续。
6. 第一阶段:查询与权限边界
第一阶段的 Prompt:
现在只实现订单导出的查询与业务规则,不实现 Controller 和 CSV。
要求:- 校验日期必填、开始日期不晚于结束日期;- 时间范围最多包含 31 天;- Repository 查询必须包含当前用户 ID;- 最多读取 10,001 条,用第 10,001 条判断是否超限;- 超过 10,000 条时抛出业务异常;- 不修改现有订单列表逻辑;- 补充 OrderExportService 单元测试。
完成后说明修改的文件和运行的测试命令。请求对象:
public record OrderExportRequest( LocalDate startDate, LocalDate endDate) {}导出行使用专门的只读 DTO,不直接把完整实体交给 CSV Writer:
public record OrderExportRow( String orderNo, LocalDateTime createdAt, String userName, OrderStatus status, BigDecimal paidAmount) {}Service 的核心实现:
@Service@RequiredArgsConstructorpublic class OrderExportService { private static final int MAX_RANGE_DAYS = 31; private static final int MAX_EXPORT_ROWS = 10_000;
private final OrderRepository orderRepository;
@Transactional(readOnly = true) public List<OrderExportRow> queryExportRows( long currentUserId, OrderExportRequest request ) { validate(request);
LocalDateTime from = request.startDate().atStartOfDay(); LocalDateTime toExclusive = request.endDate().plusDays(1).atStartOfDay();
List<OrderExportRow> rows = orderRepository.findExportRows( currentUserId, from, toExclusive, PageRequest.of(0, MAX_EXPORT_ROWS + 1) );
if (rows.size() > MAX_EXPORT_ROWS) { throw new BusinessException("导出数据超过 10000 条,请缩小时间范围"); }
return rows; }
private void validate(OrderExportRequest request) { if (request == null || request.startDate() == null || request.endDate() == null) { throw new IllegalArgumentException("开始日期和结束日期不能为空"); }
if (request.startDate().isAfter(request.endDate())) { throw new IllegalArgumentException("开始日期不能晚于结束日期"); }
long days = ChronoUnit.DAYS.between( request.startDate(), request.endDate() ) + 1;
if (days > MAX_RANGE_DAYS) { throw new IllegalArgumentException("导出时间范围不能超过 31 天"); } }}Repository 的关键不是方法叫什么,而是权限条件必须进入 SQL:
@Query(""" select new com.example.order.export.OrderExportRow( o.orderNo, o.createdAt, u.displayName, o.status, o.paidAmount ) from Order o join o.user u where o.user.id = :currentUserId and o.createdAt >= :from and o.createdAt < :toExclusive order by o.createdAt desc, o.id desc """)List<OrderExportRow> findExportRows( long currentUserId, LocalDateTime from, LocalDateTime toExclusive, Pageable pageable);这里使用小于下一天零点的半开区间:
[2026-05-01 00:00:00, 2026-06-01 00:00:00)它比手动拼接 23:59:59 更可靠,不会遗漏包含毫秒或更高精度的订单。
Review 第一阶段
这一阶段要重点检查:
- Repository 是否确实使用
currentUserId; - 时间范围的 31 天是否包含首尾两天;
- 排序是否稳定;
- 查询上限是否真的是 10,001;
- 金额类型是否仍然是
BigDecimal; - AI 是否修改了无关的订单列表代码。
不能因为方法名叫 findAuthorizedOrders 就相信权限正确,必须真正查看查询条件。
7. 第二阶段:生成可靠的 CSV
第二阶段的 Prompt:
基于已经通过 Review 的 OrderExportService,实现 CSV Writer 和下载接口。
要求:- UTF-8 BOM;- 字段顺序严格遵守 Spec;- 金额保留两位小数;- 正确转义逗号、双引号、回车和换行;- Controller 只负责获取当前用户、调用 Service 和设置响应头;- 不在 Controller 中查询数据库;- 增加 CSV Writer 测试和 Controller 测试。CSV Writer:
@Componentpublic class CsvOrderWriter { private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public void write(List<OrderExportRow> rows, OutputStream outputStream) throws IOException { outputStream.write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});
try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(outputStream, StandardCharsets.UTF_8) )) { writer.write("订单号,下单时间,用户名称,订单状态,支付金额"); writer.newLine();
for (OrderExportRow row : rows) { writer.write(String.join(",", escape(row.orderNo()), escape(TIME_FORMATTER.format(row.createdAt())), escape(row.userName()), escape(row.status().name()), escape(row.paidAmount().setScale(2, RoundingMode.HALF_UP).toPlainString()) )); writer.newLine(); } } }
static String escape(String value) { String safeValue = value == null ? "" : value; boolean needsQuotes = safeValue.contains(",") || safeValue.contains("\"") || safeValue.contains("\r") || safeValue.contains("\n");
if (!needsQuotes) { return safeValue; }
return "\"" + safeValue.replace("\"", "\"\"") + "\""; }}Controller:
@RestController@RequestMapping("/api/orders")@RequiredArgsConstructorpublic class OrderExportController { private final OrderExportService orderExportService; private final CsvOrderWriter csvOrderWriter;
@GetMapping(value = "/export", produces = "text/csv") public void export( @AuthenticationPrincipal LoginUser loginUser, @RequestParam LocalDate startDate, @RequestParam LocalDate endDate, HttpServletResponse response ) throws IOException { List<OrderExportRow> rows = orderExportService.queryExportRows( loginUser.id(), new OrderExportRequest(startDate, endDate) );
response.setCharacterEncoding(StandardCharsets.UTF_8.name()); response.setContentType("text/csv; charset=UTF-8"); response.setHeader( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=orders-" + startDate + "-" + endDate + ".csv" );
csvOrderWriter.write(rows, response.getOutputStream()); }}文件名只由经过解析的日期构成,没有直接使用用户输入,可以减少响应头注入和非法文件名风险。
Review 第二阶段
这里不能只测试正常中文,还要主动构造特殊数据:
普通名称:张三包含逗号:张三,测试账号包含引号:张三"测试"包含换行:张三\n测试空值:null预期 CSV 中:
"张三,测试账号""张三""测试""""张三测试"如果 AI 只是用 String.join(",", fields),测试普通数据可能通过,但真实数据迟早会生成损坏的文件。
8. 第三阶段:不要测试 AI 会说什么
到了测试阶段,可以继续让 AI 补代码,但验收标准必须由我们给出。
请补充并运行以下测试:
OrderExportServiceTest- 开始日期晚于结束日期时失败;- 范围正好 31 天时允许导出;- 范围为 32 天时失败;- Repository 查询必须携带当前用户 ID;- 查询到 10,001 条时拒绝导出。
CsvOrderWriterTest- 输出包含 UTF-8 BOM;- 表头顺序正确;- 金额始终保留两位;- 逗号、双引号和换行正确转义。
OrderExportControllerTest- 未登录请求返回 401;- 缺少日期参数返回 400;- 合法请求返回 text/csv 和 attachment 响应头。
先运行目标测试,再运行完整 mvn test。Service 测试中,权限边界可以通过验证 Repository 参数来固定:
@Testvoid shouldQueryOnlyCurrentUsersOrders() { long currentUserId = 42L; LocalDate start = LocalDate.of(2026, 5, 1); LocalDate end = LocalDate.of(2026, 5, 31);
when(orderRepository.findExportRows(anyLong(), any(), any(), any())) .thenReturn(List.of());
service.queryExportRows( currentUserId, new OrderExportRequest(start, end) );
verify(orderRepository).findExportRows( eq(currentUserId), eq(start.atStartOfDay()), eq(end.plusDays(1).atStartOfDay()), argThat(pageable -> pageable.getPageSize() == 10_001) );}CSV 转义测试:
@Testvoid shouldEscapeCommaQuoteAndNewline() throws IOException { OrderExportRow row = new OrderExportRow( "ORD-001", LocalDateTime.of(2026, 5, 10, 12, 30), "张三,\"测试\"\n账号", OrderStatus.PAID, new BigDecimal("19.9") );
ByteArrayOutputStream output = new ByteArrayOutputStream(); writer.write(List.of(row), output);
String csv = output.toString(StandardCharsets.UTF_8);
assertThat(csv).startsWith("\uFEFF订单号,下单时间,用户名称,订单状态,支付金额"); assertThat(csv).contains("\"张三,\"\"测试\"\"\n账号\""); assertThat(csv).contains("19.90");}这些测试不关心 AI 当时如何描述实现,而是验证系统真正必须满足的行为。
9. AI 说“完成了”之后做什么
AI 的总结只能当作线索,不能当作验收报告。
第一步:检查工作区
git status --shortgit diff --stat先确认它修改了哪些文件。如果需求只涉及订单导出,却出现用户注册、支付或数据库迁移文件,需要马上查明原因。
第二步:阅读完整 Diff
git diff重点检查:
- 是否删除了原有校验;
- 是否扩大了方法可见性;
- 是否偷偷修改公共 DTO;
- 是否引入新的依赖;
- 是否出现硬编码用户 ID、路径或配置;
- 测试是在验证行为,还是只为了提高覆盖率。
第三步:运行目标测试
mvn -Dtest=OrderExportServiceTest,CsvOrderWriterTest,OrderExportControllerTest test目标测试失败时,先阅读错误,再让 AI 针对证据修改:
CsvOrderWriterTest 的 shouldEscapeCommaQuoteAndNewline 失败。预期双引号被写成两个双引号,实际输出没有转义。
请只分析 CsvOrderWriter.escape,解释根因并给出最小修改。不要改测试,也不要修改其他文件。不要只说“测试失败了,帮我修复”。错误越具体,AI 越不容易通过删除断言、放宽条件或修改无关代码来制造“通过”。
第四步:运行完整测试和构建
mvn testmvn package目标测试证明新增功能的关键行为,完整测试用于发现回归,构建则验证最终可交付产物。三者不能互相替代。
10. 上线前仍然需要人工判断
即使所有测试通过,也不代表功能一定适合上线。
数据量与性能
当前实现最多把 10,000 行加载到内存,对于中小型导出通常可以接受,但仍要根据字段大小、并发量和服务内存进行压测。
如果未来需要导出几十万行,不应该简单把限制改大。更合理的方向可能是异步任务、分页读取、流式写出和对象存储下载。这属于新的需求,不应该被偷偷塞进本次实现。
权限回归
至少使用两个不同账号验证:
- 用户 A 能导出自己的订单;
- 用户 A 不能通过参数或请求修改导出用户 B 的订单;
- 未登录用户不能访问接口。
隐私与日志
不要把完整导出内容、用户姓名或订单详情写入日志。日志可以记录用户 ID、时间范围、导出行数、耗时和结果状态,但仍需遵守项目的数据规范。
监控
上线后关注:
- 接口耗时;
- 导出失败率;
- 超过数量限制的次数;
- 数据库慢查询;
- 单个用户的异常调用频率。
回滚
订单导出没有数据库结构变更,回滚相对简单。上线前仍应确认旧版本可以直接部署,以及前端是否能够处理接口暂时不可用。
11. AI 和人分别负责什么
整个过程中,AI 确实可以节省大量时间,但它和开发者承担的职责不同。
| AI 更适合完成 | 人必须负责 |
|---|---|
| 阅读和整理相关代码 | 确认 AI 理解的项目现状是否正确 |
| 根据 Spec 生成实现草稿 | 定义真实业务规则和范围 |
| 补充常规测试用例 | 识别权限、隐私与性能风险 |
| 根据明确报错做局部修改 | 判断测试是否真的证明需求 |
| 执行命令并整理结果 | 决定代码能否进入生产环境 |
AI 可以参与从需求到上线的每个阶段,但不能替我们定义“什么算完成”。
12. 一套可以复用的协作流程
以后面对类似需求,可以复用这套顺序:
1. 让 AI 只读项目,不修改代码2. 人与 AI 一起把需求整理成 Spec3. 让 AI 输出文件级实现方案4. 人工 Review 权限、数据和影响范围5. 把实现拆成可以独立验证的小阶段6. 每个阶段查看 Git Diff7. 运行目标测试、完整测试和构建8. 人工完成上线前检查对应的通用 Prompt 可以写成:
请先阅读与当前需求相关的代码,只输出项目现状、风险和实现方案,不要修改文件。
方案确认后,请每次只完成一个阶段:- 严格遵守 Spec 中的目标、范围和验收标准;- 不修改无关文件;- 先补充能够证明行为的测试;- 修改后运行目标测试;- 输出实际修改文件和验证命令;- 不要仅用“已经完成”作为验证结论。总结
用 AI 完成订单导出,最简单的部分其实是生成 CSV。真正决定功能能否上线的是这些问题:
- 权限是否进入数据库查询;
- 日期和数据量是否有限制;
- CSV 特殊字符是否正确处理;
- 测试是否覆盖真正的业务边界;
- Diff 是否只包含必要修改;
- 构建、回归和上线检查是否有证据。
所以,从需求到上线的 AI 编程流程,不是:
描述需求 → AI 写代码 → AI 说完成而是:
理解项目 → 明确 Spec → Review 方案 → 分阶段实现→ 检查 Diff → 测试验证 → 人工决定是否上线AI 可以让代码产生得更快,但代码产生得越快,我们越需要明确边界、保留证据,并知道最终责任仍然在开发者手里。
If this article helped you, please share it with others!
Some information may be outdated






