Command Palette

Search for a command to run...

[Spring Boot Basics] Dự án tổng kết: REST API quản lý đơn hàng với Spring Boot

Bốn mươi mốt bài đã dựng một catalogue theo từng mảnh, mỗi mảnh trong một project riêng: endpoint ở Chương 3, JPA và Flyway ở Chương 4, user và JWT ở Chương 5, test ở Chương 6, Actuator và Docker ở Chương 7. Bài tổng kết này ghép mọi mảnh vào một ứng dụng, một REST API quản lý đơn hàng: khách hàng đăng ký và đăng nhập, xem catalogue product có phân trang, đặt order và order trừ stock, hủy order, còn administrator thì ship order. Ứng dụng chạy trên H2 khi phát triển và trên PostgreSQL 18 trong Docker Compose, tự sinh tài liệu trong Swagger UI, và có test ở mọi tầng.

Ghép các tính năng lại là lúc vấn đề mới xuất hiện, nên bài này không giảng lại những gì các bài trước đã giải thích; nó trỏ về các bài đó bằng số thứ tự và dành chữ cho các tương tác: stock bị bán quá khi có request đồng thời cho tới khi row được lock, một tham số Swagger UI bị Pageable làm hỏng, một @DataJpaTest fail khi thiếu cấu hình auditing, một check constraint của H2 ngừng hoạt động sau khi connection của Flyway đóng, và một private key phải tới được container mà không nằm trong image.

Một order đi qua API vào stack container, kèm một ổ khóa trên stock

Dự án dùng Spring Boot 4.1.1, Java 21 và springdoc-openapi 3.1.1, với H2 cho môi trường phát triển và test, và PostgreSQL 18 trong Docker Compose. Request được gửi bằng curl và jq, và ứng dụng lắng nghe trên host port 8142 thay vì 8080 mặc định.

Dự án tổng kết gồm những gì và mỗi phần được dạy ở bài nào

Phần của projectDùng gìĐược dạy ở
Endpoint, status code, URL cho action hủy orderbảng thiết kế mẫu, 400 so với 422, 409 cho trạng tháibài 15
DTO của request và validationrecord với Bean Validation, @Validbài 18 và 19
Response lỗiProblemDetail, ResponseEntityExceptionHandler, danh sách field cho 422, catch-allbài 20
Bố cục package và các tầngpackage theo feature, controller map DTO, service giữ rulebài 21
Tài liệu APIspringdoc-openapi 3.1.1, Swagger UIbài 22
Entity và repositoryJpaRepository, derived query, @Querybài 26 và 27
Order và các line@ManyToOne(fetch = LAZY), mappedBy, cascade, orphanRemoval, @EntityGraphbài 28
Danh sách có phân trangPageable, @PageableDefault, PageResponse, max-page-sizebài 29
Order được lưu trọn vẹn hoặc không gì cả@Transactional trên use casebài 30
Schemamigration Flyway, ddl-auto=validate trên PostgreSQLbài 31
Timestamp auditAuditableEntity, @EnableJpaAuditing, DateTimeProviderbài 32
Tài khoản và passwordbảng users, BCrypt, UserDetailsService, đăng kýbài 34
Tokencặp RSA key, NimbusJwtEncoder, resource server, roles thành ROLE_bài 35
AuthorizationURL rule, @EnableMethodSecurity, @PreAuthorize, rethrow AccessDeniedExceptionbài 36
Unit testJUnit 6, AssertJ, Mockitobài 37
Test slice và test end-to-end@WebMvcTest, @DataJpaTest, RANDOM_PORT với RestTestClientbài 38
HealthcheckActuator /actuator/healthbài 39
ContainerDockerfile multi-stage, Compose với PostgreSQL và healthcheckbài 41
Cấu hìnhprofile, environment variablebài 11 và 13

Các package và tầng của dự án tổng kết, mỗi phần ghi số bài đã dạy nó: security filter chain đứng trước các controller, service với method security và transaction, repository, Flyway và PostgreSQL ở dưới, springdoc và Actuator bên cạnh API

Tạo project

Các id của Spring Initializr giống các bài trước, thêm springdoc-openapiactuator:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=orders&name=orders&packageName=com.example.orders&dependencies=web,validation,data-jpa,postgresql,h2,flyway,security,oauth2-resource-server,actuator,springdoc-openapi" -o orders.zip
Bash
unzip orders.zip -d orders

build.gradle được sinh ra có đủ các starter và test starter, kể cả spring-boot-starter-actuator-test, cùng springdoc-openapi-starter-webmvc-ui:3.1.0. Bốn thay đổi biến nó thành file build của dự án tổng kết:

  • springdoc 3.1.1 thay cho 3.1.0, để có Swagger UI đã vá lỗi DOMPurify, như bài 22 khuyên.
  • Mockito agent của bài 37, để JVM chạy test nạp Mockito bằng -javaagent thay vì tự attach.
  • testLogging với passed, skippedfailed, để console liệt kê từng test.
  • Tắt task jar thường, điều mà lệnh COPY build/libs/*.jar trong Dockerfile của bài 41 dựa vào.
build.gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '4.1.1'
	id 'io.spring.dependency-management' version '1.1.7'
}
 
group = 'com.example'
version = '0.0.1-SNAPSHOT'
 
java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(21)
	}
}
 
configurations {
	mockitoAgent
}
 
repositories {
	mavenCentral()
}
 
dependencies {
	implementation 'org.springframework.boot:spring-boot-h2console'
	implementation 'org.springframework.boot:spring-boot-starter-actuator'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-flyway'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'org.flywaydb:flyway-database-postgresql'
	implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'org.postgresql:postgresql'
	testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	mockitoAgent('org.mockito:mockito-core') {
		transitive = false
	}
}
 
tasks.named('test') {
	useJUnitPlatform()
	jvmArgs += "-javaagent:${configurations.mockitoAgent.asPath}"
	testLogging {
		events 'passed', 'skipped', 'failed'
	}
}
 
tasks.named('jar') {
	enabled = false
}

RSA key nằm ngoài cây source

Bài 35 sinh cặp key vào src/main/resources/certs, ổn cho một bài lab nhưng sai với bất cứ thứ gì được build thành image: mọi thứ dưới src/main/resources đều vào JAR. Ở đây key nằm trong thư mục secrets ở gốc project, nơi cả Git lẫn Docker đều không thấy:

Bash
mkdir -p secrets
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out secrets/private.pem
openssl pkey -in secrets/private.pem -pubout -out secrets/public.pem
Bash
printf '\n### Local secrets ###\nsecrets/\n.env\n' >> .gitignore

Ứng dụng đọc key qua location file: tính từ thư mục làm việc, chính là gốc project khi chạy ./gradlew bootRun, ./gradlew testjava -jar build/libs/orders-0.0.1-SNAPSHOT.jar từ đó. Phần Docker cho container một bản riêng lúc chạy.

Cây thư mục của project

Tree
orders
├── build.gradle
├── compose.yaml
├── Dockerfile
├── .dockerignore
├── .env                          not committed: APP_ADMIN_PASSWORD
├── secrets                       not committed: private.pem, public.pem
└── src
    ├── main
    │   ├── java/com/example/orders
    │   │   ├── OrdersApplication.java
    │   │   ├── common
    │   │   │   ├── AuditableEntity.java
    │   │   │   ├── AuditingConfig.java
    │   │   │   ├── AuthConfig.java
    │   │   │   ├── ConflictException.java
    │   │   │   ├── GlobalExceptionHandler.java
    │   │   │   ├── MethodSecurityConfig.java
    │   │   │   ├── NotFoundException.java
    │   │   │   ├── OpenApiConfig.java
    │   │   │   ├── PageResponse.java
    │   │   │   ├── ProblemDetailSecurityHandler.java
    │   │   │   └── SecurityConfig.java
    │   │   ├── order
    │   │   │   ├── Order.java
    │   │   │   ├── OrderAccess.java
    │   │   │   ├── OrderController.java
    │   │   │   ├── OrderItem.java
    │   │   │   ├── OrderLine.java
    │   │   │   ├── OrderNotFoundException.java
    │   │   │   ├── OrderRepository.java
    │   │   │   ├── OrderResponse.java
    │   │   │   ├── OrderService.java
    │   │   │   ├── OrderStatus.java
    │   │   │   ├── OrderStatusException.java
    │   │   │   └── PlaceOrderRequest.java
    │   │   ├── product
    │   │   │   ├── CreateProductRequest.java
    │   │   │   ├── DuplicateSkuException.java
    │   │   │   ├── InsufficientStockException.java
    │   │   │   ├── Product.java
    │   │   │   ├── ProductController.java
    │   │   │   ├── ProductNotFoundException.java
    │   │   │   ├── ProductRepository.java
    │   │   │   ├── ProductResponse.java
    │   │   │   └── ProductService.java
    │   │   └── user
    │   │       ├── AdminAccountInitializer.java
    │   │       ├── AppUser.java
    │   │       ├── AppUserRepository.java
    │   │       ├── AuthController.java
    │   │       ├── JpaUserDetailsService.java
    │   │       ├── LoginRequest.java
    │   │       ├── RegisterRequest.java
    │   │       ├── Role.java
    │   │       ├── TokenResponse.java
    │   │       ├── TokenService.java
    │   │       ├── UserAlreadyExistsException.java
    │   │       ├── UserResponse.java
    │   │       └── UserService.java
    │   └── resources
    │       ├── application.properties
    │       ├── application-postgres.properties
    │       └── db/migration
    │           ├── V1__create_users.sql
    │           ├── V2__create_products.sql
    │           └── V3__create_orders.sql
    └── test/java/com/example/orders
        ├── OrdersApplicationTests.java
        └── order
            ├── OrderApiTest.java
            ├── OrderControllerTest.java
            ├── OrderRepositoryTest.java
            └── OrderServiceTest.java

OrdersApplicationOrdersApplicationTests là của Initializr, giữ nguyên. Hai thư mục statictemplates mà Initializr tạo thì để trống.

API: endpoint, role và status code

Thiết kế của bài 15 đặt order dưới customer của nó, POST /api/customers/{id}/orders. Với JWT thì customer đã được xác định: subject của token cho biết ai đang gọi, nên một customer id trên URL chỉ có thể lặp lại thông tin đó, và mỗi request mà hai giá trị không khớp lại cần một bước kiểm tra với kết quả duy nhất là 403. Vì vậy dự án tổng kết đặt order bằng POST /api/orders cho user đã đăng nhập và giữ phần còn lại của thiết kế order ở bài 15: URL riêng của order mới trong Location, và hủy order là action POST /api/orders/{id}/cancel. Ship order theo đúng dạng đó như một action của administrator, còn order của người khác thì trả 403, như ở bài 36.

MethodPathAi được gọiThành côngLỗi
POST/api/auth/registerai cũng được201400, 409 username hoặc email đã có, 422
POST/api/auth/loginai cũng được200 kèm token400, 401 sai thông tin đăng nhập, 422
GET/api/auth/međã đăng nhập200401
GET/api/productsai cũng được200, một page; ?page=, ?size=, ?sort=400 sort property không tồn tại
GET/api/products/{id}ai cũng được200400, 404
POST/api/productsADMIN201 + Location400, 401, 403, 409 trùng SKU, 415, 422
POST/api/ordersđã đăng nhập201 + Location: /api/orders/{orderId}400, 401, 404 product không tồn tại, 409 không đủ stock, 415, 422
GET/api/ordersđã đăng nhập: order của mình; ADMIN: tất cả200, một page400, 401
GET/api/orders/{id}chủ order hoặc ADMIN200400, 401, 403 order của người khác, 404
POST/api/orders/{id}/cancelchủ order hoặc ADMIN200400, 401, 403, 404, 409 không còn PLACED
POST/api/orders/{id}/shipADMIN200400, 401, 403, 404, 409 không còn PLACED

Mọi status trong bảng đều đã được tạo ra từ ứng dụng hoàn chỉnh. Product không có PUT hay DELETE ở đây: order line tham chiếu tới product, và một endpoint nhập thêm hàng cho admin sẽ phải lấy cùng row lock mà việc đặt order lấy.

Cấu hình và migration Flyway

src/main/resources/application.properties
spring.application.name=orders
spring.jpa.open-in-view=false
spring.flyway.validate-migration-naming=true
spring.data.web.pageable.max-page-size=100
 
spring.security.oauth2.resourceserver.jwt.public-key-location=file:secrets/public.pem
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_
app.jwt.private-key-location=file:secrets/private.pem
app.jwt.issuer=https://orders.example.com
 
app.admin.username=admin
app.admin.email=admin@example.com
  • open-in-view=false như mọi bài từ bài 26, để mỗi endpoint tự fetch những gì nó map.
  • validate-migration-naming=true từ bài 31, để một migration đặt sai tên làm dừng lúc khởi động thay vì bị bỏ qua.
  • max-page-size=100 từ bài 29.
  • Location của key dùng file: thay cho classpath: của bài 35, vì lý do ở trên.
  • app.jwt.issuer thay cho hằng số trong TokenService của bài 35; cùng giá trị đó được dùng cho issuer validator.
  • app.admin.* cấu hình administrator đầu tiên. Password không có giá trị mặc định; phần về user giải thích vì sao.

Profile postgres là của bài 41, với port của project này và không có password:

src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:5442/shop
spring.datasource.username=shop
spring.jpa.hibernate.ddl-auto=validate

Ba migration, mỗi feature một file, theo phong cách của bài 31: constraint có tên, index trên foreign key, NUMERIC(10, 2) cho tiền, TIMESTAMP WITH TIME ZONE cho các cột audit Instant của bài 32, và các check bảo vệ rule về stock ngay cả trước code không đi qua entity:

src/main/resources/db/migration/V1__create_users.sql
CREATE TABLE users (
    id            BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    username      VARCHAR(50)  NOT NULL,
    email         VARCHAR(254) NOT NULL,
    password_hash VARCHAR(100) NOT NULL,
    role          VARCHAR(20)  NOT NULL,
    enabled       BOOLEAN      NOT NULL,
    CONSTRAINT uk_users_username UNIQUE (username),
    CONSTRAINT uk_users_email UNIQUE (email)
);
src/main/resources/db/migration/V2__create_products.sql
CREATE TABLE products (
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name       VARCHAR(120)             NOT NULL,
    sku        VARCHAR(40)              NOT NULL,
    price      NUMERIC(10, 2)           NOT NULL,
    stock      INTEGER                  NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL,
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
    CONSTRAINT uk_products_sku UNIQUE (sku),
    CONSTRAINT ck_products_price CHECK (price > 0),
    CONSTRAINT ck_products_stock CHECK (stock >= 0)
);
src/main/resources/db/migration/V3__create_orders.sql
CREATE TABLE orders (
    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    customer_id BIGINT                   NOT NULL,
    status      VARCHAR(20)              NOT NULL,
    created_at  TIMESTAMP WITH TIME ZONE NOT NULL,
    updated_at  TIMESTAMP WITH TIME ZONE NOT NULL,
    CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES users (id)
);
 
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
 
CREATE TABLE order_lines (
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    order_id   BIGINT         NOT NULL,
    product_id BIGINT         NOT NULL,
    quantity   INTEGER        NOT NULL,
    unit_price NUMERIC(10, 2) NOT NULL,
    CONSTRAINT fk_order_lines_order FOREIGN KEY (order_id) REFERENCES orders (id),
    CONSTRAINT fk_order_lines_product FOREIGN KEY (product_id) REFERENCES products (id),
    CONSTRAINT ck_order_lines_quantity CHECK (quantity > 0)
);
 
CREATE INDEX idx_order_lines_order_id ON order_lines (order_id);
CREATE INDEX idx_order_lines_product_id ON order_lines (product_id);

Vì sao role và status không có CHECK constraint

Phiên bản đầu của các migration này có CONSTRAINT ck_users_role CHECK (role IN ('USER', 'ADMIN')) và một check tương tự trên orders.status. Ứng dụng khởi động được trên H2 và PostgreSQL, và đăng ký chạy được trên cả hai. Rồi @DataJpaTest fail ngay lần persist user đầu tiên:

Text
org.hibernate.exception.ConstraintViolationException: could not execute statement [Check constraint invalid: "CK_USERS_ROLE: "; SQL statement:
insert into users (email,enabled,password_hash,role,username,id) values (?,?,?,?,?,default) [23514-240]]
Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Check constraint invalid: "CK_USERS_ROLE: "; SQL statement:
Caused by: org.h2.message.DbException: The database has been closed [90098-240]

Một chương trình JDBC thuần đã khoanh vùng vấn đề trên H2 2.4.240: một CHECK so sánh chuỗi, dù là IN (...) hay = 'USER' OR = 'ADMIN', vẫn chạy khi connection đã tạo bảng còn mở, và fail với đúng lỗi này trên một connection mới sau khi connection kia đóng; một check số như quantity > 0 thì vẫn chạy bình thường. Trong ứng dụng, connection của Flyway quay về pool HikariCP và vẫn mở, nên đăng ký chạy được; DataSource embedded mà @DataJpaTest thay vào thì trả về các org.h2.jdbc.JdbcConnection thuần, và một test thăm dò thấy isClosed() trả true sau close(), nên lúc test insert thì connection của Flyway đã đóng. Giá trị của cả hai cột đều đến từ enum Java qua @Enumerated(EnumType.STRING), nên dự án giữ các check số và bỏ hai check chuỗi, thay vì phụ thuộc vào việc connection nào tình cờ còn mở.

Code dùng chung trong common

Bài 21 gợi ý các feature ném subclass của vài exception gốc nằm trong common, để advice không phải import class của từng feature. Dự án tổng kết làm đúng như vậy, mỗi status một class gốc:

src/main/java/com/example/orders/common/NotFoundException.java
package com.example.orders.common;
 
public abstract class NotFoundException extends RuntimeException {
 
    protected NotFoundException(String message) {
        super(message);
    }
}
src/main/java/com/example/orders/common/ConflictException.java
package com.example.orders.common;
 
public abstract class ConflictException extends RuntimeException {
 
    protected ConflictException(String message) {
        super(message);
    }
}

AuditableEntity là của bài 32 nhưng bỏ hai cột createdByupdatedBy; order vốn đã ghi lại customer của nó:

src/main/java/com/example/orders/common/AuditableEntity.java
package com.example.orders.common;
 
import java.time.Instant;
 
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
 
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
 
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
 
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdAt;
 
    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;
 
    public Instant getCreatedAt() {
        return createdAt;
    }
 
    public Instant getUpdatedAt() {
        return updatedAt;
    }
}

AuditingConfigDateTimeProvider của bài 32, với một thay đổi mà lần chạy Docker bắt buộc phải có:

src/main/java/com/example/orders/common/AuditingConfig.java
package com.example.orders.common;
 
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
 
@Configuration
@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider")
public class AuditingConfig {
 
    @Bean
    DateTimeProvider auditingDateTimeProvider() {
        return () -> Optional.of(Instant.now().truncatedTo(ChronoUnit.MICROS));
    }
}

Với provider mặc định, một product tạo trong container được POST trả về với "createdAt":"2026-09-16T09:18:35.296391794Z", còn GET ngay sau đó trả "createdAt":"2026-09-16T09:18:35.296392Z". JVM trong container Linux đọc đồng hồ tới nano giây, JSON của POST được ghi từ giá trị đó, còn PostgreSQL lưu và trả về micro giây, đã làm tròn. Trên host macOS đồng hồ chỉ có micro giây, nên không thấy gì ở đó. Cắt xuống micro giây trước khi giá trị vào entity giúp hai response giống hệt nhau, 09:19:15.316336Z trong stack build lại.

PageResponse là record của bài 29, giữ nguyên, nằm trong com.example.orders.common. Advice là subclass ResponseEntityExceptionHandler của bài 20 cùng các handler mà những bài sau đã thêm: 409 cho DataIntegrityViolationException từ bài 26, 400 cho sort property không tồn tại từ bài 29, 401 cho đăng nhập thất bại từ bài 34 và 35, và rethrow AccessDeniedException từ bài 36:

src/main/java/com/example/orders/common/GlobalExceptionHandler.java
package com.example.orders.common;
 
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
 
import jakarta.servlet.http.HttpServletRequest;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.core.PropertyReferenceException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.validation.method.ParameterErrors;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
 
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
 
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
 
    record FieldViolation(String field, String message) {
    }
 
    @ExceptionHandler(NotFoundException.class)
    public ProblemDetail notFound(NotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
 
    @ExceptionHandler(ConflictException.class)
    public ProblemDetail conflict(ConflictException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
    }
 
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ProblemDetail constraintViolated(DataIntegrityViolationException ex) {
        log.warn("Constraint violation: {}", ex.getMostSpecificCause().getMessage());
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The request conflicts with existing data.");
    }
 
    @ExceptionHandler(PropertyReferenceException.class)
    public ProblemDetail unknownSortProperty(PropertyReferenceException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
                "Unknown sort property: " + ex.getPropertyName());
    }
 
    @ExceptionHandler(AuthenticationException.class)
    public ResponseEntity<ProblemDetail> authenticationFailed(AuthenticationException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED,
                "Invalid username or password");
        return ResponseEntity.of(problem)
                .header(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"orders\"")
                .build();
    }
 
    @ExceptionHandler(AccessDeniedException.class)
    public void rethrowAccessDenied(AccessDeniedException ex) {
        throw ex;
    }
 
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
        log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
        problem.setTitle("Internal Server Error");
        return problem;
    }
 
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        List<FieldViolation> errors = new ArrayList<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.add(new FieldViolation(error.getField(), error.getDefaultMessage()));
        }
        HttpStatus responseStatus = HttpStatus.UNPROCESSABLE_CONTENT;
        return handleExceptionInternal(ex, validationProblem(responseStatus, errors), headers, responseStatus, request);
    }
 
    @Override
    protected ResponseEntity<Object> handleHandlerMethodValidationException(
            HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        List<FieldViolation> errors = new ArrayList<>();
        boolean bodyInvalid = false;
        for (ParameterErrors result : ex.getBeanResults()) {
            if (result.getMethodParameter().hasParameterAnnotation(RequestBody.class)) {
                bodyInvalid = true;
            }
            for (FieldError error : result.getFieldErrors()) {
                errors.add(new FieldViolation(error.getField(), error.getDefaultMessage()));
            }
        }
        for (ParameterValidationResult result : ex.getValueResults()) {
            String name = result.getMethodParameter().getParameterName();
            for (MessageSourceResolvable error : result.getResolvableErrors()) {
                errors.add(new FieldViolation(name, error.getDefaultMessage()));
            }
        }
        HttpStatus responseStatus = bodyInvalid ? HttpStatus.UNPROCESSABLE_CONTENT : HttpStatus.BAD_REQUEST;
        return handleExceptionInternal(ex, validationProblem(responseStatus, errors), headers, responseStatus, request);
    }
 
    private ProblemDetail validationProblem(HttpStatus status, List<FieldViolation> errors) {
        errors.sort(Comparator.comparing(FieldViolation::field));
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                status, "Request has " + errors.size() + " invalid value(s).");
        problem.setProperty("errors", errors);
        return problem;
    }
}

Handler đăng nhập bắt AuthenticationException chứ không chỉ BadCredentialsException, nên DisabledException mà bài 34 gặp với tài khoản bị vô hiệu hóa, cũng là một AuthenticationException, được cùng handler đó trả lời.

Security: một filter chain, JWT và method security

Filter chain

Bài 33 chia ứng dụng thành một chain cho API và một chain form login, còn bài 39 thêm chain thứ ba cho Actuator. Dự án tổng kết không phục vụ trang HTML nào, nên một chain duy nhất bao mọi request, và không request nào có thể lọt ra ngoài mọi chain, lỗ hổng mà bài 39 đã tìm thấy với /actuator:

src/main/java/com/example/orders/common/SecurityConfig.java
package com.example.orders.common;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
 
@Configuration
public class SecurityConfig {
 
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll()
                        .requestMatchers(HttpMethod.GET, "/actuator/health").permitAll()
                        .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
                        .requestMatchers(HttpMethod.POST, "/api/orders/*/ship").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(Customizer.withDefaults())
                        .authenticationEntryPoint(problemHandler))
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint(problemHandler)
                        .accessDeniedHandler(problemHandler))
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
}
  • Public: đọc product, đăng ký và đăng nhập (rule của bài 36), GET /actuator/health cho healthcheck của Compose, và các path tài liệu.
  • ADMIN: tạo product và ship order, quyết định theo URL vì rule chỉ phụ thuộc vào path.
  • Đã đăng nhập: mọi thứ còn lại, gồm các endpoint order khác, nơi rule chi tiết hơn là method security bên dưới.
  • Phần còn lại là resource server của bài 35 với ProblemDetailSecurityHandler làm cả hai entry point, tắt CSRF cho API dùng bearer token và không có session.

Theo mặc định Actuator chỉ expose health qua HTTP (bài 39); /actuator/actuator/info không kèm token nhận 401 của chain.

Vì sao chain và các bean token nằm ở hai class riêng

Bài 38 import SecurityConfig vào một @WebMvcTest và gặp StackOverflowError ngay khi một request mang bearer token thật nhưng không hợp lệ, vì class đó còn khai báo cả bean AuthenticationManager. Ở đây chain có class riêng, còn các bean mà authentication và việc ký token cần nằm ở class khác:

src/main/java/com/example/orders/common/AuthConfig.java
package com.example.orders.common;
 
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 
@Configuration
public class AuthConfig {
 
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) {
        return configuration.getAuthenticationManager();
    }
 
    @Bean
    JwtEncoder jwtEncoder(
            @Value("${spring.security.oauth2.resourceserver.jwt.public-key-location}") RSAPublicKey publicKey,
            @Value("${app.jwt.private-key-location}") RSAPrivateKey privateKey) {
        return NimbusJwtEncoder.withKeyPair(publicKey, privateKey).build();
    }
 
    @Bean
    JwtTimestampValidator jwtTimestampValidator() {
        JwtTimestampValidator validator = new JwtTimestampValidator(Duration.ZERO);
        validator.setAllowEmptyExpiryClaim(false);
        return validator;
    }
 
    @Bean
    JwtIssuerValidator jwtIssuerValidator(@Value("${app.jwt.issuer}") String issuer) {
        return new JwtIssuerValidator(issuer);
    }
}

Web slice chỉ import SecurityConfigProblemDetailSecurityHandler. Để xác nhận việc tách class là điều quyết định, một bản sao của test class, SingleSecurityConfigControllerTest, thêm AuthConfig vào @Import, và test gửi Authorization: Bearer abc.def.ghi của nó fail y như ở bài 38:

Text
SingleSecurityConfigControllerTest > placeWithAnInvalidTokenIs401() FAILED
    java.lang.StackOverflowError at ReflectiveOperationException.java:90
Text
java.lang.StackOverflowError
	at java.base/java.lang.ReflectiveOperationException.<init>(ReflectiveOperationException.java:90)
	at java.base/java.lang.reflect.InvocationTargetException.<init>(InvocationTargetException.java:68)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:359)
	at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215)
	at jdk.proxy3/jdk.proxy3.$Proxy220.authenticate(Unknown Source)

Không có AuthConfig, slice không có bean AuthenticationManager nào để lazy proxy gọi vào, và cùng request đó trả đúng 401 mà test mong đợi. Method security cũng có class riêng vì cùng lý do, để slice không bọc service đã mock trong các bước kiểm tra authorization:

src/main/java/com/example/orders/common/MethodSecurityConfig.java
package com.example.orders.common;
 
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
 
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

Entry point và access denied handler kết hợp phần ghi ProblemDetail của bài 33 với việc ủy quyền cho BearerTokenAuthenticationEntryPoint của bài 35; bài 35 chỉ trình bày thay đổi đó dưới dạng diff, nên đây là toàn bộ class:

src/main/java/com/example/orders/common/ProblemDetailSecurityHandler.java
package com.example.orders.common;
 
import java.io.IOException;
import java.net.URI;
 
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
 
import tools.jackson.databind.json.JsonMapper;
 
@Component
public class ProblemDetailSecurityHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
 
    private final JsonMapper jsonMapper;
    private final BearerTokenAuthenticationEntryPoint bearerEntryPoint = new BearerTokenAuthenticationEntryPoint();
 
    public ProblemDetailSecurityHandler(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
        this.bearerEntryPoint.setRealmName("orders");
    }
 
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
        bearerEntryPoint.commence(request, response, ex);
        write(request, response, HttpStatus.valueOf(response.getStatus()),
                "Valid credentials are required to access this resource.");
    }
 
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException ex) throws IOException {
        write(request, response, HttpStatus.FORBIDDEN, "You are not allowed to perform this operation.");
    }
 
    private void write(HttpServletRequest request, HttpServletResponse response,
                       HttpStatus status, String detail) throws IOException {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
        problem.setInstance(URI.create(request.getRequestURI()));
        response.setStatus(status.value());
        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        jsonMapper.writeValue(response.getOutputStream(), problem);
    }
}

User, đăng ký, đăng nhập và admin đầu tiên

Package user gần như giữ nguyên từ bài 34 và 35. Các class sau giống hệt phiên bản cuối ở đó, chỉ đổi package thành com.example.orders.user:

ClassGiống ở
Rolebài 34: USER, ADMIN
AppUserRepositorybài 34: findByUsername, existsByUsername, existsByEmail
JpaUserDetailsServicebài 34, kể cả updatePassword
RegisterRequestbài 34, với check 72 byte bằng @AssertTrue
LoginRequest, UserResponsebài 34
UserServicebài 34: registerfindByUsername
TokenResponsebài 35

AppUser có thêm một constructor nhận role, dành cho administrator:

src/main/java/com/example/orders/user/AppUser.java
package com.example.orders.user;
 
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "users")
public class AppUser {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, length = 50, unique = true)
    private String username;
 
    @Column(nullable = false, length = 254, unique = true)
    private String email;
 
    @Column(nullable = false, length = 100)
    private String passwordHash;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private Role role = Role.USER;
 
    private boolean enabled = true;
 
    protected AppUser() {
    }
 
    public AppUser(String username, String email, String passwordHash) {
        this(username, email, passwordHash, Role.USER);
    }
 
    public AppUser(String username, String email, String passwordHash, Role role) {
        this.username = username;
        this.email = email;
        this.passwordHash = passwordHash;
        this.role = role;
    }
 
    public Long getId() { return id; }
 
    public String getUsername() { return username; }
 
    public String getEmail() { return email; }
 
    public String getPasswordHash() { return passwordHash; }
    public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
 
    public Role getRole() { return role; }
 
    public boolean isEnabled() { return enabled; }
    public void setEnabled(boolean enabled) { this.enabled = enabled; }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof AppUser other)) return false;
        return id != null && id.equals(other.getId());
    }
 
    @Override
    public int hashCode() {
        return AppUser.class.hashCode();
    }
}

UserAlreadyExistsException giờ kế thừa ConflictException:

src/main/java/com/example/orders/user/UserAlreadyExistsException.java
package com.example.orders.user;
 
import com.example.orders.common.ConflictException;
 
public class UserAlreadyExistsException extends ConflictException {
 
    public UserAlreadyExistsException(String message) {
        super(message);
    }
}

TokenService đọc issuer từ app.jwt.issuer:

src/main/java/com/example/orders/user/TokenService.java
package com.example.orders.user;
 
import java.time.Duration;
import java.time.Instant;
import java.util.List;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
 
@Service
public class TokenService {
 
    private static final Duration LIFETIME = Duration.ofMinutes(15);
 
    private final JwtEncoder jwtEncoder;
    private final String issuer;
 
    public TokenService(JwtEncoder jwtEncoder, @Value("${app.jwt.issuer}") String issuer) {
        this.jwtEncoder = jwtEncoder;
        this.issuer = issuer;
    }
 
    public TokenResponse issue(Authentication authentication) {
        Instant now = Instant.now();
        List<String> roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .filter(authority -> authority.startsWith("ROLE_"))
                .map(authority -> authority.substring("ROLE_".length()))
                .toList();
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer(issuer)
                .subject(authentication.getName())
                .issuedAt(now)
                .expiresAt(now.plus(LIFETIME))
                .claim("roles", roles)
                .build();
        String token = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
        return new TokenResponse(token, "Bearer", LIFETIME.toSeconds());
    }
}

AuthController là của bài 35 cộng thêm annotation của springdoc: một tag, và bearer requirement trên me:

src/main/java/com/example/orders/user/AuthController.java
package com.example.orders.user;
 
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("/api/auth")
@Tag(name = "Authentication")
public class AuthController {
 
    private final UserService userService;
    private final AuthenticationManager authenticationManager;
    private final TokenService tokenService;
 
    public AuthController(UserService userService, AuthenticationManager authenticationManager,
                          TokenService tokenService) {
        this.userService = userService;
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService;
    }
 
    @PostMapping("/register")
    public ResponseEntity<UserResponse> register(@Valid @RequestBody RegisterRequest request) {
        AppUser user = userService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
    }
 
    @PostMapping("/login")
    public TokenResponse login(@Valid @RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
        return tokenService.issue(authentication);
    }
 
    @GetMapping("/me")
    @SecurityRequirement(name = "bearer-jwt")
    public UserResponse me(@AuthenticationPrincipal Jwt jwt) {
        return UserResponse.from(userService.findByUsername(jwt.getSubject()));
    }
}

Tạo administrator đầu tiên

Đăng ký chỉ tạo tài khoản USER, nên ADMIN đầu tiên phải đến từ chỗ khác. Một migration Flyway có thể insert tài khoản đó kèm một BCrypt hash, nhưng khi đó hash ấy, tức là một password đã biết, sẽ có mặt trong mọi database từng được dựng từ repository, cả development lẫn production. Một runner chỉ tạo tài khoản khi app.admin.password được đặt, và chỉ khi username chưa tồn tại, giữ credential ngoài repository và cho mỗi môi trường tự chọn password:

src/main/java/com/example/orders/user/AdminAccountInitializer.java
package com.example.orders.user;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
 
@Component
class AdminAccountInitializer implements ApplicationRunner {
 
    private static final Logger log = LoggerFactory.getLogger(AdminAccountInitializer.class);
 
    private final AppUserRepository users;
    private final PasswordEncoder passwordEncoder;
    private final String username;
    private final String email;
    private final String password;
 
    AdminAccountInitializer(AppUserRepository users, PasswordEncoder passwordEncoder,
                            @Value("${app.admin.username}") String username,
                            @Value("${app.admin.email}") String email,
                            @Value("${app.admin.password:}") String password) {
        this.users = users;
        this.passwordEncoder = passwordEncoder;
        this.username = username;
        this.email = email;
        this.password = password;
    }
 
    @Override
    public void run(ApplicationArguments args) {
        if (password.isBlank()) {
            log.info("app.admin.password is not set, no admin account created");
            return;
        }
        if (users.existsByUsername(username)) {
            log.info("Admin account '{}' already exists", username);
            return;
        }
        users.save(new AppUser(username, email, passwordEncoder.encode(password), Role.ADMIN));
        log.info("Created admin account '{}'", username);
    }
}

Khi không có property này, như trong test, runner log app.admin.password is not set, no admin account created. Lần khởi động đầu của stack Compose log Created admin account 'admin', còn khi restart container ứng dụng thì log Admin account 'admin' already exists. Đổi password sau đó không phải việc của runner.

Product

Product là entity của bài 37 cùng rule về stock, giờ có thêm cột audit, độ dài khớp với V2, và thao tác ngược lại cho việc hủy order:

src/main/java/com/example/orders/product/Product.java
package com.example.orders.product;
 
import java.math.BigDecimal;
 
import com.example.orders.common.AuditableEntity;
 
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "products")
public class Product extends AuditableEntity {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, length = 120)
    private String name;
 
    @Column(nullable = false, length = 40, unique = true)
    private String sku;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;
 
    @Column(nullable = false)
    private int stock;
 
    protected Product() {
    }
 
    public Product(String name, String sku, BigDecimal price, int stock) {
        this.name = name;
        this.sku = sku;
        this.price = price;
        this.stock = stock;
    }
 
    public void decreaseStock(int quantity) {
        requirePositive(quantity);
        if (quantity > stock) {
            throw new InsufficientStockException(sku, stock, quantity);
        }
        stock -= quantity;
    }
 
    public void increaseStock(int quantity) {
        requirePositive(quantity);
        stock += quantity;
    }
 
    private static void requirePositive(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive, was " + quantity);
        }
    }
 
    public Long getId() {
        return id;
    }
 
    public String getName() {
        return name;
    }
 
    public String getSku() {
        return sku;
    }
 
    public BigDecimal getPrice() {
        return price;
    }
 
    public int getStock() {
        return stock;
    }
}
src/main/java/com/example/orders/product/ProductRepository.java
package com.example.orders.product;
 
import java.util.Optional;
 
import jakarta.persistence.LockModeType;
 
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
 
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    boolean existsBySku(String sku);
 
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Product> findForUpdateById(Long id);
}

findForUpdateById là một derived query của bài 27 kèm lock mode; phần về order service giải thích vì sao việc đặt order cần nó.

src/main/java/com/example/orders/product/ProductService.java
package com.example.orders.product;
 
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
@Transactional(readOnly = true)
public class ProductService {
 
    private final ProductRepository products;
 
    public ProductService(ProductRepository products) {
        this.products = products;
    }
 
    public Page<Product> findPage(Pageable pageable) {
        return products.findAll(pageable);
    }
 
    public Product findById(Long id) {
        return products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
    }
 
    @Transactional
    public Product create(Product product) {
        if (products.existsBySku(product.getSku())) {
            throw new DuplicateSkuException(product.getSku());
        }
        return products.save(product);
    }
}
src/main/java/com/example/orders/product/CreateProductRequest.java
package com.example.orders.product;
 
import java.math.BigDecimal;
 
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
 
public record CreateProductRequest(
        @NotBlank @Size(max = 120) String name,
        @NotBlank @Size(max = 40) String sku,
        @NotNull @Positive @Digits(integer = 8, fraction = 2) BigDecimal price,
        @NotNull @PositiveOrZero Integer stock) {
 
    Product toProduct() {
        return new Product(name, sku, price, stock);
    }
}
src/main/java/com/example/orders/product/ProductResponse.java
package com.example.orders.product;
 
import java.math.BigDecimal;
import java.time.Instant;
 
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock,
                              Instant createdAt, Instant updatedAt) {
 
    static ProductResponse from(Product product) {
        return new ProductResponse(product.getId(), product.getName(), product.getSku(), product.getPrice(),
                product.getStock(), product.getCreatedAt(), product.getUpdatedAt());
    }
}
src/main/java/com/example/orders/product/ProductController.java
package com.example.orders.product;
 
import java.net.URI;
 
import com.example.orders.common.PageResponse;
 
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
 
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
 
@RestController
@RequestMapping("/api/products")
@Tag(name = "Products")
public class ProductController {
 
    private final ProductService service;
 
    public ProductController(ProductService service) {
        this.service = service;
    }
 
    @GetMapping
    public PageResponse<ProductResponse> findAll(@ParameterObject @PageableDefault(size = 20, sort = "id") Pageable pageable) {
        return PageResponse.from(service.findPage(pageable).map(ProductResponse::from));
    }
 
    @GetMapping("/{id}")
    public ProductResponse findById(@PathVariable Long id) {
        return ProductResponse.from(service.findById(id));
    }
 
    @PostMapping
    @SecurityRequirement(name = "bearer-jwt")
    public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
        Product product = service.create(request.toProduct());
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(product.getId())
                .toUri();
        return ResponseEntity.created(location).body(ProductResponse.from(product));
    }
}

@ParameterObject dành cho springdoc, và phần tài liệu API cho thấy nó sửa được gì. Ba exception giữ nguyên message mà bài 21 và 37 đã dùng:

src/main/java/com/example/orders/product/ProductNotFoundException.java
package com.example.orders.product;
 
import com.example.orders.common.NotFoundException;
 
public class ProductNotFoundException extends NotFoundException {
 
    public ProductNotFoundException(Long id) {
        super("Product " + id + " not found");
    }
}
src/main/java/com/example/orders/product/DuplicateSkuException.java
package com.example.orders.product;
 
import com.example.orders.common.ConflictException;
 
public class DuplicateSkuException extends ConflictException {
 
    public DuplicateSkuException(String sku) {
        super("SKU " + sku + " already exists");
    }
}
src/main/java/com/example/orders/product/InsufficientStockException.java
package com.example.orders.product;
 
import com.example.orders.common.ConflictException;
 
public class InsufficientStockException extends ConflictException {
 
    public InsufficientStockException(String sku, int available, int requested) {
        super("Only " + available + " of " + sku + " in stock, " + requested + " requested");
    }
}

Order: entity, repository và ownership

Một order có customer, status và các line. Việc chuyển status nằm trong entity, giống cách bài 37 đưa rule về stock vào Product:

src/main/java/com/example/orders/order/OrderStatus.java
package com.example.orders.order;
 
public enum OrderStatus {
    PLACED, SHIPPED, CANCELLED
}
src/main/java/com/example/orders/order/Order.java
package com.example.orders.order;
 
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
 
import com.example.orders.common.AuditableEntity;
import com.example.orders.user.AppUser;
 
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "orders")
public class Order extends AuditableEntity {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private AppUser customer;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private OrderStatus status = OrderStatus.PLACED;
 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderLine> lines = new ArrayList<>();
 
    protected Order() {
    }
 
    public Order(AppUser customer) {
        this.customer = customer;
    }
 
    public void addLine(OrderLine line) {
        lines.add(line);
        line.setOrder(this);
    }
 
    public void ship() {
        if (status != OrderStatus.PLACED) {
            throw new OrderStatusException("Order " + id + " is " + status + " and cannot be shipped");
        }
        status = OrderStatus.SHIPPED;
    }
 
    public void cancel() {
        if (status != OrderStatus.PLACED) {
            throw new OrderStatusException("Order " + id + " is " + status + " and cannot be cancelled");
        }
        status = OrderStatus.CANCELLED;
    }
 
    public BigDecimal total() {
        return lines.stream()
                .map(OrderLine::lineTotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
 
    public Long getId() {
        return id;
    }
 
    public AppUser getCustomer() {
        return customer;
    }
 
    public OrderStatus getStatus() {
        return status;
    }
 
    public List<OrderLine> getLines() {
        return lines;
    }
}
src/main/java/com/example/orders/order/OrderLine.java
package com.example.orders.order;
 
import java.math.BigDecimal;
 
import com.example.orders.product.Product;
 
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "order_lines")
public class OrderLine {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;
 
    @Column(nullable = false)
    private int quantity;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal unitPrice;
 
    protected OrderLine() {
    }
 
    public OrderLine(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
        this.unitPrice = product.getPrice();
    }
 
    public BigDecimal lineTotal() {
        return unitPrice.multiply(BigDecimal.valueOf(quantity));
    }
 
    void setOrder(Order order) {
        this.order = order;
    }
 
    public Long getId() {
        return id;
    }
 
    public Product getProduct() {
        return product;
    }
 
    public int getQuantity() {
        return quantity;
    }
 
    public BigDecimal getUnitPrice() {
        return unitPrice;
    }
}

OrderLine là mapping cuối cùng của bài 28: cả hai @ManyToOne đều lazy và NOT NULL, đơn giá được chép từ product lúc tạo line. Record của request và response cũng là của bài 28, response có thêm status và timestamp audit:

src/main/java/com/example/orders/order/OrderItem.java
package com.example.orders.order;
 
public record OrderItem(Long productId, int quantity) {
}
src/main/java/com/example/orders/order/PlaceOrderRequest.java
package com.example.orders.order;
 
import java.util.List;
 
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
 
public record PlaceOrderRequest(@NotEmpty List<@Valid Line> lines) {
 
    public record Line(@NotNull Long productId, @NotNull @Positive Integer quantity) {
    }
}
src/main/java/com/example/orders/order/OrderResponse.java
package com.example.orders.order;
 
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
 
public record OrderResponse(Long id, Long customerId, OrderStatus status, List<OrderLineResponse> lines,
                            BigDecimal total, Instant createdAt, Instant updatedAt) {
 
    public record OrderLineResponse(Long productId, int quantity, BigDecimal unitPrice, BigDecimal lineTotal) {
 
        static OrderLineResponse from(OrderLine line) {
            return new OrderLineResponse(line.getProduct().getId(), line.getQuantity(),
                    line.getUnitPrice(), line.lineTotal());
        }
    }
 
    static OrderResponse from(Order order) {
        return new OrderResponse(order.getId(), order.getCustomer().getId(), order.getStatus(),
                order.getLines().stream().map(OrderLineResponse::from).toList(), order.total(),
                order.getCreatedAt(), order.getUpdatedAt());
    }
}

customerId đọc id của một reference AppUser lazy, điều mà bài 28 đã cho thấy không tốn query nào; response không bao giờ chạm vào phần còn lại của customer.

src/main/java/com/example/orders/order/OrderRepository.java
package com.example.orders.order;
 
import java.util.Optional;
 
import jakarta.persistence.LockModeType;
 
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
 
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    @EntityGraph(attributePaths = "lines")
    Optional<Order> findWithLinesById(Long id);
 
    @EntityGraph(attributePaths = "lines")
    Page<Order> findByCustomerUsername(String username, Pageable pageable);
 
    @Override
    @EntityGraph(attributePaths = "lines")
    Page<Order> findAll(Pageable pageable);
 
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @EntityGraph(attributePaths = "lines")
    Optional<Order> findForUpdateById(Long id);
 
    @Query("select o.customer.username from Order o where o.id = :id")
    Optional<String> findCustomerUsernameById(Long id);
}
  • findWithLinesById cùng findByCustomerUsernamefindAll có phân trang fetch các line bằng entity graph, như bài 28 và 29.
  • findForUpdateById lock row của order cho cancelship, kèm các line.
  • findCustomerUsernameById trả lời đúng một câu hỏi cho rule ownership: order này của ai.
src/main/java/com/example/orders/order/OrderAccess.java
package com.example.orders.order;
 
import org.springframework.stereotype.Component;
 
@Component
public class OrderAccess {
 
    private final OrderRepository orders;
 
    public OrderAccess(OrderRepository orders) {
        this.orders = orders;
    }
 
    public boolean isOwner(Long orderId, String username) {
        return orders.findCustomerUsernameById(orderId)
                .map(owner -> owner.equals(username))
                .orElse(true); // an unknown id passes, so the service itself answers 404
    }
}
src/main/java/com/example/orders/order/OrderNotFoundException.java
package com.example.orders.order;
 
import com.example.orders.common.NotFoundException;
 
public class OrderNotFoundException extends NotFoundException {
 
    public OrderNotFoundException(Long id) {
        super("Order " + id + " not found");
    }
}
src/main/java/com/example/orders/order/OrderStatusException.java
package com.example.orders.order;
 
import com.example.orders.common.ConflictException;
 
public class OrderStatusException extends ConflictException {
 
    public OrderStatusException(String message) {
        super(message);
    }
}

Business logic: OrderService.place, cancel và ship

src/main/java/com/example/orders/order/OrderService.java
package com.example.orders.order;
 
import java.util.Comparator;
import java.util.List;
 
import com.example.orders.product.Product;
import com.example.orders.product.ProductNotFoundException;
import com.example.orders.product.ProductRepository;
import com.example.orders.user.AppUser;
import com.example.orders.user.AppUserRepository;
 
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
@Transactional(readOnly = true)
public class OrderService {
 
    private final OrderRepository orders;
    private final ProductRepository products;
    private final AppUserRepository users;
 
    public OrderService(OrderRepository orders, ProductRepository products, AppUserRepository users) {
        this.orders = orders;
        this.products = products;
        this.users = users;
    }
 
    @Transactional
    public Order place(String username, List<OrderItem> items) {
        AppUser customer = users.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("No user named " + username));
        Order order = new Order(customer);
        List<OrderItem> inProductOrder = items.stream()
                .sorted(Comparator.comparing(OrderItem::productId))
                .toList();
        for (OrderItem item : inProductOrder) {
            Product product = products.findForUpdateById(item.productId())
                    .orElseThrow(() -> new ProductNotFoundException(item.productId()));
            product.decreaseStock(item.quantity());
            order.addLine(new OrderLine(product, item.quantity()));
        }
        return orders.save(order);
    }
 
    @PreAuthorize("hasRole('ADMIN') or @orderAccess.isOwner(#id, authentication.name)")
    public Order findById(Long id) {
        return orders.findWithLinesById(id).orElseThrow(() -> new OrderNotFoundException(id));
    }
 
    public Page<Order> findByCustomer(String username, Pageable pageable) {
        return orders.findByCustomerUsername(username, pageable);
    }
 
    @PreAuthorize("hasRole('ADMIN')")
    public Page<Order> findAll(Pageable pageable) {
        return orders.findAll(pageable);
    }
 
    @Transactional
    @PreAuthorize("hasRole('ADMIN') or @orderAccess.isOwner(#id, authentication.name)")
    public Order cancel(Long id) {
        Order order = orders.findForUpdateById(id).orElseThrow(() -> new OrderNotFoundException(id));
        order.cancel();
        List<OrderLine> inProductOrder = order.getLines().stream()
                .sorted(Comparator.comparing(line -> line.getProduct().getId()))
                .toList();
        for (OrderLine line : inProductOrder) {
            Product product = products.findForUpdateById(line.getProduct().getId()).orElseThrow();
            product.increaseStock(line.getQuantity());
        }
        return order;
    }
 
    @Transactional
    public Order ship(Long id) {
        Order order = orders.findForUpdateById(id).orElseThrow(() -> new OrderNotFoundException(id));
        order.ship();
        return order;
    }
}
  • place chạy trong một transaction (bài 30). Nó load customer mà token chỉ ra, rồi với từng item thì lock row của product, để Product.decreaseStock áp rule và thêm một line với giá hiện tại. Nếu một line bất kỳ thiếu hàng, InsufficientStockException rollback toàn bộ transaction, kể cả phần stock đã trừ cho các line trước, và advice trả 409. Chỉ khi mọi line đều qua thì order mới được lưu, và cascade persist luôn các line (bài 28).
  • cancel lock order, để Order.cancel từ chối mọi order không còn PLACED, rồi cộng lại số lượng của từng line vào product, cũng dưới row lock.
  • ship lock order và để Order.ship áp cùng rule về trạng thái.
  • Các method đọc thừa hưởng readOnly = true từ class (bài 30).

Mọi method đều trả entity, và OrderController map chúng sau khi transaction đã commit. Điều đó an toàn ở đây vì mỗi method trả về một order mà các line đã được fetch hoặc chạm tới bên trong transaction, và vì listener audit đặt updatedAt lên object đang được quản lý trong lúc flush, nên updatedAt mà một lần hủy trả về đã là giá trị mới; hiện tượng "chậm một lần flush" của bài 32 chỉ xảy ra khi map bên trong transaction.

Đặt một order từng bước: bearer token thành ROLE_USER, validation chạy trước controller, service có transaction lock và kiểm tra từng product theo thứ tự id, và response là 201 kèm Location; các nhánh lỗi cho thấy 401 từ resource server, 422 từ advice trước khi tới service, và 409 kèm rollback từ bước kiểm tra stock

Vì sao đặt order phải lock row của product

reserveStock ở bài 30 đọc product, giảm stock trong bộ nhớ và để dirty checking ghi xuống. Trong một transaction thì việc đó là trọn vẹn hoặc không gì cả, nhưng hai transaction có thể đọc cùng một giá trị stock cùng lúc, và mức isolation mặc định của PostgreSQL, READ COMMITTED, cho phép cả hai cùng ghi. Để đo, @Lock được bỏ khỏi findForUpdateById cho thí nghiệm này, và hai mươi request, mỗi request đặt một USB-C hub, được gửi song song tới một product còn 5 cái trong stock, ba lần, với load average một phút ở mức 3.7:

Bash
seq 1 20 | xargs -P 20 -I{} curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":3,"quantity":1}]}' http://localhost:8142/api/orders | sort | uniq -c
Lần chạy không lock201409stock sau đó
12002
21820
32000

Hai mươi order cho năm cái hub, và ở lần chạy đầu còn dư cả stock 2: mỗi lost update là một cái hub đã bán mà không bị trừ. Khi đặt @Lock(LockModeType.PESSIMISTIC_WRITE) trở lại, cùng loạt request đó trên stack Compose bên dưới, cho một cái đèn bàn (product 4, LMP-01) còn 5 trong stock:

Text
   5 201
  15 409
Text
  sku   | stock 
--------+-------
 LMP-01 |     0
(1 row)
 
 orders_for_lamp 
-----------------
               5
(1 row)

Năm order, năm line, stock 0. SQL cho thấy vì sao. Cùng JAR đó chạy với PostgreSQL 18 và --logging.level.org.hibernate.SQL=debug, cho order của alice gồm một con chuột (product 2) và hai bàn phím (product 1), danh sách cột của các câu SELECT được rút gọn:

Text
select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0
update products set name=?,price=?,sku=?,stock=?,updated_at=? where id=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0
insert into orders (created_at,customer_id,status,updated_at) values (?,?,?,?)
insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
update products set name=?,price=?,sku=?,stock=?,updated_at=? where id=?
  • Hibernate 7.4.5 ghi PESSIMISTIC_WRITE thành for no key update of p1_0 trên PostgreSQL: một row lock chỉ trên product được chọn.
  • Một request đồng thời phải chờ ở câu select … for no key update của chính nó cho tới khi transaction đầu kết thúc, rồi đọc giá trị stock mà transaction đó đã commit. Đó là lý do loạt request dừng đúng ở năm.
  • Câu UPDATE đầu tiên đến trước lock thứ hai. Hibernate flush stock đã thay đổi của bàn phím trước khi chạy một query khác trên products; UPDATE của con chuột chờ tới lúc commit.
  • Bàn phím đi trước, dù request liệt kê con chuột trước. place sắp các item theo id của product, nên hai order dùng chung product luôn lock chúng theo cùng thứ tự và không thể deadlock lẫn nhau.

Order xin năm con chuột khi chỉ còn hai dừng lại sau lock thứ hai:

Text
select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0
update products set name=?,price=?,sku=?,stock=?,updated_at=? where id=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0

Câu UPDATE của bàn phím đã được gửi rồi bị rollback cùng transaction, và không câu INSERT nào được gửi; lượt curl bên dưới đọc lại stock không đổi.

cancel có thêm một chi tiết. Lock và entity graph nằm trên cùng một method của repository, và Hibernate không đặt mệnh đề lock lên fetch join: nó chạy join không có lock rồi lock row của order bằng một câu thứ hai:

Text
select o1_0.id,o1_0.created_at,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price,o1_0.status,o1_0.updated_at from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id where o1_0.id=?
select tbl.id,tbl.created_at,tbl.customer_id,tbl.status,tbl.updated_at from orders tbl where tbl.id in (?) for no key update of tbl

Mười request hủy song song cho một order gồm hai bàn phím trả về một 200 và chín 409, và stock bàn phím từ 8 quay về đúng 10: những request phải chờ lock thấy order đã CANCELLED khi tới lượt chúng.

Rule ownership nằm ở đâu

"User chỉ xem và hủy được order của chính mình" có thể được áp ở ba chỗ. Một URL rule thấy /api/orders/7 nhưng không biết order 7 của ai. Một query của repository giới hạn theo người gọi, findByIdAndCustomerUsername, sẽ biến order của người khác thành 404 không phân biệt được với order không tồn tại, trong khi bài 36 trả 403. Vì vậy rule là method security trên service, như bảng của bài 36 khuyên dùng cho rule dựa trên dữ liệu, viết một lần trong OrderAccess và được cả findById lẫn cancel dùng; @PostAuthorize dùng được cho thao tác đọc, nhưng một lần hủy phải bị từ chối trước khi nó ghi. orElse(true) của nó cho một id không tồn tại đi tiếp vào method, nên order không tồn tại vẫn là 404 với mọi người. Endpoint danh sách là nơi duy nhất mà query giới hạn theo người gọi là đúng hoàn toàn, vì một danh sách không có trường hợp "của người khác": user dùng findByCustomerUsername, admin dùng findAll.

src/main/java/com/example/orders/order/OrderController.java
package com.example.orders.order;
 
import java.net.URI;
import java.util.List;
 
import com.example.orders.common.PageResponse;
 
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
 
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
 
@RestController
@RequestMapping("/api/orders")
@Tag(name = "Orders")
@SecurityRequirement(name = "bearer-jwt")
public class OrderController {
 
    private final OrderService service;
 
    public OrderController(OrderService service) {
        this.service = service;
    }
 
    @PostMapping
    public ResponseEntity<OrderResponse> place(@Valid @RequestBody PlaceOrderRequest request,
                                               Authentication authentication) {
        List<OrderItem> items = request.lines().stream()
                .map(line -> new OrderItem(line.productId(), line.quantity()))
                .toList();
        Order order = service.place(authentication.getName(), items);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(order.getId())
                .toUri();
        return ResponseEntity.created(location).body(OrderResponse.from(order));
    }
 
    @GetMapping
    public PageResponse<OrderResponse> findAll(@ParameterObject @PageableDefault(size = 20, sort = "id") Pageable pageable,
                                               Authentication authentication) {
        Page<Order> page = isAdmin(authentication)
                ? service.findAll(pageable)
                : service.findByCustomer(authentication.getName(), pageable);
        return PageResponse.from(page.map(OrderResponse::from));
    }
 
    @GetMapping("/{id}")
    public OrderResponse findById(@PathVariable Long id) {
        return OrderResponse.from(service.findById(id));
    }
 
    @PostMapping("/{id}/cancel")
    public OrderResponse cancel(@PathVariable Long id) {
        return OrderResponse.from(service.cancel(id));
    }
 
    @PostMapping("/{id}/ship")
    public OrderResponse ship(@PathVariable Long id) {
        return OrderResponse.from(service.ship(id));
    }
 
    private static boolean isAdmin(Authentication authentication) {
        return authentication.getAuthorities().stream()
                .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
    }
}

Controller nhận Authentication thay vì @AuthenticationPrincipal Jwt như bài 35, vì endpoint danh sách cần cả authorities lẫn tên.

Tài liệu API với springdoc và bearer scheme

FAQ của bài 22 để dành nút Authorize cho bearer token tới chương security. Nút đó cần một security scheme trong document:

src/main/java/com/example/orders/common/OpenApiConfig.java
package com.example.orders.common;
 
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityScheme;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class OpenApiConfig {
 
    @Bean
    OpenAPI ordersOpenApi() {
        return new OpenAPI()
                .info(new Info()
                        .title("Orders API")
                        .version("1.0.0")
                        .description("Products, orders and JWT authentication for the Spring Boot Basics capstone."))
                .components(new Components()
                        .addSecuritySchemes("bearer-jwt", new SecurityScheme()
                                .type(SecurityScheme.Type.HTTP)
                                .scheme("bearer")
                                .bearerFormat("JWT")));
    }
}

@SecurityRequirement(name = "bearer-jwt") trên OrderController, trên ProductController.create và trên AuthController.me gắn scheme đó vào các operation cần token. Document tại /v3/api-docs, rút gọn còn scheme và operation đặt order:

Bash
curl -s http://localhost:8142/v3/api-docs | jq '{securitySchemes: .components.securitySchemes, placeOrder: .paths["/api/orders"].post}'
JSON
{
  "securitySchemes": {
    "bearer-jwt": {
      "type": "http",
      "scheme": "bearer",
      "bearerFormat": "JWT"
    }
  },
  "placeOrder": {
    "tags": [
      "Orders"
    ],
    "operationId": "place",
    "requestBody": {
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/PlaceOrderRequest"
          }
        }
      },
      "required": true
    },
    "responses": {
      "200": {
        "description": "OK",
        "content": {
          "*/*": {
            "schema": {
              "$ref": "#/components/schemas/OrderResponse"
            }
          }
        }
      }
    },
    "security": [
      {
        "bearer-jwt": []
      }
    ]
  }
}

Tham số Authentication của place không có trong document, @AuthenticationPrincipal Jwt của me cũng vậy: springdoc đã bỏ cả hai. 200 là giới hạn của bài 22, vì springdoc không thấy được status chọn trong thân method; các annotation @ApiResponse của bài 22 mô tả đúng 201 và các lỗi.

Pageable cần @ParameterObject

Khi chưa có @ParameterObject, cả hai endpoint danh sách được mô tả bằng một query parameter bắt buộc:

JSON
{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}

Swagger UI sẽ dựng form cho một object pageable mà controller không hề đọc. Có annotation, cùng operation đó liệt kê đúng những gì resolver của Spring Data nhận, kể cả giá trị mặc định từ @PageableDefault:

Bash
curl -s http://localhost:8142/v3/api-docs | jq -c '.paths["/api/orders"].get.parameters'
JSON
[{"name":"page","in":"query","description":"Zero-based page index (0..N)","required":false,"schema":{"type":"integer","default":0,"minimum":0}},{"name":"size","in":"query","description":"The size of the page to be returned","required":false,"schema":{"type":"integer","default":20,"minimum":1}},{"name":"sort","in":"query","description":"Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.","required":false,"schema":{"type":"array","default":["id,ASC"],"items":{"type":"string"}}}]

Các path mà Swagger UI cần đi qua security

Chain cho phép /v3/api-docs/**, /swagger-ui/**/swagger-ui.html. Chrome headless mở http://localhost:8142/swagger-ui.html không kèm token đã đi theo 302 tới /swagger-ui/index.html và nhận 200 cho mọi request mà trang gửi: index.html, swagger-ui.css, index.css, swagger-ui-standalone-preset.js, swagger-ui-bundle.js, swagger-initializer.js, favicon-32x32.png, /v3/api-docs/swagger-config/v3/api-docs. Có một URL tài liệu không được bao: /v3/api-docs.yaml không nằm dưới /v3/api-docs/, và nó nhận 401 của chain. Thêm nó vào rule nếu bạn công bố dạng YAML.

Luồng dùng Swagger UI với token, như đã chạy trên stack Compose:

  1. Trang liệt kê các tag Products, OrdersAuthentication; biểu tượng ổ khóa đánh dấu POST /api/products, mọi operation của order và GET /api/auth/me.
  2. Authorize mở một hộp thoại có tiêu đề bearer-jwt (http, Bearer) với một ô Value. Dán accessToken từ POST /api/auth/login, không kèm chữ Bearer, rồi bấm Authorize; hộp thoại chuyển sang AuthorizedValue: ******. Bấm Close.
  3. Mở POST /api/orders, bấm Try it out, thay body mẫu bằng {"lines":[{"productId":3,"quantity":1}]} rồi bấm Execute.

Khi đó ô Curl cho thấy header mà Swagger UI đã thêm, token được rút gọn ở đây:

Text
curl -X 'POST' \
  'http://localhost:8142/api/orders' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer eyJraWQiOiJ1cWU1bnBBb1ZC…' \
  -H 'Content-Type: application/json' \
  -d '{"lines":[{"productId":3,"quantity":1}]}'

Server response báo 201, gắn nhãn Undocumented vì document chỉ biết 200, và trong response header có location: http://localhost:8142/api/orders/8. Try it out đặt order thật; profile production của bài 22 tắt phần tài liệu.

Test

Unit test cho các rule của order

Service được tạo bằng new và ba repository là mock của Mockito, theo phong cách của bài 37. Product và order là object thật, nên decreaseStock, increaseStock, cancelship chạy thật:

src/test/java/com/example/orders/order/OrderServiceTest.java
package com.example.orders.order;
 
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
 
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
 
import com.example.orders.product.InsufficientStockException;
import com.example.orders.product.Product;
import com.example.orders.product.ProductRepository;
import com.example.orders.user.AppUser;
import com.example.orders.user.AppUserRepository;
 
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
 
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
 
    @Mock
    private OrderRepository orders;
 
    @Mock
    private ProductRepository products;
 
    @Mock
    private AppUserRepository users;
 
    private OrderService service;
    private AppUser alice;
    private Product keyboard;
    private Product mouse;
 
    @BeforeEach
    void setUp() {
        service = new OrderService(orders, products, users);
        alice = new AppUser("alice", "alice@example.com", "hash");
        keyboard = withId(new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10), 1L);
        mouse = withId(new Product("Wireless mouse", "MS-01", new BigDecimal("24.50"), 1), 2L);
    }
 
    @Test
    void placeTakesStockForEveryLineAndSavesTheOrder() {
        given(users.findByUsername("alice")).willReturn(Optional.of(alice));
        given(products.findForUpdateById(1L)).willReturn(Optional.of(keyboard));
        given(products.findForUpdateById(2L)).willReturn(Optional.of(mouse));
        given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
 
        Order order = service.place("alice", List.of(new OrderItem(2L, 1), new OrderItem(1L, 2)));
 
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PLACED);
        assertThat(order.getLines())
                .extracting(line -> line.getProduct().getSku(), OrderLine::getQuantity)
                .containsExactly(tuple("KB-01", 2), tuple("MS-01", 1));
        assertThat(order.total()).isEqualByComparingTo("204.30");
        assertThat(keyboard.getStock()).isEqualTo(8);
        assertThat(mouse.getStock()).isZero();
    }
 
    @Test
    void placeSavesNothingWhenALineHasTooLittleStock() {
        given(users.findByUsername("alice")).willReturn(Optional.of(alice));
        given(products.findForUpdateById(1L)).willReturn(Optional.of(keyboard));
        given(products.findForUpdateById(2L)).willReturn(Optional.of(mouse));
 
        assertThatThrownBy(() -> service.place("alice", List.of(new OrderItem(1L, 2), new OrderItem(2L, 3))))
                .isInstanceOf(InsufficientStockException.class)
                .hasMessage("Only 1 of MS-01 in stock, 3 requested");
 
        then(orders).should(never()).save(any());
    }
 
    @Test
    void cancelPutsTheStockBack() {
        Order order = placedOrder(7L, keyboard, 2);
        given(orders.findForUpdateById(7L)).willReturn(Optional.of(order));
        given(products.findForUpdateById(1L)).willReturn(Optional.of(keyboard));
 
        service.cancel(7L);
 
        assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
        assertThat(keyboard.getStock()).isEqualTo(12);
    }
 
    @Test
    void cancelRefusesAShippedOrderAndKeepsTheStock() {
        Order order = placedOrder(7L, keyboard, 2);
        order.ship();
        given(orders.findForUpdateById(7L)).willReturn(Optional.of(order));
 
        assertThatThrownBy(() -> service.cancel(7L))
                .isInstanceOf(OrderStatusException.class)
                .hasMessage("Order 7 is SHIPPED and cannot be cancelled");
 
        assertThat(keyboard.getStock()).isEqualTo(10);
        then(products).should(never()).findForUpdateById(any());
    }
 
    @Test
    void shipRefusesACancelledOrder() {
        Order order = placedOrder(7L, keyboard, 2);
        order.cancel();
        given(orders.findForUpdateById(7L)).willReturn(Optional.of(order));
 
        assertThatThrownBy(() -> service.ship(7L))
                .isInstanceOf(OrderStatusException.class)
                .hasMessage("Order 7 is CANCELLED and cannot be shipped");
    }
 
    private Order placedOrder(Long id, Product product, int quantity) {
        Order order = new Order(alice);
        order.addLine(new OrderLine(product, quantity));
        return withId(order, id);
    }
 
    private static <T> T withId(T entity, Long id) {
        ReflectionTestUtils.setField(entity, "id", id);
        return entity;
    }
}

placeTakesStockForEveryLineAndSavesTheOrder còn chốt luôn thứ tự lock: các line trả về có bàn phím trước, vì place đã sắp các item. Như bài 37 đã lưu ý, unit test không chứng minh được rollback; các bước kiểm tra stock trong lượt chạy Docker làm việc đó.

@WebMvcTest cho OrderController kèm security

src/test/java/com/example/orders/order/OrderControllerTest.java
package com.example.orders.order;
 
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
 
import java.math.BigDecimal;
import java.util.List;
 
import com.example.orders.common.ProblemDetailSecurityHandler;
import com.example.orders.common.SecurityConfig;
import com.example.orders.product.InsufficientStockException;
import com.example.orders.product.Product;
import com.example.orders.user.AppUser;
 
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
 
@WebMvcTest(OrderController.class)
@Import({SecurityConfig.class, ProblemDetailSecurityHandler.class})
class OrderControllerTest {
 
    private static final String TWO_KEYBOARDS = """
            {"lines":[{"productId":1,"quantity":2}]}
            """;
 
    @Autowired
    MockMvcTester mvc;
 
    @MockitoBean
    OrderService orderService;
 
    @Test
    void placeWithoutATokenIs401() {
        assertThat(mvc.post().uri("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(TWO_KEYBOARDS))
                .hasStatus(HttpStatus.UNAUTHORIZED)
                .hasHeader(HttpHeaders.WWW_AUTHENTICATE,
                        "Bearer realm=\"orders\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")
                .bodyJson()
                .extractingPath("$.detail").asString().isEqualTo("Valid credentials are required to access this resource.");
    }
 
    @Test
    void placeWithAnInvalidTokenIs401() {
        assertThat(mvc.post().uri("/api/orders")
                .header(HttpHeaders.AUTHORIZATION, "Bearer abc.def.ghi")
                .contentType(MediaType.APPLICATION_JSON)
                .content(TWO_KEYBOARDS))
                .hasStatus(HttpStatus.UNAUTHORIZED);
    }
 
    @Test
    void placeAnswers201WithTheLocationOfTheOrder() {
        given(orderService.place(eq("alice"), anyList())).willReturn(order(42L));
 
        assertThat(mvc.post().uri("/api/orders")
                .with(user("alice"))
                .contentType(MediaType.APPLICATION_JSON)
                .content(TWO_KEYBOARDS))
                .hasStatus(HttpStatus.CREATED)
                .hasHeader(HttpHeaders.LOCATION, "http://localhost/api/orders/42")
                .bodyJson()
                .isLenientlyEqualTo("""
                        {"id":42,"status":"PLACED","lines":[{"productId":1,"quantity":2}],"total":179.80}
                        """);
 
        then(orderService).should().place("alice", List.of(new OrderItem(1L, 2)));
    }
 
    @Test
    void invalidBodyIs422AndNeverReachesTheService() {
        assertThat(mvc.post().uri("/api/orders")
                .with(user("alice"))
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                        {"lines":[{"productId":1,"quantity":0}]}
                        """))
                .hasStatus(HttpStatus.UNPROCESSABLE_CONTENT)
                .bodyJson()
                .isLenientlyEqualTo("""
                        {"status":422,"errors":[{"field":"lines[0].quantity","message":"must be greater than 0"}]}
                        """);
 
        then(orderService).shouldHaveNoInteractions();
    }
 
    @Test
    void insufficientStockIs409() {
        given(orderService.place(eq("alice"), anyList()))
                .willThrow(new InsufficientStockException("KB-01", 1, 2));
 
        assertThat(mvc.post().uri("/api/orders")
                .with(user("alice"))
                .contentType(MediaType.APPLICATION_JSON)
                .content(TWO_KEYBOARDS))
                .hasStatus(HttpStatus.CONFLICT)
                .bodyJson()
                .extractingPath("$.detail").asString().isEqualTo("Only 1 of KB-01 in stock, 2 requested");
    }
 
    @Test
    void methodSecurityDenialIs403NotA500() {
        given(orderService.findById(7L)).willThrow(new AuthorizationDeniedException("Access Denied"));
 
        assertThat(mvc.get().uri("/api/orders/7").with(user("bob")))
                .hasStatus(HttpStatus.FORBIDDEN)
                .bodyJson()
                .extractingPath("$.title").asString().isEqualTo("Forbidden");
    }
 
    @Test
    void shippingNeedsTheAdminRole() {
        assertThat(mvc.post().uri("/api/orders/7/ship").with(user("alice")))
                .hasStatus(HttpStatus.FORBIDDEN);
 
        then(orderService).shouldHaveNoInteractions();
    }
 
    private static JwtRequestPostProcessor user(String username) {
        return jwt().jwt(token -> token.subject(username))
                .authorities(new SimpleGrantedAuthority("ROLE_USER"));
    }
 
    private static Order order(Long id) {
        Product keyboard = new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 8);
        ReflectionTestUtils.setField(keyboard, "id", 1L);
        Order order = new Order(new AppUser("alice", "alice@example.com", "hash"));
        order.addLine(new OrderLine(keyboard, 2));
        ReflectionTestUtils.setField(order, "id", id);
        return order;
    }
}
  • @Import({SecurityConfig.class, ProblemDetailSecurityHandler.class}) mang vào chain thật và không gì khác, chính là cách tách class ở phần security.
  • placeWithAnInvalidTokenIs401 gửi một header Authorization thật qua resource server; khi import thêm AuthConfig, đây là test chết vì StackOverflowError.
  • jwt().jwt(...).authorities(...) cho mỗi request một JwtAuthenticationToken; như bài 38 đã thấy, post-processor không áp authorities-claim-name, nên role được đặt trực tiếp.
  • methodSecurityDenialIs403NotA500 để service đã mock ném exception mà một lần từ chối của @PreAuthorize tạo ra, và chứng minh rằng rethrow trong advice cùng access denied handler biến nó thành 403 ProblemDetail, mà không cần proxy của method security trong slice.
  • shippingNeedsTheAdminRole được URL rule quyết định trước khi controller chạy, nên service không bao giờ được gọi.

@DataJpaTest cho các query của order

src/test/java/com/example/orders/order/OrderRepositoryTest.java
package com.example.orders.order;
 
import static org.assertj.core.api.Assertions.assertThat;
 
import java.math.BigDecimal;
 
import com.example.orders.common.AuditingConfig;
import com.example.orders.product.Product;
import com.example.orders.user.AppUser;
 
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
 
@DataJpaTest
@Import(AuditingConfig.class)
class OrderRepositoryTest {
 
    @Autowired
    TestEntityManager entityManager;
 
    @Autowired
    OrderRepository orders;
 
    private Long bobsOrderId;
 
    @BeforeEach
    void setUp() {
        AppUser alice = entityManager.persist(new AppUser("alice", "alice@example.com", "hash"));
        AppUser bob = entityManager.persist(new AppUser("bob", "bob@example.com", "hash"));
        Product keyboard = entityManager.persist(
                new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10));
        place(alice, keyboard, 1);
        bobsOrderId = place(bob, keyboard, 2).getId();
        place(alice, keyboard, 3);
        entityManager.flush();
        entityManager.clear();
    }
 
    @Test
    void findsTheUsernameOfTheCustomerWhoOwnsAnOrder() {
        assertThat(orders.findCustomerUsernameById(bobsOrderId)).contains("bob");
        assertThat(orders.findCustomerUsernameById(999L)).isEmpty();
    }
 
    @Test
    void pagesTheOrdersOfOneCustomerWithTheirLines() {
        Page<Order> page = orders.findByCustomerUsername("alice", PageRequest.of(0, 1, Sort.by("id")));
        entityManager.clear();
 
        assertThat(page.getTotalElements()).isEqualTo(2);
        assertThat(page.getTotalPages()).isEqualTo(2);
        assertThat(page.getContent()).singleElement()
                .satisfies(order -> assertThat(order.getLines())
                        .extracting(OrderLine::getQuantity)
                        .containsExactly(1));
    }
 
    private Order place(AppUser customer, Product product, int quantity) {
        Order order = new Order(customer);
        order.addLine(new OrderLine(product, quantity));
        return entityManager.persist(order);
    }
}

@Import(AuditingConfig.class) không phải tùy chọn. @DataJpaTest chỉ scan các component JPA, AuditingConfig là một @Configuration, và thiếu nó thì không gì điền các cột audit. Cùng class đó khi không import:

Text
org.hibernate.exception.ConstraintViolationException: could not execute statement [NULL not allowed for column "CREATED_AT"; SQL statement:
insert into products (created_at,name,price,sku,stock,updated_at,id) values (?,?,?,?,?,?,default) [23502-240]]

Flyway chạy trong slice: log của test có Successfully applied 3 migrations to schema "PUBLIC" trên database embedded trước khi Hibernate khởi động, nên các query được test trên schema đã migrate. SQL mà query có phân trang gửi đi, với showSql bật như @DataJpaTest đặt:

Text
Hibernate: select o1_0.id,o1_0.created_at,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price,o1_0.status,o1_0.updated_at from (select o1_0.id,o1_0.created_at,o1_0.customer_id,o1_0.status,o1_0.updated_at from orders o1_0 join users c1_0 on c1_0.id=o1_0.customer_id where c1_0.username=? order by o1_0.id offset ? rows fetch first ? rows only) o1_0(id,created_at,customer_id,status,updated_at) left join order_lines l1_0 on o1_0.id=l1_0.order_id order by o1_0.id
Hibernate: select count(*) from orders o1_0 join users c1_0 on c1_0.id=o1_0.customer_id where c1_0.username=?

Page gồm một order được cắt bên trong derived table rồi các line được join vào, và câu đếm không join tới order_lines: đúng các dạng mà bài 29 đã đo cho entity graph trên một derived query. entityManager.clear() ngay sau query tách các order khỏi persistence context, nên assertion trên các line chỉ pass khi chính query đã fetch chúng. Khi bỏ @EntityGraph khỏi findByCustomerUsername, cùng test đó fail với LazyInitializationException: Cannot lazily initialize collection of role 'com.example.orders.order.Order.lines' with key '4' (no session).

Test end-to-end trên port ngẫu nhiên

src/test/java/com/example/orders/order/OrderApiTest.java
package com.example.orders.order;
 
import static org.assertj.core.api.Assertions.assertThat;
 
import java.math.BigDecimal;
import java.net.URI;
import java.util.List;
 
import com.example.orders.product.Product;
import com.example.orders.product.ProductRepository;
import com.example.orders.user.AppUserRepository;
import com.example.orders.user.LoginRequest;
import com.example.orders.user.RegisterRequest;
import com.example.orders.user.TokenResponse;
 
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;
 
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class OrderApiTest {
 
    @Autowired
    RestTestClient client;
 
    @Autowired
    OrderRepository orders;
 
    @Autowired
    ProductRepository products;
 
    @Autowired
    AppUserRepository users;
 
    private Product keyboard;
 
    @BeforeEach
    void createProduct() {
        keyboard = products.save(new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10));
    }
 
    @AfterEach
    void deleteWhatTheServerCommitted() {
        orders.deleteAll();
        products.deleteAll();
        users.deleteAll();
    }
 
    @Test
    void customerRegistersPlacesAnOrderAndReadsItBack() {
        String carol = registerAndLogIn("carol");
 
        URI location = client.post().uri("/api/orders")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + carol)
                .contentType(MediaType.APPLICATION_JSON)
                .body(new PlaceOrderRequest(List.of(new PlaceOrderRequest.Line(keyboard.getId(), 3))))
                .exchange()
                .expectStatus().isCreated()
                .returnResult()
                .getResponseHeaders().getLocation();
 
        client.get().uri(location)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + carol)
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.status").isEqualTo("PLACED")
                .jsonPath("$.total").isEqualTo(269.70);
 
        assertThat(products.findById(keyboard.getId())).get()
                .extracting(Product::getStock).isEqualTo(7);
 
        String dave = registerAndLogIn("dave");
        client.get().uri(location)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + dave)
                .exchange()
                .expectStatus().isForbidden();
    }
 
    private String registerAndLogIn(String username) {
        String password = "Secret-" + username + "-2026";
        client.post().uri("/api/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .body(new RegisterRequest(username, username + "@example.com", password))
                .exchange()
                .expectStatus().isCreated();
        return client.post().uri("/api/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .body(new LoginRequest(username, password))
                .exchange()
                .expectStatus().isOk()
                .expectBody(TokenResponse.class)
                .returnResult()
                .getResponseBody()
                .accessToken();
    }
}

Không có gì bị giả lập: BCrypt kiểm tra password của carol, TokenService ký bằng secrets/private.pem, decoder xác minh bằng secrets/public.pem, service lock row của product, và @PreAuthorize từ chối dave. Server commit trên các thread của nó, nên test xóa order, product và user sau đó thay vì dựa vào @Transactional, như bài 38 đã cho thấy.

Chạy bộ test

Bash
./gradlew test
Text
OrdersApplicationTests > contextLoads() PASSED
 
OrderApiTest > customerRegistersPlacesAnOrderAndReadsItBack() PASSED
 
OrderControllerTest > methodSecurityDenialIs403NotA500() PASSED
 
OrderControllerTest > invalidBodyIs422AndNeverReachesTheService() PASSED
 
OrderControllerTest > placeWithAnInvalidTokenIs401() PASSED
 
OrderControllerTest > placeWithoutATokenIs401() PASSED
 
OrderControllerTest > shippingNeedsTheAdminRole() PASSED
 
OrderControllerTest > placeAnswers201WithTheLocationOfTheOrder() PASSED
 
OrderControllerTest > insufficientStockIs409() PASSED
 
OrderRepositoryTest > findsTheUsernameOfTheCustomerWhoOwnsAnOrder() PASSED
 
OrderRepositoryTest > pagesTheOrdersOfOneCustomerWithTheirLines() PASSED
 
OrderServiceTest > cancelPutsTheStockBack() PASSED
 
OrderServiceTest > shipRefusesACancelledOrder() PASSED
 
OrderServiceTest > placeTakesStockForEveryLineAndSavesTheOrder() PASSED
 
OrderServiceTest > cancelRefusesAShippedOrderAndKeepsTheStock() PASSED
 
OrderServiceTest > placeSavesNothingWhenALineHasTooLittleStock() PASSED
 
BUILD SUCCESSFUL in 5s

Thời gian từng class lấy từ report JUnit của lần nhanh nhất trong ba lần chạy ./gradlew test --rerun, với load average một phút là 7.0; các con số chỉ mang tính tham khảo:

Test classLoạiSố testThời gian
OrdersApplicationTests@SpringBootTest, test context được sinh sẵn12.485 s
OrderApiTest@SpringBootTest(webEnvironment = RANDOM_PORT)11.141 s
OrderControllerTest@WebMvcTest kèm security chain70.301 s
OrderRepositoryTest@DataJpaTest20.229 s
OrderServiceTestJUnit và Mockito thuần50.131 s
Tổng164.287 s

Class đầu tiên trả giá cho JVM còn lạnh và application context đầu tiên; web slice và JPA slice dựng các context nhỏ hơn trong JVM đã ấm.

Docker: image, Compose và JWT key

Dockerfile là phiên bản multi-stage cuối cùng của bài 41, với cache mount của BuildKit, giữ nguyên:

Dockerfile
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY gradlew settings.gradle build.gradle ./
COPY gradle gradle
RUN ./gradlew dependencies --no-daemon > /dev/null
COPY src src
RUN --mount=type=cache,target=/root/.gradle/caches ./gradlew bootJar -x test --no-daemon
 
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring --no-create-home spring
WORKDIR /app
COPY --from=build /workspace/build/libs/*.jar app.jar
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Nó chỉ copy gradlew, các file build, gradlesrc, nên key trong secrets không bao giờ đi vào quá trình build. .dockerignore là của bài 41 thêm hai file cục bộ, để không lệnh COPY nào sau này nhặt được chúng:

.dockerignore
.git
.gradle
.idea
build/*
!build/libs/
secrets
.env

compose.yaml sửa từ bài 41

compose.yaml
services:
  db:
    image: postgres:18
    environment:
      POSTGRES_DB: shop
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: secret
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD", "pg_isready", "-h", "localhost", "-U", "shop", "-d", "shop"]
      interval: 2s
      timeout: 3s
      retries: 15
 
  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
    environment:
      SPRING_PROFILES_ACTIVE: postgres
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
      SPRING_DATASOURCE_PASSWORD: secret
      SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_PUBLIC_KEY_LOCATION: file:/run/secrets/jwt-public-key
      APP_JWT_PRIVATE_KEY_LOCATION: file:/run/secrets/jwt-private-key
      APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:?set APP_ADMIN_PASSWORD in .env}
    secrets: 
      - jwt-public-key
      - jwt-private-key
    ports:
      - "8141:8080"
      - "8142:8080"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s
 
secrets: 
  jwt-public-key: 
    file: ./secrets/public.pem
  jwt-private-key: 
    file: ./secrets/private.pem
 
volumes:
  db-data:
  • Hai environment variable trỏ các property của key tới /run/secrets, nơi Compose mount từng mục trong danh sách secrets của service theo tên của nó. Relaxed binding map SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_PUBLIC_KEY_LOCATION vào property mà cả decoder của Boot lẫn @Value trong AuthConfig đọc.
  • ${APP_ADMIN_PASSWORD:?...} khiến Compose từ chối khởi động khi thiếu biến này, biến mà Compose đọc từ file .env đặt cạnh compose.yaml; các lần chạy bên dưới dùng giá trị trong file này, và .gitignore giữ file ngoài Git.
  • Password của database vẫn nằm trong file, như bài 41: PostgreSQL không publish port nào, còn password admin bảo vệ một API được publish trên 8142.
.env
APP_ADMIN_PASSWORD=Admin-2026-secret

Khi không có file đó, up dừng trước khi build bất cứ thứ gì:

Text
Error while interpolating services.app.environment.APP_ADMIN_PASSWORD: required variable APP_ADMIN_PASSWORD is missing a value: set APP_ADMIN_PASSWORD in .env

Đưa JWT key vào container bằng Compose secrets

Private key ký mọi token mà API chấp nhận, nên nó phải có mặt trong container đang chạy mà không nằm trong image, nơi bất cứ ai pull được image đều đọc được. Compose secrets với file: làm đúng việc đó: file ở lại trên host và được mount chỉ đọc vào container lúc khởi động. Bên trong container ứng dụng đang chạy:

Bash
docker exec sb-a42-app-1 id
docker exec sb-a42-app-1 ls -l /run/secrets/
docker exec sb-a42-app-1 sh -c 'mount | grep secrets'
Text
uid=999(spring) gid=999(spring) groups=999(spring)
total 8
-rw------- 1 spring spring 1708 Sep 16 09:05 jwt-private-key
-rw-r--r-- 1 spring spring  451 Sep 16 09:05 jwt-public-key
/run/host_mark/private on /run/secrets/jwt-public-key type fakeowner (ro,nosuid,nodev,relatime,fakeowner)
/run/host_mark/private on /run/secrets/jwt-private-key type fakeowner (ro,nosuid,nodev,relatime,fakeowner)

Cả hai file là mount chỉ đọc, và mode 600 của openssl genpkey được giữ nguyên. Chúng hiện là của spring vì cơ chế chia sẻ file của Docker Desktop trình bày file của host với owner là process đang đọc chúng, chính là kiểu mount fakeowner ở trên; đó là lý do user không phải root của bài 41 đọc được một file 600 do user khác tạo. Trên host Linux, bind mount giữ owner dạng số thật của file. Cùng private key đó chép vào một Docker volume, nơi quyền sở hữu là thật, thuộc uid 1000 với mode 600 và mount chỉ đọc tại /run/secrets trong một container chạy bằng uid 999, đã không đọc được:

Text
head: cannot open '/run/secrets/private.pem' for reading: Permission denied

Trên Linux, hãy giao key cho uid 999 hoặc một group mà user đó thuộc về trước khi khởi động stack. Cú pháp dài của Compose có uid, gidmode cho secrets, nhưng Compose 5.3.1 bỏ qua chúng với secret dạng file và nói rõ như vậy: secrets 'uid', 'gid' and 'mode' are not supported, they will be ignored. Ở production, cũng hai file đó đến từ secret store của nền tảng, như Kubernetes Secrets hay secret manager của cloud, được mount thành file hoặc do nền tảng inject, không bao giờ từ repository hay image.

Khởi động stack

Bash
docker compose -p sb-a42 up --build -d

Sau các bước BuildKit, mất 55 giây khi build cache còn trống với load average 3.3:

Text
 Image sb-a42-app Built 
 Network sb-a42_default Creating 
 Network sb-a42_default Created 
 Volume sb-a42_db-data Creating 
 Volume sb-a42_db-data Created 
 Container sb-a42-db-1 Creating 
 Container sb-a42-db-1 Created 
 Container sb-a42-app-1 Creating 
 Container sb-a42-app-1 Created 
 Container sb-a42-db-1 Starting 
 Container sb-a42-db-1 Started 
 Container sb-a42-db-1 Waiting 
 Container sb-a42-db-1 Healthy 
 Container sb-a42-app-1 Starting 
 Container sb-a42-app-1 Started 

Build lại sau khi sửa một file source, chính là AuditingConfig ở trên, mất 9.7 giây với docker compose -p sb-a42 down -v rồi cùng lệnh up --build -d: mọi layer tới COPY src src lấy từ cache, và bootJar với cache mount chạy trong 4.0 giây.

Bash
docker compose -p sb-a42 ps
Text
NAME           IMAGE         COMMAND                  SERVICE   CREATED          STATUS                    PORTS
sb-a42-app-1   sb-a42-app    "java -jar /app/app.…"   app       13 seconds ago   Up 10 seconds (healthy)   0.0.0.0:8142->8080/tcp, [::]:8142->8080/tcp
sb-a42-db-1    postgres:18   "docker-entrypoint.s…"   db        13 seconds ago   Up 12 seconds (healthy)   5432/tcp
Bash
docker compose -p sb-a42 logs app

Các dòng cho thấy profile, migration và administrator:

Text
app-1  | 2026-09-16T09:19:04.953Z  INFO 1 --- [orders] [           main] com.example.orders.OrdersApplication     : The following 1 profile is active: "postgres"
app-1  | 2026-09-16T09:19:05.975Z  INFO 1 --- [orders] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://db:5432/shop (PostgreSQL 18.6)
app-1  | 2026-09-16T09:19:06.055Z  INFO 1 --- [orders] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 3 migrations to schema "public", now at version v3 (execution time 00:00.006s)
app-1  | 2026-09-16T09:19:07.526Z  INFO 1 --- [orders] [           main] com.example.orders.OrdersApplication     : Started OrdersApplication in 2.732 seconds (process running for 2.981)
app-1  | 2026-09-16T09:19:07.676Z  INFO 1 --- [orders] [           main] c.e.orders.user.AdminAccountInitializer  : Created admin account 'admin'

Started OrdersApplication cũng có nghĩa là ddl-auto=validate đã chấp nhận schema đã migrate trên PostgreSQL. docker images sb-a42-app báo 606MB dung lượng trên đĩa và 176MB nội dung, nhiều hơn image catalogue của bài 41 10 MB nội dung; app.jar ở đây là 69,402,210 byte so với 58,557,202 byte ở đó, với Security, springdoc và Actuator bên trong.

Stack Compose: container app chạy bằng user spring với healthcheck curl trên /actuator/health, publish trên port 8142, container db với volume sb-a42_db-data và check pg_isready, cả hai trên network sb-a42_default, và hai file key được mount chỉ đọc tại /run/secrets

Chạy API từ đầu tới cuối bằng curl

Các request dưới đây chạy theo đúng thứ tự này trên stack mới dựng, nên các id nối tiếp nhau: administrator có id 1, alice 2 và bob 3. Các security header như X-Frame-OptionsCache-Control, cùng Date, Transfer-EncodingContent-Length, được lược khỏi output của curl -i.

Đăng ký, đăng nhập và tạo product

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"username":"alice","email":"alice@example.com","password":"Wonderland-2026"}' http://localhost:8142/api/auth/register
Http
HTTP/1.1 201 
Content-Type: application/json
 
{"id":2,"username":"alice","email":"alice@example.com","role":"USER"}

bob đăng ký theo cùng cách, với password Builder-2026-bob, và nhận id 3. Đăng nhập:

Bash
curl -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8142/api/auth/login
JSON
{"accessToken":"eyJraWQiOiJ1cWU1bnBBb1ZCYVhDYnBfVHZ0MVZYV3VuSzhSQ0I4QTFGUXVWMElPRjNNIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL29yZGVycy5leGFtcGxlLmNvbSIs…","tokenType":"Bearer","expiresIn":900}

Shell giữ một token cho mỗi user; password admin là password trong .env:

Bash
ALICE=$(curl -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8142/api/auth/login | jq -r .accessToken)
BOB=$(curl -s -H 'Content-Type: application/json' -d '{"username":"bob","password":"Builder-2026-bob"}' http://localhost:8142/api/auth/login | jq -r .accessToken)
ADMIN=$(curl -s -H 'Content-Type: application/json' -d '{"username":"admin","password":"Admin-2026-secret"}' http://localhost:8142/api/auth/login | jq -r .accessToken)

Giải mã bằng b64url_decode của bài 35, payload trong token của alice và của administrator là:

JSON
{"iss":"https://orders.example.com","sub":"alice","exp":1789551255,"iat":1789550355,"roles":["USER"]}
{"iss":"https://orders.example.com","sub":"admin","exp":1789551255,"iat":1789550355,"roles":["ADMIN"]}

Administrator tạo một bàn phím với 10 cái trong stock, rồi một con chuột với 3 và một USB-C hub với 5 theo cùng cách:

Bash
curl -s -i -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":10}' http://localhost:8142/api/products
Http
HTTP/1.1 201 
Location: http://localhost:8142/api/products/1
Content-Type: application/json
 
{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":10,"createdAt":"2026-09-16T09:19:15.316336Z","updatedAt":"2026-09-16T09:19:15.316336Z"}

Ai cũng đọc được catalogue, mỗi lần một page:

Bash
curl -s -i "http://localhost:8142/api/products?size=2&sort=price,desc"
Http
HTTP/1.1 200 
Content-Type: application/json
 
{"content":[{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":10,"createdAt":"2026-09-16T09:19:15.316336Z","updatedAt":"2026-09-16T09:19:15.316336Z"},{"id":3,"name":"USB-C hub","sku":"HUB-07","price":39.00,"stock":5,"createdAt":"2026-09-16T09:19:15.346947Z","updatedAt":"2026-09-16T09:19:15.346947Z"}],"page":0,"size":2,"totalElements":3,"totalPages":2,"hasNext":true}

Đặt order và các response khi thất bại

Bash
curl -s -i -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":2,"quantity":1},{"productId":1,"quantity":2}]}' http://localhost:8142/api/orders
Http
HTTP/1.1 201 
Location: http://localhost:8142/api/orders/1
Content-Type: application/json
 
{"id":1,"customerId":2,"status":"PLACED","lines":[{"productId":1,"quantity":2,"unitPrice":89.90,"lineTotal":179.80},{"productId":2,"quantity":1,"unitPrice":24.50,"lineTotal":24.50}],"total":204.30,"createdAt":"2026-09-16T09:19:15.401168Z","updatedAt":"2026-09-16T09:19:15.401168Z"}

Stock sau đó:

Bash
curl -s "http://localhost:8142/api/products?sort=id" | jq -c '[.content[] | {sku, stock}]'
JSON
[{"sku":"KB-01","stock":8},{"sku":"MS-01","stock":2},{"sku":"HUB-07","stock":5}]

Một order gồm một bàn phím và năm con chuột, khi chỉ còn hai con chuột:

Bash
curl -s -i -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":1},{"productId":2,"quantity":5}]}' http://localhost:8142/api/orders
Http
HTTP/1.1 409 
Content-Type: application/problem+json
 
{"detail":"Only 2 of MS-01 in stock, 5 requested","instance":"/api/orders","status":409,"title":"Conflict"}

Cùng query stock đó trả [{"sku":"KB-01","stock":8},{"sku":"MS-01","stock":2},{"sku":"HUB-07","stock":5}]: line bàn phím đã được xử lý trước, và rollback không để lại dấu vết nào của nó. Một body không hợp lệ:

Bash
curl -s -i -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":0},{"quantity":2}]}' http://localhost:8142/api/orders
Http
HTTP/1.1 422 
Content-Type: application/problem+json
 
{"detail":"Request has 2 invalid value(s).","instance":"/api/orders","status":422,"title":"Unprocessable Content","errors":[{"field":"lines[0].quantity","message":"must be greater than 0"},{"field":"lines[1].productId","message":"must not be null"}]}

Không có token:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":1}]}' http://localhost:8142/api/orders
Http
HTTP/1.1 401 
WWW-Authenticate: Bearer realm="orders", resource_metadata="http://localhost:8142/.well-known/oauth-protected-resource"
Content-Type: application/problem+json
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/orders","status":401,"title":"Unauthorized"}

Ownership, hủy order và ship order

bob xin order của alice:

Bash
curl -s -i -H "Authorization: Bearer $BOB" http://localhost:8142/api/orders/1
Http
HTTP/1.1 403 
Content-Type: application/problem+json
 
{"detail":"You are not allowed to perform this operation.","instance":"/api/orders/1","status":403,"title":"Forbidden"}

Cùng request đó với token của alice trả 200 kèm order, còn GET /api/orders của bob trả {"content":[],"page":0,"size":20,"totalElements":0,"totalPages":0,"hasNext":false}. alice hủy order:

Bash
curl -s -i -X POST -H "Authorization: Bearer $ALICE" http://localhost:8142/api/orders/1/cancel
Http
HTTP/1.1 200 
Content-Type: application/json
 
{"id":1,"customerId":2,"status":"CANCELLED","lines":[{"productId":1,"quantity":2,"unitPrice":89.90,"lineTotal":179.80},{"productId":2,"quantity":1,"unitPrice":24.50,"lineTotal":24.50}],"total":204.30,"createdAt":"2026-09-16T09:19:15.401168Z","updatedAt":"2026-09-16T09:19:15.565838Z"}

Stock quay về đúng như lúc đầu, [{"sku":"KB-01","stock":10},{"sku":"MS-01","stock":3},{"sku":"HUB-07","stock":5}], và updatedAt là thời điểm hủy. Sau đó alice đặt một cái hub, order 2. Cô không tự ship được: POST /api/orders/2/ship với token của cô nhận 403 của URL rule. Administrator thì được:

Bash
curl -s -i -X POST -H "Authorization: Bearer $ADMIN" http://localhost:8142/api/orders/2/ship
Http
HTTP/1.1 200 
Content-Type: application/json
 
{"id":2,"customerId":2,"status":"SHIPPED","lines":[{"productId":3,"quantity":1,"unitPrice":39.00,"lineTotal":39.00}],"total":39.00,"createdAt":"2026-09-16T09:19:15.590799Z","updatedAt":"2026-09-16T09:19:15.616806Z"}

Và một order đã ship thì không hủy được nữa:

Bash
curl -s -i -X POST -H "Authorization: Bearer $ALICE" http://localhost:8142/api/orders/2/cancel
Http
HTTP/1.1 409 
Content-Type: application/problem+json
 
{"detail":"Order 2 is SHIPPED and cannot be cancelled","instance":"/api/orders/2/cancel","status":409,"title":"Conflict"}

Khi xong việc, docker compose -p sb-a42 down -v --rmi local xóa các container, network, volume và image đã build cho stack.

Đi tiếp từ đây

Dự án tổng kết dừng ở nơi khóa Advanced bắt đầu. Mỗi mục dưới đây xây trực tiếp trên một phần ở trên:

  • Query ở quy mô lớn: phát hiện N+1 và batch fetching, projection thay cho entity trong các danh sách, Specification cho bộ lọc, keyset pagination cho các page sâu.
  • Test trên database thật với Testcontainers và @ServiceConnection, thay cho H2 và những bất ngờ của nó.
  • Caching cho việc đọc product với cache abstraction của Spring.
  • Observability: metric với Micrometer, tracing và log có cấu trúc, cùng Actuator ngoài health.
  • Token có vòng đời: refresh token và authorization server, thứ mà token 15 phút của API này cố ý bỏ qua.
  • Delivery: layered JAR và buildpack, Docker Compose support của Spring Boot cho môi trường phát triển, pipeline CI, native image GraalVM và Kubernetes.
  • Tách hệ thống: event, outbox và microservice khi order và product lớn quá một ứng dụng.

Upload ảnh product, email xác nhận order và các job theo lịch của bài 40 có thể gắn vào cùng các package feature mà không thay đổi gì đã trình bày ở đây.

FAQ

Với JWT, nên tạo order dưới URL của customer hay dưới /api/orders?

Với bearer token, hãy dùng POST /api/orders cho user đã đăng nhập. Subject của token đã xác định customer; một customer id trên URL chỉ có thể lặp lại hoặc mâu thuẫn với nó, và một mâu thuẫn chỉ có thể được trả lời bằng 403. Giữ URL riêng của order trong Location, ở đây là http://localhost:8142/api/orders/1, và cho administrator truy cập order của user khác qua chính các URL đó kèm kiểm tra role.

Làm sao ngăn các order đồng thời bán quá stock trong Spring Boot?

Lock row của product bên trong transaction trước khi đọc stock. Không có lock, hai mươi order song song cho năm món hàng nhận lần lượt 20, 18 và 20 response 201 trong ba lần chạy trên PostgreSQL 18, và một lần chạy còn kết thúc với stock 2. Với @Lock(LockModeType.PESSIMISTIC_WRITE) trên method của repository, được Hibernate 7.4.5 gửi thành for no key update, đúng năm request thành công và mười lăm request nhận 409. Lock nhiều product theo một thứ tự cố định, ví dụ theo id, để hai order không thể deadlock.

Vì sao @WebMvcTest ném StackOverflowError với một bearer token không hợp lệ?

Vì class cấu hình được import còn khai báo bean AuthenticationManager từ AuthenticationConfiguration.getAuthenticationManager(). Trong slice không có UserDetailsService, nên bean đó trở thành một lazy proxy tự gọi chính nó khi ProviderManager của resource server hỏi manager cha. Đặt filter chain vào một class và các bean authentication cùng token vào class khác, rồi chỉ import chain: khi đó cùng test nhận được 401.

Làm sao đưa JWT private key vào container Docker mà không nhét nó vào image?

Giữ key ngoài src/main/resources và ngoài build context, rồi mount nó lúc chạy. Với Compose secretsfile: ./secrets/private.pem, key xuất hiện chỉ đọc tại /run/secrets/jwt-private-key, và APP_JWT_PRIVATE_KEY_LOCATION=file:/run/secrets/jwt-private-key trỏ ứng dụng tới đó. Trên host Linux file được mount giữ owner và mode của nó, nên hãy làm cho user của container đọc được; ở production, dùng secret store của nền tảng.

Vì sao Swagger UI hiện object pageable thay vì page, size và sort?

springdoc-openapi 3.1.1 mô tả một tham số Pageable thường của controller thành một query parameter bắt buộc tên pageable với schema dạng object. Gắn @ParameterObject lên nó thì operation liệt kê page, sizesort, kèm giá trị mặc định từ @PageableDefault.

Vì sao @DataJpaTest fail với NULL not allowed for column CREATED_AT?

@DataJpaTest không nạp các class @Configuration, nên thiếu @EnableJpaAuditingAuditingEntityListener không điền gì cả. Thêm @Import với class cấu hình auditing vào test, như OrderRepositoryTest làm.

Kết luận

Dự án tổng kết là cả khóa học trong một ứng dụng. Các package feature chứa controller map DTO, service giữ rule và repository fetch đúng những gì mỗi endpoint cần; Flyway dựng schema mà Hibernate validate; ProblemDetail trả lời mọi lỗi với status mà bài 15 đã chọn cho nó; JWT xác định người gọi, URL rule quyết định theo path và @PreAuthorize quyết định theo chủ sở hữu; springdoc mô tả API kèm bearer scheme; test phủ các rule, tầng web cùng security, các query và toàn bộ stack; và Compose chạy tất cả trên PostgreSQL với key được mount lúc chạy.

Những phần thú vị nằm ở chỗ nối. Chỉ riêng @Transactional giữ được một order trọn vẹn nhưng vẫn để hai mươi order đồng thời bán năm cái hub, cho tới khi row của product được lock theo thứ tự id. Một web slice import cả các bean authentication thì sập với token không hợp lệ, và một JPA slice thiếu cấu hình auditing thì không insert nổi một row. Pageable cần @ParameterObject để dùng được trong Swagger UI, đồng hồ của container cần được cắt xuống mức PostgreSQL lưu, H2 cần bỏ các check chuỗi, và private key cần một đường vào container không phải là image.

Bài này khép lại Spring Boot Basics: từ phần Java mà ứng dụng Spring cần, qua bean, cấu hình, REST, dữ liệu, security, test và đóng gói, tới một API hoàn chỉnh. Khóa Advanced tiếp tục từ đây, bắt đầu với những câu hỏi mà API này đặt ra khi chạy ở quy mô lớn.

Bài viết liên quan

[Spring Boot Basics] IoC và Dependency Injection trong Spring: vì sao không còn tự new object

Ý tưởng mà cả framework dựa lên, demo trên Spring Boot 4.1.1: một object graph bốn class tự new ở mọi tầng cùng ba hậu quả kéo theo, phân biệt rạch ròi Inversion of Control với Dependency Injection, nối tay cùng graph đó trong main mà không có framework nào, rồi để Spring container nối và in identity của từng instance ra để chứng minh, kèm một test JUnit 5 với stub tự viết, một lần đổi implementation mà không đụng vào class đang dùng nó, và danh sách thành thật những cái giá phải trả.

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Spring Boot Basics] Truy vấn với Spring Data JPA: derived query method, @Query với JPQL và native query

Query với Spring Data JPA trên Spring Boot 4.1.1, với H2 và PostgreSQL: tên derived query method được phân tích thành JPQL rồi SQL ra sao, bảng keyword kèm SQL mà từng keyword sinh ra, các return type và exception chúng ném ra, lỗi khởi động khi gõ sai tên property, Containing có escape % và _ không, @Query với JPQL và khi nào cần @Param, native full-text search chạy trên PostgreSQL nhưng lỗi trên H2, SQL injection do nối chuỗi, và bulk update với @Modifying cùng cái bẫy persistence context bị stale, giá trị cũ bị ghi lại khi flush, và clearAutomatically.

[Spring Boot Basics] Cài đặt Spring Boot: JDK, IDE, Spring Initializr và ứng dụng đầu tiên

Cài JDK 21 trên macOS, Windows và Linux, sửa JAVA_HOME trỏ nhầm JDK, so sánh IntelliJ IDEA với VS Code, tạo project Spring Boot 4.1.1 bằng Spring Initializr hoặc một câu lệnh curl, chạy bằng Gradle wrapper, đọc log khởi động từng dòng, viết @RestController trả về JSON, đổi server.port và xử lý năm lỗi mà người mới nào cũng gặp.