文章目录

  • 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"

redis实现考勤打卡 redis签到功能_redis

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;
    }
}

如何统计连续签到的次数:

redis实现考勤打卡 redis签到功能_业务逻辑_02

3. 测试

http://localhost/diners/sign?access_token=29394097-c480-4b5f-ac56-7e7384b32cf6

redis实现考勤打卡 redis签到功能_java_03

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

redis实现考勤打卡 redis签到功能_java_04

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-01redis实现考勤打卡 redis签到功能_java_05