先说明一下我是从“戒了么4.0 戒色签到打卡源码”这个项目名切入来写的。很多人乍一看以为是普通打卡小程序实际上这套系统的核心价值在“连续天数计算、防重复提交、补签机制、数据可视化”这几块尤其适合想自己搭一套习惯打卡系统、或者做类似“签到/自律/陪伴成长”类产品的人参考。项目本身不复杂但坑不少我把重点梳理出来直接按这套方案改改就能用。1. 戒了么4.0整体设计与需求拆解1.1 核心需求到底是什么“戒色签到打卡”表面看是一个打卡工具但把它拆开本质上是一个“行为自律记录系统”。这类系统和普通日历打卡的最大区别在于它不只是一个“勾选完成”的按钮而是需要围绕“坚持到今天第几天”这个核心指标去设计整套数据模型和交互逻辑。我先说需求拆解后的四个核心模块每日打卡用户每天只能打一次不能重复打不能提前打第二天。连续天数展示这是所有打卡类应用最核心的“爽点”数据用户打开页面第一眼想看的就是“我已经连续戒了几天”。历史记录与报表按月/按年展示打卡日历让用户看到自己过去的坚持轨迹。补签与异常处理人总有忘记的时候补签逻辑直接决定用户是放弃还是继续用下去。这四点前两个是功能硬指标后两个是留存软指标。绝大多数轻量级打卡源码只做了前两个做到第三、四点的才是真正能长期运行的系统。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版本相比早期版本最值得注意的三个改动是补签开关很多打卡类应用为了数据“纯净”不允许补签结果用户断一次就彻底放弃。4.0的设计是需要合理控制补签次数比如一个月最多补签3次每次补签前一天这样既保住用户的连续记录又不至于让数据失真。跨年连续计算早期版本经常出现12月31日和1月1日之间连续天数断掉的问题4.0专门修正了日期连续性的判断逻辑。防重复提交以前直接按“今天是否已打卡”判断在并发或网络延迟场景下会出bug4.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么打卡记录表不直接存一个“日期时间字段”而要拆成checkin_date和checkin_time两个字段这是我在实际开发里踩出来的经验。因为所有连续天数断签判断、跨月统计、日历展示都需要按“天”为单位聚合。如果只用datetime每次查询都要DATE(checkin_time)做函数转换一旦数据量上来这个查询是没法走索引的性能会非常差。拆成两个字段后checkin_date可以直接走唯一索引既保证了一天只能打一次卡又让按日期统计的SQL变得很简单。2.2 连续天数到底怎么算连续天数的计算是整套源码最容易翻车的地方。我先说结论连续天数不建议每次实时从头扫全表计算而是采用“缓存值 增量更新”的方式。用户表里有一个last_checkin_date字段记录最后一次打卡的日期。打卡时按以下规则更新如果last_checkin_date等于今天说明已经打过卡直接返回当前连续天数不做任何更新。如果last_checkin_date等于昨天的日期说明昨天打了卡、今天也打连续天数加1。如果last_checkin_date压根没有值首次打卡连续天数设为1。如果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以上。部署步骤如下创建数据库比如jielma选择utf8mb4编码。导入项目中install.sql文件创建两张核心表。修改db.php里的数据库连接参数。打开 Nginx/Apache 的站点配置把根目录指到源码的public或根目录。先访问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_zone08: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 后台管理和数据安全建议源码里如果带管理后台我建议后台至少包含三个界面用户列表查看所有用户、注册时间、当前连续天数、累计打卡天数。打卡明细按用户查看每日打卡记录能区分正常打卡和补签记录支持按日期筛选。统计面板总用户数、今日打卡人数、近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 后续可以扩展的方向如果源码只是自用基础功能已经够了。但如果你想把它做成产品我可以分享几个验证过有价值的扩展点分享卡片自动生成一张“我已坚持XX天”的图片用户分享到朋友圈这完全是免费获客渠道。群打卡PK拉一个好友组成战队每天统计战队内打卡率互相监督比纯个人打卡有效得多。阶段成就系统坚持7天、30天、100天分别解锁不同徽章徽章在个人主页展示。数据导出让用户导出自己的打卡记录为图片或Excel这个功能看起来简单但对信任建立很有价值。恒心打卡类产品本质是“给用户正反馈”。多一个正反馈触点就多一分留存。5.3 我维护这套源码后的一些个人体会最后说点代码之外的东西。我从第一个版本一路改到4.0最大的体会是这类“小工具型”源码技术难度真的不大最难的是处理“用户的坚持预期”。用户不是来学技术的他只想今天看到自己的连续天数又多了一天。一旦因为某个日期计算bug让他觉得“我今天白打了”他对整个系统的信任瞬间归零。所以如果你要把这套源码交给别人使用建议多花时间写清楚部署文档、加好后台日志、把关键操作打卡、补签、删除记录都记录操作日志。出现问题能第一时间查到原因比什么都强。源码本身改起来很快用户数据才是真正没法重来的资产。