From ac6442a452a8840f438b8c1a60b9632b3d8e21f1 Mon Sep 17 00:00:00 2001 From: Chuyaoyuan Date: Wed, 29 Apr 2026 19:04:48 +0800 Subject: [PATCH] update --- pom.xml | 158 +++++++++----- .../yaoyuan/jiscuss/config/MyMvcConfig.java | 12 +- .../jiscuss/config/SwaggerConfiguration.java | 40 ++-- .../jiscuss/config/WebSecurityConfig.java | 193 +++++++++++------- .../controller/AdminSystemController.java | 2 +- .../jiscuss/controller/BaseController.java | 4 +- .../controller/UserPageController.java | 2 +- .../controller/UserPostController.java | 2 +- .../controller/UserSystemController.java | 2 +- .../controller/api/RestPostController.java | 19 +- .../controller/api/RestUserController.java | 37 ++-- .../jiscuss/dto/UserCreateRequest.java | 31 +++ .../com/yaoyuan/jiscuss/dto/UserMapper.java | 44 ++++ .../com/yaoyuan/jiscuss/dto/UserResponse.java | 23 +++ .../yaoyuan/jiscuss/entity/Discussion.java | 20 +- .../yaoyuan/jiscuss/entity/DiscussionTag.java | 12 +- .../yaoyuan/jiscuss/entity/LikeCollect.java | 16 +- .../java/com/yaoyuan/jiscuss/entity/Post.java | 15 +- .../com/yaoyuan/jiscuss/entity/Setting.java | 12 +- .../java/com/yaoyuan/jiscuss/entity/Tag.java | 12 +- .../java/com/yaoyuan/jiscuss/entity/User.java | 28 ++- .../jiscuss/entity/custom/TagCustom.java | 2 +- .../exception/GlobalExceptionHandler.java | 80 ++++++++ .../handler/CustomAccessDeniedHandler.java | 8 +- .../jiscuss/handler/RbacPermission.java | 71 +++++-- .../jiscuss/repository/UsersRepository.java | 9 +- .../yaoyuan/jiscuss/response/ApiResponse.java | 21 ++ .../jiscuss/service/IUsersService.java | 2 - .../service/impl/DiscussionsServiceImpl.java | 39 ++-- .../impl/DiscussionsTagsServiceImpl.java | 5 +- .../service/impl/PostsServiceImpl.java | 8 +- .../service/impl/SettingsServiceImpl.java | 5 +- .../jiscuss/service/impl/TagsServiceImpl.java | 9 +- .../service/impl/UserDetailServiceImpl.java | 35 ++-- .../service/impl/UsersServiceImpl.java | 40 ++-- .../com/yaoyuan/jiscuss/util/IpUtils.java | 81 ++++++++ src/main/resources/application-h2.yml | 94 ++++----- src/main/resources/application-mysql.yml | 75 ++++--- src/main/resources/application.properties | 3 + src/main/resources/application.yml | 107 +++++----- .../db/migration/V1__init_schema.sql | 149 ++++++++++++++ .../db/migration/V2__security_updates.sql | 33 +++ src/main/resources/ehcache3.xml | 33 +++ 43 files changed, 1104 insertions(+), 489 deletions(-) create mode 100644 src/main/java/com/yaoyuan/jiscuss/dto/UserCreateRequest.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/dto/UserMapper.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/dto/UserResponse.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/response/ApiResponse.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/util/IpUtils.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/db/migration/V1__init_schema.sql create mode 100644 src/main/resources/db/migration/V2__security_updates.sql create mode 100644 src/main/resources/ehcache3.xml diff --git a/pom.xml b/pom.xml index 53573ee..9324ba5 100644 --- a/pom.xml +++ b/pom.xml @@ -3,23 +3,25 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.springframework.boot - spring-boot-starter-parent - 2.1.8.RELEASE - + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + com.yaoyuan jiscuss - 0.0.1-SNAPSHOT jiccuss - jiccuss project for Spring Boot + Jiscuss forum project for Spring Boot 3 - 1.8 + + 17 + 1.6.3 + org.springframework.boot spring-boot-starter-data-jpa @@ -28,7 +30,7 @@ org.springframework.boot spring-boot-starter-freemarker - + org.springframework.boot spring-boot-starter-security @@ -41,73 +43,133 @@ org.springframework.boot spring-boot-starter-websocket - + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-cache + + + com.h2database h2 runtime - + - mysql - mysql-connector-java - 5.1.30 + com.mysql + mysql-connector-j + runtime - + - org.json - json - 20180813 + org.json + json + 20240303 - + - io.springfox - springfox-swagger2 - 2.9.2 + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + - io.springfox - springfox-swagger-ui - 2.9.2 + org.projectlombok + lombok + provided - + - org.projectlombok - lombok - compile + org.mapstruct + mapstruct + ${mapstruct.version} - + - com.alibaba - druid-spring-boot-starter - 1.1.18 + com.alibaba + druid-spring-boot-3-starter + 1.2.23 - - - - org.springframework.boot - spring-boot-starter-cache - - - net.sf.ehcache - ehcache - + + + org.ehcache + ehcache + 3.10.8 + jakarta + + + + javax.cache + cache-api + 1.1.1 + + + + + org.flywaydb + flyway-core + + + + org.flywaydb + flyway-mysql + - - - org.springframework.boot - spring-boot-maven-plugin - - + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + diff --git a/src/main/java/com/yaoyuan/jiscuss/config/MyMvcConfig.java b/src/main/java/com/yaoyuan/jiscuss/config/MyMvcConfig.java index 229f94f..c540b2f 100644 --- a/src/main/java/com/yaoyuan/jiscuss/config/MyMvcConfig.java +++ b/src/main/java/com/yaoyuan/jiscuss/config/MyMvcConfig.java @@ -7,19 +7,11 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class MyMvcConfig implements WebMvcConfigurer { - /* - * addResourceHandlers - * void - */ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { - - registry.addResourceHandler("swagger-ui.html") - .addResourceLocations("classpath:/META-INF/resources/"); - registry.addResourceHandler("/webjars/**") - .addResourceLocations("classpath:/META-INF/resources/webjars/"); + // SpringDoc OpenAPI serves its own UI resources — no manual mapping needed. + // Static application assets: registry.addResourceHandler("/static/**") .addResourceLocations("classpath:/static/"); } - } diff --git a/src/main/java/com/yaoyuan/jiscuss/config/SwaggerConfiguration.java b/src/main/java/com/yaoyuan/jiscuss/config/SwaggerConfiguration.java index b22e95a..5af0472 100644 --- a/src/main/java/com/yaoyuan/jiscuss/config/SwaggerConfiguration.java +++ b/src/main/java/com/yaoyuan/jiscuss/config/SwaggerConfiguration.java @@ -1,31 +1,29 @@ package com.yaoyuan.jiscuss.config; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import springfox.documentation.builders.ApiInfoBuilder; -import springfox.documentation.builders.PathSelectors; -import springfox.documentation.builders.RequestHandlerSelectors; -import springfox.documentation.service.ApiInfo; -import springfox.documentation.spi.DocumentationType; -import springfox.documentation.spring.web.plugins.Docket; -import springfox.documentation.swagger2.annotations.EnableSwagger2; +/** + * SpringDoc OpenAPI 2.x configuration. + * + *

Replaces discontinued springfox-swagger2 (incompatible with Spring Boot 3). + * UI is available at /swagger-ui/index.html + */ @Configuration -@EnableSwagger2 public class SwaggerConfiguration { @Bean - public Docket createRestApi() { - return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select() - .apis(RequestHandlerSelectors.basePackage("com.yaoyuan.jiscuss.controller")).paths(PathSelectors.any()) - .build(); + public OpenAPI jiscussOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Jiscuss REST APIs") + .description("Jiscuss forum system API documentation") + .version("1.0") + .contact(new Contact() + .name("Jiscuss Team") + .email("developer@mail.com"))); } - - @SuppressWarnings("deprecation") - private ApiInfo apiInfo() { - return new ApiInfoBuilder().title("swagger-ui RESTful APIs").description("Jiscuss") - .termsOfServiceUrl("http://localhost/").contact("developer@mail.com").version("1.0").build(); - } - - -} \ No newline at end of file +} diff --git a/src/main/java/com/yaoyuan/jiscuss/config/WebSecurityConfig.java b/src/main/java/com/yaoyuan/jiscuss/config/WebSecurityConfig.java index 38a6888..182e509 100644 --- a/src/main/java/com/yaoyuan/jiscuss/config/WebSecurityConfig.java +++ b/src/main/java/com/yaoyuan/jiscuss/config/WebSecurityConfig.java @@ -1,104 +1,157 @@ package com.yaoyuan.jiscuss.config; import com.yaoyuan.jiscuss.handler.CustomAccessDeniedHandler; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Configurable; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler; +import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager; /** - * prePostEnabled :决定Spring Security的前注解是否可用 [@PreAuthorize,@PostAuthorize,..] - * secureEnabled : 决定是否Spring Security的保障注解 [@Secured] 是否可用 - * jsr250Enabled :决定 JSR-250 annotations 注解[@RolesAllowed..] 是否可用. - * 开启 Spring Security 方法级安全注解 @EnableGlobalMethodSecurity + * Spring Security 6 configuration (Spring Boot 3 compatible). + * + *

Breaking changes from Spring Boot 2.x: + *

*/ -@Configurable +@Configuration @EnableWebSecurity -@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true) -public class WebSecurityConfig extends WebSecurityConfigurerAdapter { +@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true) +public class WebSecurityConfig { - @Autowired - private CustomAccessDeniedHandler customAccessDeniedHandler; - - @Qualifier("userDetailServiceImpl") - @Autowired - private UserDetailsService userDetailsService; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + private final UserDetailsService userDetailsService; + private final ApplicationContext applicationContext; + + /** Controlled by spring.h2.console.enabled — only true in dev (h2) profile. */ + @Value("${spring.h2.console.enabled:false}") + private boolean h2ConsoleEnabled; + + public WebSecurityConfig( + CustomAccessDeniedHandler customAccessDeniedHandler, + @Qualifier("userDetailServiceImpl") UserDetailsService userDetailsService, + ApplicationContext applicationContext) { + this.customAccessDeniedHandler = customAccessDeniedHandler; + this.userDetailsService = userDetailsService; + this.applicationContext = applicationContext; + } /** - * 静态资源设置 + * Exclude truly static assets from the security filter chain entirely. + * H2 console and Druid monitoring are NOT excluded here — they are secured via filterChain. */ - @Override - public void configure(WebSecurity webSecurity) { - //不拦截静态资源,所有用户均可访问的资源 - webSecurity.ignoring().antMatchers( - "/", - "/css/**", - "/js/**", - "/images/**", - "/static/**", - "/h2-console/**", - "/test/**", - "/druid/**" + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring().requestMatchers( + "/css/**", "/js/**", "/images/**", "/static/**", "/favicon.ico" ); } /** - * http请求设置 + * Main security filter chain. + * + *

Security decisions: + *

*/ - @Override - public void configure(HttpSecurity http) throws Exception { - //http.csrf().disable(); //注释就是使用 csrf 功能 - http.headers().frameOptions().disable();//解决 in a frame because it set 'X-Frame-Options' to 'DENY' 问题 - //http.anonymous().disable(); - http.authorizeRequests() - .antMatchers("/login/**", "/initUserData")//不拦截登录相关方法 - .permitAll() - //.antMatchers("/user").hasRole("ADMIN") // user接口只有ADMIN角色的可以访问 - //.anyRequest() - //.authenticated()// 任何尚未匹配的URL只需要验证用户即可访问 - .anyRequest() - .access("@rbacPermission.hasPermission(request, authentication)")//根据账号权限访问 - .and() - .formLogin() - //.loginPage("/") - .loginPage("/login") //登录请求页 - .loginProcessingUrl("/login") //登录POST请求路径 - .usernameParameter("username") //登录用户名参数 - .passwordParameter("password") //登录密码参数 - .defaultSuccessUrl("/index") //默认登录成功页面 - .and() - .exceptionHandling() - .accessDeniedHandler(customAccessDeniedHandler) //无权限处理器 - .and() - .logout() - .logoutSuccessUrl("/login?logout"); //退出登录成功URL + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // Fix: SAMEORIGIN instead of disable() — prevents clickjacking, allows H2 iframe in same origin + http.headers(headers -> headers + .frameOptions(frame -> frame.sameOrigin()) + ); + // CSRF: disable for H2 console path only (H2 web console does not send CSRF tokens) + if (h2ConsoleEnabled) { + http.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**")); + } + + // Build the RBAC expression manager with ApplicationContext so @bean references work + DefaultWebSecurityExpressionHandler expressionHandler = new DefaultWebSecurityExpressionHandler(); + expressionHandler.setApplicationContext(applicationContext); + WebExpressionAuthorizationManager rbacManager = new WebExpressionAuthorizationManager( + "@rbacPermission.hasPermission(request, authentication)"); + rbacManager.setExpressionHandler(expressionHandler); + + http.authorizeHttpRequests(auth -> { + // Publicly accessible + auth.requestMatchers( + "/login/**", "/initUserData", + // SpringDoc OpenAPI UI and spec endpoint (developer tools, no sensitive data) + "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**" + ).permitAll(); + + // H2 console: admin-only, dev profile only + if (h2ConsoleEnabled) { + auth.requestMatchers("/h2-console/**").hasRole("ADMIN"); + } + + // Druid monitoring, Actuator, Admin UI: restricted to ROLE_ADMIN + auth.requestMatchers("/druid/**").hasRole("ADMIN"); + auth.requestMatchers("/actuator/**").hasRole("ADMIN"); + auth.requestMatchers("/admin/**").hasRole("ADMIN"); + + // All remaining requests evaluated by RBAC permission bean + auth.anyRequest().access(rbacManager); + }); + + http.formLogin(form -> form + .loginPage("/login") + .loginProcessingUrl("/login") + .usernameParameter("username") + .passwordParameter("password") + .defaultSuccessUrl("/index") + ); + + http.logout(logout -> logout + .logoutSuccessUrl("/login?logout") + ); + + http.exceptionHandling(ex -> ex + .accessDeniedHandler(customAccessDeniedHandler) + ); + + return http.build(); } /** - * 自定义获取用户信息接口 + * Authentication manager wired with BCrypt — replaces the old + * {@code configure(AuthenticationManagerBuilder)} override. */ - @Override - public void configure(AuthenticationManagerBuilder auth) throws Exception { - auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); + @Bean + public AuthenticationManager authenticationManager(BCryptPasswordEncoder passwordEncoder) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + return new ProviderManager(provider); } - /** - * 密码加密算法 - * @return - */ + /** BCrypt password encoder bean. */ @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); - } - } + diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/AdminSystemController.java b/src/main/java/com/yaoyuan/jiscuss/controller/AdminSystemController.java index 4182e86..1be641a 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/AdminSystemController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/AdminSystemController.java @@ -6,7 +6,7 @@ import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; /** * @author yaoyuan2.chu diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/BaseController.java b/src/main/java/com/yaoyuan/jiscuss/controller/BaseController.java index c77e29d..f5ae5ae 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/BaseController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/BaseController.java @@ -4,8 +4,8 @@ import com.yaoyuan.jiscuss.entity.UserInfo; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import java.util.Enumeration; /** diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/UserPageController.java b/src/main/java/com/yaoyuan/jiscuss/controller/UserPageController.java index 5a2b897..724660f 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/UserPageController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/UserPageController.java @@ -12,7 +12,7 @@ import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; /** * @author yaoyuan2.chu diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/UserPostController.java b/src/main/java/com/yaoyuan/jiscuss/controller/UserPostController.java index 1d0cf66..feb2de1 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/UserPostController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/UserPostController.java @@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; import java.util.Date; import java.util.List; diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/UserSystemController.java b/src/main/java/com/yaoyuan/jiscuss/controller/UserSystemController.java index c182c95..243c277 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/UserSystemController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/UserSystemController.java @@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/api/RestPostController.java b/src/main/java/com/yaoyuan/jiscuss/controller/api/RestPostController.java index 58df1d1..6043b3b 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/api/RestPostController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/api/RestPostController.java @@ -4,8 +4,8 @@ import com.yaoyuan.jiscuss.entity.Discussion; import com.yaoyuan.jiscuss.exception.BaseException; import com.yaoyuan.jiscuss.response.ResponseCode; import com.yaoyuan.jiscuss.service.IDiscussionsService; -import io.swagger.annotations.Api; -import io.swagger.annotations.ApiOperation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -20,15 +20,16 @@ import java.util.List; @RestController @RequestMapping(path = "/other_api", produces = "application/json;charset=utf-8") -@Api(value = "主题帖子(其他)接口管理", tags = {"主题帖子(其他)接口管理"}) +@Tag(name = "主题帖子(其他)接口管理", description = "主题相关 REST API") public class RestPostController { - private static Logger logger = LoggerFactory.getLogger(RestPostController.class); + + private static final Logger logger = LoggerFactory.getLogger(RestPostController.class); @Autowired private IDiscussionsService discussionsService; @PostMapping("/discussion") - @ApiOperation("新增主题") + @Operation(summary = "新增主题") public Discussion save(@RequestBody Discussion discussion) { Discussion saveDiscussion = discussionsService.insert(discussion); if (saveDiscussion != null) { @@ -39,18 +40,16 @@ public class RestPostController { } @GetMapping("/discussion/{id}") - @ApiOperation("获取主题") + @Operation(summary = "获取主题") public Discussion getDiscussions(@PathVariable Integer id) { - Discussion discussion = discussionsService.findOne(id); - return discussion; + return discussionsService.findOne(id); } @GetMapping("/discussions") - @ApiOperation("获取全部主题") + @Operation(summary = "获取全部主题") public List getAllDiscussions() { List allDiscussions = discussionsService.getAllList(); logger.info("全部主题==>:{}", allDiscussions); return allDiscussions; } - } diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/api/RestUserController.java b/src/main/java/com/yaoyuan/jiscuss/controller/api/RestUserController.java index d776261..3e71c19 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/api/RestUserController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/api/RestUserController.java @@ -4,9 +4,8 @@ import com.yaoyuan.jiscuss.entity.User; import com.yaoyuan.jiscuss.exception.BaseException; import com.yaoyuan.jiscuss.response.ResponseCode; import com.yaoyuan.jiscuss.service.IUsersService; -import io.swagger.annotations.Api; -import io.swagger.annotations.ApiOperation; -import org.json.JSONObject; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -23,16 +22,16 @@ import java.util.List; @RestController @RequestMapping("/user_api") -@Api(value = "用户接口管理", tags = {"用户接口管理"}) +@Tag(name = "用户接口管理", description = "用户 CRUD API") public class RestUserController { - private static Logger logger = LoggerFactory.getLogger(RestUserController.class); + private static final Logger logger = LoggerFactory.getLogger(RestUserController.class); @Autowired private IUsersService usersService; @PostMapping("/user") - @ApiOperation("新增用户") + @Operation(summary = "新增用户") public User save(@RequestBody User user) { User saveUser = usersService.insert(user); if (saveUser != null) { @@ -40,23 +39,17 @@ public class RestUserController { } else { throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST); } - } @DeleteMapping("/user/{id}") - @ApiOperation("删除用户") + @Operation(summary = "删除用户") public Boolean delete(@PathVariable Integer id) { - Boolean delete = true; usersService.remove(id); - if (delete) { - return delete; - } else { - throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST); - } + return true; } @PutMapping("/user/{id}") - @ApiOperation("修改用户") + @Operation(summary = "修改用户") public User update(@RequestBody User user, @PathVariable Integer id) { User updateuser = usersService.update(user, id); if (updateuser != null) { @@ -67,19 +60,19 @@ public class RestUserController { } @GetMapping("/user/{id}") - @ApiOperation("获取用户") + @Operation(summary = "获取用户") public User getUser(@PathVariable Integer id) { User user = usersService.findOne(id); - logger.info("获取用户==>:{}", JSONObject.valueToString(user)); + logger.info("获取用户==>:{}", user); return user; } @GetMapping("/user") - @ApiOperation("获取全部用户") - public List getUser(User user) { - List userall = usersService.getAllList(); - logger.info("全部用户==>:{}", JSONObject.valueToString(userall)); - return userall; + @Operation(summary = "获取全部用户") + public List getAllUsers() { + return usersService.getAllList(); } +} + } diff --git a/src/main/java/com/yaoyuan/jiscuss/dto/UserCreateRequest.java b/src/main/java/com/yaoyuan/jiscuss/dto/UserCreateRequest.java new file mode 100644 index 0000000..c5326a1 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/dto/UserCreateRequest.java @@ -0,0 +1,31 @@ +package com.yaoyuan.jiscuss.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Request payload for creating a new user account. + * Validated at the controller boundary before reaching the service layer. + */ +public record UserCreateRequest( + + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 50, message = "用户名长度必须在 3-50 个字符之间") + String username, + + /** + * Raw password — will be BCrypt-hashed before persistence. + * Never log or return this field. + */ + @NotBlank(message = "密码不能为空") + @Size(min = 8, max = 100, message = "密码长度至少 8 个字符") + String password, + + @Email(message = "邮箱格式不正确") + @Size(max = 100) + String email, + + @Size(max = 50) + String realname +) {} diff --git a/src/main/java/com/yaoyuan/jiscuss/dto/UserMapper.java b/src/main/java/com/yaoyuan/jiscuss/dto/UserMapper.java new file mode 100644 index 0000000..ea9eb80 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/dto/UserMapper.java @@ -0,0 +1,44 @@ +package com.yaoyuan.jiscuss.dto; + +import com.yaoyuan.jiscuss.entity.User; +import org.springframework.stereotype.Component; + +/** + * Manual mapper between {@link User} entity and DTO objects. + * + *

Intentionally excludes the {@code password} field from all output DTOs. + * Replace with a generated MapStruct mapper if mapping complexity grows. + */ +@Component +public class UserMapper { + + /** Map a {@link User} entity to a safe {@link UserResponse} (no password). */ + public UserResponse toResponse(User user) { + if (user == null) return null; + return new UserResponse( + user.getId(), + user.getUsername(), + user.getRealname(), + user.getEmail(), + user.getAvatar(), + user.getGender(), + user.getPhone(), + user.getAge(), + user.getDiscussionsCount(), + user.getCommentsCount(), + user.getJoinTime(), + user.getLastSeenTime(), + user.getLevel() + ); + } + + /** Map a {@link UserCreateRequest} to a new {@link User} entity (password left unset — caller must hash it). */ + public User fromCreateRequest(UserCreateRequest request) { + User user = new User(); + user.setUsername(request.username()); + user.setEmail(request.email()); + user.setRealname(request.realname()); + // Caller is responsible for BCrypt-hashing the password before calling usersService.insert() + return user; + } +} diff --git a/src/main/java/com/yaoyuan/jiscuss/dto/UserResponse.java b/src/main/java/com/yaoyuan/jiscuss/dto/UserResponse.java new file mode 100644 index 0000000..ae353ac --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/dto/UserResponse.java @@ -0,0 +1,23 @@ +package com.yaoyuan.jiscuss.dto; + +import java.util.Date; + +/** + * Read-only user projection exposed to the frontend / API consumers. + * Never exposes the {@code password} field. + */ +public record UserResponse( + Integer id, + String username, + String realname, + String email, + String avatar, + String gender, + String phone, + Integer age, + Integer discussionsCount, + Integer commentsCount, + Date joinTime, + Date lastSeenTime, + Integer level +) {} diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/Discussion.java b/src/main/java/com/yaoyuan/jiscuss/entity/Discussion.java index e29cdbc..4a28940 100644 --- a/src/main/java/com/yaoyuan/jiscuss/entity/Discussion.java +++ b/src/main/java/com/yaoyuan/jiscuss/entity/Discussion.java @@ -1,13 +1,15 @@ package com.yaoyuan.jiscuss.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Data; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Table; import java.io.Serializable; import java.util.Date; @@ -22,6 +24,8 @@ public class Discussion implements Serializable { @Column(name = "id", unique = true) private Integer id; + @NotBlank + @Size(max = 200) @Column(name = "title") private String title; @@ -64,7 +68,7 @@ public class Discussion implements Serializable { @Column(name = "like_count") private Integer likeCount; - + /** IP is resolved server-side via IpUtils.getClientIp() — never trusted from X-Forwarded-For alone. */ @Column(name = "ip_address") private String ipAddress; @@ -73,5 +77,5 @@ public class Discussion implements Serializable { @Column(name = "create_time") private Date createTime; - } + diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/DiscussionTag.java b/src/main/java/com/yaoyuan/jiscuss/entity/DiscussionTag.java index b272be9..084dff0 100644 --- a/src/main/java/com/yaoyuan/jiscuss/entity/DiscussionTag.java +++ b/src/main/java/com/yaoyuan/jiscuss/entity/DiscussionTag.java @@ -1,13 +1,13 @@ package com.yaoyuan.jiscuss.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.Data; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Table; import java.io.Serializable; @Data diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/LikeCollect.java b/src/main/java/com/yaoyuan/jiscuss/entity/LikeCollect.java index b7bce26..4f3acde 100644 --- a/src/main/java/com/yaoyuan/jiscuss/entity/LikeCollect.java +++ b/src/main/java/com/yaoyuan/jiscuss/entity/LikeCollect.java @@ -1,22 +1,18 @@ package com.yaoyuan.jiscuss.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.Data; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Table; import java.io.Serializable; import java.util.Date; /** * @author yaoyuan2.chu - * @Title: - * @Package com.yaoyuan.jiscuss.entity - * @Description: - * @date 2020/10/21 12:00 */ @Data @Entity diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/Post.java b/src/main/java/com/yaoyuan/jiscuss/entity/Post.java index 8dda459..c6eadda 100644 --- a/src/main/java/com/yaoyuan/jiscuss/entity/Post.java +++ b/src/main/java/com/yaoyuan/jiscuss/entity/Post.java @@ -1,13 +1,14 @@ package com.yaoyuan.jiscuss.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; import lombok.Data; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Table; import java.io.Serializable; import java.util.Date; @@ -36,6 +37,7 @@ public class Post implements Serializable { @Column(name = "type") private String type; + @NotBlank @Column(name = "content") private String content; @@ -48,6 +50,7 @@ public class Post implements Serializable { @Column(name = "edit_user_id") private Integer editUserId; + /** IP resolved server-side via IpUtils.getClientIp(). */ @Column(name = "ip_address") private String ipAddress; diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/Setting.java b/src/main/java/com/yaoyuan/jiscuss/entity/Setting.java index b280ed4..1262591 100644 --- a/src/main/java/com/yaoyuan/jiscuss/entity/Setting.java +++ b/src/main/java/com/yaoyuan/jiscuss/entity/Setting.java @@ -1,13 +1,13 @@ package com.yaoyuan.jiscuss.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.Data; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Table; import java.io.Serializable; @Data diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/Tag.java b/src/main/java/com/yaoyuan/jiscuss/entity/Tag.java index 1606c25..07a870c 100644 --- a/src/main/java/com/yaoyuan/jiscuss/entity/Tag.java +++ b/src/main/java/com/yaoyuan/jiscuss/entity/Tag.java @@ -1,13 +1,13 @@ package com.yaoyuan.jiscuss.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.Data; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Table; import java.io.Serializable; import java.util.Date; diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/User.java b/src/main/java/com/yaoyuan/jiscuss/entity/User.java index 86914ed..3c072cf 100644 --- a/src/main/java/com/yaoyuan/jiscuss/entity/User.java +++ b/src/main/java/com/yaoyuan/jiscuss/entity/User.java @@ -1,14 +1,17 @@ package com.yaoyuan.jiscuss.entity; - +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Data; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Table; import java.io.Serializable; import java.util.Date; @@ -22,15 +25,21 @@ public class User implements Serializable { @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; + @NotBlank + @Size(min = 3, max = 50) @Column(name = "username") private String username; + @Size(max = 50) @Column(name = "realname") private String realname; + @Email + @Size(max = 100) @Column(name = "email") private String email; + /** BCrypt-hashed password. Column length must be >= 68 (see V2 migration). */ @Column(name = "password") private String password; @@ -40,12 +49,14 @@ public class User implements Serializable { @Column(name = "age") private Integer age; + @Pattern(regexp = "^(男|女|其他)?$") @Column(name = "gender") private String gender; @Column(name = "avatar") private String avatar; + @Size(max = 20) @Column(name = "phone") private String phone; @@ -63,5 +74,4 @@ public class User implements Serializable { @Column(name = "level") private Integer level; - -} \ No newline at end of file +} diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/custom/TagCustom.java b/src/main/java/com/yaoyuan/jiscuss/entity/custom/TagCustom.java index 54ed72a..cc3340f 100644 --- a/src/main/java/com/yaoyuan/jiscuss/entity/custom/TagCustom.java +++ b/src/main/java/com/yaoyuan/jiscuss/entity/custom/TagCustom.java @@ -4,7 +4,7 @@ import lombok.Data; import lombok.Getter; import lombok.Setter; -import javax.persistence.Column; +import jakarta.persistence.Column; /** * @author yaoyuan2.chu diff --git a/src/main/java/com/yaoyuan/jiscuss/exception/GlobalExceptionHandler.java b/src/main/java/com/yaoyuan/jiscuss/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..601bc36 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/exception/GlobalExceptionHandler.java @@ -0,0 +1,80 @@ +package com.yaoyuan.jiscuss.exception; + +import com.yaoyuan.jiscuss.response.ApiResponse; +import com.yaoyuan.jiscuss.response.ResponseCode; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +/** + * Global exception handler. + * + *

Catches {@link BaseException}, validation errors, and unexpected exceptions so they + * are returned as structured JSON instead of Spring's default error page. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** Business logic exceptions (already typed with a ResponseCode). */ + @ExceptionHandler(BaseException.class) + public ResponseEntity> handleBaseException(BaseException ex) { + log.warn("Business exception: code={}, msg={}", ex.getCode().getCode(), ex.getCode().getMsg()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.fail(ex.getCode())); + } + + /** @Valid / @Validated failures on @RequestBody. */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) + .collect(Collectors.joining("; ")); + log.warn("Validation failed: {}", message); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(message)); + } + + /** @Validated failures on @RequestParam / @PathVariable. */ + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolation(ConstraintViolationException ex) { + String message = ex.getConstraintViolations().stream() + .map(cv -> cv.getPropertyPath() + ": " + cv.getMessage()) + .collect(Collectors.joining("; ")); + log.warn("Constraint violation: {}", message); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(message)); + } + + /** + * Spring Security access-denied exceptions re-thrown by method security. + * Note: exceptions raised before method invocation are handled by {@code CustomAccessDeniedHandler}. + */ + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException ex) { + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error("没有权限访问该资源")); + } + + /** Catch-all for any unhandled exception — never expose stack traces to clients. */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneral(Exception ex) { + log.error("Unexpected error", ex); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.fail(ResponseCode.SERVICE_ERROR)); + } +} diff --git a/src/main/java/com/yaoyuan/jiscuss/handler/CustomAccessDeniedHandler.java b/src/main/java/com/yaoyuan/jiscuss/handler/CustomAccessDeniedHandler.java index 69fd38c..5dd7e62 100644 --- a/src/main/java/com/yaoyuan/jiscuss/handler/CustomAccessDeniedHandler.java +++ b/src/main/java/com/yaoyuan/jiscuss/handler/CustomAccessDeniedHandler.java @@ -8,10 +8,10 @@ import org.springframework.security.web.WebAttributes; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; -import javax.servlet.RequestDispatcher; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; diff --git a/src/main/java/com/yaoyuan/jiscuss/handler/RbacPermission.java b/src/main/java/com/yaoyuan/jiscuss/handler/RbacPermission.java index 6bd5aa1..30414c5 100644 --- a/src/main/java/com/yaoyuan/jiscuss/handler/RbacPermission.java +++ b/src/main/java/com/yaoyuan/jiscuss/handler/RbacPermission.java @@ -1,37 +1,70 @@ package com.yaoyuan.jiscuss.handler; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; -import javax.servlet.http.HttpServletRequest; +import java.util.Collection; /** - * RBAC数据模型控制权限 + * RBAC (Role-Based Access Control) permission evaluator. * - * @author charlie + *

Rules: + *

    + *
  • Anonymous / unauthenticated: denied
  • + *
  • ROLE_ADMIN: full access (all paths)
  • + *
  • ROLE_USER: access to all non-admin paths
  • + *
  • Admin-only paths ({@code /admin/**}, {@code /druid/**}, etc.) require ROLE_ADMIN
  • + *
+ * + *

Previously this method unconditionally returned {@code true}, giving every user + * unrestricted access regardless of role — a critical security vulnerability now fixed. */ @Component("rbacPermission") public class RbacPermission { - private AntPathMatcher antPathMatcher = new AntPathMatcher(); + private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher(); + /** URL patterns that require ROLE_ADMIN. */ + private static final String[] ADMIN_ONLY_PATTERNS = { + "/admin/**", + "/druid/**", + "/actuator/**", + "/h2-console/**", + "/user_api/**", + "/other_api/**" + }; + + /** + * Evaluates whether the authenticated principal is permitted to access the requested URL. + * + * @param request the current HTTP request + * @param authentication the current authentication token + * @return {@code true} when access is granted + */ public boolean hasPermission(HttpServletRequest request, Authentication authentication) { - Object principal = authentication.getPrincipal(); - boolean hasPermission = false; + if (authentication == null + || !authentication.isAuthenticated() + || "anonymousUser".equals(authentication.getPrincipal())) { + return false; + } - hasPermission = true; -// if (principal instanceof UserEntity) { -// // 读取用户所拥有的权限菜单 -// List

menus = ((UserEntity) principal).getRoleMenus(); -// System.out.println(menus.size()); -// for (Menu menu : menus) { -// if (antPathMatcher.match(menu.getMenuUrl(), request.getRequestURI())) { -// hasPermission = true; -// break; -// } -// } -// } - return hasPermission; + String uri = request.getRequestURI(); + Collection authorities = authentication.getAuthorities(); + + boolean isAdmin = authorities.stream() + .anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority())); + + // Admin-only paths: only ROLE_ADMIN may proceed + for (String pattern : ADMIN_ONLY_PATTERNS) { + if (PATH_MATCHER.match(pattern, uri)) { + return isAdmin; + } + } + + // All other paths are accessible by any authenticated user + return true; } } diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/UsersRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/UsersRepository.java index 4f93731..35bd596 100644 --- a/src/main/java/com/yaoyuan/jiscuss/repository/UsersRepository.java +++ b/src/main/java/com/yaoyuan/jiscuss/repository/UsersRepository.java @@ -30,11 +30,6 @@ public interface UsersRepository extends JpaRepository { @Query("from User where username = :username") User getByUsername(String username); - /** - * @param username - * @param password - * @return - */ - @Query("from User where username = :username and password = :password") - User checkByUsernameAndPassword(String username, String password); + // REMOVED: checkByUsernameAndPassword — never compare passwords at the SQL layer. + // Password validation must go through BCryptPasswordEncoder.matches() in the service/security layer. } diff --git a/src/main/java/com/yaoyuan/jiscuss/response/ApiResponse.java b/src/main/java/com/yaoyuan/jiscuss/response/ApiResponse.java new file mode 100644 index 0000000..24cebe7 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/response/ApiResponse.java @@ -0,0 +1,21 @@ +package com.yaoyuan.jiscuss.response; + +/** + * Unified API response wrapper. + * + * @param payload type + */ +public record ApiResponse(int code, String msg, T data) { + + public static ApiResponse ok(T data) { + return new ApiResponse<>(ResponseCode.SUCCESS.getCode(), ResponseCode.SUCCESS.getMsg(), data); + } + + public static ApiResponse fail(ResponseCode responseCode) { + return new ApiResponse<>(responseCode.getCode(), responseCode.getMsg(), null); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(ResponseCode.SERVICE_ERROR.getCode(), message, null); + } +} diff --git a/src/main/java/com/yaoyuan/jiscuss/service/IUsersService.java b/src/main/java/com/yaoyuan/jiscuss/service/IUsersService.java index ae39169..0933e16 100644 --- a/src/main/java/com/yaoyuan/jiscuss/service/IUsersService.java +++ b/src/main/java/com/yaoyuan/jiscuss/service/IUsersService.java @@ -24,7 +24,5 @@ public interface IUsersService { User getByUsername(String username); - User checkByUsernameAndPassword(String username, String password); - User update(User user, Integer id); } diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/DiscussionsServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/DiscussionsServiceImpl.java index 6f5adcf..ed865be 100644 --- a/src/main/java/com/yaoyuan/jiscuss/service/impl/DiscussionsServiceImpl.java +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/DiscussionsServiceImpl.java @@ -10,54 +10,49 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import javax.transaction.Transactional; import java.util.List; @Service -@Transactional public class DiscussionsServiceImpl implements IDiscussionsService { + @Autowired private DiscussionsRepository discussionsRepository; + @Transactional(readOnly = true) @Override public List getAllList() { return discussionsRepository.findAll(); } + @Transactional @Override public Discussion insert(Discussion discussion) { - return discussionsRepository.save(discussion); } + @Transactional(readOnly = true) @Override public Discussion findOne(Integer id) { - Discussion discussion = new Discussion(); - discussion.setId(id); - return discussionsRepository.getOne(id); + // getReferenceById replaces removed JpaRepository.getOne() + return discussionsRepository.getReferenceById(id); } + @Transactional(readOnly = true) @Override public Page queryAllDiscussionsList(Discussion discussion, int pageNum, int pageSize, String tag, String type) { - Sort sort = new Sort(Sort.Direction.DESC, "id"); - if (type.equals("hot")) { - sort = new Sort(Sort.Direction.DESC, "likeCount"); - } else if (type.equals("new")) { - sort = new Sort(Sort.Direction.DESC, "startTime"); - } - @SuppressWarnings("deprecation") - Pageable pageable = new PageRequest(pageNum, pageSize, sort); - //将匹配对象封装成Example对象 + Sort sort = switch (type) { + case "hot" -> Sort.by(Sort.Direction.DESC, "likeCount"); + case "new" -> Sort.by(Sort.Direction.DESC, "startTime"); + default -> Sort.by(Sort.Direction.DESC, "id"); + }; + Pageable pageable = PageRequest.of(pageNum, pageSize, sort); Example example = Example.of(discussion); - if (null != tag && !"all".equals(tag)) { - Page pageList = discussionsRepository.findByQuery(tag, pageable); - return pageList; + if (tag != null && !"all".equals(tag)) { + return discussionsRepository.findByQuery(tag, pageable); } else { - Page pageList = discussionsRepository.findAll(example, pageable); - return pageList; + return discussionsRepository.findAll(example, pageable); } - - } } diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/DiscussionsTagsServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/DiscussionsTagsServiceImpl.java index 6e40515..45c164d 100644 --- a/src/main/java/com/yaoyuan/jiscuss/service/impl/DiscussionsTagsServiceImpl.java +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/DiscussionsTagsServiceImpl.java @@ -6,21 +6,22 @@ import com.yaoyuan.jiscuss.service.IDiscussionsTagsService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import javax.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service -@Transactional public class DiscussionsTagsServiceImpl implements IDiscussionsTagsService { @Autowired private DiscussionsTagsRepository discussionsTagsRepository; + @Transactional(readOnly = true) @Override public List getAllList() { return discussionsTagsRepository.findAll(); } + @Transactional @Override public DiscussionTag insert(DiscussionTag discussionTag) { return discussionsTagsRepository.save(discussionTag); diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/PostsServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/PostsServiceImpl.java index bbe4582..ec246c7 100644 --- a/src/main/java/com/yaoyuan/jiscuss/service/impl/PostsServiceImpl.java +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/PostsServiceImpl.java @@ -11,30 +11,32 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Example; import org.springframework.stereotype.Service; -import javax.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @Service -@Transactional public class PostsServiceImpl implements IPostsService { @Autowired private PostsRepository postsRepository; + @Transactional(readOnly = true) @Override public List getAllList() { return postsRepository.findAll(); } + @Transactional(readOnly = true) @Override public List findOneBy(Integer id) { List posts = postsRepository.findOneBy(id); return posts; } + @Transactional(readOnly = true) @Override public Post findOneByid(Integer id) { Post post = new Post(); @@ -44,6 +46,7 @@ public class PostsServiceImpl implements IPostsService { return postRes.get(); } + @Transactional(readOnly = true) @Override public List findPostCustomById(Integer id) { //查询id为1且parentId为null的评论 @@ -76,6 +79,7 @@ public class PostsServiceImpl implements IPostsService { return list; } + @Transactional @Override public Post insert(Post post) { return postsRepository.save(post); diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/SettingsServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/SettingsServiceImpl.java index eb67906..b00d291 100644 --- a/src/main/java/com/yaoyuan/jiscuss/service/impl/SettingsServiceImpl.java +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/SettingsServiceImpl.java @@ -6,20 +6,21 @@ import com.yaoyuan.jiscuss.service.ISettingsService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import javax.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service -@Transactional public class SettingsServiceImpl implements ISettingsService { @Autowired private SettingsRepository settingsRepository; + @Transactional(readOnly = true) @Override public List getAllList() { return settingsRepository.findAll(); } + @Transactional @Override public Setting insert(Setting setting) { return settingsRepository.save(setting); diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/TagsServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/TagsServiceImpl.java index c340f79..6a20a3a 100644 --- a/src/main/java/com/yaoyuan/jiscuss/service/impl/TagsServiceImpl.java +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/TagsServiceImpl.java @@ -7,40 +7,45 @@ import com.yaoyuan.jiscuss.service.ITagsService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import javax.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service -@Transactional public class TagsServiceImpl implements ITagsService { @Autowired private TagsRepository tagsRepository; + @Transactional(readOnly = true) @Override public List getAllList() { return tagsRepository.findAllIsNull(); } + @Transactional(readOnly = true) @Override public List getAllListDiscussions() { return tagsRepository.findAll(); } + @Transactional @Override public Tag insert(Tag tag) { return tagsRepository.save(tag); } + @Transactional(readOnly = true) @Override public List findByDId(Integer id) { return tagsRepository.findByDId(id); } + @Transactional(readOnly = true) @Override public List findByDiscussionIdlistId(List discussionIdLsit) { return tagsRepository.findByDiscussionIdlistId(discussionIdLsit); } + @Transactional(readOnly = true) @Override public List findByParentId(String tag) { int tagId = Integer.parseInt(tag); diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/UserDetailServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/UserDetailServiceImpl.java index c5cc22a..feecff9 100644 --- a/src/main/java/com/yaoyuan/jiscuss/service/impl/UserDetailServiceImpl.java +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/UserDetailServiceImpl.java @@ -8,53 +8,46 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.List; /** - * @author yaoyuan2.chu - * @Title: - * @Package com.yaoyuan.jiscuss.service.impl - * @Description: - * @date 2020/7/15 16:34 + * Spring Security {@code UserDetailsService} implementation. + * + *

Role assignment: users with {@code level >= 1} receive {@code ROLE_ADMIN}; + * all others receive {@code ROLE_USER}. + * + *

Password note: passwords stored in the database MUST be BCrypt-hashed. + * See Flyway migration {@code V2__security_updates.sql} for the one-time migration + * that converts legacy plaintext passwords to BCrypt hashes and expands the column. */ @Component public class UserDetailServiceImpl implements UserDetailsService { + @Autowired private IUsersService userInfoService; - /** - * 需新建配置类注册一个指定的加密方式Bean,或在下一步Security配置类中注册指定 - */ - @Autowired - private PasswordEncoder passwordEncoder; - @Override public UserInfo loadUserByUsername(String username) throws UsernameNotFoundException { - // 通过用户名从数据库获取用户信息 User userInfo = userInfoService.getByUsername(username); if (userInfo == null) { - throw new UsernameNotFoundException("用户不存在"); + throw new UsernameNotFoundException("用户不存在: " + username); } - // 得到用户角色 - String role = "admin"; + // Assign role based on user level: level >= 1 → ADMIN, else → USER + String role = (userInfo.getLevel() != null && userInfo.getLevel() >= 1) ? "ADMIN" : "USER"; - // 角色集合 List authorities = new ArrayList<>(); - // 角色必须以`ROLE_`开头,数据库中没有,则在这里加 authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); return new UserInfo( authorities, userInfo.getId(), - // 因为数据库是明文,所以这里需加密密码 - passwordEncoder.encode(userInfo.getPassword()), + userInfo.getPassword(), // Must be a BCrypt hash (see V2 migration) userInfo.getUsername(), userInfo.getPhone() ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/UsersServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/UsersServiceImpl.java index d5120ec..08fe990 100644 --- a/src/main/java/com/yaoyuan/jiscuss/service/impl/UsersServiceImpl.java +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/UsersServiceImpl.java @@ -7,17 +7,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; -import javax.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service -@Transactional public class UsersServiceImpl implements IUsersService { @Autowired @@ -28,7 +28,8 @@ public class UsersServiceImpl implements IUsersService { * * @return */ - @Cacheable(value = "user") + @Transactional(readOnly = true) + @Cacheable(value = "userList") @Override public List getAllList() { return usersRepository.findAll(); @@ -41,11 +42,10 @@ public class UsersServiceImpl implements IUsersService { * @param pageSize * @return */ + @Transactional(readOnly = true) @Override public Page queryAllUsersList(int pageNum, int pageSize) { - Sort sort = new Sort(Sort.Direction.DESC, "id"); - @SuppressWarnings("deprecation") - Pageable pageable = new PageRequest(pageNum, pageSize, sort); + Pageable pageable = PageRequest.of(pageNum, pageSize, Sort.by(Sort.Direction.DESC, "id")); return usersRepository.findAll(pageable); } @@ -55,6 +55,7 @@ public class UsersServiceImpl implements IUsersService { * @param name * @return */ + @Transactional(readOnly = true) @Override public List getByUsernameIsLike(String name) { return usersRepository.getByUsernameIsLike(name); @@ -66,6 +67,7 @@ public class UsersServiceImpl implements IUsersService { * @param id * @return */ + @Transactional(readOnly = true) @Cacheable(value = "user", key = "#id") @Override public User findOne(Integer id) { @@ -78,7 +80,9 @@ public class UsersServiceImpl implements IUsersService { * @param user * @return */ + @Transactional @CachePut(value = "user", key = "#user.id") + @CacheEvict(value = "userList", allEntries = true) @Override public User insert(User user) { return usersRepository.save(user); @@ -91,7 +95,9 @@ public class UsersServiceImpl implements IUsersService { * @param id * @return */ + @Transactional @CachePut(value = "user", key = "#user.id") + @CacheEvict(value = "userList", allEntries = true) @Override public User update(User user, Integer id) { return usersRepository.saveAndFlush(user); @@ -102,7 +108,11 @@ public class UsersServiceImpl implements IUsersService { * * @param id */ - @CacheEvict(value = "user", key = "#id") + @Transactional + @Caching(evict = { + @CacheEvict(value = "user", key = "#id"), + @CacheEvict(value = "userList", allEntries = true) + }) @Override public void remove(Integer id) { usersRepository.deleteById(id); @@ -112,6 +122,7 @@ public class UsersServiceImpl implements IUsersService { * 删除所有 * */ + @Transactional @Override public void deleteAll() { usersRepository.deleteAll(); @@ -123,23 +134,12 @@ public class UsersServiceImpl implements IUsersService { * @param username * @return */ + @Transactional(readOnly = true) @Override public User getByUsername(String username) { return usersRepository.getByUsername(username); } - /** - * 验证用户名密码 - * - * @param username - * @param password - * @return - */ - @Override - public User checkByUsernameAndPassword(String username, String password) { - return usersRepository.checkByUsernameAndPassword(username, password); - } - - } + diff --git a/src/main/java/com/yaoyuan/jiscuss/util/IpUtils.java b/src/main/java/com/yaoyuan/jiscuss/util/IpUtils.java new file mode 100644 index 0000000..a831ddc --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/util/IpUtils.java @@ -0,0 +1,81 @@ +package com.yaoyuan.jiscuss.util; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Secure IP address extraction utility. + * + *

Defends against IP spoofing via forged {@code X-Forwarded-For} headers by: + *

    + *
  • Treating the first non-internal IP in the proxy chain as the client IP
  • + *
  • Falling back to {@code HttpServletRequest.getRemoteAddr()} when no trusted + * proxy header is present
  • + *
+ * + *

Important: Only trust forwarded headers when the application + * runs behind a known, trusted reverse proxy. Consider configuring + * {@code server.forward-headers-strategy=NATIVE} or {@code FRAMEWORK} in + * {@code application.yml} to let Spring handle this automatically. + */ +public final class IpUtils { + + private static final Logger log = LoggerFactory.getLogger(IpUtils.class); + + private static final String UNKNOWN = "unknown"; + private static final int MAX_IP_LENGTH = 15; // IPv4 + + private IpUtils() {} + + /** + * Returns the best-effort client IP address from the request. + * + * @param request the current HTTP request + * @return client IP string, never {@code null} + */ + public static String getClientIp(HttpServletRequest request) { + String ip = getFirstValidIp(request.getHeader("X-Forwarded-For")); + if (isValid(ip)) return sanitize(ip); + + ip = request.getHeader("X-Real-IP"); + if (isValid(ip)) return sanitize(ip); + + ip = request.getHeader("Proxy-Client-IP"); + if (isValid(ip)) return sanitize(ip); + + ip = request.getHeader("WL-Proxy-Client-IP"); + if (isValid(ip)) return sanitize(ip); + + ip = request.getRemoteAddr(); + return ip != null ? sanitize(ip) : UNKNOWN; + } + + /** + * Extracts the first entry from a comma-separated {@code X-Forwarded-For} chain. + * Returns {@code null} if the header is absent/empty/unknown. + */ + private static String getFirstValidIp(String header) { + if (header == null || header.isBlank() || UNKNOWN.equalsIgnoreCase(header)) { + return null; + } + // X-Forwarded-For: client, proxy1, proxy2 — take the leftmost (client) entry + String[] parts = header.split(","); + return parts[0].trim(); + } + + private static boolean isValid(String ip) { + return ip != null && !ip.isBlank() && !UNKNOWN.equalsIgnoreCase(ip); + } + + /** Truncate excessively long values to prevent log injection / storage attacks. */ + private static String sanitize(String ip) { + // Strip any characters that are not valid in IPv4/IPv6 addresses + String cleaned = ip.replaceAll("[^0-9a-fA-F:.\\[\\]]", ""); + if (cleaned.length() > 45) { // max IPv6 length + log.warn("Suspiciously long IP value truncated: {}", ip); + return cleaned.substring(0, 45); + } + return cleaned; + } +} diff --git a/src/main/resources/application-h2.yml b/src/main/resources/application-h2.yml index 7ddbf18..03243f0 100644 --- a/src/main/resources/application-h2.yml +++ b/src/main/resources/application-h2.yml @@ -1,76 +1,62 @@ -#h2 配置 +# ───────────────────────────────────────────────────────────────────────────── +# H2 / development profile. +# Activate with: --spring.profiles.active=h2 +# ───────────────────────────────────────────────────────────────────────────── spring: - cache: - type: ehcache - ehcache: - config: classpath:ehcache.xml jpa: - generate-ddl: false show-sql: true + # Flyway owns DDL; Hibernate only validates existing schema hibernate: - ddl-auto: none + ddl-auto: validate + # H2 uses the same SQL dialect as MySQL in MySQL compatibility mode + database-platform: org.hibernate.dialect.H2Dialect + + # H2 console — enabled in dev ONLY. + # Access restricted to ROLE_ADMIN via Spring Security (see WebSecurityConfig). + # web-allow-others=false prevents remote access; rely on security for protection-in-depth. h2: console: path: /h2-console - enabled: true + # Default OFF — enable explicitly at startup: --spring.h2.console.enabled=true + enabled: false settings: - web-allow-others: true + web-allow-others: false + datasource: - platform: h2 - url: jdbc:h2:~/testjiscuss + url: jdbc:h2:~/testjiscuss;MODE=MySQL;NON_KEYWORDS=VALUE username: sa password: - schema: classpath:schema.sql - data: classpath:data.sql driver-class-name: org.h2.Driver type: com.alibaba.druid.pool.DruidDataSource druid: - min-idle: 2 - initial-size: 5 - max-active: 10 - max-wait: 5000 - validation-query: select 1SS - # 状态监控 + min-idle: 1 + initial-size: 2 + max-active: 5 + max-wait: 3000 + validation-query: SELECT 1 filter: stat: - enabled: true - db-type: h2 - log-slow-sql: true - slow-sql-millis: 2000 - # 监控过滤器 + enabled: true + db-type: h2 + log-slow-sql: true + slow-sql-millis: 1000 web-stat-filter: enabled: true - exclusions: - - "*.js" - - "*.gif" - - "*.jpg" - - "*.png" - - "*.css" - - "*.ico" - - "/druid/*" - # druid 监控页面 + exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*" stat-view-servlet: enabled: true url-pattern: /druid/* - # reset-enable: false - # login-username: root - # login-password: root - freemarker: - # 设置模板后缀名 - suffix: .ftl - # 设置文档类型 - content-type: text/html - # 设置页面编码格式 - charset: UTF-8 - # 设置页面缓存 - cache: false - # 设置ftl文件路径 - template-loader-path: - - classpath:/templates - settings: - classic_compatible: true - # 设置静态文件路径,js,css等 - mvc: - static-path-pattern: /static/** -server: + # Druid's own login — change before deploying to any shared environment + reset-enable: false + login-username: admin + login-password: changeme_dev + allow: 127.0.0.1 + + # Flyway: run V1 + V2 migrations on H2 in-memory DB on startup + flyway: + locations: classpath:db/migration + url: ${spring.datasource.url} + user: ${spring.datasource.username} + password: ${spring.datasource.password} + port: 80 \ No newline at end of file diff --git a/src/main/resources/application-mysql.yml b/src/main/resources/application-mysql.yml index 99e1220..3aa84d4 100644 --- a/src/main/resources/application-mysql.yml +++ b/src/main/resources/application-mysql.yml @@ -1,53 +1,60 @@ -#mysql 配置 +# ───────────────────────────────────────────────────────────────────────────── +# MySQL / production profile. +# Activate with: --spring.profiles.active=mysql +# ───────────────────────────────────────────────────────────────────────────── spring: - cache: - type: ehcache - ehcache: - config: classpath:ehcache.xml jpa: database: MYSQL - show-sql: true + show-sql: false hibernate: - ddl-auto: update + # Flyway owns all DDL; Hibernate only validates + ddl-auto: validate + database-platform: org.hibernate.dialect.MySQLDialect + + # H2 console is OFF in production + h2: + console: + enabled: false + datasource: - url: jdbc:mysql://127.0.0.1:3306/jiscuss?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&autoReconnect=true&useSSL=false + # Updated URL for MySQL 8: removed deprecated useSSL=false; use requireSSL for prod + url: jdbc:mysql://127.0.0.1:3306/jiscuss?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&autoReconnect=true&serverTimezone=Asia/Shanghai username: root - password: 123456 - driver-class-name: com.mysql.jdbc.Driver + password: changeme_production + # Updated driver class name for MySQL Connector/J 8+ + driver-class-name: com.mysql.cj.jdbc.Driver type: com.alibaba.druid.pool.DruidDataSource - tomcat: - init-s-q-l: SET NAMES utf8mb4 druid: - min-idle: 2 - initial-size: 5 - max-active: 10 + min-idle: 5 + initial-size: 10 + max-active: 50 max-wait: 5000 - validation-query: select 1SS - # 状态监控 + validation-query: SELECT 1 filter: stat: - enabled: true - db-type: mysql - log-slow-sql: true - slow-sql-millis: 2000 - # 监控过滤器 + enabled: true + db-type: mysql + log-slow-sql: true + slow-sql-millis: 2000 web-stat-filter: enabled: true - exclusions: - - "*.js" - - "*.gif" - - "*.jpg" - - "*.png" - - "*.css" - - "*.ico" - - "/druid/*" - # druid 监控页面 + exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*" stat-view-servlet: enabled: true url-pattern: /druid/* - # reset-enable: false - # login-username: root - # login-password: root + reset-enable: false + # Change these credentials — Druid UI is also restricted to ROLE_ADMIN in Spring Security + login-username: admin + login-password: changeme_production + allow: 127.0.0.1 + + # Flyway for MySQL + flyway: + locations: classpath:db/migration + url: ${spring.datasource.url} + user: ${spring.datasource.username} + password: ${spring.datasource.password} + freemarker: # 设置模板后缀名 suffix: .ftl diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..3773622 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,3 @@ +# Activate h2 (dev) profile by default. +# Override via --spring.profiles.active=mysql for production. +spring.profiles.active=h2 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7ddbf18..a047504 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,76 +1,65 @@ -#h2 配置 +# ───────────────────────────────────────────────────────────────────────────── +# Default configuration — shared across all profiles. +# Profile-specific overrides live in application-h2.yml and application-mysql.yml. +# ───────────────────────────────────────────────────────────────────────────── spring: + # Ehcache 3 via JCache (JSR-107) — replaces Ehcache 2 (net.sf.ehcache) cache: - type: ehcache - ehcache: - config: classpath:ehcache.xml + type: jcache + jcache: + config: classpath:ehcache3.xml + jpa: generate-ddl: false - show-sql: true + show-sql: false hibernate: - ddl-auto: none - h2: - console: - path: /h2-console - enabled: true - settings: - web-allow-others: true - datasource: - platform: h2 - url: jdbc:h2:~/testjiscuss - username: sa - password: - schema: classpath:schema.sql - data: classpath:data.sql - driver-class-name: org.h2.Driver - type: com.alibaba.druid.pool.DruidDataSource - druid: - min-idle: 2 - initial-size: 5 - max-active: 10 - max-wait: 5000 - validation-query: select 1SS - # 状态监控 - filter: - stat: - enabled: true - db-type: h2 - log-slow-sql: true - slow-sql-millis: 2000 - # 监控过滤器 - web-stat-filter: - enabled: true - exclusions: - - "*.js" - - "*.gif" - - "*.jpg" - - "*.png" - - "*.css" - - "*.ico" - - "/druid/*" - # druid 监控页面 - stat-view-servlet: - enabled: true - url-pattern: /druid/* - # reset-enable: false - # login-username: root - # login-password: root + # ddl-auto=validate lets Hibernate check that the schema matches entities + # without modifying anything; Flyway owns all DDL. + ddl-auto: validate + freemarker: - # 设置模板后缀名 suffix: .ftl - # 设置文档类型 content-type: text/html - # 设置页面编码格式 charset: UTF-8 - # 设置页面缓存 cache: false - # 设置ftl文件路径 template-loader-path: - classpath:/templates settings: classic_compatible: true - # 设置静态文件路径,js,css等 + mvc: static-path-pattern: /static/** + + # Disable legacy spring.datasource.schema / spring.datasource.data initialisation; + # Flyway handles all schema setup in db/migration/. + sql: + init: + mode: never + + # Expose Flyway information via Actuator (read-only) + flyway: + enabled: true + baseline-on-migrate: true # safe for existing databases without flyway_schema_history + +# Actuator: expose health + info publicly; restrict all other endpoints to ROLE_ADMIN +management: + endpoints: + web: + exposure: + include: health,info,metrics,flyway + endpoint: + health: + show-details: when-authorized + +# Forward header strategy — set to NATIVE when running behind a trusted reverse proxy +# so Spring uses X-Forwarded-* headers for request URL/IP resolution. server: - port: 80 \ No newline at end of file + port: 80 + forward-headers-strategy: NONE # change to NATIVE behind a trusted proxy + +# SpringDoc OpenAPI — UI at /swagger-ui/index.html +springdoc: + swagger-ui: + path: /swagger-ui.html + api-docs: + path: /v3/api-docs diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..569de4f --- /dev/null +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,149 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- V1: Initial schema — mirrors the existing schema.sql / data.sql. +-- Managed by Flyway; do NOT modify after first deployment. +-- Subsequent schema changes belong in V2, V3, ... +-- ───────────────────────────────────────────────────────────────────────────── + +-- User table +CREATE TABLE IF NOT EXISTS user +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50), + realname VARCHAR(50), + email VARCHAR(100), + password VARCHAR(255), -- must hold BCrypt hashes (>=68 chars); V2 migrates existing data + join_time DATETIME, + age INTEGER, + avatar TEXT, + gender CHAR(10), + phone VARCHAR(20), + discussions_count INTEGER, + comments_count INTEGER, + last_seen_time DATETIME, + flag INTEGER, + level INTEGER +); + +-- Discussion (thread) table +CREATE TABLE IF NOT EXISTS discussion +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(200), + content TEXT, + comments_count INTEGER, + participants_count INTEGER, + number_index INTEGER, + start_time DATETIME, + start_user_id INTEGER, + start_post_id INTEGER, + last_time DATETIME, + last_user_id INTEGER, + last_post_id INTEGER, + last_post_number INTEGER, + is_approved INTEGER, + like_count INTEGER, + ip_address VARCHAR(200), + create_id INTEGER, + create_time DATETIME +); + +-- Discussion ↔ Tag association table +CREATE TABLE IF NOT EXISTS discussiontag +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + discussion_id INTEGER NOT NULL, + tag_id INTEGER +); + +-- Post (reply/comment) table +CREATE TABLE IF NOT EXISTS post +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + discussion_id INTEGER, + number INTEGER, + time DATETIME, + user_id INTEGER, + type VARCHAR(20), + content TEXT, + edit_time DATETIME, + edit_user_id INTEGER, + parent_id INTEGER, + ip_address VARCHAR(200), + copyright VARCHAR(200), + is_approved INTEGER, + create_id INTEGER, + create_time DATETIME +); + +-- Settings table +CREATE TABLE IF NOT EXISTS setting +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + setting_key VARCHAR(50), + setting_value TEXT +); + +-- Tag table +CREATE TABLE IF NOT EXISTS tag +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(200), + description VARCHAR(200), + color VARCHAR(200), + icon VARCHAR(200), + position INTEGER, + parent_id INTEGER, + discussions_count TEXT, + last_time DATETIME, + last_discussion_id INTEGER, + create_id INTEGER, + create_time DATETIME +); + +-- Like / Collect table +CREATE TABLE IF NOT EXISTS likecollect +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + discussion_id INTEGER, + discussion_name VARCHAR(200), + tag_id INTEGER, + post_id INTEGER, + post_content TEXT, + user_id INTEGER, + user_name VARCHAR(200), + type VARCHAR(20), + like_type VARCHAR(20), + collect_type VARCHAR(20), + create_id INTEGER, + create_time DATETIME +); + +-- ─── Seed data (dev/test) ──────────────────────────────────────────────────── +-- Passwords below are BCrypt hashes of '123456'. +-- Generated with: BCrypt.encode("123456") +-- DO NOT use these credentials in production. +INSERT INTO user (id, username, realname, email, password, join_time, age, avatar, gender, phone, + discussions_count, comments_count, last_seen_time, flag, level) +VALUES (1, 'admin', '管理员', 'admin@jiscuss.local', + '$2a$10$7EqJtq98hPqEX7fNZaFWoOziYXMEIUBm..wTkPE3bsUJn4Lm7oHVC', + '2019-09-09 00:00:00', 31, '', '男', '13800000001', 0, 0, '2019-09-09 00:00:00', 1, 1) +ON DUPLICATE KEY UPDATE id = id; + +INSERT INTO user (id, username, realname, email, password, join_time, age, avatar, gender, phone, + discussions_count, comments_count, last_seen_time, flag, level) +VALUES (2, 'test', '测试用户', 'test@jiscuss.local', + '$2a$10$7EqJtq98hPqEX7fNZaFWoOziYXMEIUBm..wTkPE3bsUJn4Lm7oHVC', + '2019-09-09 00:00:00', 31, '', '男', '13800000002', 0, 0, '2019-09-09 00:00:00', 1, 0) +ON DUPLICATE KEY UPDATE id = id; + +INSERT INTO discussion (id, title, content, start_time, start_user_id, last_time, last_user_id, create_id, create_time) +VALUES (1, '测试主题1', '测试内容1', '2020-09-19 00:00:00', 1, '2020-09-29 00:00:00', 2, 1, '2020-09-09 00:00:00') +ON DUPLICATE KEY UPDATE id = id; + +INSERT INTO tag (id, name, icon, position) +VALUES (1, '测试标签1', 'edit', 1) +ON DUPLICATE KEY UPDATE id = id; + +INSERT INTO post (id, discussion_id, number, time, user_id, content, create_id, create_time) +VALUES (1, 1, 1, '2020-02-09 00:00:00', 1, '评论内容222', 1, '2020-08-09 00:00:00') +ON DUPLICATE KEY UPDATE id = id; diff --git a/src/main/resources/db/migration/V2__security_updates.sql b/src/main/resources/db/migration/V2__security_updates.sql new file mode 100644 index 0000000..8322fd2 --- /dev/null +++ b/src/main/resources/db/migration/V2__security_updates.sql @@ -0,0 +1,33 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- V2: Security hardening — password column expansion + BCrypt migration. +-- +-- BACKGROUND: +-- The original schema used VARCHAR(20) for the password column, which is too +-- short to store BCrypt hashes (60–68 characters). This migration: +-- 1. Expands the column to VARCHAR(255). +-- 2. Provides UPDATE statements to replace any remaining plaintext passwords +-- with BCrypt hashes (useful when applying this migration to an existing +-- database that has not yet been migrated). +-- +-- NOTE: The example BCrypt hash below encodes the string '123456'. +-- For a real production migration, generate individual hashes per user +-- using a one-off script and run this migration during a maintenance window. +-- ───────────────────────────────────────────────────────────────────────────── + +-- Step 1: Expand the password column so it can hold BCrypt hashes +ALTER TABLE user + MODIFY COLUMN password VARCHAR(255); + +-- Step 2: Identify and log any users whose passwords look like plaintext +-- (BCrypt hashes always start with '$2a$' or '$2b$') +-- Run this SELECT manually before Step 3 to audit affected rows: +-- +-- SELECT id, username FROM user WHERE password NOT LIKE '$2%'; + +-- Step 3: Replace all non-BCrypt passwords with the hash of a known reset value +-- ('changeme123' hashed below) and force a password reset via application logic. +-- Replace the hash value with one generated by your BCryptPasswordEncoder. +-- +-- UPDATE user +-- SET password = '$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' +-- WHERE password NOT LIKE '$2%'; diff --git a/src/main/resources/ehcache3.xml b/src/main/resources/ehcache3.xml new file mode 100644 index 0000000..7ead744 --- /dev/null +++ b/src/main/resources/ehcache3.xml @@ -0,0 +1,33 @@ + + + + + + + + 5 + + 5000 + + + + + + 2 + + 10 + + +