Spring Boot + Vue 3 构建技术社区投稿审核系统实战

Spring BootVue 3投稿系统
于 2026-08-04 04:08:00 修改
·本内容遵循CC 4.0 BY-SA版权协议

最近在整理技术社区内容时,发现很多开发者朋友在分享自己的项目或学习笔记时,常常会遇到一个问题:如何让自己的内容被更多人看到,如何参与到一个有活力的技术交流圈子里?这让我想到了一个有趣的模式——“粉丝空间站”。这并非一个具体的软件或框架,而是一种社区运营与内容共创的虚拟概念模型。它模拟了一个技术爱好者聚集、分享、投稿、互动的线上空间。本文将围绕如何构建这样一个“技术粉丝空间站”的核心机制——投稿规则系统,从零开始,用代码实现一套简易但完整的投稿、审核与展示流程。无论你是想学习社区功能开发,还是想为自己的开源项目增加互动板块,这篇文章都能提供一套可直接复用的实战方案。

1. 背景与核心概念:什么是“技术粉丝空间站”?

在开源社区和技术论坛中,一个健康的生态离不开优质内容的持续产出和有序管理。“粉丝空间站”在这里是一个比喻,它代表了一个专属于某技术领域或项目的互动社区。在这个“空间站”里,“粉丝”(即开发者用户)可以投稿自己的技术文章、代码片段、项目经验等;“站方”(即社区维护者)则需要一套规则来审核、分类和展示这些投稿,确保内容质量并激发更多人的参与热情。

核心要解决的问题:

  1. 内容收集:如何提供一个便捷的入口,让用户提交内容(投稿)?
  2. 规则审核:如何根据预设规则(如格式要求、关键词过滤、内容质量)自动或手动审核投稿?
  3. 状态管理:如何清晰地跟踪一篇投稿的完整生命周期(如:待审核、审核中、已通过、已拒绝、已发布)?
  4. 内容展示:如何将审核通过的内容,美观、有序地展示给所有访客?

本文将使用 Spring Boot 作为后端框架,Vue 3 作为前端框架,实现一个涵盖上述流程的简易系统。通过这个实战项目,你将掌握前后端分离架构下,一个典型用户内容生成(UGC)功能模块的设计与开发。

2. 环境准备与版本说明

在开始编码之前,请确保你的开发环境已就绪。以下是本文示例所使用的主要技术栈及版本,你可以根据实际情况进行调整。

后端环境 (Spring Boot):

  • JDK: 17 或 21 (推荐17,长期支持版本)
  • 构建工具: Maven 3.6+
  • 框架: Spring Boot 3.1.x
  • 数据库: MySQL 8.0 (本地或远程实例)
  • IDE: IntelliJ IDEA 或 VS Code

前端环境 (Vue 3):

  • Node.js: 18.x 或 20.x
  • 包管理工具: npm 或 yarn
  • 框架: Vue 3 + TypeScript
  • UI 库: Element Plus (用于快速构建界面)
  • 构建工具: Vite
  • IDE: VS Code 或 WebStorm

项目结构预览:

TEXT
fans-space-station/
├── backend/ # Spring Boot 后端项目
│ ├── src/main/java/com/example/space/
│ │ ├── controller/ # 控制器层,处理HTTP请求
│ │ ├── service/ # 业务逻辑层
│ │ ├── repository/ # 数据访问层 (使用JPA)
│ │ ├── entity/ # 实体类,对应数据库表
│ │ ├── dto/ # 数据传输对象
│ │ └── config/ # 配置类
│ └── src/main/resources/
│ └── application.yml # 应用配置文件
└── frontend/ # Vue 3 前端项目
├── src/
│ ├── views/ # 页面组件
│ ├── components/ # 可复用组件
│ ├── router/ # 路由配置
│ ├── api/ # 后端接口封装
│ └── stores/ # 状态管理 (Pinia)
└── vite.config.ts # Vite 配置

3. 核心数据模型与业务规则设计

任何系统的核心都是其数据模型。我们先设计投稿(Contribution)这个核心实体,并定义其状态流转规则。

3.1 投稿实体设计

一篇投稿至少包含以下信息:

JAVA
// 文件路径:backend/src/main/java/com/example/space/entity/Contribution.java
package com.example.space.entity;
 
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
 
@Entity
@Data
@Table(name = "contribution")
public class Contribution {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
 
@Column(nullable = false)
private String title; // 投稿标题
 
@Column(columnDefinition = "TEXT")
private String content; // 投稿内容 (支持Markdown)
 
@Column(nullable = false)
private String author; // 投稿人昵称
 
@Column(nullable = false)
private String contact; // 联系方式 (如邮箱)
 
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ContributionStatus status; // 投稿状态
 
@Column
private String reviewerComment; // 审核人批注
 
@Column(updatable = false)
private LocalDateTime createTime; // 创建时间
 
@Column
private LocalDateTime updateTime; // 更新时间
 
@PrePersist
protected void onCreate() {
createTime = LocalDateTime.now();
status = ContributionStatus.PENDING; // 新建投稿默认为“待审核”
}
 
@PreUpdate
protected void onUpdate() {
updateTime = LocalDateTime.now();
}
}

3.2 投稿状态枚举

投稿的生命周期由状态驱动,我们定义一个枚举类:

JAVA
// 文件路径:backend/src/main/java/com/example/space/entity/ContributionStatus.java
package com.example.space.entity;
 
public enum ContributionStatus {
PENDING, // 待审核 (用户刚提交)
UNDER_REVIEW, // 审核中 (管理员已开始处理)
APPROVED, // 已通过 (审核通过,等待发布或已发布)
REJECTED, // 已拒绝 (审核不通过)
PUBLISHED // 已发布 (在空间站公开展示)
}

状态流转说明:

  1. 用户提交 → PENDING
  2. 管理员开始处理 → UNDER_REVIEW
  3. 审核通过 → APPROVED
  4. 审核拒绝 → REJECTED (可附reviewerComment说明原因)
  5. 管理员将已通过的内容发布到前台 → PUBLISHED
  6. 状态可以从 APPROVEDPUBLISHED 回退到 REJECTED,反之亦然,这取决于管理员的二次操作。

3.3 投稿规则设计(业务逻辑核心)

“投稿规则”是本文的重点。我们将其抽象为一个可配置的规则检查器。规则可以包括:

  1. 基础格式规则:标题、内容不能为空,且长度在合理范围内。
  2. 关键词过滤规则:内容中不得包含预设的违禁词。
  3. 内容质量规则(示例):内容不能过于简短(如少于100字)。
  4. 重复性检查规则(示例):标题不能与已存在的投稿过于相似。

我们将创建一个 SubmissionRuleValidator 服务类来封装这些规则检查。

JAVA
// 文件路径:backend/src/main/java/com/example/space/service/rule/SubmissionRuleValidator.java
package com.example.space.service.rule;
 
import com.example.space.dto.ContributionSubmissionDTO;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Arrays;
import java.util.List;
 
@Component
public class SubmissionRuleValidator {
 
// 模拟一个违禁词列表,实际应从数据库或配置中心加载
private static final List<String> FORBIDDEN_WORDS = Arrays.asList("广告", "赌博", "敏感词A", "敏感词B");
 
/**
* 验证投稿是否符合规则
* @param dto 投稿数据
* @return 验证结果。如果通过,返回空字符串;否则返回错误信息。
*/
public String validate(ContributionSubmissionDTO dto) {
// 规则1:基础非空与长度检查
if (!StringUtils.hasText(dto.getTitle())) {
return "投稿标题不能为空";
}
if (dto.getTitle().length() > 100) {
return "投稿标题长度不能超过100个字符";
}
if (!StringUtils.hasText(dto.getContent())) {
return "投稿内容不能为空";
}
if (dto.getContent().length() < 100) {
return "投稿内容至少需要100个字符,以保证内容质量";
}
if (dto.getContent().length() > 10000) {
return "投稿内容长度不能超过10000个字符";
}
 
// 规则2:违禁词检查
for (String word : FORBIDDEN_WORDS) {
if (dto.getTitle().contains(word) || dto.getContent().contains(word)) {
return String.format("投稿内容包含不允许的词汇: 【%s】", word);
}
}
 
// 规则3:其他自定义规则可以在此扩展
// 例如:检查联系方式格式
if (!isValidContact(dto.getContact())) {
return "联系方式格式不正确,请提供有效的邮箱地址";
}
 
// 所有规则通过
return "";
}
 
private boolean isValidContact(String contact) {
// 简单的邮箱格式验证,实际项目应使用更严谨的正则表达式
return contact != null && contact.matches("^[A-Za-z0-9+_.-]+@(.+)$");
}
}

这个验证器会在用户提交投稿时被调用,如果返回错误信息,则投稿请求会被拒绝,并告知用户具体原因。

4. 完整实战:构建投稿与审核系统

接下来,我们从前到后实现整个流程。

4.1 后端实现:Spring Boot 接口开发

第一步:创建数据访问层 使用 Spring Data JPA 简化数据库操作。

JAVA
// 文件路径:backend/src/main/java/com/example/space/repository/ContributionRepository.java
package com.example.space.repository;
 
import com.example.space.entity.Contribution;
import com.example.space.entity.ContributionStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
 
@Repository
public interface ContributionRepository extends JpaRepository<Contribution, Long> {
// 根据状态查找投稿
List<Contribution> findByStatus(ContributionStatus status);
// 检查是否存在相似标题的投稿(用于查重,此处为简单示例)
boolean existsByTitleAndStatusNot(String title, ContributionStatus excludedStatus);
}

第二步:创建数据传输对象(DTO) 用于在前后端之间传输数据,与实体类解耦。

JAVA
// 文件路径:backend/src/main/java/com/example/space/dto/ContributionSubmissionDTO.java
package com.example.space.dto;
 
import lombok.Data;
 
@Data
public class ContributionSubmissionDTO {
private String title;
private String content;
private String author;
private String contact;
}
JAVA
// 文件路径:backend/src/main/java/com/example/space/dto/ContributionDTO.java
package com.example.space.dto;
 
import com.example.space.entity.ContributionStatus;
import lombok.Data;
import java.time.LocalDateTime;
 
@Data
public class ContributionDTO {
private Long id;
private String title;
private String author;
private ContributionStatus status;
private LocalDateTime createTime;
// 注意:审核批注和详细内容可能只在管理接口或详情页返回
private String reviewerComment;
private String content;
}

第三步:创建业务逻辑层(Service) 处理核心业务,包括规则验证和状态变更。

JAVA
// 文件路径:backend/src/main/java/com/example/space/service/ContributionService.java
package com.example.space.service;
 
import com.example.space.dto.ContributionDTO;
import com.example.space.dto.ContributionSubmissionDTO;
import com.example.space.entity.Contribution;
import com.example.space.entity.ContributionStatus;
import com.example.space.repository.ContributionRepository;
import com.example.space.service.rule.SubmissionRuleValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
 
@Service
@RequiredArgsConstructor
public class ContributionService {
 
private final ContributionRepository contributionRepository;
private final SubmissionRuleValidator ruleValidator;
 
/**
* 用户提交投稿
*/
@Transactional
public ContributionDTO submitContribution(ContributionSubmissionDTO dto) {
// 1. 规则验证
String validationError = ruleValidator.validate(dto);
if (!validationError.isEmpty()) {
throw new IllegalArgumentException("投稿验证失败: " + validationError);
}
 
// 2. 保存到数据库
Contribution contribution = new Contribution();
contribution.setTitle(dto.getTitle());
contribution.setContent(dto.getContent());
contribution.setAuthor(dto.getAuthor());
contribution.setContact(dto.getContact());
// 状态已在实体类的 @PrePersist 中设置为 PENDING
 
Contribution saved = contributionRepository.save(contribution);
return convertToDTO(saved);
}
 
/**
* 管理员获取投稿列表(可按状态过滤)
*/
public List<ContributionDTO> getContributions(ContributionStatus status) {
List<Contribution> contributions;
if (status != null) {
contributions = contributionRepository.findByStatus(status);
} else {
contributions = contributionRepository.findAll();
}
return contributions.stream().map(this::convertToDTO).collect(Collectors.toList());
}
 
/**
* 管理员审核投稿
* @param id 投稿ID
* @param newStatus 目标状态 (APPROVED 或 REJECTED)
* @param comment 审核批注
*/
@Transactional
public ContributionDTO reviewContribution(Long id, ContributionStatus newStatus, String comment) {
if (newStatus != ContributionStatus.APPROVED && newStatus != ContributionStatus.REJECTED) {
throw new IllegalArgumentException("审核操作只支持更改为 APPROVED 或 REJECTED 状态");
}
 
Contribution contribution = contributionRepository.findById(id)
.orElseThrow(() -> new RuntimeException("投稿不存在,ID: " + id));
 
// 检查当前状态是否允许审核(例如,只有 PENDING 或 UNDER_REVIEW 状态才能被审核)
if (contribution.getStatus() != ContributionStatus.PENDING && contribution.getStatus() != ContributionStatus.UNDER_REVIEW) {
throw new IllegalStateException("当前投稿状态不允许执行审核操作");
}
 
contribution.setStatus(newStatus);
contribution.setReviewerComment(comment);
Contribution saved = contributionRepository.save(contribution);
return convertToDTO(saved);
}
 
/**
* 将已审核通过的投稿发布
*/
@Transactional
public ContributionDTO publishContribution(Long id) {
Contribution contribution = contributionRepository.findById(id)
.orElseThrow(() -> new RuntimeException("投稿不存在,ID: " + id));
 
if (contribution.getStatus() != ContributionStatus.APPROVED) {
throw new IllegalStateException("只有状态为 APPROVED 的投稿才能被发布");
}
 
contribution.setStatus(ContributionStatus.PUBLISHED);
Contribution saved = contributionRepository.save(contribution);
return convertToDTO(saved);
}
 
/**
* 前台获取已发布的投稿列表
*/
public List<ContributionDTO> getPublishedContributions() {
List<Contribution> contributions = contributionRepository.findByStatus(ContributionStatus.PUBLISHED);
return contributions.stream().map(this::convertToDTO).collect(Collectors.toList());
}
 
private ContributionDTO convertToDTO(Contribution contribution) {
ContributionDTO dto = new ContributionDTO();
dto.setId(contribution.getId());
dto.setTitle(contribution.getTitle());
dto.setAuthor(contribution.getAuthor());
dto.setStatus(contribution.getStatus());
dto.setCreateTime(contribution.getCreateTime());
dto.setReviewerComment(contribution.getReviewerComment());
// 注意:在列表接口中,可能不返回完整的 content 以节省带宽
// dto.setContent(contribution.getContent());
return dto;
}
}

第四步:创建控制器层(Controller) 提供 RESTful API 给前端调用。

JAVA
// 文件路径:backend/src/main/java/com/example/space/controller/ContributionController.java
package com.example.space.controller;
 
import com.example.space.dto.ContributionDTO;
import com.example.space.dto.ContributionSubmissionDTO;
import com.example.space.entity.ContributionStatus;
import com.example.space.service.ContributionService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
 
@RestController
@RequestMapping("/api/contributions")
@RequiredArgsConstructor
public class ContributionController {
 
private final ContributionService contributionService;
 
// 用户投稿接口
@PostMapping("/submit")
public ResponseEntity<ContributionDTO> submit(@RequestBody ContributionSubmissionDTO dto) {
ContributionDTO result = contributionService.submitContribution(dto);
return ResponseEntity.ok(result);
}
 
// 管理员获取投稿列表(带状态过滤)
@GetMapping("/admin")
public ResponseEntity<List<ContributionDTO>> getContributionsForAdmin(
@RequestParam(required = false) ContributionStatus status) {
List<ContributionDTO> list = contributionService.getContributions(status);
return ResponseEntity.ok(list);
}
 
// 管理员审核接口
@PostMapping("/admin/{id}/review")
public ResponseEntity<ContributionDTO> review(
@PathVariable Long id,
@RequestParam ContributionStatus status,
@RequestParam(required = false) String comment) {
ContributionDTO result = contributionService.reviewContribution(id, status, comment);
return ResponseEntity.ok(result);
}
 
// 管理员发布接口
@PostMapping("/admin/{id}/publish")
public ResponseEntity<ContributionDTO> publish(@PathVariable Long id) {
ContributionDTO result = contributionService.publishContribution(id);
return ResponseEntity.ok(result);
}
 
// 前台获取已发布的投稿列表(公开接口)
@GetMapping("/published")
public ResponseEntity<List<ContributionDTO>> getPublishedContributions() {
List<ContributionDTO> list = contributionService.getPublishedContributions();
return ResponseEntity.ok(list);
}
}

第五步:配置文件 配置数据库连接和服务器端口。

YAML
# 文件路径:backend/src/main/resources/application.yml
spring:
application:
name: fans-space-station
datasource:
url: jdbc:mysql://localhost:3306/space_station_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: your_username
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update # 开发环境使用,生产环境应改为 validate 或 none,并使用 migration 工具
show-sql: true
properties:
hibernate:
format_sql: true
 
server:
port: 8080
 
# 允许跨域请求(前端开发时使用)
cors:
allowed-origins: http://localhost:5173

4.2 前端实现:Vue 3 + Element Plus 界面

第一步:创建投稿表单页面

VUE
<!-- 文件路径:frontend/src/views/SubmitView.vue -->
<template>
<div class="submit-container">
<h2>向粉丝空间站投稿</h2>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="投稿标题" prop="title">
<el-input v-model="form.title" placeholder="请输入清晰、吸引人的标题" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="投稿内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="10"
placeholder="请详细描述你的技术分享、项目经验或学习笔记(支持Markdown语法)"
maxlength="10000"
show-word-limit
/>
<div class="tip">内容需不少于100字,且不得包含违禁词汇。</div>
</el-form-item>
<el-form-item label="你的昵称" prop="author">
<el-input v-model="form.author" placeholder="用于展示的昵称" />
</el-form-item>
<el-form-item label="联系方式" prop="contact">
<el-input v-model="form.contact" placeholder="用于接收审核通知的邮箱" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm" :loading="submitting">提交投稿</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</div>
</template>
 
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { submitContribution } from '@/api/contribution'
 
interface SubmitForm {
title: string
content: string
author: string
contact: string
}
 
const formRef = ref<FormInstance>()
const submitting = ref(false)
 
const form = reactive<SubmitForm>({
title: '',
content: '',
author: '',
contact: ''
})
 
const validateContent = (rule: any, value: string, callback: any) => {
if (value.length < 100) {
callback(new Error('内容至少需要100个字符'))
} else {
callback()
}
}
 
const validateEmail = (rule: any, value: string, callback: any) => {
const emailRegex = /^[A-Za-z0-9+_.-]+@(.+)$/
if (!emailRegex.test(value)) {
callback(new Error('请输入有效的邮箱地址'))
} else {
callback()
}
}
 
const rules = reactive<FormRules<SubmitForm>>({
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ min: 5, max: 100, message: '标题长度在 5 到 100 个字符', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入内容', trigger: 'blur' },
{ validator: validateContent, trigger: 'blur' }
],
author: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' }
],
contact: [
{ required: true, message: '请输入联系方式', trigger: 'blur' },
{ validator: validateEmail, trigger: 'blur' }
]
})
 
const submitForm = () => {
formRef.value?.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
await submitContribution(form)
ElMessageBox.alert('投稿提交成功!请耐心等待管理员审核。审核结果将通过邮件通知您。', '提交成功', {
confirmButtonText: '确定',
callback: () => {
resetForm()
}
})
} catch (error: any) {
ElMessage.error(`提交失败: ${error.response?.data?.message || error.message}`)
} finally {
submitting.value = false
}
}
})
}
 
const resetForm = () => {
formRef.value?.resetFields()
}
</script>
 
<style scoped>
.submit-container {
max-width: 800px;
margin: 40px auto;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
</style>

第二步:封装 API 请求

TYPESCRIPT
// 文件路径:frontend/src/api/contribution.ts
import axios from 'axios'
import type { ContributionDTO, ContributionSubmissionDTO } from '@/types/contribution'
 
// 创建 axios 实例
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
timeout: 10000
})
 
// 投稿提交
export const submitContribution = (data: ContributionSubmissionDTO) => {
return request.post<ContributionDTO>('/contributions/submit', data)
}
 
// 获取已发布的投稿列表(前台)
export const getPublishedContributions = () => {
return request.get<ContributionDTO[]>('/contributions/published')
}
 
// --- 管理员接口(通常需要身份验证)---
// 获取投稿列表(带状态过滤)
export const getContributionsForAdmin = (status?: string) => {
return request.get<ContributionDTO[]>('/contributions/admin', { params: { status } })
}
 
// 审核投稿
export const reviewContribution = (id: number, status: string, comment?: string) => {
return request.post<ContributionDTO>(`/contributions/admin/${id}/review`, null, {
params: { status, comment }
})
}
 
// 发布投稿
export const publishContribution = (id: number) => {
return request.post<ContributionDTO>(`/contributions/admin/${id}/publish`)
}

第三步:创建管理员审核页面(关键功能) 由于篇幅限制,这里展示管理员审核页面的核心表格和操作逻辑。

VUE
<!-- 文件路径:frontend/src/views/admin/ReviewView.vue (部分代码) -->
<template>
<div>
<h2>投稿审核管理</h2>
<el-radio-group v-model="filterStatus" @change="loadData">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="PENDING">待审核</el-radio-button>
<el-radio-button label="UNDER_REVIEW">审核中</el-radio-button>
<el-radio-button label="APPROVED">已通过</el-radio-button>
<el-radio-button label="REJECTED">已拒绝</el-radio-button>
<el-radio-button label="PUBLISHED">已发布</el-radio-button>
</el-radio-group>
 
<el-table :data="tableData" style="width: 100%; margin-top: 20px">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="author" label="作者" width="120" />
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="getStatusTagType(scope.row.status)">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="投稿时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="scope">
<el-button size="small" @click="showDetail(scope.row)">详情</el-button>
<el-button
v-if="scope.row.status === 'PENDING' || scope.row.status === 'UNDER_REVIEW'"
size="small"
type="success"
@click="handleReview(scope.row, 'APPROVED')"
>通过</el-button>
<el-button
v-if="scope.row.status === 'PENDING' || scope.row.status === 'UNDER_REVIEW'"
size="small"
type="danger"
@click="handleReview(scope.row, 'REJECTED')"
>拒绝</el-button>
<el-button
v-if="scope.row.status === 'APPROVED'"
size="small"
type="primary"
@click="handlePublish(scope.row)"
>发布</el-button>
</template>
</el-table-column>
</el-table>
<!-- 详情对话框、审核对话框等组件省略 -->
</div>
</template>
 
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getContributionsForAdmin, reviewContribution, publishContribution } from '@/api/contribution'
import type { ContributionDTO } from '@/types/contribution'
 
const filterStatus = ref('')
const tableData = ref<ContributionDTO[]>([])
 
const loadData = async () => {
try {
const res = await getContributionsForAdmin(filterStatus.value || undefined)
tableData.value = res.data
} catch (error) {
ElMessage.error('加载数据失败')
}
}
 
const handleReview = async (row: ContributionDTO, targetStatus: string) => {
try {
let comment = ''
if (targetStatus === 'REJECTED') {
const result = await ElMessageBox.prompt('请输入拒绝理由', '审核拒绝', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /.+/,
inputErrorMessage: '拒绝理由不能为空'
})
comment = result.value
}
await reviewContribution(row.id!, targetStatus, comment)
ElMessage.success('操作成功')
loadData()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(`操作失败: ${error.message}`)
}
}
}
 
const handlePublish = async (row: ContributionDTO) => {
try {
await ElMessageBox.confirm(`确定要发布投稿《${row.title}》吗?`, '确认发布', { type: 'warning' })
await publishContribution(row.id!)
ElMessage.success('发布成功')
loadData()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(`发布失败: ${error.message}`)
}
}
}
 
// 初始化加载
onMounted(() => {
loadData()
})
</script>

4.3 运行与验证

  1. 启动后端:在 backend 目录下运行 mvn spring-boot:run,确保 MySQL 服务已启动且数据库 space_station_db 存在。
  2. 启动前端:在 frontend 目录下运行 npm run dev
  3. 访问前端:打开浏览器访问 http://localhost:5173
  4. 测试流程
    • 用户侧:访问投稿页面,填写表单并提交。观察控制台和后端日志,确认数据入库且状态为 PENDING
    • 管理侧:访问管理页面(需要实现登录拦截,此处简化),查看投稿列表。对一条 PENDING 状态的投稿执行“通过”或“拒绝”操作。观察状态变化和 reviewer_comment 字段。
    • 发布侧:对一条 APPROVED 状态的投稿执行“发布”操作,状态应变为 PUBLISHED
    • 展示侧:访问公开的投稿列表页面(/published 接口),应只看到状态为 PUBLISHED 的投稿。

5. 常见问题与排查思路

在实现和运行上述系统时,你可能会遇到以下典型问题:

问题现象 常见原因 解决思路
前端提交投稿后,收到“投稿验证失败”错误。 1. 投稿内容少于100字。
2. 标题或内容包含违禁词。
3. 联系方式邮箱格式不正确。
1. 检查前端表单验证规则是否与后端一致。
2. 查看后端 SubmissionRuleValidator 返回的具体错误信息。
3. 在前端增加更即时的输入提示和验证。
管理员审核时,提示“当前投稿状态不允许执行审核操作”。 1. 投稿状态已不是 PENDINGUNDER_REVIEW(可能已被其他管理员处理)。
2. 前端传递的状态枚举值不正确。
1. 在审核前,重新加载列表获取最新状态。
2. 在后端 reviewContribution 方法中打印日志,确认传入的 idnewStatus 参数。
3. 考虑引入乐观锁机制防止并发修改。
前端调用后端 API 时出现跨域 (CORS) 错误。 后端未正确配置跨域。 1. 在后端增加一个 WebMvcConfigurer 配置类,允许前端的源(如 http://localhost:5173)。
2. 检查 application.yml 中的 cors.allowed-origins 配置是否生效。
数据库表没有自动创建。 1. JPA 的 ddl-auto 配置为 none
2. 数据库连接失败。
3. 实体类注解(如 @Entity, @Table)缺失或错误。
1. 开发环境可暂时设为 update
2. 检查数据库 URL、用户名、密码。
3. 检查实体类所在的包是否在 Spring Boot 主应用类的扫描路径下。
投稿列表页面加载缓慢。 1. 数据量过大,一次性查询所有字段(如 content)。
2. 没有分页。
1. 在列表查询的 DTO 转换中,不要包含大字段 content
2. 在后端 Service 和 Repository 中实现分页查询(使用 Pageable)。
3. 前端表格组件使用分页。

6. 最佳实践与工程建议

将“粉丝空间站”投稿系统投入实际项目时,以下最佳实践能显著提升其健壮性、安全性和可维护性。

  1. 规则引擎化

    • 现状:规则硬编码在 SubmissionRuleValidator 中。
    • 优化:将规则(如违禁词、最小字数、标题格式正则)存储在数据库或配置中心(如 Apollo、Nacos)。创建一个 RuleEngine 服务,动态加载和评估规则。这样无需重启服务即可修改规则。
  2. 状态机显式化

    • 现状:状态流转逻辑散落在 Service 方法的 if 判断中。
    • 优化:使用状态机框架(如 Spring State Machine)明确定义 Contribution 的状态、事件和迁移条件。这使状态流转一目了然,易于维护和扩展。
  3. 权限控制精细化

    • 现状:管理接口没有鉴权。
    • 优化:集成 Spring Security。为 ContributionController 的管理员接口(/api/contributions/admin/**)添加 @PreAuthorize("hasRole('ADMIN')") 注解。投稿接口可设为公开或需要用户登录。
  4. 操作日志与审计

    • 记录所有关键操作,如投稿提交、状态变更(审核、发布)。创建一张 audit_log 表,记录操作人、时间、IP、动作类型、目标ID和变更详情。这对于问题追溯和数据分析至关重要。
  5. 异步处理与通知

    • 投稿提交、审核通过/拒绝等操作可以放入消息队列(如 RabbitMQ、Kafka)进行异步处理。
    • 使用邮件或站内信服务异步通知用户审核结果,避免阻塞主请求线程。
  6. 内容安全与过滤

    • 除了违禁词,还应考虑防范 XSS 攻击。用户提交的 content(尤其是支持 HTML/Markdown 时)在存储和展示前必须进行转义或使用安全的渲染库(如前端 DOMPurify)。
    • 对于图片、文件上传功能,必须进行严格的类型、大小检查和病毒扫描。
  7. 性能与可扩展性

    • 缓存:已发布的、不常变的投稿列表可以缓存在 Redis 中,设置合理的过期时间。
    • 搜索:当投稿量很大时,集成 Elasticsearch 提供全文搜索功能,比数据库 LIKE 查询高效得多。
    • 微服务化:如果系统规模扩大,可以将投稿服务、审核服务、用户服务、通知服务拆分为独立的微服务。
  8. 前端体验优化

    • Markdown 编辑器:集成一个功能完善的 Markdown 编辑器(如 @bytemd/vue-next),提供实时预览,提升用户投稿体验。
    • 自动保存草稿:使用 localStorage 或防抖技术,在用户输入时自动保存草稿,防止意外丢失。
    • 加载状态与反馈:所有按钮操作都应配有加载状态,并使用 ElMessage 给予明确的操作成功/失败反馈。

通过以上步骤,我们不仅实现了一个可运行的“粉丝空间站”投稿系统原型,更深入探讨了其背后的设计思想、常见问题解决方案以及面向生产的优化方向。这套代码和思路可以直接作为你下一个社区类项目的基础模块,根据实际需求进行裁剪和增强。