简介:一套面向大学校园社交场景的前后端分离项目,基于React与Spring Boot构建,适合正在学习主流前后端框架整合、希望从零搭建可运行项目的开发者参考。项目场景贴近校园生活,完整覆盖用户登录注册、动态发布与浏览点赞、个人资料编辑等社交功能;管理员端则提供用户查询、添加、修改、删除,帖子查询、添加、删除及审核通过或拒绝等操作,普通用户与管理员权限分层清晰,业务链路完整。压缩包整体约1.39MB,属于轻量级资源,内部包含前端React工程、后端Spring Boot服务及项目配置代码,便于导入开发工具后直接运行和二次扩展,也可作为课程设计或毕业设计的项目底稿。目前已有112人学习浏览,对于想深入理解前后端分离架构、Spring Boot接口设计与React页面交互的读者,能够提供一套从功能设计到代码实现的可参考样板。
1. 为什么校园社交平台是React+SpringBoot前后端分离项目最常见的落点
校园范围内的社交应用,规模刚好卡在单体后端就能撑住、上微服务反而浪费的区间,但用户、动态、评论、点赞、私信这些模块之间存在真实的关系约束,恰好能把JWT鉴权、REST接口设计、关系型表结构、React组件状态管理这一整条链路完整串起来。React负责把动态流、消息红点、个人主页这些高频交互做成单页体验,SpringBoot负责把用户状态和内容数据收敛成一组可以被小程序或App复用的API。.zip解压后能不能直接跑并不重要,真正值得关注的是三个问题:前后端分离的Token怎么一路走通、校园社交场景的表怎么设计、联调阶段跨域和字段不一致怎么快速定位。下面从接口契约开始,逐步把这个系统在本地完整复现出来。
2. 前后端分离在校园社交场景下的接口契约与JWT鉴权链路
2.1 先把接口清单定下来,React和SpringBoot才不会各写各的
做校园社交平台,第一步不是创建SpringBoot工程,也不是初始化React,而是把接口契约定了。两个端同时开工后,后端字段名改一次,前端就要跟着调一次,累计消耗的时间远超先花半小时列清单。常见做法是维护一份接口文档,哪怕先放团队Wiki里一张表格都行,包含方法、路径、入参、出参、错误码。以动态模块为例,最小集通常是这五个接口:
| 方法 | 路径 | 作用 | 关键参数 |
|---|---|---|---|
| POST | /api/feed | 发布动态 | content, images, location |
| GET | /api/feed/page | 分页获取动态流 | page, size, sortBy |
| GET | /api/feed/{id} | 动态详情 | id |
| POST | /api/feed/{id}/like | 点赞或取消点赞 | id |
| POST | /api/feed/{id}/comment | 发表评论 | id, content, parentId |
配套响应体统一用ApiResponse包装,后端SpringBoot返回的JSON固定长这样:
{ "code": 0, "message": "success", "data": { "records": [], "total": 12, "hasMore": true } }code用0代表成功,非0走错误分支,这样HTTP状态码保留它的原始语义:网络层失败看401、502,业务失败看业务code。data里把total和hasMore设计出来,前端做下拉加载前就能决定要不要显示已经到底的提示。字段命名用records而不是list,接Ant Design Table时可以少一层map改名。
2.2 JWT从登录到请求校验的完整链路:签发、比对、过滤器
校园社交平台的鉴权链路和普通管理后台有个关键区别:用户随时在产生写操作,Token必须能撤销。如果用纯JWT,服务端无状态,用户被封号或改密码后旧Token在到期前依然能调接口。常见做法是JWT加Redis版本号方案:登录成功时在Redis写login:token:userId等于当前Token,JWT过滤器解析Token后还要和Redis比一次,不一致直接返回401。Redis里可以顺带存用户状态,被封禁的用户在登录校验阶段就直接进不来。
签发代码在SpringBoot侧:
String token = Jwts.builder() .setSubject(userId.toString()) .claim("username", user.getUsername()) .claim("role", user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000L)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();setSubject存userId字符串,是服务端查询的入口;claim里的role用于前端控制管理员删帖按钮的显隐,不必每次再查库;secretKey建议从环境变量读取,避免Git提交泄露。过期时间给7天是为了减少校园用户反复登录的烦躁感,配合Redis版本号达到改密即失效的效果。JWT过滤器解析部分长这样:
String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); Long userId = jwtUtil.parseToken(token); String redisToken = redisTemplate.opsForValue().get("login:token:" + userId); if (token.equals(redisToken)) { UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( userId, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); SecurityContextHolder.getContext().setAuthentication(auth); } }Authorization头统一用Bearer加空格加Token的格式,substring(7)把前缀丢掉得到原始Token。Redis比对未通过说明用户已在他处登录或已下线,此时不设置Authentication,后续访问受保护接口自然被Spring Security拒掉。每次请求只做一次Redis GET,不查数据库,校园几千人同时在线的量级完全扛得住。
2.3 React端Axios拦截器统一挂载Token和处理认证失败
前端的第一道关卡是Axios请求拦截器。Token存在哪里是个高频问题:localStorage方便但XSS脚本能直接读走,存内存里刷新页面就丢。常见折中是存store里,刷新后调/api/auth/me重新拉用户信息。拦截器代码:
import axios from 'axios'; import { useAuthStore } from '../store/auth'; const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000, }); request.interceptors.request.use(config => { const token = useAuthStore.getState().token; if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( response => { const { code, data, message } = response.data; if (code === 0) return data; if (code === 401) { useAuthStore.getState().logout(); window.location.href = '/login'; } throw new Error(message || '请求失败'); }, error => Promise.reject(error) );baseURL通过Vite环境变量注入,本地开发指向后端地址,部署后Nginx把/api路径反代给SpringBoot,前端代码本身不用切换。响应拦截器把业务data解包后再返回,业务组件里直接拿records渲染,不再重复写code判断。401统一登出跳登录页,后续加自动续期时只要在这个分支里插入刷新Token的逻辑,改动点集中在一处。
3. React端把校园动态流和登录态跑起来
3.1 Vite初始化工程与按业务切分的目录
用Vite创建React-TS工程比Create React App冷启动快得多,环境变量机制也更干净:
npm create vite@latest campus-front -- --template react-ts cd campus-front npm install npm install react-router-dom axios zustand @tanstack/react-query目录不按components、hooks这种技术类型切,而是按业务模块切:pages下面放Login、Feed、Profile、Message四个文件夹,每个文件夹内部自带组件和hooks。这样后改消息中心不会误碰动态流文件,合并代码时冲突面也小。登录态用zustand管理,原因很简单:这个项目里前端状态大部分是服务端数据的缓存,Redux的样板代码在这里没有优势。store入口代码:
import { create } from 'zustand'; export const useAuthStore = create(set => ({ token: null, user: null, setAuth: (token, user) => set({ token, user }), logout: () => set({ token: null, user: null }), }));setAuth在登录成功后调用一次,logout在响应拦截器401时调用。Token放在store内存里,刷新丢失是预期行为,App入口组件挂一个useEffect调/api/auth/me,后端根据请求头Token返回用户信息并补回store,用户无感知。
3.2 动态流无限滚动用React Query和虚拟列表
动态流是校园社交平台最高频页面,时间长了下拉加载和图片卡顿都会出现。无限滚动常见用useInfiniteQuery管理页码和缓存,列表渲染交给react-window的FixedSizeList:
import { useInfiniteQuery } from '@tanstack/react-query'; import { FixedSizeList } from 'react-window'; import FeedCard from './FeedCard'; import { request } from '../utils/request'; export function FeedList() { const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey: ['feed', 'page'], queryFn: ({ pageParam }) => request.get('/feed/page', { params: pageParam }), initialPageParam: { page: 1, size: 10 }, getNextPageParam: lastPage => lastPage.hasMore ? { page: lastPage.page + 1, size: 10 } : undefined, }); const items = data?.pages.flatMap(p => p.records) ?? []; return ( <FixedSizeList height={window.innerHeight - 56} width="100%" itemCount={items.length} itemSize={80} onItemsRendered={({ visibleStopIndex }) => { if (visibleStopIndex === items.length - 1 && hasNextPage) { fetchNextPage(); } }} > {({ index, style }) => ( <div style={style}><FeedCard feed={items[index]} /></div> )} </FixedSizeList> ); }getNextPageParam返回的是下一页完整参数对象,React Query在调用queryFn时把它作为pageParam传进来,页码完全由调用方控制。onItemsRendered看到最后一个元素可见时触发fetchNextPage,滚到底自动加载。itemSize设80是估算值,如果FeedCard内部高度不固定,要在卡片里用ResizeObserver动态修正行高,否则会出现滚动跳动。
3.3 本地联调用Vite proxy解决跨域,不用@CrossOrigin
前后端分离联调时浏览器拦截的是跨域请求,最快解法不是在后端加@CrossOrigin,而是在Vite开发服务器配一条代理规则:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, }, });前端请求发到5173的/api/feed/page,Vite代理转发给8080的/api/feed/page,后端Controller的RequestMapping也带/api前缀,路径完全对应。浏览器看到的请求始终同源,整个开发过程不会出现CORS报错。上线时换成Nginx同源反代,后端注解就没有存在意义,所以一开始就不依赖它。
| 参数 | 作用 | 不配置后果 |
|---|---|---|
| target | 代理目标地址,即后端服务地址 | 请求无转发对象,本地报502 |
| changeOrigin | 改写请求头Host为目标地址 | 后端严格校验Host时拒绝 |
| rewrite | 按规则改写路径前缀 | 后端路径不匹配时404 |
提示:团队开发时在VSCode里装上ESLint插件,开启react/jsx-closing-bracket-location和react/self-closing-comp两条规则,JSX标签闭合错误会在保存时自动修正,新同事上手React第一周的体验会好很多。
4. SpringBoot端表设计、接口实现与安全配置
4.1 选型:MyBatis-Plus还是JPA
校园社交平台的核心是CRUD加多表联查,选MyBatis-Plus的理由有两条。一是SQL手写可控,动态流分页这种需要精细索引的场景,JPA自动生成的SQL在某些关联查询里会出现N+1问题;二是逻辑删除、自动填充、分页插件开箱即用,几行配置就能省掉大量模板代码。依赖版本如下:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency>3.5.5同时兼容SpringBoot 2.7和3.x,但如果新建工程自动选了最新SpringBoot版本,比如3.4以上,要先确认MyBatis-Plus发布了对应适配版本,否则运行时会报Mapper method not found这种看日志很难定位的错误。JDK锁17,LTS且和SpringBoot 3.x兼容性最好。
4.2 三张核心表:用户表、动态表、评论表
校园社交平台表设计的高频考点是点赞数和评论数要不要存冗余字段。做法是存,动态列表页一条带索引的SELECT就能拿到所有展示数据,不用每条都COUNT一次。明细存feed_like和comment表,写操作走事务,先插明细再更新计数,下面这套SQL结构在SpringBoot里可以直接配MyBatis-Plus使用:
CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL COMMENT 'BCrypt哈希', nickname VARCHAR(32) NOT NULL, avatar VARCHAR(255) DEFAULT '', role TINYINT DEFAULT 0 COMMENT '0-学生 1-教师 2-管理员', status TINYINT DEFAULT 1 COMMENT '1-正常 0-封禁', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE feed ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, content TEXT NOT NULL, images VARCHAR(1000) DEFAULT '', location VARCHAR(64) DEFAULT '', like_count INT DEFAULT 0, comment_count INT DEFAULT 0, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE comment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, feed_id BIGINT NOT NULL, user_id BIGINT NOT NULL, parent_id BIGINT DEFAULT 0 COMMENT '0为一级评论,非0为回复某条评论', content VARCHAR(500) NOT NULL, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_feed_id (feed_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;images用逗号分隔字符串存,这个场景不需要对单张图片做独立查询,前端split逗号就能渲染。parent_id为0表示一级评论,回复评论时存被回复评论的id,渲染对话树只需要一次按feed_id查询,然后在内存中组树。密码列直接存BCrypt哈希,长度128是为了容纳$2a$10$开头的特征串。username做唯一索引,注册时靠数据库兜底,避免并发下两个相同用户名都注册成功的边界情况。三张表的索引用途对应关系:
| 表 | 索引 | 覆盖的查询 |
|---|---|---|
| user | username唯一索引 | 登录、注册查重 |
| feed | idx_user_id、idx_create_time | 个人动态、动态流时间排序 |
| comment | idx_feed_id | 按动态查评论列表 |
4.3 Controller与Service:统一响应体、取当前用户、事务
Controller只做参数绑定和调用Service,业务逻辑下沉Service层,这是后端团队最容易对齐的写法。接口代码:
@RestController @RequestMapping("/api/feed") public class FeedController { private final FeedService feedService; public FeedController(FeedService feedService) { this.feedService = feedService; } @PostMapping public ApiResponse<Long> createFeed(@RequestBody FeedDTO dto) { return ApiResponse.success(feedService.createFeed(dto)); } @GetMapping("/page") public ApiResponse<IPage<FeedVO>> page( @RequestParam(defaultValue = "1") long page, @RequestParam(defaultValue = "10") long size) { return ApiResponse.success(feedService.pageFeed(page, size)); } }构造器注入是Spring官方推荐,比@Autowired字段注入更利于单测。DTO接收的只有content、images、location,userId不从前端拿,而是在Service里从SecurityContext取,这样能防止调用者伪造别人身份发帖。Service核心逻辑:
@Transactional public Long createFeed(FeedDTO dto) { Long userId = SecurityUtil.getCurrentUserId(); Feed feed = new Feed(); feed.setUserId(userId); feed.setContent(dto.getContent()); feed.setImages(String.join(",", dto.getImages())); feed.setLocation(dto.getLocation()); feedMapper.insert(feed); return feed.getId(); }@Transactional覆盖insert,任何异常都会回滚,不会产生无主动态。getCurrentUserId从SecurityContextHolder拿Authentication的principal,这个值在JWT过滤器里被设置成userId。点赞逻辑同样走事务:先insert feed_like,再update feed set like_count = like_count + 1,两步任一失败一起回滚,计数不会错乱。
4.4 Spring Security过滤链:放行规则与JWT过滤器位置
SecurityConfig是整个后端最容易配错的地方。原则是登录注册放行,动态流和用户主页这些公开页面放行,写操作全部要求登录:
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.stateless()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/register").permitAll() .requestMatchers(HttpMethod.GET, "/api/feed/**", "/api/user/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, redisTemplate), UsernamePasswordAuthenticationFilter.class); return http.build(); }csrf.disable()在前后端分离里是安全的,因为没有Cookie,CSRF攻击面不存在。sessionManagement().stateless()告诉Spring Security不要创建HttpSession,每个请求都独立校验Authorization头。上面用的requestMatchers是Spring Security 6写法,对应SpringBoot 3.x;如果项目还在SpringBoot 2.7,换成antMatchers即可。JwtAuthenticationFilter放在UsernamePasswordAuthenticationFilter之前,在过滤器里解析Token并和Redis比对,全部通过才设置SecurityContext。
提示:403和401的排查路径完全不同。401是Token无效,先查JWT解析和Redis比对;403是已认证但没权限,先查放行规则是否覆盖了当前路径。
5. 部署验证:Nginx try_files、Token自动续期、连接池上限
前端npm run build拿到dist,后端mvn clean package -DskipTests拿到jar包,上传到云服务器后Nginx配置是上线第一个坑。React用BrowserRouter时,用户直接在地址栏访问/feed/123,Nginx在文件系统里找不到这个文件会返回404白屏,需要在server块里加回退:
location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }try_files把不存在的路径全部回退到index.html,React Router接管后再渲染对应页面。顺序很重要,$uri先找静态文件,找不到再回退,JS、CSS、图片照常由Nginx直接返回。验证方式:部署后执行curl -I http://你的域名/feed/123,返回200且Content-Type是text/html,说明回退生效。
第二个坑是Token过期。7天有效期一到,用户正在刷动态流时突然跳回登录页体验很差。常见做法是登录时同时签发refreshToken,accessToken失效时前端拦截器自动换新并重放原请求:
request.interceptors.response.use( response => response, async error => { const original = error.config; if (error.response?.status === 401 && !original._retry) { original._retry = true; const { data } = await axios.post('/api/auth/refresh', { refreshToken: localStorage.getItem('refreshToken') }); localStorage.setItem('accessToken', data.accessToken); original.headers.Authorization = `Bearer ${data.accessToken}`; return request(original); } window.location.href = '/login'; return Promise.reject(error); } );_retry标志防止请求重放后又失败进入死循环。refreshToken只在refresh接口使用,签发时过期时间比accessToken长,比如14天;服务端如果发现refreshToken尝试访问其他接口,JWT过滤器直接拒绝,这个约束要写进签发逻辑里。
第三个要在部署前检查的是数据库连接池。SpringBoot默认的HikariCP连接池上限是10,并发高峰时如果接口里有慢SQL,连接池被占满就会出现Connection is not available。常见做法是调大一点并配上等待超时:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000maximum-pool-size设20时,单实例后端够用,MySQL侧要确认max_connections大于这个数,否则连接数打满反而更慢。connection-timeout给30秒是为了慢查询时不至于客户端瞬间报错,但根治办法是从慢查询日志里把SQL揪出来优化索引。改完配置后重启后端,观察启动日志里HikariPool的初始化信息,再用一个压测脚本同时打100个请求确认没有连接超时,前后端链路就算真正通了。
本文还有配套的精品资源,点击获取