前阵子我负责的模拟项目,目标就是搭建一个基于Spring Boot的健身爱好者线上互动与打卡社交平台系统。说白了,它解决的是健身人群普遍面临的三件事:运动记录分散、一个人练难坚持、练完没有正向反馈。这套系统做出来以后,我把从需求梳理到部署上线的完整过程复盘了一遍,今天用一篇文章讲清楚:模块怎么拆、数据库怎么设计、打卡和社交关系怎么落地、上线之后哪些坑一定要避开。如果你正准备做类似的社区或打卡类系统,或者想找一个能把 Spring Boot 主流技术串起来的练手项目,这篇内容可以参考。
1. 需求梳理:线上互动与打卡平台要解决的核心问题
1.1 场景与痛点
健身本身带反人性属性,和刷短视频不一样,运动完成之后的正反馈来得慢。很多人办了卡去了两三次就放弃,缺的往往不是意志力,而是及时的正向激励。我在整理需求的时候找了几位长期健身的朋友聊天,他们的反馈很集中:平时训练完想记录一下今天的配速、组数和感受,但找不到一个像样的地方;发到朋友圈又太重,不想被所有人围观。更深一层的需求是互相监督——如果知道训练搭子今天跑了五公里,自己就不好意思躺着。
基于这些观察,这个平台要做的不是单纯的打卡工具,而是把“运动行为”和“社交激励”串成一个闭环。用户练完打卡,系统给积分和勋章;打卡可以选择同步成动态,朋友看到后点赞评论;通知再把人拉回来。整个产品的核心逻辑是:用记录产生内容,用内容带动互动,用互动促进持续打卡。这个闭环想清楚了,后面所有的表结构和接口实现都有了依据。
1.2 核心角色与关键用户流程
系统分为两类角色。普通用户负责日常运动和内容互动,管理员负责内容审核、违规用户处理和数据统计。第一版不做复杂的运营后台,管理端只需要能看用户列表、动态列表,能下架动态、封禁用户就够了。
普通用户的完整路径是这样设计的:注册登录,完善身体资料(身高、体重、运动目标);训练完成后填写运动类型、时长、距离、卡路里,提交打卡;系统根据规则发放积分并可能触发成就勋章;用户可以选择把这次打卡自动同步为一条动态,也可以单独发纯文字或图片动态;好友在关注流里看到动态后点赞评论;消息通知触达用户,引导他回来看互动情况。整套流程形成一个闭环,每一步都在为下一步做铺垫。
1.3 第一版的功能边界
很多团队做这类系统容易失控,一上来就想做约课、直播、商城、私教预约。我把第一版的功能范围严格收敛在核心闭环上:打卡记录、积分排行、动态内容、关注关系、点赞评论、消息通知、个人中心。砍掉的功能并不是没用,而是它们不解决“验证这件事能不能跑通”的问题。等日活起来之后,再围绕用户需求做增量迭代会更有把握。
这里有一件事我要强调:边界清晰比功能多更重要。第一版功能越少,出问题的面就越小,你才有精力把打卡幂等、Feed 性能、通知异步这些真正有技术含量的地方打磨到位。
2. 技术栈与工程架构:为什么选择 Spring Boot 单体方案
2.1 选型逻辑与版本搭配
选 Spring Boot 不是因为“大家都在用”,而是因为它对这个场景确实合适。平台的核心是业务闭环,不是高并发基础设施;初期用户规模就是几千注册、几百日活,单体应用绰绰有余。Spring Boot 的好处在于生态成熟,Spring Security、MyBatis-Plus、Spring Data Redis 这些组件稳定且资料多,遇到问题基本都能找到解决方案。内置 Tomcat 让打包部署变得很轻,一个 jar 就能跑起来,这在单人开发或者小团队作战时非常重要。
我用的版本组合供参考:
| 组件 | 版本 |
|---|---|
| Spring Boot | 2.7.x |
| JDK | 8 或 11 |
| MySQL | 8.0 |
| Redis | 6.x |
| MyBatis-Plus | 3.5.x |
| Spring Security | 随 Spring Boot 管理 |
如果要重新选,我仍然选 Spring Boot 2.7 而不是 3.x。3.x 要求 JDK 17,如果你的部署环境还停留在 JDK 8,会有很多不必要的兼容工作;2.7 对云服务器、运维脚本、第三方组件的兼容性都更宽松,学习成本也更低。
2.2 工程目录:横向分层加纵向业务包
项目代码结构是我比较在意的点。纯按 controller、service、mapper 分层,在业务模块一多之后很容易变成大杂烩,找代码全靠猜。我采用的方案是“全局横向分层 + 业务模块纵向分包”。
com.example.project ├── common // 统一返回、异常、常量、工具类 ├── config // Redis、WebMvc、异步线程池等配置 ├── security // JWT 过滤器、登录用户上下文、权限注解 └── module ├── user // 用户注册、登录、资料 ├── checkin // 打卡记录 ├── post // 动态内容 ├── interaction // 点赞、评论、关注 ├── points // 积分流水 └── message // 消息通知每个 module 内部再放 controller、service、mapper、entity、dto。这样做的直接好处是:如果将来要把积分模块拆出去做独立服务,只需要把整个 points 包复制走,把暴露接口改成远程调用即可,不用在多层目录里挑挑拣拣。
2.3 两个必要的工程约束
第一,实体类不能直接返回给前端。User 实体里有密码字段,直接用实体响应会有严重的安全问题;另外关联关系复杂之后,实体转 JSON 还可能出现循环引用。统一用 DTO/VO 做数据承载,不仅安全,还能让前端拿到的字段更稳定。
第二,所有业务异常都要走全局异常处理器。我在 common 里定义了 BusinessException,业务代码里只要抛出它,全局处理器就会转成统一的 code 和 message 返回。这样做的好处是 controller 层不需要到处写 try-catch,整个代码会干净很多。
3. 数据库设计:把打卡、社交和积分记录存明白
3.1 用户域:主表与扩展表拆分
用户表我分成 user 和 user_profile 两张表。user 表只放认证必要字段:id、手机号、密码哈希、状态、角色、创建时间。user_profile 表放个性化资料:昵称、头像、性别、身高、体重、健身目标、训练年限。
拆开的原因很现实:登录认证时只需要查手机号和密码,频率高但字段少;个人主页打开时才会查昵称头像身高体重,频率相对低。拆成两张表可以避免每次认证都拖着一堆不相关的字段,也方便以后扩展健身偏好、身体指标变化历史这类功能,不用去动核心用户表。
3.2 打卡域:记录一次运动的表结构
打卡表是整套系统的地基,字段设计要覆盖一次运动的关键信息:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 用户ID |
| exercise_type | varchar | 跑步/力量/瑜伽/骑行 |
| title | varchar | 打卡标题 |
| content | text | 文字记录 |
| duration_minutes | int | 运动时长 |
| distance_km | decimal | 跑步距离等 |
| calories | int | 估算卡路里 |
| images | varchar | 图片地址 |
| check_in_date | date | 业务日期 |
| points_earned | int | 本次获得积分 |
| created_at | datetime | 创建时间 |
关键索引是(user_id, check_in_date, exercise_type)的唯一索引。这个索引的含义是:同一个用户在同一天,同一种运动类型只能有一条打卡记录。它不是为了加快查询速度,而是为了从数据库层面杜绝重复打卡。
动态表 post 和打卡表是分开的。打卡如果选择同步成动态,系统会往 post 表插一条数据,source_type 标记成“来自打卡”,source_id 指向 check_in 的 id。单独发布的动态则不走打卡表。动态表里我冗余了 like_count 和 comment_count 两个计数,虽然多了一步更新操作,但列表页不需要每次实时聚合,对读写性能影响很大。
3.3 社交域:关注、点赞、评论、通知
社交关系在关系型数据库里是典型的多对多,我设计了这样几张核心表:
| 表名 | 关键字段 | 唯一索引 |
|---|---|---|
| follow_relation | follower_id, followee_id | (follower_id, followee_id) |
| like_record | user_id, target_type, target_id | (user_id, target_type, target_id) |
| comment | post_id, user_id, content, parent_id | 无 |
| notification | receiver_id, actor_id, type, target_type, target_id | 无 |
关注表和点赞表必须加唯一索引,这是防止重复操作最可靠的防线。comment 表支持楼中楼回复,所以加了 parent_id 和 reply_user_id。通知表里 receiver_id 是接收者,actor_id 是触发动作的人,target_type 和 target_id 指向具体被点赞或评论的对象,方便前端点击跳转。
3.4 积分域与排行榜
积分设计成流水表和汇总表两层。points_record 记录每一次积分变化的来龙去脉:谁、因为什么、变化多少、关联业务单号。user_points 只存当前总积分和等级。查积分明细走流水表,查排行榜和等级走汇总表。
排行榜第一版我用 Redis 的 ZSet 实现,key 类似leaderboard:today,成员是用户ID,分数是当日积分。每次打卡发积分时同步向 ZSet 增加分数。这样做的好处是排序操作由 Redis 完成,性能远高于数据库 order by,而且今日榜、本周榜天然支持过期清理。总榜这种长期数据还是读数据库,用 total_points 倒序取前一百名,量级完全扛得住。
3.5 时间字段的统一约定
这是设计里让我印象最深的一个点。所有时间字段统一成这样的约定:业务日期用 date 类型,记录创建时间用 datetime 类型;代码里一律用 LocalDate 和 LocalDateTime,并且固定使用 Asia/Shanghai 时区;不依赖数据库的 now() 函数,全部由应用层传入时间。
为什么这么做?如果数据库服务器部署在境外的 UTC 时区机房,或者连接配置里的时区没有写对,数据库 now() 返回的时间可能和用户所在的东八区相差好几个小时。打卡记录会莫名其妙算到前一天或者后一天,这种问题在测试时不容易发现,上线后用户一打卡就是事故现场。
4. 认证与权限:Spring Security + JWT 的落地细节与刷新策略
4.1 登录认证流程
用户注册时密码用 BCryptPasswordEncoder 做哈希,千万不要明文存储。登录成功之后,后端签发两个令牌:accessToken 有效期两小时,refreshToken 有效期七天。客户端后续请求在 Header 里带 accessToken,Spring Security 的过滤器链在业务接口执行前完成令牌校验。
校验的核心逻辑在 JwtAuthenticationFilter 里:从请求头解析 token,如果解析成功并且 Redis 中存在对应用户的登录状态,就把用户信息放进 SecurityContextHolder,后续接口就能通过工具类拿到当前登录用户。这样设计之后,业务代码里想要获取当前用户,一行代码就行,不用每个接口手动解析 token。
4.2 无感刷新令牌
accessToken 有效期太短会影响体验,太长又有安全风险,所以无感刷新是必须的。前端 Axios 封装了拦截器:请求返回 401 时,拿 refreshToken 调用刷新接口,拿到新 accessToken 后重放原请求。这个流程顺畅的前提是后端刷新接口要处理好几个边界条件。
第一个边界是 refreshToken 不能多次重复使用。我把它存在 Redis 里,key 是 refresh token 本身,value 是用户ID和会话信息的序列化值。每次刷新时先比对,如果不存在说明已经被用过,直接拒绝。第二个边界是并发刷新。用户同时在两个设备上操作,可能同时带同一个 refreshToken 来刷新,我加了一个短时间的分布式锁,避免两个请求都执行成功导致先前下发的令牌全部失效。
4.3 权限模型与越权防护
管理员接口用 Spring Security 的方法级权限控制,加上@PreAuthorize("hasRole('ADMIN')")注解就够用。真正容易出问题的是越权防护,很多系统只检查用户是否登录,不检查操作的数据是不是属于当前用户。删除自己评论的时候,不能只信前端传过来的评论 id,后端要校验这个评论的 user_id 是否等于当前登录用户ID,否则就是任意删除漏洞。
还有一个容易被忽略的点:管理员封禁用户之后,用户手里已经拿到的 accessToken 在有效期内仍然能访问接口。我的方案是登录时生成一个随机的 login_version,存 Redis,JWT 里也带上这个字段。每次过滤器校验时把 JWT 里的版本号和 Redis 里的版本号比对,不一致就拒绝。封禁用户时删掉或重置这个版本号,旧 token 立刻失效。
5. 打卡核心实现:积分规则、防重复和日期边界
5.1 打卡接口主流程
打卡是写操作最重的接口,我把主流程拆成五个步骤:参数校验、防重复校验、写入打卡记录、计算并发放积分、发布领域事件。
领域事件用来做打卡成功后的联动,比如自动同步动态、更新今日排行榜、给连续打卡用户发成就奖章。主流程里只负责事件发布,具体的消费逻辑放在监听器里异步执行,这样打卡接口本身不会因为同步动态或排行榜更新而变慢。
核心代码示意如下:
@Transactional public CheckInVO submitCheckIn(CheckInRequest request) { Boolean locked = redisTemplate.opsForValue() .setIfAbsent(checkInLockKey(request), "1", Duration.ofSeconds(30)); if (Boolean.FALSE.equals(locked)) { throw new BusinessException("正在提交中,请勿重复操作"); } try { // 插入打卡记录,数据库唯一索引最终兜底 // 计算积分并更新 user_points // 发布打卡完成事件 return buildCheckInVO(); } catch (DuplicateKeyException e) { throw new BusinessException("今天已经打过卡啦"); } finally { redisTemplate.delete(checkInLockKey(request)); } }5.2 防重复打卡的两种防线
打卡接口并发场景比较常见:用户手快连点了两次,或者前端重试机制把同一个请求发了两遍。如果只靠“先查询再插入”,两个请求同时查到不存在,然后同时插入,就会产生重复数据。
我做了两层防护。第一层是 Redis 锁,key 由 userId、业务日期、运动类型拼接而成,用 setIfAbsent 实现只有第一个请求能获取锁,其他请求直接提示“请勿重复提交”。第二层是数据库唯一索引,即便 Redis 锁因为极端情况失效,两条请求同时插入,数据库也会抛出 DuplicateKeyException,被全局异常处理转成友好提示。这两层加起来,重复打卡几乎不可能发生。
5.3 日期边界:凌晨打卡算昨天还是今天
打卡日期边界是个典型的业务歧义问题。凌晨一点跑完步,用户脑子里想的往往是“这是昨晚的训练”,如果系统直接记为今天,第二天他会发现当天已经打过卡了,体验很糟糕。
我的处理方式是:前端默认带出当前自然日,但允许用户手动调整业务日期;后端校验业务日期不能是未来日期,并且在 Redis 锁和唯一索引里都用这个业务日期。同时后端判重时以用户传的业务日期为准,而不是以服务器当前日期为准。这样既保留了补卡能力,又不会出现未来打卡。补卡功能也有限制,最多补昨天,补卡积分享受同样规则。
5.4 积分规则用策略模式而不是 if-else
积分规则非常容易变化,今天跑步每公里一分,明天可能改成两分,如果全写在打卡逻辑里,每次改规则都要动主流程。我用策略模式做了隔离:
public interface PointsCalculator { ExerciseType support(); int calc(CheckInRequest request); }跑步计算器按公里数给分,力量训练按固定组数区间给分,瑜伽按完成时长给分。工厂类根据 exercise_type 找到对应的计算器,打卡主流程完全不感知具体规则。后续要增加游泳项目,只需要新写一个 SwimmingPointsCalculator 注册进去,不需要改动打卡逻辑。连续打卡奖励单独抽了一个服务,通过查询近七天是否有打卡记录来判断是否触发额外奖励。
6. 互动链路:Feed 流、点赞评论和消息通知的实现
6.1 关注 Feed 的 DB 实现与游标分页
关注流要解决的核心问题是:用户登录后,怎么看得到他关注的每个人最新发布的动态。第一版我直接用数据库关联查询:
SELECT p.* FROM post p JOIN follow_relation f ON p.user_id = f.followee_id WHERE f.follower_id = #{currentUserId} ORDER BY p.id DESC LIMIT 20;这个方案在关注数几百、动态数几十万的规模下完全没有问题,配合(followee_id, follower_id)和post(id)索引,单次查询都能在几十毫秒内返回。真正要警惕的是深分页场景:用户一直往下翻到第 500 页时,LIMIT 10000, 20会让 MySQL 先扫描一万行再丢弃,速度会明显下降。
我改成了游标分页:客户端传上一页最后一条动态的 id,后端用WHERE p.id < #{cursor} ORDER BY p.id DESC LIMIT 20查询。id 是主键且有趋势递增特性,排序和过滤都能走索引,翻页越深性能衰减越小。
6.2 点赞评论的并发一致性
点赞表唯一索引保证了同一个人对同一条内容只能点一次赞。取消点赞时走的是删除记录加更新计数两条 SQL,包在同一个事务里。计数更新用 SQL 自增而不是先查后改:
UPDATE post SET like_count = like_count + 1 WHERE id = #{postId};这样并发点赞时不需要乐观锁也能保证计数准确。缓存策略上,动态详情页先读 Redis,读不到再回源数据库;数据更新时先操作数据库,再删除缓存,而不是先删缓存再写库,否则会有写缓存覆盖旧数据的窗口期。
评论和动态内容做了简单关键词过滤,第一版用关键词列表加人工举报机制,热门动态每分钟被大量恶意灌水时能及时处理。注意这里你没渠道接外部内容安全服务的话,至少要保留举报入口和管理员审核下架能力,否则平台很容易被垃圾信息淹没。
6.3 消息通知:事件驱动加异步落库
点赞、评论、关注都会产生通知,如果这些逻辑散落在各自的业务代码里,每加一种互动类型就要改一堆地方。我用 Spring 的 ApplicationEventPublisher 发布了统一的 NotificationEvent,事件里包含 actorId、receiverId、targetType、targetId、notificationType。任何互动产生时,业务代码只需要发布事件,剩下的通知入库统一交给异步监听器。
监听器加了@Async注解,线程池单独配置,核心线程 4,最大线程 8,队列容量 200。异步的好处是点赞接口不需要等通知写完才能返回,响应时间能降下来。通知入库时做了简单聚合:一段时间内同一个用户对同一个目标的同类型互动,只保留一条通知,并把最新的点赞人信息追加到内容字段里,避免出现“张三赞了你”“李四赞了你”这种刷屏式通知。
7. 接口规范与前端联调:哪些约定能让双方少返工
7.1 统一返回体与错误码
前后端联调最怕接口返回结构不统一,一个接口返回 object,另一个返回 array,前端封装起来满屏判断。我定义了统一的 Result 结构,所有接口都返回这个格式:
public class Result<T> { private Integer code; private String message; private T data; }code 约定如下:200 成功;401 登录过期;403 无权限;404 资源不存在;409 业务冲突(比如重复打卡);500 服务器异常。业务异常和系统异常都有全局兜底,不会让前端收到一堆奇奇怪怪的堆栈文本。
7.2 分页返回结构
分页接口统一用 PageResult 包装,字段固定为 records、total、current、size、pages。前端传当前页和每页条数用 current 和 size,不用 pageNum、pageSize 的混搭。排序需求通过 sortField 和 sortOrder 传递,但后端必须做白名单校验,只允许传白名单内的字段,否则就有 SQL 注入风险。
时间格式也提前约定好。后端一律返回 ISO 格式字符串,例如2025-01-15T09:30:00,前端不用再做时区猜谜。日期类型只传日期部分,杜绝前后端对日期边界理解不一致导致的“差一天”问题。
7.3 图片上传与访问路径
图片上传用 multipart 表单,后端限制了单张 5MB,校验扩展名外还校验文件头的 magic bytes,防止有人上传一个改后缀的可执行文件。存储路径按年月日分目录,文件名用 UUID 重命名,避免中文名和路径穿越问题。上传结束后数据库存的是相对路径/2025/01/15/xxxx.jpg,访问时通过 WebMvc 配置映射到本地磁盘目录,上线后也可以用 Nginx 直接代理这个目录。
防盗链第一版没有做,因为按 Referer 判断很容易误伤正常请求,比如用户从对话窗口点开图片时 Referer 为空。真要防滥用,优先考虑缩短 URL 有效期或者加签名参数,而不是简单拦截。
8. 部署上线:从 jar 包到服务的完整流程与压测复盘
8.1 环境准备
生产环境只用了一台云服务器,2 核 4G 内存,操作系统是 Linux。MySQL 和 Redis 用 Docker 启动,应用本身打成 jar 用 systemd 管理。Docker 适合跑有状态但简单的中间件,应用进程用 systemd 管理起来更直观,日志直接进 journald,重启策略也容易控制。
MySQL 容器的连接参数里必须显式写serverTimezone=Asia/Shanghai,这一点在测试环境不容易暴露,生产环境经常会因为容器默认时区引发时间问题。Java 启动参数我配置为-Xms512m -Xmx1024m -XX:+UseG1GC,对 2C4G 的机器来说足够。应用和中间件之间用 Docker 网络隔离,端口不对公网暴露,只让应用容器访问。
8.2 压测复盘
我用压测工具模拟了 50 个并发用户,持续压打卡接口三分钟。实测下来打卡接口 TPS 稳定在 180 到 200 之间,平均响应约 200ms,P99 在 400ms 左右。对于日活几百人的社区,这个表现是够用的。压测过程中发现两个瓶颈:第一个是数据库连接池默认只有 10,某一瞬间大量写操作堆积,把连接池占满后出现连接超时。我把 HikariCP 的 maximum-pool-size 调到 20 就解决了。第二个是动态列表接口存在 N+1 查询问题,每条动态都要查发布者信息,后来改成批量查询并配合 Redis 缓存用户基础信息,接口响应从 800ms 降到了 150ms 以内。
8.3 上线后遇到的真实问题
第一个问题是时区错乱。数据库连接串里没写时区参数时,容器默认 UTC,和用户所在的东八区差了八个小时,晚上十一点的打卡被算到了第二天。解决方式是刚才说的,所有时间字段应用层传入,数据库连接串显式指定时区。
第二个问题是深分页带来的慢查询。热门动态被刷到第几十页之后,SQL 扫描行数飙升。改成游标分页后,页数不再是问题。
第三个问题是缓存穿透。某条动态被大量关注者刷到,如果这条动态其实已经被删除,请求会一直穿透到数据库。我在查询结果为空时也缓存了一个空值,并设置两分钟的过期时间,压力立刻降了下来。
第四个问题是 JWT 密钥硬编码在配置文件里还提交到了代码仓库。上线前我把密钥改成环境变量注入,同时配置了定期轮换机制。
9. 复盘与下一步
这套系统跑完一轮小范围试用之后,我的整体感受是:Spring Boot 单体架构对健身爱好者线上互动与打卡社交平台这类场景确实非常合适。它不需要过度设计,把业务闭环走通、把数据库约束设计好、把接口规范约定清楚,系统的稳定性和可维护性已经远超预期。最有价值的三段经验,我认为是数据库唯一索引兜底防重复、游标分页解决深翻页、事件驱动异步处理通知链路——这三件事看似简单,实际在真实项目中能帮你挡掉大量线上问题。
如果继续往下做,我会优先补三块:接入对象存储和 CDN 解决图片带宽问题;接入第三方登录和短信验证码降低注册门槛;根据用户的运动偏好和关注关系做内容推荐,让 Feed 流从“按时间排”升级成“按兴趣排”。社区运营层面还需要完善内容审核机制、举报处理流程和更为细粒度的用户等级体系。对做同类项目练手的人来说,我的建议是先不要碰微服务和分布式,专心把单体性能优化、缓存策略和业务闭环吃透。能在一个系统里把登录认证、打卡幂等、社交关系、消息通知、排行榜这些链路都亲手写一遍,这套项目带来的成长,远比换一堆中间件名字更有价值。