1.技术选型
Redis主写入查询,Mysql辅助查询,传统签到多数都是直接采用mysql为存储DB,在大数据的情况下数据库的压力较大.查询速率也会随着数据量增大而增加.所以在需求定稿以后查阅了很多签到实现方式,发现用redis做签到会有很大的优势.本功能主要用到redis位图
2.功能实现
1
签到流程(签到,补签,连续,签到记录)
2
签到任务(每日任务,固定任务)
3.表设计
## 用户积分总表
CREATE TABLE `t_user_integral` (
`id` varchar(50) NOT NULL COMMENT 'id',
`user_id` int(11) NOT NULL COMMENT '用户id',
`integral` int(16) DEFAULT '0' COMMENT '当前积分',
`integral_total` int(16) DEFAULT '0' COMMENT '累计积分',
`create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='用户积分总表'
## 积分流水表
CREATE TABLE `t_user_integral_log` (
`id` varchar(50) NOT NULL COMMENT 'id',
`user_id` int(11) NOT NULL COMMENT '用户id',
`integral_type` int(3) DEFAULT NULL COMMENT '积分类型 1.签到 2.连续签到 3.福利任务 4.每日任务 5.补签',
`integral` int(16) DEFAULT '0' COMMENT '积分',
`bak` varchar(100) DEFAULT NULL COMMENT '积分补充文案',
`operation_time` date DEFAULT NULL COMMENT '操作时间(签到和补签的具体日期)',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='用户积分流水表'
4.Redis位图Key的设计
//人员签到位图key,一个位图存一个用户一年的签到状态,以userSign为标识,后面的两个参数是今年的年份和用户的id
public final static String USER_SIGN_IN = "userSign:%d:%d";
//人员补签key,一个Hash列表存用户一个月的补签状态,以userSign:retroactive为标识,后面的两个参数是当月的月份和用户的id
public final static String USER_RETROACTIVE_SIGN_IN = "userSign:retroactive:%d:%d";
//人员签到总天数key,以userSign:count为标识,后面的参数是用户的id
public final static String USER_SIGN_IN_COUNT = "userSign:count:%d";
5.实现签到
5.1controller
@ApiOperation("用户签到")
@PostMapping("/signIn")
@LoginValidate
public ResponseResult saveSignIn(@RequestHeader Integer userId) {
return userIntegralLogService.saveSignIn(userId);
}
5.2service
1
创建一个 ResponseResult 对象,用于统一返回结果。
2
使用 String.format 方法拼接出该用户的签到位图在 Redis 中的 Key,格式为 USER_SIGN_IN:年份:用户ID。
3
获取当前日期的月份和日期,转换为格式为 MMdd 的长整型数字,作为位图的偏移点。
4
设置默认的响应结果信息为 “今日已签到”,并将 code 设置为 -1。
5
使用 cacheClient.getBit 方法检查用户今日是否已经签到过。如果返回值为 false,表示用户今日尚未签到。
6
执行 cacheClient.setbit 方法设置位图中对应位置为 1,表示用户今日已签到。该方法会返回设置前的位图值。
7
如果之前没有签到过(即 oldResult 为 false),则计算该用户本月至今天的连续签到天数,调用 getContinuousSignCount 方法。
8
执行 doSaveUserIntegral 方法,记录用户的签到积分类型以及连续签到积分。具体实现不在代码段中。
将响应结果的 code 设置为 0,表示签到成功。
9
返回最终的响应结果。
public ResponseResult saveSignIn(Integer userId) {
//这里是我们的公司统一返回类
ResponseResult responseResult = ResponseResult.newSingleData();
//用String.format拼装好单个用户的位图key
String signKey = String.format(RedisKeyConstant.USER_SIGN_IN, LocalDate.now().getYear(), userId);
//位图的偏移点为当天的日期,如今天,偏移值就是1010
long monthAndDay = Long.parseLong(LocalDate.now().format(DateTimeFormatter.ofPattern("MMdd")));
responseResult.setMessage("今日已签到");
responseResult.setCode((byte) -1);
//检测是否用户今日签到过,用getBit可以取出该用户具体日期的签到状态(位图的值只有两个,1或者0,这里1代表true)
if (!cacheClient.getBit(signKey, monthAndDay)) {
//位图的set方法会返回该位图未改变前的数值,这里如果之前没有签到过默认是0,也就是false
boolean oldResult = cacheClient.setbit(signKey, monthAndDay);
if (!oldResult) {
//计算出这个月该用户的到今天的连续签到天数,此方法参照下方计算连续签到天数的代码块
int signContinuousCount = getContinuousSignCount(userId);
//此方法参照下方记录签到积分类型以及连续签到积分代码块
doSaveUserIntegral(userId, signContinuousCount);
responseResult.setCode((byte) 0);
}
}
return responseResult;
}
5.3连续签到
流程:
1.本质就是取出位图一个偏移值区间内
的值,区间起始值为当月的第一天,范围为当月的总天数(BITFIELD命令)——>2.如果没有签到呢默认0,若是连续签到则将得到的long值右移一位再左移一位,若是不相等signCount+1;
1
首先,根据传入的用户 ID 和当前时间,生成用户在 Redis 中保存签到信息的 Key,然后从 Redis 中获取该用户在本月内的签到信息的位图。
2
接着,遍历该位图,从第一天到当前日期,判断该用户在每一天是否进行了签到。位图中每一个值都表示一天是否签到,如果为 1 表示签到,为 0 则表示没有签到。
3
在遍历位图的过程中,如果某一天没有签到,则返回当前的连续签到天数。如果该用户从本月的第一天开始一直到当前日期都进行了签到,则返回当前日期所在的连续签到天数。
如果该用户在本月尚未进行过签到,则返回连续签到天数为 0。
如何判断连续签到?
1
通过遍历位图的方式,从第一天到当前日期(date.getDayOfMonth()),依次检查每一天的签到情况。
2
对于每一天的检查,首先从位图中获取对应的值,即 list.get(i)。如果该值为 null,则默认设置为 0,表示该天没有进行签到。
3
判断获取到的值 v 是否满足连续签到的条件:右移一位再左移一位后与原始值不相等。如果相等,说明当前位为 0,表示当天没有签到,返回当前的连续签到天数。
4
如果满足连续签到的条件,将连续签到天数 signCount 加一。
5
将值 v 右移一位,用于下一天的判断。
遍历完所有的天数后,返回连续签到天数 signCount。
private int getContinuousSignCount(Integer userId) {
int signCount = 0;
LocalDate date = LocalDate.now();
String signKey = String.format(RedisKeyConstant.USER_SIGN_IN, date.getYear(), userId);
//这里取出的是位图一个偏移值区间的值,区间起始值为当月的第一天,范围值为当月的总天数(参考命令bitfield)
List<Long> list = cacheClient.getBit(signKey, date.getMonthValue() * 100 + 1, date.getDayOfMonth());
if (list != null && list.size() > 0) {
//可能该用户这个月就没有签到过,需要判断一下,如果是空就给一个默认值0
long v = list.get(0) == null ? 0 : list.get(0);
for (int i = 0; i < date.getDayOfMonth(); i++) {
//如果是连续签到得到的long值右移一位再左移一位后与原始值不相等,连续天数加一(相等说明当前位为0没有签到过了)
if (v >> 1 << 1 == v) return signCount;
signCount += 1;
v >>= 1;
}
}
return signCount;
}
5.4记录积分类型和连续签到的积分
public Boolean doSaveUserIntegral(int userId, int signContinuousCount) {
int count = 0;
//叠加签到次数
cacheClient.incrValue(String.format(RedisKeyConstant.USER_SIGN_IN_COUNT, userId));
List<UserIntegralLog> userIntegralLogList = new LinkedList<>();
userIntegralLogList.add(UserIntegralLog.builder()
.createTime(LocalDateTime.now())
.operationTime(LocalDate.now())
.bak(BusinessConstant.Integral.NORMAL_SIGN_COPY)
.integral(BusinessConstant.Integral.SIGN_TYPE_NORMAL_INTEGRAL)
.integralType(BusinessConstant.Integral.SIGN_TYPE_NORMAL)
.userId(userId)
.build());
count += BusinessConstant.Integral.SIGN_TYPE_NORMAL_INTEGRAL;
//连续签到处理,获取缓存配置连续签到奖励
//因为每个月的天数都不是固定的,连续签到奖励是用的redis hash写入的.所以这个地方用32代替一个月的连续签到天数,具体配置在下方图中
if (signContinuousCount == LocalDate.now().lengthOfMonth()) {
signContinuousCount = 32;
}
Map<String, String> configurationHashMap = cacheClient.hgetAll("userSign:configuration");
String configuration = configurationHashMap.get(signContinuousCount);
if (null != configuration) {
int giveIntegral = 0;
JSONObject item = JSONObject.parseObject(configuration);
giveIntegral = item.getInteger("integral");
if (giveIntegral != 0) {
if (signContinuousCount == 32) {
signContinuousCount = LocalDate.now().lengthOfMonth();
}
userIntegralLogList.add(UserIntegralLog.builder()
.createTime(LocalDateTime.now())
.bak(String.format(BusinessConstant.Integral.CONTINUOUS_SIGN_COPY, signContinuousCount))
.integral(giveIntegral)
.integralType(BusinessConstant.Integral.SIGN_TYPE_CONTINUOUS)
.userId(userId)
.build());
count += giveIntegral;
}
}
//改变总积分和批量写入积分记录
return updateUserIntegralCount(userId, count) && userIntegralLogService.saveBatch(userIntegralLogList);
}
6.补签
补签功能是一个签到补充功能,主要就是方便用户在忘了签到的情况下也能通过补签功能达到相应的连续签到条件,从而得到奖励.
这段代码是一个补签功能的后端实现,主要用于用户在签到平台上进行补签操作。我来详细解释一下它的业务流程:
1
首先,根据传入的用户ID和需要补签的日期信息,判断今日是否需要进行补签。如果不需要补签,则返回相应的提示信息并结束流程。
2
接着,从 Redis 中获取用户当月已经补签的次数,如果已经达到三次补签上限,则返回相应的提示信息,并将结果设置为失败。
3
然后,检查用户的积分情况,确保用户的积分足够进行本次补签所需的积分消耗。如果积分不足,则返回相应的提示信息,并将结果设置为失败。
4
如果前面的步骤都通过了,那么表示用户可以进行补签操作。在这里会先构建补签日期的 LocalDate 对象,并生成相应的 Redis Key 来存储用户的签到信息。
5
在进行补签前,会对用户今日是否已经签到过进行检测,同时也会检测补签的日期是否大于今天的日期。如果通过检测,则将用户的签到信息在 Redis 中进行设置。
6
如果补签成功,将补签的记录存入 Redis,同时更新用户的积分信息,并返回补签成功的提示信息。
整体来说,这段代码主要是通过 Redis 存储用户的签到信息及补签记录,并结合用户的积分情况来进行补签操作的逻辑。
//day表示需要补签的日期,因为我们平台的签到周期是一个月所以只需要传日的信息就可以,入 7号传入7
public ResponseResult saveSignInRetroactive(Integer userId, Integer day) {
Boolean result = Boolean.TRUE;
ResponseResult responseResult = ResponseResult.newSingleData();
responseResult.setMessage("今日无需补签哟");
responseResult.setCode((byte) -1);
LocalDate timeNow = LocalDate.now();
//检测是否补签达上限
String retroactiveKey = String.format(RedisKeyConstant.USER_RETROACTIVE_SIGN_IN, timeNow.getMonthValue(), userId);
//从redis中取出用户的当月补签的集合set.我们平台的限制是三次补签
Set<String> keys = cacheClient.hkeys(retroactiveKey);
if (CollUtil.isNotEmpty(keys) && keys.size() == 3) {
responseResult.setMessage("本月补签次数已达上限");
result = Boolean.FALSE;
}
//检查补签积分是否足够,这里就是一个简单的单表查询,用于查询积分是否足够本次消耗
UserIntegral userIntegral = userIntegralService.getOne(new LambdaQueryWrapper<UserIntegral>().eq(UserIntegral::getUserId, userId));
//这里只是简单的做了一个map放置三次补签分别消耗的积分(key:次数 value:消耗积分),也可参照之前连续签到配置放入redis缓存中便于后台管理系统可配置
Integer reduceIntegral = getReduceIntegral().get(keys.size() + 1);
if (reduceIntegral > userIntegral.getIntegral()) {
responseResult.setMessage("您的橙汁值不足");
result = Boolean.FALSE;
}
if (result) {
LocalDate retroactiveDate = LocalDate.of(timeNow.getYear(), timeNow.getMonthValue(), day);
String signKey = String.format(RedisKeyConstant.USER_SIGN_IN, timeNow.getYear(), userId);
long monthAndDay = Long.parseLong(retroactiveDate.format(DateTimeFormatter.ofPattern("MMdd")));
//后端检测是否用户今日签到过同时补签日期不可大于今天的日期
if (!cacheClient.getBit(signKey, monthAndDay) && timeNow.getDayOfMonth() > day) {
boolean oldResult = cacheClient.setbit(signKey, monthAndDay);
if (!oldResult) {
//补签记录(:月份) 过月清零,过期时间是计算出当前时间的差值,补签次数是一个月一刷新的
cacheClient.hset(retroactiveKey, retroactiveDate.getDayOfMonth() + "", "1",
(Math.max(retroactiveDate.lengthOfMonth() - retroactiveDate.getDayOfMonth(), 1)) * 60 * 60 * 24);
//这里就是对积分总表减少.以及对积分记录进行记录.参照下方代码块
doRemoveUserIntegral(userId, reduceIntegral, RETROACTIVE_SIGN_COPY);
responseResult.setCode((byte) 0);
responseResult.setMessage("补签成功");
}
}
}
return responseResult;
}
积分总表和积分流水表的变动
public Boolean doRemoveUserIntegral(int userId, int reduceIntegral, String bak) {
return updateUserIntegralCount(userId, -reduceIntegral)
&& userIntegralLogService.save(UserIntegralLog.builder()
.createTime(LocalDateTime.now())
.operationTime(LocalDate.now())
.bak(bak)
.integral(-reduceIntegral)
.integralType(BusinessConstant.Integral.RETROACTIVE_SIGN_COPY.equals(bak) ?
BusinessConstant.Integral.SIGN_TYPE_RETROACTIVE : BusinessConstant.Integral.SIGN_TYPE_WELFARE)
.userId(userId)
.build());
}