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/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/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 预留入口
+ 本阶段仅验证角色跳转与权限边界,业务能力将在后续分支迭代实现。
+
+
+
+
+
+
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"));
+ }
+}
|