1. 概述

在本篇 Reddit 应用案例研究 中,我们将开始记录每篇帖子的提交尝试历史,并对状态进行更详细、更易读的描述,提升系统的可观测性。

这个改动看似小,但在实际运维中非常实用——当某个帖子反复提交失败时,你不再需要翻日志猜原因,所有信息一目了然。✅

2. 改进 Post 实体

首先,我们替换掉原来用 String 表示的简单状态字段,引入一个专门的实体来记录每次提交的详细信息。

Post 实体中添加一个一对多的关联:

public class Post {
    ...
    @OneToMany(fetch = FetchType.EAGER, mappedBy = "post")
    private List<SubmissionResponse> submissionsResponse;
}

接下来是核心的 SubmissionResponse 实体,用于记录每次提交的完整上下文:

@Entity
public class SubmissionResponse implements IEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private int attemptNumber;

    private String content;

    private Date submissionDate;

    private Date scoreCheckDate;

    @JsonIgnore
    @ManyToOne
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;

    public SubmissionResponse(int attemptNumber, String content, Post post) {
        super();
        this.attemptNumber = attemptNumber;
        this.content = content;
        this.submissionDate = new Date();
        this.post = post;
    }

    @Override
    public String toString() {
        StringBuilder builder = new StringBuilder();
        builder.append("Attempt No ").append(attemptNumber).append(" : ").append(content);
        return builder.toString();
    }
}

每个提交尝试对应一条 SubmissionResponse 记录,包含以下关键字段:

  • attemptNumber:第几次尝试
  • content:本次尝试的详细结果(成功/失败原因)
  • submissionDate:提交时间
  • scoreCheckDate:本次尝试中检查 Reddit 帖子分数的时间

配套的 JPA 仓库接口也很简单:

public interface SubmissionResponseRepository extends JpaRepository<SubmissionResponse, Long> {

    SubmissionResponse findOneByPostAndAttemptNumber(Post post, int attemptNumber);
}

3. 调度服务逻辑改造

现在我们要在服务层记录这些历史信息,第一步是让成功/失败的原因更清晰可读。

构建可读的成败原因

我们定义了两个模板字符串,用于生成标准化的提示信息:

private final static String SCORE_TEMPLATE = "score %d %s minimum score %d";
private final static String TOTAL_VOTES_TEMPLATE = "total votes %d %s minimum total votes %d";

然后是两个核心方法,用于生成失败和成功的原因:

protected String getFailReason(Post post, PostScores postScores) { 
    StringBuilder builder = new StringBuilder(); 
    builder.append("Failed because "); 
    builder.append(String.format(
      SCORE_TEMPLATE, postScores.getScore(), "<", post.getMinScoreRequired())); 
    
    if (post.getMinTotalVotes() > 0) { 
        builder.append(" and "); 
        builder.append(String.format(TOTAL_VOTES_TEMPLATE, 
          postScores.getTotalVotes(), "<", post.getMinTotalVotes()));
    } 
    if (post.isKeepIfHasComments()) { 
        builder.append(" and has no comments"); 
    } 
    return builder.toString(); 
}
protected String getSuccessReason(Post post, PostScores postScores) {
    StringBuilder builder = new StringBuilder(); 
    if (postScores.getScore() >= post.getMinScoreRequired()) { 
        builder.append("Succeed because "); 
        builder.append(String.format(SCORE_TEMPLATE, 
          postScores.getScore(), ">=", post.getMinScoreRequired())); 
        return builder.toString(); 
    } 
    if (
      (post.getMinTotalVotes() > 0) && 
      (postScores.getTotalVotes() >= post.getMinTotalVotes())
    ) { 
        builder.append("Succeed because "); 
        builder.append(String.format(TOTAL_VOTES_TEMPLATE, 
          postScores.getTotalVotes(), ">=", post.getMinTotalVotes()));
        return builder.toString(); 
    } 
    return "Succeed because has comments"; 
}

更新核心调度逻辑

接下来改造原有的调度逻辑,加入历史记录:

private void submitPost(...) {
    ...
    if (errorNode == null) {
        post.setSubmissionsResponse(addAttemptResponse(post, "Submitted to Reddit"));
        ...
    } else {
        post.setSubmissionsResponse(addAttemptResponse(post, errorNode.toString()));
        ...
    }
}
private void checkAndReSubmit(Post post) {
    if (didIntervalPass(...)) {
        PostScores postScores = getPostScores(post);
        if (didPostGoalFail(post, postScores)) {
            ...
            resetPost(post, getFailReason(post, postScores));
        } else {
            ...
            updateLastAttemptResponse(
              post, "Post reached target score successfully " + 
                getSuccessReason(post, postScores));
        }
    }
}
private void checkAndDeleteInternal(Post post) {
    if (didIntervalPass(...)) {
        PostScores postScores = getPostScores(post);
        if (didPostGoalFail(post, postScores)) {
            updateLastAttemptResponse(post, 
              "Deleted from reddit, consumed all attempts without reaching score " + 
                getFailReason(post, postScores));
            ...
        } else {
            updateLastAttemptResponse(post, 
              "Post reached target score successfully " + 
                getSuccessReason(post, postScores));
            ...
        }
    }
}
private void resetPost(Post post, String failReason) {
    ...
    updateLastAttemptResponse(post, "Deleted from Reddit, to be resubmitted " + failReason);
    ...
}

⚠️ 注意两个底层方法的作用:

  • addAttemptResponse():每次提交时创建新的 SubmissionResponse 记录
  • updateLastAttemptResponse():在检查分数时更新最后一次尝试的结果

这样就能完整记录“提交 → 检查 → 失败/成功”的整个生命周期。

4. 调度帖子 DTO 改造

为了让前端能拿到这些历史数据,我们需要更新 DTO。

ScheduledPostDto

public class ScheduledPostDto {
    ...

    private String status;

    private List<SubmissionResponseDto> detailedStatus;
}

SubmissionResponseDto

public class SubmissionResponseDto {

    private int attemptNumber;

    private String content;

    private String localSubmissionDate;

    private String localScoreCheckDate;
}

转换逻辑

ScheduledPostRestController 中更新转换方法:

private ScheduledPostDto convertToDto(Post post) {
    ...
    List<SubmissionResponse> response = post.getSubmissionsResponse();
    if ((response != null) && (response.size() > 0)) {
        postDto.setStatus(response.get(response.size() - 1).toString().substring(0, 30));
        List<SubmissionResponseDto> responsedto = 
          post.getSubmissionsResponse().stream().
            map(res -> generateResponseDto(res)).collect(Collectors.toList());
        postDto.setDetailedStatus(responsedto);
    } else {
        postDto.setStatus("Not sent yet");
        postDto.setDetailedStatus(Collections.emptyList());
    }
    return postDto;
}
private SubmissionResponseDto generateResponseDto(SubmissionResponse responseEntity) {
    SubmissionResponseDto dto = modelMapper.map(responseEntity, SubmissionResponseDto.class);
    String timezone = userService.getCurrentUser().getPreference().getTimezone();
    dto.setLocalSubmissionDate(responseEntity.getSubmissionDate(), timezone);
    if (responseEntity.getScoreCheckDate() != null) {
        dto.setLocalScoreCheckDate(responseEntity.getScoreCheckDate(), timezone);
    }
    return dto;
}

这里做了两件事:

  1. status 字段显示最近一次尝试的摘要(截取前30字符)
  2. detailedStatus 返回完整的尝试历史,且时间已转换为用户本地时区

5. 前端展示

前端 scheduledPosts.jsp 也需相应调整,支持查看详细历史。

<div class="modal">
    <h4 class="modal-title">Detailed Status</h4>
    <table id="res"></table>
</div>

<script >
var loadedData = [];
var detailedResTable = $('#res').DataTable( {
    "searching":false,
    "paging": false,
    columns: [
        { title: "Attempt Number", data: "attemptNumber" },
        { title: "Detailed Status", data: "content" },
        { title: "Attempt Submitted At", data: "localSubmissionDate" },
        { title: "Attempt Score Checked At", data: "localScoreCheckDate" }
 ]
} );
           
$(document).ready(function() {
    $('#myposts').dataTable( {
        ...
        "columnDefs": [
            { "targets": 2, "data": "status",
              "render": function ( data, type, full, meta ) {
                  return data + 
                    ' <a href="#" onclick="showDetailedStatus('+meta.row+' )">More Details</a>';
              }
            },
            ....
        ],
        ...
    });
});

function showDetailedStatus(row){
    detailedResTable.clear().rows.add(loadedData[row].detailedStatus).draw();
    $('.modal').modal();
}
</script>

效果很简单粗暴:

  • 表格每行的 status 后面加个 “More Details” 链接
  • 点击后弹出 modal,用 DataTable 展示完整的提交历史
  • 包含尝试次数、内容、提交时间、评分检查时间

6. 单元测试

最后别忘了给新加的逻辑补上单元测试,避免踩坑。

测试 getSuccessReason

@Test
public void whenHasEnoughScore_thenSucceed() {
    Post post = new Post();
    post.setMinScoreRequired(5);
    PostScores postScores = new PostScores(6, 10, 1);

    assertTrue(getSuccessReason(post, postScores).contains("Succeed because score"));
}

@Test
public void whenHasEnoughTotalVotes_thenSucceed() {
    Post post = new Post();
    post.setMinScoreRequired(5);
    post.setMinTotalVotes(8);
    PostScores postScores = new PostScores(2, 10, 1);

    assertTrue(getSuccessReason(post, postScores).contains("Succeed because total votes"));
}

@Test
public void givenKeepPostIfHasComments_whenHasComments_thenSucceed() {
    Post post = new Post();
    post.setMinScoreRequired(5);
    post.setKeepIfHasComments(true);
    final PostScores postScores = new PostScores(2, 10, 1);

    assertTrue(getSuccessReason(post, postScores).contains("Succeed because has comments"));
}

测试 getFailReason

@Test
public void whenNotEnoughScore_thenFail() {
    Post post = new Post();
    post.setMinScoreRequired(5);
    PostScores postScores = new PostScores(2, 10, 1);

    assertTrue(getFailReason(post, postScores).contains("Failed because score"));
}

@Test
public void whenNotEnoughTotalVotes_thenFail() {
    Post post = new Post();
    post.setMinScoreRequired(5);
    post.setMinTotalVotes(15);
    PostScores postScores = new PostScores(2, 10, 1);

    String reason = getFailReason(post, postScores);
    assertTrue(reason.contains("Failed because score"));
    assertTrue(reason.contains("and total votes"));
}

@Test
public void givenKeepPostIfHasComments_whenNotHasComments_thenFail() {
    Post post = new Post();
    post.setMinScoreRequired(5);
    post.setKeepIfHasComments(true);
    final PostScores postScores = new PostScores(2, 10, 0);

    String reason = getFailReason(post, postScores);
    assertTrue(reason.contains("Failed because score"));
    assertTrue(reason.contains("and has no comments"));
}

这些测试覆盖了主要分支,确保提示信息生成正确。

7. 总结

通过本次改造,我们为 Reddit 帖子的生命周期增加了完整的提交历史追踪能力

现在你可以清晰地看到:

  • ✅ 每次提交的时间和结果
  • ✅ 每次检查分数的时间
  • ✅ 失败或成功的确切原因(基于分数、投票数、评论数)

这个改动虽然不大,但极大提升了系统的可观测性和调试效率。当你收到告警时,不再需要查日志、问同事,直接看前端就能定位问题。这才是真正面向运维的设计。✅


原始标题:Preserve the History of Reddit Post Submissions