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 订阅源拥有者

⚠️ 踩坑提醒:务必检查 postfeed 是否存在,避免 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. 实体类调整

修改 PostPreference 实体类,拆分时间间隔字段:

移除旧字段:

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):

  1. 首次提交时间:T
  2. 评分检查时间:T + checkAfterInterval
  3. 若未达标,下次提交时间: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 实践案例,值得借鉴。


原始标题:Sixth Round of Improvements to the Reddit App

« 上一篇: Java周报, 44
» 下一篇: Java周报, 45