mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2811 words
7 minutes
从需求到上线:用 AI 完成一个 Spring Boot 订单导出功能
2026-05-22

从需求到上线:用 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()
);
}
}

这段代码有很多风险:

  1. findAll() 会导出整张订单表;
  2. 没有按照当前用户限制数据范围;
  3. 没有时间范围和数量上限;
  4. 用户名包含逗号、双引号或换行时会破坏 CSV;
  5. 没有明确字符编码,中文可能乱码;
  6. Controller 同时负责查询、权限和文件生成;
  7. 没有任何测试证明结果正确。

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 一次修改十几个文件。这个需求可以拆成三个独立阶段:

  1. 查询和业务规则;
  2. CSV 生成与 HTTP 下载;
  3. 测试、构建和回归检查。

每个阶段结束后都查看 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
@RequiredArgsConstructor
public 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:

@Component
public 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")
@RequiredArgsConstructor
public 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 参数来固定:

@Test
void 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 转义测试:

@Test
void 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 --short
git diff --stat

先确认它修改了哪些文件。如果需求只涉及订单导出,却出现用户注册、支付或数据库迁移文件,需要马上查明原因。

第二步:阅读完整 Diff#

git diff

重点检查:

  • 是否删除了原有校验;
  • 是否扩大了方法可见性;
  • 是否偷偷修改公共 DTO;
  • 是否引入新的依赖;
  • 是否出现硬编码用户 ID、路径或配置;
  • 测试是在验证行为,还是只为了提高覆盖率。

第三步:运行目标测试#

mvn -Dtest=OrderExportServiceTest,CsvOrderWriterTest,OrderExportControllerTest test

目标测试失败时,先阅读错误,再让 AI 针对证据修改:

CsvOrderWriterTest 的 shouldEscapeCommaQuoteAndNewline 失败。
预期双引号被写成两个双引号,实际输出没有转义。
请只分析 CsvOrderWriter.escape,解释根因并给出最小修改。
不要改测试,也不要修改其他文件。

不要只说“测试失败了,帮我修复”。错误越具体,AI 越不容易通过删除断言、放宽条件或修改无关代码来制造“通过”。

第四步:运行完整测试和构建#

mvn test
mvn package

目标测试证明新增功能的关键行为,完整测试用于发现回归,构建则验证最终可交付产物。三者不能互相替代。

10. 上线前仍然需要人工判断#

即使所有测试通过,也不代表功能一定适合上线。

数据量与性能#

当前实现最多把 10,000 行加载到内存,对于中小型导出通常可以接受,但仍要根据字段大小、并发量和服务内存进行压测。

如果未来需要导出几十万行,不应该简单把限制改大。更合理的方向可能是异步任务、分页读取、流式写出和对象存储下载。这属于新的需求,不应该被偷偷塞进本次实现。

权限回归#

至少使用两个不同账号验证:

  • 用户 A 能导出自己的订单;
  • 用户 A 不能通过参数或请求修改导出用户 B 的订单;
  • 未登录用户不能访问接口。

隐私与日志#

不要把完整导出内容、用户姓名或订单详情写入日志。日志可以记录用户 ID、时间范围、导出行数、耗时和结果状态,但仍需遵守项目的数据规范。

监控#

上线后关注:

  • 接口耗时;
  • 导出失败率;
  • 超过数量限制的次数;
  • 数据库慢查询;
  • 单个用户的异常调用频率。

回滚#

订单导出没有数据库结构变更,回滚相对简单。上线前仍应确认旧版本可以直接部署,以及前端是否能够处理接口暂时不可用。

11. AI 和人分别负责什么#

整个过程中,AI 确实可以节省大量时间,但它和开发者承担的职责不同。

AI 更适合完成人必须负责
阅读和整理相关代码确认 AI 理解的项目现状是否正确
根据 Spec 生成实现草稿定义真实业务规则和范围
补充常规测试用例识别权限、隐私与性能风险
根据明确报错做局部修改判断测试是否真的证明需求
执行命令并整理结果决定代码能否进入生产环境

AI 可以参与从需求到上线的每个阶段,但不能替我们定义“什么算完成”。

12. 一套可以复用的协作流程#

以后面对类似需求,可以复用这套顺序:

1. 让 AI 只读项目,不修改代码
2. 人与 AI 一起把需求整理成 Spec
3. 让 AI 输出文件级实现方案
4. 人工 Review 权限、数据和影响范围
5. 把实现拆成可以独立验证的小阶段
6. 每个阶段查看 Git Diff
7. 运行目标测试、完整测试和构建
8. 人工完成上线前检查

对应的通用 Prompt 可以写成:

请先阅读与当前需求相关的代码,只输出项目现状、风险和实现方案,不要修改文件。
方案确认后,请每次只完成一个阶段:
- 严格遵守 Spec 中的目标、范围和验收标准;
- 不修改无关文件;
- 先补充能够证明行为的测试;
- 修改后运行目标测试;
- 输出实际修改文件和验证命令;
- 不要仅用“已经完成”作为验证结论。

总结#

用 AI 完成订单导出,最简单的部分其实是生成 CSV。真正决定功能能否上线的是这些问题:

  • 权限是否进入数据库查询;
  • 日期和数据量是否有限制;
  • CSV 特殊字符是否正确处理;
  • 测试是否覆盖真正的业务边界;
  • Diff 是否只包含必要修改;
  • 构建、回归和上线检查是否有证据。

所以,从需求到上线的 AI 编程流程,不是:

描述需求 → AI 写代码 → AI 说完成

而是:

理解项目 → 明确 Spec → Review 方案 → 分阶段实现
→ 检查 Diff → 测试验证 → 人工决定是否上线

AI 可以让代码产生得更快,但代码产生得越快,我们越需要明确边界、保留证据,并知道最终责任仍然在开发者手里。

Share

If this article helped you, please share it with others!

从需求到上线:用 AI 完成一个 Spring Boot 订单导出功能
https://mizuki.mysqil.com/posts/ai-spring-order-export/
Author
梦幻晨风
Published at
2026-05-22
License
CC BY-NC-SA 4.0

Some information may be outdated

Table of Contents