要设计一套积分系统,求思路

需求概述

1、用户每次获得的积分有个过期时间,时长固定为1年,过期时间 = 当前获取积分的时间 + 1年;
2、消费的时候先从最先获得的积分开始消费;
3、退款的时候如果该积分已过期则不退;

例子

假设用户每天获取10个积分,现有100积分(其实是由10笔不同过期时间的10积分构成的),消费了79积分。此时会将前8笔流水打上已消费的标记,并生成一笔获得1积分的流水,剩余积分 = 1 + 10 + 10。如果后面用户退款,要考虑这79积分里有哪些已经过期的,如果过期则不退还积分。

求助

1、技术选型,用什么框架或数据库;
2、算法,更快得到结果;
3、注意事项,可能会存在哪些潜在问题;

附言 1  ·  4个月前

感谢各位的回复与关注,已有想法,后续有进展会再向大伙求助分享,谢谢大家了~

《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
alante
最佳答案

最近刚好做了一套积分相关的, 可以考虑设计, 积分流水表, 冻结积分表, 可用积分表, 待入账积分表 积分流水只做真实的扣除和增加, 冻结和待入账都是临时状态, 可用积分上边记录获取时间, 到时候可以依据获取时间控制过期

4个月前 评论
zzzzzq (楼主) 4个月前
zzzzzq (楼主) 4个月前
zzzzzq (楼主) 4个月前
alante (作者) 4个月前
alante (作者) 4个月前
alante (作者) 4个月前
wxf666 4个月前
zzzzzq (楼主) 4个月前
讨论数量: 35
MissYou-Coding

1. 技术选型

框架:Laravel
数据库:MySQL

2. 算法

积分过期处理:队列或者延时队列
积分消费算法:查询积分记录时,根据created_at字段进行排序,优先使用最早获得的积分。
退款算法:在退款操作时,检查积分记录的expires_at字段,如果已过期则不退还。

3. 注意事项

数据一致性:确保积分的增加、消费和过期标记在数据库层面保持一致性。
性能问题:随着数据量的增加,需要考虑数据库的索引优化和查询效率。
过期积分处理:定期清理过期积分,避免数据表过大。
用户体验:确保积分变动能够及时通知用户,提高透明度。

4个月前 评论
猪猪

gpt上问一下

4个月前 评论
zzzzzq (楼主) 4个月前
MissYou-Coding

1. 技术选型

框架:Laravel
数据库:MySQL

2. 算法

积分过期处理:队列或者延时队列
积分消费算法:查询积分记录时,根据created_at字段进行排序,优先使用最早获得的积分。
退款算法:在退款操作时,检查积分记录的expires_at字段,如果已过期则不退还。

3. 注意事项

数据一致性:确保积分的增加、消费和过期标记在数据库层面保持一致性。
性能问题:随着数据量的增加,需要考虑数据库的索引优化和查询效率。
过期积分处理:定期清理过期积分,避免数据表过大。
用户体验:确保积分变动能够及时通知用户,提高透明度。

4个月前 评论
  1. 技术选型 框架: 后端框架: Laravel(PHP):提供了优雅的语法,强大的ORM,适合快速开发复杂的业务逻辑。 Django(Python):具有良好的Admin后台管理和ORM,适合快速开发和数据管理。 Spring Boot(Java):强大的企业级框架,适合复杂应用,提供了良好的开发习惯。 数据库: 关系型数据库: MySQL 或 PostgreSQL:存储用户积分记录,设计有过期时间和消费状态的表结构,适合处理有关系的数据。 NoSQL 数据库(可选): MongoDB:适合存储灵活的数据结构,如积分流水,可以快速查询。

  2. 算法设计 积分记录结构: 创建一个积分记录表,包含以下字段:

    • id:唯一标识
    • user_id:用户ID
    • points:积分数
    • earned_time:获得积分的时间
    • status:消费状态(如:已消费、未消费) 消费逻辑: 按 earned_time 排序,优先消费最旧的积分。 逐笔减少积分,直到消费达到所需的积分数。 示例伪代码:
      function consumePoints($userId, $pointsToConsume) {
          $records = getPointRecords($userId); // 获取用户积分记录,按时间排序
         $consumedPoints = 0;
         foreach ($records as $record) {
             if ($consumedPoints >= $pointsToConsume) break;
             // 如果积分未过期
             if (!isExpired($record->earned_time)) {
                 $consumedPoints += $record->points;
                 markAsConsumed($record->id); // 标记为已消费
             }
         }
         // Handle residual points
         if ($consumedPoints < $pointsToConsume) {
             throw new Exception("Not enough valid points to consume");
         }
      }
      // 检查积分是否过期
      function isExpired($earnedTime) {
      return (time() - strtotime($earnedTime)) > 365 * 24 * 60 * 60; // 1年
      }

退款逻辑: 在退款时,验证消费记录中哪些积分已过期。 对于未过期的积分,进行退款。 示例伪代码:

function refundPoints($userId, $pointsToRefund, $consumedRecords) {
    foreach ($consumedRecords as $record) {
        if (!isExpired($record->earned_time)) {
            // 执行退款
            addPointsToUser($userId, $record->points);
            $pointsToRefund -= $record->points;
            if ($pointsToRefund <= 0) break;
        }
    }
}
  1. 注意事项 过期处理:注意积分的过期时间,确保在消费和退款时能够准确判断。 并发处理:确保积分记录在并发操作下的一致性,使用数据库的事务处理。 积分精度:注意积分的计算精度,避免因浮点数误差导致的积分不准确。 数据量:如果用户的积分交易数据量较大,考虑如何优化查询性能,如使用索引。 适应性:设计时考虑未来可能的业务变更,例如积分获取规则、有效期调整等。
4个月前 评论

积分系统设计方案

1. 技术选型

  • 后端框架:推荐使用 Laravel 框架。Laravel 是一个强大且功能丰富的 PHP 框架,支持 ORM(Eloquent),让你可以轻松操作数据库,处理积分的业务逻辑。
  • 数据库:MySQL 是不错的选择,支持事务、索引、复杂查询。考虑到积分系统的查询需求,MySQL 的 ACID 特性能保证数据一致性。
  • 缓存:Redis 可以用于存储常用积分数据,避免频繁数据库查询,提升系统性能。

2. 数据库设计

表结构设计

可以创建以下几张表:

  • points 表:用于记录用户积分的获取、过期和消费状态。
CREATE TABLE points (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,               -- 用户ID
    points INT NOT NULL,                -- 积分数量
    earned_at DATETIME NOT NULL,        -- 积分获取时间
    expires_at DATETIME NOT NULL,       -- 积分过期时间
    status ENUM('active', 'spent') DEFAULT 'active' -- 积分状态
);
transactions 表:记录用户消费和退款的流水。

CREATE TABLE transactions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    type ENUM('earn', 'spend', 'refund') NOT NULL, -- 类型:获得、消费、退款
    points INT NOT NULL,                          -- 积分数量
    related_points_id INT,                        -- 关联的积分记录ID,用于消费或退款
    created_at DATETIME NOT NULL                  -- 交易时间
);
3. 积分消费和退款算法
消费积分
消费时,先按获取时间排序,优先消费最早获取的积分。

function spendPoints($userId, $pointsToSpend) {
    $points = DB::table('points')
        ->where('user_id', $userId)
        ->where('status', 'active')
        ->where('expires_at', '>', now())
        ->orderBy('earned_at', 'asc')
        ->get();
    $totalSpent = 0;
    foreach ($points as $point) {
        if ($totalSpent >= $pointsToSpend) break;
        $spend = min($pointsToSpend - $totalSpent, $point->points);
    // 更新积分状态
    DB::table('points')->where('id', $point->id)->update([
        'points' => $point->points - $spend,
        'status' => $point->points - $spend > 0 ? 'active' : 'spent'
    ]);

    // 创建消费流水
    DB::table('transactions')->insert([
        'user_id' => $userId,
        'type' => 'spend',
        'points' =>
4个月前 评论
sanders

看了楼上的方案,我也想了想,觉得实际操作过程中还是挺复杂的。

楼上的几个方案中有个共同的问题是不可预期的迭代次数,这个在积分记录数据量大的情况下执行效率会降的很快,建议约束使用 mysql 8 以上版本的数据库并使用窗口函数进行查询优化,以楼上为例:

with w as (
    select id, points, earned_at, sum(points) over (order by earned_at) as tt
    from points
    where user_id=$userId and expired_at > $now
)
select id, points, earned_at from w where tt > $pointsToSpend order by earned_at;
4个月前 评论
sanders (作者) 4个月前
sanders

目前还没找到什么包能在 laravel orm 里面使用窗口函数,直接操作数据批量更新应该是不可避免的,注意这时模型上的事件不会触发,逐条更新的话基本上就没考虑数据量的问题。

4个月前 评论

感觉不是很复杂,没有太多计算吧?加个欢乐锁应该就可以了。

4个月前 评论
她来听我的演唱会 4个月前
zzzzzq (楼主) 4个月前
keyboby (作者) 4个月前

参考一些大厂的积分设计,有没有记得,我记得他们是按年来过期的。

所以你也可以将积分设计成按时间段来过期

4个月前 评论
wxf666 4个月前
code789 4个月前
alante

最近刚好做了一套积分相关的, 可以考虑设计, 积分流水表, 冻结积分表, 可用积分表, 待入账积分表 积分流水只做真实的扣除和增加, 冻结和待入账都是临时状态, 可用积分上边记录获取时间, 到时候可以依据获取时间控制过期

4个月前 评论
zzzzzq (楼主) 4个月前
zzzzzq (楼主) 4个月前
zzzzzq (楼主) 4个月前
alante (作者) 4个月前
alante (作者) 4个月前
alante (作者) 4个月前
wxf666 4个月前
zzzzzq (楼主) 4个月前
alante

一张表会有很多垃圾数据, 比如你要解决一次获取的积分用给多个订单的时候,就不好整了

4个月前 评论
zzzzzq (楼主) 4个月前

京东京豆吗

4个月前 评论
zzzzzq (楼主) 4个月前

和我之前做的一个项目很像 (过了 2,3 年了,记不太清了,直接将之前代码扒过来了),兑换商品的时候,优先使用去年积分,不够则使用今年积分 积分流水表里面有 3 个关键字段 获取的时间 created_at 获得积分 points 该条记录被抵扣的积分 struck_points

看去年积分够不够全额扣减,如果不够的话,去年积分全部扣减完 此时将 struck_points 更新成和 points 一致即可 如果有多余的话

if($lastAvailablePoints<$struck_points){
                    $extra_score = $extra_score-$lastAvailablePoints;
                    //优先扣除去年的积分,别改status为2,会导致积分记录看不到
                    MemberPointsSource::where('status',1)
                        ->where('points','>',0)
                        ->where(function($query) use($mobile,$member_id){
                            $query->where(["member_id" => $member_id]);
                        })
                        ->where('created_at',">=",$lastYearBeginTime)
                        ->where('created_at',"<",$thisYearBeginTime)
                        ->update(['struck_points'=>DB::raw('points')]);
                    //之后扣除今年的积分(这边操作获得的积分)
                    $thisYearData = MemberPointsSource::where('status',1)
                        ->select('*',DB::raw('points-struck_points as availPoints'))
                        ->where('created_at',">=",$thisYearBeginTime)
                        ->where('points','>',0)
                        ->where(function($query) use($mobile,$member_id){
                            $query->where(["member_id" => $member_id]);
                        })
                        ->orderBy('availPoints','desc')
                        ->get()
                        ->toArray();
                    foreach($thisYearData as $k=>$v){
                        if($extra_score>$v['availPoints']){
                            $update_data2['struck_points'] = ($v['struck_points']+$extra_score)<=$v['points'] ? $v['struck_points']+$extra_score : $v['points'] ;
                            MemberPointsSource::where('id',$v['id'])->update($update_data2);
                        }else{
                            $update_data3['struck_points'] = $v['struck_points']+$extra_score;
                            MemberPointsSource::where('id',$v['id'])->update($update_data3);
                            break;
                        }
                        $extra_score = $extra_score-$v['availPoints'];
                    }
                }else{  //去年积分满足兑换商品所需的分数
                    $lastYearData = MemberPointsSource::where('status',1)
                        ->select('*',DB::raw('points-struck_points as availPoints'))
                        ->where('points','>',0)
                        ->where(function($query) use($mobile,$member_id){
                            $query->where(["member_id" => $member_id]);
                        })
                        ->where('created_at',">=",$lastYearBeginTime)
                        ->where('created_at',"<",$thisYearBeginTime)
                        ->orderBy('availPoints','desc')
                        ->get()
                        ->toArray();
                    foreach($lastYearData as $k=>$v){
                        if($extra_score>$v['availPoints']){
                            $update_data2['struck_points'] = ($v['struck_points']+$extra_score)<=$v['points'] ? $v['struck_points']+$extra_score : $v['points'] ;
                            MemberPointsSource::where('id',$v['id'])->update($update_data2);
                        }else{
                            $update_data3['struck_points'] = $v['struck_points']+$extra_score;
                            MemberPointsSource::where('id',$v['id'])->update($update_data3);
                            break;
                        }
                        $extra_score = $extra_score-$v['availPoints'];
                    }
                }
4个月前 评论
-- 积分余额表
CREATE TABLE 积分余额表 (
    主键ID INT PRIMARY KEY AUTO_INCREMENT,
    用户 INT NOT NULL,
    余额 INT NOT NULL DEFAULT 0,
);
INSERT INTO 积分余额表 (主键ID, 用户, 余额) VALUES
(1, 1, 400);

-- 用户积分获得表
CREATE TABLE 用户积分获得表 (
    主键ID INT PRIMARY KEY AUTO_INCREMENT,
    用户 INT NOT NULL,
    获得积分 INT NOT NULL,
    到期时间 DATETIME NOT NULL,
    已使用 INT NOT NULL DEFAULT 0,
    剩余积分 INT NOT NULL,
);
INSERT INTO 用户积分获得表 (主键ID, 用户, 获得积分, 到期时间, 已使用, 剩余积分) VALUES
(1, 1, 500, '2024-10-12 00:00:00', 100, 400);

-- 用户积分日志表
CREATE TABLE 用户积分日志表 (
    主键ID INT PRIMARY KEY AUTO_INCREMENT,
    用户 INT NOT NULL,
    操作 ENUM('添加', '消费') NOT NULL,
    额度 INT NOT NULL,
    消费积分表ID INT,
    关联订单 VARCHAR(255),
);
INSERT INTO 用户积分日志表 (主键ID, 用户, 操作, 额度, 用户积分表ID, 关联订单) VALUES
(1, 1, '添加', 500, 1, NULL),
(2, 1, '消费', 100, 1, 'order_no_123213');


注意:
1、用户获取积分,先给用户积分获得增加记录,然后刷新用户积分余额表
2、定时任务更新失效的积分,刷新用户积分余额表
3、消费的时候按照到期时间取用户积分获得表,按照顺序扣减,刷新用户积分余额表
4、退款,按照订单查询用户积分日志表的消费,然后按照用户积分获得表的到期时间回退,刷新用户积分余额表

PS:记住mysql用InnoDB引擎,然后每次处理这三张表的查询和更新、添加时用事务,会自动锁行,防止积分的并发(比如定时任务和用户操作)
以上是我用汉字阐述的,看懂了,了解具体思路就成,其他的自己自由发挥,总之这种方式,查询积分日志,以及积分余额会很方便,相信你开发一个积分商城,最怕的是用户量大后的积分汇总统计
4个月前 评论

用户积分表:主要记录用户拥有多少积分

  • id
  • 用户id
  • 剩余积分

用户积分流水:记录用户积分的变动

  • id
  • 用户id
  • 交易前积分
  • 操作:增加/减少
  • 交易积分数量
  • 状态:成功/已被撤回
  • 撤回用户积分流水id

用户拥有积分表

  • id
  • 用户id
  • 可使用的积分
  • 总共积分
  • 过期时间

用户拥有积分流水表

  • id
  • 用户积分流水id
  • 用户拥有积分id
  • 操作: 增加或减少
  • 积分数量
  • 状态:成功/已被撤回
  • 撤回流水id: 也就是它撤回了哪条记录id - 用户拥有积分流水表的id

关系:

  • 一个用户 对应 一个用户积分表

  • 一个用户 对应 多个用户拥有积分表

  • 一个用户 对应 多个用户积分流水

  • 一个用户积分流水 对应 多个用户拥有积分流水

每次交易会添加 一个用户积分流水、1个或多个用户拥有积分流水
修改 用户积分、1个或多个用户拥有积分表中的剩余积分;总结就是产生2个类型流水,修改两个类型积分

总结来说:就是为用户积分变动创建的流水、以及 用户拥有的积分变动 也创建的流水。一层套一层,一个是整体的变动流水,一个整体变动就包括一个或多个部分变动

这个数据结构可以解决过期的积分撤回问题,撤回积分会增加用户拥有的积分表中可用积分数量,不管有没有过期。就算过期了它也没办法使用。

这个只是大概想了一下,并没有实践。只考虑的它的实现,没有考虑性能等问题。希望能为你提供思路

4个月前 评论

看你们写的天花乱坠的,最简单的方案就是一张流水表即可,每次查剩余积分都用sum()统计积分表。别说什么性能问题,我项目积分表数据3亿多都没到瓶颈。

4个月前 评论
soulqq 4个月前
xujinhui (作者) 4个月前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!