
1. 签到功能背后的产品逻辑为什么“连续”比“累计”更值得做很多人做小程序积分体系时第一反应是“签到送积分”然后随手写一个signIn()接口每天调用一次加5分就完事。但真正跑过一段时间数据就会发现累计签到和连续签到对用户留存的拉动效果完全不在一个量级。累计签到本质上是“薅羊毛”逻辑用户想起来就点一下想不起来也无所谓反正积分不会跑而连续签到引入了一个“断签焦虑”用户会为了保住连续天数而主动打开小程序哪怕当天没什么别的事可做。这个心理机制在游戏化设计里叫“损失厌恶”——失去100块的痛苦远大于得到100块的快乐。连续签到就是把用户已经积累的天数变成一种“虚拟资产”一旦断掉资产清零这种心理成本会驱动用户形成打开习惯。所以如果你只是做一个“每日签到5分”的功能那这篇文章可能不太适合你但如果你想做的是用签到作为留存钩子把用户每天拉回小程序一次那接下来的内容会帮你把这件事做扎实。从技术实现角度看连续签到比累计签到多出来的复杂度主要在三个地方断签判定逻辑、连续天数与奖励的阶梯映射、补签机制的设计。这三个点每一个都有坑而且坑往往不在代码本身而在产品规则和边界条件的定义上。我见过不少项目上线后才发现“跨天时间点算错了”“用户改时区刷分”“补签把连续天数算乱了”这类问题返工成本很高。这篇文章会从小程序的实际开发场景出发把连续签到积分系统的完整实现路径拆开讲。适合正在做小程序会员体系、积分体系、任务体系的开发者也适合产品经理了解技术侧的实现边界。我会用微信小程序原生开发的方式演示但核心逻辑在任何小程序平台或H5上都通用。读完之后你应该能直接搭出一套稳定、防刷、可扩展的签到系统而不是一个“能跑但不敢上线”的Demo。2. 数据表设计签到记录到底该怎么存2.1 为什么不能只存一个“最后签到时间”新手最容易犯的错误是在用户表上加两个字段last_sign_date和continuous_days签到的时候判断一下日期差是昨天就加一不是昨天就重置为一。这个方案在功能演示阶段没问题但它有三个致命缺陷。第一无法追溯历史。用户投诉“我明明连续签了7天怎么断了”你查不到任何记录只能看当前状态没法排查是系统问题还是用户记错了。第二无法支持补签。补签需要往历史日期里插一条记录如果只有最后签到时间补签逻辑会变得极其别扭。第三无法做数据统计。运营想知道“连续签到7天以上的用户占比”“平均连续天数是多少”你根本算不出来。所以正确的做法是用一张独立的签到记录表每次签到插一条记录用户表上只保留冗余的continuous_days和last_sign_date用于快速展示真正的数据源是记录表。2.2 签到记录表的具体字段设计下面是我在实际项目中反复打磨过的一个表结构以MySQL为例CREATE TABLE user_sign_record ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, user_id bigint(20) unsigned NOT NULL COMMENT 用户ID, sign_date date NOT NULL COMMENT 签到日期格式YYYY-MM-DD, continuous_days int(11) NOT NULL DEFAULT 1 COMMENT 签到时的连续天数快照, reward_points int(11) NOT NULL DEFAULT 0 COMMENT 本次获得积分, is_makeup tinyint(1) NOT NULL DEFAULT 0 COMMENT 是否补签0否1是, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_date (user_id, sign_date), KEY idx_user_continuous (user_id, continuous_days) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户签到记录表;这里有几个设计点值得展开说。sign_date用 date 类型而不是 datetime。签到是按“天”维度的行为用date类型天然避免了时分秒带来的比较麻烦。判断今天是否签到过直接WHERE sign_date CURDATE()就行不用做时间范围查询。这个选择在后续所有逻辑里都会让你省心。唯一索引uk_user_date是防重复签到的第一道防线。即使应用层因为并发或者逻辑漏洞导致重复请求数据库层面也会直接拒绝。我在压测时模拟过同一用户同一秒发100个签到请求靠这个唯一索引兜底最终只有一条成功其余全部报唯一键冲突应用层捕获后返回“今日已签到”即可。这比先查再插的方案可靠得多因为“查”和“插”之间有时间窗口并发下必然出问题。continuous_days存的是签到那一刻的快照。这个字段很关键它记录的是“这次签到时用户已经连续签了多少天”而不是用户当前的连续天数。为什么要存快照因为补签会改变历史如果只存当前值补签之后你没法还原当时的情况。存快照之后每一行记录都是自解释的做数据分析和问题排查时非常方便。is_makeup标记补签。补签和正常签到在业务上要区分开比如补签可能不送积分、可能消耗补签卡、可能不计入某些活动统计。单独一个字段比用其他方式推断要清晰得多。2.3 用户表上的冗余字段用户表上建议加这两个字段ALTER TABLE user ADD COLUMN continuous_days int(11) NOT NULL DEFAULT 0 COMMENT 当前连续签到天数; ALTER TABLE user ADD COLUMN last_sign_date date DEFAULT NULL COMMENT 最后签到日期;这两个字段是冗余的展示字段不是数据源。每次签到成功后同步更新用于首页快速展示“已连续签到X天”避免每次都去记录表里聚合查询。如果出现数据不一致以记录表为准写一个修复脚本重算即可。注意冗余字段一定要有“以谁为准”的明确约定否则时间长了必然出现两边对不上的情况。我的习惯是记录表为唯一真相源用户表字段只读不写除了签到流程内部更新任何对账都以记录表重算结果覆盖用户表。3. 连续天数的计算跨天、断签与补签的边界处理3.1 判断“昨天”到底怎么算连续签到的核心判断是用户上次签到是不是昨天。如果是连续天数1如果不是不管是前天还是更早连续天数重置为1。听起来简单但“昨天”的定义有几个坑。第一个坑是时区。服务器可能跑在UTC时区而用户在东八区。如果直接用服务器时间算日期每天凌晨0点到8点之间签到的用户会被算成前一天导致连续天数错乱。解决方案是统一用业务时区在应用层把时间转成东八区再取日期或者数据库连接时设置时区。我一般会在应用层封装一个getBusinessDate()函数所有涉及“今天”“昨天”的地方都调它绝不直接用new Date()。第二个坑是跨天临界点的并发。用户在23:59:59点签到请求到服务器时已经00:00:01这时候算哪天我的处理原则是以服务器收到请求的时间为准不做客户端时间补偿。因为客户端时间可以随意修改如果信任客户端时间用户把手机时间调到明天就能提前签到这是典型的刷分漏洞。第三个坑是夏令时。国内没有夏令时但如果你的小程序有海外用户某些地区有夏令时切换那一天可能只有23小时或25小时。用date类型存日期天然规避了这个问题因为date不关心小时数。这也是我前面强调用date而不是datetime的原因之一。3.2 断签判定的完整逻辑下面是一段核心判定逻辑的伪代码用PHP风格写其他语言照搬思路即可$today getBusinessDate(); // 业务时区的今天格式Y-m-d $lastSignDate $user[last_sign_date]; // 上次签到日期 if ($lastSignDate $today) { // 今天已经签过了 return error(今日已签到); } $yesterday date(Y-m-d, strtotime($today . -1 day)); if ($lastSignDate $yesterday) { // 昨天签过连续1 $newContinuous $user[continuous_days] 1; } else { // 断签或首次签到重置为1 $newContinuous 1; }这段逻辑看起来简单但有一个隐藏问题如果用户上次签到是“未来”的日期怎么办比如用户通过某种方式补签bug、数据异常导致last_sign_date比今天还大。这时候上面的逻辑会走到else分支重置为1虽然不会出错但会掩盖数据异常。更稳妥的做法是加一个判断如果lastSignDate today记录一条异常日志并人工介入因为这通常意味着有bug或者数据被篡改。3.3 补签如何不破坏连续天数补签是最容易把连续天数算乱的场景。假设用户连续签到到第5天第6天忘了第7天想起来补签第6天然后签第7天。正确的连续天数应该是7天而不是从第7天重新算。补签的实现思路是补签插入一条历史记录然后从补签日期开始向后重算连续天数。具体做法是补签成功后查询该用户从补签日期起的所有签到记录按日期排序重新计算连续段更新用户表的continuous_days。这里有个性能考量如果用户连续签到几百天每次补签都全量重算会不会慢实际上不会因为查询条件是sign_date 补签日期而且有(user_id, sign_date)索引数据量再大也就几百行毫秒级。真正需要担心的是补签的业务规则比如补签卡从哪来签到奖励送、积分兑换、还是活动发放一个月能补签几次无限补签会让连续签到的稀缺性消失。补签的日期范围限制一般只允许补最近7天或30天太久远的补签没有意义。补签是否送积分我的建议是补签不送积分只恢复连续天数否则用户会为了积分疯狂补签。这些规则没有标准答案取决于你的产品定位。但技术上要保证的是补签操作必须在一个事务里完成插入记录、重算连续天数、扣补签卡、写流水要么全成功要么全回滚不能出现扣了卡但没补上的情况。4. 积分奖励的阶梯设计让连续签到有“奔头”4.1 固定积分为什么留不住人如果每天签到都是5分用户签了一周之后就会觉得“没意思”因为奖励是可预测的没有惊喜感。更关键的是固定积分无法体现“连续”的价值——用户断签一天损失5分和连续30天的用户每天拿5分两者在激励上没有任何区别连续的意义就被稀释了。阶梯奖励的核心思路是连续天数越多单日奖励越高而且在高天数节点设置“大奖”给用户一个明确的目标。比如连续7天送一张补签卡连续30天送一张优惠券连续100天送一个限定徽章。这些非积分奖励往往比积分本身更有吸引力因为它们有稀缺性和展示价值。4.2 阶梯规则的具体配置方式我一般会把奖励规则做成可配置的而不是硬编码在代码里。因为运营随时可能调整奖励力度硬编码意味着每次调整都要发版小程序发版还要审核周期太长。配置可以放在数据库表里也可以放在配置中心甚至简单点用一个JSON文件。下面是一个阶梯配置的示例结构{ rules: [ { minDays: 1, maxDays: 1, points: 5, extra: null }, { minDays: 2, maxDays: 2, points: 6, extra: null }, { minDays: 3, maxDays: 3, points: 8, extra: null }, { minDays: 4, maxDays: 6, points: 10, extra: null }, { minDays: 7, maxDays: 7, points: 20, extra: makeup_card }, { minDays: 8, maxDays: 29, points: 10, extra: null }, { minDays: 30, maxDays: 30, points: 100, extra: coupon_5 }, { minDays: 31, maxDays: 99, points: 10, extra: null }, { minDays: 100, maxDays: 9999, points: 200, extra: badge_100 } ] }这个配置的逻辑是基础天数给基础分关键节点给爆发奖励。第7天、第30天、第100天是三个心理锚点分别对应“一周”“一月”“百日”的成就感。第7天之后到第29天回落到10分是因为用户已经养成了习惯不需要持续高激励而第30天的大奖是给用户一个“再坚持23天”的理由。计算奖励的代码很简单遍历规则找到匹配的区间即可function calcReward(continuousDays, rules) { for (const rule of rules) { if (continuousDays rule.minDays continuousDays rule.maxDays) { return { points: rule.points, extra: rule.extra }; } } return { points: 0, extra: null }; }4.3 奖励发放的幂等性保障积分发放最怕的是重复发放。用户点一次签到因为网络重试或者前端重复提交后端处理了两次积分加了两次这就是事故。保障幂等性有两个层面。第一层是数据库唯一索引。前面设计的uk_user_date保证了同一用户同一天只能有一条签到记录重复插入会失败签到流程直接中断不会走到发积分那一步。这是最可靠的兜底。第二层是积分流水表。每次发积分都要写一条流水流水表上用一个业务唯一键比如sign_用户ID_日期做唯一约束。即使签到记录因为某种原因重复了积分流水也会因为唯一键冲突而拒绝重复发放。积分流水表示例CREATE TABLE points_flow ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, user_id bigint(20) unsigned NOT NULL, change_points int(11) NOT NULL COMMENT 变动积分正为增负为减, balance int(11) NOT NULL COMMENT 变动后余额, biz_type varchar(32) NOT NULL COMMENT 业务类型sign/makeup/exchange等, biz_id varchar(64) NOT NULL COMMENT 业务唯一ID用于幂等, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_biz (biz_type, biz_id), KEY idx_user_time (user_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分流水表;biz_id对于签到场景可以拼成sign_{userId}_{date}这样即使用户同一天因为异常流程触发了两次发放第二次也会被唯一键拦下来。这个设计我在多个项目里用过实测能挡住99%的重复发放问题剩下1%是业务逻辑本身写错了那就得靠代码review和测试覆盖。提示积分余额字段balance建议存在用户表上每次变动时用UPDATE user SET points points ? WHERE id ?的原子操作不要先查再算再写那样并发下会丢更新。流水表里的balance是记录变动后的快照用于对账不是数据源。5. 防刷与异常处理那些上线后才会暴露的问题5.1 客户端时间篡改的防御前面提过签到日期必须以服务器时间为准。但有些开发者会在前端做“今天是否已签到”的判断然后根据前端判断决定是否调接口。这个做法本身没问题能减少无效请求但后端必须独立再做一次判断绝不能信任前端传来的“今天”或“已签到”状态。我见过一个案例前端把signDate作为参数传给后端后端直接拿这个日期去插入记录。结果用户把手机时间改成明天就能提前签明天的到连续天数直接刷上去。修复方案很简单后端完全忽略前端传的日期自己用服务器时间算。前端传的日期只用于展示对比不参与任何业务逻辑。5.2 并发请求下的重复签到即使用户不恶意刷正常使用中也可能出现并发。比如用户手快点了两下签到按钮或者网络卡顿时前端重试了。如果后端没有防并发措施两个请求同时查到“今天未签到”然后同时插入就会产生两条记录如果没有唯一索引或者一条成功一条报错有唯一索引。有唯一索引的情况下报错的那个请求需要被优雅处理。我的做法是在应用层捕获唯一键冲突异常然后返回“今日已签到”而不是“系统错误”。这样用户看到的是正常提示不会觉得系统坏了。代码大概长这样try { await db.insert(user_sign_record, { userId, signDate, ... }); } catch (err) { if (err.code ER_DUP_ENTRY) { return { code: 0, msg: 今日已签到, data: { alreadySigned: true } }; } throw err; }5.3 补签卡的获取与消耗要分离补签卡作为一种虚拟资产它的获取和消耗必须是两条独立的流水。获取的时候写一条makeup_card类型的流水消耗的时候写一条负数流水用户表上的补签卡数量做原子增减。这样任何一张补签卡的来龙去脉都能查到用户投诉“我的补签卡怎么没了”时你能拿出完整记录。补签卡的数量也要做上限控制。比如最多持有10张超过就不能再获取。这个限制在发放环节做消耗环节不用管。如果不做上限用户可能囤积几百张补签卡然后一次性补签一整年连续天数直接爆表奖励体系就崩了。5.4 连续天数异常增长的监控上线之后一定要加监控。我一般会监控两个指标单用户单日连续天数增长超过1的和连续天数超过某个阈值比如365的。前者说明可能有bug导致一天加了多次后者说明可能有刷分行为。监控到之后不是自动处理而是告警让人工介入因为正常用户也可能因为补签导致连续天数跳变需要结合补签记录一起看。监控的实现可以是一个定时任务每天凌晨跑一次扫描前一天有签到记录的用户对比签到前后的连续天数。如果发现异常写入告警表运营后台展示。这个任务不复杂但能帮你提前发现很多潜在问题。6. 小程序端的交互细节让签到“顺手”而不是“负担”6.1 签到入口的位置选择签到入口放哪里直接决定了签到率。我测试过几种方案放在首页顶部banner、放在个人中心、放在首页悬浮按钮、放在任务中心列表。数据最好的是首页顶部banner因为用户一打开小程序就能看到不需要额外跳转。其次是个人中心因为用户查看积分时会顺手点。但banner有个问题占地方而且用户看久了会视觉疲劳。我的折中方案是首页顶部用一个可折叠的签到条未签到时展开显示“今日签到可得X积分”签到后折叠成“已连续签到X天”。这样既不占太多空间又能持续提醒。6.2 签到动画与反馈签到是一个低频但需要仪式感的操作。用户点下签到按钮后如果只是弹一个“签到成功”的toast体验很平淡。加一个简单的动画——比如积分数字从按钮飞到积分余额的位置或者连续天数的数字滚动增加——会让用户感觉“我确实得到了东西”。动画不需要很复杂小程序原生的wx.createAnimation或者CSS动画就够用。关键是反馈要及时用户点击后100毫秒内必须有视觉响应哪怕只是一个按钮按下的效果。如果等接口返回再给反馈网络慢的时候用户会以为没点上然后重复点击反而增加并发压力。6.3 断签提醒的推送策略连续签到最怕用户忘记。小程序有订阅消息的能力可以在用户即将断签时推送提醒。但推送不能滥用否则用户会反感甚至关闭订阅。我的策略是只在连续天数达到关键节点时提醒。比如用户已经连续签了6天第7天有大奖那第7天晚上8点如果还没签到就推一条“你已连续签到6天今天签到可得双倍积分别断了哦”。如果用户只是连续2天断了也无所谓就不推。这样推送的打开率和转化率都高也不会打扰用户。订阅消息的模板要提前在小程序后台申请字段设计要简洁突出“连续”和“奖励”两个关键词。推送时机建议选在用户活跃时段一般是晚上7点到9点具体看你的用户画像。7. 从签到延伸出去的积分消耗场景签到只是积分体系的入口如果积分只能攒不能花用户很快就会失去动力。所以做签到的时候就要想好积分怎么消耗。常见的消耗场景有兑换优惠券、兑换实物、参与抽奖、抵扣现金、解锁内容。每个场景的兑换比例和库存管理又是另一套逻辑但核心原则是一样的积分要有价值感兑换要有稀缺性。我一般建议签到积分占用户总积分获取的30%左右剩下的70%来自其他任务比如分享、浏览、下单。这样签到是“保底收入”其他任务是“额外收入”用户既有每天打开的理由又有做更多事的动力。如果签到积分占比太高用户会觉得“光签到就够了”反而不会去探索其他功能。兑换场景的库存要实时扣减而且要有防超卖机制。最简单的做法是用数据库的UPDATE stock stock - 1 WHERE stock 0根据影响行数判断是否扣减成功。这个和签到防重复是类似的思路都是靠数据库的原子操作兜底。8. 一些踩过坑之后才明白的经验第一不要用datetime存签到日期。我早期项目用过datetime结果跨天判断要写BETWEEN 今天0点 AND 今天23:59:59不仅麻烦而且索引效率低。改成date之后所有逻辑都清爽了。第二连续天数的重置不要用定时任务。有些方案是每天凌晨跑一个任务把所有用户扫一遍昨天没签到的重置连续天数。这个方案在用户量大的时候会非常慢而且有延迟——用户凌晨1点签到任务还没跑完连续天数可能被错误重置。正确做法是惰性重置在用户签到时判断不签到就不管。用户表上的continuous_days可能不是实时准确的比如用户断签了但还没签到字段还是旧值但展示的时候可以结合last_sign_date判断如果last_sign_date不是昨天也不是今天就显示为0。第三补签的日期不能是未来。这个要在接口层校验sign_date today直接拒绝。虽然正常流程不会传未来日期但接口暴露在外面总有人会尝试。第四积分流水的balance字段一定要对。我见过因为并发导致balance快照和实际余额不一致的情况对账时非常痛苦。解决方案是每次写流水时用SELECT ... FOR UPDATE锁住用户行算出新余额再写。虽然有一点性能损耗但积分这种低频操作完全承受得起。第五测试的时候一定要模拟跨天。不要只在白天测试要把服务器时间调到23:59然后等一分钟看跨天逻辑对不对。这个测试能发现80%的日期相关bug。