diff --git a/src/main/resources/templates/admin/products.html b/src/main/resources/templates/admin/products.html
index 8cac584..417c413 100644
--- a/src/main/resources/templates/admin/products.html
+++ b/src/main/resources/templates/admin/products.html
@@ -12,6 +12,8 @@
商品管理
ID |
名称 |
价格 |
+
库存 |
+
状态 |
操作 |
@@ -20,6 +22,11 @@
商品管理
1 |
iPhone 13 |
999.99 |
+
0 |
+
+ ACTIVE
+ INACTIVE
+ |
编辑
删除
diff --git a/src/main/resources/templates/products.html b/src/main/resources/templates/products.html
index 30cd3b4..0781114 100644
--- a/src/main/resources/templates/products.html
+++ b/src/main/resources/templates/products.html
@@ -12,10 +12,16 @@
电商网站
@@ -39,14 +45,19 @@ 商品列表
商品名称
商品描述
¥0.00
-
-
+
+
+
+
+
+ 登录后购买
+
diff --git a/src/main/resources/templates/sales/dashboard.html b/src/main/resources/templates/sales/dashboard.html
new file mode 100644
index 0000000..a30f1a9
--- /dev/null
+++ b/src/main/resources/templates/sales/dashboard.html
@@ -0,0 +1,34 @@
+
+
+
+
+ 销售工作台
+
+
+
+
+
+
+
+ 当前页面为 Phase 1 权限与路由占位页面。后续将按计划逐步补齐商品分类管理、库存维护、日志监控等 Sales 功能。
+
+
+
+
+ Phase 2/3 预留入口
+ 本阶段仅验证角色跳转与权限边界,业务能力将在后续分支迭代实现。
+
+
+
+
+
+
From e44c7000e4c6ab52aee428228c59a43564cdde7f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=97=8B=E9=A3=8ESELF?=
<121877340+Self4215@users.noreply.github.com>
Date: Tue, 14 Apr 2026 21:50:41 +0800
Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=AE=89?=
=?UTF-8?q?=E5=85=A8=E4=B8=8E=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现自定义 `AccessDeniedHandler` ,用于管理 403 错误并补充详细属性。
- 添加 `ErrorPageController` ,用于处理通用错误响应并展示更友好的提示信息。
- 引入 `AppException` 和 `AppErrorCode` ,为各个服务提供结构化错误处理。
- 调整 `OrderService` 和 `ProductService` ,在业务逻辑出错时抛出 `AppException` 。
- 修改应用配置,启用 Flyway 进行数据库迁移。
- 创建迁移脚本,确保数据库结构更新是安全且幂等的。
- 添加 `OrderService` 和 `ProductService` 的单元测试,用于验证错误处理和业务逻辑。
- 添加安全访问集成测试,确保授权与错误处理行为正确。
---
pom.xml | 5 +
.../config/GlobalControllerAdvice.java | 67 +++++
.../org/self4215/config/SecurityConfig.java | 23 +-
.../controller/ErrorPageController.java | 30 ++
.../self4215/controller/OrderController.java | 8 +-
.../org/self4215/exception/AppErrorCode.java | 9 +
.../org/self4215/exception/AppException.java | 26 ++
.../org/self4215/service/OrderService.java | 9 +-
.../org/self4215/service/ProductService.java | 4 +-
.../org/self4215/service/UserService.java | 8 +-
src/main/resources/application.properties | 12 +-
.../V1__phase1_domain_foundation.sql | 240 ++++++++++++++--
.../db/migration/V2__seed_base_data.sql | 272 +++++++++++++++---
.../resources/templates/error/common.html | 36 +++
.../config/GlobalControllerAdviceTest.java | 77 +++++
.../self4215/service/OrderServiceTest.java | 99 +++++++
.../self4215/service/ProductServiceTest.java | 56 ++++
.../web/SecurityAccessIntegrationTest.java | 88 ++++++
18 files changed, 997 insertions(+), 72 deletions(-)
create mode 100644 src/main/java/org/self4215/controller/ErrorPageController.java
create mode 100644 src/main/java/org/self4215/exception/AppErrorCode.java
create mode 100644 src/main/java/org/self4215/exception/AppException.java
create mode 100644 src/main/resources/templates/error/common.html
create mode 100644 src/test/java/org/self4215/config/GlobalControllerAdviceTest.java
create mode 100644 src/test/java/org/self4215/service/OrderServiceTest.java
create mode 100644 src/test/java/org/self4215/service/ProductServiceTest.java
create mode 100644 src/test/java/org/self4215/web/SecurityAccessIntegrationTest.java
diff --git a/pom.xml b/pom.xml
index 534a435..d761abc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -85,6 +85,11 @@
spring-boot-starter-test
test
+
+ org.springframework.security
+ spring-security-test
+ test
+
diff --git a/src/main/java/org/self4215/config/GlobalControllerAdvice.java b/src/main/java/org/self4215/config/GlobalControllerAdvice.java
index 6363acc..32cc009 100644
--- a/src/main/java/org/self4215/config/GlobalControllerAdvice.java
+++ b/src/main/java/org/self4215/config/GlobalControllerAdvice.java
@@ -1,8 +1,18 @@
package org.self4215.config;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.self4215.exception.AppErrorCode;
+import org.self4215.exception.AppException;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
+
+import java.time.Instant;
@ControllerAdvice
public class GlobalControllerAdvice {
@@ -46,4 +56,61 @@ public String addLicenseYearsToModel() {
public String addAuthorToModel() {
return appAuthor;
}
+
+ @ExceptionHandler(AppException.class)
+ public String handleAppException(AppException ex, HttpServletRequest request, HttpServletResponse response, Model model) {
+ HttpStatus status = mapStatus(ex.getErrorCode());
+ response.setStatus(status.value());
+ addAppMetadata(model);
+
+ model.addAttribute("errorCode", ex.getErrorCode().name());
+ model.addAttribute("errorMessage", ex.getUserMessage());
+ model.addAttribute("httpStatus", status.value());
+ model.addAttribute("path", request.getRequestURI());
+ model.addAttribute("timestamp", Instant.now().toString());
+ return "error/common";
+ }
+
+ @ExceptionHandler(NoResourceFoundException.class)
+ public String handleNoResourceFoundException(NoResourceFoundException ex, HttpServletRequest request, HttpServletResponse response, Model model) {
+ response.setStatus(HttpStatus.NOT_FOUND.value());
+ addAppMetadata(model);
+
+ model.addAttribute("errorCode", AppErrorCode.RESOURCE_NOT_FOUND.name());
+ model.addAttribute("errorMessage", "请求路径不存在");
+ model.addAttribute("httpStatus", HttpStatus.NOT_FOUND.value());
+ model.addAttribute("path", request.getRequestURI());
+ model.addAttribute("timestamp", Instant.now().toString());
+ return "error/common";
+ }
+
+ @ExceptionHandler(Exception.class)
+ public String handleUnknownException(Exception ex, HttpServletRequest request, HttpServletResponse response, Model model) {
+ response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
+ addAppMetadata(model);
+
+ model.addAttribute("errorCode", AppErrorCode.SYSTEM_ERROR.name());
+ model.addAttribute("errorMessage", "系统出现异常,请稍后重试。");
+ model.addAttribute("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR.value());
+ model.addAttribute("path", request.getRequestURI());
+ model.addAttribute("timestamp", Instant.now().toString());
+ return "error/common";
+ }
+
+ private HttpStatus mapStatus(AppErrorCode errorCode) {
+ return switch (errorCode) {
+ case RESOURCE_NOT_FOUND -> HttpStatus.NOT_FOUND;
+ case ACCESS_DENIED -> HttpStatus.FORBIDDEN;
+ case VALIDATION_ERROR, BUSINESS_CONFLICT -> HttpStatus.BAD_REQUEST;
+ case SYSTEM_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR;
+ };
+ }
+
+ private void addAppMetadata(Model model) {
+ model.addAttribute("appVersion", appVersion);
+ model.addAttribute("appUrl", appUrl);
+ model.addAttribute("appLicenseUrl", appLicenseUrl);
+ model.addAttribute("appLicenseYears", appLicenseYears);
+ model.addAttribute("appAuthor", appAuthor);
+ }
}
diff --git a/src/main/java/org/self4215/config/SecurityConfig.java b/src/main/java/org/self4215/config/SecurityConfig.java
index cbab553..86c5c1c 100644
--- a/src/main/java/org/self4215/config/SecurityConfig.java
+++ b/src/main/java/org/self4215/config/SecurityConfig.java
@@ -9,9 +9,12 @@
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+import java.time.Instant;
+
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@@ -21,9 +24,22 @@ public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
+ @Bean
+ public AccessDeniedHandler accessDeniedHandler() {
+ return (request, response, accessDeniedException) -> {
+ response.setStatus(403);
+ request.setAttribute("errorCode", "ACCESS_DENIED");
+ request.setAttribute("errorMessage", "无权访问该资源");
+ request.setAttribute("httpStatus", 403);
+ request.setAttribute("path", request.getRequestURI());
+ request.setAttribute("timestamp", Instant.now().toString());
+ request.getRequestDispatcher("/error/common").forward(request, response);
+ };
+ }
+
// 安全过滤链:配置登录、权限、退出等规则
@Bean
- public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationSuccessHandler successHandler, LogoutSuccessHandler logoutSuccessHandler) throws Exception {
+ public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationSuccessHandler successHandler, LogoutSuccessHandler logoutSuccessHandler, AccessDeniedHandler accessDeniedHandler) throws Exception {
http
// 关闭CSRF(新手简化,生产环境需开启)
.csrf(AbstractHttpConfigurer::disable)
@@ -31,6 +47,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication
.authorizeHttpRequests(auth -> auth
// 登录页、注册页、静态资源允许匿名访问
.requestMatchers("/login", "/register", "/css/**", "/js/**", "/images/**").permitAll()
+ .requestMatchers("/error/common").permitAll()
// 课程要求:未登录用户可浏览商品
.requestMatchers(HttpMethod.GET, "/products").permitAll()
// Sales 工作台(管理员可兼容访问)
@@ -42,6 +59,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication
// 其他所有请求需要登录
.anyRequest().authenticated()
)
+ // 统一处理 403,走系统错误页
+ .exceptionHandling(exception -> exception
+ .accessDeniedHandler(accessDeniedHandler)
+ )
// 配置登录页面
.formLogin(form -> form
.loginPage("/login") // 自定义登录页路径
diff --git a/src/main/java/org/self4215/controller/ErrorPageController.java b/src/main/java/org/self4215/controller/ErrorPageController.java
new file mode 100644
index 0000000..6d9af0f
--- /dev/null
+++ b/src/main/java/org/self4215/controller/ErrorPageController.java
@@ -0,0 +1,30 @@
+package org.self4215.controller;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.self4215.exception.AppErrorCode;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+
+import java.time.Instant;
+
+@Controller
+public class ErrorPageController {
+
+ @GetMapping("/error/common")
+ public String commonError(HttpServletRequest request, Model model) {
+ Object errorCode = request.getAttribute("errorCode");
+ Object errorMessage = request.getAttribute("errorMessage");
+ Object httpStatus = request.getAttribute("httpStatus");
+ Object path = request.getAttribute("path");
+ Object timestamp = request.getAttribute("timestamp");
+
+ model.addAttribute("errorCode", errorCode != null ? errorCode : AppErrorCode.SYSTEM_ERROR.name());
+ model.addAttribute("errorMessage", errorMessage != null ? errorMessage : "系统出现异常,请稍后重试。");
+ model.addAttribute("httpStatus", httpStatus != null ? httpStatus : 500);
+ model.addAttribute("path", path != null ? path : request.getRequestURI());
+ model.addAttribute("timestamp", timestamp != null ? timestamp : Instant.now().toString());
+
+ return "error/common";
+ }
+}
diff --git a/src/main/java/org/self4215/controller/OrderController.java b/src/main/java/org/self4215/controller/OrderController.java
index b08b985..6098d5b 100644
--- a/src/main/java/org/self4215/controller/OrderController.java
+++ b/src/main/java/org/self4215/controller/OrderController.java
@@ -21,12 +21,8 @@ public OrderController(OrderService orderService) {
@PostMapping("/create")
public String createOrder(Principal principal) {
- try {
- Order order = orderService.createOrder(principal.getName());
- return "redirect:/order/payment/" + order.getId();
- } catch (Exception e) {
- return "redirect:/cart?error=Order creation failed: " + e.getMessage();
- }
+ Order order = orderService.createOrder(principal.getName());
+ return "redirect:/order/payment/" + order.getId();
}
@GetMapping("/payment/{orderId}")
diff --git a/src/main/java/org/self4215/exception/AppErrorCode.java b/src/main/java/org/self4215/exception/AppErrorCode.java
new file mode 100644
index 0000000..2fd5b9e
--- /dev/null
+++ b/src/main/java/org/self4215/exception/AppErrorCode.java
@@ -0,0 +1,9 @@
+package org.self4215.exception;
+
+public enum AppErrorCode {
+ VALIDATION_ERROR,
+ RESOURCE_NOT_FOUND,
+ ACCESS_DENIED,
+ BUSINESS_CONFLICT,
+ SYSTEM_ERROR
+}
diff --git a/src/main/java/org/self4215/exception/AppException.java b/src/main/java/org/self4215/exception/AppException.java
new file mode 100644
index 0000000..da69036
--- /dev/null
+++ b/src/main/java/org/self4215/exception/AppException.java
@@ -0,0 +1,26 @@
+package org.self4215.exception;
+
+public class AppException extends RuntimeException {
+ private final AppErrorCode errorCode;
+ private final String userMessage;
+
+ public AppException(AppErrorCode errorCode, String userMessage) {
+ super(userMessage);
+ this.errorCode = errorCode;
+ this.userMessage = userMessage;
+ }
+
+ public AppException(AppErrorCode errorCode, String userMessage, Throwable cause) {
+ super(userMessage, cause);
+ this.errorCode = errorCode;
+ this.userMessage = userMessage;
+ }
+
+ public AppErrorCode getErrorCode() {
+ return errorCode;
+ }
+
+ public String getUserMessage() {
+ return userMessage;
+ }
+}
diff --git a/src/main/java/org/self4215/service/OrderService.java b/src/main/java/org/self4215/service/OrderService.java
index 4777b47..bd64bff 100644
--- a/src/main/java/org/self4215/service/OrderService.java
+++ b/src/main/java/org/self4215/service/OrderService.java
@@ -1,6 +1,8 @@
package org.self4215.service;
import org.self4215.entity.*;
+import org.self4215.exception.AppErrorCode;
+import org.self4215.exception.AppException;
import org.self4215.repository.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,7 +36,7 @@ public Order createOrder(String username) {
List cartItems = cartService.getCartItems(username);
if (cartItems.isEmpty()) {
- throw new RuntimeException("购物车为空");
+ throw new AppException(AppErrorCode.BUSINESS_CONFLICT, "购物车为空");
}
Order order = new Order();
@@ -72,7 +74,8 @@ public Order createOrder(String username) {
}
public Order getOrderById(Long orderId) {
- return orderRepository.findById(orderId).orElseThrow(() -> new RuntimeException("订单未找到"));
+ return orderRepository.findById(orderId)
+ .orElseThrow(() -> new AppException(AppErrorCode.RESOURCE_NOT_FOUND, "订单未找到"));
}
public void payOrder(Long orderId) {
@@ -89,7 +92,7 @@ public void cancelOrder(Long orderId) {
order.setStatus("CANCELLED");
orderRepository.save(order);
} else {
- throw new RuntimeException("只有待支付的订单可以取消");
+ throw new AppException(AppErrorCode.BUSINESS_CONFLICT, "只有待支付的订单可以取消");
}
}
diff --git a/src/main/java/org/self4215/service/ProductService.java b/src/main/java/org/self4215/service/ProductService.java
index 37761de..f5299e4 100644
--- a/src/main/java/org/self4215/service/ProductService.java
+++ b/src/main/java/org/self4215/service/ProductService.java
@@ -1,6 +1,8 @@
package org.self4215.service;
import org.self4215.entity.Product;
+import org.self4215.exception.AppErrorCode;
+import org.self4215.exception.AppException;
import org.self4215.repository.ProductRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -26,7 +28,7 @@ public List getActiveProducts() {
// 根据ID查询商品
public Product getProductById(Long id) {
return productRepository.findById(id)
- .orElseThrow(() -> new RuntimeException("商品不存在:" + id));
+ .orElseThrow(() -> new AppException(AppErrorCode.RESOURCE_NOT_FOUND, "商品不存在: " + id));
}
// 保存商品(初始化数据用)
diff --git a/src/main/java/org/self4215/service/UserService.java b/src/main/java/org/self4215/service/UserService.java
index 090428f..11279cb 100644
--- a/src/main/java/org/self4215/service/UserService.java
+++ b/src/main/java/org/self4215/service/UserService.java
@@ -2,6 +2,8 @@
import org.self4215.entity.User;
import org.self4215.entity.UserRole;
+import org.self4215.exception.AppErrorCode;
+import org.self4215.exception.AppException;
import org.self4215.repository.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
@@ -40,20 +42,20 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
- Collections.singletonList(new SimpleGrantedAuthority(authority.toUpperCase(Locale.ROOT)))
+ Collections.singletonList(new SimpleGrantedAuthority(authority.toUpperCase(Locale.ROOT)))
);
}
// 获取当前登录用户的实体
public User getCurrentUser(String username) {
return userRepository.findByUsername(username)
- .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
+ .orElseThrow(() -> new AppException(AppErrorCode.RESOURCE_NOT_FOUND, "用户不存在"));
}
// 注册新用户
public void registerUser(User user) {
if (userRepository.findByUsername(user.getUsername()).isPresent()) {
- throw new RuntimeException("用户名已存在");
+ throw new AppException(AppErrorCode.BUSINESS_CONFLICT, "用户名已存在");
}
user.setRole(UserRole.CUSTOMER.name()); // 默认角色
saveUser(user);
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 93c563e..c010aee 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -10,19 +10,19 @@ spring.datasource.password=${DB_PASSWORD:root123456789}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# JPA 配置(自动建表、显示SQL)
-# Phase 1 过渡策略:保留 update,确保历史环境平滑升级;后续可切换为 validate
+# Phase 1 过渡策略:Flyway 主导,JPA 保留 update 以兼容历史数据
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
# Hibernate 6 在 Spring Boot 3 中能自动正确检测,且 `MySQL8Dialect` 已被弃用
# spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
-# 当前阶段以可运行优先:恢复 data.sql 初始化,Flyway 在后续分支完成 MySQL 8.4 兼容后再启用
-spring.jpa.defer-datasource-initialization=true
-spring.sql.init.mode=always
+# Flyway 启用后关闭 data.sql 初始化,避免循环依赖和重复写入
+spring.jpa.defer-datasource-initialization=false
+spring.sql.init.mode=never
-# Flyway 配置(已预置脚本,暂时关闭)
-spring.flyway.enabled=false
+# Flyway 配置(版本化迁移)
+spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
diff --git a/src/main/resources/db/migration/V1__phase1_domain_foundation.sql b/src/main/resources/db/migration/V1__phase1_domain_foundation.sql
index 82f561f..5596deb 100644
--- a/src/main/resources/db/migration/V1__phase1_domain_foundation.sql
+++ b/src/main/resources/db/migration/V1__phase1_domain_foundation.sql
@@ -3,40 +3,244 @@
CREATE TABLE IF NOT EXISTS categories (
id BIGINT NOT NULL AUTO_INCREMENT,
- code VARCHAR(64) NOT NULL,
- name VARCHAR(128) NOT NULL,
+ code VARCHAR(255) NOT NULL,
+ name VARCHAR(255) NOT NULL,
description VARCHAR(255),
enabled TINYINT(1) NOT NULL DEFAULT 1,
- created_at TIMESTAMP NULL,
- updated_at TIMESTAMP NULL,
+ created_at DATETIME(6) NULL,
+ updated_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_categories_code (code)
);
-ALTER TABLE users ADD COLUMN IF NOT EXISTS province VARCHAR(64);
-ALTER TABLE users ADD COLUMN IF NOT EXISTS city VARCHAR(64);
-ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NULL;
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'users'
+ AND column_name = 'province'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE users ADD COLUMN province VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'users'
+ AND column_name = 'city'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE users ADD COLUMN city VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'users'
+ AND column_name = 'updated_at'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE users ADD COLUMN updated_at DATETIME(6)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
UPDATE users SET role = 'CUSTOMER' WHERE role = 'ROLE_CUSTOMER';
UPDATE users SET role = 'ADMIN' WHERE role = 'ROLE_ADMIN';
UPDATE users SET role = 'SALES' WHERE role = 'ROLE_SALES';
UPDATE users SET role = 'CUSTOMER' WHERE role IS NULL OR TRIM(role) = '';
-ALTER TABLE users MODIFY COLUMN role VARCHAR(32) NOT NULL;
-ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id BIGINT NULL;
-ALTER TABLE products ADD COLUMN IF NOT EXISTS stock INT NOT NULL DEFAULT 0;
-ALTER TABLE products ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE';
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'products'
+ AND column_name = 'category_id'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE products ADD COLUMN category_id BIGINT'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'products'
+ AND column_name = 'stock'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE products ADD COLUMN stock INT NOT NULL DEFAULT 0'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'products'
+ AND column_name = 'status'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE products ADD COLUMN status VARCHAR(255) NOT NULL DEFAULT ''ACTIVE'''
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
UPDATE products SET stock = 0 WHERE stock IS NULL;
UPDATE products SET status = 'ACTIVE' WHERE status IS NULL OR TRIM(status) = '';
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS actor_role VARCHAR(32);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS module VARCHAR(64);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS operation_type VARCHAR(64);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS target_type VARCHAR(64);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS result VARCHAR(32);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS ip_address VARCHAR(64);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS trace_id VARCHAR(128);
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'actor_role'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN actor_role VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'module'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN module VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'operation_type'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN operation_type VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'target_type'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN target_type VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'result'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN result VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'ip_address'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN ip_address VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'trace_id'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN trace_id VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
UPDATE operation_logs SET result = 'SUCCESS' WHERE result IS NULL OR TRIM(result) = '';
diff --git a/src/main/resources/db/migration/V2__seed_base_data.sql b/src/main/resources/db/migration/V2__seed_base_data.sql
index 8d45fd2..a40f37f 100644
--- a/src/main/resources/db/migration/V2__seed_base_data.sql
+++ b/src/main/resources/db/migration/V2__seed_base_data.sql
@@ -5,61 +5,266 @@
CREATE TABLE IF NOT EXISTS categories (
id BIGINT NOT NULL AUTO_INCREMENT,
- code VARCHAR(64) NOT NULL,
- name VARCHAR(128) NOT NULL,
+ code VARCHAR(255) NOT NULL,
+ name VARCHAR(255) NOT NULL,
description VARCHAR(255),
enabled TINYINT(1) NOT NULL DEFAULT 1,
- created_at TIMESTAMP NULL,
- updated_at TIMESTAMP NULL,
+ created_at DATETIME(6) NULL,
+ updated_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_categories_code (code)
);
-ALTER TABLE users ADD COLUMN IF NOT EXISTS province VARCHAR(64);
-ALTER TABLE users ADD COLUMN IF NOT EXISTS city VARCHAR(64);
-ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NULL;
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'users'
+ AND column_name = 'province'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE users ADD COLUMN province VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'users'
+ AND column_name = 'city'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE users ADD COLUMN city VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'users'
+ AND column_name = 'updated_at'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE users ADD COLUMN updated_at DATETIME(6)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
UPDATE users SET role = 'CUSTOMER' WHERE role = 'ROLE_CUSTOMER';
UPDATE users SET role = 'ADMIN' WHERE role = 'ROLE_ADMIN';
UPDATE users SET role = 'SALES' WHERE role = 'ROLE_SALES';
UPDATE users SET role = 'CUSTOMER' WHERE role IS NULL OR TRIM(role) = '';
-ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id BIGINT NULL;
-ALTER TABLE products ADD COLUMN IF NOT EXISTS stock INT NOT NULL DEFAULT 0;
-ALTER TABLE products ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE';
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'products'
+ AND column_name = 'category_id'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE products ADD COLUMN category_id BIGINT'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'products'
+ AND column_name = 'stock'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE products ADD COLUMN stock INT NOT NULL DEFAULT 0'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'products'
+ AND column_name = 'status'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE products ADD COLUMN status VARCHAR(255) NOT NULL DEFAULT ''ACTIVE'''
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
UPDATE products SET stock = 0 WHERE stock IS NULL;
UPDATE products SET status = 'ACTIVE' WHERE status IS NULL OR TRIM(status) = '';
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS actor_role VARCHAR(32);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS module VARCHAR(64);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS operation_type VARCHAR(64);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS target_type VARCHAR(64);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS result VARCHAR(32);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS ip_address VARCHAR(64);
-ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS trace_id VARCHAR(128);
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'actor_role'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN actor_role VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'module'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN module VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'operation_type'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN operation_type VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'target_type'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN target_type VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'result'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN result VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'ip_address'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN ip_address VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(
+ EXISTS(
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'operation_logs'
+ AND column_name = 'trace_id'
+ ),
+ 'SELECT 1',
+ 'ALTER TABLE operation_logs ADD COLUMN trace_id VARCHAR(255)'
+ )
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE operation_logs SET result = 'SUCCESS' WHERE result IS NULL OR TRIM(result) = '';
INSERT INTO categories (id, code, name, description, enabled)
VALUES
(1, 'PHONE', '手机数码', '手机与配件', 1),
(2, 'COMPUTER', '电脑办公', '电脑与办公设备', 1),
(3, 'AUDIO', '音频设备', '耳机音响等设备', 1)
-AS new
ON DUPLICATE KEY UPDATE
- name = new.name,
- description = new.description,
- enabled = new.enabled;
+ name = VALUES(name),
+ description = VALUES(description),
+ enabled = VALUES(enabled);
INSERT INTO users (username, password, full_name, role)
VALUES
('testuser', '$2a$10$UEwMZTMQo1wVipiTsPrCFevVjblJUmlZd5Ud69utZEokMIi/IcWxu', '测试用户', 'CUSTOMER'),
('admin', '$2a$10$UEwMZTMQo1wVipiTsPrCFevVjblJUmlZd5Ud69utZEokMIi/IcWxu', '管理员', 'ADMIN'),
('sales', '$2a$10$UEwMZTMQo1wVipiTsPrCFevVjblJUmlZd5Ud69utZEokMIi/IcWxu', '销售人员', 'SALES')
-AS new
ON DUPLICATE KEY UPDATE
- password = new.password,
- full_name = new.full_name,
- role = new.role;
+ password = VALUES(password),
+ full_name = VALUES(full_name),
+ role = VALUES(role);
INSERT INTO products (id, name, description, price, image_url, category_id, stock, status, deleted)
VALUES
@@ -68,13 +273,12 @@ VALUES
(3, '苹果耳机', '无线蓝牙耳机,降噪功能', 1299.00, 'https://picsum.photos/200/200?random=3', 3, 80, 'ACTIVE', 0),
(4, '联想笔记本', '轻薄本,16GB+512GB', 4999.00, 'https://picsum.photos/200/200?random=4', 2, 20, 'ACTIVE', 0),
(5, '大疆无人机', '高清航拍,便携折叠', 3699.00, 'https://picsum.photos/200/200?random=5', 1, 15, 'ACTIVE', 0)
-AS new
ON DUPLICATE KEY UPDATE
- name = new.name,
- description = new.description,
- price = new.price,
- image_url = new.image_url,
- category_id = new.category_id,
- stock = new.stock,
- status = new.status,
- deleted = new.deleted;
+ name = VALUES(name),
+ description = VALUES(description),
+ price = VALUES(price),
+ image_url = VALUES(image_url),
+ category_id = VALUES(category_id),
+ stock = VALUES(stock),
+ status = VALUES(status),
+ deleted = VALUES(deleted);
diff --git a/src/main/resources/templates/error/common.html b/src/main/resources/templates/error/common.html
new file mode 100644
index 0000000..b83093c
--- /dev/null
+++ b/src/main/resources/templates/error/common.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ 系统错误
+
+
+
+
+
+
+
+
+ 请求处理失败
+ 系统已捕获异常,请根据错误信息定位并重试。
+
+ HTTP 状态: 500
+ 错误码: SYSTEM_ERROR
+ 错误信息: 系统出现异常,请稍后重试。
+ 请求路径: /unknown
+ 时间: 1970-01-01T00:00:00Z
+
+
+ 返回商品页
+
+
+
+
+
+
+
+
diff --git a/src/test/java/org/self4215/config/GlobalControllerAdviceTest.java b/src/test/java/org/self4215/config/GlobalControllerAdviceTest.java
new file mode 100644
index 0000000..f95f364
--- /dev/null
+++ b/src/test/java/org/self4215/config/GlobalControllerAdviceTest.java
@@ -0,0 +1,77 @@
+package org.self4215.config;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.self4215.exception.AppErrorCode;
+import org.self4215.exception.AppException;
+import org.springframework.http.HttpMethod;
+import org.springframework.ui.ExtendedModelMap;
+import org.springframework.ui.Model;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class GlobalControllerAdviceTest {
+
+ private GlobalControllerAdvice advice;
+
+ @BeforeEach
+ void setUp() {
+ advice = new GlobalControllerAdvice();
+ ReflectionTestUtils.setField(advice, "appVersion", "1.2.3");
+ ReflectionTestUtils.setField(advice, "appUrl", "https://example.com/project");
+ ReflectionTestUtils.setField(advice, "appLicenseUrl", "https://example.com/license");
+ ReflectionTestUtils.setField(advice, "appLicenseYears", "2025-2026");
+ ReflectionTestUtils.setField(advice, "appAuthor", "Tester");
+ }
+
+ @Test
+ void handleAppException_populatesFooterMetadata() {
+ HttpServletRequest request = new MockHttpServletRequest("GET", "/orders/999");
+ HttpServletResponse response = new MockHttpServletResponse();
+ Model model = new ExtendedModelMap();
+
+ String view = advice.handleAppException(
+ new AppException(AppErrorCode.BUSINESS_CONFLICT, "测试冲突"),
+ request,
+ response,
+ model
+ );
+
+ assertThat(view).isEqualTo("error/common");
+ assertThat(((MockHttpServletResponse) response).getStatus()).isEqualTo(400);
+ assertThat(model.getAttribute("appVersion")).isEqualTo("1.2.3");
+ assertThat(model.getAttribute("appUrl")).isEqualTo("https://example.com/project");
+ assertThat(model.getAttribute("appLicenseUrl")).isEqualTo("https://example.com/license");
+ assertThat(model.getAttribute("appLicenseYears")).isEqualTo("2025-2026");
+ assertThat(model.getAttribute("appAuthor")).isEqualTo("Tester");
+ }
+
+ @Test
+ void handleNoResourceFoundException_sets404AndFooterMetadata() {
+ HttpServletRequest request = new MockHttpServletRequest("GET", "/missing/path");
+ HttpServletResponse response = new MockHttpServletResponse();
+ Model model = new ExtendedModelMap();
+
+ String view = advice.handleNoResourceFoundException(
+ new NoResourceFoundException(HttpMethod.GET, "/missing/path"),
+ request,
+ response,
+ model
+ );
+
+ assertThat(view).isEqualTo("error/common");
+ assertThat(((MockHttpServletResponse) response).getStatus()).isEqualTo(404);
+ assertThat(model.getAttribute("errorCode")).isEqualTo("RESOURCE_NOT_FOUND");
+ assertThat(model.getAttribute("appVersion")).isEqualTo("1.2.3");
+ assertThat(model.getAttribute("appUrl")).isEqualTo("https://example.com/project");
+ assertThat(model.getAttribute("appLicenseUrl")).isEqualTo("https://example.com/license");
+ assertThat(model.getAttribute("appLicenseYears")).isEqualTo("2025-2026");
+ assertThat(model.getAttribute("appAuthor")).isEqualTo("Tester");
+ }
+}
diff --git a/src/test/java/org/self4215/service/OrderServiceTest.java b/src/test/java/org/self4215/service/OrderServiceTest.java
new file mode 100644
index 0000000..fbe2a6a
--- /dev/null
+++ b/src/test/java/org/self4215/service/OrderServiceTest.java
@@ -0,0 +1,99 @@
+package org.self4215.service;
+
+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.self4215.entity.CartItem;
+import org.self4215.entity.Order;
+import org.self4215.entity.Product;
+import org.self4215.entity.User;
+import org.self4215.exception.AppErrorCode;
+import org.self4215.exception.AppException;
+import org.self4215.repository.OrderRepository;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class OrderServiceTest {
+
+ @Mock
+ private OrderRepository orderRepository;
+
+ @Mock
+ private CartService cartService;
+
+ @Mock
+ private UserService userService;
+
+ @Mock
+ private LogService logService;
+
+ private OrderService orderService;
+
+ private final Clock fixedClock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+
+ @BeforeEach
+ void setUp() {
+ orderService = new OrderService(orderRepository, cartService, userService, logService, fixedClock);
+ }
+
+ @Test
+ void createOrder_throwsBusinessConflict_whenCartEmpty() {
+ User user = new User();
+ user.setUsername("alice");
+
+ when(userService.getCurrentUser("alice")).thenReturn(user);
+ when(cartService.getCartItems("alice")).thenReturn(List.of());
+
+ assertThatThrownBy(() -> orderService.createOrder("alice"))
+ .isInstanceOf(AppException.class)
+ .extracting(ex -> ((AppException) ex).getErrorCode())
+ .isEqualTo(AppErrorCode.BUSINESS_CONFLICT);
+ }
+
+ @Test
+ void createOrder_persistsOrderAndClearsCart_whenCartHasItems() {
+ User user = new User();
+ user.setUsername("alice");
+
+ Product product = new Product();
+ product.setPrice(new BigDecimal("100.00"));
+
+ CartItem cartItem = new CartItem();
+ cartItem.setProduct(product);
+ cartItem.setQuantity(2);
+
+ when(userService.getCurrentUser("alice")).thenReturn(user);
+ when(cartService.getCartItems("alice")).thenReturn(List.of(cartItem));
+ when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
+ Order saved = invocation.getArgument(0);
+ saved.setId(10L);
+ return saved;
+ });
+
+ Order result = orderService.createOrder("alice");
+
+ assertThat(result.getId()).isEqualTo(10L);
+ assertThat(result.getStatus()).isEqualTo("PENDING");
+ assertThat(result.getCreateTime()).isEqualTo(fixedClock.instant());
+ assertThat(result.getTotalAmount()).isEqualByComparingTo(new BigDecimal("200.00"));
+ assertThat(result.getOrderItems()).hasSize(1);
+
+ verify(cartService).clearCart("alice");
+ verify(logService).logAction(eq(user), eq("购买"), eq("10"), contains("创建订单"));
+ }
+}
diff --git a/src/test/java/org/self4215/service/ProductServiceTest.java b/src/test/java/org/self4215/service/ProductServiceTest.java
new file mode 100644
index 0000000..c052513
--- /dev/null
+++ b/src/test/java/org/self4215/service/ProductServiceTest.java
@@ -0,0 +1,56 @@
+package org.self4215.service;
+
+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.self4215.entity.Product;
+import org.self4215.exception.AppErrorCode;
+import org.self4215.exception.AppException;
+import org.self4215.repository.ProductRepository;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ProductServiceTest {
+
+ @Mock
+ private ProductRepository productRepository;
+
+ private ProductService productService;
+
+ @BeforeEach
+ void setUp() {
+ productService = new ProductService(productRepository);
+ }
+
+ @Test
+ void getProductById_throwsNotFound_whenMissing() {
+ when(productRepository.findById(99L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> productService.getProductById(99L))
+ .isInstanceOf(AppException.class)
+ .extracting(ex -> ((AppException) ex).getErrorCode())
+ .isEqualTo(AppErrorCode.RESOURCE_NOT_FOUND);
+ }
+
+ @Test
+ void saveProduct_setsDefaults_whenStockOrStatusInvalid() {
+ Product input = new Product();
+ input.setStock(null);
+ input.setStatus(" ");
+
+ when(productRepository.save(any(Product.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ Product result = productService.saveProduct(input);
+
+ assertThat(result.getStock()).isEqualTo(0);
+ assertThat(result.getStatus()).isEqualTo("ACTIVE");
+ }
+}
diff --git a/src/test/java/org/self4215/web/SecurityAccessIntegrationTest.java b/src/test/java/org/self4215/web/SecurityAccessIntegrationTest.java
new file mode 100644
index 0000000..05a6a1f
--- /dev/null
+++ b/src/test/java/org/self4215/web/SecurityAccessIntegrationTest.java
@@ -0,0 +1,88 @@
+package org.self4215.web;
+
+import org.junit.jupiter.api.Test;
+import org.self4215.config.SecurityConfig;
+import org.self4215.controller.ErrorPageController;
+import org.self4215.controller.ProductController;
+import org.self4215.controller.SalesController;
+import org.self4215.entity.Product;
+import org.self4215.service.LogService;
+import org.self4215.service.ProductService;
+import org.self4215.service.UserService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
+
+@WebMvcTest(controllers = {ProductController.class, SalesController.class, ErrorPageController.class})
+@Import(SecurityConfig.class)
+class SecurityAccessIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private ProductService productService;
+
+ @MockBean
+ private LogService logService;
+
+ @MockBean
+ private UserService userService;
+
+ @MockBean
+ private AuthenticationSuccessHandler authenticationSuccessHandler;
+
+ @MockBean
+ private LogoutSuccessHandler logoutSuccessHandler;
+
+ @Test
+ void products_isAccessibleForAnonymous() throws Exception {
+ Product sample = new Product();
+ sample.setName("sample");
+ when(productService.getActiveProducts()).thenReturn(List.of(sample));
+
+ mockMvc.perform(get("/products"))
+ .andExpect(status().isOk())
+ .andExpect(view().name("products"));
+ }
+
+ @Test
+ void salesDashboard_redirectsAnonymousToLogin() throws Exception {
+ mockMvc.perform(get("/sales/dashboard"))
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrlPattern("**/login"));
+ }
+
+ @Test
+ @WithMockUser(username = "customer", roles = "CUSTOMER")
+ void salesDashboard_forbiddenForCustomer() throws Exception {
+ mockMvc.perform(get("/sales/dashboard"))
+ .andExpect(status().isForbidden())
+ .andExpect(forwardedUrl("/error/common"))
+ .andExpect(request().attribute("errorCode", "ACCESS_DENIED"))
+ .andExpect(request().attribute("path", "/sales/dashboard"));
+ }
+
+ @Test
+ @WithMockUser(username = "sales", roles = "SALES")
+ void salesDashboard_accessibleForSales() throws Exception {
+ mockMvc.perform(get("/sales/dashboard"))
+ .andExpect(status().isOk())
+ .andExpect(view().name("sales/dashboard"));
+ }
+}
From 88f82e139c037c440f96975fbc6b685da5b40529 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=97=8B=E9=A3=8ESELF?=
<121877340+Self4215@users.noreply.github.com>
Date: Tue, 14 Apr 2026 23:03:39 +0800
Subject: [PATCH 3/3] =?UTF-8?q?chore(ver):=20=E6=9B=B4=E6=96=B0=E7=89=88?=
=?UTF-8?q?=E6=9C=AC=E5=8F=B7=E8=87=B3=202.1.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
从现在开始,正式修改版本号规范。
我知道版本号一般应从 0 开始,但为了能跟 plan 中的
Phase X (1, 2, ...) 对应上,就从 1 开始吧。
---
pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pom.xml b/pom.xml
index d761abc..1b9e805 100644
--- a/pom.xml
+++ b/pom.xml
@@ -13,7 +13,7 @@
org.self4215
com.self4215.ecommerce.02
- 1.7.20260408.01
+ 2.1.0
java-web-design-lesson-exp-ecommerce-02
|