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 小程序
@PostMapping?.(用 && 替代)@ResourceResult<T>Files:
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCommentService.java (auditComment 方法)cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java (auditComment 方法)Interfaces:
ArticleCommentMapper, ArticleMapper, UserMapper (已有注入)Produces: auditComment(commentId, auditorId, status, role) — 新增 role 参数;拒绝时抛 RuntimeException("无权限审核该评论")
[ ] Step 1: ArticleCommentService.auditComment() 增加权限校验
在 auditComment() 方法开头增加逻辑:
ArticleComment,获取 articleIdArticle,获取 createdBy(文章作者ID)如果 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)
添加 @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
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCommentService.java (getPendingComments 方法)cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.javaInterfaces:
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
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCommentService.java (新增 getAdminList 方法)cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java (新增接口)Interfaces:
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
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 调用方式,保持风格一致。
Files:
Rewrite: cfc-web/src/views/admin/CommentReview.vue
[ ] Step 1: 重写 CommentReview.vue
完整实现包含:
调用新的 /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>
Files:
cfc-frontend/pages/article-center/article-detail.vueModify: cfc-frontend/utils/api.js
[ ] Step 1: api.js 新增 pending 评论接口
在 utils/api.js 中 getArticleComments 附近添加:
export const getPendingComments = (data) => request('/api/articles/comments/pending', 'POST', data)
在 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.
在评论区的 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; }
Task 4 和 5 可并行执行(都在 cfc-web 中),Task 6 独立。