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. 总结
本轮优化实现了四个核心改进:
- ✅ 邮件通知系统(事件驱动设计)
- ✅ 严格会话控制(防并发登录)
- ✅ 前后端 Servlet 分离(清晰职责划分)
- ✅ RSS 链接去重定向(提升内容准确性)
下一步建议进行 API 性能压测,验证系统在高并发下的稳定性表现。