ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

校园社交平台前后端分离实战:React+SpringBoot+JWT完整链路搭建

校园社交平台前后端分离实战:React+SpringBoot+JWT完整链路搭建 简介一套面向大学校园社交场景的前后端分离项目基于React与Spring Boot构建适合正在学习主流前后端框架整合、希望从零搭建可运行项目的开发者参考。项目场景贴近校园生活完整覆盖用户登录注册、动态发布与浏览点赞、个人资料编辑等社交功能管理员端则提供用户查询、添加、修改、删除帖子查询、添加、删除及审核通过或拒绝等操作普通用户与管理员权限分层清晰业务链路完整。压缩包整体约1.39MB属于轻量级资源内部包含前端React工程、后端Spring Boot服务及项目配置代码便于导入开发工具后直接运行和二次扩展也可作为课程设计或毕业设计的项目底稿。目前已有112人学习浏览对于想深入理解前后端分离架构、Spring Boot接口设计与React页面交互的读者能够提供一套从功能设计到代码实现的可参考样板。1. 为什么校园社交平台是ReactSpringBoot前后端分离项目最常见的落点校园范围内的社交应用规模刚好卡在单体后端就能撑住、上微服务反而浪费的区间但用户、动态、评论、点赞、私信这些模块之间存在真实的关系约束恰好能把JWT鉴权、REST接口设计、关系型表结构、React组件状态管理这一整条链路完整串起来。React负责把动态流、消息红点、个人主页这些高频交互做成单页体验SpringBoot负责把用户状态和内容数据收敛成一组可以被小程序或App复用的API。.zip解压后能不能直接跑并不重要真正值得关注的是三个问题前后端分离的Token怎么一路走通、校园社交场景的表怎么设计、联调阶段跨域和字段不一致怎么快速定位。下面从接口契约开始逐步把这个系统在本地完整复现出来。2. 前后端分离在校园社交场景下的接口契约与JWT鉴权链路2.1 先把接口清单定下来React和SpringBoot才不会各写各的做校园社交平台第一步不是创建SpringBoot工程也不是初始化React而是把接口契约定了。两个端同时开工后后端字段名改一次前端就要跟着调一次累计消耗的时间远超先花半小时列清单。常见做法是维护一份接口文档哪怕先放团队Wiki里一张表格都行包含方法、路径、入参、出参、错误码。以动态模块为例最小集通常是这五个接口方法路径作用关键参数POST/api/feed发布动态content, images, locationGET/api/feed/page分页获取动态流page, size, sortByGET/api/feed/{id}动态详情idPOST/api/feed/{id}/like点赞或取消点赞idPOST/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等于当前TokenJWT过滤器解析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 vitelatest 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的FixedSizeListimport { 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} width100% 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/pageVite代理转发给8080的/api/feed/page后端Controller的RequestMapping也带/api前缀路径完全对应。浏览器看到的请求始终同源整个开发过程不会出现CORS报错。上线时换成Nginx同源反代后端注解就没有存在意义所以一开始就不依赖它。参数作用不配置后果target代理目标地址即后端服务地址请求无转发对象本地报502changeOrigin改写请求头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在某些关联查询里会出现N1问题二是逻辑删除、自动填充、分页插件开箱即用几行配置就能省掉大量模板代码。依赖版本如下dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency3.5.5同时兼容SpringBoot 2.7和3.x但如果新建工程自动选了最新SpringBoot版本比如3.4以上要先确认MyBatis-Plus发布了对应适配版本否则运行时会报Mapper method not found这种看日志很难定位的错误。JDK锁17LTS且和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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;images用逗号分隔字符串存这个场景不需要对单张图片做独立查询前端split逗号就能渲染。parent_id为0表示一级评论回复评论时存被回复评论的id渲染对话树只需要一次按feed_id查询然后在内存中组树。密码列直接存BCrypt哈希长度128是为了容纳$2a$10$开头的特征串。username做唯一索引注册时靠数据库兜底避免并发下两个相同用户名都注册成功的边界情况。三张表的索引用途对应关系表索引覆盖的查询userusername唯一索引登录、注册查重feedidx_user_id、idx_create_time个人动态、动态流时间排序commentidx_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 ApiResponseLong createFeed(RequestBody FeedDTO dto) { return ApiResponse.success(feedService.createFeed(dto)); } GetMapping(/page) public ApiResponseIPageFeedVO page( RequestParam(defaultValue 1) long page, RequestParam(defaultValue 10) long size) { return ApiResponse.success(feedService.pageFeed(page, size)); } }构造器注入是Spring官方推荐比Autowired字段注入更利于单测。DTO接收的只有content、images、locationuserId不从前端拿而是在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()在前后端分离里是安全的因为没有CookieCSRF攻击面不存在。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/123Nginx在文件系统里找不到这个文件会返回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.htmlReact Router接管后再渲染对应页面。顺序很重要$uri先找静态文件找不到再回退JS、CSS、图片照常由Nginx直接返回。验证方式部署后执行curl -I http://你的域名/feed/123返回200且Content-Type是text/html说明回退生效。第二个坑是Token过期。7天有效期一到用户正在刷动态流时突然跳回登录页体验很差。常见做法是登录时同时签发refreshTokenaccessToken失效时前端拦截器自动换新并重放原请求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个请求确认没有连接超时前后端链路就算真正通了。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进