✅ Mục tiêu bài học:
- CRUD bảng
Product
có quan hệCategory
. - Phân trang danh sách sản phẩm.
- Validate dữ liệu khi thêm/sửa.
- Upload hình ảnh sản phẩm.
- Tìm kiếm sản phẩm theo tên, sắp xếp theo giá.
🗄️ 1️⃣ SQL Cơ sở dữ liệu
CREATE DATABASE WebBanHangDB;
GO
USE WebBanHangDB;
GO
-- Nếu bảng Category có rồi thì không cần tạo lại
CREATE TABLE Category (
id INT IDENTITY(1,1) PRIMARY KEY,
name NVARCHAR(255) NOT NULL
);
CREATE TABLE Product (
id INT IDENTITY(1,1) PRIMARY KEY,
name NVARCHAR(255) NOT NULL,
price FLOAT NOT NULL,
image NVARCHAR(255),
category_id INT FOREIGN KEY REFERENCES Category(id)
);
INSERT INTO Category (name) VALUES
(N'Điện thoại'), (N'Máy tính'), (N'Thiết bị văn phòng');
INSERT INTO Product (name, price, image, category_id) VALUES
(N'iPhone 15', 29999, 'iphone15.jpg', 1),
(N'Laptop Dell', 22000, 'dell.jpg', 2),
(N'Máy in Canon', 4500, 'canon.jpg', 3);
📦 2️⃣ Cấu trúc thư mục
src/
├─ main/
│ ├─ java/com/lungcode/
│ │ ├─ entity/Category.java
│ │ ├─ entity/Product.java
│ │ ├─ repository/CategoryRepository.java
│ │ ├─ repository/ProductRepository.java
│ │ ├─ service/ProductService.java
│ │ ├─ service/impl/ProductServiceImpl.java
│ │ └─ controller/ProductController.java
│ └─ resources/
│ ├─ templates/product/list.html
│ ├─ templates/product/form.html
│ ├─ static/uploads/
│ └─ application.properties
⚙️ 3️⃣ Cấu hình kết nối SQL Server
📄 File: src/main/resources/application.properties
spring.datasource.url=jdbc:sqlserver://localhost:1433;databaseName=WebBanHangDB spring.datasource.username=sa spring.datasource.password=your_password spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.servlet.multipart.max-file-size=5MB spring.servlet.multipart.max-request-size=5MB
🧑💻 4️⃣ Entity
📄 File: src/main/java/com/lungcode/entity/Category.java
package com.lungcode.entity; import jakarta.persistence.*; import lombok.Data; @Entity @Data public class Category { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String name; }
📄 File: src/main/java/com/lungcode/entity/Product.java
package com.lungcode.entity; import jakarta.persistence.*; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import lombok.Data; @Entity @Data public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @NotBlank(message = "Tên sản phẩm không được để trống!") private String name; @Min(value = 1, message = "Giá phải lớn hơn 0!") private double price; private String image; @ManyToOne @JoinColumn(name = "category_id") private Category category; }
📂 5️⃣ Repository
📄 File: src/main/java/com/lungcode/repository/CategoryRepository.java
package com.lungcode.repository; import com.lungcode.entity.Category; import org.springframework.data.jpa.repository.JpaRepository; public interface CategoryRepository extends JpaRepository<Category, Integer> {}
📄 File: src/main/java/com/lungcode/repository/ProductRepository.java
package com.lungcode.repository; import com.lungcode.entity.Product; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface ProductRepository extends JpaRepository<Product, Integer> { Page<Product> findByNameContainingIgnoreCase(String name, Pageable pageable); }
🔧 6️⃣ Service
📄 File: src/main/java/com/lungcode/service/ProductService.java
package com.lungcode.service; import com.lungcode.entity.Product; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface ProductService { Page<Product> getAll(String keyword, Pageable pageable); Product save(Product product); Product getById(Integer id); void deleteById(Integer id); }
📄 File: src/main/java/com/lungcode/service/impl/ProductServiceImpl.java
package com.lungcode.service.impl; import com.lungcode.entity.Product; import com.lungcode.repository.ProductRepository; import com.lungcode.service.ProductService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @Service public class ProductServiceImpl implements ProductService { @Autowired private ProductRepository repository; @Override public Page<Product> getAll(String keyword, Pageable pageable) { if (keyword != null && !keyword.isEmpty()) { return repository.findByNameContainingIgnoreCase(keyword, pageable); } return repository.findAll(pageable); } @Override public Product save(Product product) { return repository.save(product); } @Override public Product getById(Integer id) { return repository.findById(id).orElse(null); } @Override public void deleteById(Integer id) { repository.deleteById(id); } }
🌐 7️⃣ Controller
📄 File: src/main/java/com/lungcode/controller/ProductController.java
package com.lungcode.controller; import com.lungcode.entity.Category; import com.lungcode.entity.Product; import com.lungcode.repository.CategoryRepository; import com.lungcode.service.ProductService; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; @Controller @RequestMapping("/products") public class ProductController { @Autowired private ProductService service; @Autowired private CategoryRepository categoryRepo; @GetMapping public String list(Model model, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "") String keyword) { Pageable pageable = PageRequest.of(page, 5); model.addAttribute("products", service.getAll(keyword, pageable)); model.addAttribute("keyword", keyword); return "product/list"; } @GetMapping("/create") public String createForm(Model model) { model.addAttribute("product", new Product()); model.addAttribute("categories", categoryRepo.findAll()); return "product/form"; } @PostMapping("/save") public String save(@Valid @ModelAttribute Product product, BindingResult result, @RequestParam("file") MultipartFile file) throws IOException { if (result.hasErrors()) { return "product/form"; } if (!file.isEmpty()) { String path = "src/main/resources/static/uploads/" + file.getOriginalFilename(); file.transferTo(new File(path)); product.setImage(file.getOriginalFilename()); } service.save(product); return "redirect:/products"; } @GetMapping("/edit/{id}") public String edit(@PathVariable Integer id, Model model) { Product product = service.getById(id); model.addAttribute("product", product); model.addAttribute("categories", categoryRepo.findAll()); return "product/form"; } @GetMapping("/delete/{id}") public String delete(@PathVariable Integer id) { service.deleteById(id); return "redirect:/products"; } }
🎨 8️⃣ Giao diện Thymeleaf
📄 File: src/main/resources/templates/product/list.html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Danh sách sản phẩm</title> </head> <body> <h2>Danh sách sản phẩm</h2> <form method="get" th:action="@{/products}"> Tìm tên: <input type="text" name="keyword" th:value="${keyword}"> <button type="submit">Tìm kiếm</button> </form> <a th:href="@{/products/create}">Thêm mới</a> <table border="1"> <tr> <th>ID</th><th>Tên</th><th>Giá</th><th>Hình</th><th>Danh mục</th><th>Hành động</th> </tr> <tr th:each="p : ${products.content}"> <td th:text="${p.id}"></td> <td th:text="${p.name}"></td> <td th:text="${p.price}"></td> <td><img th:src="@{'/uploads/' + ${p.image}}" width="50"/></td> <td th:text="${p.category.name}"></td> <td> <a th:href="@{'/products/edit/' + ${p.id}}">Sửa</a> <a th:href="@{'/products/delete/' + ${p.id}}" onclick="return confirm('Bạn chắc chắn muốn xoá?')">Xoá</a> </td> </tr> </table> <div> <span th:if="${products.hasPrevious()}"> <a th:href="@{|/products?page=${products.number - 1}&keyword=${keyword}|}">Trang trước</a> </span> <span th:text="${products.number + 1}"></span>/<span th:text="${products.totalPages}"></span> <span th:if="${products.hasNext()}"> <a th:href="@{|/products?page=${products.number + 1}&keyword=${keyword}|}">Trang tiếp</a> </span> </div> </body> </html>
📄 File: src/main/resources/templates/product/form.html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Thêm / Sửa sản phẩm</title> </head> <body> <h2 th:text="${product.id == null} ? 'Thêm sản phẩm' : 'Sửa sản phẩm'"></h2> <form th:action="@{/products/save}" th:object="${product}" method="post" enctype="multipart/form-data"> <input type="hidden" th:field="*{id}"/> Tên sản phẩm: <input type="text" th:field="*{name}"/> <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span><br/> Giá: <input type="number" th:field="*{price}"/> <span th:if="${#fields.hasErrors('price')}" th:errors="*{price}"></span><br/> Ảnh: <input type="file" name="file"/><br/> Danh mục: <select th:field="*{category}"> <option th:each="c : ${categories}" th:value="${c}" th:text="${c.name}"></option> </select><br/> <button type="submit">Lưu</button> </form> <a th:href="@{/products}">Quay lại danh sách</a> </body> </html>