Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/prompts/phase1-domain-foundation.plan.md
Original file line number Diff line number Diff line change
@@ -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
28 changes: 27 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

<groupId>org.self4215</groupId>
<artifactId>com.self4215.ecommerce.02</artifactId>
<version>1.7.20260408.01</version>
<version>2.1.0</version>
<name>java-web-design-lesson-exp-ecommerce-02</name>

<licenses>
Expand Down Expand Up @@ -58,6 +58,15 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Flyway 版本化数据库迁移 -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
Expand All @@ -76,10 +85,27 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<!-- Spring Boot 打包插件(生成可运行Jar) -->
<plugin>
<groupId>org.springframework.boot</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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();
}
Comment on lines +60 to +63
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractClientIp is duplicated across multiple classes and trusts the X-Forwarded-For header unconditionally, which makes audit IPs trivially spoofable unless the app is behind a trusted proxy with forwarded-header handling configured. Consider centralizing this logic (e.g., a shared util/service) and using Spring’s forwarded header support (ForwardedHeaderFilter / server.forward-headers-strategy) so you only trust proxy headers when appropriate.

Suggested change
String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor != null && !forwardedFor.isBlank()) {
return forwardedFor.split(",")[0].trim();
}

Copilot uses AI. Check for mistakes.
return request.getRemoteAddr();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Comment on lines +41 to +53
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractClientIp is duplicated across multiple classes and trusts the X-Forwarded-For header unconditionally, which makes audit IPs trivially spoofable unless the app is behind a trusted proxy with forwarded-header handling configured. Consider centralizing this logic (e.g., a shared util/service) and using Spring’s forwarded header support (ForwardedHeaderFilter / server.forward-headers-strategy) so you only trust proxy headers when appropriate.

Suggested change
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();
}
request.getRemoteAddr(),
request.getRequestedSessionId());
}
response.sendRedirect("/login?logout");
}

Copilot uses AI. Check for mistakes.
}

67 changes: 67 additions & 0 deletions src/main/java/org/self4215/config/GlobalControllerAdvice.java
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
}
}
32 changes: 30 additions & 2 deletions src/main/java/org/self4215/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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") // 自定义登录页路径
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/org/self4215/controller/ErrorPageController.java
Original file line number Diff line number Diff line change
@@ -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";
}
}
8 changes: 2 additions & 6 deletions src/main/java/org/self4215/controller/OrderController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
Loading