1. 概述
本文将对 Reddit 应用 的改进工作进行阶段性收尾,涵盖权限控制、功能增强、模块化拆分等多个关键优化点。这些改进不仅提升了系统的安全性与可维护性,也增强了用户体验。
2. 命令接口安全控制
为防止非资源所有者篡改或删除数据,我们对命令类接口(如更新、删除)进行了细粒度的权限校验。
2.1. 配置启用方法级安全
首先,需要在配置类中启用 Spring Security 的方法级权限注解支持:
@EnableGlobalMethodSecurity(prePostEnabled = true)
✅ 这是使用 @PreAuthorize
的前提,否则注解不会生效。
2.2. 接口层权限校验
在控制器方法上使用 @PreAuthorize
结合自定义安全服务进行权限判断:
@PreAuthorize("@resourceSecurityService.isPostOwner(#postDto.id)")
@RequestMapping(value = "/{id}", method = RequestMethod.PUT)
@ResponseStatus(HttpStatus.OK)
public void updatePost(@RequestBody ScheduledPostUpdateCommandDto postDto) {
...
}
@PreAuthorize("@resourceSecurityService.isPostOwner(#id)")
@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deletePost(@PathVariable("id") Long id) {
...
}
@PreAuthorize("@resourceSecurityService.isRssFeedOwner(#feedDto.id)")
@RequestMapping(value = "/{id}", method = RequestMethod.PUT)
@ResponseStatus(HttpStatus.OK)
public void updateFeed(@RequestBody FeedUpdateCommandDto feedDto) {
..
}
@PreAuthorize("@resourceSecurityService.isRssFeedOwner(#id)")
@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteFeed(@PathVariable("id") Long id) {
...
}
⚠️ 注意:
#参数名
:用于引用方法参数,如#id
、#postDto.id
@beanName
:用于引用 Spring 容器中的 Bean,如@resourceSecurityService
这种写法简洁且解耦,权限逻辑下沉到服务层。
2.3. 资源安全服务实现
ResourceSecurityService
负责具体的资源归属判断:
@Service
public class ResourceSecurityService {
@Autowired
private PostRepository postRepository;
@Autowired
private MyFeedRepository feedRepository;
public boolean isPostOwner(Long postId) {
UserPrincipal userPrincipal = (UserPrincipal)
SecurityContextHolder.getContext().getAuthentication().getPrincipal();
User user = userPrincipal.getUser();
Post post = postRepository.findOne(postId);
return post.getUser().getId() == user.getId();
}
public boolean isRssFeedOwner(Long feedId) {
UserPrincipal userPrincipal = (UserPrincipal)
SecurityContextHolder.getContext().getAuthentication().getPrincipal();
User user = userPrincipal.getUser();
MyFeed feed = feedRepository.findOne(feedId);
return feed.getUser().getId() == user.getId();
}
}
✅ 核心逻辑:
isPostOwner()
:判断当前登录用户是否为指定postId
的拥有者isRssFeedOwner()
:判断当前用户是否为指定feedId
的 RSS 订阅源拥有者
⚠️ 踩坑提醒:务必检查 post
或 feed
是否存在,避免 NPE。生产环境建议加上空值判断。
2.4. 异常统一处理
当权限校验失败时,Spring Security 会抛出 AccessDeniedException
,我们在全局异常处理器中捕获并返回标准错误:
@ExceptionHandler({ AuthenticationCredentialsNotFoundException.class, AccessDeniedException.class })
public ResponseEntity<Object> handleAccessDeniedException(final Exception ex, final WebRequest request) {
logger.error("403 Status Code", ex);
ApiError apiError = new ApiError(HttpStatus.FORBIDDEN, ex);
return new ResponseEntity<Object>(apiError, new HttpHeaders(), HttpStatus.FORBIDDEN);
}
✅ 返回 HTTP 403 状态码,符合 RESTful 规范。
2.5. 权限测试验证
通过集成测试验证权限控制是否生效:
public class CommandAuthorizationLiveTest extends ScheduledPostLiveTest {
@Test
public void givenPostOwner_whenUpdatingScheduledPost_thenUpdated() throws ParseException, IOException {
ScheduledPostDto post = newDto();
post.setTitle("new title");
Response response = withRequestBody(givenAuth(), post).put(urlPrefix + "/api/scheduledPosts/" + post.getId());
assertEquals(200, response.statusCode());
}
@Test
public void givenUserOtherThanOwner_whenUpdatingScheduledPost_thenForbidden() throws ParseException, IOException {
ScheduledPostDto post = newDto();
post.setTitle("new title");
Response response = withRequestBody(givenAnotherUserAuth(), post).put(urlPrefix + "/api/scheduledPosts/" + post.getId());
assertEquals(403, response.statusCode());
}
private RequestSpecification givenAnotherUserAuth() {
FormAuthConfig formConfig = new FormAuthConfig(
urlPrefix + "/j_spring_security_check", "username", "password");
return RestAssured.given().auth().form("test", "test", formConfig);
}
}
✅ 测试设计要点:
givenAuth()
使用用户john
(资源拥有者)givenAnotherUserAuth()
使用用户test
(非拥有者)- 验证非拥有者操作时返回 403,确保权限拦截有效
3. 增加重投递时间选项
为了让用户更灵活地控制文章在 Reddit 上的发布节奏,我们新增了“延迟重投递”功能,将原有的 timeInterval
拆分为两个独立配置。
3.1. 实体类调整
修改 Post
和 Preference
实体类,拆分时间间隔字段:
移除旧字段:
private int timeInterval;
新增两个独立字段:
private int checkAfterInterval; // 提交后多久检查评分
private int submitAfterInterval; // 评分检查后多久再次提交
⚠️ DTO 类也需要同步更新,保持前后端数据结构一致。
3.2. 调度器逻辑调整
调度器根据新字段执行分阶段操作:
private void checkAndReSubmitInternal(Post post) {
if (didIntervalPass(post.getSubmissionDate(), post.getCheckAfterInterval())) {
PostScores postScores = getPostScores(post);
...
}
private void checkAndDeleteInternal(Post post) {
if (didIntervalPass(post.getSubmissionDate(), post.getCheckAfterInterval())) {
PostScores postScores = getPostScores(post);
...
}
private void resetPost(Post post, String failReason) {
long time = new Date().getTime();
time += TimeUnit.MILLISECONDS.convert(post.getSubmitAfterInterval(), TimeUnit.MINUTES);
post.setSubmissionDate(new Date(time));
...
}
✅ 执行流程示例(假设首次提交时间为 T):
- 首次提交时间:T
- 评分检查时间:T + checkAfterInterval
- 若未达标,下次提交时间:T + checkAfterInterval + submitAfterInterval
这种拆分让策略更清晰,用户可独立配置“观察期”和“冷却期”。
4. OAuth2 访问令牌额外校验
为应对 Reddit 访问令牌失效问题,增加令牌有效性检查机制,引导用户重新授权。
4.1. 控制器接口
提供一个简单的健康检查接口:
@RequestMapping(value = "/isAccessTokenValid")
@ResponseBody
public boolean isAccessTokenValid() {
return redditService.isCurrentUserAccessTokenValid();
}
4.2. 服务层实现
通过调用 Reddit API 的轻量级接口验证令牌有效性:
@Override
public boolean isCurrentUserAccessTokenValid() {
UserPrincipal userPrincipal = (UserPrincipal)
SecurityContextHolder.getContext().getAuthentication().getPrincipal();
User currentUser = userPrincipal.getUser();
if (currentUser.getAccessToken() == null) {
return false;
}
try {
redditTemplate.needsCaptcha();
} catch (Exception e) {
redditTemplate.setAccessToken(null);
currentUser.setAccessToken(null);
currentUser.setRefreshToken(null);
currentUser.setTokenExpiration(null);
userRepository.save(currentUser);
return false;
}
return true;
}
✅ 核心逻辑:
- 使用
needsCaptcha()
作为探测接口(轻量、无需权限) - 若调用失败,认为令牌失效,清除数据库中该用户的令牌信息
- 用户下次操作时将被重定向至授权页
⚠️ 踩坑提醒:异常类型需精确捕获,避免误判网络抖动为令牌失效。
4.3. 前端提示
在页面加载时检查令牌状态,并提示用户重新连接:
<div id="connect" style="display:none">
<a href="redditLogin">Connect your Account to Reddit</a>
</div>
<script>
$.get("api/isAccessTokenValid", function(data){
if(!data){
$("#connect").show();
}
});
</script>
✅ 用户体验优化:当令牌失效时,主动提示用户操作,避免静默失败。
5. 项目模块化拆分
为提升代码可维护性与团队协作效率,将单体应用拆分为多个 Maven 模块。
5.1. 父模块配置
reddit-scheduler
作为父模块,统一管理版本和依赖:
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>org.baeldung</groupId>
<artifactId>reddit-scheduler</artifactId>
<version>0.2.0-SNAPSHOT</version>
<name>reddit-scheduler</name>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.7.RELEASE</version>
</parent>
<modules>
<module>reddit-common</module>
<module>reddit-rest</module>
<module>reddit-ui</module>
<module>reddit-web</module>
</modules>
<properties>
<!-- dependency versions and properties -->
</properties>
</project>
✅ 所有子模块继承此 POM,实现依赖版本统一。
5.2. Common 模块
reddit-common
模块包含:
- 数据访问层(Repository)
- 业务服务层(Service)
- Reddit 集成相关代码
- 公共配置类(如
PersistenceJpaConfig
,ServiceConfig
) - 集成测试
pom.xml 示例:
<project>
<modelVersion>4.0.0</modelVersion>
<artifactId>reddit-common</artifactId>
<name>reddit-common</name>
<packaging>jar</packaging>
<parent>
<groupId>org.baeldung</groupId>
<artifactId>reddit-scheduler</artifactId>
<version>0.2.0-SNAPSHOT</version>
</parent>
</project>
✅ 作为基础模块,被其他模块依赖。
5.3. REST 模块
reddit-rest
模块职责:
- REST 接口定义(Controller)
- DTO 数据传输对象
- 全局异常处理(API 层)
依赖:
<dependency>
<groupId>org.baeldung</groupId>
<artifactId>reddit-common</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>
✅ 关注点分离,REST 层仅处理 HTTP 相关逻辑。
5.4. UI 模块
reddit-ui
模块包含:
- MVC 控制器(处理页面跳转)
- 前端页面(HTML)
- Thymeleaf 模板引擎配置
关键配置(从 classpath 加载模板):
@Bean
public TemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/");
templateResolver.setSuffix(".html");
templateResolver.setCacheable(false);
return templateResolver;
}
前端异常处理器示例:
@ControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler implements Serializable {
@ExceptionHandler({ UserApprovalRequiredException.class, UserRedirectRequiredException.class })
public String handleRedirect(RuntimeException ex, WebRequest request) {
logger.info(ex.getLocalizedMessage());
throw ex;
}
@ExceptionHandler({ Exception.class })
public String handleInternal(RuntimeException ex, WebRequest request) {
logger.error(ex);
String response = "Error Occurred: " + ex.getMessage();
return "redirect:/submissionResponse?msg=" + response;
}
}
5.5. Web 模块
reddit-web
是唯一可部署的 WAR 模块,负责:
- Spring Boot 启动类
- Servlet 注册(前端和 API 分离)
- 安全配置
- 监听器注册
核心启动类:
@SpringBootApplication
public class Application extends SpringBootServletInitializer {
@Bean
public ServletRegistrationBean frontendServlet() {
AnnotationConfigWebApplicationContext dispatcherContext =
new AnnotationConfigWebApplicationContext();
dispatcherContext.register(WebFrontendConfig.class, ThymeleafConfig.class);
ServletRegistrationBean registration = new ServletRegistrationBean(
new DispatcherServlet(dispatcherContext), "/*");
registration.setName("FrontendServlet");
registration.setLoadOnStartup(1);
return registration;
}
@Bean
public ServletRegistrationBean apiServlet() {
AnnotationConfigWebApplicationContext dispatcherContext =
new AnnotationConfigWebApplicationContext();
dispatcherContext.register(WebApiConfig.class);
ServletRegistrationBean registration = new ServletRegistrationBean(
new DispatcherServlet(dispatcherContext), "/api/*");
registration.setName("ApiServlet");
registration.setLoadOnStartup(2);
return registration;
}
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
application.sources(Application.class, CommonConfig.class,
PersistenceJpaConfig.class, RedditConfig.class,
ServiceConfig.class, WebGeneralConfig.class);
return application;
}
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
super.onStartup(servletContext);
servletContext.addListener(new SessionListener());
servletContext.addListener(new RequestContextListener());
servletContext.addListener(new HttpSessionEventPublisher());
}
public static void main(String... args) {
SpringApplication.run(Application.class, args);
}
}
依赖关系:
<dependency>
<groupId>org.baeldung</groupId>
<artifactId>reddit-common</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.baeldung</groupId>
<artifactId>reddit-rest</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.baeldung</groupId>
<artifactId>reddit-ui</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>
✅ 模块化后结构清晰,但仍以单体应用形式部署,便于过渡。
6. 总结
本轮优化基本完成了 Reddit 应用的核心改进。从权限控制到功能增强,再到模块化拆分,每一步都围绕着提升系统健壮性和可维护性展开。该项目源于个人需求,最终演变为一个结构清晰、扩展性强的 Spring Boot 实践案例,值得借鉴。