签到功能几乎是小程序的标配模块,看起来简单,真正落地时却涉及连续签到判定、积分奖励、防重复签到、时区处理等一堆细节。本文基于 ThinkPHP 6 分享一套可以直接复用的签到功能实现方案,包含数据表设计、后端接口与核心业务逻辑。
一、数据表设计
签到功能至少需要两张表:一张记录用户的签到流水,一张记录用户的积分与连续签到状态。分开设计的好处是流水表可以按时间归档,而状态表只保留一行,查询效率高。
-- 签到流水表
CREATE TABLE `sign_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`user_id` int unsigned NOT NULL,
`sign_date` date NOT NULL COMMENT '签到日期',
`days` int unsigned NOT NULL DEFAULT 1 COMMENT '连续天数',
`points` int unsigned NOT NULL DEFAULT 0 COMMENT '本次获得积分',
`create_time` int unsigned NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_date` (`user_id`,`sign_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 用户签到状态表
CREATE TABLE `user_sign` (
`user_id` int unsigned NOT NULL,
`points` int unsigned NOT NULL DEFAULT 0 COMMENT '累计积分',
`continuous_days` int unsigned NOT NULL DEFAULT 0 COMMENT '当前连续天数',
`last_sign_date` date DEFAULT NULL,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意 uk_user_date 这个唯一索引,它是防止并发重复签到的最后一道防线,比在代码里查一次再插入可靠得多。
二、连续签到与积分规则
常见的奖励规则是:基础 5 积分,连续第 7 天额外奖励 20 积分,断签后连续天数归零。规则建议抽成独立方法,方便后期改成配置化。
protected function calcPoints(int $days): int
{
$points = 5;
if ($days % 7 === 0) {
$points += 20;
}
return $points;
}三、核心签到逻辑
签到接口的关键在于:判断今天是否已签到、判断昨天是否签到以决定连续天数、写流水与更新状态必须放在同一个事务里。
public function sign()
{
$userId = $this->request->uid;
$today = date('Y-m-d');
$yesterday = date('Y-m-d', strtotime('-1 day'));
$exist = Db::name('sign_log')
->where('user_id', $userId)
->where('sign_date', $today)
->find();
if ($exist) {
return json(['code' => 1001, 'msg' => '今天已经签过啦']);
}
$state = Db::name('user_sign')->where('user_id', $userId)->find();
if (!$state) {
Db::name('user_sign')->insert([
'user_id' => $userId,
'points' => 0,
'continuous_days' => 0,
]);
$state = ['continuous_days' => 0, 'last_sign_date' => null];
}
$days = ($state['last_sign_date'] === $yesterday)
? $state['continuous_days'] + 1
: 1;
$points = $this->calcPoints($days);
Db::startTrans();
try {
Db::name('sign_log')->insert([
'user_id' => $userId,
'sign_date' => $today,
'days' => $days,
'points' => $points,
'create_time'=> time(),
]);
Db::name('user_sign')->where('user_id', $userId)->update([
'points' => Db::raw('points + ' . $points),
'continuous_days' => $days,
'last_sign_date' => $today,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return json(['code' => 1002, 'msg' => '签到失败,请重试']);
}
return json([
'code' => 0,
'msg' => '签到成功',
'data' => ['days' => $days, 'points' => $points],
]);
}这里用 Db::raw('points + n') 做原子自增,避免读改写导致的积分丢失。即使并发请求同时进来,唯一索引也会让第二条插入失败并触发回滚。
四、日历与状态查询接口
小程序端通常需要一个日历视图,展示本月哪些天已签到。可以一次返回本月签到日期数组和当前状态。
public function info()
{
$userId = $this->request->uid;
$month = $this->request->param('month', date('Y-m'));
$start = $month . '-01';
$end = date('Y-m-t', strtotime($start));
$days = Db::name('sign_log')
->where('user_id', $userId)
->whereBetween('sign_date', [$start, $end])
->column('sign_date');
$state = Db::name('user_sign')->where('user_id', $userId)->find();
return json([
'code' => 0,
'data' => [
'signed_days' => array_map(fn($d) => (int)date('j', strtotime($d)), $days),
'points' => $state['points'] ?? 0,
'continuous' => $state['continuous_days'] ?? 0,
'today_signed'=> in_array(date('Y-m-d'), $days),
],
]);
}五、几个容易踩的坑
- 时区问题:ThinkPHP 默认时区可能不是
Asia/Shanghai,务必在config/app.php中设置default_timezone,否则凌晨签到会算错日期。 - 补签需求:如果产品要求补签,不要把补签写进
sign_log的普通流程,建议单独加is_makeup字段并限制次数。 - 防刷:签到接口建议加上用户级限流,ThinkPHP 的
think-throttle中间件即可满足。 - 积分流水:真实项目里积分变动最好单独记一张流水表,方便对账和排查问题。
整体思路就是:唯一索引兜底、事务保证一致性、原子自增防并发。这套代码在日活几千的小程序上跑过一年,没有出现过重复签到或积分错乱的问题,可以直接拿去改造使用。
评论 (0)
还没有评论,快来抢沙发吧~