Spring Boot导入返回409,为什么第一行还是入库了?

接口返回 409,响应里的 importedRows 也是 0,第一行数据却已经入库了。只断言返回值,这条导入测试仍然能通过。

下面用一个本地联系人导入实验复现这个差别:两行数据,第一行正常,第二行邮箱重复。分别用逐行提交和整批事务处理,输入、状态码和返回数量都相同,数据库结果如下。

实现 HTTP 返回的importedRows 数据库总行数 本批第一行是否存在
逐行提交 409 0 2 存在
整批事务 409 0 1 不存在

两版开始前,数据库都只有一条旧记录。409 报告了冲突,但前面的写入有没有撤销,还得查库。

为什么要让第二行失败

数据库先放一条旧记录:used@example.com,姓名为"旧用户"。

本次文件里的两行依次是:

text 复制代码
第2行:本次新用户 / new@example.com
第3行:试图改名   / used@example.com

这组输入特意把正常数据放在前面。第二行失败时,才有一条已经执行过的写入可供检查;如果第一行就报错,两版都可能没有新增记录,测不出事务边界的差别。

这里特意不做"库内邮箱预查",让错误真正发生在写入阶段。即使业务接口已有预查,也仍需要单独测试写入冲突;另一个请求可能在预查后提交同样的邮箱。

两段代码只挪了事务的位置

坏版本的关键片段:

java 复制代码
for (var row : rows) {
    tx.executeWithoutResult(status -> insert(row));
}

本实验没有外层事务。每次 executeWithoutResult 都开启并结束一次事务,第一行已经提交,第二行回滚自己的那次事务,撤销不了第一行。

修正版把循环放进去:

java 复制代码
tx.executeWithoutResult(status -> {
    for (var row : rows) insert(row);
});

这时两个 INSERT 属于同一个事务。第二行的运行时异常离开回调,事务回滚,外层再把异常转换成409。Spring Framework 6.2.10 的 TransactionTemplate 源码可以核对这段异常处理顺序。

两版都有 catch,也都返回409。排查时要沿异常往外看:事务包住的是一次写入还是整个循环,异常有没有在触发回滚之前被捕获。只看有没有 try-catch,判断不了回滚结果。

请求结束后,至少再查三件事

本例要求整批成功或整批失败。对应到这份数据,请求结束后要满足:

  • 总行数仍然是1。
  • new@example.com 不存在。
  • used@example.com 的姓名仍然是"旧用户"。

只查总行数也不够:新增一条、误删一条,数量照样不变。最后一条检查的是旧数据没有被意外修改。

测试类没有加 @Transactional。请求完成后,重新从数据源取得连接,再查询提交后的状态。

MockMvc 请求常在测试线程里处理,应用事务可能加入测试事务。如果测试结束时又自动回滚,就容易把测试负责清理数据和接口自身的回滚混在一起。这里去掉测试事务,让断言检查请求完成后的数据库状态。

两个文件复现这个差别

下面是完整的最小实验,使用 JDK 21、Spring Boot 3.5.5 管理的 Spring Framework 6.2.10,以及 H2 2.3.232。

为聚焦事务边界,HTTP 入口直接接收"Excel 已解析出的两行"JSON,不重复上传和 POI 解析。MockMvc 确实走 Spring MVC 的路由、参数转换和响应处理,但没有启动真实网络端口。

创建下面两个文件,在项目目录执行 mvn test。输出是:

text 复制代码
atomic: HTTP=409, total=1, new=0
broken: HTTP=409, total=2, new=1

两项测试都通过,是因为坏版测试明确断言"应该复现残留",并不意味着坏实现满足业务要求。真正业务接口的验收应使用整批事务那组断言。

pom.xml

xml 复制代码
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.5</version><relativePath/>
  </parent>
  <groupId>demo</groupId><artifactId>excel-import-lab</artifactId><version>1.0</version>
  <properties><java.version>21</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
    <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
  </dependencies>
</project>

src/test/java/demo/BoundaryTest.java

java 复制代码
package demo;

import java.util.*;
import org.junit.jupiter.api.*;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.support.*;
import org.springframework.web.bind.annotation.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class BoundaryTest {
    DriverManagerDataSource ds;
    JdbcTemplate jdbc;
    MockMvc mvc;
    public record Contact(String name, String email) {}

    @RestController
    static class Api {
        final JdbcTemplate jdbc;
        final TransactionTemplate tx;
        Api(JdbcTemplate jdbc, DriverManagerDataSource ds) {
            this.jdbc = jdbc;
            tx = new TransactionTemplate(new DataSourceTransactionManager(ds));
        }
        @PostMapping("/imports/{mode}")
        ResponseEntity<?> upload(@PathVariable String mode, @RequestBody List<Contact> rows) {
            try {
                if ("broken".equals(mode)) {
                    for (var row : rows) tx.executeWithoutResult(s -> insert(row));
                } else {
                    tx.executeWithoutResult(s -> { for (var row : rows) insert(row); });
                }
                return ResponseEntity.ok(Map.of("importedRows", rows.size()));
            } catch (DataIntegrityViolationException e) {
                // 故意让两版响应相同,观察状态码能不能证明回滚。
                return ResponseEntity.status(409).body(Map.of("importedRows", 0));
            }
        }
        void insert(Contact row) {
            jdbc.update("INSERT INTO contacts VALUES (?,?)", row.name(), row.email());
        }
    }
    @BeforeEach void setup() {
        ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
        jdbc = new JdbcTemplate(ds);
        jdbc.execute("CREATE TABLE contacts(name VARCHAR(50) NOT NULL,email VARCHAR(254) NOT NULL UNIQUE)");
        jdbc.update("INSERT INTO contacts VALUES ('旧用户','used@example.com')");
        mvc = MockMvcBuilders.standaloneSetup(new Api(jdbc, ds)).build();
    }
    @AfterEach void close() { jdbc.execute("SHUTDOWN"); }
    @Test void brokenLeavesOneNewRow() throws Exception { verify("broken", true); }
    @Test void atomicLeavesNoNewRow() throws Exception { verify("atomic", false); }
    void verify(String mode, boolean hasNew) throws Exception {
        assertFalse(TransactionSynchronizationManager.isActualTransactionActive());
        mvc.perform(post("/imports/" + mode).contentType("application/json").content("""
            [{"name":"本次新用户","email":"new@example.com"},
             {"name":"试图改名","email":"used@example.com"}]
            """))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.importedRows").value(0));
        // 请求已经完成,再借出连接,检查提交后的状态。
        try (var connection = ds.getConnection(); var statement = connection.createStatement()) {
            assertTrue(connection.getAutoCommit());
            var rows = new LinkedHashMap<String,String>();
            try (var rs = statement.executeQuery("SELECT email,name FROM contacts ORDER BY email")) {
                while (rs.next()) rows.put(rs.getString(1), rs.getString(2));
            }
            assertEquals(hasNew ? 2 : 1, rows.size());
            assertEquals(hasNew, rows.containsKey("new@example.com"));
            assertEquals("旧用户", rows.get("used@example.com"));
            System.out.println(mode + ": HTTP=409, total=" + rows.size() + ", new=" + (hasNew ? 1 : 0));
        }
    }
}

这个实验验证的是同一数据源里的两次写入。换成 MySQL、PostgreSQL,或者事务里还要调用外部接口,需要补对应环境的测试;H2 的结果不能替代它们。

给现有接口补测试,可以先用这组输入:第一行能写入,第二行才冲突。你们目前的断言会检查第一行是否还在,还是只检查状态码和返回数量?

相关推荐
yunwei371 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化
用户8356290780511 小时前
Python 设置 Excel 单元格边框与样式
后端·python
波加曼大王1 小时前
记一次用 AI 重构三年前老 Spring Boot 服务的真实经历:爽是真爽,账单也是真疼
后端
大勇前进1 小时前
大模型的上下文窗口越大越好吗?长文本模型暗藏哪些缺陷
后端
Bazingga1 小时前
RAG进阶-分块Chunking从原理到企业级实践
后端
回家路上绕了弯1 小时前
智能体编排平台中,工作流与 Agent 如何分工?
后端·ai编程
leeyi1 小时前
erlang_pay 为 Erlang 补上支付这块拼图:一个库接支付宝、微信、Stripe
后端·erlang·支付宝
用户3126874877201 小时前
JVM 内存结构全链路:堆、栈、方法区、元空间
java
GoGeekBaird1 小时前
手机远控 DeepSeek Harness?四款 DSH Desktop 测评
后端·github
PC2005_cloud1 小时前
RabbitMQ 在 Spring Boot 中的完整使用
前端·后端