第五章--美食社交--签到服务

业务需求分析

一般类似微博等,各种社交软件,游戏APP等等互联网应用, 都会有一个签到功能,签到送积分、签到领取奖励等需求,比如 :

  • 签到1天送10积分,连续签到2天送20积分,3天送30积分,4天以上均送50积分等。
  • 如果连续签到中断,则重置计数,每月初重置计数。
  • 显示用户某个月的签到次数。
  • 在日历控件上展示用户每月签到情况,可以切换年月显示。


设计思路

数据库解决

关系型数据库保存 签到数据(t_user_sign)

字段dec
id数据表主键(AUTO_INCREMENT)
fk_diner_id用户 ID
sign_date签到日期 (比如 2023-01-01)
amount连续签到天数 (如4)
  • 用户签到:往此表插入一条数据,并更新连续签到天数;
  • 查询根据签到日期查询
  • 统计根据amount统计

如果这样存数据的话,对于用户量比较大的应用,数据库可能就扛不住,比如1000W用户,一天一条,那么一个月就是3亿数据,这是非常庞大的。
那怎么优化呢?

使用Redis的BitMaps完成

Bitmaps叫位图,它不是Redis的基本数据类型(比如Strings、Lists、Sets、Hashes这类实际的数据类型),而是基于String数据类型的按位操作,高阶数据类型的一种。Bitmaps支持的最大位数是232位。使用512M内存就可以存储多达42.9亿的字节信息(232 = 4,294,967,296)

它是由一组bit位组成的,每个bit位对应0和1两个状态,虽然内部还是采用String类型存储,但Redis提供了一些指令用于直接操作位图,可以把它看作是一个bit数组,数组的下标就是偏移量。它的优点是内存开销小、效率高且操作简单,很适合用于签到这类场景。比如按月进行存储,一个月最多31天,那么我们将该月用户的签到缓存二进制就是00000000000000000000000000000000,当某天签到将0改成1即可,而且Redis提供对bitmap的很多操作比如存储、获取、统计等指令,使用起来非常方便

BitMaps常用指令

考虑到每月初需要重置连续签到次数,最简单的方式是按用户每月存一条签到数据(也可以每年存一条数据)。Key的格式为 u:sign:userid:yyyyMM ,Value则采用长度为4个字节(32位)的位图(最大月份只有31天)。位图的每一位代表一天的签到,1表示已签,0表示未签。从高位插入,也就是说左边位算是开始日期。

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

image.png

功能开发

用户签到,可补签

需求说明

用户签到,默认是当天,但可以通过传入日期补签,返回用户连续签到次数(后续如果有积分规则,就会返回用户此次签到积分)

代码实现

SignService层关注方法

  1. 获取登录用户信息
  2. 根据日期获取当前是多少号(使用BITSET指令关注时,offset从0开始计算,0就代表1号)
  3. 构建用户按月存储key(user:sign:用户id:月份)
  4. 判断用户是否签到(GETBIT指令)
  5. 用户签到(SETBIT)
  6. 返回用户连续签到次数(BITFIELD key GET [u/i] type offset value, 获取从用户从当前日期开始到1号的所有签到状态,然后进行位移操作,获取连续签到天数)
package com.lezijie.diners.service;



/**
 * 签到服务 Service
 */
@Service
public class SignService {

    @Value("${service.name.fs-oauth-server}")
    private String oauthServerName;
    @Value("${service.name.fs-points-server}")
    private String pointsServerName;
    @Resource
    private RestTemplate restTemplate;
    @Resource
    private RedisTemplate redisTemplate;

    /**
     * 用户签到/可以补签
     *
     * @param accessToken 登录用户 token
     * @param dateStr     日期,默认当天
     * @return 连续签到次数
     */
    public int doSign(String accessToken, String dateStr) {
        // 获取登录用户信息
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // 获取日期
        Date date = getDate(dateStr);
        // 获取日期对应的天数,多少号
        int offset = DateUtil.dayOfMonth(date) - 1; // 从 0 开始
        // 构建 Redis Key
        String signKey = buildSignKey(dinerInfo.getId(), date);
        // 查看指定日期是否已签到
        boolean isSigned = redisTemplate.opsForValue().getBit(signKey, offset);
        AssertUtil.isTrue(isSigned, "当前日期已完成签到,无需再签");
        // 签到
        redisTemplate.opsForValue().setBit(signKey, offset, true);
        // 统计连续签到次数
        // 根据当前日期统计
        date = new Date();
        int count = getContinuousSignCount(dinerInfo.getId(), date);
        // 用户签到成功添加对应积分
        int points = addPoints(count, dinerInfo.getId());
        return points;
    }

    /**
     * 获取登录用户信息
     *
     * @param accessToken
     * @return
     */
    private SignInDinerInfo loadSignInDinerInfo(String accessToken) {
        // 是否有 accessToken
        AssertUtil.mustLogin(accessToken);
        // 拼接远程请求 url
        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);
        return dinerInfo;
    }

    /**
     * 获取日期
     *
     * @param dateStr
     * @return
     */
    private Date getDate(String dateStr) {
        if (StrUtil.isBlank(dateStr)) {
            return new Date();
        }
        try {
            return DateUtil.parse(dateStr);
        } catch (Exception e) {
            throw new ParameterException("请传入yyyy-MM-dd的日期格式");
        }
    }

    /**
     * 构建 Redis Key
     * e.g. user:sign:1:202108 11110001010010101010
     *
     * @param dinerId
     * @param date
     * @return
     */
    private String buildSignKey(Integer dinerId, Date date) {
        return String.format("user:sign:%d:%s", dinerId,
                DateUtil.format(date, "yyyyMM"));
    }

    /**
     * 统计连续签到次数
     *
     * @param dinerId
     * @param date
     * @return
     */
    private int getContinuousSignCount(Integer dinerId, Date date) {
        // 获取日期对应的天数,多少号
        int dayOfMonth = DateUtil.dayOfMonth(date); // BitField 左闭右开
        // 构建 Redis Key
        String signKey = buildSignKey(dinerId, date);
        // 命令:bitfield key get [u/i]dayOfMonth offset
        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);
        // 取低位连续不为 0 的个数即为连续签到次数,需考虑当天尚未签到的情况
        for (int i = dayOfMonth; i > 0; i--) {
            /*
                  原:1111000111
                右移:0111100011
              再左移:1111000110
              右移再左移如果不等于自己,表示低位是 1,签到,计数器++
              右移再左移如果等于自己,表示低位是 0,未签到
             */
            if (v >> 1 << 1 == v) {
                // 低位为 0 且非当天说明连续签到中断了
                if (i != dayOfMonth) {
                    break;
                }
            } else {
                // 签到了,计数器++
                signCount++;
            }
            // 右移一位并重新赋值,相当于把最右边一位去掉
            v >>= 1;
        }
        return signCount;
    }


}

统计用户签到次数

获取用户某月签到情况,默认当前月,返回当前月的所有日期以及该日期的签到情况

SignService方法统计
package com.itkaka.diners.service;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.StrUtil;
import com.itkaka.commons.constant.ApiConstant;
import com.itkaka.commons.constant.PointTypesConstant;
import com.itkaka.commons.exception.ParameterException;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.model.vo.SignInDinerInfo;
import com.itkaka.commons.utils.AssertUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.BitFieldSubCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.*;

/**
 * 签到服务 Service
 */
@Service
public class SignService {

    @Value("${service.name.fs-oauth-server}")
    private String oauthServerName;
    @Value("${service.name.fs-points-server}")
    private String pointsServerName;
    @Resource
    private RestTemplate restTemplate;
    @Resource
    private RedisTemplate redisTemplate;

    /**
     * 用户签到/可以补签
     *
     * @param accessToken 登录用户 token
     * @param dateStr     日期,默认当天
     * @return 连续签到次数
     */
    public int doSign(String accessToken, String dateStr) {
        // 获取登录用户信息
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // 获取日期
        Date date = getDate(dateStr);
        // 获取日期对应的天数,多少号
        int offset = DateUtil.dayOfMonth(date) - 1; // 从 0 开始
        // 构建 Redis Key
        String signKey = buildSignKey(dinerInfo.getId(), date);
        // 查看指定日期是否已签到
        boolean isSigned = redisTemplate.opsForValue().getBit(signKey, offset);
        AssertUtil.isTrue(isSigned, "当前日期已完成签到,无需再签");
        // 签到
        redisTemplate.opsForValue().setBit(signKey, offset, true);
        // 统计连续签到次数
        // 根据当前日期统计
        date = new Date();
        int count = getContinuousSignCount(dinerInfo.getId(), date);
        // 用户签到成功添加对应积分
        int points = addPoints(count, dinerInfo.getId());
        return points;
    }

    /**
     * 添加积分
     *
     * @param count
     * @param dinerId
     * @return
     */
    private int addPoints(int count, Integer dinerId) {
        // 签到 1 天送 10 积分,连续签到 2 天送 20 积分,3 天送 30 积分
        // 4 天以及以上均送 50 积分
        int points = 10;
        if (count == 2) {
            points = 20;
        } else if (count == 3) {
            points = 30;
        } else if (count >= 4) {
            points = 50;
        }
        // 调用积分服务添加积分
        // 构建请求头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        // 构建请求体(请求参数)
        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
        body.add("dinerId", dinerId);
        body.add("points", points);
        body.add("types", PointTypesConstant.sign.getType());
        HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
        // 发送请求
        ResponseEntity<ResultInfo> result = restTemplate.postForEntity(pointsServerName, entity, ResultInfo.class);
        AssertUtil.isTrue(result.getStatusCode() != HttpStatus.OK, "登录失败");
        ResultInfo resultInfo = result.getBody();
        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
            throw new ParameterException(resultInfo.getCode(), resultInfo.getMessage());
        }
        return points;
    }

    /**
     * 统计连续签到次数
     *
     * @param dinerId
     * @param date
     * @return
     */
    private int getContinuousSignCount(Integer dinerId, Date date) {
        // 获取日期对应的天数,多少号
        int dayOfMonth = DateUtil.dayOfMonth(date); // BitField 左闭右开
        // 构建 Redis Key
        String signKey = buildSignKey(dinerId, date);
        // 命令:bitfield key get [u/i]dayOfMonth offset
        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);
        // 取低位连续不为 0 的个数即为连续签到次数,需考虑当天尚未签到的情况
        for (int i = dayOfMonth; i > 0; i--) {
            /*
                  原:1111000111
                右移:0111100011
              再左移:1111000110
              右移再左移如果不等于自己,表示低位是 1,签到,计数器++
              右移再左移如果等于自己,表示低位是 0,未签到
             */
            if (v >> 1 << 1 == v) {
                // 低位为 0 且非当天说明连续签到中断了
                if (i != dayOfMonth) {
                    break;
                }
            } else {
                // 签到了,计数器++
                signCount++;
            }
            // 右移一位并重新赋值,相当于把最右边一位去掉
            v >>= 1;
        }
        return signCount;
    }

    /**
     * 构建 Redis Key
     * e.g. user:sign:1:202108 11110001010010101010
     *
     * @param dinerId
     * @param date
     * @return
     */
    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.parse(dateStr);
        } catch (Exception e) {
            throw new ParameterException("请传入yyyy-MM-dd的日期格式");
        }
    }

    /**
     * 获取登录用户信息
     *
     * @param accessToken
     * @return
     */
    private SignInDinerInfo loadSignInDinerInfo(String accessToken) {
        // 是否有 accessToken
        AssertUtil.mustLogin(accessToken);
        // 拼接远程请求 url
        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);
        return dinerInfo;
    }

    /**
     * 获取用户签到次数
     *
     * @param accessToken
     * @param dateStr
     * @return
     */
    public long getSignCount(String accessToken, String dateStr) {
        // 获取登录用户信息
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // 获取日期
        Date date = getDate(dateStr);
        // 构建 Redis Key
        String signKey = buildSignKey(dinerInfo.getId(), date);
        // e.g. BITCOUNT user:sign:1:202108
        return (long) redisTemplate.execute(
                (RedisCallback<Long>) con -> con.bitCount(signKey.getBytes())
        );
    }

    /**
     * 获取当月签到情况
     *
     * @param accessToken
     * @param dateStr
     * @return
     */
    public Map<String, Boolean> getSignInfo(String accessToken, String dateStr) {
        // 获取登录用户信息
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // 获取日期
        Date date = getDate(dateStr);
        // 构建 Redis Key
        String signKey = buildSignKey(dinerInfo.getId(), date);
        // 构建一个自动排序的 Map
        Map<String, Boolean> signInfo = new TreeMap<>();
        // 获取某月的总天数(考虑闰年)
        int dayOfMonth = DateUtil.lengthOfMonth(DateUtil.month(date) + 1,
                DateUtil.isLeapYear(DateUtil.dayOfYear(date)));
        // 命令:bitfield key get [u/i]dayOfMonth offset
        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 signInfo;
        }
        long v = list.get(0) == null ? 0 : list.get(0);
        // 取低位连续不为 0 的个数即为连续签到次数,需考虑当天尚未签到的情况
        for (int i = dayOfMonth; i > 0; i--) {
            /*
                Map:
                    yyyy-MM-01 true
                    yyyy-MM-02 false
             */
            // 获取日期时间,比如 i = 31,最终 20210831
            LocalDateTime dateTime = LocalDateTimeUtil.of(date).withDayOfMonth(i);
            // 右移再左移如果不等于自己说明低位是 1,表示签到
            boolean flag = v >> 1 << 1 != v;
            // 构建一个 Key 为日期,Value 为是否签到标记
            signInfo.put(DateUtil.format(dateTime, "yyyy-MM-dd"), flag);
            // 右移一位并重新赋值,相当于把最右边一位去掉
            v >>= 1;
        }
        return signInfo;
    }

}

SignController方法
package com.itkaka.diners.controller;

import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.utils.ResultInfoUtil;
import com.itkaka.diners.service.SignService;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;

/**
 * 签到服务 Controller
 */
@RestController
@RequestMapping("sign")
public class SignController {

    @Resource
    private HttpServletRequest request;
    @Resource
    private SignService signService;

    /**
     * 用户签到/可以补签
     *
     * @param access_token
     * @param date
     * @return 连续签到次数
     */
    @PostMapping
    public ResultInfo<Integer> sign(String access_token,
                                    @RequestParam(required = false) String date) {
        int count = signService.doSign(access_token, date);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), count);
    }

    /**
     * 获取签到次数,默认当月
     *
     * @param access_token
     * @param date
     * @return
     */
    @GetMapping("count")
    public ResultInfo<Long> getSignCount(String access_token, String date) {
        Long count = signService.getSignCount(access_token, date);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), count);
    }

    /**
     * 获取用户签到情况,默认当月
     *
     * @param access_token
     * @param date
     * @return
     */
    @GetMapping
    public ResultInfo<Map<String, Boolean>> getSignInfo(String access_token, String date) {
        Map<String, Boolean> signInfo = signService.getSignInfo(access_token, date);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), signInfo);
    }

}

获取用户签到情况

SignService方法

获取某月签到情况,默认当月

  • 获取登录用户信息
  • 构建Redis保存的Key
  • 获取月份的总天数(考虑2月闰、平年)
  • 通过BITFIELD指令获取当前月的所有签到数据
  • 遍历进行判断是否签到,并存入TreeMap方便排序
package com.itkaka.diners.service;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.StrUtil;
import com.itkaka.commons.constant.ApiConstant;
import com.itkaka.commons.constant.PointTypesConstant;
import com.itkaka.commons.exception.ParameterException;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.model.vo.SignInDinerInfo;
import com.itkaka.commons.utils.AssertUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.BitFieldSubCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.*;

/**
 * 签到服务 Service
 */
@Service
public class SignService {

    @Value("${service.name.fs-oauth-server}")
    private String oauthServerName;
    @Value("${service.name.fs-points-server}")
    private String pointsServerName;
    @Resource
    private RestTemplate restTemplate;
    @Resource
    private RedisTemplate redisTemplate;

    /**
     * 用户签到/可以补签
     *
     * @param accessToken 登录用户 token
     * @param dateStr     日期,默认当天
     * @return 连续签到次数
     */
    @Transactional(rollbackFor = Exception.class)
    public int doSign(String accessToken, String dateStr) {
        // 获取登录用户信息
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // 获取日期
        Date date = getDate(dateStr);
        // 获取日期对应的天数,多少号
        int offset = DateUtil.dayOfMonth(date) - 1; // 从 0 开始
        // 构建 Redis Key
        String signKey = buildSignKey(dinerInfo.getId(), date);
        // 查看指定日期是否已签到
        boolean isSigned = redisTemplate.opsForValue().getBit(signKey, offset);
        AssertUtil.isTrue(isSigned, "当前日期已完成签到,无需再签");
        // 签到
        redisTemplate.opsForValue().setBit(signKey, offset, true);
        // 统计连续签到次数
        // 根据当前日期统计
        date = new Date();
        int count = getContinuousSignCount(dinerInfo.getId(), date);
        // 用户签到成功添加对应积分
        int points = addPoints(count, dinerInfo.getId());
        return points;
    }

    /**
     * 添加积分
     *
     * @param count
     * @param dinerId
     * @return
     */
    private int addPoints(int count, Integer dinerId) {
        // 签到 1 天送 10 积分,连续签到 2 天送 20 积分,3 天送 30 积分
        // 4 天以及以上均送 50 积分
        int points = 10;
        if (count == 2) {
            points = 20;
        } else if (count == 3) {
            points = 30;
        } else if (count >= 4) {
            points = 50;
        }
        // 调用积分服务添加积分
        // 构建请求头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        // 构建请求体(请求参数)
        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
        body.add("dinerId", dinerId);
        body.add("points", points);
        body.add("types", PointTypesConstant.sign.getType());
        HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
        // 发送请求
        ResponseEntity<ResultInfo> result = restTemplate.postForEntity(pointsServerName, entity, ResultInfo.class);
        AssertUtil.isTrue(result.getStatusCode() != HttpStatus.OK, "登录失败");
        ResultInfo resultInfo = result.getBody();
        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
            throw new ParameterException(resultInfo.getCode(), resultInfo.getMessage());
        }
        return points;
    }

    /**
     * 统计连续签到次数
     *
     * @param dinerId
     * @param date
     * @return
     */
    private int getContinuousSignCount(Integer dinerId, Date date) {
        // 获取日期对应的天数,多少号
        int dayOfMonth = DateUtil.dayOfMonth(date); // BitField 左闭右开
        // 构建 Redis Key
        String signKey = buildSignKey(dinerId, date);
        // 命令:bitfield key get [u/i]dayOfMonth offset
        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);
        // 取低位连续不为 0 的个数即为连续签到次数,需考虑当天尚未签到的情况
        for (int i = dayOfMonth; i > 0; i--) {
            /*
                  原:1111000111
                右移:0111100011
              再左移:1111000110
              右移再左移如果不等于自己,表示低位是 1,签到,计数器++
              右移再左移如果等于自己,表示低位是 0,未签到
             */
            if (v >> 1 << 1 == v) {
                // 低位为 0 且非当天说明连续签到中断了
                if (i != dayOfMonth) {
                    break;
                }
            } else {
                // 签到了,计数器++
                signCount++;
            }
            // 右移一位并重新赋值,相当于把最右边一位去掉
            v >>= 1;
        }
        return signCount;
    }

    /**
     * 构建 Redis Key
     * e.g. user:sign:1:202108 11110001010010101010
     *
     * @param dinerId
     * @param date
     * @return
     */
    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.parse(dateStr);
        } catch (Exception e) {
            throw new ParameterException("请传入yyyy-MM-dd的日期格式");
        }
    }

    /**
     * 获取登录用户信息
     *
     * @param accessToken
     * @return
     */
    private SignInDinerInfo loadSignInDinerInfo(String accessToken) {
        // 是否有 accessToken
        AssertUtil.mustLogin(accessToken);
        // 拼接远程请求 url
        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);
        return dinerInfo;
    }

    /**
     * 获取用户签到次数
     *
     * @param accessToken
     * @param dateStr
     * @return
     */
    public long getSignCount(String accessToken, String dateStr) {
        // 获取登录用户信息
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // 获取日期
        Date date = getDate(dateStr);
        // 构建 Redis Key
        String signKey = buildSignKey(dinerInfo.getId(), date);
        // e.g. BITCOUNT user:sign:1:202108
        return (long) redisTemplate.execute(
                (RedisCallback<Long>) con -> con.bitCount(signKey.getBytes())
        );
    }

    /**
     * 获取当月签到情况
     *
     * @param accessToken
     * @param dateStr
     * @return
     */
    public Map<String, Boolean> getSignInfo(String accessToken, String dateStr) {
        // 获取登录用户信息
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // 获取日期
        Date date = getDate(dateStr);
        // 构建 Redis Key
        String signKey = buildSignKey(dinerInfo.getId(), date);
        // 构建一个自动排序的 Map
        Map<String, Boolean> signInfo = new TreeMap<>();
        // 获取某月的总天数(考虑闰年)
        int dayOfMonth = DateUtil.lengthOfMonth(DateUtil.month(date) + 1,
                DateUtil.isLeapYear(DateUtil.dayOfYear(date)));
        // 命令:bitfield key get [u/i]dayOfMonth offset
        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 signInfo;
        }
        long v = list.get(0) == null ? 0 : list.get(0);
        // 取低位连续不为 0 的个数即为连续签到次数,需考虑当天尚未签到的情况
        for (int i = dayOfMonth; i > 0; i--) {
            /*
                Map:
                    yyyy-MM-01 true
                    yyyy-MM-02 false
             */
            // 获取日期时间,比如 i = 31,最终 20210831
            LocalDateTime dateTime = LocalDateTimeUtil.of(date).withDayOfMonth(i);
            // 右移再左移如果不等于自己说明低位是 1,表示签到
            boolean flag = v >> 1 << 1 != v;
            // 构建一个 Key 为日期,Value 为是否签到标记
            signInfo.put(DateUtil.format(dateTime, "yyyy-MM-dd"), flag);
            // 右移一位并重新赋值,相当于把最右边一位去掉
            v >>= 1;
        }
        return signInfo;
    }

}

SignController方法
package com.itkaka.diners.controller;

import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.utils.ResultInfoUtil;
import com.itkaka.diners.service.SignService;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;

/**
 * 签到服务 Controller
 */
@RestController
@RequestMapping("sign")
public class SignController {

    @Resource
    private HttpServletRequest request;
    @Resource
    private SignService signService;

    /**
     * 用户签到/可以补签
     *
     * @param access_token
     * @param date
     * @return 连续签到次数
     */
    @PostMapping
    public ResultInfo<Integer> sign(String access_token,
                                    @RequestParam(required = false) String date) {
        int count = signService.doSign(access_token, date);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), count);
    }

    /**
     * 获取签到次数,默认当月
     *
     * @param access_token
     * @param date
     * @return
     */
    @GetMapping("count")
    public ResultInfo<Long> getSignCount(String access_token, String date) {
        Long count = signService.getSignCount(access_token, date);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), count);
    }

    /**
     * 获取用户签到情况,默认当月
     *
     * @param access_token
     * @param date
     * @return
     */
    @GetMapping
    public ResultInfo<Map<String, Boolean>> getSignInfo(String access_token, String date) {
        Map<String, Boolean> signInfo = signService.getSignInfo(access_token, date);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), signInfo);
    }

}

写在最后

签到功能
这个功能中我们实现了签到、补签、获取连续签到次数、获取签到总次数、获取签到详情功能。
这个功能中 Redis 主要用于存储签到信息,使用了 Bitmap 数据类型。

签到功能是很常用的,在项目中,是一个不错的亮点,统计功能也是各大系统中比较重要的功能,签到完成后,去统计本月的连续 签到记录,来给予奖励,可大大增加用户对系统的活跃度 。

如果这篇【文章】有帮助到你,希望可以动动小手给点个赞👍,创作不易,也欢迎关注❤️❤️❤️ !
👉 💕美好的一天,从现在开始,大家一起努力!后续持续更新 ~

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值