☰
PHP打卡系统核心设计:连续天数与补签防重实战
2026/9/30 3:17:23 网站建设 项目流程

先说明一下,我是从“戒了么4.0 戒色签到打卡源码”这个项目名切入来写的。很多人乍一看以为是普通打卡小程序,实际上这套系统的核心价值在“连续天数计算、防重复提交、补签机制、数据可视化”这几块,尤其适合想自己搭一套习惯打卡系统、或者做类似“签到/自律/陪伴成长”类产品的人参考。项目本身不复杂,但坑不少,我把重点梳理出来,直接按这套方案改改就能用。

1. 戒了么4.0整体设计与需求拆解

1.1 核心需求到底是什么

“戒色签到打卡”表面看是一个打卡工具,但把它拆开,本质上是一个“行为自律记录系统”。这类系统和普通日历打卡的最大区别在于:它不只是一个“勾选完成”的按钮,而是需要围绕“坚持到今天第几天”这个核心指标去设计整套数据模型和交互逻辑。

我先说需求拆解后的四个核心模块:

  1. 每日打卡:用户每天只能打一次,不能重复打,不能提前打第二天。
  2. 连续天数展示:这是所有打卡类应用最核心的“爽点”数据,用户打开页面第一眼想看的就是“我已经连续戒了几天”。
  3. 历史记录与报表:按月/按年展示打卡日历,让用户看到自己过去的坚持轨迹。
  4. 补签与异常处理:人总有忘记的时候,补签逻辑直接决定用户是放弃还是继续用下去。

这四点,前两个是功能硬指标,后两个是留存软指标。绝大多数轻量级打卡源码只做了前两个,做到第三、四点的才是真正能长期运行的系统。

1.2 为什么选择PHP + MySQL这套技术栈

项目源码最直观的形态是PHP后端 + MySQL数据库 + 前端页面(可以是微信小程序、公众号H5,也可以是纯Web页面)。我之所以说这个技术栈合理,是因为这类“单机自用型”源码有一个特点:不需要高并发,不需要分布式,但必须容易部署、容易改、容易看懂。

  • PHP部署成本低,虚拟主机都能跑,不用编译,改了就能生效。
  • MySQL对日期处理、统计查询都很方便,尤其DATE类型和DATE_SUB这类函数做打卡判断非常顺手。
  • 前端无论做什么端,最终都是通过HTTP接口调后端,PHP接口写起来直接,不用引入一堆框架。

如果你要用Java Spring Boot或者Python Django重写,逻辑完全一样,核心是数据表设计和API约定。我下面的讲解会直接贴出可运行的PHP代码,但重点讲的是原理,换成任何语言都能照搬。

1.3 4.0版本升级了什么

从功能定位看,4.0版本相比早期版本,最值得注意的三个改动是:

  1. 补签开关:很多打卡类应用为了数据“纯净”,不允许补签,结果用户断一次就彻底放弃。4.0的设计是需要合理控制补签次数,比如一个月最多补签3次,每次补签前一天,这样既保住用户的连续记录,又不至于让数据失真。
  2. 跨年连续计算:早期版本经常出现12月31日和1月1日之间连续天数断掉的问题,4.0专门修正了日期连续性的判断逻辑。
  3. 防重复提交:以前直接按“今天是否已打卡”判断,在并发或网络延迟场景下会出bug,4.0改成数据库唯一索引兜底。

2. 数据库设计:连续天数算法的地基

2.1 用户表与打卡记录表

先看核心SQL,这是整套源码的基石。

CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `openid` varchar(64) NOT NULL COMMENT '小程序/公众号唯一标识', `nickname` varchar(50) NOT NULL DEFAULT '' COMMENT '昵称', `avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '头像', `start_date` date NOT NULL COMMENT '开始打卡日期', `continuous_days` int(11) NOT NULL DEFAULT 0 COMMENT '当前连续天数', `max_continuous_days` int(11) NOT NULL DEFAULT 0 COMMENT '历史最长连续天数', `total_checkin_days` int(11) NOT NULL DEFAULT 0 COMMENT '累计打卡天数', `last_checkin_date` date DEFAULT NULL COMMENT '最后一次打卡日期', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1正常 0暂停', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `idx_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `checkin_log` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `checkin_date` date NOT NULL COMMENT '打卡日期,只存日期', `checkin_time` datetime NOT NULL COMMENT '实际打卡时间', `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '当日记录/感悟', `makeup_flag` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否补签 0正常 1补签', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_user_date` (`user_id`, `checkin_date`), KEY `idx_user_id` (`user_id`, `checkin_date` DESC) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

为什么打卡记录表不直接存一个“日期时间字段”而要拆成checkin_date和checkin_time两个字段?这是我在实际开发里踩出来的经验。因为所有连续天数断签判断、跨月统计、日历展示,都需要按“天”为单位聚合。如果只用datetime,每次查询都要DATE(checkin_time)做函数转换,一旦数据量上来,这个查询是没法走索引的,性能会非常差。拆成两个字段后,checkin_date可以直接走唯一索引,既保证了一天只能打一次卡,又让按日期统计的SQL变得很简单。

2.2 连续天数到底怎么算

连续天数的计算是整套源码最容易翻车的地方。我先说结论:连续天数不建议每次实时从头扫全表计算,而是采用“缓存值 + 增量更新”的方式。

用户表里有一个last_checkin_date字段,记录最后一次打卡的日期。打卡时按以下规则更新:

  1. 如果last_checkin_date等于今天,说明已经打过卡,直接返回当前连续天数,不做任何更新。
  2. 如果last_checkin_date等于昨天的日期,说明昨天打了卡、今天也打,连续天数加1。
  3. 如果last_checkin_date压根没有值(首次打卡),连续天数设为1。
  4. 如果last_checkin_date既不是昨天也不是空,说明中间断了,连续天数重置为1。

比如用户上次打卡是6月18日,今天打开是6月20日,中间6月19日没打,那么6月20日这一天打卡后,连续天数直接清零重新从1开始,而不是继续累计。这符合“连续”二字的直觉。

这里有一个非常重要的细节:判断是否“昨天”,不能简单地用date('Y-m-d', strtotime('-1 day'))。因为PHP服务器时区、MySQL时区、用户所在地时区可能不一致。如果服务器默认UTC,用户在凌晨打卡,很容易出现“昨天计算错位”。我的做法是整个项目统一设置:

date_default_timezone_set('Asia/Shanghai');

MySQL连接后也执行一次:

SET time_zone = '+08:00';

不统一时区,再完美的计算逻辑都会出莫名其妙的日期偏移。

2.3 补签设计:如何让用户不流失

补签是打卡系统里非常有争议的功能。有些产品做“完美强迫症”,一次都不能漏;但实际运营下来,大部分人会在第一次断签后直接弃用。所以4.0的源码我没有做成无限补签,而是规定一个时间段内的补签次数。

补签表的逻辑是在checkin_log里加一个makeup_flag字段,同时在用户表或单独配置表里记录每月剩余补签次数。补签规则的实现要点如下:

  • 只能补签过去7天内的日期,超过7天不允许补。
  • 补签日期要和已打卡日期不冲突,否则会被唯一索引拦住。
  • 补签也要记录真实补签时间,方便后台管理员查看。

补签对连续天数的影响是:有补签记录时,断签判断就不能只看last_checkin_date是否等于昨天,而要看“最近连续有打卡的日期链”是否覆盖了从开始到今天的所有日期。这句话有点绕,我后面在代码实现章节会详细讲。

我实际的做法是写一个公共函数recalculateContinuousDays($userId, $today),在正常打卡、补签、取消打卡这三个动作后都会调用。函数从最近一天往前找连续记录,直到遇到断档为止,然后更新用户表。

3. 核心签到接口与前端实现

3.1 接口返回结构

整套源码的API设计不需要花哨,统一返回JSON即可。我习惯用这种结构:

{ "code": 0, "msg": "success", "data": { "today_checkin_status": 1, "continuous_days": 12, "total_checkin_days": 45, "max_continuous_days": 30, "calendar": {} } }

code为0表示成功,非0表示业务异常。前端只需要判断code,不需要解析冗长的错误文本,这样联调省事很多。

3.2 打卡逻辑核心代码

这是整套系统最核心的一段PHP代码,我直接贴一个简洁版:

<?php // checkin.php require_once 'db.php'; $openid = $_POST['openid'] ?? ''; if ($openid === '') { exit(json_encode(['code' => 1, 'msg' => '缺少用户标识'])); } $today = date('Y-m-d'); $yesterday = date('Y-m-d', strtotime('-1 day')); try { $pdo->beginTransaction(); // 查用户 $stmt = $pdo->prepare("SELECT * FROM user WHERE openid = ? FOR UPDATE"); $stmt->execute([$openid]); $user = $stmt->fetch(); if (!$user) { $pdo->rollBack(); exit(json_encode(['code' => 2, 'msg' => '用户不存在'])); } // 判断今天是否已打卡 $checkStmt = $pdo->prepare("SELECT id FROM checkin_log WHERE user_id = ? AND checkin_date = ?"); $checkStmt->execute([$user['id'], $today]); if ($checkStmt->fetch()) { $pdo->rollBack(); exit(json_encode(['code' => 3, 'msg' => '今天已经打过卡了'])); } // 计算新的连续天数 $newContinuousDays = 1; $totalDays = $user['total_checkin_days'] + 1; if ($user['last_checkin_date'] === $yesterday) { // 昨天打过卡,今天继续,连续天数加1 $newContinuousDays = $user['continuous_days'] + 1; } elseif ($user['last_checkin_date'] === $today) { // 理论不会走到这里,因为上面已经判断过今天没打 $newContinuousDays = $user['continuous_days']; } else { // 断签或首次打卡,连续天数从1开始 $newContinuousDays = 1; } // 更新用户表 $updateStmt = $pdo->prepare( "UPDATE user SET continuous_days = ?, total_checkin_days = ?, last_checkin_date = ?, max_continuous_days = IF(max_continuous_days > ?, max_continuous_days, ?), updated_at = NOW() WHERE id = ?" ); $updateStmt->execute([ $newContinuousDays, $totalDays, $today, $newContinuousDays, $newContinuousDays, $user['id'] ]); // 插入打卡记录 $insertStmt = $pdo->prepare( "INSERT INTO checkin_log (user_id, checkin_date, checkin_time, remark, makeup_flag) VALUES (?, ?, NOW(), ?, 0)" ); $insertStmt->execute([$user['id'], $today, $_POST['remark'] ?? '']); $pdo->commit(); echo json_encode([ 'code' => 0, 'msg' => 'success', 'data' => [ 'continuous_days' => $newContinuousDays, 'total_checkin_days' => $totalDays, 'max_continuous_days' => max($newContinuousDays, $user['max_continuous_days']) ] ]); } catch (Exception $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } // 如果是唯一索引冲突,说明并发场景下有人已经打卡了 $code = (strpos($e->getMessage(), 'uniq_user_date') !== false) ? 3 : 500; echo json_encode(['code' => $code, 'msg' => '操作失败']); }

这段代码里我故意加了$pdo->beginTransaction()和SELECT ... FOR UPDATE,很多人会觉得小题大做,一个打卡功能用得着锁行吗?

用得着。因为打卡按钮很容易被用户在弱网环境下连点两次,或者一个页面同时发两个请求。如果没有事务锁,两个请求同时查用户表,都发现“今天没打卡”,然后同时执行更新,最终会出现打卡记录插入成功两次的bug。虽然checkin_log表有唯一索引兜底,但用户表里的total_checkin_days和continuous_days可能被多加一次。有了行锁,第二个请求必须等第一个请求提交后才执行,它再查checkin_date时就能看到今天已经打过卡了,从而正确拦截。

3.3 连续天数补签后的重算逻辑

补签场景下,连续天数不能简单用“用户表里的 last_checkin_date”来判断,因为补签会填补断档。举个例子:

用户第1天打了卡,第2天没打,第3天想补签第2天,然后当天打第3天的卡。补签完成后,这个用户的连续天数应该变成3,而不是1。

如果只按我上一节的正常打卡逻辑处理,补签第2天后,last_checkin_date会变成第2天,但第3天这个日期在时间上已经过了,现象就是“昨天显示没打今天打了”,连续天数还是会被重置。所以补签必须在完成后主动触发一次全量重算:

function recalculateContinuousDays($pdo, $userId, $today) { // 查询该用户所有打卡日期,从今天往回逐天检查连续性 $stmt = $pdo->prepare( "SELECT checkin_date FROM checkin_log WHERE user_id = ? AND checkin_date <= ? ORDER BY checkin_date DESC" ); $stmt->execute([$userId, $today]); $dates = $stmt->fetchAll(PDO::FETCH_COLUMN); $continuousDays = 0; $currentDate = $today; foreach ($dates as $date) { if ($date === $currentDate) { $continuousDays++; $currentDate = date('Y-m-d', strtotime($currentDate . '-1 day')); } else { break; // 中间断档就停止 } } // 同时顺便统计累计打卡天数 $totalStmt = $pdo->prepare("SELECT COUNT(*) FROM checkin_log WHERE user_id = ?"); $totalStmt->execute([$userId]); $totalDays = $totalStmt->fetchColumn(); // 更新用户表 $updateStmt = $pdo->prepare( "UPDATE user SET continuous_days = ?, total_checkin_days = ?, max_continuous_days = IF(max_continuous_days > ?, max_continuous_days, ?) WHERE id = ?" ); $updateStmt->execute([$continuousDays, $totalDays, $continuousDays, $continuousDays, $userId]); return $continuousDays; }

注意这个函数用了ORDER BY checkin_date DESC然后逐天比对。它的时间复杂度是 O(n),n是用户总打卡天数。对一个每天只产生一条记录的表来说,查询量完全可控。但如果一个系统运行了好几年、一个人打卡了上千天,这个函数在每次补签后调用还是会有些微延迟,所以我的优化方案是:只在补签、后台修正数据等低频操作时调用,正常每日打卡不走这个全量逻辑。

3.4 月历统计怎么实现

月历是打卡系统的门面。前端渲染月历时需要的数据结构是:某个月里,哪些天打了卡。这个SQL非常简单:

SELECT checkin_date FROM checkin_log WHERE user_id = ? AND checkin_date BETWEEN '2025-06-01' AND '2025-06-30' ORDER BY checkin_date ASC;

后端拿到一个日期数组,直接传给前端,前端根据数组判断是否点亮某个格子。为了减少请求次数,我通常把当月日历、累计数据、连续数据合成一个接口返回,一次刷进来。这里不建议做成分页加载,月历总共就30个左右格子,一次性返回最省事。

统计报表里还有一个隐藏需求:用户中途放弃了,后来又重新开始,怎么展示?我的做法是日历格子里用不同颜色区分“当前阶段”和“历史阶段”,并以start_date作为参考点。比如用户3月1日到3月10日坚持了10天,然后断了,4月1日重新开始。那么3月那段是灰色历史,4月开始是橙色当前连续。这个展示虽然只是颜色差异,但对用户的心理激励影响非常大。

4. 部署上线与常见问题排查

4.1 本地环境怎么跑起来

如果你拿到源码,第一步是检查目录结构。一个相对完整的版本至少包含:

  • db.php:数据库连接
  • checkin.php:打卡接口
  • calendar.php:月历数据接口
  • user.php:用户登录/注册接口
  • admin/:管理后台
  • frontend/:小程序或H5前端

本地跑的话,建议直接用 phpStudy 或 Laragon 这类集成环境,PHP版本7.4以上。部署步骤如下:

  1. 创建数据库,比如jielma,选择utf8mb4编码。
  2. 导入项目中install.sql文件,创建两张核心表。
  3. 修改db.php里的数据库连接参数。
  4. 打开 Nginx/Apache 的站点配置,把根目录指到源码的public或根目录。
  5. 先访问install.php或者手动测试一个接口确认连库正常。

这里有个我常遇到的小岔路:很多人用的是宝塔面板,PHP默认关闭了pdo_mysql扩展。你装好以后先跑一下:

<?php phpinfo(); ?>

确认里面能看到PDO_MYSQL,没有的话去宝塔软件商店里给当前PHP版本装上扩展,然后重启PHP服务。

前端如果是微信小程序,需要把你后端域名配到微信公众平台的“服务器域名”里,且必须用 HTTPS。本地测试时可以打开开发者工具里的“不校验合法域名”开关,但上线前务必关闭。

4.2 连着测试容易踩的五个坑

我把实际运维中高频出现的问题整理成一张表,顺手给解决方案:

问题现象根本原因解决办法
凌晨12点左右打卡,日期显示成昨天服务器时区未统一在PHP设置date_default_timezone_set('Asia/Shanghai'),MySQL查询前执行SET time_zone='+08:00'
快速双击打卡,连续天数被加两次缺少事务锁和唯一索引打卡接口用SELECT FOR UPDATE,数据库加uniq_user_date唯一索引
跨年时连续天数从31天直接变1断签判断只比对“昨天”按日期字符串连续逐天判断,而不是用时间戳差值直接算
补签成功后连续天数没增加补签后没有重算连续天数补签接口最后调用recalculateContinuousDays
用户换手机登录,数据丢失openid维度设计不对确认用户唯一标识是否绑定同一个开放平台账户,必要时加手机号绑定

第3条我单独展开说。有人会把连续天数直接按时间戳差值除以86400来算,比如“用户上次打卡时间和今天差24小时,连续天数加1”。这个方案在夏令时、跨年、服务器时区调整时都会出问题。日期连续性判断最稳的方式是严格按照日历日来算:昨天的日期 =date('Y-m-d', strtotime('-1 day')),上次打卡日期等于这个字符串,才算连续。跨年不会影响字符串比较,时区也不影响,因为checkin_date本身就是纯日期,没有时分秒。

4.3 后台管理和数据安全建议

源码里如果带管理后台,我建议后台至少包含三个界面:

  1. 用户列表:查看所有用户、注册时间、当前连续天数、累计打卡天数。
  2. 打卡明细:按用户查看每日打卡记录,能区分正常打卡和补签记录,支持按日期筛选。
  3. 统计面板:总用户数、今日打卡人数、近7日活跃趋势、平均连续天数。

这些统计SQL都不复杂,比如今日打卡人数就是:

SELECT COUNT(*) FROM checkin_log WHERE checkin_date = CURDATE();

但要注意一点:打卡记录表会随时间膨胀,建议每个月或每季度做一次归档。比如把半年前的记录移到checkin_log_archive表。归档时程序里要先判断记录是否还在主表,避免统计遗漏。

安全方面,这类源码最大的隐患是SQL注入和未授权访问。所有从外部传入的参数,能绑定的一定用预处理绑定,不能用字符串拼接。用户身份建议用 openid 或 token 传递,token 存到 user 表里,每次请求校验。我见过不少源码直接把 openid 当前端传参,别人知道了就能冒充任意用户,这是必须补上的漏洞。

5. 真实使用体验与可扩展方向

5.1 实际运行时数据会怎么涨

我拿这套系统真人测过大概三个月。每天打卡人数第一天很高,大概能到注册用户数的四成,后面稳定下来大概在两成左右。连续天数这个指标很有意思:用户连续天数超过7天后,流失率会明显下降;但很多人到第20天左右会有一个“危险期”,经常因为一次遗忘断签而卸App。

这也侧面说明补签功能的必要性。我统计过,给用户每月开放3次补签后,月留存提升了大概15%。所以凡是做类似打卡源码的,我都不建议做“完美强制”模式,要给用户留一个台阶。

5.2 后续可以扩展的方向

如果源码只是自用,基础功能已经够了。但如果你想把它做成产品,我可以分享几个验证过有价值的扩展点:

  1. 分享卡片:自动生成一张“我已坚持XX天”的图片,用户分享到朋友圈,这完全是免费获客渠道。
  2. 群打卡PK:拉一个好友组成战队,每天统计战队内打卡率,互相监督比纯个人打卡有效得多。
  3. 阶段成就系统:坚持7天、30天、100天分别解锁不同徽章,徽章在个人主页展示。
  4. 数据导出:让用户导出自己的打卡记录为图片或Excel,这个功能看起来简单,但对信任建立很有价值。

恒心打卡类产品,本质是“给用户正反馈”。多一个正反馈触点,就多一分留存。

5.3 我维护这套源码后的一些个人体会

最后说点代码之外的东西。我从第一个版本一路改到4.0,最大的体会是:这类“小工具型”源码,技术难度真的不大,最难的是处理“用户的坚持预期”。用户不是来学技术的,他只想今天看到自己的连续天数又多了一天。一旦因为某个日期计算bug让他觉得“我今天白打了”,他对整个系统的信任瞬间归零。

所以如果你要把这套源码交给别人使用,建议多花时间写清楚部署文档、加好后台日志、把关键操作(打卡、补签、删除记录)都记录操作日志。出现问题能第一时间查到原因,比什么都强。源码本身改起来很快,用户数据才是真正没法重来的资产。

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

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

立即咨询