feat(platform): I1 bootstrap, I2 M1 APIs, OpenAPI SSOT, and CI guards

Deliver dual Spring Boot services (platform API + webhook ingress), JWT
auth, Flyway with isolated history tables, customer/project/dictionary
endpoints, OpenAPI snapshot under contracts/, RUNBOOK, and CI that runs
on services/web/contracts paths plus enforcer + dependency tree ban on
craftlabs-auth-bitanswer.

Made-with: Cursor
This commit is contained in:
2026-04-06 21:04:56 +08:00
parent 76ff98db87
commit 3f577b34d5
57 changed files with 3170 additions and 0 deletions
@@ -0,0 +1,39 @@
package cn.craftlabs.platform.api.auth;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void loginSuccess() throws Exception {
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"admin\",\"password\":\"admin\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").exists());
}
@Test
void loginFail() throws Exception {
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"x\",\"password\":\"y\"}"))
.andExpect(status().isUnauthorized());
}
}
@@ -0,0 +1,76 @@
package cn.craftlabs.platform.api.contract;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 将 springdoc 产出的 OpenAPI 固化为仓库内 {@code contracts/openapi/delivery-platform-api.json}。
*
* <p>更新快照:{@code UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest}(在 {@code
* services/delivery-platform-api} 模块目录下执行)。
*/
@SpringBootTest
@AutoConfigureMockMvc
class OpenApiContractSnapshotTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Autowired
private MockMvc mockMvc;
@Test
void openApiMatchesCommittedContract() throws Exception {
MvcResult result =
mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andReturn();
String raw = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
JsonNode actual = MAPPER.readTree(raw);
String normalized = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(actual);
Path contractFile = repositoryRoot().resolve("contracts/openapi/delivery-platform-api.json");
boolean update = "1".equals(System.getenv("UPDATE_OPENAPI"));
if (update) {
Files.createDirectories(contractFile.getParent());
Files.writeString(contractFile, normalized, StandardCharsets.UTF_8);
return;
}
assertThat(Files.isRegularFile(contractFile))
.as(
"缺少契约文件 %s;请执行: cd services/delivery-platform-api && UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest",
contractFile)
.isTrue();
JsonNode expected = MAPPER.readTree(Files.readString(contractFile, StandardCharsets.UTF_8));
assertThat(actual)
.as(
"OpenAPI 与快照不一致。若变更为刻意更新契约,请设置 UPDATE_OPENAPI=1 重新导出并提交 %s",
contractFile)
.isEqualTo(expected);
}
/** Surefire 的 user.dir 为当前模块根(delivery-platform-api)。 */
private static Path repositoryRoot() {
Path dir = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
if (dir.endsWith("delivery-platform-api")) {
return dir.getParent().getParent();
}
return dir;
}
}
@@ -0,0 +1,91 @@
package cn.craftlabs.platform.api.customer;
import cn.craftlabs.platform.api.support.JwtTestSupport;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class CustomerControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void customerCrudHappyPath() throws Exception {
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
String auth = "Bearer " + token;
String createBody = "{\"name\":\"测试客户\",\"creditCode\":\"91110000MA\",\"status\":\"ACTIVE\"}";
String created =
mockMvc.perform(
post("/api/v1/customers")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(createBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.name").value("测试客户"))
.andExpect(jsonPath("$.creditCode").value("91110000MA"))
.andExpect(jsonPath("$.status").value("ACTIVE"))
.andReturn()
.getResponse()
.getContentAsString();
long id = objectMapper.readTree(created).get("id").asLong();
mockMvc.perform(get("/api/v1/customers").header("Authorization", auth))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.number").value(0))
.andExpect(jsonPath("$.size").value(20));
mockMvc.perform(get("/api/v1/customers").param("keyword", "测试").header("Authorization", auth))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1));
mockMvc.perform(get("/api/v1/customers/" + id).header("Authorization", auth))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("测试客户"));
mockMvc.perform(
put("/api/v1/customers/" + id)
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"已更名\",\"creditCode\":\"91110000MA\",\"status\":\"ACTIVE\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("已更名"));
mockMvc.perform(delete("/api/v1/customers/" + id).header("Authorization", auth))
.andExpect(status().isNoContent());
JsonNode after =
objectMapper.readTree(
mockMvc.perform(get("/api/v1/customers/" + id).header("Authorization", auth))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString());
assertThat(after.get("status").asText()).isEqualTo("INACTIVE");
}
}
@@ -0,0 +1,43 @@
package cn.craftlabs.platform.api.dictionary;
import cn.craftlabs.platform.api.support.JwtTestSupport;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class DictionaryControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void listProjectPhaseDictionary() throws Exception {
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
mockMvc.perform(
get("/api/v1/dictionaries/PROJECT_PHASE")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(3)))
.andExpect(jsonPath("$[0].dictCode").value("PLANNING"))
.andExpect(jsonPath("$[0].dictLabel").value("规划中"))
.andExpect(jsonPath("$[1].dictCode").value("IN_PROGRESS"))
.andExpect(jsonPath("$[1].dictLabel").value("进行中"))
.andExpect(jsonPath("$[2].dictCode").value("DELIVERED"))
.andExpect(jsonPath("$[2].dictLabel").value("已交付"));
}
}
@@ -0,0 +1,51 @@
package cn.craftlabs.platform.api.ping;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class PingWithJwtTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void pingRequiresJwt() throws Exception {
mockMvc.perform(get("/api/v1/ping")).andExpect(status().isUnauthorized());
}
@Test
void pingWithBearerFromLogin() throws Exception {
MvcResult login =
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"admin\",\"password\":\"admin\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").isString())
.andReturn();
String token =
objectMapper.readTree(login.getResponse().getContentAsString()).get("token").asText();
assertThat(token).isNotBlank();
mockMvc.perform(get("/api/v1/ping").header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("ok"));
}
}
@@ -0,0 +1,95 @@
package cn.craftlabs.platform.api.project;
import cn.craftlabs.platform.api.support.JwtTestSupport;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class ProjectControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void projectCrudHappyPath() throws Exception {
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
String auth = "Bearer " + token;
String cust =
mockMvc.perform(
post("/api/v1/customers")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"项目所属客户\"}"))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
long customerId = objectMapper.readTree(cust).get("id").asLong();
String projBody =
"{\"customerId\":"
+ customerId
+ ",\"name\":\"交付项目A\",\"phase\":\"PLANNING\"}";
String created =
mockMvc.perform(
post("/api/v1/projects")
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(projBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.phase").value("PLANNING"))
.andReturn()
.getResponse()
.getContentAsString();
long projectId = objectMapper.readTree(created).get("id").asLong();
mockMvc.perform(
get("/api/v1/projects")
.param("customerId", String.valueOf(customerId))
.header("Authorization", auth))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].name").value("交付项目A"));
mockMvc.perform(get("/api/v1/projects/" + projectId).header("Authorization", auth))
.andExpect(status().isOk())
.andExpect(jsonPath("$.customerId").value(customerId));
mockMvc.perform(
put("/api/v1/projects/" + projectId)
.header("Authorization", auth)
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"customerId\":"
+ customerId
+ ",\"name\":\"交付项目A-改\",\"phase\":\"IN_PROGRESS\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("交付项目A-改"))
.andExpect(jsonPath("$.phase").value("IN_PROGRESS"));
mockMvc.perform(delete("/api/v1/projects/" + projectId).header("Authorization", auth))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/v1/projects/" + projectId).header("Authorization", auth))
.andExpect(status().isNotFound());
}
}
@@ -0,0 +1,24 @@
package cn.craftlabs.platform.api.support;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
public final class JwtTestSupport {
private JwtTestSupport() {}
public static String obtainBearerToken(MockMvc mockMvc, ObjectMapper objectMapper) throws Exception {
MvcResult login =
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"admin\",\"password\":\"admin\"}"))
.andReturn();
String body = login.getResponse().getContentAsString();
return objectMapper.readTree(body).get("token").asText();
}
}
@@ -0,0 +1,14 @@
# 单测不依赖本机 PostgreSQLH2 模拟 PostgreSQL 语法习惯(版本仍以生产 PG15 为准)
spring:
datasource:
url: jdbc:h2:mem:craftlabs_platform;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
driver-class-name: org.h2.Driver
username: sa
password:
flyway:
enabled: true
table: flyway_platform_api
platform:
jwt:
secret: unit-test-jwt-secret-at-least-32-chars-ok