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:
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+24
-12
@@ -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();
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user