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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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。