2026-07-30-comment-review-implementation.md 19 KB

文章评论审核系统 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

Goal: 补充文章评论审核能力:后端权限加固、Web管理端审核页完善、小程序端文章作者审核

Architecture: 后端通过校验文章作者或 admin 角色来加固审核权限,同时新增管理端分页查询接口和增强现有接口返回数据;Web管理端重写 CommentReview.vue 增加筛选/分页/驳回原因;小程序端 article-detail 增加作者审核面板。

Tech Stack: Spring Boot 2.7 + MyBatis-Plus / Vue 2 + Element UI / uni-app Vue 2 小程序

Global Constraints

  • 后端接口统一使用 @PostMapping
  • 小程序禁止可选链 ?.(用 && 替代)
  • Vue 2 Options API
  • DI 使用 @Resource
  • 响应统一 Result<T>

Task 1: 后端 — 审核权限校验

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCommentService.java (auditComment 方法)
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java (auditComment 方法)

Interfaces:

  • Consumes: ArticleCommentMapper, ArticleMapper, UserMapper (已有注入)
  • Produces: auditComment(commentId, auditorId, status, role) — 新增 role 参数;拒绝时抛 RuntimeException("无权限审核该评论")

  • [ ] Step 1: ArticleCommentService.auditComment() 增加权限校验

auditComment() 方法开头增加逻辑:

  1. 根据 commentId 查 ArticleComment,获取 articleId
  2. 根据 articleId 查 Article,获取 createdBy(文章作者ID)
  3. 如果 auditorId != article.createdBy 且角色不是 admin,抛出异常

    // 权限校验:只有文章作者或管理员可审核
    ArticleComment comment = articleCommentMapper.selectById(commentId);
    if (comment == null) return false;
    Article article = articleMapper.selectById(comment.getArticleId());
    if (article == null) return false;
    if (!article.getCreatedBy().equals(auditorId) && !"admin".equals(role)) {
    throw new RuntimeException("无权限审核该评论");
    }
    

注意:这段代码要放在方法现有逻辑的最前面。方法签名改为:

public boolean auditComment(Long commentId, Long auditorId, String status, String role)
  • Step 2: ArticleCommentController.auditComment() 传递 role

添加 @RequestAttribute("role") String role 参数,调用 service 时传入:

return articleCommentService.auditComment(commentId, userId, status, role) 
    ? Result.success("审核完成") 
    : Result.error("评论不存在");
  • [ ] Step 3: 编译验证

    cd /app/cfc/cfc-backend && mvn clean compile
    

Task 2: 后端 — 待审核列表增强

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCommentService.java (getPendingComments 方法)
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java

Interfaces:

  • Produces: getPendingComments(articleId) 返回 List<Map<String, Object>>,每个 map 包含 id, articleId, articleTitle, content, userName, userId, status, parentId, replyToId, createdAt

  • [ ] Step 1: 修改 ArticleCommentService.getPendingComments() 返回增强数据

将返回类型从 List<ArticleComment> 改为 List<Map<String, Object>>,方法内查询文章标题并返回:

public List<Map<String, Object>> getPendingComments(Long articleId) {
    LambdaQueryWrapper<ArticleComment> wrapper = new LambdaQueryWrapper<ArticleComment>()
            .eq(ArticleComment::getStatus, "pending");
    if (articleId != null) {
        wrapper.eq(ArticleComment::getArticleId, articleId);
    }
    wrapper.orderByDesc(ArticleComment::getCreatedAt);
    List<ArticleComment> comments = articleCommentMapper.selectList(wrapper);
    
    Map<Long, String> articleTitleCache = new HashMap<>();
    Map<Long, User> userCache = new HashMap<>();
    List<Map<String, Object>> result = new ArrayList<>();
    
    for (ArticleComment c : comments) {
        Map<String, Object> item = new HashMap<>();
        item.put("id", c.getId());
        item.put("articleId", c.getArticleId());
        item.put("content", c.getContent());
        item.put("status", c.getStatus());
        item.put("parentId", c.getParentId());
        item.put("replyToId", c.getReplyToId());
        item.put("createdAt", c.getCreatedAt());
        
        // 文章标题
        if (!articleTitleCache.containsKey(c.getArticleId())) {
            Article a = articleMapper.selectById(c.getArticleId());
            articleTitleCache.put(c.getArticleId(), a != null ? a.getTitle() : "");
        }
        item.put("articleTitle", articleTitleCache.get(c.getArticleId()));
        
        // 用户名
        User user = getUserCached(c.getUserId(), userCache);
        item.put("userName", user != null ? (user.getNickname() != null ? user.getNickname() : user.getPhone()) : "匿名");
        item.put("userId", c.getUserId());
        
        result.add(item);
    }
    return result;
}

注意:方法内已有 getUserCached() 辅助方法,可直接复用。

  • [ ] Step 2: 更新 ArticleCommentController.getPendingComments() 签名

    @PostMapping("/comments/pending")
    public Result<List<Map<String, Object>>> getPendingComments(@RequestBody Map<String, Object> params) {
    Long articleId = params.get("articleId") != null ? Long.valueOf(params.get("articleId").toString()) : null;
    return Result.success(articleCommentService.getPendingComments(articleId));
    }
    
  • [ ] Step 3: 编译验证

    cd /app/cfc/cfc-backend && mvn clean compile
    

Task 3: 后端 — 新增管理端评论列表接口

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCommentService.java (新增 getAdminList 方法)
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java (新增接口)

Interfaces:

  • Produces: getAdminList(status, articleId, keyword, page, size)Page<Map<String,Object>>
  • 新 API: POST /api/admin/articles/comments/list

  • [ ] Step 1: ArticleCommentService 新增 getAdminList 方法

    public Page<Map<String, Object>> getAdminList(String status, Long articleId, String keyword, int page, int size) {
    LambdaQueryWrapper<ArticleComment> wrapper = new LambdaQueryWrapper<>();
    if (status != null && !status.isEmpty()) {
        wrapper.eq(ArticleComment::getStatus, status);
    }
    if (articleId != null) {
        wrapper.eq(ArticleComment::getArticleId, articleId);
    }
    if (keyword != null && !keyword.trim().isEmpty()) {
        wrapper.like(ArticleComment::getContent, keyword.trim());
    }
    wrapper.orderByDesc(ArticleComment::getCreatedAt);
        
    Page<ArticleComment> p = articleCommentMapper.selectPage(new Page<>(page, size), wrapper);
        
    Map<Long, String> articleTitleCache = new HashMap<>();
    Map<Long, User> userCache = new HashMap<>();
    List<Map<String, Object>> records = new ArrayList<>();
        
    for (ArticleComment c : p.getRecords()) {
        Map<String, Object> item = new HashMap<>();
        item.put("id", c.getId());
        item.put("articleId", c.getArticleId());
        item.put("content", c.getContent());
        item.put("status", c.getStatus());
        item.put("parentId", c.getParentId());
        item.put("replyToId", c.getReplyToId());
        item.put("auditBy", c.getAuditBy());
        item.put("auditAt", c.getAuditAt());
        item.put("createdAt", c.getCreatedAt());
            
        if (!articleTitleCache.containsKey(c.getArticleId())) {
            Article a = articleMapper.selectById(c.getArticleId());
            articleTitleCache.put(c.getArticleId(), a != null ? a.getTitle() : "");
        }
        item.put("articleTitle", articleTitleCache.get(c.getArticleId()));
            
        User user = getUserCached(c.getUserId(), userCache);
        item.put("userName", user != null ? (user.getNickname() != null ? user.getNickname() : user.getPhone()) : "匿名");
        item.put("userId", c.getUserId());
            
        records.add(item);
    }
        
    Page<Map<String, Object>> result = new Page<>(page, size);
    result.setTotal(p.getTotal());
    result.setRecords(records);
    return result;
    }
    
  • [ ] Step 2: AdminArticleController 新增接口

    @PostMapping("/comments/list")
    public Result<Page<Map<String, Object>>> commentList(@RequestBody Map<String, Object> body) {
    String status = (String) body.get("status");
    Long articleId = body.get("articleId") != null ? Long.valueOf(body.get("articleId").toString()) : null;
    String keyword = (String) body.get("keyword");
    int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
    int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 20;
    return Result.success(articleCommentService.getAdminList(status, articleId, keyword, page, size));
    }
    

需要在 AdminArticleController 中注入 ArticleCommentService

@Resource
private ArticleCommentService articleCommentService;
  • [ ] Step 3: 编译验证

    cd /app/cfc/cfc-backend && mvn clean compile
    

Task 4: Web 管理端 — API 封装

Files:

  • Modify: cfc-web/src/api/article.js

  • [ ] Step 1: 新增评论管理 API

在 article.js 末尾添加:

// 评论管理
export const getCommentList(data) {
  return request({ url: '/api/admin/articles/comments/list', method: 'post', data })
}

export const auditComment(data) {
  return request({ url: '/api/articles/comments/audit', method: 'post', data })
}

检查 CommentReview.vue 中的现有 request 调用方式,保持风格一致。


Task 5: Web 管理端 — 评论审核页面重写

Files:

  • Rewrite: cfc-web/src/views/admin/CommentReview.vue

  • [ ] Step 1: 重写 CommentReview.vue

完整实现包含:

  1. 筛选区:状态下拉框 + 文章搜索输入框 + 搜索按钮
  2. 表格:ID、文章标题(可点击)、评论用户、评论内容、状态标签、审核原因、时间、操作
  3. 分页组件
  4. 驳回弹窗含原因输入
  5. 调用新的 /api/admin/articles/comments/list 接口

    <template>
    <div class="comment-review admin-page">
    <div class="header">
      <h2>评论审核</h2>
      <div class="filters">
        <el-select v-model="query.status" @change="loadList" placeholder="状态筛选" style="width:130px" clearable>
          <el-option label="全部" value="" />
          <el-option label="待审核" value="pending" />
          <el-option label="已通过" value="approved" />
          <el-option label="已驳回" value="rejected" />
        </el-select>
        <el-input v-model="query.keyword" placeholder="搜索评论内容" style="width:200px" clearable @keyup.enter.native="loadList" />
        <el-button type="primary" @click="loadList">搜索</el-button>
      </div>
    </div>
    
    <el-table :data="list" v-loading="loading" border stripe>
      <el-table-column prop="id" label="ID" width="60" />
      <el-table-column label="文章" min-width="200">
        <template slot-scope="{ row }">
          <router-link :to="'/article-edit?id=' + row.articleId" style="color:#409EFF">{{ row.articleTitle || ('文章#' + row.articleId) }}</router-link>
        </template>
      </el-table-column>
      <el-table-column prop="userName" label="评论用户" width="120" />
      <el-table-column label="评论内容" min-width="300">
        <template slot-scope="{ row }">
          <span v-if="row.parentId" style="color:#999">[回复] </span>{{ row.content }}
        </template>
      </el-table-column>
      <el-table-column label="状态" width="100">
        <template slot-scope="{ row }">
          <el-tag :type="row.status === 'approved' ? 'success' : row.status === 'rejected' ? 'danger' : 'warning'">
            {{ { pending: '待审核', approved: '已通过', rejected: '已驳回' }[row.status] || row.status }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="auditReason" label="审核原因" min-width="150" />
      <el-table-column prop="createdAt" label="时间" width="160" />
      <el-table-column label="操作" width="200" fixed="right">
        <template slot-scope="{ row }">
          <el-button size="mini" type="success" @click="doAudit(row.id, 'approved')" :disabled="row.status !== 'pending'">通过</el-button>
          <el-button size="mini" type="danger" @click="openRejectDialog(row.id)" :disabled="row.status !== 'pending'">驳回</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <el-pagination
      @current-change="onPageChange"
      :current-page="query.page"
      :page-size="query.size"
      :total="total"
      layout="total, prev, pager, next"
      style="margin-top:20px;text-align:right"
    />
    
    <el-dialog title="驳回评论" :visible.sync="rejectDialogVisible" width="400px">
      <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
      <span slot="footer">
        <el-button @click="rejectDialogVisible = false">取消</el-button>
        <el-button type="danger" @click="confirmReject">确定驳回</el-button>
      </span>
    </el-dialog>
    </div>
    </template>
    
    <script>
    import request from '@/utils/request'
    export default {
    data() {
    return {
      list: [], loading: false, total: 0,
      query: { status: 'pending', keyword: '', articleId: null, page: 1, size: 20 },
      rejectDialogVisible: false, rejectCommentId: null, rejectReason: ''
    }
    },
    mounted() { this.loadList() },
    methods: {
    async loadList() {
      this.loading = true
      try {
        var res = await request({ url: '/api/admin/articles/comments/list', method: 'post', data: this.query })
        if (res.code === 200) { this.list = res.data.records || []; this.total = res.data.total || 0 }
      } finally { this.loading = false }
    },
    onPageChange(page) { this.query.page = page; this.loadList() },
    async doAudit(id, status) {
      var res = await request({ url: '/api/articles/comments/audit', method: 'post', data: { commentId: id, status } })
      if (res.code === 200) { this.$message.success(status === 'approved' ? '已通过' : '已驳回'); this.loadList() }
      else { this.$message.error(res.message || '操作失败') }
    },
    openRejectDialog(id) { this.rejectCommentId = id; this.rejectReason = ''; this.rejectDialogVisible = true },
    async confirmReject() {
      if (!this.rejectReason.trim()) { this.$message.warning('请填写驳回原因'); return }
      var res = await request({ url: '/api/articles/comments/audit', method: 'post', data: { commentId: this.rejectCommentId, status: 'rejected', auditReason: this.rejectReason } })
      if (res.code === 200) { this.$message.success('已驳回'); this.rejectDialogVisible = false; this.loadList() }
      else { this.$message.error(res.message || '操作失败') }
    }
    }
    }
    </script>
    <style scoped>
    .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px; }
    .filters { display: flex; gap: 10px; align-items: center; }
    </style>
    

Task 6: 小程序端 — 文章作者审核

Files:

  • Modify: cfc-frontend/pages/article-center/article-detail.vue
  • Modify: cfc-frontend/utils/api.js

  • [ ] Step 1: api.js 新增 pending 评论接口

utils/api.jsgetArticleComments 附近添加:

export const getPendingComments = (data) => request('/api/articles/comments/pending', 'POST', data)
  • Step 2: article-detail.vue 增加作者审核能力

在 data 中新增:

isAuthor: false,
pendingComments: [],
showPendingSection: false

loadDetail 成功后增加:

var currentUserId = uni.getStorageSync('userId')
this.isAuthor = res.data.createdBy == currentUserId
if (this.isAuthor) this.loadPendingComments()

新增方法:

async loadPendingComments() {
  if (!this.articleId) return
  try {
    var res = await getPendingComments({ articleId: this.articleId })
    if (res.code === 200) this.pendingComments = res.data || []
  } catch (e) {}
},

async auditComment(commentId, status) {
  var reason = ''
  if (status === 'rejected') {
    var r = await new Promise(function(resolve) {
      uni.showModal({ title: '驳回评论', editable: true, placeholderText: '请输入驳回原因...', success: function(res) { resolve(res.confirm ? res.content : '') } })
    })
    if (!r) return // 用户取消
    reason = r
  }
  try {
    var res = await createArticleComment // 需要新建 audit API
  }
}

etc. More details.

Wait - the frontend doesn't have an import for the audit API yet. The article-detail.vue already imports APIs from @/utils/api.js. I need to also add audit API to api.js.

Let me refine this:

In api.js add:

export const auditArticleComment = (data) => request('/api/articles/comments/audit', 'POST', data)

In article-detail.vue, add to imports: auditArticleComment

And the template should show pending comments section when isAuthor && pendingComments.length > 0.

  • Step 3: 完善 article-detail.vue 模板

在评论区的 comment-section 内部,已审核评论之前,添加:

<view v-if="isAuthor && pendingComments.length > 0" class="pending-section">
  <view class="comment-header">
    <text class="comment-title">待审核评论 ({{ pendingComments.length }})</text>
  </view>
  <view v-for="(c, i) in pendingComments" :key="'p'+c.id" class="comment-item pending-item">
    <view class="comment-user">
      <text class="comment-avatar">{{ (c.userName || '?').charAt(0) }}</text>
      <text class="comment-name">{{ c.userName || '匿名' }}</text>
      <text class="comment-time">{{ formatDate(c.createdAt) }}</text>
    </view>
    <view class="comment-content">{{ c.content }}</view>
    <view class="audit-actions">
      <button class="audit-btn approve" @click="auditComment(c.id, 'approved')">通过</button>
      <button class="audit-btn reject" @click="auditComment(c.id, 'rejected')">驳回</button>
    </view>
  </view>
  <view class="divider"></view>
</view>

样式添加:

.pending-item { background: #FFFBF0; border-radius: 8rpx; padding: 16rpx; margin-bottom: 12rpx; }
.audit-actions { display: flex; gap: 16rpx; padding-left: 52rpx; margin-top: 12rpx; }
.audit-btn { height: 56rpx; line-height: 56rpx; padding: 0 24rpx; border-radius: 28rpx; font-size: 24rpx; border: none; }
.audit-btn.approve { background: #10B981; color: #fff; }
.audit-btn.reject { background: #EF4444; color: #fff; }

执行顺序

  1. Task 1(后端权限) → Task 2(列表增强) → Task 3(管理端接口)
  2. Task 4(Web API 封装)+ Task 5(Web 页面重写)
  3. Task 6(小程序端)

Task 4 和 5 可并行执行(都在 cfc-web 中),Task 6 独立。