校园社交平台实战:Spring Boot + React 的帖子审核状态机与权限设计

发布时间:2026/9/16 16:17:10
校园社交平台实战:Spring Boot + React 的帖子审核状态机与权限设计 简介基于React与Spring Boot的前后端分离项目整理为一套完整校园社交平台源码面向高校学生及Java全栈初学者可满足课程设计、毕业设计或第二课堂项目需求。平台覆盖用户注册登录、动态发布点赞、个人资料维护以及管理员对用户和帖子的增删改查、帖子审核等功能形成完整业务闭环。压缩包约1.39MB内含前后端工程文件与基础配置便于导入开发环境后运行调试。目前已有112人学习该资源。项目前端采用React组件化开发后端使用Spring Boot提供RESTful接口结构清晰可帮助读者理解前后端分离架构、权限控制、状态管理等核心知识点也能在此基础上扩展二手交易、求助广场等校园特色模块快速构建同类应用。1. 为什么校园社交平台要先解决“审核态”而不是先做页面大学里的动态、二手交易、失物招领这些内容天然有“发布即展示”和“管理员事后兜底”两种诉求互相拉扯。只做用户发帖的论坛运营一段时间必然被广告和违规内容淹没而审核链路太重又会把学生分享欲压没。这个基于 React Spring Boot 的校园社交平台把答案放在了帖子的状态机里用 PENDING、APPROVED、REJECTED 三个状态平衡发布体验和内容治理。对正在学前后端分离的开发者来说它是一个把 JWT 鉴权、分页动态流、管理员权限、审核工作台串成闭环的完整样本对有经验的人来讲拆这个项目的价值在于看数据模型设计、权限控制粒度、前后端联调时最容易爆的雷。2. Spring Boot侧的数据模型、权限控制与帖子审核接口前后端分离项目里后端最容易犯的错是只做 CRUD把权限和状态流转全丢给前端判断。这个项目里真正值得先看的是数据模型和帖子审核接口的设计它们决定了管理端和用户端的行为边界。2.1 数据模型为什么帖子状态要用枚举而不是布尔值动态和用户是最核心的两张表点赞关系单独建表不塞进用户表或帖子表里。帖子表的关键字段可以这样设计建表语句按实际使用的 MySQL 版本微调即可CREATE TABLE post ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 发布者, content varchar(2000) NOT NULL, images varchar(1000) DEFAULT NULL COMMENT 图片地址逗号分隔, status varchar(16) NOT NULL DEFAULT PENDING COMMENT PENDING/APPROVED/REJECTED, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_status_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;很多教学项目会把帖子可见性设计成visible布尔值这在小 demo 里能用但真实运营时说不清“还没审”和“被拒绝了”的区别。用三态枚举后用户端列表永远查APPROVED管理员端默认查PENDING状态流转时还能记录操作时间为后续的申诉功能留退路。这是本项目里我认为最有复用价值的设计决策。对应到 JPA 实体常见做法是Table(name post) public class Post { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, length 2000) private String content; private String images; Enumerated(EnumType.STRING) Column(nullable false, length 16) private PostStatus status PostStatus.PENDING; Column(name create_time, updatable false) private LocalDateTime createTime LocalDateTime.now(); }Enumerated(EnumType.STRING)存的是字符串而不是枚举序号好处是数据库里直接可读将来在枚举里插值也不会导致旧数据错位。我一般会再加一个version字段做乐观锁防止两个管理员同时审核同一条帖子后面会专门说这个问题。2.2 REST 接口设计与统一返回体前端拿数据不关心实体结构所以接口层要返回 VO 而不是直接吐实体。这个项目的接口大致可以划成这样几组权限划分是硬性的方法路径角色说明POST/api/auth/register匿名注册POST/api/auth/login匿名登录返回 JWTGET/api/postsUSER/ADMIN分页查询动态status 可过滤POST/api/postsUSER发布动态POST/api/posts/{id}/likeUSER点赞或取消点赞GET/api/profileUSER个人信息PUT/api/profileUSER修改个人信息GET/api/admin/usersADMIN用户分页列表PUT/api/admin/users/{id}ADMIN修改用户信息POST/api/admin/usersADMIN添加用户DELETE/api/admin/users/{id}ADMIN删除用户GET/api/admin/postsADMIN帖子管理列表PUT/api/admin/posts/{id}/reviewADMIN帖子审核通过/拒绝这里有个容易忽略的点所有返回都给一个统一包装前端拦截器才方便统一判错。一个极简的ResultT包含code、message、data三个字段即可。接口层的命名不要用save、delete这种没业务含义的词审核接口叫review管理端查询用户叫pageUsers把操作语义写清楚前端对接时不用翻后端代码猜。动态分页接口是用户端流量最大的一个参数设计要克制GetMapping(/posts) public ResultPagePostVO listPosts( RequestParam(defaultValue 0) int page, RequestParam(defaultValue 10) int size, RequestParam(defaultValue APPROVED) PostStatus status, RequestParam(defaultValue createTime,desc) String sort) { // 白名单校验 sort 字段防止任意外传造成排序注入 Pageable pageable PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, createTime)); return Result.success(postService.pagePosts(status, pageable)); }page从 0 开始前端第一页传 0不要传 1这是 Spring Data 的惯例。size一般限制在 10 到 20 之间刷到 50 条以上移动端性能会明显变差。sort参数必须做白名单校验只允许createTime、likeCount等固定字段否则恶意请求可以传任意字段导致数据库排序报错甚至信息泄露。2.3 帖子审核状态机与并发控制审核接口要做的不是把 status 字段一改就完事。它要满足三个约束只有 ADMIN 角色能调、只有 PENDING 状态的帖子能审、审核结果要留下记录。Spring Security 注解可以解决第一个PutMapping(/admin/posts/{id}/review) PreAuthorize(hasRole(ADMIN)) public ResultVoid review(PathVariable Long id, RequestBody ReviewRequest req) { postService.review(id, req.getTarget()); return Result.success(); }核心逻辑放在 Service 层重点看并发时的状态约束。直接先查再改会有竞态两个管理员同时审同一条帖子可能一个显示通过成功、一个显示拒绝成功。常见做法是用带条件的更新语句让数据库帮我们保证原子性Transactional public void review(Long postId, PostStatus target) { // 只有当前状态是 PENDING 时才允许变更 int affected postMapper.updateStatusIfPending(postId, target); if (affected 0) { throw new IllegalStateException(该帖子当前状态不允许审核); } auditLogMapper.insert(postId, target); }对应 MyBatis 的 update 语句就是UPDATE post SET status #{target} WHERE id #{id} AND status PENDING。返回值affected为 0 表示这条帖子已经被别人审过了直接抛异常前端用统一错误提示展示“状态已变更请刷新列表”。这种写法比在应用层加锁简单得多也没有锁竞争开销。审核流里还要注意一个边界拒绝时需要让用户看到原因。ReviewRequest里除了target最好再加一个reason字段拒绝时必填通过时为空。拒绝理由存入单独列或审核流水表用户端动态卡片显示“被拒绝含违规联系方式”这比冷冰冰的“状态已更新”体验好得多。3. React侧的路由守卫与动态社区流实现前端这边很多人一上来就写页面组件拆了一堆最后路由权限和状态同步全乱。这个项目的 React 端核心就两件事用路由守住管理员和普通用户的边界用受控组件和分页把动态流做流畅。3.1 路由分级把角色写进路由配置React Router 在这一版已经到 v6路由配置用对象数组比 JSX 写法更清晰权限角色直接挂在配置上const routes [ { path: /login, element: Login /, meta: { guestOnly: true } }, { path: /, element: Home /, meta: { auth: true } }, { path: /profile, element: Profile /, meta: { auth: true } }, { path: /admin, element: AdminLayout /, meta: { auth: true, roles: [ADMIN] } }, { path: /admin/posts, element: AdminPosts /, meta: { auth: true, roles: [ADMIN] } } ];表驱动路由的好处是权限逻辑收敛在一个组件里不用每个页面自己判断。权限守卫组件ProtectedRoute的做法是提前把用户信息放进 Context 或状态管理库里function ProtectedRoute({ children, roles }) { const { user } useAuth(); const location useLocation(); if (!user) { return Navigate to/login state{{ from: location }} replace /; } if (roles !roles.includes(user.role)) { return Navigate to/403 replace /; } return children; }这里有个面试常问的点为什么把Navigate的state带上因为用户在动态页被踢到登录页登录成功后应该跳回原页面而不是固定在首页。在Login组件里读location.state?.from?.pathname再跳转体验会顺很多。路由守卫只做前端拦截真正防越权还是要靠后端接口的PreAuthorize前端隐藏菜单和路由只是体验优化不是安全边界。3.2 动态流的分页与加载状态用户端动态流是本项目的门面常见实现是“加载更多”按钮或滚动到底自动加载。我建议第一版先用按钮逻辑更直观也方便排查分页参数是否正确function PostFeed() { const [posts, setPosts] useState([]); const [page, setPage] useState(0); const [hasMore, setHasMore] useState(true); const [loading, setLoading] useState(false); const loadPosts useCallback(async () { if (loading || !hasMore) return; setLoading(true); try { const res await api.get(/posts, { params: { page, size: 10, status: APPROVED } }); setPosts(prev [...prev, ...res.data.content]); setHasMore(!res.data.last); setPage(prev prev 1); } finally { setLoading(false); } }, [page, loading, hasMore]); return ( div {posts.map(post PostCard key{post.id} post{post} /)} {hasMore button onClick{loadPosts}加载更多/button} /div ); }注意几个细节setPosts用函数式更新而不是posts.concat因为多次点击加载时闭包里拿到的posts可能是旧值key用帖子的id不要用数组下标否则点赞状态更新时会渲染错乱分页参数是page不是pageNum和 Spring Data 的约定对应。response.data.content是 Spring 分页对象里的数据数组这是后端规范的常见结构前端对接文档时要先确认。3.3 点赞的乐观更新与回滚点赞是高频操作等接口返回再改 UI 会感觉卡顿。常见的做法是乐观更新先改界面请求失败再回滚。React 里做这个要小心状态竞争async function toggleLike(postId) { const post posts.find(p p.id postId); const prev { liked: post.likedByMe, count: post.likeCount }; // 乐观更新 updatePost(postId, { likedByMe: !prev.liked, likeCount: prev.count (prev.liked ? -1 : 1) }); try { await api.post(/posts/${postId}/like); } catch (e) { // 请求失败回滚到操作前状态 updatePost(postId, prev); } }updatePost内部是用setPosts做 map 替换这样可以保证并发点击同一帖子时每次更新都基于最新列表。不要用posts.find拿到的对象直接post.likeCount那会直接改掉 state 里的引用React 可能不触发重渲染。这个项目里如果加了点赞排行或者热门动态务必要给post表加like_count冗余字段同步更新而不是每次实时count(*)。就读多写少的校园场景来说冗余计数加定期对账足够用了。4. JWT登录态、Axios拦截器与前后端联调排错前后端分离项目联调期 80% 的问题集中在登录态、跨域和时间格式上。这一章把认证链路从头到尾穿起来再给一份高频报错对照表。4.1 JWT 认证链路怎么和 Spring Security 配合登录接口验证用户名密码后签一个 JWT 返回给前端后面的请求都带着这个 token。Spring Security 侧的核心配置在 Spring Boot 3.x 下已经改用SecurityFilterChain老项目的WebSecurityConfigurerAdapter在这套体系里已经删掉了Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .sessionManagement(s - s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated()) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }几个关键点STATELESS表示服务端不创建 Sessiontoken 就是凭证/api/admin/**直接按路径前缀圈定管理员接口而不是每个方法都写注解JWT 过滤器放在UsernamePasswordAuthenticationFilter之前从Authorization头里解析 token校验通过后把用户信息塞进SecurityContextHolder。这里踩坑最多的是 Spring Boot 版本差异。网上大量教程还在用antMatchers、WebSecurityConfigurerAdapter把项目从 2.x 升到 3.x 或新版本后这些 API 全变了编译期才报错。如果你拿到的新项目创建时初始化卡住先看 Maven 仓库镜像是否配好这是 SpringBoot 初体验最常见的耗时点和处理业务代码无关。4.2 Axios 拦截器统一注入 token前端不能每个请求手动加 token用一个请求拦截器统一处理axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); axios.interceptors.response.use( res { // 后端统一返回 Result 包装直接取 data.data return res.data; }, err { const status err.response?.status; if (status 401) { localStorage.removeItem(token); window.location.href /login; } return Promise.reject(err); } );401 统一跳登录页是常规操作但要注意用户登录时输入的密码错了后端返回的也是 401这时候不能让拦截器二话不说跳登录页。解决办法是登录接口单独用axios.create()实例不走全局拦截器或者后端对登录失败返回400 业务错误码让前端能区分场景。token 存localStorage还是httpOnly cookie是个取舍localStorage 实现简单但 XSS 攻击能偷走cookie 更安全但要处理 CSRF。校园项目第一版用 localStorage 没问题如果要上生产建议换成 httpOnly cookie。4.3 联调期的跨域、时区与序列化问题联调期最费时间的不是我上面写的业务逻辑而是下面这张表里的问题几乎每个前后端分离项目都会碰到症状根因处理方式请求发出后 OPTIONS 预检 403后端 CORS 没配或 Spring Security 拦截了预检配置 CorsConfigurationSource允许 OPTIONS 方法前端拿到的createTime是数组LocalDateTime 默认序列化成对象或 UTC 时间实体字段加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)登录成功但访问列表 403hasRole(ADMIN)要求权限标识是ROLE_ADMIN返回的角色可能少了前缀检查 JWT 里的角色字段确认是ROLE_ADMIN而不是ADMINPost 实体返回时把密码也带出去了JPA 实体直接做响应体用 VO/DTO字段上JsonIgnore兜底本地联调一切正常部署到服务器后跨域失败前后端域名不同CORS 配置里的 allowedOrigin 没覆盖用allowedOriginPatterns配合环境变量配置域名列表时间格式是最容易忽略的细节。Jackson 默认把LocalDateTime序列化成数组像[2025, 1, 15, 10, 30, 0]前端根本没法直接渲染。项目里统一在application.yml里配spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这比在每个实体字段上加JsonFormat省事但如果有字段是要给前端做倒计时或排期的建议序列化成时间戳而不是格式化字符串避免时区二次转换。和 Spring Security 相关的联调错误我一般会让前端先看浏览器 network 面板里 401 响应头的WWW-Authenticate字段它比后端日志更早暴露问题位置。5. 管理员审核工作台与批量审核验证最后收在管理端最实用的一步批量审核和部署后的真实链路验证。5.1 批量审核接口的部分失败处理管理员一天要看几十条待审帖子逐条点通过效率太低。批量接口要设计成逐条处理、逐条返回结果而不是一个事务整体回滚PutMapping(/api/admin/posts/batch-review) public ResultMapLong, String batchReview(RequestBody BatchReviewRequest req) { MapLong, String results new HashMap(); for (Long id : req.getIds()) { try { postService.review(id, req.getTarget()); results.put(id, 成功); } catch (Exception e) { results.put(id, e.getMessage()); } } return Result.success(results); }返回结构是每个帖子 id 对应一个结果前端可以用红色标出失败项。这样不会因为一条帖子已经被别人审核过导致剩下几十条全部白点。5.2 用 curl 验证完整审核链路部署前后用一条命令串起登录、发帖、查询、审核四步TOKEN$(curl -s -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:123456} | jq -r .data.token) curl -X POST http://localhost:8080/api/posts \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d {content:出二手显示器宿舍自提} curl -s http://localhost:8080/api/admin/posts?statusPENDING \ -H Authorization: Bearer $TOKEN | jq .data.content[].id5.3 分离部署时的 nginx 配置前后端分离部署前端构建产物交给 nginx/api/请求反代到 Spring Bootserver { listen 80; root /opt/web/dist; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } }proxy_pass后面不要带路径带上后就变成路径替换了。发帖里的图片如果存的是相对路径同样要走/api/前缀的代理否则浏览器会去 nginx 的根目录找图返回 404。部署后用curl -I分别检查首页接口和静态资源状态码再打开浏览器走一遍管理员审核流程就能确认整套链路没有隐藏断点。生产环境部署时把 JWT 密钥从配置文件挪到环境变量定期轮换这比在代码里写死要安全得多。本文还有配套的精品资源点击获取