diff --git a/.github/prompts/phase1-domain-foundation.plan.md b/.github/prompts/phase1-domain-foundation.plan.md new file mode 100644 index 0000000..674cf5c --- /dev/null +++ b/.github/prompts/phase1-domain-foundation.plan.md @@ -0,0 +1,67 @@ +## Plan: Phase 1 基座改造与版本化迁移详细设计 + +本阶段在不改变 Spring Boot + Thymeleaf 主体架构的前提下,优先完成可持续演进底座:统一角色语义、稳定领域模型、引入可回滚迁移、预埋采集与导入导出扩展点、统一异常与审计口径。这样可避免 Phase 2-7 重复改表和改权限,保证后续功能可并行推进且回归成本可控。 + +### Steps +1. Phase 1A 基线冻结与差距对齐(无依赖) +- 建立当前实现 vs 课程条款映射清单,覆盖 3.1、3.2、3.3(仅非推荐部分)、3.4(仅数据导入导出)。 +- 明确 Phase 1 只做底座,不提前实现分析算法、推荐系统、导入导出业务流程页面。 +- 输出三份基线工件:领域字段字典、角色矩阵草案、迁移影响清单。 + +2. Phase 1B 角色模型统一与命名规范(depends on 1) +- 统一角色域为 CUSTOMER、SALES、ADMIN(保留 Spring Security 的 ROLE_ 前缀映射规则)。 +- 规定角色枚举为唯一真源,控制器与模板只消费标准化角色语义,避免字符串散落。 +- 定义登录后路由占位策略:Customer 主站、Sales 工作台、Admin 管理台。 + +3. Phase 1C 领域模型补齐(depends on 1,可与 2 并行) +- 商品域:补齐类别和库存基础能力,确保 Phase 3 可直接进入目录管理与库存策略实现。 +- 用户域:预留画像必需基础维度(如地域字段)与统一更新时间字段。 +- 日志域:扩展为可分析结构(操作者身份、模块、操作类型、目标对象、结果、IP、时间、请求追踪标识等)。 +- 兼容策略:新增字段提供默认值与向后兼容读取。 + +4. Phase 1D 引入版本化迁移机制并完成首批迁移脚本(depends on 2 and 3) +- 从 JPA update 自动改表迁移到版本化脚本主导,建立可审计、可回滚、可重放的演进流程。 +- 首批迁移覆盖:角色标准化、商品分类与库存字段、日志扩展字段、必要索引与初始角色数据补齐。 +- 迁移设计要求:幂等、可重复执行、失败可回滚、支持本地/dev/docker 同步。 + +5. Phase 1E 匿名浏览与登录后能力解耦(depends on 2,可与 4 并行) +- 路由策略分层:公开可读(商品浏览)与登录后可写(购物车、下单、管理操作)明确分离。 +- 页面策略:未登录访问商品列表可见,触发加购/下单动作时引导登录并保留回跳上下文。 + +6. Phase 1F 统一异常处理与错误码口径(depends on 1,可与 2/3 并行) +- 建立统一错误语义:参数校验失败、资源不存在、权限不足、业务冲突、并发冲突、系统异常。 +- 控制器层统一处理异常并输出一致反馈模型。 + +7. Phase 1G 导入导出滚动伴生交付预埋(depends on 3 and 4) +- Phase 1 不实现导入导出业务功能,但完成字段字典、CSV 列命名规范、模板版本号规则、导入校验错误模型。 +- 权限基线:Admin 全量导入导出;Sales 仅职责内对象可导入导出;职责外对象导入与导出双禁。 +- 滚动安排:Phase 3 对接 7A,Phase 4 对接 7B,Phase 5 对接 7C,Phase 6 对接 7D。 + +8. Phase 1H 测试基线与验收门禁(depends on 2-7) +- 建立 Phase 1 验收脚本:角色与路由、迁移执行、旧数据兼容、页面回归、日志结构完整性。 +- 增加最小自动化覆盖:服务层关键路径单元测试 + 权限与页面访问集成测试。 + +### Branch +- Phase 1 对应分支:chore/domain-foundation + +### Scope Boundaries +- Included:角色语义统一、领域模型补齐、迁移机制、匿名浏览解耦、异常口径、导入导出契约预埋、Phase 1 测试门禁。 +- Excluded:推荐系统、分析算法实现、可视化页面、完整导入导出功能、反爬虫、移动端适配。 + +### Relevant files +- docs/大三下-课程项目要求/《网络应用开发》课程设计.pdf.md +- .github/prompts/plan-javaWebDesignLessonExpEcommerce02.prompt.md +- src/main/java/org/self4215/config/SecurityConfig.java +- src/main/java/org/self4215/config/CustomAuthenticationSuccessHandler.java +- src/main/java/org/self4215/config/CustomLogoutSuccessHandler.java +- src/main/java/org/self4215/controller/ProductController.java +- src/main/java/org/self4215/controller/LoginController.java +- src/main/java/org/self4215/service/UserService.java +- src/main/java/org/self4215/service/ProductService.java +- src/main/java/org/self4215/service/LogService.java +- src/main/java/org/self4215/entity/User.java +- src/main/java/org/self4215/entity/Product.java +- src/main/java/org/self4215/entity/OperationLog.java +- src/main/resources/application.properties +- src/main/resources/data.sql +- pom.xml diff --git a/pom.xml b/pom.xml index 5f5601b..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 @@ -58,6 +58,15 @@ org.springframework.boot spring-boot-starter-data-jpa + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + com.mysql @@ -76,10 +85,27 @@ spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + org.springframework.boot diff --git a/src/main/java/org/self4215/config/CustomAuthenticationSuccessHandler.java b/src/main/java/org/self4215/config/CustomAuthenticationSuccessHandler.java index a9ed953..f4cb0b7 100644 --- a/src/main/java/org/self4215/config/CustomAuthenticationSuccessHandler.java +++ b/src/main/java/org/self4215/config/CustomAuthenticationSuccessHandler.java @@ -32,15 +32,35 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo if (authentication.getPrincipal() instanceof UserDetails) { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); User user = userService.getCurrentUser(userDetails.getUsername()); - logService.logAction(user, "登录", null, "用户登录成功"); + logService.logAction( + user, + "登录", + null, + "用户登录成功", + "AUTH", + "LOGIN", + "USER", + "SUCCESS", + extractClientIp(request), + request.getRequestedSessionId()); } Set roles = AuthorityUtils.authorityListToSet(authentication.getAuthorities()); if (roles.contains("ROLE_ADMIN")) { response.sendRedirect("/admin/users"); + } else if (roles.contains("ROLE_SALES")) { + response.sendRedirect("/sales/dashboard"); } else { response.sendRedirect("/products"); } } + + private String extractClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank()) { + return forwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } } diff --git a/src/main/java/org/self4215/config/CustomLogoutSuccessHandler.java b/src/main/java/org/self4215/config/CustomLogoutSuccessHandler.java index bd1aca1..3e367c1 100644 --- a/src/main/java/org/self4215/config/CustomLogoutSuccessHandler.java +++ b/src/main/java/org/self4215/config/CustomLogoutSuccessHandler.java @@ -29,9 +29,27 @@ public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse resp if (authentication != null && authentication.getPrincipal() instanceof UserDetails) { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); User user = userService.getCurrentUser(userDetails.getUsername()); - logService.logAction(user, "注销", null, "用户注销成功"); + logService.logAction( + user, + "注销", + null, + "用户注销成功", + "AUTH", + "LOGOUT", + "USER", + "SUCCESS", + extractClientIp(request), + request.getRequestedSessionId()); } response.sendRedirect("/login?logout"); } + + private String extractClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank()) { + return forwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } } 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 21f46fa..86c5c1c 100644 --- a/src/main/java/org/self4215/config/SecurityConfig.java +++ b/src/main/java/org/self4215/config/SecurityConfig.java @@ -2,15 +2,19 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 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 { @@ -20,21 +24,45 @@ 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) // 配置请求权限 .authorizeHttpRequests(auth -> auth // 登录页、注册页、静态资源允许匿名访问 - .requestMatchers("/login", "/register", "/css/**", "/js/**").permitAll() + .requestMatchers("/login", "/register", "/css/**", "/js/**", "/images/**").permitAll() + .requestMatchers("/error/common").permitAll() + // 课程要求:未登录用户可浏览商品 + .requestMatchers(HttpMethod.GET, "/products").permitAll() + // Sales 工作台(管理员可兼容访问) + .requestMatchers("/sales/**").hasAnyRole("SALES", "ADMIN") // 管理员页面仅允许 ADMIN 角色访问 .requestMatchers("/admin/**").hasRole("ADMIN") + // 购物车与订单流程需要登录 + .requestMatchers("/cart/**", "/order/**").authenticated() // 其他所有请求需要登录 .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/controller/ProductController.java b/src/main/java/org/self4215/controller/ProductController.java index a62beb8..0d07448 100644 --- a/src/main/java/org/self4215/controller/ProductController.java +++ b/src/main/java/org/self4215/controller/ProductController.java @@ -4,11 +4,14 @@ import org.self4215.service.LogService; import org.self4215.service.ProductService; import org.self4215.service.UserService; +import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import jakarta.servlet.http.HttpServletRequest; + @Controller public class ProductController { private final ProductService productService; @@ -23,21 +26,46 @@ public ProductController(ProductService productService, LogService logService, U // 商品列表页 @GetMapping("/products") - public String productList(Model model, Authentication authentication) { - // 获取当前登录用户名(用于后续购物车操作) - String username = authentication.getName(); - - // 记录日志 - try { - User user = userService.getCurrentUser(username); - logService.logAction(user, "浏览", "所有商品", "用户浏览了商品列表"); - } catch (Exception e) { - // Ignore if user not found or other error + public String productList(Model model, Authentication authentication, HttpServletRequest request) { + boolean isAuthenticated = authentication != null + && authentication.isAuthenticated() + && !(authentication instanceof AnonymousAuthenticationToken); + + // 匿名用户可浏览;登录用户可以直接加购 + String username = isAuthenticated ? authentication.getName() : null; + + // 仅对登录用户记录行为日志 + if (isAuthenticated) { + try { + User user = userService.getCurrentUser(username); + logService.logAction( + user, + "浏览", + "ALL_PRODUCTS", + "用户浏览了商品列表", + "CATALOG", + "BROWSE", + "PRODUCT_LIST", + "SUCCESS", + extractClientIp(request), + request.getRequestedSessionId()); + } catch (Exception e) { + // 忽略日志故障,避免影响商品页展示 + } } // 查询所有商品并传递到前端页面 - model.addAttribute("products", productService.getAllProducts()); + model.addAttribute("products", productService.getActiveProducts()); model.addAttribute("username", username); + model.addAttribute("isAuthenticated", isAuthenticated); return "products"; // 对应 templates/products.html } + + private String extractClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank()) { + return forwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } } \ No newline at end of file diff --git a/src/main/java/org/self4215/controller/SalesController.java b/src/main/java/org/self4215/controller/SalesController.java new file mode 100644 index 0000000..dd72621 --- /dev/null +++ b/src/main/java/org/self4215/controller/SalesController.java @@ -0,0 +1,26 @@ +package org.self4215.controller; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/sales") +public class SalesController { + + @ModelAttribute + public void addAttributes(Model model, Authentication authentication) { + if (authentication != null) { + model.addAttribute("username", authentication.getName()); + } + } + + @GetMapping("/dashboard") + public String dashboard(Model model) { + model.addAttribute("activePage", "sales-dashboard"); + return "sales/dashboard"; + } +} diff --git a/src/main/java/org/self4215/entity/Category.java b/src/main/java/org/self4215/entity/Category.java new file mode 100644 index 0000000..a8169f8 --- /dev/null +++ b/src/main/java/org/self4215/entity/Category.java @@ -0,0 +1,33 @@ +package org.self4215.entity; + +import org.self4215.config.UtcInstantAttributeConverter; +import lombok.Data; +import jakarta.persistence.*; + +import java.time.Instant; + +@Data +@Entity +@Table(name = "categories") +public class Category { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String code; + + @Column(nullable = false) + private String name; + + private String description; + + @Column(nullable = false) + private Boolean enabled = true; + + @Convert(converter = UtcInstantAttributeConverter.class) + private Instant createdAt; + + @Convert(converter = UtcInstantAttributeConverter.class) + private Instant updatedAt; +} diff --git a/src/main/java/org/self4215/entity/OperationLog.java b/src/main/java/org/self4215/entity/OperationLog.java index e3212d6..89955f2 100644 --- a/src/main/java/org/self4215/entity/OperationLog.java +++ b/src/main/java/org/self4215/entity/OperationLog.java @@ -23,6 +23,20 @@ public class OperationLog { private String details; + private String actorRole; // CUSTOMER, SALES, ADMIN + + private String module; // AUTH, CATALOG, ORDER, ADMIN ... + + private String operationType; // LOGIN, LOGOUT, BROWSE, PURCHASE ... + + private String targetType; // PRODUCT, ORDER, USER ... + + private String result; // SUCCESS, FAILURE + + private String ipAddress; + + private String traceId; + @Convert(converter = UtcInstantAttributeConverter.class) private Instant timestamp; } diff --git a/src/main/java/org/self4215/entity/Product.java b/src/main/java/org/self4215/entity/Product.java index 3d59d4f..917faa0 100644 --- a/src/main/java/org/self4215/entity/Product.java +++ b/src/main/java/org/self4215/entity/Product.java @@ -22,6 +22,16 @@ public class Product { private String imageUrl; // 商品图片(用占位图) + @ManyToOne + @JoinColumn(name = "category_id") + private Category category; + + @Column(nullable = false) + private Integer stock = 0; + + @Column(nullable = false) + private String status = "ACTIVE"; // ACTIVE, INACTIVE + @Column(nullable = false) private boolean deleted = false; // 软删除标记 } \ No newline at end of file diff --git a/src/main/java/org/self4215/entity/User.java b/src/main/java/org/self4215/entity/User.java index 707e6a0..547032f 100644 --- a/src/main/java/org/self4215/entity/User.java +++ b/src/main/java/org/self4215/entity/User.java @@ -1,10 +1,12 @@ package org.self4215.entity; +import org.self4215.config.UtcInstantAttributeConverter; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import jakarta.persistence.*; +import java.time.Instant; import java.util.Set; @Data @@ -21,13 +23,20 @@ public class User { @Column(nullable = false) private String password; // 密码(Spring Security 会加密) - private String role; // 角色:ROLE_CUSTOMER, ROLE_ADMIN + private String role; // 角色:CUSTOMER, SALES, ADMIN @Transient // 不持久化到数据库 private String confirmPassword; // 确认密码 private String fullName; // 昵称 + private String province; // 画像基础维度:省份 + + private String city; // 画像基础维度:城市 + + @Convert(converter = UtcInstantAttributeConverter.class) + private Instant updatedAt; + // 关联购物车(一个用户对应多个购物车项) @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) @ToString.Exclude // 【新增】防止 toString 死循环 diff --git a/src/main/java/org/self4215/entity/UserRole.java b/src/main/java/org/self4215/entity/UserRole.java new file mode 100644 index 0000000..8e4fa82 --- /dev/null +++ b/src/main/java/org/self4215/entity/UserRole.java @@ -0,0 +1,7 @@ +package org.self4215.entity; + +public enum UserRole { + CUSTOMER, + SALES, + ADMIN +} 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/repository/CategoryRepository.java b/src/main/java/org/self4215/repository/CategoryRepository.java new file mode 100644 index 0000000..672891c --- /dev/null +++ b/src/main/java/org/self4215/repository/CategoryRepository.java @@ -0,0 +1,10 @@ +package org.self4215.repository; + +import org.self4215.entity.Category; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CategoryRepository extends JpaRepository { + Optional findByCode(String code); +} diff --git a/src/main/java/org/self4215/repository/ProductRepository.java b/src/main/java/org/self4215/repository/ProductRepository.java index ff8104c..185950a 100644 --- a/src/main/java/org/self4215/repository/ProductRepository.java +++ b/src/main/java/org/self4215/repository/ProductRepository.java @@ -7,4 +7,6 @@ public interface ProductRepository extends JpaRepository { List findByDeletedFalse(); + + List findByDeletedFalseAndStatus(String status); } \ No newline at end of file diff --git a/src/main/java/org/self4215/service/LogService.java b/src/main/java/org/self4215/service/LogService.java index 96e9bd0..c8d2b56 100644 --- a/src/main/java/org/self4215/service/LogService.java +++ b/src/main/java/org/self4215/service/LogService.java @@ -19,11 +19,32 @@ public LogService(OperationLogRepository logRepository, Clock clock) { } public void logAction(User user, String action, String targetId, String details) { + logAction(user, action, targetId, details, null, null, null, "SUCCESS", null, null); + } + + public void logAction( + User user, + String action, + String targetId, + String details, + String module, + String operationType, + String targetType, + String result, + String ipAddress, + String traceId) { OperationLog log = new OperationLog(); log.setUser(user); log.setAction(action); log.setTargetId(targetId); log.setDetails(details); + log.setActorRole(user != null ? user.getRole() : null); + log.setModule(module); + log.setOperationType(operationType); + log.setTargetType(targetType); + log.setResult(result); + log.setIpAddress(ipAddress); + log.setTraceId(traceId); log.setTimestamp(clock.instant()); logRepository.save(log); } 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 bd2e070..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; @@ -18,14 +20,25 @@ public List getAllProducts() { return productRepository.findByDeletedFalse(); } + // 前台商品列表:仅展示上架状态商品 + public List getActiveProducts() { + return productRepository.findByDeletedFalseAndStatus("ACTIVE"); + } + // 根据ID查询商品 public Product getProductById(Long id) { return productRepository.findById(id) - .orElseThrow(() -> new RuntimeException("商品不存在:" + id)); + .orElseThrow(() -> new AppException(AppErrorCode.RESOURCE_NOT_FOUND, "商品不存在: " + id)); } // 保存商品(初始化数据用) public Product saveProduct(Product product) { + if (product.getStock() == null || product.getStock() < 0) { + product.setStock(0); + } + if (product.getStatus() == null || product.getStatus().isBlank()) { + product.setStatus("ACTIVE"); + } return productRepository.save(product); } diff --git a/src/main/java/org/self4215/service/UserService.java b/src/main/java/org/self4215/service/UserService.java index f7da7dd..11279cb 100644 --- a/src/main/java/org/self4215/service/UserService.java +++ b/src/main/java/org/self4215/service/UserService.java @@ -1,6 +1,9 @@ package org.self4215.service; 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; @@ -10,6 +13,7 @@ import org.springframework.stereotype.Service; import java.util.Collections; +import java.util.Locale; @Service public class UserService implements UserDetailsService { @@ -28,31 +32,40 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("用户不存在:" + username)); + String role = user.getRole(); + if (role == null || role.isBlank()) { + role = UserRole.CUSTOMER.name(); + } + String authority = role.startsWith("ROLE_") ? role : "ROLE_" + role; + // 封装成 Spring Security 的 UserDetails(包含用户名、密码、权限) return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), - Collections.singletonList(new SimpleGrantedAuthority(user.getRole())) + 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("ROLE_CUSTOMER"); // 默认角色 + user.setRole(UserRole.CUSTOMER.name()); // 默认角色 saveUser(user); } // 保存用户(初始化测试用户用) public User saveUser(User user) { + if (user.getRole() == null || user.getRole().isBlank()) { + user.setRole(UserRole.CUSTOMER.name()); + } // 密码加密(Spring Security 要求密码必须加密) user.setPassword(passwordEncoder.encode(user.getPassword())); return userRepository.save(user); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index dc6aeb0..c010aee 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -10,16 +10,21 @@ spring.datasource.password=${DB_PASSWORD:root123456789} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # JPA 配置(自动建表、显示SQL) +# 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 -# 【新增】关键配置:延迟数据初始化,确保先由 Hibernate 建表,再执行 data.sql -spring.jpa.defer-datasource-initialization=true -# 【新增】强制执行 data.sql(MySQL 默认不执行),修复商品列表为空的问题 -spring.sql.init.mode=always +# Flyway 启用后关闭 data.sql 初始化,避免循环依赖和重复写入 +spring.jpa.defer-datasource-initialization=false +spring.sql.init.mode=never + +# Flyway 配置(版本化迁移) +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.flyway.baseline-on-migrate=true # Thymeleaf 配置(开发时禁用缓存) spring.thymeleaf.cache=false diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 133880c..68deb50 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,18 +1,38 @@ -- 初始化测试用户(密码是 123456,已用 BCrypt 加密) -- 使用 MySQL 的 IGNORE 语法,如果条目已存在则忽略,避免重复插入导致无法启动,下同 INSERT IGNORE INTO users (username, password, full_name, role) -VALUES ('testuser', '$2a$10$UEwMZTMQo1wVipiTsPrCFevVjblJUmlZd5Ud69utZEokMIi/IcWxu', '测试用户', 'ROLE_CUSTOMER'); +VALUES ('testuser', '$2a$10$UEwMZTMQo1wVipiTsPrCFevVjblJUmlZd5Ud69utZEokMIi/IcWxu', '测试用户', 'CUSTOMER'); -- 初始化管理员用户(密码是 123456) INSERT IGNORE INTO users (username, password, full_name, role) -VALUES ('admin', '$2a$10$UEwMZTMQo1wVipiTsPrCFevVjblJUmlZd5Ud69utZEokMIi/IcWxu', '管理员', 'ROLE_ADMIN'); +VALUES ('admin', '$2a$10$UEwMZTMQo1wVipiTsPrCFevVjblJUmlZd5Ud69utZEokMIi/IcWxu', '管理员', 'ADMIN'); + +-- 初始化销售用户(密码是 123456) +INSERT IGNORE INTO users (username, password, full_name, role) +VALUES ('sales', '$2a$10$UEwMZTMQo1wVipiTsPrCFevVjblJUmlZd5Ud69utZEokMIi/IcWxu', '销售人员', 'SALES'); + +-- 初始化商品分类 +INSERT IGNORE INTO categories (id, code, name, description, enabled) +VALUES + (1, 'PHONE', '手机数码', '手机与配件', 1), + (2, 'COMPUTER', '电脑办公', '电脑与办公设备', 1), + (3, 'AUDIO', '音频设备', '耳机音响等设备', 1); -- 初始化商品数据 -- 使用 INSERT IGNORE 配合手动指定 ID 来防止重复商品条目 -INSERT IGNORE INTO products (id, name, description, price, image_url, deleted) +INSERT IGNORE INTO products (id, name, description, price, image_url, category_id, stock, status, deleted) VALUES - (1, '小米手机', '高性能智能手机,8GB+256GB', 2999.00, 'https://picsum.photos/200/200?random=1', 0), - (2, '华为平板', '10.4英寸大屏,学习办公神器', 1899.00, 'https://picsum.photos/200/200?random=2', 0), - (3, '苹果耳机', '无线蓝牙耳机,降噪功能', 1299.00, 'https://picsum.photos/200/200?random=3', 0), - (4, '联想笔记本', '轻薄本,16GB+512GB', 4999.00, 'https://picsum.photos/200/200?random=4', 0), - (5, '大疆无人机', '高清航拍,便携折叠', 3699.00, 'https://picsum.photos/200/200?random=5', 0); + (1, '小米手机', '高性能智能手机,8GB+256GB', 2999.00, 'https://picsum.photos/200/200?random=1', 1, 50, 'ACTIVE', 0), + (2, '华为平板', '10.4英寸大屏,学习办公神器', 1899.00, 'https://picsum.photos/200/200?random=2', 2, 35, 'ACTIVE', 0), + (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); + +-- 兼容历史数据:标准化角色与商品状态,避免老数据无法通过新逻辑展示 +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 role = ''; + +UPDATE products SET stock = 0 WHERE stock IS NULL; +UPDATE products SET status = 'ACTIVE' WHERE status IS NULL OR status = ''; diff --git a/src/main/resources/db/migration/V1__phase1_domain_foundation.sql b/src/main/resources/db/migration/V1__phase1_domain_foundation.sql new file mode 100644 index 0000000..5596deb --- /dev/null +++ b/src/main/resources/db/migration/V1__phase1_domain_foundation.sql @@ -0,0 +1,287 @@ +-- Phase 1: domain foundation migration +-- Goal: role normalization, category/stock foundation, structured operation log expansion. + +CREATE TABLE IF NOT EXISTS categories ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + description VARCHAR(255), + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_categories_code (code) +); + +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) = ''; + +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) = ''; + +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 (code, name, description, enabled) +VALUES + ('PHONE', '手机数码', '手机与配件', 1), + ('COMPUTER', '电脑办公', '电脑与办公设备', 1), + ('AUDIO', '音频设备', '耳机音响等设备', 1) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + description = VALUES(description), + enabled = VALUES(enabled); + +SET @idx_products_category_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'products' + AND index_name = 'idx_products_category_id' +); +SET @idx_products_category_sql := IF( + @idx_products_category_exists = 0, + 'CREATE INDEX idx_products_category_id ON products (category_id)', + 'SELECT 1' +); +PREPARE idx_products_category_stmt FROM @idx_products_category_sql; +EXECUTE idx_products_category_stmt; +DEALLOCATE PREPARE idx_products_category_stmt; + +SET @idx_operation_logs_module_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'operation_logs' + AND index_name = 'idx_operation_logs_module' +); +SET @idx_operation_logs_module_sql := IF( + @idx_operation_logs_module_exists = 0, + 'CREATE INDEX idx_operation_logs_module ON operation_logs (module)', + 'SELECT 1' +); +PREPARE idx_operation_logs_module_stmt FROM @idx_operation_logs_module_sql; +EXECUTE idx_operation_logs_module_stmt; +DEALLOCATE PREPARE idx_operation_logs_module_stmt; diff --git a/src/main/resources/db/migration/V2__seed_base_data.sql b/src/main/resources/db/migration/V2__seed_base_data.sql new file mode 100644 index 0000000..a40f37f --- /dev/null +++ b/src/main/resources/db/migration/V2__seed_base_data.sql @@ -0,0 +1,284 @@ +-- Phase 1 seed data moved from data.sql to Flyway. +-- V2 需兼容两种状态: +-- 1) 新库按 V1 -> V2 执行 +-- 2) 旧库 baseline=1,V1 跳过后直接执行 V2 + +CREATE TABLE IF NOT EXISTS categories ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + description VARCHAR(255), + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_categories_code (code) +); + +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) = ''; + +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) = ''; + +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) +ON DUPLICATE KEY UPDATE + 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') +ON DUPLICATE KEY UPDATE + 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 + (1, '小米手机', '高性能智能手机,8GB+256GB', 2999.00, 'https://picsum.photos/200/200?random=1', 1, 50, 'ACTIVE', 0), + (2, '华为平板', '10.4英寸大屏,学习办公神器', 1899.00, 'https://picsum.photos/200/200?random=2', 2, 35, 'ACTIVE', 0), + (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) +ON DUPLICATE KEY UPDATE + 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/admin/product_form.html b/src/main/resources/templates/admin/product_form.html index c38f16b..dc77078 100644 --- a/src/main/resources/templates/admin/product_form.html +++ b/src/main/resources/templates/admin/product_form.html @@ -19,6 +19,17 @@

商品表 +
+ + +
+
+ + +
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")); + } +}