做一个在线阅读打卡系统,最难的部分往往不是“能看书”,而是把阅读行为转成可量化的数据,再推动用户持续参与。最近我把「书洞图书在线阅读打卡系统」从零到一落地,整套东西刚好把SpringBoot、Vue、SpringCloud和小程序四块拼图串到了一起:小程序端负责用户阅读和打卡,Vue后台管理图书和运营数据,后端拆成多个微服务独立开发部署。这篇文章想把这次项目里确定下来的方案、踩过的坑、可以直接复用的代码片段都翻出来聊聊,尤其适合正准备做读书社区、知识付费或者打卡激励类产品的朋友。
做这类项目的过程中,我最大的感受是:很多团队不是被技术难倒的,而是被“业务边界模糊”拖垮的。本来一个单体也能跑,硬拆成十几个微服务,光是理清服务之间的调用关系就够喝一壶。所以下面所有内容,都基于一个前提:先想清楚业务闭环,再决定服务怎么拆、数据怎么放、接口怎么对。
1. 系统定位与核心业务拆解
1.1 先理清楚这个系统到底服务谁
“书洞”表面上是一个图书在线阅读工具,但加上“打卡”两个字,它的核心逻辑就变了。用户进入小程序,先浏览书架选择一本书,然后进入阅读页开始读,读到一定时长可以打卡,打卡会累计连续天数和积分,积分又能反过来兑换权益或者参与排行榜。对运营方来说,需要在后台把图书、章节、用户数据和打卡规则管理起来。
这里有几个角色和场景,我把它拆成表格方便看清楚:
| 角色 | 使用端 | 核心诉求 |
|---|---|---|
| 普通读者 | 小程序 | 找书、阅读、记录进度、打卡、看排行 |
| 运营管理员 | Vue后台 | 维护图书与章节、查看用户数据、配置积分规则 |
| 系统管理员 | Vue后台 | 管理用户状态、服务监控、异常处理 |
这一步梳理下来,业务域就非常清楚了:用户身份是一块,图书内容是一块,阅读打卡行为是一块,积分激励又是一块。这四块天然适合拆成独立服务,因为它们的数据模型、变化频率、扩展方向都不一样。
1.2 为什么选SpringCloud而不是单体应用
有些朋友看到“微服务”就头疼,觉得一个小项目没必要。但“书洞”这类产品有几个特点:用户行为数据量大、需要高频写入阅读进度和打卡记录;图书内容更新和用户系统之间没有强耦合;后续很可能要加新功能,比如书评、圈子、活动。如果全塞在一个单体里,任何模块出问题都会影响整体。
选了SpringCloud之后,我控制在了六个服务左右,而不是一上来就拆十几个:
- 网关服务:统一入口,做路由和鉴权过滤
- 用户服务:登录注册、openid绑定、用户资料
- 图书服务:图书信息、章节内容、分类管理
- 阅读打卡服务:阅读时长记录、打卡判定、连续天数
- 积分服务:积分流水、积分发放、排行数据
- 公共服务:公共工具类、统一响应结构、Feign接口定义
拆分的核心原则是“高内聚低耦合”。比如阅读打卡服务可以自己落库阅读记录,也可以直接调用用户服务查询用户是否存在,但不要把图书内容和打卡进度塞在同一张表里。
2. 微服务拆分与数据建模实战
2.1 服务之间怎么划分才不别扭
我自己在拆分时,最怕的是为了拆而拆。判断标准很简单:如果两个功能模块对数据的读写频率差距很大,或者未来扩展方向不同,就拆开。比如图书章节内容,基本是“读多写少”,而且是静态内容;用户的打卡记录则“写多读也多”,还需要实时计算。拆开后,图书服务可以做缓存优化,打卡服务可以单独扩容,互不影响。
服务之间的调用,我选的是OpenFeign,配合Nacos做注册发现。登录接口走网关,网关校验JWT后把用户信息通过Header透传给下游服务。这里有个小细节:下游服务不要自己去解析Token,统一从Header里拿userId,这样避免了每个服务都引入一遍JWT解析逻辑。
2.2 核心数据表设计经验
数据库我按服务拆库,每个服务维护自己的表。下表是几个核心库表的设计思路:
| 表名 | 所属服务 | 关键字段 | 说明 |
|---|---|---|---|
| user_account | 用户服务 | id、openid、nickname、avatar、status | 小程序用户唯一标识 |
| book_info | 图书服务 | id、title、author、cover_url、category_id、status | 图书元信息 |
| book_chapter | 图书服务 | id、book_id、chapter_no、title、content、word_count | 章节内容,用TEXT类型 |
| clock_record | 打卡服务 | id、user_id、book_id、clock_date、duration、streak、status | 每日打卡记录 |
| score_log | 积分服务 | id、user_id、change_score、source_type、source_id、create_time | 积分变动流水 |
这里重点说下打卡记录表。刚开始我图省事,只存“用户id + 打卡日期”,连续天数用代码实时往前推。后来发现排行榜、个人中心、日历视图都要用连续天数,每次都把所有历史记录查一遍,性能很差。最后折中方案是:打卡表里直接加一个streak字段,记录当天的连续打卡天数,插入时只需查昨天一条记录就能算出来。这个字段虽然冗余,但查询收益非常大。
另外,章节内容我直接用TEXT字段存,没用文件存储。原因很简单:书洞的章节内容是结构化数据,需要按章节号精准读取,数据库存储配合索引,查询简单又可控。如果以后涉及PDF、音视频这种大文件,再引入对象存储也不迟。
3. 后端关键链路:认证、网关与打卡计时
3.1 统一登录与接口鉴权
小程序端的登录流程和Web端不一样,没有账号密码,而是通过wx.login拿到code,后端拿着code去微信接口换openid。我在用户服务里做了一个登录接口,流程是:
- 小程序调用wx.login获取临时code
- 后端用code换取openid和session_key
- 查询user_account表,如果没有记录则自动注册
- 生成JWT返回前端,前端后续请求都带上Authorization头
网关过滤器拦截所有请求,白名单放行登录、图书列表等公开接口。代码大致是这样:
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Autowired private JwtUtil jwtUtil; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); if (WHITE_LIST.contains(path)) { return chain.filter(exchange); } String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (token == null || !jwtUtil.validateToken(token)) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } String userId = jwtUtil.getUserId(token); ServerHttpRequest mutatedRequest = exchange.getRequest().mutate() .header("X-User-Id", userId) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } @Override public int getOrder() { return -100; } }这里有个容易被忽视的问题:网关转发时默认会丢掉自定义Header,需要在路由配置里用“dedupe”策略保留X-User-Id。Spring Cloud Gateway有一个HeadersFilter,不加配置的话下游拿不到用户信息,排查起来非常隐蔽。
3.2 阅读打卡的核心逻辑与事务控制
整个系统的重头戏都在打卡逻辑上。我当时的流程是这样设计的:
用户打开阅读页,前端每30秒上报一次当前章节和阅读时长。打卡服务收到后累加当日时长,并更新最后阅读章节。当当日累计阅读时长超过设定阈值(比如5分钟),用户点击“打卡”,服务端执行打卡逻辑。
@Service public class ClockService { @Transactional(rollbackFor = Exception.class) public ClockResult clockIn(Long userId, Long bookId) { LocalDate today = LocalDate.now(); ClockRecord record = clockRecordMapper.selectByUserAndDate(userId, today); if (record != null && record.getStatus() == 1) { return ClockResult.already(); } Integer duration = readDurationService.getTodayDuration(userId, today); if (duration < MIN_CLOCK_DURATION) { return ClockResult.notEnough(); } // 计算连续打卡天数 int streak = 1; ClockRecord yesterday = clockRecordMapper.selectByUserAndDate(userId, today.minusDays(1)); if (yesterday != null && yesterday.getStatus() == 1) { streak = yesterday.getStreak() + 1; } ClockRecord newRecord = new ClockRecord(); newRecord.setUserId(userId); newRecord.setBookId(bookId); newRecord.setClockDate(today); newRecord.setDuration(duration); newRecord.setStreak(streak); newRecord.setStatus(1); clockRecordMapper.insert(newRecord); // 发放积分 int score = 5; if (streak >= 7) { score += 10; } scoreService.addScore(userId, score, "CLOCK", newRecord.getId()); return ClockResult.success(streak, score); } }有两处要注意。第一,事务必须加在打卡记录插入和积分发放上,因为这两个操作要么同时成功,要么同时失败。我当时试过把积分发放改成异步,结果遇到打卡记录成功、积分没发的情况,用户反馈体验很差。第二,连续天数的计算,要查昨天而不是查“最近一条记录”。理由很简单:如果用户昨天打卡了、今天还没打,最近一条记录只会是昨天,直接加1会把昨天的记录误判成“已连续到今天”,影响后续判断。
3.3 排行榜怎么设计才扛得住
排行榜我是用Redis的ZSet做的。每日打卡榜的key为“rank:daily:日期”,用户打卡成功时执行ZADD,member是userId,score是连续天数或累计积分。这样做的好处是实时性高,前端拉排行榜只需一次ZREVRANGE,毫秒级返回。
但ZSet有个问题:数据需要持久化,不然Redis重启排行榜就丢了。我的方案是每天凌晨用定时任务把前一天排行快照落库,生成rank_history表。这样既保证实时查询走Redis,又保证历史数据可回溯。
4. 小程序端与Vue管理后台的落地实现
4.1 小程序端的阅读体验与打卡交互
小程序端是整个产品的门面,阅读体验做不好,打卡功能再完善也是白搭。页面我划分成书架页、阅读页、打卡页、个人中心四个Tab。
阅读页是最花心思的。章节内容长,不能一次性全渲染,我用的是scroll-view配合分页加载。进入页面先加载当前章节的前三屏内容,滚动到底部时再加载下一段。同时,前端会用本地缓存记录当前阅读位置,用户退出再进来时,自动定位到上次读到的地方。
上报阅读进度的接口,我做了个简单防抖逻辑:每30秒调一次,避免频繁请求打爆后端。这里有个填过的坑:小程序在切后台时会暂停JS执行,如果你只依赖setInterval上报,用户切后台看微信消息再回来,这段时间的阅读时长就会丢失。正确做法是同时监听onHide和onShow,在onHide时立即上报一次,在onShow时计算切出时长并合并到本地累计里,下一次上报时一起提交。
打卡页用了一个简单的日历组件,高亮已打卡日期,下方展示连续天数。因为连续天数已经冗余在打卡记录表里,前端直接查当天记录就能展示,不需要再写复杂的统计接口。
4.2 Vue管理后台的搭建与权限控制
Vue后台我用的是Vue3加Element Plus,路由层面做了动态权限控制。管理员登录后,后端根据角色返回菜单权限,前端用addRoute动态注册。比如普通运营只能看到图书管理和打卡记录,超级管理员才能看到用户管理和系统监控。
图书管理页面里最麻烦的是图片上传。图书封面如果直接传给Java后端,再转发到对象存储,带宽和耗时都不划算。我的做法是:前端先请求后端获取一个临时上传凭证,然后直接把图片传到对象存储,上传成功后再把文件URL提交给图书服务。这样业务服务器不经过图片字节流,压力小很多。
数据看板我用了ECharts,主要展示三块数据:每日新增用户、每日打卡率、图书阅读排行TOP10。这些数据如果直接聚合查询数据库,报表加载会很慢。我是用定时任务每天凌晨把统计结果算好,写入dashboard_summary表,前端只查汇总数据。
5. 分布式部署与排查实录
5.1 用Docker Compose快速编排整套环境
本地开发时大家各跑各的服务没问题,但联调和部署时,最好有统一的环境编排。我是用Docker Compose把基础设施和服务都编排起来的,大致结构:
version: "3.8" services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: book_cave ports: - "3306:3306" redis: image: redis:7.0 ports: - "6379:6379" nacos: image: nacos/nacos-server:v2.2.3 environment: MODE: standalone ports: - "8848:8848" - "9848:9848" gateway: build: ./gateway ports: - "8080:8080" depends_on: - nacos user-service: build: ./user-service ports: - "8081:8081" depends_on: - nacos - mysql book-service: build: ./book-service ports: - "8082:8082" clock-service: build: ./clock-service ports: - "8083:8083" score-service: build: ./score-service ports: - "8084:8084"这里端口规划要提前做好,避免服务多了之后互相冲突。服务启动顺序也有讲究,一定要先启动Nacos和数据库,然后是业务服务,最后是网关。如果业务服务比Nacos先启动,注册会失败,需要等Nacos就绪后重启服务才能正常注册。
5.2 实际项目中的高频坑点
做这个项目踩了不少坑,挑几个最高频的列一下:
| 问题现象 | 根本原因 | 解决方式 |
|---|---|---|
| 打卡服务之间调用超时 | Feign默认超时只有1秒,打卡逻辑里查库较多 | 在配置文件调大connectTimeout和readTimeout |
| 小程序请求一直失败 | 小程序要求HTTPS合法域名,本地联调用的是HTTP | 开发工具勾选“不校验合法域名” |
| 用户打卡跨天数据错乱 | 服务器时区与用户本地时区不一致 | 后端统一使用东八区时区,打卡日期基于用户传的timezone计算 |
| 排行榜积分和数据库不一致 | Redis故障或重启导致ZSet丢失 | 定时任务做快照落库,必要时从快照恢复 |
还有一个印象很深的坑:用户服务数据库连接池被占满。原因是打卡高峰期用户批量涌入,每个打卡请求都要查询用户服务校验身份,连接数一下子打满。后来我在网关层面加了简单的限流,并且给用户服务配置了连接池最大连接数,才把问题压下去。这个问题的排查过程也提醒了我:微服务架构下,任何一个下游服务的抖动都会通过调用链放大,所以服务之间的超时和重试策略一定要提前设计。
另外一个隐藏比较深的是跨服务事务问题。打卡记录和积分发放我用的是本地事务,但如果两个服务之间有更复杂的业务,比如打卡成功后还要触发活动奖励,就会涉及分布式事务。我当时权衡了一下,活动奖励走的是异步消息,失败了可以通过对账补偿,所以没有引入分布式事务框架。如果你们的业务要求强一致,那就得提前评估Seata或可靠消息方案,而不是等到线上出问题再补。
最后再分享一个关于微服务拆分的个人体会:这个项目做下来,我最大的收获不是把SpringCloud全家桶用熟练了,而是学会了“克制”。不是每个功能都适合拆成微服务,也不是每张表都要独立库。书洞最终保留的几个服务,每一个都有清晰的业务边界和独立的扩展需求。如果你只是做一个演示项目,单体加上模块化拆分完全够用;但如果你要的是团队并行开发和独立部署的能力,SpringCloud这套组合拳依然是最稳妥的选择。希望这篇项目复盘能帮到正准备做类似系统的朋友,少走一些弯路。