☰
学习打卡功能开发实战:从表结构设计到线上踩坑全记录
2026/10/18 4:39:10 网站建设 项目流程

前段时间接了一个学习类App的项目,需求池里躺着一个编号3.10的任务,名字就四个字:学习打卡。当时团队里不少人都觉得,这不就是给用户一个按钮,每天点一下签个到吗?结果真进入开发阶段才发现,这个看似人畜无害的功能,牵涉到的细节远比想象中多——连续天数怎么算、跨天怎么处理、补卡规则怎么定、并发点击会不会刷出多条记录、时区不一样的人打卡时间算哪一天……每一个点都能让测试同学头大三天。这篇文章就把我从需求梳理到上线维护的完整过程记录下来,包括你直接能拿去用的表结构、接口设计和代码逻辑,也有我在实际操作中踩过的一些坑。内容适合正在做学习类产品、社区养成类模块的产品经理、前后端工程师和测试同学,哪怕是刚入门的新人也能照着思路落地。

1. 需求拆解与整体设计思路:学习打卡不是一个“点按钮”的事

1.1 把模糊需求翻译成可落地的功能清单

项目最初的描述只有寥寥几行:“3.10 学习打卡:用户每天打卡,记录学习状态,展示连续天数。”连打卡范围是什么都没有说清楚。我在做技术方案之前,先拉着产品随手写了一份功能清单,这一步看起来不算技术活,但对后续开发影响极大。

打卡功能拆下来,至少包含几块:打卡动作、打卡记录查询、连续天数统计、积分或激励、提醒机制,以及带争议的补卡。对照MVP优先级,打卡动作和记录查询是必须项,连续天数统计是核心卖点,积分激励可以先做最简单版本,提醒机制可以二期接,补卡则是建议直接藏起来,不要上线首版就放开。这样排序的理由很简单:学习打卡的核心用户动线是“记录-反馈-坚持”,先保证这一条链路稳定,再叠加花活。

清单确定后,我建议团队用“业务日”而不是“自然日”来定义打卡归属。举个例子,凌晨0点30分打的卡,算头一天晚上还是当天?大部分用户心里的“今天”是早上醒来之后的时间。如果按服务器时间0点切天,就会有人因为零点打卡被分到前一天,体验很怪。因此我们约定:业务日按用户所在时区的自然日计算,切割点是当地时间的0点。这个约定后续省了很多解释不清的坑。

1.2 技术选型:为什么是这套组合

打卡场景的技术特点是写多读多、数据量增长快、状态简单。用户每天至少打一次卡,一年就是365条记录,看起来不多,但用户数量过万后就是几百万行。考虑到团队最熟的栈是Java后端加MySQL,我在技术选型上并没有追求新奇,而是保留了常规但稳妥的组合:Spring Boot作为服务端框架,MySQL做持久化存储,Redis做连续天数和热数据的缓存。前端侧,因为产品需要同时覆盖小程序和App,选择了跨端框架来统一代码。

有人会问为什么不用MongoDB或者专门的时序数据库来存打卡记录。我考虑过,但最终放弃了。打卡记录本质上就是“用户ID+日期+状态”的简单结构,用MySQL关系模型非常好表达,而且团队对MySQL的运维和排障经验成熟,出了问题容易定位。Redis则盯准了连续天数计算的高频读需求:用户每次打开打卡页,首页就要展示连续打卡天数,如果每次都去MySQL里扫表回溯,压力不值得。用Redis存一份缓存,命中就直接返回,穿透时再回源计算,这套逻辑在团队里是验证过的。

还有一个容易被忽视的决策:打卡记录不要直接接消息队列异步写。很多设计者喜欢把打卡这种低操作成本的请求扔进MQ,觉得能削峰,但这会让用户打卡后立刻看到“成功”和后续数据不一致之间出现时间差。学习打卡的反馈链路要求秒级一致,用户点了按钮,页面马上变成已打卡状态。所以打卡接口走同步写MySQL,确认成功后再更新Redis,异步可以留给积分发放这类非核心动作。

2. 核心数据模型与边界细节:把连续天数算明白

2.1 打卡记录表应该怎么设计

数据表是整个打卡功能的地基。我设计的表结构如下:

CREATE TABLE `check_in_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '用户ID', `check_in_date` date NOT NULL COMMENT '业务日期,按用户时区', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `source` varchar(20) NOT NULL DEFAULT 'app' COMMENT '打卡来源:app/h5/mini_program', `is_makeup` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否补卡', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_date` (`user_id`, `check_in_date`), KEY `idx_user_month` (`user_id`, `check_in_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个关键点要解释清楚。第一是唯一索引 uk_user_date,它负责挡住重复打卡请求。无论前端按钮被用户点多少次,接口被并发请求多少次,数据库层面就能保证同一个用户同一天最多只能有一条有效记录。第二是check_in_date 与 created_at 分离,created_at是服务器时间,用来做审计和排查,check_in_date才是用户可见的业务日期,真正决定打卡归属哪一天。第三是is_makeup字段,为后续可能的补卡功能预先留口子,首版可以先不放,但表结构有一列不会增加成本。

积分变动的表也跟着设计好了,避免以后要查用户积分流水时抓瞎:

CREATE TABLE `point_change_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `change_value` int(11) NOT NULL COMMENT '变动值,正负', `biz_type` varchar(30) NOT NULL COMMENT '业务类型:check_in/check_in_extra', `biz_id` bigint(20) DEFAULT NULL COMMENT '关联业务ID', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这张表的核心是biz_id关联打卡记录ID,保证积分流水可追溯,排查数据不一致时能快速定位是哪一笔业务产生的积分。

2.2 连续天数计算:边界条件比你想的多

连续打卡天数是最容易出Bug的地方。常见的错误算法是“拿今天往前数,只要每天都打卡就+1”,但这里隐藏着大量边界情况,我实际处理时至少遇过这几种:

  • 跨年跨月:连续天数不能只查当月的记录,需要回溯到上月甚至上年。
  • 空档判断:用户昨天没打卡,但前天连续打了10天,今天再打卡连续天数应该是1,而不是把历史加起来。
  • 未来日期:用户手动调整设备时间到明天,然后打卡,系统必须拒绝,否则连续天数会被“预支”。
  • 时区差异:同一个服务器部署在全球,用户可能在不同时区,打卡日期不能一刀切用UTC。
  • 补卡影响:补卡当天算不算连续,规则不同结果完全不同。

我最后实现的连续天数算法逻辑并不复杂,但每一步都要想清楚。核心思路是以“当前业务日”为锚点,往前遍历最近的一条打卡记录,如果这个日期不是今天也不是昨天,那么连续天数直接归零或为1;如果是正常连续轨迹,再继续往前找相邻日期的记录,直到出现断档。

2.3 Redis缓存键设计与更新策略

连续天数的高频读取靠Redis缓存。我设计了两个键:checkin:streak:{userId},值为连续天数;checkin:last_date:{userId},值为最近打卡的业务日期,例如“2026-01-15”。只要有了“最近打卡日期”和“连续天数”,就能快速判断用户今天是否已打,以及今天打了之后连续天数应该加几。

更新策略要注意一个细节:用户打卡成功当天,不要直接累加连续天数,而是先判断昨天是否打卡。如果昨天有记录,新连续天数才是旧天数+1;如果昨天没记录,说明从今天重新开始连续,新值就是1。这个判断不能省,否则就会出现用户隔天打卡但连续天数还是累加的情况。

为了性能,当用户查询打卡状态时,先用Redis拿数据,如果Redis里没有对应键,再回源MySQL扫描最近30条记录做回溯计算,算好之后回填Redis。这个回源逻辑一定要加分布式锁或者设置短过期时间,防止多个请求同时回源把数据库打爆。我从实战中得到的经验是:锁的粒度细到用户维度就够用了,不需要全局锁。

3. 从数据库到接口到前端:打卡功能的落地实现

3.1 接口定义:把业务逻辑收敛到后端

后端接口我一共做了三个,定位都很清晰。

打卡接口:

POST /api/v1/checkin Content-Type: application/json { "source": "app", "clientTime": 1734256800000 }

这里的clientTime是客户端当前毫秒级时间戳,主要作用是服务端辅助判断用户设备时间和服务器时间的偏差,真正决定业务日期还是后端根据自己的业务日规则来算,防止用户篡改设备时间刷连续天数。

记录查询接口:

GET /api/v1/checkin/records?year=2026&month=1

返回当月每天的打卡状态列表,前端用来渲染日历热力图,接口返回数据量很小,也不需要分页。

汇总接口:

GET /api/v1/checkin/summary

返回连续打卡天数、累计打卡天数、今日是否已打卡,首页直接拉这个接口就够。

接口层面还有一个约定:所有接口必须返回统一的响应结构,包含code、message、data三个字段。打卡接口的成功code是0,错误码给到10001代表“重复打卡”,10002代表“日期非法”。这个约定让前端处理逻辑时不用猜。

3.2 打卡接口的核心实现逻辑

服务端处理打卡请求时,我按下面这个顺序做,每一步都不能省:

第一步,校验入参。clientTime不能为空,source限定在枚举范围内。

第二步,计算业务日期。根据userId获取用户所在的时区配置,然后用这个时区算出用户当前的业务日期,格式如“2026-01-15”。用户时区的获取优先从用户表中读取,读不到就按默认时区处理,但一定要在日志里记一条warn,方便排查。

第三步,查重。通过check_in_record表检查唯一索引,直接INSERT即可,如果捕获到DuplicateKeyException,就说明重复打卡了,返回错误码10001。这里不要先SELECT再INSERT,两步操作有并发窗口,唯一索引才是最终防线。

第四步,更新Redis。拿到今日打卡成功后,读取checkin:last_date:{userId},如果last_date等于今天的业务日期,说明是重复请求被Unique拦截;如果last_date等于昨天的日期,说明连续轨迹没断,streak加1;否则streak重置为1。然后更新last_date为今天的业务日期。

第五步,发送积分奖励。积分变动不放在主链路里同步执行,而是直接写point_change_log表,如果依赖外部系统再加MQ异步,否则会拖慢打卡接口响应时间。

核心代码逻辑用Java伪代码表示如下:

public CheckinResult checkin(Long userId, String source, Long clientTime) { LocalDate bizDate = userTimeZonePolicy.getBizDate(userId, clientTime); CheckInRecord record = new CheckInRecord(); record.setUserId(userId); record.setCheckInDate(bizDate); record.setSource(source); try { checkInRecordMapper.insert(record); } catch (DuplicateKeyException e) { return CheckinResult.duplicate(); } Long newStreak = calculateStreakAfterCheckin(userId, bizDate); redisService.set("checkin:last_date:" + userId, bizDate.toString()); redisService.set("checkin:streak:" + userId, newStreak); pointService.recordAsync(userId, 10, "check_in", record.getId()); return CheckinResult.ok(newStreak); }

这里唯一“容易慌”的点是重复插入的异常处理,很多新人喜欢先查再插,结果并发一高就出重复数据。我自己踩过这个坑之后,就只信数据库唯一索引了。

3.3 前端交互与状态呈现

前端打卡页的核心元素有三个:主打卡按钮、连续天数卡片、日历热力图。按钮的状态我设计成三种:未打卡(可点击)、已打卡(置灰并展示“今日已打卡”)、补卡中(可选,首版不做)。

要注意按钮点击的交互细节:点击后立刻置灰,并进入“打卡中”加载态,等接口返回成功后再展示连续天数动画。不要等接口失败了才解除置灰,而是要定义一个失败后的重试状态,防止用户连续快速点击造成重复请求。虽然数据库有唯一索引兜底,但能减少无效请求终归是好的。

日历热力图我直接用了一个现成的日历组件,然后按月份拉打卡记录。渲染逻辑是:遍历当月每一天,如果当天有记录就高亮,同时用颜色深浅区分是否连续,比如连续1到3天浅绿,4到7天中绿,8天以上深绿。这部分前端实现不难,难的是用户对“连续”的心理感知,所以我会在连续天数卡片上放一个“再坚持N天获得徽章”的进度条,让用户看到明确目标。

4. 线上踩坑实录:打卡数据为什么会乱

4.1 打卡时间总是不对:时区问题排查

项目上线两周后,运营反馈有海外用户打卡日期显示乱码,明明是当地时间1月15日晚上10点的打卡,App里却显示成1月16日。查了一圈发现,后端接口用服务器时区(默认UTC+8)来计算业务日期,而用户位于UTC+4时区。服务端按北京时间已经进入1月16日,但用户当地时间还是1月15日晚上10点,因此记录被打到了“明天”。

这个问题不只在海外用户身上出现,国内跨时区旅行的人也会遇到。解决方法是把用户时区作为基础信息存进用户表,打卡时取用户当前时区的日期作为业务日。如果取不到时区,默认使用服务端所在时区兜底,但必须给用户提供手动纠正入口。上线这个小修复之后,乱码问题就消失了。

4.2 并发点击引发的重复打卡

刚开始上线时,有用户反馈自己只打了一次卡,积分却加了两倍。查日志发现是用户连续快速点击了打卡按钮,前端虽然做了置灰,但有个旧版本客户端没有做状态控制,导致同时发出3个请求。三个请求全部打到后端,唯一索引拦截了后面两个插入,但由于积分发送是异步的,前两个请求在唯一索引判断之前都已经执行到了积分发送逻辑,结果就重复加了积分。

这个案例说明一个道理:幂等不能只靠唯一的数据库约束,业务逻辑也要做幂等。后来我把积分发放改成由打卡记录ID驱动,通过biz_id字段的唯一性避免重复发放,在point_change_log表上加了唯一索引uk_user_biz_type_bizid,保证同一张打卡记录只能产生一条积分流水。

4.3 补卡功能带来的统计难题

产品中期提了一个需求:允许用户每月补卡3次,理由是用户可能会因为突发事件错过当天打卡。我没有直接反对,但提了一个方案:补卡操作只能补充“过去的日期”,不能补“未来日期”;每月有次数上限;补卡不产生积分,或者积分减半;补卡后连续天数以实际记录为准,但补卡动作本身要打上is_makeup标记,方便后续审计。

上线之后果然出了Bug。有用户在月底最后一天补了月初漏打的卡,直接导致“月打卡率”统计口径出现两条线:按打卡记录数统计和按业务日统计结果互相矛盾。后来跟运营对齐了规则:月打卡率分母是当月天数,分子是当月实际打过卡的天数,补卡记录按打卡日计算,不按补卡操作日计算。规则一旦定清楚,统计口径就稳了,前端展示的数据也不再忽高忽低。

4.4 一套线上排查清单

打卡功能上线免不了要处理线上异常。我整理了一份排查步骤,测试和运维照着做基本能快速定位问题:

  • 查用户反馈的具体时间点,先看服务端日志中该用户ID的打卡请求,确认是否进入后端,返回码是什么。
  • 查MySQL中该用户在对应业务日的打卡记录是否存在,注意区分是完全没有记录还是记录被覆盖。
  • 查Redis中checkin:streak和checkin:last_date两个键的值,与MySQL记录比对,不一致时以MySQL为准,手动修正Redis缓存。
  • 查point_change_log表,确认积分流水是否有重复或缺失,特别注意biz_id是否关联到了正确的打卡记录ID。
  • 所有修复动作执行前,先导出相关记录备份,操作过程全程留日志,不要在客户环境和数据无备份的情况下直接改库。

这套清单救过我很多次,尤其是“Redis和MySQL不一致”的场景,多半是缓存更新失败或者回源计算错误导致的,修正缓存后问题就解决了。

5. 打卡功能上线后的迭代与扩展思考

5.1 从打卡记录里还能挖掘什么

打卡功能稳定后,我就在想,这些数据除了展示连续天数,还能怎么用。后来发现打卡时间分布本身就是一个很有价值的维度:有人习惯早起打卡,有人习惯睡前打卡,还有人是固定午休时间。这些行为习惯可以通过打卡时间戳聚类分析出来,然后给不同用户匹配不同的提醒策略。

比如早起的用户,提醒时间放在早上7点前;夜猫子用户,提醒放在晚上10点前。这样个性化之后,有效打卡率确实涨了几个百分点。再进一步,连续打卡超过30天的用户和频繁断卡用户的推送文案也要不同,前者更愿意接受挑战式激励,后者需要更多宽容感,比如“断卡也没关系,今天重新开始”会比“你已经断了3天”更友好。

5.2 习惯养成设计里的一个反直觉经验

最后再分享一个实际操作中得来的经验:打卡功能上线后,最担心的不是技术稳定性,而是用户为了刷连续天数钻空子。有一段时间,有人用修改设备时间的方式提前打卡“预支”连续天数,逼得我们强制做了服务器时间校验,前端必须把设备信息和打卡动作绑定校验。这件事让我意识到,习惯养成类功能必须在“激励”和“防作弊”之间找平衡。

后来我们还加了一个“免打卡日”机制,允许用户每周有一天不打卡也能保留连续天数。一开始团队担心这样做会降低活跃度,结果实际数据反而更好——给了当天确实忙不过来的用户一个缓冲,他们不会因为断了一次就直接放弃,整体留存反而上升。如果你也在做类似功能,可以考虑把这个设计提前放进去,这比事后补卡机制更有效,也更容易向用户解释规则。

学习打卡这个项目做下来,我最大的体感是:一个功能的大小不在于代码量,而在于边界条件的数量。能把“每天点一个按钮”背后这些细节都想清楚,在数据层、接口层、展示层都做好对应处理,这个功能才真正立住了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询