场景设计-积分系统
1.概念和规则
- 积分:用户在网站的各种交互行为都可以产生积分,积分值与行为类型有关
- 天梯榜:按照每个用户的总积分排序得到的排行榜,称为天梯榜。排名靠前的有奖励。天梯榜每个自然月为一个赛季,月初清零
具体的积分获取的细则通常如下:
积分获取规则
1. 签到规则
连续7天奖励10分 连续14天 奖励20 连续28天奖励40分, 每月签到进度当月第一天重置
2. 学习规则
每学习一小节,积分+10,每天获得上限50分
3. 交互规则(有效交互数据参与积分规则,无效数据会被删除)
- 写评价 每个课程只能评价一次,每日无上限
- 写问答 积分+5 每日获得上限为20分
- 写笔记 积分+3 每次被采集+2 每日获得上限为20分
2.页面原型
3.需要的接口统计
业务 | 编号 | 接口简述 |
---|---|---|
签到 | 1 | 签到 |
2 | 查询本月签到记录 | |
积分 | 3 | 新增积分记录 |
4 | 查询今日积分情况 | |
排行榜 | 5 | 查询本赛季的积分排行榜 |
6 | 查询赛季列表 | |
7 | 查询历史赛季积分排行榜 |
4.表结构设计
4.1签到
签到表主要记载,谁在什么时候,通过什么渠道进行了签到,还可以加上补签功能
CREATE TABLE `sign_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint NOT NULL COMMENT '用户id',
`year` year NOT NULL COMMENT '签到年份',
`month` tinyint NOT NULL COMMENT '签到月份',
`date` date NOT NULL COMMENT '签到日期',
`is_backup` bit(1) NOT NULL COMMENT '是否补签',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到记录表';
4.2积分记录
积分记录,记录谁(用户),在什么时间,通过哪种方式,获得了多少积分
CREATE TABLE IF NOT EXISTS `points_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '积分记录表id',
`user_id` bigint NOT NULL COMMENT '用户id',
`type` tinyint NOT NULL COMMENT '积分方式:1-课程学习,2-每日签到,3-课程问答, 4-课程笔记,5-课程评价',
`points` tinyint NOT NULL COMMENT '积分值',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_user_id` (`user_id`,`type`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学习积分记录,每个月底清零';
4.3排行榜
主体需要两个,一个是赛季表记录赛季开始时间和结束时间.另一个是排行榜,记录用户在某个赛季的排行
CREATE TABLE IF NOT EXISTS `points_board_season` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '自增长id,season标示',
`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '赛季名称,例如:第1赛季',
`begin_time` date NOT NULL COMMENT '赛季开始时间',
`end_time` date NOT NULL COMMENT '赛季结束时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC;
CREATE TABLE IF NOT EXISTS `points_board` (
`id` bigint NOT NULL COMMENT '榜单id',
`user_id` bigint NOT NULL COMMENT '学生id',
`points` int NOT NULL COMMENT '积分值',
`rank` tinyint NOT NULL COMMENT '名次,只记录赛季前100',
`season` smallint NOT NULL COMMENT '赛季,例如 1,就是第一赛季,2-就是第二赛季',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_season_user` (`season`,`user_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学霸天梯榜';
5.实现思路
5.1签到功能
使用bitmap,每个月为每个用户生成一个独立的KEY,因此KEY中必须包含用户信息、月份信息,长这样sign:uid:xxx:20XX01
签到
把这一天的下位列表设置成true,关键代码如下:
LocalDate now = LocalDate.now();
int offset = now.getDayOfMonth() - 1;
Boolean exists = redisTemplate.opsForValue().setBit(key, offset, true);
计算连续签到
private int countSignDays(String key, int len) {
// 1.获取本月从第一天开始,到今天为止的所有签到记录
List<Long> result = redisTemplate.opsForValue()
.bitField(key, BitFieldSubCommands.create().get(
BitFieldSubCommands.BitFieldType.unsigned(len)).valueAt(0));
if (CollUtils.isEmpty(result)) {
return 0;
}
int num = result.get(0).intValue();
// 2.定义一个计数器
int count = 0;
// 3.循环,与1做与运算,得到最后一个bit,判断是否为0,为0则终止,为1则继续
while ((num & 1) == 1) {
// 4.计数器+1
count++;
// 5.把数字右移一位,最后一位被舍弃,倒数第二位成了最后一位
num >>>= 1;
}
return count;
}
具体解释:
-
.bitField
:- Redis 的
BITFIELD
命令允许对 Bitmap 中的位域进行灵活的批量操作。 - 它可以高效地读取、修改和操作指定范围的位数据。
- Redis 的
-
BitFieldSubCommands.create().get(...).valueAt(0)
:-
create()
:创建BitFieldSubCommands
对象,用于构造 RedisBITFIELD
命令。 -
get
:获取 Bitmap 中的位域值。
BitFieldType.unsigned(len)
:读取一个无符号整数,长度为len
位。
-
valueAt(0)
:从偏移量0
开始读取。
-
目的: 将从第 1 位到第 len
位的所有位数据读取出来,作为一个整体返回。例如,如果一个月有 31 天的签到数据,这里会读取连续 31 位并将其转为一个 Long
值。
根据连续签到天数计算奖励积分值:
可以定义一个枚举,避免用swtich case
public enum SignReward {
SEVEN_DAYS(7, 10),
FOURTEEN_DAYS(14, 20),
TWENTY_EIGHT_DAYS(28, 40);
private final int requiredDays;
private final int points;
SignReward(int requiredDays, int points) {
this.requiredDays = requiredDays;
this.points = points;
}
public static int calculateReward(int signDays) {
// 按天数降序排列,优先匹配高奖励
for (SignReward reward : values()) {
if (signDays==reward.requiredDays) {
return reward.points;
}
}
return 0;
}
}
查询签到记录
核心代码
Byte[] arr = new Byte[dayOfMonth];
int pos = dayOfMonth - 1;
while (pos >= 0){
arr[pos--] = (byte)(num & 1);
// 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num >>>= 1;
}
return arr;
使用Redis保存签到记录,那如果Redis宕机怎么办?
我们可以给Redis添加数据持久化机制,比如使用AOF持久化。这样宕机后也丢失的数据量不多,可以接受。
或者呢,我们可以搭建Redis主从集群,再结合Redis哨兵。主节点会把数据持续的同步给从节点,宕机后也会有哨兵重新选主,基本不用担心数据丢失问题。
当然,如果对于数据的安全性要求非常高。肯定还是要用传统数据库来实现的。但是为了解决签到数据量较大的问题,我们可能就需要对数据做分表处理了。或者及时将历史数据存档。
总的来说,签到数据使用Redis的BitMap无论是安全性还是数据内存占用情况,都是可以接受的。但是具体是选择Redis还是数据库方案,最终还是要看具体的要求来选择。
我个人觉得,使用bitmap优势是可以快速的统计,降低数据库的压力,查询操作可以由bitmap完成,而持久化操作可以异步入库
5.2积分功能
积分功能通常是其他业务进行操作,来通知积分系统进行添加积分操作,使用MQ来实现异步解耦。
如签到功能,在计算完积分之后,通知新增积分入库操作
但是入库并不是无脑进行入库,根据不同的业务类型,我们首先要查询当日积分上限,满足条件才能进行积分增加
这里有发现一个之前没用过的小技巧:
MyBatis-Plus 的确支持将 QueryWrapper
或 LambdaQueryWrapper
作为参数传递到自定义的 SQL 方法中,这是一个非常实用的小技巧,可以大大简化动态查询条件的处理逻辑。
@Select("SELECT SUM(points) FROM points_record ${ew.customSqlSegment}")
Integer queryUserPointsByTypeAndDate(@Param(Constants.WRAPPER) QueryWrapper<PointsRecord> wrapper);
${ew.customSqlSegment}
:
- 这是 MyBatis-Plus 提供的占位符,用于插入
QueryWrapper
动态生成的 SQL 条件。 - 它会将
QueryWrapper
中构建的条件自动拼接到 SQL 中。
@Param(Constants.WRAPPER)
:
- MyBatis-Plus 约定,
QueryWrapper
的参数必须使用@Param(Constants.WRAPPER)
标记,Constants.WRAPPER
是 MyBatis-Plus 提供的默认常量,值为"ew"
。