PHP如何实现积分系统并支持签到任务与等级成长机制

PHP积分系统深度解析:从签到到登基成皇的完整实战指南

各位同学,大家下午好!我是你们的老朋友,一个在代码堆里摸爬滚打多年的“资深”工程师。今天咱们不聊虚的,咱们来聊聊那个让无数运营同事爱不释手,让程序员头秃不已的玩意儿——积分系统

你以为积分系统就是给用户发张优惠券?天真!这简直是一个微型的经济社会!在这个系统里,你是中央银行行长,用户是股民。你发点,他们就涨;你扣点,他们就哭。这不仅仅是技术问题,这是心理学与数学的博弈。

咱们今天的目标很明确:用PHP搭建一个既能扛高并发,又能让用户乐此不疲签到的“大厂级”积分系统,顺便还要搞个“等级成长机制”,让用户感觉自己正在走上人生巅峰。

准备好了吗?系好安全带,咱们开干。


第一部分:数据库设计——别让账本漏了一页

我们要做任何事,地基得打得牢。积分系统的核心就是数据的一致性。如果你用户的积分从1000变到-1000,那场面可就不好收场了。

咱们得设计三张核心表:users(用户主档)、points_log(积分流水账)和 checkin_logs(签到记录)。

1. 用户表:核心资产库

这个表不仅仅是存个名字那么简单,它是你的“财富账户”。

CREATE TABLE `users` (
  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID,主键',
  `username` VARCHAR(50) NOT NULL COMMENT '用户名',
  `score` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前持有积分',
  `level` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '当前等级',
  `exp` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前经验值',
  `current_streak` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续签到天数',
  `last_checkin_date` DATE DEFAULT NULL COMMENT '最后一次签到日期',
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户积分主表';

注意:这里我特意用了 UNSIGNED 类型。大家记住,积分永远不应该有负数,除非是给用户“罚款”。

2. 积分流水表:审计与追溯

当用户积分变动时,别直接在 users 表里 UPDATE。你要把变动记录下来,不然以后用户来查账,你说“你是幻觉”?这账本得记,还得记清清楚楚。

CREATE TABLE `points_log` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id` INT UNSIGNED NOT NULL COMMENT '关联用户ID',
  `amount` INT SIGNED NOT NULL COMMENT '变动数量(正数为增加,负数为扣除)',
  `balance_after` INT UNSIGNED NOT NULL COMMENT '变动后余额',
  `type` VARCHAR(50) NOT NULL COMMENT '变动类型:LOGIN, COMMENT, SIGNIN等',
  `remark` VARCHAR(255) DEFAULT NULL COMMENT '备注:比如“登录奖励”',
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分变动流水';

3. 签到日志表:防止作弊的护城河

签到有时候需要记录用户每天签没签,为了防止用户通过改时间来刷积分,我们必须记录下“打卡时间”。

CREATE TABLE `checkin_logs` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id` INT UNSIGNED NOT NULL,
  `date` DATE NOT NULL COMMENT '签到日期',
  `points` INT UNSIGNED NOT NULL COMMENT '当日获得积分',
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_user_date` (`user_id`, `date`),
  KEY `idx_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户签到记录';

第二部分:核心引擎——PointService 类

好了,表建好了,现在咱们要写代码了。我建议大家使用 Service 层模式。不要在 Controller 里直接写逻辑,那样代码会像面条一样乱。咱们来写一个 PointService.php

1. 基础的加分逻辑

这是最基础的操作。我这里用 PDO 来演示,大家根据自己项目习惯换。

<?php

namespace AppService;

use PDO;

class PointService
{
    private $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    /**
     * 增加用户积分
     * @param int $userId
     * @param int $points
     * @param string $type
     * @param string $remark
     * @return array ['success' => bool, 'new_balance' => int]
     */
    public function addPoints(int $userId, int $points, string $type, string $remark = ''): array
    {
        // 1. 开启事务,保证原子性!别让人在并发下刷分
        $this->pdo->beginTransaction();

        try {
            // 2. 更新用户余额
            $sql = "UPDATE users SET score = score + :points WHERE id = :id";
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute(['points' => $points, 'id' => $userId]);

            // 3. 记录流水
            // 获取变动后的余额(虽然数据库会更新,但为了保险起见,我们查一下或者直接算)
            // 这里为了简单,假设事务成功,余额就是 score + points
            $balanceAfter = $points > 0 ? "score + {$points}" : "score";
            $logSql = "INSERT INTO points_log (user_id, amount, balance_after, type, remark) 
                       VALUES (:uid, :amt, {$balanceAfter}, :type, :remark)";

            $stmt = $this->pdo->prepare($logSql);
            $stmt->execute([
                'uid' => $userId,
                'amt' => $points,
                'type' => $type,
                'remark' => $remark
            ]);

            // 4. 提交事务
            $this->pdo->commit();

            return ['success' => true, 'new_balance' => (int)($this->getUserScore($userId) + $points)];
        } catch (Exception $e) {
            // 5. 出错回滚,哭都没用
            $this->pdo->rollBack();
            return ['success' => false, 'message' => $e->getMessage()];
        }
    }

    private function getUserScore(int $userId): int
    {
        $stmt = $this->pdo->prepare("SELECT score FROM users WHERE id = ?");
        $stmt->execute([$userId]);
        return $stmt->fetchColumn() ?? 0;
    }
}

温馨提示:上面的 balance_after 用了拼接字符串,虽然方便但有点不安全。生产环境最好先查一下 current_balance,然后算出 new_balance 再写进去。


第三部分:签到机制——连续签到才是王道

签到系统最头疼的是什么?是连续签到。如果用户第1天签了,第3天签,第2天没签,能不能算3天连续?
通常来说,不能。第3天签到,算作“复活”,连续签到天数重置为1。这才是留住用户的逻辑——如果你断了,你得重新爬回来,这种挫败感和成就感并存的感觉,才叫好玩。

我们来实现这个逻辑。

/**
 * 用户签到
 * @param int $userId
 * @return array
 */
public function checkIn(int $userId): array
{
    $this->pdo->beginTransaction();

    try {
        // 1. 获取今天的日期和昨天的日期
        $today = date('Y-m-d');
        $yesterday = date('Y-m-d', strtotime('-1 day'));

        // 2. 检查今天是否已经签到了(防刷)
        $checkStmt = $this->pdo->prepare("SELECT * FROM checkin_logs WHERE user_id = ? AND date = ?");
        $checkStmt->execute([$userId, $today]);
        if ($checkStmt->fetch()) {
            return ['success' => false, 'message' => '今天已经签过了,别贪心'];
        }

        // 3. 查询上一次签到日期
        $lastCheckInStmt = $this->pdo->prepare("SELECT date FROM checkin_logs WHERE user_id = ? ORDER BY date DESC LIMIT 1");
        $lastCheckInStmt->execute([$userId]);
        $lastRecord = $lastCheckInStmt->fetch();

        // 4. 计算积分奖励和连续天数
        $points = 1; // 基础分
        $streak = 1; 

        if ($lastRecord && $lastRecord['date'] === $yesterday) {
            // 昨天签了,连上
            $streak = $this->getUserCurrentStreak($userId) + 1; 
            $points = $this->calculateStreakBonus($streak); // 签到倍率计算
        } else {
            // 昨天没签,或者没签过,重新开始
            $streak = 1;
            $points = 1;
        }

        // 5. 更新用户表
        // 这里需要获取当前连续签到数(为了精准计算下一天的倍率)
        $currentStreak = $this->getUserCurrentStreak($userId);
        $newStreak = $streak; // 简化处理,实际可能需要重新计算

        $updateSql = "UPDATE users SET current_streak = :streak, last_checkin_date = :date WHERE id = :id";
        $this->pdo->prepare($updateSql)->execute([
            'streak' => $newStreak, 
            'date' => $today, 
            'id' => $userId
        ]);

        // 6. 写入签到记录
        $logSql = "INSERT INTO checkin_logs (user_id, date, points) VALUES (:uid, :date, :points)";
        $this->pdo->prepare($logSql)->execute(['uid' => $userId, 'date' => $today, 'points' => $points]);

        // 7. 增加积分
        $this->addPoints($userId, $points, 'SIGNIN', "连续签到第{$newStreak}天");

        $this->pdo->commit();
        return ['success' => true, 'message' => "签到成功!获得{$points}积分,连续签到{$newStreak}天"];

    } catch (Exception $e) {
        $this->pdo->rollBack();
        return ['success' => false, 'message' => '签到失败,请稍后重试'];
    }
}

// 辅助:计算签到倍率(比如每5天翻倍)
private function calculateStreakBonus(int $streak): int
{
    return 1 + floor($streak / 5); // 每5天多1分
}

那个可怕的“断签”逻辑

看懂了吗?这里的逻辑是:如果最后签到日期等于昨天,连续+1。如果最后签到日期不是昨天,直接重置为1。这就是为什么用户不敢断签的原因。


第四部分:等级成长——经验值与阶梯

积分是钱,经验值是等级。用户为什么要升级?为了炫耀,为了解锁特权。
等级的算法怎么设计?千万别设计成线性的,否则用户3天就能升到100级。我们要设计成指数级或者平方根曲线。

假设升级经验公式:exp_needed = level * 1000

  • 1级到2级:1000经验。
  • 2级到3级:2000经验。
  • 3级到4级:3000经验。
  • 99级到100级:99000经验。
  • 100级到101级:100000经验。

你看,越往后升级越难,这才有挑战性。咱们写个升级检查的方法。

/**
 * 检查并升级用户
 * @param int $userId
 * @return bool 是否升级了
 */
public function checkLevelUp(int $userId): bool
{
    $user = $this->getUser($userId);
    $currentLevel = $user['level'];
    $currentExp = $user['exp'];

    // 下一级所需经验
    $nextLevelExp = $currentLevel * 1000;

    if ($currentExp >= $nextLevelExp) {
        // 升级了!
        $newLevel = $currentLevel + 1;

        $this->pdo->beginTransaction();
        try {
            // 更新等级
            $this->pdo->prepare("UPDATE users SET level = :level WHERE id = :id")->execute(['level' => $newLevel, 'id' => $userId]);

            // 给予升级奖励(比如额外送1000积分)
            $this->addPoints($userId, 1000, 'LEVEL_UP', "升级到{$newLevel}级");

            $this->pdo->commit();
            return true;
        } catch (Exception $e) {
            $this->pdo->rollBack();
            return false;
        }
    }
    return false;
}

更高级的曲线:对数曲线

如果你觉得上面的线性增长太慢了,可以用对数曲线。exp = base + log(level) * factor。这样前期升级快,后期慢,保护服务器压力(因为后期用户很少了)。


第五部分:并发与锁——别让用户刷出bug

好了,现在我们有了一个能跑的Demo。但是,如果你有100万用户同时签到,或者有人写个脚本每秒刷100次登录,你的数据库会瞬间挂掉。这就是并发问题

场景:
用户A和用户B同时来签到。数据库里的余额是100。
用户A读余额 -> 100。
用户B读余额 -> 100。
用户A更新余额 -> 101。
用户B更新余额 -> 101。
结果:两人都觉得自己赚了,但你实际只发了1个积分。

解决方案:悲观锁与原子更新

别用 PHP 去抢锁(flock),那是开车的,咱们用数据库原子的方式。

方案一:使用 FOR UPDATE 锁行

在事务中加上 FOR UPDATE,告诉数据库:“给我锁住这一行,别让其他人动它”。

// 在 PointService 中
public function addPointsSafe(int $userId, int $points): int
{
    $this->pdo->beginTransaction();
    try {
        // 锁定用户行,直到事务结束
        $stmt = $this->pdo->prepare("SELECT score FROM users WHERE id = ? FOR UPDATE");
        $stmt->execute([$userId]);
        $score = $stmt->fetchColumn();

        $newScore = $score + $points;

        // 原子更新
        $this->pdo->prepare("UPDATE users SET score = ? WHERE id = ?")->execute([$newScore, $userId]);

        $this->pdo->commit();
        return $newScore;
    } catch (Exception $e) {
        $this->pdo->rollBack();
        throw $e;
    }
}

注意:在 MySQL 的 InnoDB 引擎中,行锁是基于索引的。所以 id 最好有主键索引。

方案二:原子自增 (CAS 思想)

如果你只是单纯增加积分,不关心变动前的余额(只关心变动后的余额),用 UPDATE users SET score = score + ? WHERE id = ?。这叫“原子操作”,数据库自己保证只有一个线程能执行这条 SQL。


第六部分:缓存层——Redis 的神威

虽然我们有了事务和锁,但如果每次积分变动都去查库、写库,那数据库早就爆表了。咱们得请出 Redis 这位大神。

为什么要用 Redis?

  1. 查余额快: 从数据库读要 5ms,从 Redis 读要 0.1ms。
  2. 分布式锁: 多台服务器部署时,PHP 的 flock 是不管用的,得靠 Redis 的 SETNX

实战:分布式签到锁

假设你部署了3台服务器(A, B, C)。用户请求到了 A 服务器。A 服务器把用户锁住。如果此时用户又发起了请求到了 B 服务器,B 服务器应该直接报错“正在处理中”。

public function checkInWithLock(int $userId, Redis $redis): array
{
    $lockKey = "checkin_lock:{$userId}";
    // SETNX:只有 Key 不存在时才设置成功,返回 1,否则返回 0
    $isLocked = $redis->set($lockKey, '1', ['NX', 'EX' => 10]); // 锁定10秒

    if (!$isLocked) {
        return ['success' => false, 'message' => '正在签到中,请勿重复提交'];
    }

    try {
        // 真正的签到逻辑(复用上面的 checkIn 方法,但把数据库操作换掉)
        // 这里为了演示简单,假设内部逻辑已经处理好了数据库更新
        // ... 签到逻辑代码 ...

        return ['success' => true, 'message' => '签到成功'];
    } finally {
        // 必须解锁!否则死锁了,其他用户永远没法签到
        $redis->del($lockKey);
    }
}

实战:积分缓存

不要每次都查数据库。

  1. 用户登录,从数据库读积分,写入 Redis user:1001:score
  2. 用户签到,在 Redis 里 INCRBY user:1001:score 10
  3. 用户查看积分,读 Redis。
  4. 定时任务: 每隔 5 分钟,把 Redis 里所有用户的积分 HGETALL 导入数据库的 users 表。

这样,你的数据库压力瞬间降为 0。


第七部分:积分消费与惩罚——守财奴的必修课

系统里只有加法没有减法?那不成提款机了吗?
用户违规删评论、恶意刷屏,你得扣积分。积分扣多了,用户没分了,你还得给他恢复(毕竟不能把人逼死)。

扣分逻辑

扣分和加分一样,也要记录日志。

public function deductPoints(int $userId, int $points, string $reason): bool
{
    // 1. 检查余额
    $current = $this->redis->get("user:{$userId}:score") ?? $this->getUserScore($userId);

    if ($current < $points) {
        return false; // 积分不足
    }

    // 2. 执行扣分(原子操作)
    $this->redis->decrBy("user:{$userId}:score", $points);
    // 然后同步到数据库的定时任务会处理
    // 这里可以写扣分日志

    return true;
}

注意: 扣分是高风险操作。很多公司规定,扣分必须经过人工审核。比如系统自动扣 50 分,管理员看到后点击“确认扣分”,积分才真正扣除。这样用户投诉的时候,你还有机会撤回。


第八部分:实战中的那些坑

写完了代码,别急着上线。作为专家,我得给你们提个醒,这些坑我以前都掉进去过。

  1. 时间差问题:
    中国和美国的用户在一起,你的签到时间是北京时间还是纽约时间?用户在北京 23:59 签到,美国用户 23:59 签到,对系统来说差了12小时。一定要统一时间标准(UTC 或统一本地时)。

  2. 整型溢出:
    积分类型用 INT 最多支持到 20 亿。如果用户签到 100 年,每天都得奖 1000 分,会不会爆?用 BIGINT!还有经验值 EXP,也可能很大。

  3. SQL 注入:
    不要为了省事去拼接 SQL。我之前的例子里有拼接,那是为了讲课方便。生产环境请务必使用参数绑定!

  4. 幂等性:
    用户网络卡顿,点击“签到”后转圈圈,但他其实已经签到了。他再点一下,怎么办?我们在 checkin_logs 表里加了 UNIQUE KEY (user_id, date),数据库会直接报错 Duplicate entry。这时候你要捕获这个错误,告诉用户“已签到”,而不是报错。

  5. 奖励配置化:
    别把“签到5天奖励100分”这种硬编码在代码里。你应该搞个配置表 checkin_config,里面存 day: 5, points: 100。万一哪天老板说“签到5天奖励200分”,你不用改代码,改配置表就行。


第九部分:进阶优化——数据一致性保障

这是专家级的挑战了。
场景:Redis 里的积分是 1000,数据库里的积分是 500。为什么?因为 Redis 刚更新,数据库还没同步,或者 Redis 挂了回滚了。

最终一致性策略:

  1. 写入: Redis 更新成功,数据库异步更新。
  2. 读取: 优先读 Redis。如果 Redis 没了,读数据库。
  3. 补偿任务: 写个脚本,每天凌晨跑一次,对比 Redis 和数据库的差值。如果 Redis 多了,补到数据库;如果 Redis 少了(Redis 挂了),把数据库的同步到 Redis。
// 伪代码:补偿任务
function syncPoints() {
    $redisKeys = $redis->keys('user:*:score');
    foreach ($redisKeys as $key) {
        $userId = str_replace('user:', '', $key);
        $redisScore = $redis->get($key);
        $dbScore = getDbScore($userId);

        $diff = $redisScore - $dbScore;
        if ($diff !== 0) {
            updateDbScore($userId, $redisScore);
            log("同步完成:用户{$userId},Redis{$redisScore} -> DB{$dbScore}");
        }
    }
}

第十部分:接口设计

最后,给咱们辛苦写的 Service 暴露几个 API 接口。前端只需要这几行 JSON 就能玩转整个系统。

1. 查看我的积分和等级

GET /api/user/profile
{
  "code": 200,
  "data": {
    "username": "老王",
    "score": 1250,
    "level": 3,
    "exp": 1200,
    "next_level_exp": 3000
  }
}

2. 执行签到

POST /api/user/checkin
{
  "code": 200,
  "data": {
    "message": "签到成功!获得3积分(连续签到第2天)",
    "current_score": 1253,
    "current_streak": 2
  }
}

3. 获取排行榜

GET /api/points/rank?page=1&limit=20
{
  "data": [
    {"rank": 1, "username": "超级VIP", "score": 99999},
    {"rank": 2, "username": "新人王", "score": 5500}
  ]
}

结语:系统是活的,人是复杂的

好了,咱们这篇长篇大论差不多讲完了。从数据库表设计,到 Service 层逻辑,再到并发锁、Redis 缓存,最后是防刷和补偿机制。

你现在拥有的不仅仅是一套代码,而是一个激励机制
积分系统就像一个驯兽师手里的鞭子。鞭子挥得快(接口快),驯兽师(你的代码)才能驾驭野兽(用户行为)。

  • 签到是让它听话。
  • 等级是让它为了更高的地位努力。
  • 扣分是惩罚它的坏毛病。

记住,技术只是手段,目的是让用户离不开你的产品。如果你发现用户为了积分天天来签到,那恭喜你,你的系统大功告成!

最后,别忘了给你的数据库加索引,给你的 Redis 设置过期时间,给你的代码加上注释。代码写得好,架构设计得巧,老板看了直夸妙。

下课!大家赶紧去把代码跑起来,别让用户的积分跑了!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注