1. 概述

继续推进我们正在进行的案例研究中的 Reddit 应用开发。本轮将实现几个实用功能和架构优化,提升用户体验和系统健壮性。

2. 帖子评论的邮件通知

当前 Reddit 应用缺少邮件通知功能。我们的目标很明确:当用户发布的帖子收到新评论时,自动发送一封简短的邮件提醒。

实现思路如下:

  • 用户可配置是否接收此类通知
  • 后台定时任务扫描用户收件箱
  • 发现未读回复后触发邮件事件

2.1 用户偏好设置

首先扩展 Preference 实体类和 DTO,新增字段:

private boolean sendEmailReplies;

✅ 允许用户自主开启/关闭评论邮件提醒功能。

2.2 通知调度器

使用 Spring 的 @Scheduled 实现轮询任务:

@Component
public class NotificationRedditScheduler {

    @Autowired
    private INotificationRedditService notificationRedditService;

    @Autowired
    private PreferenceRepository preferenceRepository;

    @Scheduled(fixedRate = 60 * 60 * 1000)
    public void checkInboxUnread() {
        List<Preference> preferences = preferenceRepository.findBySendEmailRepliesTrue();
        for (Preference preference : preferences) {
            notificationRedditService.checkAndNotify(preference);
        }
    }
}

⚠️ 调度频率设为每小时一次,生产环境可根据需要调整为更短周期(如5分钟)。

2.3 通知服务

核心逻辑在 NotificationRedditService 中实现:

@Service
public class NotificationRedditService implements INotificationRedditService {
    private Logger logger = LoggerFactory.getLogger(getClass());
    private static String NOTIFICATION_TEMPLATE = "您有 %d 条未读回复。";
    private static String MESSAGE_TEMPLATE = "%s 在您的帖子 %s 中回复:%s";

    @Autowired
    @Qualifier("schedulerRedditTemplate")
    private OAuth2RestTemplate redditRestTemplate;

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @Autowired
    private UserRepository userRepository;

    @Override
    public void checkAndNotify(Preference preference) {
        try {
            checkAndNotifyInternal(preference);
        } catch (Exception e) {
            logger.error(
              "检查并发送通知时发生异常 = " + preference.getEmail(), e);
        }
    }

    private void checkAndNotifyInternal(Preference preference) {
        User user = userRepository.findByPreference(preference);
        if ((user == null) || (user.getAccessToken() == null)) {
            return;
        }

        DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(user.getAccessToken());
        token.setRefreshToken(new DefaultOAuth2RefreshToken((user.getRefreshToken())));
        token.setExpiration(user.getTokenExpiration());
        redditRestTemplate.getOAuth2ClientContext().setAccessToken(token);

        JsonNode node = redditRestTemplate.getForObject(
          "https://oauth.reddit.com/message/selfreply?mark=false", JsonNode.class);
        parseRepliesNode(preference.getEmail(), node);
    }

    private void parseRepliesNode(String email, JsonNode node) {
        JsonNode allReplies = node.get("data").get("children");
        int unread = 0;
        for (JsonNode msg : allReplies) {
            if (msg.get("data").get("new").asBoolean()) {
                unread++;
            }
        }
        if (unread == 0) {
            return;
        }

        JsonNode firstMsg = allReplies.get(0).get("data");
        String author = firstMsg.get("author").asText();
        String postTitle = firstMsg.get("link_title").asText();
        String content = firstMsg.get("body").asText();

        StringBuilder builder = new StringBuilder();
        builder.append(String.format(NOTIFICATION_TEMPLATE, unread));
        builder.append("\n");
        builder.append(String.format(MESSAGE_TEMPLATE, author, postTitle, content));
        builder.append("\n");
        builder.append("查看所有新回复:");
        builder.append("https://www.reddit.com/message/unread/");

        eventPublisher.publishEvent(new OnNewPostReplyEvent(email, builder.toString()));
    }
}

关键点:

  • ✅ 调用 Reddit API 获取 selfreply 列表
  • ✅ 遍历判断每条回复是否为“未读”状态
  • ✅ 存在未读时,发布 OnNewPostReplyEvent 事件

2.4 新回复事件

定义事件类用于解耦通知逻辑:

public class OnNewPostReplyEvent extends ApplicationEvent {
    private String email;
    private String content;

    public OnNewPostReplyEvent(String email, String content) {
        super(email);
        this.email = email;
        this.content = content;
    }
}

2.5 回复监听器

监听事件并发送邮件:

@Component
public class ReplyListener implements ApplicationListener<OnNewPostReplyEvent> {
    @Autowired
    private JavaMailSender mailSender;

    @Autowired
    private Environment env;

    @Override
    public void onApplicationEvent(OnNewPostReplyEvent event) {
        SimpleMailMessage email = constructEmailMessage(event);
        mailSender.send(email);
    }

    private SimpleMailMessage constructEmailMessage(OnNewPostReplyEvent event) {
        String recipientAddress = event.getEmail();
        String subject = "新帖子回复提醒";
        SimpleMailMessage email = new SimpleMailMessage();
        email.setTo(recipientAddress);
        email.setSubject(subject);
        email.setText(event.getContent());
        email.setFrom(env.getProperty("support.email"));
        return email;
    }
}

📧 示例邮件内容:

您有 1 条未读回复。
Alice 在您的帖子 "Spring Boot 最佳实践" 中回复:这个建议很实用!
查看所有新回复:https://www.reddit.com/message/unread/

3. 会话并发控制

防止同一账号多处登录,提升安全性:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.sessionManagement()
          .maximumSessions(1)
          .maxSessionsPreventsLogin(true);
}

⚠️ 踩坑提醒:由于使用了自定义 UserDetails 实现,必须重写 equals()hashCode() 方法。否则 Spring Security 的会话管理无法正确识别用户主体(Principal)。

public class UserPrincipal implements UserDetails {

    private User user;

    @Override
    public int hashCode() {
        int prime = 31;
        int result = 1;
        result = (prime * result) + ((user == null) ? 0 : user.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        UserPrincipal other = (UserPrincipal) obj;
        if (user == null) {
            if (other.user != null) {
                return false;
            }
        } else if (!user.equals(other.user)) {
            return false;
        }
        return true;
    }
}

4. 分离 API 与前端 Servlet

当前应用使用单一 DispatcherServlet 处理所有请求,不利于职责分离。改进方案:

4.1 配置双 Servlet

@Bean
public ServletRegistrationBean frontendServlet() {
    ServletRegistrationBean registration = 
      new ServletRegistrationBean(new DispatcherServlet(), "/*");

    Map<String, String> params = new HashMap<String, String>();
    params.put("contextClass", 
      "org.springframework.web.context.support.AnnotationConfigWebApplicationContext");
    params.put("contextConfigLocation", "org.baeldung.config.frontend");
    registration.setInitParameters(params);
    
    registration.setName("FrontendServlet");
    registration.setLoadOnStartup(1);
    return registration;
}

@Bean
public ServletRegistrationBean apiServlet() {
    ServletRegistrationBean registration = 
      new ServletRegistrationBean(new DispatcherServlet(), "/api/*");
    
    Map<String, String> params = new HashMap<String, String>();
    params.put("contextClass", 
      "org.springframework.web.context.support.AnnotationConfigWebApplicationContext");
    params.put("contextConfigLocation", "org.baeldung.config.api");
    
    registration.setInitParameters(params);
    registration.setName("ApiServlet");
    registration.setLoadOnStartup(2);
    return registration;
}

@Override
protected SpringApplicationBuilder configure(final SpringApplicationBuilder application) {
    application.sources(Application.class);
    return application;
}

架构优势:

  • ✅ 前端 Servlet 处理 JSP 页面请求,加载 WebFrontendConfig
  • ✅ API Servlet 处理 /api/** 接口,加载 WebApiConfig
  • ✅ 两者共享父级 Spring 上下文(含持久层、服务层等通用配置)

4.2 前端配置

@Configuration
@EnableWebMvc
@ComponentScan({ "org.baeldung.web.controller.general" })
public class WebFrontendConfig implements WebMvcConfigurer {

    @Bean
    public static PropertySourcesPlaceholderConfigurer 
      propertySourcesPlaceholderConfigurer() {
        return new PropertySourcesPlaceholderConfigurer();
    }

    @Bean
    public ViewResolver viewResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setPrefix("/WEB-INF/jsp/");
        viewResolver.setSuffix(".jsp");
        return viewResolver;
    }

    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/home");
        // ...
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/resources/**").addResourceLocations("/resources/");
    }
}

4.3 API 配置

@Configuration
@EnableWebMvc
@ComponentScan({ "org.baeldung.web.controller.rest", "org.baeldung.web.dto" })
public class WebApiConfig implements WebMvcConfigurer {

    @Bean
    public ModelMapper modelMapper() {
        return new ModelMapper();
    }
}

5. RSS 链接去重定向

解决 RSS 订阅中常见的短链接跳转问题(如 Feedburner 转发),确保发布到 Reddit 的是原始真实链接。

5.1 接口实现

@RequestMapping(value = "/url/original")
@ResponseBody
public String getOriginalLink(@RequestParam("url") String sourceUrl) {
    try {
        List<String> visited = new ArrayList<String>();
        String currentUrl = sourceUrl;
        while (!visited.contains(currentUrl)) {
            visited.add(currentUrl);
            currentUrl = getOriginalUrl(currentUrl);
        }
        return currentUrl;
    } catch (Exception ex) {
        // log the exception
        return sourceUrl;
    }
}

5.2 重定向解析

private String getOriginalUrl(String oldUrl) throws IOException {
    URL url = new URL(oldUrl);
    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
    connection.setInstanceFollowRedirects(false);
    String originalUrl = connection.getHeaderField("Location");
    connection.disconnect();
    if (originalUrl == null) {
        return oldUrl;
    }
    if (originalUrl.indexOf("?") != -1) {
        return originalUrl.substring(0, originalUrl.indexOf("?"));
    }
    return originalUrl;
}

✅ 关键特性:

  • 支持多级重定向追踪
  • 使用 visited 列表防止无限重定向循环
  • 自动去除 URL 中的查询参数(如 utm_source)

6. 总结

本轮优化实现了四个核心改进:

  1. ✅ 邮件通知系统(事件驱动设计)
  2. ✅ 严格会话控制(防并发登录)
  3. ✅ 前后端 Servlet 分离(清晰职责划分)
  4. ✅ RSS 链接去重定向(提升内容准确性)

下一步建议进行 API 性能压测,验证系统在高并发下的稳定性表现。


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

« 上一篇: Java Web每周39
» 下一篇: Java周报,40