1. 概述
继我们之前关于 使用 Spring 构建 Reddit Web 应用的案例研究 之后,本文将进行第二轮功能迭代。本轮目标是提升用户体验,让应用更易用、更智能。
这些改进涵盖了分页、登录控制、高级发布策略、日志告警、缓存优化以及基础监控,都是实际项目中常见的“踩坑”点和优化方向。
2. 定时发布内容的分页支持
当前定时发布的帖子列表没有分页,当数量较多时用户体验极差。我们需要引入分页机制,让列表更清晰易读。
2.1. 分页数据查询实现
借助 Spring Data JPA,我们可以非常简洁地实现分页查询。只需在 Repository 接口中声明方法,框架会自动生成实现。
public interface PostRepository extends JpaRepository<Post, Long> {
Page<Post> findByUser(User user, Pageable pageable);
}
控制器方法接收页码参数,默认每页 10 条:
private static final int PAGE_SIZE = 10;
@RequestMapping("/scheduledPosts")
@ResponseBody
public List<Post> getScheduledPosts(
@RequestParam(value = "page", required = false, defaultValue = "0") int page) {
User user = getCurrentUser();
Page<Post> posts =
postRepository.findByUser(user, PageRequest.of(page, PAGE_SIZE));
return posts.getContent();
}
⚠️ 注意:required = false
和 defaultValue = "0"
确保了首次访问时 page 参数为 0,避免空指针。
2.2. 前端分页控件实现
前端使用简单的 jQuery 实现分页按钮和数据加载:
<table>
<thead><tr><th>帖子标题</th></tr></thead>
<tbody id="postList"></tbody>
</table>
<br/>
<button id="prev" onclick="loadPrev()">上一页</button>
<button id="next" onclick="loadNext()">下一页</button>
JavaScript 脚本:
$(function(){
loadPage(0);
});
var currentPage = 0;
function loadNext(){
if (currentPage < totalPages) loadPage(currentPage + 1);
}
function loadPrev(){
if (currentPage > 0) loadPage(currentPage - 1);
}
function loadPage(page){
currentPage = page;
$('#postList').empty();
$.get("/scheduledPosts?page=" + page, function(data){
$.each(data, function(index, post) {
$('#postList').append('<tr><td>' + post.title + '</td></tr>');
});
}).fail(function() {
alert('加载失败');
});
}
✅ 提示:虽然这里用了原生 jQuery,但生产环境建议替换为 DataTables 等成熟表格插件。
3. 根路径登录状态分流
用户访问首页 /
时,应根据登录状态返回不同页面:
- 已登录 → 跳转至个人主页(dashboard)
- 未登录 → 显示登录页
这个逻辑非常基础,但容易被忽略。
@RequestMapping("/")
public String homePage() {
if (SecurityContextHolder.getContext().getAuthentication() != null
&& SecurityContextHolder.getContext().getAuthentication().isAuthenticated()) {
return "home";
}
return "index";
}
⚠️ 注意:判断登录状态时不仅要检查 Authentication
是否为空,还要确认其 isAuthenticated()
返回 true,避免匿名用户被误判。
4. 发布重试的高级控制选项
Reddit 的“删除并重新发布”功能很强大,但需要精细化控制。我们引入三个新选项,避免误操作或无效重试。
4.1. Post 实体扩展
在 Post
实体中新增三个字段:
@Entity
public class Post {
...
private int minUpvoteRatio; // 最低点赞率(百分比)
private boolean keepIfHasComments; // 有评论时保留帖子
private boolean deleteAfterLastAttempt; // 最后一次尝试失败后删除
}
字段说明:
minUpvoteRatio
:用户期望的最低点赞率(0-100),例如 70 表示至少 70% 的投票是赞keepIfHasComments
:若帖子已有评论,即使未达标也保留deleteAfterLastAttempt
:所有重试机会用尽且未达标时,是否自动删除
4.2. 调度器逻辑增强
调度器定期检查是否需要删除或重试帖子。关键逻辑在 checkAndDelete()
方法:
@Scheduled(fixedRate = 3 * 60 * 1000)
public void checkAndDeleteAll() {
List<Post> submitted = postRepository
.findByRedditIDNotNullAndNoOfAttemptsAndDeleteAfterLastAttemptTrue(0);
for (Post post : submitted) {
checkAndDelete(post);
}
}
private void checkAndDelete(Post post) {
if (didIntervalPass(post.getSubmissionDate(), post.getTimeInterval())) {
if (didPostGoalFail(post)) {
deletePost(post.getRedditID());
post.setSubmissionResponse("Attempts consumed without reaching score");
post.setRedditID(null);
postRepository.save(post);
} else {
// 达标,重置尝试次数
post.setNoOfAttempts(0);
post.setRedditID(null);
postRepository.save(post);
}
}
}
核心判断逻辑 didPostGoalFail()
:
private boolean didPostGoalFail(Post post) {
PostScores scores = getPostScores(post);
int score = scores.getScore();
int upvoteRatio = scores.getUpvoteRatio();
int commentCount = scores.getNoOfComments();
boolean failedByScoreOrRatio =
(score < post.getMinScoreRequired()) || (upvoteRatio < post.getMinUpvoteRatio());
boolean shouldKeepDueToComments =
(commentCount > 0) && post.isKeepIfHasComments();
return failedByScoreOrRatio && !shouldKeepDueToComments;
}
获取帖子评分信息(来自 Reddit API):
public PostScores getPostScores(Post post) {
JsonNode node = restTemplate.getForObject(
"http://www.reddit.com/r/" + post.getSubreddit() +
"/comments/" + post.getRedditID() + ".json", JsonNode.class);
PostScores scores = new PostScores();
node = node.get(0).get("data").get("children").get(0).get("data");
scores.setScore(node.get("score").asInt());
double ratio = node.get("upvote_ratio").asDouble();
scores.setUpvoteRatio((int) (ratio * 100));
scores.setNoOfComments(node.get("num_comments").asInt());
return scores;
}
PostScores
值对象:
public class PostScores {
private int score;
private int upvoteRatio;
private int noOfComments;
// getter/setter
}
⚠️ 注意:checkAndReSubmit()
方法也需同步修改,确保成功重试后重置 redditID
。
4.3. 前端表单更新
在发布表单 schedulePostForm.html
中添加新选项:
<label>
最低点赞率 (%):
<input type="number" name="minUpvoteRatio" min="0" max="100" value="60"/>
</label>
<label>
<input type="checkbox" name="keepIfHasComments" value="true"/>
有评论时保留帖子
</label>
<label>
<input type="checkbox" name="deleteAfterLastAttempt" value="true"/>
最后一次失败后删除
</label>
5. 重要日志邮件告警
生产环境必须对错误日志敏感。我们通过 Logback 配置,实现 ERROR 级别日志自动邮件通知。
5.1. 添加邮件依赖
<dependency>
<groupId>jakarta.mail</groupId>
<artifactId>jakarta.mail-api</artifactId>
<version>2.1.2</version>
</dependency>
5.2. 配置 SMTPAppender
在 logback.xml
中添加邮件 Appender:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="EMAIL" class="ch.qos.logback.classic.net.SMTPAppender">
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<smtpHost>smtp.gmail.com</smtpHost>
<smtpPort>587</smtpPort>
<username>admin@reddittool.com</username>
<password>securePassword123</password>
<to>dev-team@reddittool.com</to>
<from>admin@reddittool.com</from>
<subject>【ERROR】%logger{20} - %m</subject>
<layout class="ch.qos.logback.classic.html.HTMLLayout"/>
<asynchronousSending>true</asynchronousSending>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
<appender-ref ref="EMAIL"/>
</root>
</configuration>
✅ 效果:一旦应用抛出未捕获异常,开发团队将立即收到邮件,快速响应。
6. Subreddit 缓存优化
原始实现中,每次用户输入 subreddit 名称时都调用 Reddit API 实时查询,体验差且浪费资源。
我们改为缓存热门 subreddit 列表,实现本地自动补全。
6.1. 初始化缓存数据
编写一次性脚本,抓取前 2000 个热门 subreddit 并保存到本地 CSV:
public void getAllSubreddits() {
JsonNode node;
String after = "";
FileWriter writer = null;
try {
writer = new FileWriter("src/main/resources/subreddits.csv");
for (int i = 0; i < 20; i++) {
node = restTemplate.getForObject(
"https://www.reddit.com/subreddits/popular.json?limit=100&after=" + after,
JsonNode.class);
after = node.get("data").get("after").asText();
node = node.get("data").get("children");
for (JsonNode child : node) {
String name = child.get("data").get("display_name").asText();
writer.append(name).append(",");
}
Thread.sleep(3000); // 避免请求过快被限流
}
writer.close();
} catch (Exception e) {
logger.error("Error fetching subreddits", e);
}
}
⚠️ 生产建议:可考虑使用定时任务定期更新缓存,或使用 Redis 存储。
6.2. 启动加载与自动补全
服务启动时加载 CSV 到内存:
@Service
public class SubredditService implements InitializingBean {
private List<String> subreddits = new ArrayList<>();
@Override
public void afterPropertiesSet() {
loadSubreddits();
}
private void loadSubreddits() {
try {
Resource resource = new ClassPathResource("subreddits.csv");
Scanner scanner = new Scanner(resource.getFile());
scanner.useDelimiter(",");
while (scanner.hasNext()) {
String sr = scanner.next().trim();
if (!sr.isEmpty()) subreddits.add(sr);
}
scanner.close();
logger.info("Loaded {} subreddits into cache", subreddits.size());
} catch (IOException e) {
logger.error("Failed to load subreddits", e);
}
}
public List<String> searchSubreddit(String query) {
if (query == null || query.isEmpty()) return Collections.emptyList();
return subreddits.stream()
.filter(sr -> sr.toLowerCase().startsWith(query.toLowerCase()))
.limit(9)
.collect(Collectors.toList());
}
}
暴露自动补全接口:
@RequestMapping(value = "/subredditAutoComplete")
@ResponseBody
public List<String> subredditAutoComplete(@RequestParam("term") String term) {
return subredditService.searchSubreddit(term);
}
✅ 效果:前端输入时通过 AJAX 调用此接口,实现毫秒级响应的自动补全。
7. 接口调用监控
为掌握应用运行状况,我们添加基础的 HTTP 接口调用统计。
7.1. 监控过滤器
通过 Servlet Filter 拦截所有请求:
@Component
public class MetricFilter implements Filter {
@Autowired
private IMetricService metricService;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestKey = httpRequest.getMethod() + " " + httpRequest.getRequestURI();
chain.doFilter(request, response);
int status = ((HttpServletResponse) response).getStatus();
metricService.increaseCount(requestKey, status);
}
}
注册到容器:
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
super.onStartup(servletContext);
servletContext.addListener(new SessionListener());
registerProxyFilter(servletContext, "oauth2ClientContextFilter");
registerProxyFilter(servletContext, "springSecurityFilterChain");
registerProxyFilter(servletContext, "metricFilter"); // 注册监控过滤器
}
7.2. 监控服务接口
public interface IMetricService {
void increaseCount(String request, int status);
Map<String, Integer> getFullMetric(); // 全部请求统计
Map<Integer, Integer> getStatusMetric(); // 各状态码统计
Object[][] getGraphData(); // 图表数据
}
7.3. 监控数据接口
提供 HTTP 接口供前端展示:
@Controller
public class MetricController {
@Autowired
private IMetricService metricService;
@RequestMapping(value = "/metric", method = RequestMethod.GET)
@ResponseBody
public Map<String, Integer> getMetric() {
return metricService.getFullMetric();
}
@RequestMapping(value = "/status-metric", method = RequestMethod.GET)
@ResponseBody
public Map<Integer, Integer> getStatusMetric() {
return metricService.getStatusMetric();
}
@RequestMapping(value = "/metric-graph-data", method = RequestMethod.GET)
@ResponseBody
public Object[][] getMetricGraphData() {
Object[][] data = metricService.getGraphData();
// 确保第一行为字符串(如时间戳转为字符串)
for (int i = 1; i < data[0].length; i++) {
data[0][i] = data[0][i].toString();
}
return data;
}
}
✅ 建议:可结合 ECharts 或 Grafana 实现可视化监控面板。
8. 总结
这个 Reddit 工具从最初的 OAuth 教程,逐步演变为一个功能丰富的自动化发布平台。本轮优化聚焦于:
- ✅ 用户体验:分页、自动补全
- ✅ 系统健壮性:登录分流、错误告警
- ✅ 智能策略:基于评论、点赞率的发布控制
- ✅ 可观测性:基础监控与日志告警
实际使用中,这些改进显著提升了帖子的曝光效率。项目虽小,但涵盖了现代 Web 应用开发的多个关键环节,值得参考。