PHP 积分冻结解冻

wen PHP项目 6

本文目录导读:

PHP 积分冻结解冻

  1. 数据库设计
  2. 核心 PHP 类设计
  3. 业务场景示例(交易流程)
  4. 高级注意事项

在 PHP 中实现积分冻结/解冻功能,核心是确保数据一致性,防止并发问题导致积分被重复使用或超额消费。

以下是一个完整的 PHP 实现方案,包含数据库设计、核心逻辑以及业务场景示例。


数据库设计

你需要三张核心表:users(用户表)、points_accounts(积分账户表)、points_transactions(积分流水表)。

-- 用户表(假设已存在,这里只添加积分相关字段)
ALTER TABLE `users` ADD `points_total` INT DEFAULT 0 COMMENT '总积分余额';
ALTER TABLE `users` ADD `points_frozen` INT DEFAULT 0 COMMENT '冻结积分';
-- 积分流水表 (关键:记录每一次变动)
CREATE TABLE `points_transactions` (
  `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `user_id` INT UNSIGNED NOT NULL COMMENT '用户ID',
  `change_amount` INT NOT NULL COMMENT '变动积分(正数增加,负数减少)',
  `frozen_amount` INT NOT NULL DEFAULT 0 COMMENT '冻结变动(正数冻结,负数解冻/扣除)',
  `type` ENUM('earn', 'spend', 'freeze', 'unfreeze', 'expire') NOT NULL COMMENT '变动类型',
  `order_id` VARCHAR(64) DEFAULT NULL COMMENT '关联业务订单号',
  `description` VARCHAR(255) DEFAULT NULL COMMENT '描述',
  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) COMMENT='积分流水表';

核心 PHP 类设计

我们将使用一个 PointsService 类,利用 MySQL 的 事务(Transaction)行锁(SELECT ... FOR UPDATE) 来保证数据安全。

<?php
class PointsService
{
    private PDO $pdo;
    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }
    /**
     * 冻结积分
     * @param int $userId
     * @param int $amount 冻结数量(必须为正数)
     * @param string $orderId 业务订单号(用于解冻时定位)
     * @param string $description
     * @return bool
     * @throws Exception
     */
    public function freeze(int $userId, int $amount, string $orderId, string $description = ''): bool
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException('冻结积分必须为正数');
        }
        try {
            $this->pdo->beginTransaction();
            // 1. 锁定用户行,防止并发
            $sql = "SELECT points_total, points_frozen FROM users WHERE id = :uid FOR UPDATE";
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute([':uid' => $userId]);
            $user = $stmt->fetch(PDO::FETCH_ASSOC);
            if (!$user) {
                throw new RuntimeException('用户不存在');
            }
            // 2. 检查可用余额(总积分 - 已冻结积分)
            $available = $user['points_total'] - $user['points_frozen'];
            if ($available < $amount) {
                throw new RuntimeException('可用积分不足,无法冻结');
            }
            // 3. 更新用户积分(冻结数增加)
            $updateSql = "UPDATE users 
                          SET points_frozen = points_frozen + :freeze 
                          WHERE id = :uid";
            $this->pdo->prepare($updateSql)->execute([
                ':freeze' => $amount,
                ':uid' => $userId
            ]);
            // 4. 写入流水记录(记录冻结)
            $this->recordTransaction($userId, $amount, 0, 'freeze', $orderId, $description);
            // 5. 提交事务
            $this->pdo->commit();
            return true;
        } catch (Exception $e) {
            $this->pdo->rollBack();
            // 在这里可以记录日志
            throw $e;
        }
    }
    /**
     * 解冻积分(退回到可用余额)
     * @param int $userId
     * @param int $amount 解冻数量
     * @param string $orderId 原始冻结的订单号
     * @param string $description
     * @return bool
     * @throws Exception
     */
    public function unfreeze(int $userId, int $amount, string $orderId, string $description = ''): bool
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException('解冻积分必须为正数');
        }
        try {
            $this->pdo->beginTransaction();
            // 1. 锁定用户行
            $sql = "SELECT points_total, points_frozen FROM users WHERE id = :uid FOR UPDATE";
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute([':uid' => $userId]);
            $user = $stmt->fetch(PDO::FETCH_ASSOC);
            if (!$user) {
                throw new RuntimeException('用户不存在');
            }
            // 2. 检查冻结余额是否足够
            if ($user['points_frozen'] < $amount) {
                throw new RuntimeException('冻结余额不足,无法解冻');
            }
            // 3. 更新用户积分(冻结数减少)
            $updateSql = "UPDATE users 
                          SET points_frozen = points_frozen - :unfreeze 
                          WHERE id = :uid";
            $this->pdo->prepare($updateSql)->execute([
                ':unfreeze' => $amount,
                ':uid' => $userId
            ]);
            // 4. 写入流水记录(记录解冻)
            $this->recordTransaction($userId, 0, -$amount, 'unfreeze', $orderId, $description);
            // 5. 提交事务
            $this->pdo->commit();
            return true;
        } catch (Exception $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }
    /**
     * 消费冻结积分(真正扣减,积分消失)
     * @param int $userId
     * @param int $amount 消费数量
     * @param string $orderId 原始冻结的订单号
     * @param string $description
     * @return bool
     * @throws Exception
     */
    public function consumeFrozen(int $userId, int $amount, string $orderId, string $description = ''): bool
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException('消费积分为正数');
        }
        try {
            $this->pdo->beginTransaction();
            // 1. 锁定用户行
            $sql = "SELECT points_total, points_frozen FROM users WHERE id = :uid FOR UPDATE";
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute([':uid' => $userId]);
            $user = $stmt->fetch(PDO::FETCH_ASSOC);
            if (!$user) {
                throw new RuntimeException('用户不存在');
            }
            // 2. 检查冻结余额
            if ($user['points_frozen'] < $amount) {
                throw new RuntimeException('冻结余额不足');
            }
            // 3. 这里比较关键:
            // 消费冻结积分 = 总积分减少 + 冻结积分减少
            $updateSql = "UPDATE users 
                          SET points_total = points_total - :spend, 
                              points_frozen = points_frozen - :spend_frozen 
                          WHERE id = :uid";
            $this->pdo->prepare($updateSql)->execute([
                ':spend' => $amount,
                ':spend_frozen' => $amount,
                ':uid' => $userId
            ]);
            // 4. 写入流水(负数表示积分消耗)
            $this->recordTransaction($userId, -$amount, -$amount, 'spend', $orderId, $description);
            $this->pdo->commit();
            return true;
        } catch (Exception $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }
    /**
     * 写入流水记录
     */
    private function recordTransaction(int $userId, int $pointsChange, int $frozenChange, string $type, string $orderId, string $desc): void
    {
        $sql = "INSERT INTO points_transactions 
                (user_id, change_amount, frozen_amount, type, order_id, description) 
                VALUES (:uid, :pChange, :fChange, :type, :oid, :desc)";
        $this->pdo->prepare($sql)->execute([
            ':uid' => $userId,
            ':pChange' => $pointsChange,
            ':fChange' => $frozenChange,
            ':type' => $type,
            ':oid' => $orderId,
            ':desc' => $desc
        ]);
    }
}

业务场景示例(交易流程)

假设你在做一个电商系统,下单时冻结积分,取消时解冻,完成支付后消费积分。

<?php
// 1. 用户下单(冻结100积分)
$points = new PointsService($pdo);
try {
    $points->freeze($userId, 100, 'ORDER12345', '购物订单冻结');
    // 订单状态设为 "等待支付"
} catch (Exception $e) {
    // 冻结失败(积分不足),拒绝下单
    echo "下单失败: " . $e->getMessage();
}
// 2. 用户取消订单(解冻)
try {
    $points->unfreeze($userId, 100, 'ORDER12345', '取消订单解冻');
    // 订单状态取消
} catch (Exception $e) {
    // 处理异常(订单号重复等)
}
// 3. 用户支付成功(消费冻结积分)
try {
    $points->consumeFrozen($userId, 100, 'ORDER12345', '支付完成消费积分');
    // 订单完成
} catch (Exception $e) {
    // 处理异常
}
?>

高级注意事项

A. 防止双重解冻/消费

  • 你的业务订单表需要有一个字段(如 status)来记录订单当前状态(待支付、已取消、已完成)。
  • 在调用 unfreezeconsumeFrozen 之前,先检查订单状态,确保该订单未被处理过。
  • 更严格的做法:在订单表加唯一索引,或在事务中结合订单状态加锁。

B. 过期积分处理

  • 如果积分有有效期,需要在定时任务(Cron Job)中扫描过期积分。
  • 可以将冻结或未冻结的积分在过期时清除,并记录流水。

C. 避免使用浮点数

  • 积分必须用 INTDECIMAL绝对不能使用 FLOAT / DOUBLE,否则会有精度问题。

D. 乐观锁替代方案

  • 如果你的数据库不支持行锁(FOR UPDATE)或不想用事务,可以使用“乐观锁”:
    • users 表增加一个 version 字段。
    • 更新时加上 WHERE version = :old_version,如果影响行数为0,则代表有并发冲突,需要重试。
  • 但使用事务+FOR UPDATE是金融系统推荐的首选方式。

这个方案通过数据库事务 + 行级锁(SELECT ... FOR UPDATE 保证了积分操作的原子性,彻底避免了并发下可能出现的“超扣”或“重复解冻”问题。

你可以根据这个核心逻辑,结合自己的业务框架(如 Laravel、ThinkPHP)进行封装。

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