Thao tác CRUD với Spring Data JPA

Repository Pattern — Tìm hiểu sâu

Repository Pattern là gì?

Repository pattern là design pattern trừu tượng hóa truy cập dữ liệu phía sau một interface sạch. Ý tưởng đơn giản: tầng service không nên biết (hoặc quan tâm) dữ liệu đến từ database, REST API, file, hay danh sách trong bộ nhớ. Nó chỉ gọi phương thức trên repository interface.

Hệ thống phân cấp Repository

Repository                    (interface đánh dấu — không có phương thức)
    ↓
CrudRepository               (CRUD cơ bản: save, findById, findAll, delete, count)
    ↓
ListCrudRepository            (giống CrudRepository nhưng trả List thay vì Iterable)
    ↓
PagingAndSortingRepository   (thêm phân trang và sắp xếp)
    ↓
JpaRepository                (thêm phương thức JPA: flush, saveAndFlush, batch delete)

Luôn extend JpaRepository — nó bao gồm mọi thứ từ các tầng trên, cộng thêm tính năng đặc thù JPA.


Các phương thức có sẵn — save(), findById(), findAll(), deleteById()

save() — Tạo và Cập nhật

Phương thức save() xử lý cả INSERT và UPDATE. Nó kiểm tra entity là mới (id null) hay đã tồn tại (id có giá trị):

@Service
public class UserService {

    // TẠO — khi id null, save() sinh INSERT
    public User createUser(String username, String email) {
        User user = new User(username, email, "tempHash");
        // user.getId() là null → Hibernate sinh:
        // INSERT INTO users (username, email, ...) VALUES (?, ?, ...)
        User saved = userRepository.save(user);
        // Sau save(), id được gán bởi database (AUTO_INCREMENT)
        System.out.println("Đã tạo user với id: " + saved.getId());
        return saved;
    }

    // CẬP NHẬT — khi id có giá trị, save() sinh UPDATE
    public User updateUserBio(Long id, String newBio) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Không tìm thấy user"));
        user.setBio(newBio);
        // user.getId() KHÔNG null → Hibernate sinh:
        // UPDATE users SET username=?, email=?, bio=?, ... WHERE id=?
        return userRepository.save(user);
    }
}

Chi tiết quan trọng: Khi cập nhật, Hibernate gửi TẤT CẢ cột trong câu UPDATE, không chỉ cột bạn thay đổi.

findById() — Đọc một

public User getUserById(Long id) {
    // Sinh: SELECT * FROM users WHERE id = ?
    // Trả về Optional<User> — buộc bạn xử lý trường hợp "không tìm thấy"
    return userRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Không tìm thấy user id: " + id));
}

deleteById() — Xóa

public void deleteUser(Long id) {
    // Sinh: SELECT * FROM users WHERE id = ?  (tải entity trước)
    //       DELETE FROM users WHERE id = ?     (rồi xóa)
    // Hibernate tải entity trước khi xóa mặc định để cascade và callback hoạt động.
    userRepository.deleteById(id);
}

flush() và saveAndFlush()

Hibernate không thực thi SQL ngay lập tức. Nó thu thập thay đổi và ghi vào database vào thời điểm cụ thể. flush() buộc Hibernate ghi thay đổi đang chờ ngay lập tức:

// save() — xếp hàng INSERT nhưng có thể chưa thực thi ngay
User user = userRepository.save(new User("alice", "alice@example.com", "hash"));

// saveAndFlush() — thực thi INSERT ngay lập tức
User user = userRepository.saveAndFlush(new User("alice", "alice@example.com", "hash"));

Derived Query Method — Phép thuật quy ước đặt tên

Đây là một trong những tính năng ấn tượng nhất của Spring Data JPA. Bạn định nghĩa phương thức trong repository interface theo quy ước đặt tên cụ thể, và Spring tự động sinh truy vấn SQL.

Pattern cơ bản

findBy + TênTrường + ĐiềuKiện

Ví dụ cơ bản

public interface UserRepository extends JpaRepository<User, Long> {

    // findByEmail → SELECT * FROM users WHERE email = ?
    Optional<User> findByEmail(String email);

    // findByRole → SELECT * FROM users WHERE role = ?
    List<User> findByRole(String role);

    // findByActive → SELECT * FROM users WHERE is_active = ?
    List<User> findByActive(boolean active);
}

Kết hợp nhiều trường

public interface PostRepository extends JpaRepository<Post, Long> {

    // And — kết hợp với AND
    // SELECT * FROM posts WHERE status = ? AND author_id = ?
    List<Post> findByStatusAndAuthorId(String status, Long authorId);

    // Or — kết hợp với OR
    List<Post> findByStatusOrStatus(String status1, String status2);
}

Toán tử so sánh

// After — so sánh ngày
// SELECT * FROM posts WHERE created_at > ?
List<Post> findByCreatedAtAfter(LocalDateTime date);

// Between — phạm vi
// SELECT * FROM posts WHERE created_at BETWEEN ? AND ?
List<Post> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);

Thao tác chuỗi

// Containing → LIKE '%keyword%'
List<Post> findByTitleContaining(String keyword);

// IgnoreCase — không phân biệt hoa thường
List<Post> findByTitleContainingIgnoreCase(String keyword);

// StartingWith → LIKE 'prefix%'
List<Post> findByTitleStartingWith(String prefix);

// Not
List<Post> findByStatusNot(String status);

Thao tác Collection

// In — khớp bất kỳ giá trị nào trong danh sách
// SELECT * FROM posts WHERE status IN (?, ?, ?)
List<Post> findByStatusIn(List<String> statuses);

Kiểm tra Null

// IsNull → WHERE published_at IS NULL
List<Post> findByPublishedAtIsNull();

// IsNotNull → WHERE published_at IS NOT NULL
List<Post> findByPublishedAtIsNotNull();

Sắp xếp và giới hạn kết quả

// OrderBy — sắp xếp
List<Post> findByStatusOrderByCreatedAtDesc(String status);

// Top/First — giới hạn số kết quả
List<Post> findTop5ByOrderByCreatedAtDesc();

// Đếm và kiểm tra tồn tại
long countByStatus(String status);
boolean existsBySlug(String slug);

Khi Derived Query quá dài

Tên phương thức có thể trở nên cồng kềnh:

// Hoạt động nhưng khó đọc và bảo trì
List<Post> findByStatusAndCategoryIdAndTitleContainingIgnoreCaseOrderByCreatedAtDesc(
        String status, Long categoryId, String keyword);

Khi tên phương thức dài như vậy, hãy chuyển sang @Query (phần tiếp theo).


@Query với JPQL (Java Persistence Query Language)

Khi derived query trở nên quá phức tạp hoặc bạn cần kiểm soát nhiều hơn, sử dụng @Query với JPQL.

JPQL là gì?

JPQL là ngôn ngữ truy vấn tương tự SQL nhưng hoạt động trên entity thay vì bảng. Thay vì SELECT * FROM posts, bạn viết SELECT p FROM Post p — sử dụng tên class Java và tên trường.

Ví dụ @Query cơ bản

public interface PostRepository extends JpaRepository<Post, Long> {

    // JPQL dùng tên entity (Post) và tên trường (title), không phải tên bảng/cột
    @Query("SELECT p FROM Post p WHERE p.status = :status ORDER BY p.createdAt DESC")
    List<Post> findPublishedPosts(@Param("status") String status);

    // Tìm kiếm trong cả tiêu đề và nội dung
    @Query("SELECT p FROM Post p WHERE " +
           "LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
           "LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))")
    List<Post> searchByKeyword(@Param("keyword") String keyword);
}

Truy vấn JOIN trong JPQL

// JOIN sử dụng quan hệ entity — "p.author" theo trường @ManyToOne
@Query("SELECT p FROM Post p JOIN p.author a WHERE a.username = :username")
List<Post> findByAuthorUsername(@Param("username") String username);

Update và Delete với @Query

Cần thêm annotation @Modifying:

// @Modifying cho Spring biết truy vấn này thay đổi dữ liệu
@Modifying
@Transactional
@Query("UPDATE Post p SET p.status = :status WHERE p.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") String status);
// Trả về số hàng bị ảnh hưởng

// Cập nhật hàng loạt
@Modifying
@Transactional
@Query("UPDATE Post p SET p.status = 'PUBLISHED', p.publishedAt = CURRENT_TIMESTAMP " +
       "WHERE p.author.id = :authorId AND p.status = 'DRAFT'")
int publishAllDraftsByAuthor(@Param("authorId") Long authorId);

Quan trọng: Truy vấn @Modifying bỏ qua cache Hibernate. Thêm clearAutomatically = true để đồng bộ:

@Modifying(clearAutomatically = true)
@Query("UPDATE Post p SET p.status = :status WHERE p.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") String status);

Truy vấn SQL thuần với @Query(nativeQuery = true)

Đôi khi JPQL không đủ — bạn cần tính năng đặc thù database hoặc SQL thô cho hiệu suất.

// SQL thuần — dùng tên bảng và cột, không phải tên entity và trường
@Query(value = "SELECT * FROM posts WHERE status = :status ORDER BY created_at DESC",
       nativeQuery = true)
List<Post> findByStatusNative(@Param("status") String status);

// Tìm kiếm FULLTEXT đặc thù MariaDB (không có trong JPQL)
@Query(value = "SELECT * FROM posts WHERE MATCH(title, content) AGAINST(:keyword IN BOOLEAN MODE)",
       nativeQuery = true)
List<Post> fullTextSearch(@Param("keyword") String keyword);

Truy vấn native với phân trang

Cần cung cấp count query riêng:

@Query(value = "SELECT * FROM posts WHERE status = :status ORDER BY created_at DESC",
       countQuery = "SELECT COUNT(*) FROM posts WHERE status = :status",
       nativeQuery = true)
Page<Post> findByStatusNativePaged(@Param("status") String status, Pageable pageable);

JPQL vs Native SQL — Khi nào dùng cái nào?

Tình huống Dùng JPQL Dùng Native SQL
Truy vấn CRUD đơn giản
Truy vấn liên quan quan hệ entity
Portable giữa các database
Hàm đặc thù database
Truy vấn analytics/báo cáo phức tạp
Truy vấn hiệu suất critical

Bắt đầu với derived query method. Nếu quá phức tạp, chuyển sang JPQL. Chỉ dùng native SQL khi JPQL không diễn đạt được.


Phân trang và Sắp xếp với Pageable

Tại sao phân trang quan trọng

Tưởng tượng blog có 100,000 bài viết. Gọi findAll() tải tất cả 100,000 vào bộ nhớ — crash ứng dụng ngay lập tức. Phân trang giải quyết bằng cách tải dữ liệu theo từng phần nhỏ (trang).

Interface Pageable

// Tạo Pageable: trang 0 (trang đầu), 10 item/trang, sắp xếp theo createdAt giảm dần
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());

// Số trang bắt đầu từ 0:
// Trang 0 = item 1-10
// Trang 1 = item 11-20
// Trang 2 = item 21-30

Sử dụng Pageable trong Repository

public interface PostRepository extends JpaRepository<Post, Long> {
    // Derived query với phân trang
    Page<Post> findByStatus(String status, Pageable pageable);

    // JPQL với phân trang
    @Query("SELECT p FROM Post p WHERE p.status = :status AND p.author.active = true")
    Page<Post> findPublishedByActiveAuthors(@Param("status") String status, Pageable pageable);
}

Đối tượng Page

Page chứa không chỉ dữ liệu, mà còn metadata về phân trang:

Page<Post> postPage = postRepository.findByStatus("PUBLISHED", pageable);

List<Post> posts = postPage.getContent();         // Dữ liệu thực tế
long totalElements = postPage.getTotalElements();  // Tổng bài viết tất cả trang
int totalPages = postPage.getTotalPages();         // Tổng số trang
int currentPage = postPage.getNumber();            // Số trang hiện tại (từ 0)
boolean isFirst = postPage.isFirst();              // Có phải trang đầu?
boolean isLast = postPage.isLast();                // Có phải trang cuối?
boolean hasNext = postPage.hasNext();              // Có trang tiếp không?

Phân trang trong Controller

// GET /api/posts?page=0&size=10&sort=createdAt&direction=desc
@GetMapping
public Page<Post> getPosts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size,
        @RequestParam(defaultValue = "createdAt") String sortBy,
        @RequestParam(defaultValue = "desc") String direction) {

    Sort sort = direction.equalsIgnoreCase("asc")
            ? Sort.by(sortBy).ascending()
            : Sort.by(sortBy).descending();
    Pageable pageable = PageRequest.of(page, size, sort);
    return postService.getPostsByPage(pageable);
}

Response JSON bao gồm cả dữ liệu và metadata phân trang:

{
  "content": [ ... ],
  "totalElements": 45,
  "totalPages": 5,
  "last": false,
  "first": true,
  "numberOfElements": 10,
  "size": 10,
  "number": 0
}

Slice vs Page

Nếu không cần tổng số (đòi hỏi thêm truy vấn COUNT), dùng Slice thay vì Page:

// Slice — KHÔNG thực thi COUNT query (nhanh hơn). Chỉ biết có trang tiếp hay không.
Slice<Post> findByStatus(String status, Pageable pageable);

// Page — thực thi cả SELECT và COUNT. Biết totalElements và totalPages.
Page<Post> findByStatus(String status, Pageable pageable);

Dùng Slice cho UI cuộn vô hạn (chỉ cần “tải thêm”) và Page cho phân trang truyền thống với số trang.


Projection — Trả về dữ liệu một phần

Vấn đề

Khi tải entity Post, Hibernate lấy TẤT CẢ cột — bao gồm content TEXT có thể rất lớn. Cho trang danh sách chỉ cần title, status, và date, tải toàn bộ content là lãng phí.

Interface-Based Projection (Khuyến nghị)

Định nghĩa interface với getter cho các trường bạn cần:

public interface PostSummary {
    Long getId();
    String getTitle();
    String getSlug();
    String getStatus();
    String getExcerpt();
    LocalDateTime getCreatedAt();
}

Sử dụng projection trong repository:

public interface PostRepository extends JpaRepository<Post, Long> {

    // Trả PostSummary thay vì Post — chỉ cột được chọn mới được tải
    // Hibernate sinh: SELECT id, title, slug, status, excerpt, created_at FROM posts WHERE status = ?
    // Lưu ý: "content" KHÔNG được tải!
    List<PostSummary> findByStatus(String status);

    // Projection hoạt động với phân trang
    Page<PostSummary> findByStatus(String status, Pageable pageable);
}

Nested Projection

Có thể bao gồm dữ liệu entity liên quan:

public interface PostDetail {
    Long getId();
    String getTitle();
    String getContent();
    
    AuthorInfo getAuthor();      // Projection lồng nhau
    CategoryInfo getCategory();
    
    interface AuthorInfo {
        Long getId();
        String getUsername();
        String getFullName();
    }
    
    interface CategoryInfo {
        Long getId();
        String getName();
    }
}

Auditing — @CreatedDate, @LastModifiedDate

Trong Bài 5, chúng ta dùng @PrePersist@PreUpdate để đặt timestamp thủ công. Spring Data JPA cung cấp cách tiếp cận sạch hơn với auditing annotation.

Thiết lập Auditing

Bước 1: Bật JPA Auditing

@SpringBootApplication
@EnableJpaAuditing  // Bật Spring Data JPA auditing
public class BlogApiApplication { ... }

Bước 2: Tạo Base Entity

// @MappedSuperclass nghĩa là: class này không phải entity,
// nhưng trường của nó được kế thừa bởi entity con
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    // @CreatedDate — Spring tự động đặt khi entity được lưu lần đầu
    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    // @LastModifiedDate — Spring tự động cập nhật mỗi lần lưu
    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
}

Bước 3: Extend BaseEntity

@Entity
@Table(name = "users")
public class User extends BaseEntity {
    // Không cần trường createdAt/updatedAt hay @PrePersist/@PreUpdate!
    // Chúng được kế thừa từ BaseEntity và quản lý tự động.
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    // ...
}

Hiểu LazyInitializationException

Đây là lỗi JPA phổ biến nhất mà người mới gặp.

Nguyên nhân

Trong Bài 5, chúng ta định nghĩa @ManyToOne(fetch = FetchType.LAZY):

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;

FetchType.LAZY nghĩa là: không tải User khi tải Post. Trường authorproxy — placeholder chỉ tải dữ liệu thật khi bạn truy cập.

Vấn đề phát sinh khi Hibernate session (kết nối database) đã đóng trước khi bạn truy cập trường lazy:

Controller gọi Service.getPost()
    → Service tải Post (session mở)
    → Service trả Post cho Controller (session đóng)
    → Controller trả Post cho Jackson để serialize JSON
    → Jackson gọi post.getAuthor() để serialize trường author
    → Session đã đóng → BÙM! LazyInitializationException

Giải pháp

Giải pháp 1: Dùng DTO (Tốt nhất — Bài 8)

Ánh xạ sang DTO trong tầng service khi session còn mở:

@Transactional(readOnly = true)
public PostDto getPost(Long id) {
    Post post = postRepository.findById(id).orElseThrow();
    PostDto dto = new PostDto();
    dto.setId(post.getId());
    dto.setTitle(post.getTitle());
    dto.setAuthorName(post.getAuthor().getUsername());  // An toàn — session còn mở
    return dto;
}

Giải pháp 2: JOIN FETCH trong JPQL

@Query("SELECT p FROM Post p JOIN FETCH p.author WHERE p.id = :id")
Optional<Post> findByIdWithAuthor(@Param("id") Long id);

Giải pháp 3: @EntityGraph

@EntityGraph(attributePaths = {"author", "category"})
List<Post> findByStatus(String status);

Nên tắt open-in-view và dùng giải pháp đúng:

spring.jpa.open-in-view=false

Thực hành: Blog CRUD hoàn chỉnh với Phân trang & Tìm kiếm

PostRepository nâng cao

public interface PostRepository extends JpaRepository<Post, Long> {

    // Danh sách tóm tắt bài viết phân trang (không tải content)
    Page<PostSummary> findByStatus(String status, Pageable pageable);

    // Tìm theo slug (cho URL routing)
    Optional<Post> findBySlug(String slug);
    boolean existsBySlug(String slug);
    long countByStatus(String status);

    // Tìm kiếm với phân trang
    @Query("SELECT p FROM Post p WHERE " +
           "LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
           "LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))")
    Page<Post> searchByKeyword(@Param("keyword") String keyword, Pageable pageable);

    // Tải bài viết với tác giả và danh mục (tránh LazyInitializationException)
    @Query("SELECT p FROM Post p JOIN FETCH p.author LEFT JOIN FETCH p.category WHERE p.id = :id")
    Optional<Post> findByIdWithDetails(@Param("id") Long id);

    // EntityGraph cho danh sách có tác giả
    @EntityGraph(attributePaths = {"author"})
    @Query("SELECT p FROM Post p WHERE p.status = :status")
    Page<Post> findByStatusWithAuthor(@Param("status") String status, Pageable pageable);

    // Truy vấn sửa đổi
    @Modifying(clearAutomatically = true)
    @Transactional
    @Query("UPDATE Post p SET p.status = 'PUBLISHED', p.publishedAt = CURRENT_TIMESTAMP WHERE p.id = :id AND p.status = 'DRAFT'")
    int publishPost(@Param("id") Long id);
}

PostService với dirty checking

@Service
@Transactional(readOnly = true)
public class PostService {

    @Transactional
    public PostResponse updatePost(Long id, UpdatePostRequest request) {
        Post post = findPostOrThrow(id);

        if (request.getTitle() != null) {
            post.setTitle(request.getTitle().trim());
            post.setSlug(generateUniqueSlug(request.getTitle()));
        }
        if (request.getContent() != null) {
            post.setContent(request.getContent().trim());
        }

        // Không cần gọi save() tường minh!
        // Post là "managed entity" trong phương thức @Transactional.
        // Hibernate tự động phát hiện thay đổi và sinh UPDATE
        // khi transaction commit. Đây gọi là "dirty checking."
        return postMapper.toResponse(post);
    }
}

Kiểm thử

# Tạo bài viết
curl -X POST http://localhost:8080/api/posts \
  -H "Content-Type: application/json" \
  -d '{"title":"Hướng dẫn Spring Data JPA","content":"Hướng dẫn đầy đủ...","authorId":1}'

# Phát hành bài viết
curl -X PATCH http://localhost:8080/api/posts/1/publish

# Lấy danh sách phân trang (trang 0, 2 item/trang)
curl "http://localhost:8080/api/posts?page=0&size=2&status=PUBLISHED"

# Tìm kiếm có phân trang
curl "http://localhost:8080/api/posts/search?keyword=spring&page=0&size=10"

# Thống kê
curl http://localhost:8080/api/posts/stats

Bài tập

  1. Xây dựng CategoryServiceCategoryController với CRUD đầy đủ.
  2. Thêm endpoint GET /api/posts/recent trả 5 bài mới nhất dùng findTop5ByStatusOrderByPublishedAtDesc.
  3. Quan sát SQL logging: so sánh SQL sinh bởi findAll() vs findByStatus() vs searchByKeyword().
  4. Thử dirty checking: xác nhận UPDATE SQL được sinh mà không cần gọi save().

Tổng kết

  • Phương thức repository có sẵn: save() xử lý cả INSERT và UPDATE, findById() trả Optional, deleteById() tải rồi xóa.
  • Derived query method: Spring sinh SQL từ tên phương thức — findByStatus, findByTitleContainingIgnoreCase, countByStatus.
  • JPQL (@Query): Viết truy vấn dùng tên entity/trường. Dùng @Modifying cho UPDATE/DELETE.
  • Native SQL: Dùng nativeQuery = true cho tính năng đặc thù database.
  • Phân trang: Pageable + Page cung cấp phân trang đầy đủ với metadata. Dùng Slice cho cuộn vô hạn.
  • Projection: Interface-based projection chỉ tải cột cần thiết.
  • Auditing: @CreatedDate@LastModifiedDate với @EnableJpaAuditing tự động hóa quản lý timestamp.
  • LazyInitializationException: Do truy cập trường lazy sau khi session đóng. Sửa bằng DTO, JOIN FETCH, hoặc @EntityGraph.
  • Dirty checking: Trong @Transactional, Hibernate tự động phát hiện và lưu thay đổi — không cần save() tường minh cho update.

Tham chiếu nhanh

Khái niệm Mô tả
save(entity) INSERT nếu mới (id null), UPDATE nếu đã tồn tại
findById(id) Trả Optional — phải xử lý trường hợp không tìm thấy
findAll() Trả tất cả hàng — dùng phân trang cho bảng lớn
Derived query findByFieldCondition — Spring sinh SQL từ tên phương thức
@Query (JPQL) Truy vấn tùy chỉnh dùng tên entity
@Query (native) SQL thô với nativeQuery = true
@Modifying Bắt buộc cho phương thức UPDATE/DELETE @Query
Pageable Đóng gói số trang, kích thước, và sắp xếp
Page Kết quả với dữ liệu + metadata phân trang
Projection Interface với getter — chỉ tải cột được chọn
@CreatedDate Tự đặt timestamp khi lưu lần đầu
@LastModifiedDate Tự cập nhật timestamp mỗi lần lưu
@EntityGraph Tải eager khai báo cho truy vấn cụ thể
JOIN FETCH Từ khóa JPQL tải entity liên quan trong một truy vấn
Dirty checking Hibernate tự phát hiện thay đổi trong @Transactional

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *