iT邦幫忙

2024 iThome 鐵人賽

DAY 27
0

這篇繼續擴充訂單功能,可以回顧一下先前畫的架構圖,我們要有一張中間的表來記錄購物車內有哪些商品資訊,這部分建立訂單可以想像完成選擇要購買的商品之後送出訂單所產生的資料紀錄。

https://ithelp.ithome.com.tw/upload/images/20241009/20150977I3N7C01CDw.png

訂單功能建立

order_info 訂單資訊,用來記錄我們整筆訂單的彙整資訊,包含關聯哪位 user, 總金額多少

order_item 訂單項目資訊,用來記錄整筆訂單中各別項目關聯哪個 product (product_id)、購買數量是多少(quantity)、價錢(amount),從中也可以知道屬於哪筆訂單(order_info_id)。

基本資料建立

CREATE TABLE order_info
(
    id                 INT       NOT NULL PRIMARY KEY AUTO_INCREMENT,
    user_id            INT       NOT NULL,
    total_amount       DECIMAL(10, 2)  NOT NULL, -- 訂單總花費
    created_at     TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at     TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE order_item
(
    id            INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
    order_info_id INT NOT NULL,
    product_id    INT NOT NULL,
    quantity      INT NOT NULL,
    amount        DECIMAL(10, 2) NOT NULL  
);

Entity

// OrderInfo.class
@Entity
@Data
@Table(name = "order_info")
public class OrderInfo extends BaseEntity {
    private Integer userId;
    private Double totalAmount;
}

// OrderItem.class
@Entity
@Data
@Table(name = "order_item")
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private Integer orderInfoId;
    private Integer productId;
    private Integer quantity;
    private Double amount;
}

DTO (CreateOrderInfoRequest.class, CreateOrderResponse.class)

// 建立訂單請求
@Data
public class CreateOrderInfoRequest {

    @NotEmpty
    private List<BuyItem> buyItemList;
}

// 回傳建立訂單 
@Data
public class CreateOrderResponse {
    private OrderInfo orderInfo;
    private List<HashMap<String, Object>> orderItemList;
}

新增訂單

當勾選好要哪些商品要送出訂單時就會呼叫這隻 API 建立訂單

Controller

@RestController
@RequestMapping("/api")
public class OrderInfoController {
    private static final Logger log = LoggerFactory.getLogger(OrderInfoController.class);
    @Autowired
    private OrderService orderService;

    @Autowired
    private UserService userService;

    @PostMapping("/users/{userId}/orders")
    public ResponseEntity<?> createOrder(@PathVariable Integer userId,
                                         @RequestBody @Valid CreateOrderInfoRequest createOrderInfoRequest) {

        Optional<User> user = userService.getUserByID(userId);
        if (!user.isPresent()) {
            log.warn("UserId: {} is not found", userId);
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
        }
        CreateOrderResponse createOrderResponse = orderService.createOrder(userId, createOrderInfoRequest);
        return ResponseEntity.status(HttpStatus.CREATED).body(createOrderResponse);
    }
}

Service

這邊 createOrder 的運作流程:

  1. 取出訂單內各項商品,確認庫存
  2. 計算總價錢
  3. 扣庫存
  4. 建立訂單 orderInfo
  5. orderItem 建立組裝 Response ())
  6. 組裝 Response (CreateOrderResponse)
@Service
public class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);
    @Autowired
    private OrderInfoDao orderInfoDao;

    @Autowired
    private OrderItemDao orderItemDao;

    @Autowired
    private ProductDao productDao;

    @Transactional
    public CreateOrderResponse createOrder(Integer userId, CreateOrderInfoRequest createOrderInfoRequest) {

        Double totalAmount = 0.0;
        List<OrderItem> orderItemList = new ArrayList<>();

        for (BuyItem buyItem : createOrderInfoRequest.getBuyItemList()) {
            Product product = productDao.findById(buyItem.getProductId())
                    .orElseThrow(() -> {
                        log.warn("productId: {} not found", buyItem.getProductId());
                        throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
                    });

            if (product.getUnitsInStock() < buyItem.getQuantity()) {
                log.warn("productId: {} stock is not enough, remaining stock is {}, requested quantity is {}", buyItem.getProductId(), product.getUnitsInStock(), buyItem.getQuantity());
                throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
            }
						
						// 計算價錢
            double amount = (buyItem.getQuantity() * product.getUnitPrice());
            totalAmount += amount;

            OrderItem orderItem = new OrderItem();
            orderItem.setProductId(buyItem.getProductId());
            orderItem.setQuantity(buyItem.getQuantity());
            orderItem.setAmount(amount);

            // 扣庫存
            int updateStock = product.getUnitsInStock() - orderItem.getQuantity();
            product.setUnitsInStock(updateStock);
            productDao.save(product);

            orderItemList.add(orderItem);
        }

        // 存 orderInfo
        OrderInfo orderInfo = new OrderInfo();
        orderInfo.setUserId(userId);
        orderInfo.setTotalAmount(totalAmount);
        OrderInfo orderInfoSaved = orderInfoDao.save(orderInfo);
        int orderInfoId = orderInfoSaved.getId();

        // 存多筆 orderItem
        orderItemList.forEach(item -> item.setOrderInfoId(orderInfoId));
        orderItemDao.saveAll(orderItemList);

        // response
        List<HashMap<String, Object>> responseOrderItemList = new ArrayList<>();
        for (OrderItem orderItem : orderItemList) {
            HashMap<String, Object> map = new HashMap<>();
            Product product = productDao.findById(orderItem.getProductId()).get();

            map.put("orderItemId", orderItem.getId());
            map.put("orderInfoId", orderItem.getOrderInfoId());
            map.put("productId", orderItem.getProductId());
            map.put("quantity", orderItem.getQuantity());
            map.put("amount", orderItem.getAmount());
            map.put("productName", product.getProductName());
            responseOrderItemList.add(map);
        }

        CreateOrderResponse createOrderResponse = new CreateOrderResponse();
        createOrderResponse.setOrderInfo(orderInfoSaved);
        createOrderResponse.setOrderItemList(responseOrderItemList);

        return createOrderResponse;
    }
}

dao

public interface OrderInfoDao extends JpaRepository<OrderInfo, Integer> {
}

public interface OrderItemDao extends JpaRepository<OrderItem, Integer> {
}

權限建立

這邊希望可以針對特定路徑 (/api/users/{userId}/orders )去判斷,只有自己能夠建立自己訂單如果創建訂單 userId 沒有對應路徑上的 userId 就會不給與權限。

針對 SecurityConfig 設置,有特別的寫法可以讓進行請求時進入特定方法來驗證,這邊加上一個 checkUserIdAndRole 方法,裡面用到 Authentication 物件來確認登入者資訊,因為登入取得 JWT 後,這些資訊就會存入 Security Context ,讓你可以取出來進行驗證。

我這邊有特別開放讓 Admin 可以不受限制建立所有路徑下的訂單,主要是要設計讓 ROLE_BUYER 才可以針對自己路徑的訂單建立,符合 Restful 的風格。

@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/**").permitAll()
                                .requestMatchers(HttpMethod.GET, "/checkAuthentication").hasAnyAuthority("ROLE_BUYER", "ROLE_SELLER", "ROLE_ADMIN")
                                .requestMatchers(HttpMethod.POST, "/api/products").hasAuthority("ROLE_SELLER")
                                .requestMatchers(HttpMethod.DELETE, "/api/products").hasAuthority("ROLE_SELLER")
                                .requestMatchers("/api/users/*").hasAuthority("ROLE_ADMIN") 
                                // 透過 checkUserIdAndRole 來確認權限
                                .requestMatchers(HttpMethod.POST, "/api/users/{userId}/orders").access(this::checkUserIdAndRole)
                                .anyRequest().authenticated()
                )
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }

    private AuthorizationDecision checkUserIdAndRole(Supplier<Authentication> authentication, RequestAuthorizationContext context) {
        int userId = Integer.parseInt(context.getVariables().get("userId"));
        Authentication auth = authentication.get();
        // 轉型問題處理
        if (!(auth.getPrincipal() instanceof UserPrincipal userPrincipal)) {
            return new AuthorizationDecision(false);
        }

        boolean hasAccess = auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")) ||
                // 當前 userId 對應路徑 userId 才允許授權
                (auth.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_BUYER")) && userPrincipal.getId().equals(userId));

        log.info("checkUserIdAndRole access permit: {}", hasAccess);
        return new AuthorizationDecision(hasAccess);
    }

以上大致就完成建立訂單的創建跟權限設定。

進行測試

{
    "buyItemList": [
        {
            "productId": 1,
            "quantity":1
        },
        {
            "productId": 2,
            "quantity":1
        }
    ]
}
  • 確定 buyer 可以創建 Order_Info,這邊使用 userId = 2 的使用者測試,先登入
    https://ithelp.ithome.com.tw/upload/images/20241007/2015097721Ty335gbt.png

對應其 id = 2路徑就可以創建

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

如果用其他 id 路徑就會被禁止

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

以上大概是這次介紹這些電商的一些功能設計,還有加入 Security 權限控管的一些實作,實際上還有很多東西都可以細部去設計,這邊提供一些實務上的應用給大家參考。

下一篇就簡單寫一些測試也應用到之前的介紹,讓大家可以比較了解應用 Spring Boot 實務的開發部分。


相關文章也會同步更新我的部落格,有興趣也可以在裡面找其他的技術分享跟資訊。


上一篇
Day 26 - 電商 RESTFul API + Spring Security (1) 商品功能
下一篇
Day 28 - 電商 RESTFul API + Spring Security (3) UnitTest
系列文
關於我和 Spring Boot 變成家人的那件事30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言