Skip to content

Spring完整项目:带版本的任务API · Spring任务API实现了哪些业务边界至Spring完整项目怎样复用同一份源码 ​

返回分篇目录

本篇承接语言基础,围绕同一任务模型展开。示例未执行;命令与现象均为读者未来自行验证的步骤和预期,不能视为运行记录。

Spring完整项目:带版本的任务API ​

本篇承接语言基础,围绕同一任务模型展开。示例未执行;命令与现象均为读者未来自行验证的步骤和预期,不能视为运行记录。

Spring任务API实现了哪些业务边界 ​

Spring任务API实现了哪些业务边界?项目实现任务列表、创建、详情、替换和删除。标题非空、至多120个UTF-16单元,done必填,更新和删除携带读取时的字符串版本;列表前100条。不存在为404,格式错误为400,旧版本为409,未知内部故障为500。没有账号、角色、公开部署、生产迁移或自动重试功能;任务为当前本机进程内共享数据。

完整文件在examples/java/p2-spring,可单独复制目录。请求→DTO→服务事务→Repository→实体→View形成可追踪链条。读者应先沿构造器依赖阅读,再看事务与异常处理,最后读测试;不需要先搭建外部数据库。本文所有源码与测试均为示例未执行,本轮没有Maven、JDK、HTTP或H2运行证据。

Spring项目的手写OpenAPI能证明接口运行吗 ​

Spring项目的手写OpenAPI能证明接口运行吗?不能。OpenAPI文件是先于实现落盘的手写静态契约,只说明路径、方法、请求字段、响应和错误约定,既不是从运行服务生成,也不是已验证的服务清单。Java与Vue项目各自带有字节一致的openapi.json,方便单独复制和比较;维护时须同时修改并校对,不能依赖两个副本永远自动同步。

POST /api/tasks输入title/done返回201;GET列表和详情返回200;PUT输入title/done/version返回200;DELETE使用version查询参数返回204且无JSON体。JSON对象禁止未知字段,所有id/version均为字符串。契约描述Long上限和UTF-16限制,但OpenAPI字符串maxLength通常按Unicode码点解释;补充文字是本例Java长度规则的必要约束,客户端不能只凭schema推导字形长度。

Spring完整项目怎样复用同一份源码 ​

Spring完整项目怎样复用同一份源码?下方直接包含仓库源码、配置、测试、契约和README,避免文章另抄一份而与项目漂移。README保存读者未来可自行执行的命令及预期结果;文件包含不会执行Java、启动服务器或连接数据库。src/test中的测试是可运行目标源码,当前未执行,也不构成编译成功的证明。

阅读时把pom.xml的Java/Boot基线、application.properties的内存库生命周期、TaskService的事务和Errors的HTTP映射对应起来。任何修改若影响字段或状态码,都应同时改手写契约、Vue调用和验收规格。生产迁移/鉴权应在明确需求后独立设计,不把配置示例误当部署指南。

openapi.json

完整文件:openapi.json 完整源码。保留原始内容,使用前请核对本文的版本与环境说明。

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.7</version><relativePath/></parent>
  <groupId>notes</groupId><artifactId>p2-spring-tasks</artifactId><version>1.0.0</version>
  <properties><java.version>21</java.version></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-validation</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</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>
  <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
README.md
md
# Spring MVC任务API(示例未执行)

Java21、Maven3.6.3+;Spring Boot固定3.5.7为已发布教学基线,非当前最新版承诺。依赖交由该parent管理。本轮未下载依赖、编译、运行或测试,未启动H2。

项目可单独复制。openapi.json为先于实现手写的3.0.3契约,不是运行结果或生成文档。src/main为完整源码,src/test为未执行测试。无账号、无认证,不承诺生产安全;只绑定127.0.0.1。H2内存库重启即丢失,create-drop只适用教学。列表固定id升序前100条,不提供分页。

读者日后自行运行:在此目录先 `mvn test`,再 `mvn spring-boot:run`。这会启动应用和内存数据库;本次编写未执行这些命令。健康检查不在契约范围,直接请求GET /api/tasks。

预期流程:POST /api/tasks,JSON为 {"title":"阅读事务","done":false},返回201与字符串id/version;GET /api/tasks/{id}得200;PUT同地址携带title、done、原version得200;旧version更新或删除得409,GET后用新version重试。DELETE地址附?version=当前版本得204,再GET得404。格式错误/超Long范围/空标题/缺done/未知字段得400。所有契约错误体为code/message;500仅通用消息,服务端记录异常。ID和版本在网络上始终是字符串,禁止转成JavaScript Number。

测试TaskApiTest覆盖空标题、字符串ID、改变字段后的旧版本冲突、删除、404和Long溢出;并发规格:两个独立事务读取相同版本并修改不同标题,恰好一个提交,另一个409;这项需读者另以并发集成测试验证,不能用顺序测试证明。无变化的PUT允许保留版本,不承诺每次请求版本递增。

边界:校验在控制器、事务在服务代理、JPA实体不直接输出;flush使数据库冲突在服务事务内出现,事务提交仍可能失败,响应只在服务返回后形成。生产迁移、权限、重试幂等键、分页、持久数据库和运维另设计。本例删除也检查版本。JPA的@Version是数据库写入条件,显式版本比较不能替代它。
src/main/java/notes/tasks/ApiFailure.java
java
package notes.tasks;
final class ApiFailure extends RuntimeException {
    final int status;
    final String code;
    ApiFailure(int status, String code, String message) { super(message); this.status = status; this.code = code; }
}
src/main/java/notes/tasks/Errors.java
java
package notes.tasks;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.*;
@RestControllerAdvice
class Errors {
    private static final Logger log = LoggerFactory.getLogger(Errors.class);
    @ExceptionHandler(ApiFailure.class)
    ResponseEntity<TaskDtos.Error> known(ApiFailure e) { return reply(e.status, e.code, e.getMessage()); }
    @ExceptionHandler({MethodArgumentNotValidException.class, HttpMessageNotReadableException.class,
                       MissingServletRequestParameterException.class})
    ResponseEntity<TaskDtos.Error> invalid(Exception e) { return reply(400, "invalid", "检查JSON、标题、done和版本"); }
    @ExceptionHandler(OptimisticLockingFailureException.class)
    ResponseEntity<TaskDtos.Error> conflict(Exception e) { return reply(409, "conflict", "任务已变化,请重新读取"); }
    @ExceptionHandler(Exception.class)
    ResponseEntity<TaskDtos.Error> unexpected(Exception e) {
        if (e instanceof org.springframework.web.ErrorResponse frameworkError)
            return ResponseEntity.status(frameworkError.getStatusCode()).headers(frameworkError.getHeaders())
                .body(new TaskDtos.Error("http_error", "请求无法处理"));
        log.error("Task request failed", e);
        return reply(500, "internal", "服务器暂时无法完成请求");
    }
    private ResponseEntity<TaskDtos.Error> reply(int status, String code, String message) {
        return ResponseEntity.status(status).body(new TaskDtos.Error(code, message));
    }
}
src/main/java/notes/tasks/JsonConfig.java
java
package notes.tasks;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.cfg.CoercionAction;
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
import com.fasterxml.jackson.databind.type.LogicalType;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class JsonConfig {
    @Bean
    Jackson2ObjectMapperBuilderCustomizer strictJson() {
        return builder -> builder.featuresToDisable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
            .postConfigurer(mapper -> {
                for (var shape : new CoercionInputShape[] { CoercionInputShape.Integer,
                        CoercionInputShape.Float, CoercionInputShape.Boolean }) {
                    mapper.coercionConfigFor(LogicalType.Textual).setCoercion(shape, CoercionAction.Fail);
                }
            });
    }
}
src/main/java/notes/tasks/Task.java
java
package notes.tasks;
import jakarta.persistence.*;
@Entity
@Table(name = "tasks")
class Task {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    @Version Long version;
    @Column(nullable = false, length = 120) String title;
    @Column(nullable = false) boolean done;
    protected Task() {}
    Task(String title, boolean done) { this.title = title; this.done = done; }
}
src/main/java/notes/tasks/TaskController.java
java
package notes.tasks;
import java.util.List;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/tasks")
class TaskController {
    private final TaskService service;
    TaskController(TaskService service) { this.service = service; }
    @GetMapping public List<TaskDtos.View> list() { return service.list(); }
    @GetMapping("/{id}") public TaskDtos.View get(@PathVariable String id) { return service.get(id); }
    @PostMapping @ResponseStatus(HttpStatus.CREATED)
    public TaskDtos.View create(@Valid @RequestBody TaskDtos.Create input) { return service.create(input); }
    @PutMapping("/{id}")
    public TaskDtos.View update(@PathVariable String id, @Valid @RequestBody TaskDtos.Update input) { return service.update(id, input); }
    @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable String id, @RequestParam String version) { service.delete(id, version); }
}
src/main/java/notes/tasks/TaskDtos.java
java
package notes.tasks;
import jakarta.validation.constraints.*;
final class TaskDtos {
    private TaskDtos() {}
    record Create(@NotBlank @Size(max = 120) String title, @NotNull Boolean done) {}
    record Update(@NotBlank @Size(max = 120) String title, @NotNull Boolean done,
                  @NotNull @Pattern(regexp = "0|[1-9][0-9]{0,18}") String version) {}
    record View(String id, String title, boolean done, String version) {
        static View from(Task t) { return new View(t.id.toString(), t.title, t.done, t.version.toString()); }
    }
    record Error(String code, String message) {}
}
src/main/java/notes/tasks/TaskRepository.java
java
package notes.tasks;
import org.springframework.data.jpa.repository.JpaRepository;
interface TaskRepository extends JpaRepository<Task, Long> {}
src/main/java/notes/tasks/TasksApplication.java
java
package notes.tasks;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TasksApplication {
    public static void main(String[] args) { SpringApplication.run(TasksApplication.class, args); }
}

继续阅读