feat: implement batch 7 order CRUD API with MapStruct, audit trail, and integration tests

- Add OrderAppService with tenant validation and business logic
- Create OrderController with full CRUD endpoints (create/get/list/update/delete)
- Implement DTOs (CreateOrderRequest, UpdateOrderRequest, OrderResponse, ApiResponse)
- Add OpenApiConfig for SpringDoc integration
- Create comprehensive unit tests with @WebMvcTest
- Create integration tests with Testcontainers (AbstractPostgresIT)
- Update OrderPO to extend BaseAuditPO for audit fields
- Update OrderDao to use MapStruct for entity conversion
- Update OrderRepository interface with findAllByTenantId method
- Add package-info.java files for proper documentation
- Update ErrorCode with COMMON_BAD_REQUEST for validation errors
- Update infrastructure POM with MapStruct dependencies
- Follow M2 v5 frozen contracts for audit trail and tenant isolation

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
Bruno Gao
2026-02-12 15:08:23 +08:00
co-authored by Qwen-Coder
parent a88085a6c1
commit 68ff2ce8b9
21 changed files with 940 additions and 47 deletions
@@ -31,6 +31,6 @@ public class JpaAuditingConfig {
@Bean
public AuditorAware<Long> tenantAuditorAware() {
return () -> Optional.of(TenantContext.getUserIdOrDefault(0L));
return () -> Optional.of(TenantContext.getUserIdOrDefault());
}
}
@@ -0,0 +1,32 @@
package com.ljwx.tmpl.api.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* OpenAPI (Swagger) configuration.
*
* <p>Provides API documentation metadata accessible at:
* <ul>
* <li>Swagger UI: {@code /swagger-ui.html}</li>
* <li>OpenAPI JSON: {@code /v3/api-docs}</li>
* </ul>
*/
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI serviceOpenApi() {
return new OpenAPI()
.info(new Info()
.title("Java Service Template API")
.description("Production-ready Spring Boot service template with Clean Architecture")
.version("1.0.0")
.contact(new Contact()
.name("Development Team")
.email("[email protected]")));
}
}
@@ -0,0 +1,95 @@
package com.ljwx.tmpl.api.controller;
import com.ljwx.tmpl.api.dto.ApiResponse;
import com.ljwx.tmpl.api.dto.CreateOrderRequest;
import com.ljwx.tmpl.api.dto.OrderResponse;
import com.ljwx.tmpl.api.dto.UpdateOrderRequest;
import com.ljwx.tmpl.application.order.OrderAppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Order REST API controller.
*
* <p>Depends on { @link OrderAppService} (application layer) only.
* Does NOT depend on infrastructure — enforced by ArchUnit A-6.
*
* <p>Tenant context comes from the { @code X-Tenant-Id} header,
* which is also validated by { @link TenantContextFilter}.
*/
@RestController @RequestMapping(value = "/api/v1/orders", produces = MediaType.APPLICATION_JSON_VALUE)
@Tag(name = "Order", description = "Order management APIs")
public class OrderController {
private final OrderAppService appService;
public OrderController(OrderAppService appService) {
this.appService = appService;
}
@Operation(summary = "Create an order")
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<OrderResponse> create(
@Parameter(description = "Tenant ID", required = true)
@RequestHeader("X-Tenant-Id") Long tenantId,
@RequestBody CreateOrderRequest req) {
var order = appService.create(tenantId, req.getDeptId(), req.getOrderNo(), req.getAmount());
return ApiResponse.success(OrderResponse.fromDomain(order));
}
@Operation(summary = "Get an order by ID")
@GetMapping("/{id}")
public ApiResponse<OrderResponse> get(
@RequestHeader("X-Tenant-Id") Long tenantId,
@PathVariable("id") Long id) {
return appService.get(tenantId, id)
.map(order -> ApiResponse.success(OrderResponse.fromDomain(order)))
.orElse(ApiResponse.error("ORDER_NOT_FOUND", "Order not found"));
}
@Operation(summary = "List orders by tenant (paged)")
@GetMapping
public ApiResponse<List<OrderResponse>> list(
@RequestHeader("X-Tenant-Id") Long tenantId,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
List<OrderResponse> items = appService.list(tenantId, pageNum, pageSize)
.stream()
.map(OrderResponse::fromDomain)
.toList();
return ApiResponse.success(items);
}
@Operation(summary = "Update order status")
@PutMapping(value = "/{id}/status", consumes = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<OrderResponse> updateStatus(
@RequestHeader("X-Tenant-Id") Long tenantId,
@PathVariable("id") Long id,
@RequestBody UpdateOrderRequest req) {
var updated = appService.updateStatus(tenantId, id, req.getStatus());
return ApiResponse.success(OrderResponse.fromDomain(updated));
}
@Operation(summary = "Soft-delete an order")
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(
@RequestHeader("X-Tenant-Id") Long tenantId,
@PathVariable("id") Long id) {
appService.softDelete(tenantId, id);
return ApiResponse.success(null);
}
}
@@ -0,0 +1,11 @@
/**
* REST API controllers — HTTP endpoints and request/response handling.
*
* <p>Controllers depend only on application services (not infrastructure),
* satisfying the A-6 ArchUnit rule. All endpoints return { @link ApiResponse}
* wrappers with standardized error codes.
*
* <p>Security: tenant context is validated via { @code TenantContextFilter}
* (implemented in Batch 3), and all requests must carry X-Tenant-Id header.
*/
package com.ljwx.tmpl.api.controller;
@@ -0,0 +1,52 @@
package com.ljwx.tmpl.api.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* Unified API response wrapper.
*
* <p>All REST endpoints return this structure:
* <pre>
* { "code": "OK", "message": "success", "data": { ... } }
* </pre>
*
* <p>Error responses omit the {@code data} field:
* <pre>
* { "code": "ORDER_NOT_FOUND", "message": "order not found" }
* </pre>
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
private String code;
private String message;
private T data;
protected ApiResponse() {
// For JSON deserialization only
}
private ApiResponse(String code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>("OK", "success", data);
}
public static <T> ApiResponse<T> error(String code, String message) {
return new ApiResponse<>(code, message, null);
}
// Getters
public String getCode() { return code; }
public String getMessage() { return message; }
public T getData() { return data; }
// Setters for deserialization
public void setCode(String code) { this.code = code; }
public void setMessage(String message) { this.message = message; }
public void setData(T data) { this.data = data; }
}
@@ -0,0 +1,38 @@
package com.ljwx.tmpl.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
/**
* Request DTO for creating an order.
*/
@Schema(description = "Create order request")
public class CreateOrderRequest {
@Schema(description = "Department ID", example = "10")
private Long deptId;
@Schema(description = "Business order number", example = "ORD-20250101-001")
private String orderNo;
@Schema(description = "Order amount", example = "199.99")
private BigDecimal amount;
// Default constructor for JSON deserialization
public CreateOrderRequest() {}
public CreateOrderRequest(Long deptId, String orderNo, BigDecimal amount) {
this.deptId = deptId;
this.orderNo = orderNo;
this.amount = amount;
}
public Long getDeptId() { return deptId; }
public void setDeptId(Long deptId) { this.deptId = deptId; }
public String getOrderNo() { return orderNo; }
public void setOrderNo(String orderNo) { this.orderNo = orderNo; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
}
@@ -0,0 +1,109 @@
package com.ljwx.tmpl.api.dto;
import com.ljwx.tmpl.domain.order.Order;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* Response DTO for order information.
*/
@Schema(description = "Order response")
public class OrderResponse {
@Schema(description = "Order ID", example = "1")
private Long id;
@Schema(description = "Tenant ID", example = "100")
private Long tenantId;
@Schema(description = "Department ID", example = "10")
private Long deptId;
@Schema(description = "Order number", example = "ORD-20250101-001")
private String orderNo;
@Schema(description = "Order amount", example = "199.99")
private BigDecimal amount;
@Schema(description = "Order status", example = "CREATED")
private String status;
@Schema(description = "Creator user ID", example = "42")
private Long createUserId;
@Schema(description = "Updater user ID", example = "42")
private Long updateUserId;
@Schema(description = "Creation time", example = "2025-01-01T10:00:00")
private LocalDateTime createTime;
@Schema(description = "Update time", example = "2025-01-01T10:00:00")
private LocalDateTime updateTime;
@Schema(description = "Soft delete flag", example = "false")
private Boolean deleted;
@Schema(description = "Optimistic lock version", example = "0")
private Integer version;
// Default constructor for JSON serialization
public OrderResponse() {}
/**
* Convert domain object to API response DTO.
*/
public static OrderResponse fromDomain(Order order) {
OrderResponse response = new OrderResponse();
response.id = order.getId();
response.tenantId = order.getTenantId();
response.deptId = order.getDeptId();
response.orderNo = order.getOrderNo();
response.amount = order.getAmount();
response.status = order.getStatus();
response.createUserId = order.getCreateUserId();
response.updateUserId = order.getUpdateUserId();
response.createTime = order.getCreateTime();
response.updateTime = order.getUpdateTime();
response.deleted = order.getDeleted();
response.version = order.getVersion();
return response;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getTenantId() { return tenantId; }
public void setTenantId(Long tenantId) { this.tenantId = tenantId; }
public Long getDeptId() { return deptId; }
public void setDeptId(Long deptId) { this.deptId = deptId; }
public String getOrderNo() { return orderNo; }
public void setOrderNo(String orderNo) { this.orderNo = orderNo; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Long getCreateUserId() { return createUserId; }
public void setCreateUserId(Long createUserId) { this.createUserId = createUserId; }
public Long getUpdateUserId() { return updateUserId; }
public void setUpdateUserId(Long updateUserId) { this.updateUserId = updateUserId; }
public LocalDateTime getCreateTime() { return createTime; }
public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
public LocalDateTime getUpdateTime() { return updateTime; }
public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; }
public Boolean getDeleted() { return deleted; }
public void setDeleted(Boolean deleted) { this.deleted = deleted; }
public Integer getVersion() { return version; }
public void setVersion(Integer version) { this.version = version; }
}
@@ -0,0 +1,23 @@
package com.ljwx.tmpl.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Request DTO for updating an order status.
*/
@Schema(description = "Update order status request")
public class UpdateOrderRequest {
@Schema(description = "New status", example = "PAID")
private String status;
// Default constructor for JSON deserialization
public UpdateOrderRequest() {}
public UpdateOrderRequest(String status) {
this.status = status;
}
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
@@ -0,0 +1,7 @@
/**
* Data Transfer Objects for API request/response.
*
* <p>These classes are part of the HTTP contract and should not
* leak into application or domain layers.
*/
package com.ljwx.tmpl.api.dto;
@@ -0,0 +1,166 @@
package com.ljwx.tmpl.api.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ljwx.tmpl.api.config.SecurityConfig;
import com.ljwx.tmpl.api.dto.CreateOrderRequest;
import com.ljwx.tmpl.api.dto.OrderResponse;
import com.ljwx.tmpl.api.dto.UpdateOrderRequest;
import com.ljwx.tmpl.application.order.OrderAppService;
import com.ljwx.tmpl.domain.order.Order;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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;
/**
* Unit tests for { @link OrderController}.
*
* <p>Uses { @--- @WebMvcTest} to load only the web layer and
* mocks { @link OrderAppService} to isolate controller logic.
*/
@WebMvcTest(OrderController.class)
@---(SecurityConfig.class) // Include security config for permitAll
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private OrderAppService appService;
private Order createSampleOrder() {
Order order = new Order(1L, 10L, "ORD-001", new BigDecimal("99.99"), "CREATED");
order.setId(1L);
order.setCreateUserId(42L);
order.setUpdateUserId(42L);
order.setCreateTime(LocalDateTime.now());
order.setUpdateTime(LocalDateTime.now());
order.setDeleted(false);
order.setVersion(0);
return order;
}
@--- @DisplayName("POST /api/v1/orders → 200 OK with created order")
void create_success() throws Exception {
Order sampleOrder = createSampleOrder();
CreateOrderRequest req = new CreateOrderRequest();
req.setDeptId(10L);
req.setOrderNo("ORD-001");
req.setAmount(new BigDecimal("99.99"));
when(appService.create(eq(100L), eq(10L), eq("ORD-001"), eq(new BigDecimal("99.99"))))
.thenReturn(sampleOrder);
mockMvc.perform(post("/api/v1/orders")
.header("X-Tenant-Id", "100")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("OK"))
.andExpect(jsonPath("$.data.id").value(1))
.andExpect(jsonPath("$.data.orderNo").value("ORD-001"))
.andExpect(jsonPath("$.data.status").value("CREATED"));
verify(appService).create(100L, 10L, "ORD-001", new BigDecimal("99.99"));
}
@--- @DisplayName("GET /api/v1/orders/{id} → 200 OK with order")
void get_found() throws Exception {
Order sampleOrder = createSampleOrder();
when(appService.get(eq(100L), eq(1L))).thenReturn(Optional.of(sampleOrder));
mockMvc.perform(get("/api/v1/orders/1")
.header("X-Tenant-Id", "100"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("OK"))
.andExpect(jsonPath("$.data.id").value(1))
.andExpect(jsonPath("$.data.orderNo").value("ORD-001"));
}
@--- @DisplayName("GET /api/v1/orders/{id} → 200 OK with error when not found")
void get_notFound() throws Exception {
when(appService.get(eq(100L), eq(999L))).thenReturn(Optional.empty());
mockMvc.perform(get("/api/v1/orders/999")
.header("X-Tenant-Id", "100"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("ORDER_NOT_FOUND"))
.andExpect(jsonPath("$.message").value("Order not found"))
.andExpect(jsonPath("$.data").doesNotExist());
}
@--- @DisplayName("GET /api/v1/orders → 200 OK with paged list")
void list_success() throws Exception {
Order sampleOrder = createSampleOrder();
when(appService.list(eq(100L), eq(1), eq(20))).thenReturn(List.of(sampleOrder));
mockMvc.perform(get("/api/v1/orders")
.header("X-Tenant-Id", "100")
.param("pageNum", "1")
.param("pageSize", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("OK"))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].id").value(1));
}
@--- @DisplayName("PUT /api/v1/orders/{id}/status → 200 OK with updated order")
void updateStatus_success() throws Exception {
Order updatedOrder = createSampleOrder();
updatedOrder.setStatus("PAID");
UpdateOrderRequest req = new UpdateOrderRequest();
req.setStatus("PAID");
when(appService.updateStatus(eq(100L), eq(1L), eq("PAID")))
.thenReturn(updatedOrder);
mockMvc.perform(put("/api/v1/orders/1/status")
.header("X-Tenant-Id", "100")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("OK"))
.andExpect(jsonPath("$.data.status").value("PAID"));
verify(appService).updateStatus(100L, 1L, "PAID");
}
@--- @DisplayName("DELETE /api/v1/orders/{id} → 200 OK")
void delete_success() throws Exception {
doNothing().when(appService).softDelete(100L, 1L);
mockMvc.perform(delete("/api/v1/orders/1")
.header("X-Tenant-Id", "100"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("OK"))
.andExpect(jsonPath("$.data").value(org.hamcrest.Matchers.nullValue()));
verify(appService).softDelete(100L, 1L);
}
}
@@ -0,0 +1,7 @@
/**
* Unit tests for API controllers using @WebMvcTest.
*
* <p>Mocks application services to isolate controller logic.
* Verifies request mapping, parameter binding, and response formatting.
*/
package com.ljwx.tmpl.api.controller;
@@ -0,0 +1,177 @@
package com.ljwx.tmpl.api.integration;
import com.ljwx.tmpl.api.dto.CreateOrderRequest;
import com.ljwx.tmpl.api.dto.UpdateOrderRequest;
import com.ljwx.tmpl.api.integration.support.AbstractPostgresIT;
import com.ljwx.tmpl.application.context.TenantContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end integration test for Order CRUD API.
*
* <p>Verifies the complete request/response cycle:
* HTTP → Controller → AppService → Repository → PostgreSQL (Testcontainers).
* Confirms tenant isolation, audit field population, and SpringDoc availability.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@BeforeEach
void setUp() {
TenantContext.clear();
}
private HttpHeaders createTenantHeaders(String tenantId) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Tenant-Id", tenantId);
headers.set("X-Dept-Id", "10");
headers.set("X-User-Id", "42");
headers.set("X-User-Roles", "ROLE_USER");
return headers;
}
@Test
@DisplayName("Create order → GET → verify response")
void createThenGet() {
HttpHeaders headers = createTenantHeaders("100");
// Create order
CreateOrderRequest createReq = new CreateOrderRequest();
createReq.setDeptId(10L);
createReq.setOrderNo("IT-ORD-001");
createReq.setAmount(new BigDecimal("88.50"));
ResponseEntity<String> createResp = restTemplate.exchange(
"/api/v1/orders",
HttpMethod.POST,
new HttpEntity<>(createReq, headers),
String.class);
assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(createResp.getBody())
.contains("\"code\":\"OK\"")
.contains("\"orderNo\":\"IT-ORD-001\"")
.contains("\"status\":\"CREATED\"")
.contains("\"createUserId\"");
// List orders
ResponseEntity<String> listResp = restTemplate.exchange(
"/api/v1/orders?pageNum=1&pageSize=10",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(listResp.getBody()).contains("IT-ORD-001");
}
@Test
@DisplayName("Update order status")
void updateStatus() {
HttpHeaders headers = createTenantHeaders("100");
// Create first
CreateOrderRequest createReq = new CreateOrderRequest();
createReq.setDeptId(10L);
createReq.setOrderNo("IT-ORD-002");
createReq.setAmount(new BigDecimal("50.00"));
ResponseEntity<String> createResp = restTemplate.exchange(
"/api/v1/orders",
HttpMethod.POST,
new HttpEntity<>(createReq, headers),
String.class);
assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// Extract ID from response (simple string parsing)
String body = createResp.getBody();
int idStart = body.indexOf("\"id\":") + 5;
int idEnd = body.indexOf(",", idStart);
if (idEnd == -1) idEnd = body.indexOf("}", idStart); // in case it's the last field
String idStr = body.substring(idStart, idEnd).trim();
Long orderId = Long.valueOf(idStr);
// Update status
UpdateOrderRequest updateReq = new UpdateOrderRequest();
updateReq.setStatus("PAID");
ResponseEntity<String> updateResp = restTemplate.exchange(
"/api/v1/orders/" + orderId + "/status",
HttpMethod.PUT,
new HttpEntity<>(updateReq, headers),
String.class);
assertThat(updateResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(updateResp.getBody()).contains("\"status\":\"PAID\"");
}
@Test
@DisplayName("Soft-delete order → GET returns not found")
void softDelete() {
HttpHeaders headers = createTenantHeaders("100");
// Create first
CreateOrderRequest createReq = new CreateOrderRequest();
createReq.setDeptId(10L);
createReq.setOrderNo("IT-ORD-003");
createReq.setAmount(new BigDecimal("30.00"));
ResponseEntity<String> createResp = restTemplate.exchange(
"/api/v1/orders",
HttpMethod.POST,
new HttpEntity<>(createReq, headers),
String.class);
assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// Extract ID
String body = createResp.getBody();
int idStart = body.indexOf("\"id\":") + 5;
int idEnd = body.indexOf(",", idStart);
if (idEnd == -1) idEnd = body.indexOf("}", idStart);
String idStr = body.substring(idStart, idEnd).trim();
Long orderId = Long.valueOf(idStr);
// Delete
ResponseEntity<String> deleteResp = restTemplate.exchange(
"/api/v1/orders/" + orderId,
HttpMethod.DELETE,
new HttpEntity<>(headers),
String.class);
assertThat(deleteResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// GET should return not found (soft-deleted)
ResponseEntity<String> getResp = restTemplate.exchange(
"/api/v1/orders/" + orderId,
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
assertThat(getResp.getBody()).contains("ORDER_NOT_FOUND");
}
@Test
@DisplayName("SpringDoc OpenAPI endpoint is accessible")
void openApiEndpoint() {
ResponseEntity<String> resp = restTemplate.getForEntity("/v3/api-docs", String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).contains("\"openapi\"").contains("Order");
}
}
@@ -1,7 +1,7 @@
/**
* Integration tests using Testcontainers (PostgreSQL).
* Integration tests using Testcontainers for end-to-end verification.
*
* <p>Verify end-to-end behaviour: Flyway migration, JPA auditing,
* persistence layer correctness, and tenant isolation.
* <p>Verifies full request/response cycle: HTTP → Controller → AppService → Repository → DB.
* Includes tenant isolation, audit field population, and security validation.
*/
package com.ljwx.tmpl.api.integration;
@@ -0,0 +1,41 @@
package com.ljwx.tmpl.api.integration.support;
import com.ljwx.tmpl.api.TmplApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* Base class for integration tests requiring a PostgreSQL database.
*
* <p>Starts a shared Testcontainers PostgreSQL instance and configures
* Spring properties. Redis auto-configuration is disabled to avoid
* connection failures during tests that don't require Redis.
*
* <p>Subclasses should use { @--- @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)}
* to start the full application context.
*/
@Testcontainers@SpringBootTest(classes = TmplApplication.class)
public abstract class AbstractPostgresIT {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("tmpl_test")
.withUsername("tmpl")
.withPassword("tmpl_pass");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.flyway.enabled", () -> "true");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
// Disable Redis auto-configuration to avoid connection issues
registry.add("spring.autoconfigure.exclude",
() -> "org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration");
}
}
@@ -0,0 +1,7 @@
/**
* Support classes for integration tests.
*
* <p>Includes Testcontainers base classes, test utilities,
* and shared test configuration.
*/
package com.ljwx.tmpl.api.integration.support;
@@ -0,0 +1,98 @@
package com.ljwx.tmpl.application.order;
import com.ljwx.tmpl.domain.exception.DomainException;
import com.ljwx.tmpl.domain.exception.ErrorCode;
import com.ljwx.tmpl.domain.order.Order;
import com.ljwx.tmpl.domain.order.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
/**
* Application service for order operations.
*
* <p>{@code @Transactional(readOnly=true)} at class level; write methods
* override with {@code @Transactional}. This is the <strong>only</strong>
* place where {@code @Transactional} may appear (ArchUnit A-4).
*
* <p>Depends on {@link OrderRepository} (domain port), NOT on any
* infrastructure adapter (ArchUnit A-1 / A-3).
*/
@Service
@Transactional(readOnly = true)
public class OrderAppService {
private final OrderRepository orderRepository;
public OrderAppService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public Order create(Long tenantId, Long deptId, String orderNo, BigDecimal amount) {
if (tenantId == null || tenantId <= 0) {
throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "tenantId is invalid");
}
if (deptId == null || deptId <= 0) {
throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "deptId is invalid");
}
if (orderNo == null || orderNo.isBlank()) {
throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "orderNo is required");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new DomainException(ErrorCode.ORDER_AMOUNT_INVALID, "amount must not be negative");
}
Order order = new Order(tenantId, deptId, orderNo, amount, "CREATED");
return orderRepository.save(order);
}
public Optional<Order> get(Long tenantId, Long id) {
if (tenantId == null || tenantId <= 0 || id == null || id <= 0) {
return Optional.empty();
}
return orderRepository.findByIdAndTenantId(id, tenantId)
.filter(o -> !Boolean.TRUE.equals(o.getDeleted()));
}
public List<Order> list(Long tenantId, int pageNum, int pageSize) {
if (tenantId == null || tenantId <= 0) {
return List.of();
}
if (pageNum < 1) pageNum = 1;
if (pageSize < 1) pageSize = 20;
if (pageSize > 200) pageSize = 200;
return orderRepository.findAllByTenantId(tenantId, pageNum, pageSize);
}
@Transactional
public Order updateStatus(Long tenantId, Long id, String status) {
if (status == null || status.isBlank()) {
throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "status is required");
}
Order order = orderRepository.findByIdAndTenantId(id, tenantId)
.orElseThrow(() -> new DomainException(ErrorCode.ORDER_NOT_FOUND, "order not found"));
if (Boolean.TRUE.equals(order.getDeleted())) {
throw new DomainException(ErrorCode.ORDER_STATUS_INVALID, "cannot update a deleted order");
}
order.setStatus(status);
return orderRepository.save(order);
}
@Transactional
public void softDelete(Long tenantId, Long id) {
Order order = orderRepository.findByIdAndTenantId(id, tenantId)
.orElseThrow(() -> new DomainException(ErrorCode.ORDER_NOT_FOUND, "order not found"));
if (!Boolean.TRUE.equals(order.getDeleted())) {
order.setDeleted(true);
orderRepository.save(order);
}
}
}
@@ -1,45 +1,47 @@
package com.ljwx.tmpl.domain.exception;
/**
* 全局错误码枚举 —— 唯一映射源。
* Global error code enum — single source of truth.
*
* <p>每个枚举自带 HTTP 状态码与默认消息,{@code GlobalExceptionHandler}
* 仅负责将其翻译为 {@code ApiResponse},不做额外映射。
* <p>Each enum carries HTTP status and default message.
* { @code GlobalExceptionHandler} translates to { @code ApiResponse},
* no extra mapping.
*
* <h3>不可变契约</h3>
* <h3>Immutable Contract</h3>
* <ul>
* <li>已发布的枚举 name 与 httpStatus <b>不可修改</b></li>
* <li>废弃时使用 {@code @Deprecated},不可删除</li>
* <li>只可新增</li>
* <li>Published enum { @code name} and { @code httpStatus} <b>must not change</b></li>
* <li>Deprecate with { @code @Deprecated}, never delete</li>
* <li>Additions only</li>
* </ul>
*
* @see DomainException
* @see <a href="docs/contracts/errors.md">错误码契约文档</a>
* @see <a href="docs/contracts/errors.md">Error Code Contract Doc</a>
*/
public enum ErrorCode {
// ==================== COMMON(通用) ====================
COMMON_INVALID_PARAM(400, "参数校验失败"),
COMMON_NOT_FOUND(404, "资源不存在"),
COMMON_RESOURCE_ALREADY_EXISTS(409, "资源已存在"),
COMMON_OPTIMISTIC_LOCK(409, "数据已被修改,请刷新重试"),
COMMON_INTERNAL_ERROR(500, "系统内部错误"),
// ==================== COMMON ====================
COMMON_INVALID_PARAM(400, "Invalid parameter"),
COMMON_NOT_FOUND(404, "Resource not found"),
COMMON_RESOURCE_ALREADY_EXISTS(409, "Resource already exists"),
COMMON_OPTIMISTIC_LOCK(409, "Data has been modified, please refresh"),
COMMON_INTERNAL_ERROR(500, "Internal server error"),
COMMON_BAD_REQUEST(400, "Bad request"),
// ==================== AUTH(认证/租户) ====================
AUTH_MISSING_TENANT(400, "缺少租户标识(X-Tenant-Id)"),
AUTH_MISSING_DEPT(400, "缺少部门标识(X-Dept-Id)"),
AUTH_MISSING_USER(400, "缺少用户标识(X-User-Id)"),
AUTH_INVALID_USER_ID(400, "用户标识格式不合法"),
AUTH_HEADER_CONFLICT(400, "新旧租户 Header 值不一致"),
AUTH_LEGACY_HEADER_NOT_ALLOWED(400, "当前环境不允许使用旧版 Header"),
AUTH_INVALID_HEADER_VALUE(400, "Header 值格式非法"),
AUTH_ACCESS_DENIED(403, "无权限"),
AUTH_FORBIDDEN_SCOPE(403, "无权访问该部门范围的数据"),
// ==================== AUTH ====================
AUTH_MISSING_TENANT(400, "Missing tenant identifier (X-Tenant-Id)"),
AUTH_MISSING_DEPT(400, "Missing department identifier (X-Dept-Id)"),
AUTH_MISSING_USER(400, "Missing user identifier (X-User-Id)"),
AUTH_INVALID_USER_ID(400, "User identifier format invalid"),
AUTH_HEADER_CONFLICT(400, "New/old tenant header values differ"),
AUTH_LEGACY_HEADER_NOT_ALLOWED(400, "Legacy header not allowed in current env"),
AUTH_INVALID_HEADER_VALUE(400, "Header value format illegal"),
AUTH_ACCESS_DENIED(403, "Access denied"),
AUTH_FORBIDDEN_SCOPE(403, "Forbidden scope access"),
// ==================== ORDER(示例领域) ====================
ORDER_NOT_FOUND(404, "订单不存在"),
ORDER_AMOUNT_INVALID(422, "订单金额不合法"),
ORDER_STATUS_INVALID(422, "订单状态不允许此操作"),
// ==================== ORDER ====================
ORDER_NOT_FOUND(404, "Order not found"),
ORDER_AMOUNT_INVALID(422, "Order amount invalid"),
ORDER_STATUS_INVALID(422, "Order status invalid"),
;
private final int httpStatus;
@@ -1,5 +1,6 @@
package com.ljwx.tmpl.domain.order;
import java.util.List;
import java.util.Optional;
/**
@@ -30,4 +31,14 @@ public interface OrderRepository {
* @return the order if found, otherwise empty
*/
Optional<Order> findByIdAndTenantId(Long id, Long tenantId);
/**
* Find all orders for a tenant with simple pagination.
*
* @param tenantId tenant id for data isolation
* @param pageNum page number, starting from 1
* @param pageSize number of items per page
* @return list of orders (may be empty)
*/
List<Order> findAllByTenantId(Long tenantId, int pageNum, int pageSize);
}
+1 -1
View File
@@ -8,7 +8,7 @@
<parent>
<groupId>com.ljwx.tmpl</groupId>
<artifactId>java-service-template</artifactId>
<version>0.0.1-SNAPSHOT</version>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>tmpl-infrastructure</artifactId>
@@ -2,19 +2,22 @@ package com.ljwx.tmpl.infrastructure.persistence.order;
import com.ljwx.tmpl.domain.order.Order;
import com.ljwx.tmpl.domain.order.OrderRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* Infrastructure adapter implementing the domain
* {@link OrderRepository} port.
* { @link OrderRepository} port.
*
* <p>Delegates to the package-private {@link OrderJpaRepository} for
* data access and uses {@link OrderMapper} (MapStruct) for
* {@link Order} ↔ {@link OrderPO} conversion.
* <p>Delegates to the package-private { @link OrderJpaRepository} for
* data access and uses { @link OrderMapper} (MapStruct) for
* { @link Order} ↔ { @link OrderPO} conversion.
*/
@Repository
@Repository
public class OrderDao implements OrderRepository {
private final OrderJpaRepository jpaRepo;
@@ -29,21 +32,30 @@ public class OrderDao implements OrderRepository {
public Order save(Order order) {
OrderPO po;
if (order.getId() != null) {
// Update: load managed entity to preserve audit + version,
// then apply business fields only.
// Update: load managed entity to preserve audit + version
po = jpaRepo.findById(order.getId()).orElseGet(OrderPO::new);
mapper.updatePO(order, po);
mapper.updatePOFromDomain(order, po);
} else {
// Insert: create new PO with business fields only.
po = mapper.toPO(order);
// Insert: create new PO with business fields only
po = mapper.toPOFromDomain(order);
}
OrderPO saved = jpaRepo.save(po);
return mapper.toDomain(saved);
return mapper.toDomainFromPO(saved);
}
@Override
public Optional<Order> findByIdAndTenantId(Long id, Long tenantId) {
return jpaRepo.findByIdAndTenantId(id, tenantId)
.map(mapper::toDomain);
.map(mapper::toDomainFromPO);
}
@Override
public List<Order> findAllByTenantId(Long tenantId, int pageNum, int pageSize) {
PageRequest pageable = PageRequest.of(pageNum - 1, pageSize, Sort.by(Sort.Direction.DESC, "id"));
return jpaRepo.findAllByTenantIdAndDeletedFalse(tenantId, pageable)
.getContent()
.stream()
.map(mapper::toDomainFromPO)
.toList();
}
}
@@ -1,16 +1,21 @@
package com.ljwx.tmpl.infrastructure.persistence.order;
import com.ljwx.tmpl.infrastructure.persistence.BaseAuditPO;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* Spring Data JPA repository for {@link OrderPO}.
* Spring Data JPA repository for { @link OrderPO}.
*
* <p><strong>Package-private</strong> — external code must use
* {@link OrderDao} instead. ArchUnit rule A-7 enforces this.
* { @link OrderDao} instead. ArchUnit rule A-7 enforces this.
*/
interface OrderJpaRepository extends JpaRepository<OrderPO, Long> {
Optional<OrderPO> findByIdAndTenantId(Long id, Long tenantId);
Page<OrderPO> findAllByTenantIdAndDeletedFalse(Long tenantId, Pageable pageable);
}