一、引言
打卡功能对于一个系统来说还算是挺重要的,我们可以通过用户打卡来给用户一些激励,也可以提高我们系统的用户活跃度。
因此这篇文章就介绍一下我对打卡功能的一个设计流程。
二、功能
一般打卡功能需要包括三个部分:每日签到、补签、历史签到记录。
除了上述三个功能之外,我们还需要在此基础上得到最大签到和连续签到两个指标。
三、设计
对于上述功能,我们应该采用什么方式来进行打卡记录的存储呢?
- 直接用户打卡一次,就存储一条打卡记录
- 因为打卡就两种状态,使用long来进行存储
一般来说就这两种,我们来分析一下这两种存储方式的优缺点,假如我们不引入其他的中间键例如redis:
直接存储的优点:在数据库中查找插入签到记录很方便。
直接存储的缺点:数据量大,打卡记录可以更加简便的存储。
long存储的优点:数据量小,可以很方便的根据月份来进行划分。
long存储的缺点:mysql对long的支持不太够,每次查询和插入需要对long值进行转换后才能得到数据(使用redis后可以大幅度减小这种情况)。
综上所述,我们使用long来进行存储,配合Redis来提示效率。
1、打卡
已经确定了存储方案,我们可以来进行一个功能的设计了,首先我们知道Redis中有一个数据类型叫Bitmap,它可以储存bit,并且它可以很快的给我们返回指定索引位置的情况,而且插入也很方便。我们可以根据月份来进行存储,打卡bit就设置成1,否则为0。
具体代码如下:
// 前端传递的数据类似于 "2024-08-16",所以这个需要进行转化
Date date = getDate(dateStr);
int day = DateUtil.dayOfMonth(date) - 1; // bitmap从0开始
String key = StrUtil.format("sign:{}:{}:{}",openId
,DateUtil.format(date,"yyyy")
,DateUtil.format(date,"MM"));
// 操作Bitmap
RBitSet bitSet = redisService.getBitSet(key);
// 获取某月的总天数
int dayOfMonth = DateUtil.lengthOfMonth(DateUtil.month(date) + 1
,DateUtil.isLeapYear(DateUtil.year(date)));
// 判断该日期是否已经签到
boolean isSign = bitSet.get(day);
if (isSign) {
return SignTypeVO.REPEAT_SIGN;
}
boolean sign = bitSet.set(day);
// 这个方法返回一个long,需要持久化到数据库
long unsigned = bitSet.getUnsigned(dayOfMonth, 0);
// 签到记录保存,将redis中的数据转换成long存储到数据库
saveSignRecord(openId,date,unsigned);
// 这个方法就是进行String -> Date的方法,
private Date getDate(String dateStr) {
return StrUtil.isBlank(dateStr)
? Date.valueOf(new Date(System.currentTimeMillis()).toString())
: new Date(DateUtil.parseDate(dateStr).getTime());
}
注意:
我这里使用Date是java.sql.Date,他的java.util.Date的一个子类,它默认是没有时间的,只有日期,如果业务中需要精确到秒,那可能需要考虑使用java.util.Date。
我这里使用的Redission来操作Redis,如果使用其他库可以方法不太一致。
2、补签
补签和今日签到其实流程差不多,这里就不过多介绍。
3、获取签到记录
public List<String> getSignRecords(String openId, String dateStr) {
Date date = getDate(dateStr);
// 因为是按月份进行储存的,所以要转换成 "2024-08"这种格式
String key = StrUtil.format("sign:{}:{}:{}",openId
,DateUtil.format(date,"yyyy")
,DateUtil.format(date,"MM"));
RBitSet bitSet = redisService.getBitSet(key);
List<String> records = new ArrayList<>();
// 获取某月的总天数
int dayOfMonth = DateUtil.lengthOfMonth(DateUtil.month(date) + 1
,DateUtil.isLeapYear(DateUtil.year(date)));
long unsigned = bitSet.getUnsigned(dayOfMonth, 0);
//这里还可以做判断,假如redis失败了,可以去mysql中获取,看选择,我这里就不写了
if (unsigned == 0) {
// 为0则代表无任何数据
return records;
}
// 这里开始将bit转换成对应的日期
for (int i = dayOfMonth; i > 0 ; i--) {
if (unsigned >> 1 << 1 != unsigned) {
LocalDateTime localDateTime = LocalDateTimeUtil.of(date.getTime()).withDayOfMonth(i);
records.add(DateUtil.format(localDateTime,"yyyy-MM-dd"));
}
unsigned >>= 1;
}
return records;
}
这里转换的原理:
将long右移动1位,在像左移动一位,假如当前移动的位是1,那边左移回来变成0,那么就不会相等,进行添加,如此往复,就能得到我这个月的一个情况。
4、获取总签到数和连续签到数
获取总签到数很简单,我们不可能每次都去进行统计,我们可以在我们的用户表中加一个总签到数的字段,每次签到或者补签就上1就像,后续直接去获取这个字段即可。
获取连续签到数就复杂一些,我们不可能每次获取连续签到记录,就去通过位运算统计,这样太恐怖了,所以这里我通过设计一张表来进行处理,这张表包括四个字段id、start_time、end_time、sign_length,这张表主要就是来记录我们每一段连续签到的信息。
当我们今日签到,我们根据我们end_time = 今天的日期-1去连续签到表中进行查询,是否存在连续签到记录,存在我们就将这个记录的end_time 修改成我们今天的日期,长度+1,不存在我就新插入一条记录,开始时间和结束时间都为今天的日期,长度为1。
我我们补签的时候,判断的情况就会多一些,我们要查询两个数据,一个就是当start_time = 今天的日期+1(记录1) ,另一个就是end_time=今天的日期-1(记录2)。
- 记录1==null && 记录2==null:这时候我们需要插入一条新记录,开始时间和结束时间都为补签的日期,长度为1。
- 记录1!=null && 记录2==null:这时候我们需要将记录1的start_time 设置成补签的日期,长度+1
- 记录1==null && 记录2!=null:这时候我们需要将记录1的end_time设置成补签的日期,长度+1
- 记录1!=null && 记录2!=null:这时候我们需要将这两个记录给连接起来,我们可以创建一个新记录,开始时间为记录2的start_time ,结束时间为记录1的end_time,长度为两个记录长度总和+1,让后将这两个记录删除。
有了这个表了,我们获取我们的连续签到记录就很容易了,这里也有两种情况。
- 根据redis判断今天是否签到,签到就根据end_time=今天的日期去连续签到记录表中进行查询,得到长度返回即可
- 假如今天没签到就需要根据end_time=今天的日期-1去连续签到记录表中进行查询长度。
- 假如上面流程都为null,这时候就代表我们的签到断开了,我们直接返回0即可。
这样是不是比去根据一个个的long来判断更加快,虽然在连续签到短的时候到是通过long来判断不影响,但是当连续签到很长,我们需要先获取这个月的连续签到,假如1号还没短,我们还需要继续去上一个月重复同样操作,这样花儿都谢了。通过我们这样,只需要查询一次数据库即可。
四、总结
上述基本上就是打卡的一个总设计了,只是在细节上的优化可能不太够,比如,
1、我们的redis不可用了,我们应该去到数据库获取long来进行操作。
2、我们在进行签到补签的时候假如插入redis或者插入数据库有一个失败了怎么办,这里可以取捕获redis抛出来的异常,或者数据库更新条数来判断给用户返回什么。
3、崩溃后Redis数据怎么恢复,看什么情况了,假如是个人项目,你可以重启弄一个缓存预热,在将数据加载进Redis中,假如是企业里面,那应该做一些集群之类的部署,开一个定时任务不定期去将mysql中的数据加载进Redis。