文章目录
- 1. 需求分析
- 2. 设计思路
- 3. 用户签到和统计连续签到的次数
- 1. 签到控制层 SignController
- 2. 签到业务逻辑层 SignService
- 3. 测试
- 4. 按月统计用户签到的次数
- 1. 签到控制层 SignController
- 2. 签到业务逻辑层 SignService
- 3. 测试
- 5. 获取用户签到情况
- 1. 签到控制层 SignController
- 2. 签到业务逻辑层 SignService
- 3. 测试
Redis的 bitmaps 命令使用可以参考这篇文章:Redis实战 - 08 Redis 的 BitMaps 位图命令
1. 需求分析
在很多互联网应用中,我们会存在签到送积分、签到领取奖励等这样的需求,比如:
- 签到1天送10积分,连续签到2天送20积分,3天送30积分,4天以上均送50积分等。
- 如果连续签到中断,则重置计数,每月初重置计数。
- 显示用户某个月的签到次数。
- 在日历控件上展示用户每月签到情况,可以切换年月显示。
2. 设计思路
最简单的设计思路就是利用关系型数据库保存签到数据(t_user_sign),如下:
字段名 | 描述 |
id | 数据表主键(AUTO_INCREMENT) |
fk_diner_id | 用户ID |
sign_date | 签到日期(如2010-11-11) |
amount | 连续签到天数(如2) |
(1) 用户签到:往此表插入一条数据,并更新连续签到天数;
(2) 查询根据签到日期查询
(3) 统计根据 amount 统计
如果这样存数据的话,对于用户量比较大的应用,数据库可能就扛不住,比如1000W用户,一天一条,那么一个月就是3亿数据,这是非常庞大的,因此使用 redis 的 bitmaps 优化。
考虑到每月初需要重置连续签到次数,最简单的方式是按用户每月存一条签到数据(也可以每年存一条数据)。key 的格式为 u:sign:userid:yyyyMM
,value 则采用长度为4个字节(32位)的位图(最大月份只有31天)。位图的每一位代表一天的签到,1表示已签,0表示未签。从高位插入,也就是说左边位算是开始日期。
例如 user:sign:98:202003
表示用户 id=98 的用户在2020年3月的签到记录。那么
# 2020年3月1号签到
127.0.0.1:0>SETBIT user:sign:98:202003 0 1
"1"
# 2020年3月2号签到
127.0.0.1:0>SETBIT user:sign:98:202003 1 1
"1"
# 2020年3月3号签到
127.0.0.1:0>SETBIT user:sign:98:202003 2 1
"0"
# 2020年3月4号签到
127.0.0.1:0>SETBIT user:sign:98:202003 3 1
"1"
# 获取2020年3月4号签到情况
127.0.0.1:0>GETBIT user:sign:98:202003 3
"1"
# 统计2020年3月签到次数
127.0.0.1:0>BITCOUNT user:sign:98:202003
"4"
# 获取2020年3月首次签到
127.0.0.1:0>BITPOS user:sign:98:202003 1
"0"
# 获取2020年3月前3签到情况,返回7,二进制111,意味着前三天都签到了
127.0.0.1:0>BITFIELD user:sign:98:202003 get u3 0
"7"
3. 用户签到和统计连续签到的次数
用户签到,默认是当天,但可以通过传入日期补签,返回用户连续签到次数(后续如果有积分规则,就会返回用户此次签到积分)
1. 签到控制层 SignController
package com.hh.diners.controller;
/**
* 签到控制层
*/
@RestController
@RequestMapping("sign")
public class SignController {
/**
* 签到,可以补签
*
* @param access_token 登录凭证
* @param date 签到日期
*/
@PostMapping
public ResultInfo sign(String access_token,
@RequestParam(required = false) String date) {
int count = signService.doSign(access_token, date);
return ResultInfoUtil.buildSuccess(request.getServletPath(), count);
}
}
2. 签到业务逻辑层 SignService
- 获取登录用户信息
- 根据日期获取当前是多少号(使用BITSET指令关注时,offset从0开始计算,0就代表1号)
- 构建用户按月存储key(user:sign:用户id:月份)
- 判断用户是否签到(GETBIT指令)
- 用户签到(SETBIT)
- 返回用户连续签到次数(BITFIELD key GET [u/i] type offset value, 获取从用户从当前日期开始到1号的所有签到状态,然后进行位移操作,获取连续签到天数)
/**
* 签到业务逻辑层
*/
@Service
public class SignService {
@Value("${service.name.ms-oauth-server}")
private String oauthServerName;
@Resource
private RestTemplate restTemplate;
@Resource
private RedisTemplate redisTemplate;
/**
* 用户签到
*
* @param accessToken 登录凭证
* @param dateStr 签到日期 2022-10-14
*/
public int doSign(String accessToken, String dateStr) {
// 获取登录用户信息
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
// 获取日期
Date date = getDate(dateStr);
// 获得指定日期是所在月份的第几天:2020-10-14,返回14,代表这个月份的第14天
int dayOfMonth = DateUtil.dayOfMonth(date);
// 偏移量 offset 从 0 开始
int offset = dayOfMonth - 1;
// 构建 Key user:sign:5:yyyyMM
String signKey = buildSignKey(dinerInfo.getId(), date);
// 查看是否已签到
boolean isSigned = redisTemplate.opsForValue().getBit(signKey, offset);
AssertUtil.isTrue(isSigned, "当前日期已完成签到,无需再签");
// 签到
redisTemplate.opsForValue().setBit(signKey, offset, true);
// 统计连续签到的次数
int count = getContinuousSignCount(dinerInfo.getId(), date);
return count;
}
/**
* 统计连续签到的次数
*/
private int getContinuousSignCount(Integer dinerId, Date date) {
// 获取日期对应的天数,多少号,假设是 30
int dayOfMonth = DateUtil.dayOfMonth(date);
// 构建 Key
String signKey = buildSignKey(dinerId, date);
// bitfield user:sgin:5:202011 u30 0
BitFieldSubCommands bitFieldSubCommands
= BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
.valueAt(0);
List<Long> list = redisTemplate.opsForValue().bitField(signKey, bitFieldSubCommands);
if (list == null || list.isEmpty()) {
return 0;
}
int signCount = 0;
long v = list.get(0) == null ? 0 : list.get(0);
for (int i = dayOfMonth; i > 0; i--) {
// i 表示位移操作次数
// 右移再左移,如果等于自己说明最低位是 0,表示未签到
if (v >> 1 << 1 == v) {
// 低位 0 且非当天说明连续签到中断了
if (i != dayOfMonth) {
break;
}
} else {
signCount++;
}
// 右移一位并重新赋值,相当于把最低位丢弃一位
v >>= 1;
}
return signCount;
}
/**
* 构建 Key -- user:sign:5:yyyyMM
*
* @param dinerId 用户
* @param date 签到日期
* @return redis key
*/
private String buildSignKey(Integer dinerId, Date date) {
return String.format("user:sign:%d:%s", dinerId, DateUtil.format(date, "yyyyMM"));
}
/**
* 获取日期
*
* @param dateStr 日期字符串
* @return 日期格式
*/
private Date getDate(String dateStr) {
if (StrUtil.isBlank(dateStr)) {
return new Date();
}
try {
return DateUtil.parseDate(dateStr);
} catch (Exception e) {
throw new ParameterException("请传入yyyy-MM-dd的日期格式");
}
}
/**
* 获取登录用户信息
*/
private SignInDinerInfo loadSignInDinerInfo(String accessToken) {
// 必须登录
AssertUtil.mustLogin(accessToken);
String url = oauthServerName + "user/me?access_token={accessToken}";
ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
throw new ParameterException(resultInfo.getCode(), resultInfo.getMessage());
}
SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
new SignInDinerInfo(), false);
if (dinerInfo == null) {
throw new ParameterException(ApiConstant.NO_LOGIN_CODE, ApiConstant.NO_LOGIN_MESSAGE);
}
return dinerInfo;
}
}
如何统计连续签到的次数:
3. 测试
http://localhost/diners/sign?access_token=29394097-c480-4b5f-ac56-7e7384b32cf6
4. 按月统计用户签到的次数
用户需求:统计某月签到次数,默认是当月
1. 签到控制层 SignController
/**
* 签到控制层
*/
@RestController
@RequestMapping("sign")
public class SignController {
@Resource
private SignService signService;
@Resource
private HttpServletRequest request;
/**
* 获取签到次数 默认当月
*
* @param access_token 用户凭证
* @param date 日期
* @return ResultInfo
*/
@GetMapping("count")
public ResultInfo getSignCount(String access_token, String date) {
Long count = signService.getSignCount(access_token, date);
return ResultInfoUtil.buildSuccess(request.getServletPath(), count);
}
}
2. 签到业务逻辑层 SignService
/**
* 签到业务逻辑层
*/
@Service
public class SignService {
@Value("${service.name.ms-oauth-server}")
private String oauthServerName;
@Resource
private RestTemplate restTemplate;
@Resource
private RedisTemplate redisTemplate;
/**
* 获取用户签到次数
*
* @param accessToken
* @param dateStr
* @return
*/
public long getSignCount(String accessToken, String dateStr) {
// 获取登录用户信息
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
// 获取日期
Date date = getDate(dateStr);
// 构建 Key
String signKey = buildSignKey(dinerInfo.getId(), date);
// e.g. BITCOUNT user:sign:5:202011
return (Long) redisTemplate.execute(
(RedisCallback<Long>) con -> con.bitCount(signKey.getBytes())
);
}
}
3. 测试
http://localhost/diners/sign/count?access_token=29394097-c480-4b5f-ac56-7e7384b32cf6 或
http://localhost/diners/sign/count?access_token=29394097-c480-4b5f-ac56-7e7384b32cf6&date=2022-10-02
5. 获取用户签到情况
获取用户某月签到情况,默认当前月,返回当前月的所有日期以及该日期的签到情况
1. 签到控制层 SignController
/**
* 签到控制层
*/
@RestController
@RequestMapping("sign")
public class SignController {
@Resource
private SignService signService;
@Resource
private HttpServletRequest request;
/**
* 获取用户签到情况 默认当月
*
* @param access_token 登录凭证
* @param dateStr 签到日期
*/
@GetMapping
public ResultInfo getSignInfo(String access_token, String dateStr) {
Map<String, Boolean> map = signService.getSignInfo(access_token, dateStr);
return ResultInfoUtil.buildSuccess(request.getServletPath(), map);
}
}
2. 签到业务逻辑层 SignService
获取某月签到情况,默认当月
- 获取登录用户信息
- 构建Redis保存的Key
- 获取月份的总天数(考虑2月闰、平年)
- 通过BITFIELD指令获取当前月的所有签到数据
- 遍历进行判断是否签到,并存入TreeMap方便排序
/**
* 签到业务逻辑层
*/
@Service
public class SignService {
@Value("${service.name.ms-oauth-server}")
private String oauthServerName;
@Resource
private RestTemplate restTemplate;
@Resource
private RedisTemplate redisTemplate;
/**
* 获取当月签到情况
*/
public Map<String, Boolean> getSignInfo(String accessToken, String dateStr) {
// 获取登录用户信息
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
// 获取日期
Date date = getDate(dateStr);
// 构建 Key
String signKey = buildSignKey(dinerInfo.getId(), date);
// 构建一个自动排序的 Map
Map<String, Boolean> signInfo = new TreeMap<>();
// 获取月份,从0开始:0-11
int month = DateUtil.month(date);
// 是否是闰年
boolean leapYear = DateUtil.isLeapYear(DateUtil.year(date));
// 获取某月的总天数(考虑闰年)
int dayOfMonth = DateUtil.lengthOfMonth(month+ 1,leapYear);
// bitfield user:sign:5:202011 u30 0
BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
.valueAt(0);
// 从偏移量offset=0开始取dayOfMonth位,获取无符号整数的值
List<Long> list = redisTemplate.opsForValue().bitField(signKey, bitFieldSubCommands);
if (list == null || list.isEmpty()) {
return signInfo;
}
long v = list.get(0) == null ? 0 : list.get(0);
// 从低位到高位进行遍历,为 0 表示未签到,为 1 表示已签到
for (int i = dayOfMonth; i > 0; i--) {
/*
签到: yyyy-MM-01 true
未签到:yyyy-MM-01 false
*/
LocalDateTime localDateTime = LocalDateTimeUtil.of(date).withDayOfMonth(i);
// 先右移再左移,如果不等于自己说明签到,否则未签到
boolean flag = v >> 1 << 1 != v;
// 存放当月每天的签到情况
signInfo.put(DateUtil.format(localDateTime, "yyyy-MM-dd"), flag);
v >>= 1;
}
return signInfo;
}
}
3. 测试
http://localhost/diners/sign?access_token=29394097-c480-4b5f-ac56-7e7384b32cf6 或
http://localhost/diners/sign?access_token=29394097-c480-4b5f-ac56-7e7384b32cf6&date=2022-10-01