這系列文章會總結先前包含 JPA 和 Security 的應用,詳細可以回顧 Spring Security (1 ~ 4),整合成一個小電商專案 side project,針對後端 API 和認證的部分,內容因為前面大量的內容會比較長,大家有興趣的可以好好來了解一下。

資料庫結構

如果先回顧前面 Security 部分建構的資料庫結構 UML

會有 users, roles, user_roles 三個表去紀錄關於使用者的資訊,也可以從多對多的關聯中找出使用者的角色權限

https://ithelp.ithome.com.tw/upload/images/20241007/201509779TY3BNpxrk.png

現在要來加入電商主要的資料就是商品部分,預計會規畫成下面這樣的關係
https://ithelp.ithome.com.tw/upload/images/20241007/20150977aFtoNqUZwj.png

需求規格

會拆分成兩階段來建構:

  1. products 建構,先建立 products, suppliers 1 : 1 關係
  2. order_info 建構,order_info 記錄總價和購買使用者 id,再由 order_item 分別關聯 products 和 order_info,可以關聯出購物車內容,所有需要購買的商品數量和同品項總金額,也關聯出屬於哪張 order_info

大致清楚架構之後接下來預計會實作的一些功能也列出來:

商品相關

  • product 的 CRUD
  • product 查詢功能分類及呈現
    • 模糊搜尋商品名稱
    • 根據分類
    • 升冪降冪排序
    • 分頁資料呈現(一頁幾筆,顯示第幾頁資料)
  • order_info, order_item 資訊讀取 (購物車資訊)

使用者權限

  • 分成 buyer, seller, admin
  • 商品部分 Read 部分不設權限,操作資料部分 Create, Update, Delete 需要對應創建商家才可以
  • buyer 可建立訂單

使用者

  • 註冊
  • 登入

這樣就有基本的電商運作模式需要的一些功能了。

那先前 Security 的介紹部分已經把使用者註冊、登入和權限的設置都已經先建置好,就可以直接來接著進行商品部分功能建立

商品功能建立

基本資料建置

商品相關資料表,刻意多加入一個 supplier 關聯表 可以對應多對一關聯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CREATE TABLE suppliers
(
id INT AUTO_INCREMENT PRIMARY KEY,
supplier_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE products
(
id INT AUTO_INCREMENT PRIMARY KEY,
product_name VARCHAR(100) NOT NULL,
unit_price DECIMAL(10, 2),
units_in_stock INT,
discontinued BOOLEAN DEFAULT FALSE,
supplier_id INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (supplier_id) REFERENCES suppliers (id)
);

先建立基本 Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
@Data
@Table(name = "products")
public class Product extends BaseEntity {
private String productName;

private Double unitPrice;

private Integer unitsInStock;

private Boolean discontinued = false;

@ManyToOne
private Supplier supplier;

建立時需要的 dto,順便透過 Validation 相關註解來驗證傳入欄位的資訊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Data
public class ProductRequest {

@NotBlank(message = "ProductName cannot be blank")
private String productName;

@NotNull
@PositiveOrZero(message = "UnitPrice must be zero or positive")
private Double unitPrice;

@NotNull
@PositiveOrZero(message = "UnitInStock must be zero or positive")
private Integer unitsInStock;

@NotNull
private Boolean discontinued;

@NotNull
private Supplier supplier;

private LocalDateTime createAt;

private LocalDateTime updatedAt;
}

增刪查改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@RestController
@RequestMapping("/api/products")
public class ProductController {

@Autowired
private ProductService productService;

@PostMapping
public ResponseEntity<Product> createProduct(@RequestBody @Valid ProductRequest productRequest) {
Product createdProduct = productService.createProduct(productRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(createdProduct);
}

@GetMapping
public ResponseEntity<List<Product>> getProducts() {
List<Product> productList = productService.getAllProducts();
return ResponseEntity.status(HttpStatus.OK).body(productList);
}

@GetMapping("/{id}")
public ResponseEntity<Optional<Product>> getProduct(@PathVariable Integer id) {
Optional<Product> product = productService.getProductById(id);
if (product.isPresent()) {
return ResponseEntity.status(HttpStatus.OK).body(product);
}
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}

@PutMapping("/{id}")
public ResponseEntity<Product> updateProduct(@PathVariable Integer id, @Valid @RequestBody ProductRequest productRequest) {
Optional<Product> product = productService.getProductById(id);
if (!product.isPresent()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
Product updatedProduct = productService.updateProduct(id, productRequest);
return ResponseEntity.status(HttpStatus.OK).body(updatedProduct);
}

@DeleteMapping("/{id}")
public ResponseEntity<?> deleteProduct(@PathVariable Integer id) {
Optional<Product> product = productService.getProductById(id);
if (product.isPresent()) {
productService.deleteProductById(id);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Service
public class ProductService {

@Autowired
private ProductDao productDao;

public List<Product> getAllProducts() {
return productDao.findAll();
}

public Optional<Product> getProductById(int id) {
return productDao.findById(id);
}

@Transactional
public Product updateProduct(Integer id, ProductRequest productRequest) {
Optional<Product> product = getProductById(id);
if (product.isPresent()) {
Product updatedProduct = convertToModel(productRequest);
updatedProduct.setId(id);
return productDao.save(updatedProduct);
}
return null;
}

@Transactional
public Product createProduct(ProductRequest productRequest) {
Product product = convertToModel(productRequest);
return productDao.save(product);
}

@Transactional
public void deleteProductById(Integer id) {
productDao.deleteById(id);
}

public List<Product> searchProducts(String productName) {
return productDao.findByProductName(productName);
}

public Product convertToModel(ProductRequest productRequest) {
Product product = new Product();
product.setProductName(productRequest.getProductName());
product.setUnitPrice(productRequest.getUnitPrice());
product.setUnitsInStock(productRequest.getUnitsInStock());
product.setDiscontinued(productRequest.getDiscontinued());
product.setSupplier(productRequest.getSupplier());
return product;
}
}

篩選/查詢

開一個相關端口接收對應參數,可以對應倒前端搜尋頁面的操作

sortBy 篩選欄位

sortOrder 降冪或升冪排序

page 呈現第幾頁資料

limit 一頁顯示幾筆

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestController
@RequestMapping("/api/products")
public class ProductController {

@Autowired
private ProductService productService;

// ...

@GetMapping("/search")
public ResponseEntity<List<Product>> searchProducts(
@RequestParam(required = false) String productName,
@RequestParam(defaultValue = "id", required = false) String sortBy,
@RequestParam(defaultValue = "asc", required = false) String sortOrder,
@RequestParam(defaultValue = "0", required = false) int page,
@RequestParam(defaultValue = "5", required = false) int limit
) {
List<Product> productList = new ArrayList<>();
productList = productService.searchAndSortProducts(productName, sortBy, sortOrder, page, limit);
return ResponseEntity.status(HttpStatus.OK).body(productList);
}
}

可以透過 Sort/Pageable 物件進行篩選及資料分頁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class ProductService {

@Autowired
private ProductDao productDao;

// ...

public List<Product> searchAndSortProducts(String productName, String sortBy, String sortOrder, int page, int limit) {
Sort sort = sortOrder.equalsIgnoreCase("asc") ? Sort.by(sortBy).ascending() : Sort.by(sortBy).descending();
Pageable pageable = PageRequest.of(page, limit, sort);

Page<Product> productPage = null;
if (productName == null || productName.isEmpty()) {
productPage = productDao.findAll(pageable);
} else {
productPage = productDao.findByProductNameContainingIgnoreCase(productName, pageable);
}

List<Product> products = productPage.getContent();
return products;
}
}

權限建立

讓 seller 才能建立商品資料,需要再 SecurityConfig 那邊新增相關路徑對應可以操作的權限

也可以同步在端口上加上 @PreAuthorize("hasRole('SELLER')") ,這部分可以不用,兩種選一個即可達成,但如果需要標示清楚易閱讀可以選擇兩邊都加入,但管理上就要注意是否重工,可能調整時兩邊都要設定,兩種都用 @PreAuthorize 可以幫忙切得更精細一點。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf(customizer -> customizer.disable())
.authorizeHttpRequests((registry) -> registry
.requestMatchers(HttpMethod.POST, "/register", "/login").permitAll()
.requestMatchers(HttpMethod.GET, "/error", "/api/products/**", "/who-am-i").permitAll()
.requestMatchers(HttpMethod.GET, "/checkAuthentication").hasAnyAuthority("ROLE_BUYER", "ROLE_SELLER", "ROLE_ADMIN")
// 需要 ROLE_SELLER 才能修改跟刪除
.requestMatchers(HttpMethod.POST, "/api/products").hasAuthority("ROLE_SELLER")
.requestMatchers(HttpMethod.DELETE, "/api/products").hasAuthority("ROLE_SELLER")
.requestMatchers("/api/users/*").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}

進行測試

這邊提供一些資料先塞進去測試

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
INSERT INTO suppliers (id, supplier_name)
VALUES (1, 'TechSupply Inc.'),
(2, 'GadgetWorld Ltd.'),
(3, 'ElectroGoods Co.');

INSERT INTO products (product_name, supplier_id, unit_price, units_in_stock, discontinued)
VALUES ('Wireless Mouse', 1, 24.99, 150, FALSE),
('Bluetooth Keyboard', 2, 49.99, 75, FALSE),
('USB-C Charger', 3, 19.99, 200, FALSE),
('Gaming Headset', 1, 79.99, 50, FALSE),
('4K Monitor', 2, 299.99, 40, TRUE),
('External Hard Drive', 1, 89.99, 120, FALSE),
('Mechanical Keyboard', 3, 129.99, 30, TRUE),
('Portable SSD', 3, 159.99, 60, FALSE),
('Wireless Earbuds', 1, 199.99, 80, FALSE),
('Laptop Stand', 3, 39.99, 100, FALSE);
  • 先確認未登入任何人都可以直接進行商品資訊

https://ithelp.ithome.com.tw/upload/images/20241007/20150977yD9tZ572Ky.png

  • 確認登入一個 seller 才可以進行商品創建
    https://ithelp.ithome.com.tw/upload/images/20241007/20150977HEMk1uLBf7.png

輸入 JWT
https://ithelp.ithome.com.tw/upload/images/20241007/20150977oAnJy0dnoV.png

輸入新增電文,下面提供簡單電文給大家參考

1
2
3
4
5
6
7
8
9
{
"productName": "Good Mouse",
"unitPrice": 110.99,
"unitsInStock": 100,
"discontinued": false,
"supplier": {
"id": 2
}
}

https://ithelp.ithome.com.tw/upload/images/20241007/20150977fJU2g7vMFG.png

  • 確認 seller 修改商品、刪除商品

與前面新增相同,確認這些端口有正常運作。

以上大概是先介紹商品功能的擴充介紹,下一篇來加入訂單功能。