签到
签到最核心的包含两个要素:
-
谁签到:用户id
-
什么时候签的:签到日期
同时要考虑一些功能要素,比如:
-
补签功能,所以要有补签标示
-
按照年、月统计的功能:所以签到日期可以按照年、月、日分离保存
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='签到记录表';
但是签到占用数据太大了
随着用户量增多、时间的推移,这张表中的数据只会越来越多,占用的空间也会越来越大。可以考虑用比特位进行签到
实现思路
像这种把每一个二进制位,与某些业务数据一一映射(本例中是与一个月的每一天映射),然后用二进制位上的数字0和1来标识业务状态的思路,称为位图。也叫做BitMap.
这种数据统计的方式非常节省空间,因此经常用来做各种数据统计。比如大名鼎鼎的布隆过滤器就是基于BitMap来实现的。
OK,那么利用BitMap我们就能直接实现签到功能,并且非常节省内存,还很高效。所以就无需通过数据库来操作了。
BitMap用法
Redis中就提供了BitMap这种结构以及一些相关的操作命令
修改某个bit位上的数据
setbit 键值 偏移量 数值(0或者1) 返回值是原来比特位上的数值,偏移量从0开始,就是选择设置第几位的值
实例
签到(第几位上设置为1即可)
查询签到记录
签到接口
而在后台,要做的事情就是把BitMap中的与签到日期对应的bit位,置为1.
另外,为了便于统计,我们计划每个月为每个用户生成一个独立的KEY,因此KEY中必须包含用户信息、月份信息,长这样:
连续签到统计
如何得到连续签到天数?需要下面几步:
-
获取本月到今天为止的所有签到数据
-
从今天开始,向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数
如图:
每一次签到都做一次统计(从后向前统计),直到遇到0位置 ,每天签到都会统计一次,所以前面连续签到了8天,第九天断了,也没事,因为每次签到的时候都统计,而且第八天签到的时候奖励积分也加上了不用再加了 ,伪代码写法如下:
这里存在几个问题:
-
如何才能得到本月到今天为止的所有签到记录?
-
如何从后向前遍历每一个bit位?
至于签到记录,可以利用我们之前讲的BITFIELD命令来获取,从0开始,到今天为止的记录,命令是这样的:
遍历比特位思路: 与1做与运算,结果为1 说明签到了,这个只是与最后一位做&运算,然后按位右移(这样倒数第二位就会变成最后一位,依次类推)
public SignResultVO addSignRecords() {
// 1. 获取用户id
Long userId = UserContext.getUser();
// 2.拼接key
LocalDate now = LocalDate.now();
String format = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId.toString() + format;
// 3. 使用bitset命令 将签到记录保存到redis的bitmao结构中 需要校验是否已签到
int offset = now.getDayOfMonth() -1;
Boolean exists = redisTemplate.opsForValue().setBit(key, offset, true);
// 返回值就是原来位上的值
if (exists){
throw new BizIllegalException("不允许重复签到");
}
// 4. 计算连续签到的天数
int signDays = countSignDays(key,now.getDayOfMonth());
// 5.计算签到得分
int rewardPoints = 0;
switch (signDays) {
case 7:
rewardPoints = 10;
break;
case 14:
rewardPoints = 20;
break;
case 28:
rewardPoints = 40;
break;
}
// todo 6. 保存积分
mqHelper.send(
MqConstants.Exchange.LEARNING_EXCHANGE,
MqConstants.Key.SIGN_IN,
SignInMessage.of(userId, rewardPoints + 1)); //签到积分是基本积分+奖励积分
// 5.封装返回
SignResultVO vo = new SignResultVO();
vo.setSignDays(signDays);
vo.setRewardPoints(rewardPoints);
return vo;
}
// 计算连续签到天数
private int countSignDays(String key, int dayOfMonth) {
// 从第0位取,取dayOfMonth个数,参数是可以传集合的 所以返回结果是集合,取出来的是10进制
List<Long> result = redisTemplate.opsForValue()
.bitField(key, BitFieldSubCommands.create().get(
BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
if (CollUtils.isEmpty(result)){
return 0;
}
int num = result.get(0).intValue();
// 2.定义一个计数器
int count = 0;
// 从最后一位往前数连续一的个数,,每天签到一次执行一次
while ((num & 1) == 1){
count ++ ;
// 数字右移一位,最后一位被舍弃,倒数第二位成了最后一位,
num >>>= 1;
}
return count;
}
需要记得语法:
localdatetime的format,datetimeformatter.ofpattern
操作 setbit 对用的是opsforvalue的setbit方法,因为比特底层就是string类型实现的
valueat表示从第几位开始取,取无符号位到第几位(无符号位就是整数),取出来的数是对应的从0到指定的位数对应的二进制转化的十进制
与1做与运算会自动转化为二进制
新增积分功能
查看该类型积分是否有每日上限,如果没有直接保存,如果有统计今日已获得的积分如果加上新积分是否会超过上限(然后保存),同时统计已经获得的总积分,放到redis中以zset方式存储
积分的获取方式多种多样,为了解耦采用MQ来实现异步解耦。
综上,在MQ中我们只需要传递用户id一个参数即可。(因为每一种类型加的分都是固定的,可以在枚举或者常量中定义)
发送MQ消息
在签到签到中发送的消息:填写了用户id编写消息监听器
根据不用routingkey的类型接手消息
因为每种类型的积分可能有积分上限
首先根据雷翔判断有没有积分上限,然后数据库统计今日已经获得的积分,并累加积分到Zset
public void addPointsRecord(SignInMessage msg, PointsRecordType type) {
// 判断该积分类型是否有上限 type.getmaxpoint()
int maxPoints = type.getMaxPoints();
// 涉及要增加的积分
int realPoint = msg.getPoints();
if (maxPoints > 0){
LocalDateTime now = LocalDateTime.now();
LocalDateTime dayStartTime = DateUtils.getDayStartTime(now);
LocalDateTime dayEndTime = DateUtils.getDayEndTime(now);
// 如果有积分上限 查询该用户今日得到的积分
QueryWrapper<PointsRecord> wrapper = new QueryWrapper<>();
wrapper.select("sum(points) as totalPoints");
wrapper.eq("user_id",msg.getUserId());
wrapper.eq("type",type);
wrapper.between("create_time",dayStartTime,dayEndTime);
Map<String, Object> map = this.getMap(wrapper);
int currebtPoints = 0; // 当前用户 这个类型下已经获得的积分
if (map!=null){
BigDecimal totalPoints = (BigDecimal)map.get("totalPoints");
currebtPoints = totalPoints.intValue();
}
// 3.判断已得积分是否超过上限
if(currebtPoints >= maxPoints){
return;
}
// 判断加上积分是否会超过上限
if (currebtPoints + msg.getPoints() > maxPoints){
realPoint = maxPoints-currebtPoints;
}
}
// 保存积分
PointsRecord pointsRecord = new PointsRecord();
pointsRecord.setUserId(msg.getUserId());
pointsRecord.setType(type);
pointsRecord.setPoints(realPoint);
save(pointsRecord);
// 累加并保存总积分到redis 采用zset 当前赛季排行榜
LocalDate now = LocalDate.now();
String format = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + format;
redisTemplate.opsForZSet().incrementScore(key,msg.getUserId().toString(),realPoint);
}
语法:
Map<String, Object> map = this.getMap(wrapper); 一般只有一行数据的时候采用 getmapper
sum 聚合出来的值是bigdecimal形式
查询今日已获得的积分
groupby分组聚合,统计今日获得的积分
查询签到记录
在签到日历中,需要把本月第一天到今天为止的所有签到过的日期高亮显示。因此我们必须把签到记录返回,具体来说就是每一天是否签到的数据。是否签到,就是0或1,刚好在前端0和1代表false和true,也就是签到或没签到。
因此,每一天的签到结果就是一个0或1的数字,我们最终返回的结果是一个0或1组成的数组,对应从本月第1天到今天为止每一天的签到情况。
思路分析,还是做位运算,返回值是list还是byte都行,从第0位取,取到本月的第几天-1,对每位做与运算然后放到数组中
面试
SET:点赞中用到了set,,key是业务id,内容是每个点赞的用户id
积分中用到了zset member是每个用户ID score是积分数
注意点:
mq队列名随便写,只要routingkey匹配即可
什么时候用 getmap当结果只有一行的时候用getmap(并且使用了select()制定某个列的时候??),结果有多行用list()
mybatisplus 使用select("sum(point) as points") 并且使用getmap接收时,结果是bigdecimal类型
十进制 & 1 会自动转化为二进制,得到的结果类型看操作数的类型(可能发生自动转化?)
redistemplate和stringredistemplate区别
-
两者的数据是不共通的;也就是说StringRedisTemplate只能管理StringRedisTemplate里面的数据,RedisTemplate只能管理RedisTemplate中的数据。
StringRedisTemplate使用的是StringRedisSerializer,当你的redis数据库里面本来存的是字符串数据,或者你要存取的数据就是字符串类型数据的时候,那么你就使用StringRedisTemplate即可
枚举
springboot整合mybatisplus通用枚举(五)---@EnumValue
@EnumValue在mp中世纪取得的是其标注的值