PHP项目签到与连续天数

wen PHP项目 2

PHP项目签到与连续天数:从零搭建高并发积分系统的完整实战指南

📚 目录导读

  1. 签到功能的核心价值与业务场景
  2. 数据库表设计与连续天数算法解析
  3. PHP实现签到API的完整代码(含缓存优化)
  4. 连续天数统计的高效查询策略
  5. 常见问题与踩坑实录(FAQ)
  6. SEO与用户体验优化的最佳实践

签到功能的核心价值与业务场景

在电商、社区、游戏等Web项目中,每日签到+连续天数奖励机制是提升用户黏性的黄金手段,某知识付费平台测试发现,引入连续签到30天赠送VIP后,用户7日留存率提升42%,作为PHP开发者,你需要掌握的不只是写个INSERT语句,而是如何在高并发下保证数据一致性,同时计算用户“历史最长连续天数”和“当前连续天数”。

PHP项目签到与连续天数

业务场景示例:

  • 用户每天只能签到1次,点击后获得积分/虚拟币
  • 连续签到第3、7、30天有额外奖励
  • 中断后连续天数重置为1(从新签到日起算)

数据库表设计与连续天数算法解析

基础表结构(MySQL)

-- 用户签到记录表
CREATE TABLE `user_sign` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL COMMENT '用户ID',
  `sign_date` date NOT NULL COMMENT '签到日期(索引)',
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `reward_points` int(11) DEFAULT 1 COMMENT '本次获得积分',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_date` (`user_id`,`sign_date`),
  KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 用户签到统计表(预计算连续天数)
CREATE TABLE `user_sign_stats` (
  `user_id` int(11) NOT NULL PRIMARY KEY,
  `continuous_days` int(11) NOT NULL DEFAULT 0 COMMENT '当前连续天数',
  `max_continuous_days` int(11) NOT NULL DEFAULT 0 COMMENT '历史最高连续天数',
  `last_sign_date` date DEFAULT NULL COMMENT '上次签到日期',
  `total_days` int(11) NOT NULL DEFAULT 0 COMMENT '累计签到总天数'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计关键点

  • 使用唯一索引uk_user_date防止重复签到(比程序逻辑判断更可靠)
  • 建立user_sign_stats统计表,避免每次查询都扫描全表计算连续天数
  • last_sign_date用于判断是否中断

连续天数算法逻辑(伪代码)

IF 今天已签到 THEN 返回“已签到”
ELSE IF 昨日已签到(last_sign_date = 昨天) THEN 
    continuous_days += 1
ELSE 
    continuous_days = 1  // 中断了,重置
END IF
更新 last_sign_date = 
IF continuous_days > max_continuous_days THEN 更新max
签到记录写入user_sign表

PHP实现签到API的完整代码(含Redis缓存优化)

场景:日均10万用户签到,需支持高并发

<?php
class SignService {
    private $redis;
    private $db;
    public function sign($userId) {
        $today = date('Y-m-d');
        $redisKey = "sign:{$userId}:{$today}";
        // 第一步:Redis缓存检测是否已签到(避免数据库查询)
        if ($this->redis->exists($redisKey)) {
            return ['code' => -1, 'msg' => '今日已签到'];
        }
        // 第二步:数据库事务 + 行锁
        $this->db->beginTransaction();
        try {
            // 使用SELECT ... FOR UPDATE锁住用户统计行
            $stats = $this->db->query(
                "SELECT * FROM user_sign_stats WHERE user_id = ? FOR UPDATE",
                [$userId]
            );
            if (empty($stats)) {
                // 首次签到初始化
                $continuousDays = 1;
                $this->db->execute(
                    "INSERT INTO user_sign_stats SET user_id=?, continuous_days=1, 
                     max_continuous_days=1, last_sign_date=?, total_days=1",
                    [$userId, $today]
                );
            } else {
                $lastDate = $stats[0]['last_sign_date'];
                $yesterday = date('Y-m-d', strtotime('-1 day'));
                if ($lastDate == $today) {
                    // 理论上已被Redis拦截,但数据库层面再次防御
                    $this->db->rollback();
                    return ['code' => -1, 'msg' => '重复签到'];
                }
                // 核心连续天数计算
                $continuousDays = ($lastDate == $yesterday) 
                    ? $stats[0]['continuous_days'] + 1 
                    : 1;
                $newMax = max($stats[0]['max_continuous_days'], $continuousDays);
                $this->db->execute(
                    "UPDATE user_sign_stats SET continuous_days=?, max_continuous_days=?,
                     last_sign_date=?, total_days=total_days+1 WHERE user_id=?",
                    [$continuousDays, $newMax, $today, $userId]
                );
            }
            // 写入签到记录
            $reward = $this->calculateReward($continuousDays);
            $this->db->execute(
                "INSERT INTO user_sign (user_id, sign_date, reward_points) VALUES (?,?,?)",
                [$userId, $today, $reward]
            );
            $this->db->commit();
            // 第三步:写入Redis缓存(TTL=86400秒)
            $this->redis->setEx($redisKey, 86400, 1);
            // 清理其他缓存...
            return ['code' => 0, 'msg' => '签到成功', 'data' => [
                'continuous_days' => $continuousDays,
                'reward' => $reward
            ]];
        } catch (Exception $e) {
            $this->db->rollback();
            return ['code' => -2, 'msg' => '系统繁忙'];
        }
    }
    private function calculateReward($days) {
        if ($days >= 30) return 100;
        if ($days >= 7) return 30;
        if ($days >= 3) return 10;
        return 1;
    }
}

高并发优化要点

  • 先查Redis再查数据库,避免大量请求穿透到MySQL
  • 事务中使用FOR UPDATE行锁,保证同一用户并发不冲突(注意死锁风险)
  • 积分奖励计算放在数据库事务外,降低事务执行时间

连续天数统计的高效查询策略

获取用户当前连续天数(直接从统计表查)

SELECT continuous_days, max_continuous_days, total_days 
FROM user_sign_stats WHERE user_id = 123;

获取连续签到排行榜(前100名)

SELECT u.nickname, s.continuous_days
FROM user_sign_stats s
JOIN users u ON s.user_id = u.id
ORDER BY s.continuous_days DESC
LIMIT 100;

单个用户历史最长连续天数(数据库直接记录max字段,无需计算)

注意:如果用户量超过100万,请对user_sign_stats表按user_id做分区,或使用Redis Sorted Set维护排行榜。


常见问题与踩坑实录(FAQ)

Q1:用户签到后立即取消订单/封号,怎么处理?

A:签到成功后积分已发放,通常不进行“扣回”操作,若必须回滚,建议在user_sign表增加status字段,封号时标记为-1,并手动调整user_sign_statstotal_days等字段。

Q2:用户跨时区怎么办?

A:统一使用服务器UTC时间,前端由JS转换为用户当地时间,数据库存储sign_date使用date类型(不含时区),后端判断“按服务器时区计算(建议项目统一Asia/Shanghai)。

Q3:Redis挂了会不会导致重复签到?

A:会,但数据库层有唯一索引和FOR UPDATE兜底,最多返回“重复签到”错误,不会产生脏数据,建议Redis作为缓存,核心数据以MySQL为准。

Q4:连续天数统计在跨年/闰年时会不会出错?

A:只要使用date('Y-m-d')strtotime('-1 day'),PHP会自动处理闰年、跨月、跨年场景,无需额外判断。

Q5:如何防止用户通过修改客户端时间签到?

A:所有签到逻辑必须在服务端完成,前端只能发请求,不能提交sign_date参数,服务端以数据库时间为准。


SEO与用户体验优化的最佳实践

前端展示优化

  • 在页面显著位置显示“已连续签到X天”,使用CSS动画+金色图标吸引点击
  • 使用localStorage记录上次签到时间,用户无需刷新即可看到今日状态
  • 对未签到用户显示“签到得积分”按钮,已签到显示灰色“已打卡”

后端SEO友好

  • 签到API使用POST方法,配合CSRF Token防止盗链
  • 页面URL结构:/user/sign 使用语义化短链
  • 签到成功后的跳转页面包含rel="canonical"标签,避免百度收录重复页面

用户体验细节

  • 每次签到成功后弹窗显示获得的积分和连续天数,附带进度条
  • 在“我的积分”页面用图表展示连续天数趋势(使用Chart.js)
  • 增加补签卡功能(允许用户用积分补签昨日),进一步提升留存率

延伸阅读:若你的项目需要支持千万级用户签到,请参考以下架构调整:

  • 使用RabbitMQ异步写入签到记录
  • 用ClickHouse替代MySQL的user_sign明细表
  • 连续天数改用ElasticSearch或Redis Bitmap存储

希望这篇指南能帮你构建一个稳定、高性能的PHP签到系统,如果你在实现过程中遇到问题,欢迎在评论区交流。

抱歉,评论功能暂时关闭!