实现一个app的签到功能,没你想的那么复杂!

329次阅读  |  发布于3年以前

1 . 签到定义以及作用

签到,指在规定的簿册上签名或写一“到”字,表示本人已经到达。在APP中使用此功能,可以增加用户粘性和活跃度。

一个有签到功能的APP,往往会提供补签功能,连续签到多少天会给予相关的奖励;而为了进一步增加用户粘性,还会提供签到任务功能,完成任务也可获取对应的奖励。

功能用例

本文带你实现一个包含上述用例的签到功能,看完以后你会发现,签到,没有你想的那么复杂!

2 . 技术选型

redis为主写入查询,mysql辅助查询。传统签到多数都是直接采用mysql为存储DB,在大数据的情况下数据库的压力较大。查询速率也会随着数据量增大而增加。所以在需求定稿以后查阅了很多签到实现方式,发现用redis做签到会有很大的优势。

本功能主要用到redis位图[1],后面我会详细讲解实现过程。

3 . 实现效果

这里抛砖引玉,展示一下我们app的签到实现效果

4 功能实现

功能大致分为两个大模块

签到流程图如下:

4.1.1 表设计

因为大部分功能使用redis存储,使用到mysql主要是为了存储用户总积分以及积分记录,便于查询签到记录和用户总积分

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.1.2 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";

4.1.3 实现签到

接口restful的形式,头信息里传入用户id

@ApiOperation("用户签到")
@PostMapping("/signIn")
@LoginValidate
public ResponseResult saveSignIn(@RequestHeader Integer userId) {
    return userIntegralLogService.saveSignIn(userId);
}

sevice实现层

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

计算连续签到天数

/**
 * @description: 获取连续签到天数
 * @author: chenyunxuan
 * @updateTime: 2020/8/25 4:43 下午
 */
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值右移一位再左移一位后与原始值不相等,连续天数加一
            if (v >> 1 << 1 == v) return signCount;
            signCount += 1;
            v >>= 1;
        }
    }
    return signCount;
}

记录签到积分类型以及连续签到积分

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

连续签到获取的积分配置以及文案配置

4.1.4 实现补签

补签功能是一个签到补充功能,主要就是方便用户在忘了签到的情况下也能通过补签功能达到相应的连续签到条件,从而得到奖励.

补签主方法

//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() - timeNow.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());
}

至此,用例中的签到与补签功能实现完成,接下来我们看看签到日历以及签到任务如何实现。

5 签到日历周期

签到周期: 常用的签到周期为一周或者一个月。我们的app采用的是一个月的方案(市面上的签到日历界面都大同小异,接下来我会给大家分享以月为周期的签到日历实现方案以及伴生的签到任务实现方案)

6 展示效果以及接口分析

6.1 效果图

6.2 需求分析

通过图上分析,可大致把这个界面分成四个部分

通过分析我把这个界面分成了三个接口

7 查询总积分,签到日历接口

public ResponseResult selectSignIn(Integer userId, Integer year, Integer month) {
    boolean signFlag = Boolean.FALSE;
    String signKey = String.format(RedisKeyConstant.USER_SIGN_IN, year, userId);
    LocalDate date = LocalDate.of(year, month, 1);
    //这个方法前面的文章有介绍过.是查询出一个偏移值区间的位图集合
    List<Long> list = cacheClient.getBit(signKey, month * 100 + 1, date.lengthOfMonth());
    //查询reids中当前用户补签的hash列表 (hash列表的key为补签的日期,value存在就说明这个日期补签了)
    String retroactiveKey = String.format(RedisKeyConstant.USER_RETROACTIVE_SIGN_IN, date.getMonthValue(), userId);
    Set<String> keys = cacheClient.hkeys(retroactiveKey);
    TreeMap<Integer, Integer> signMap = new TreeMap<>();
    if (list != null && list.size() > 0) {
        // 由低位到高位,为0表示未签,为1表示已签
        long v = list.get(0) == null ? 0 : list.get(0);
        //循环次数为当月的天数
        for (int i = date.lengthOfMonth(); i > 0; i--) {
            LocalDate d = date.withDayOfMonth(i);
            int type = 0;
            if (v >> 1 << 1 != v) {
                //状态为正常签到
                type = 1;
                //这里和当前日期对比,方便前端特殊标记今天是否签到
                if (d.compareTo(LocalDate.now()) == 0) {
                    signFlag = Boolean.TRUE;
                }
            }
            if (keys.contains(d.getDayOfMonth() + "")) {
                //状态为补签
                type = 2;
            }
            //返回给前端当月的所有日期,以及签,补签或者未签的状态
            signMap.put(Integer.parseInt(d.format(DateTimeFormatter.ofPattern("dd"))), type);
            v >>= 1;
        }
    }
    ResponseResult responseResult = ResponseResult.newSingleData();
    Map<String, Object> result = new HashMap<>(2);
    //前文有介绍过这个表存储了用户的总积分
    UserIntegral userIntegral = userIntegralService.getOne(new LambdaQueryWrapper<UserIntegral>().eq(UserIntegral::getUserId, userId));
    //用户总积分
    result.put("total", userIntegral.getIntegral());
    //用户今日是否签到
    result.put("todaySignFlag", signFlag ? 1 : 0);
    //后端返回日期是为了防止手机端直接修改系统时间导致的问题
    result.put("today", LocalDate.now().getDayOfMonth());
    //当月的签到情况
    result.put("signCalendar", signMap);
    //返回给前端这个月的第一天是星期几,方便前端渲染日历图的时候定位
    result.put("firstDayOfWeek", date.getDayOfWeek().getValue());
    //服务器的当前月份(同上,防止手机端直接修改系统时间)
    result.put("monthValue", date.getMonthValue());
    //用户当月补签的次数
    result.put("retroactiveCount", keys.size());
    //日历部分会有上月的结尾几天的数据,所以这里需要返回给前端上个月共有多少天
    result.put("lengthOfLastMonth", date.minusMonths(1).lengthOfMonth());
    responseResult.setData(result);
    return responseResult;
}

因为整体使用了Redis位图的查询,每个用户的签到数据都是通过key隔离开的,时间复杂度为 O(1).实测百毫秒内可返回数据

8.查询签到任务以及任务的完成状态

这一部分采用的是redis和mysql结合查询的方式.任务我们做了后台可配置.分为只能完成一次的 福利任务和每天都可以重置的 每日任务.

8.1 表结构

设计这张任务表的时候,总要就是类型和跳转方式需要注意.因为不同的任务有不同的功能划分.用 jump_type去区分各自的功能区域.jump_source可以是H5地址也可以是手机端的路由地址.可以做到灵活调控.前端调用完成任务的接口传入任务对应的 task_tag就可以完成指定的任务

CREATE TABLE `t_user_integral_task` (
  `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `task_type` tinyint(4) DEFAULT '1' COMMENT '任务类型 1.每日任务 2福利任务',
  `task_tag` varchar(100) DEFAULT NULL COMMENT '任务前端标识(大写字母组合)',
  `task_title` varchar(100) DEFAULT NULL COMMENT '任务标题',
  `icon` varchar(255) DEFAULT NULL COMMENT '小图标',
  `task_copy` varchar(100) DEFAULT NULL COMMENT '任务文案',
  `integral` int(16) DEFAULT '0' COMMENT '任务赠送积分数',
  `jump_type` tinyint(4) DEFAULT NULL COMMENT '跳转方式 1.跳转指定商品 2.跳转链接 3.跳转指定接口,4:跳转随机商品',
  `jump_source` text COMMENT '跳转或分享的地址',
  `sort` tinyint(2) DEFAULT '0' COMMENT '排序号',
  `delete_flag` tinyint(2) DEFAULT '0' COMMENT '删除/隐藏,0:未删除/未隐藏,1:已删除/已隐藏',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='用户任务表'

8.2 任务查询

因为每日任务和福利任务大概也就十条左右,所以mysql查询是非常快速的.然后完成状态储存在redis中,时间复杂度为 O(1)

public ResponseResult selectSignInTask(Integer userId) {
    ResponseResult responseResult = ResponseResult.newSingleData();
    //先查出签到任务的mysql记录.
    List<UserIntegralTask> userIntegralTaskList = list(new LambdaQueryWrapper<UserIntegralTask>()
            .orderByDesc(UserIntegralTask::getTaskType).orderByAsc(UserIntegralTask::getSort));
    //创建一个map,key为任务的task_tag,value存在则是完成了该任务.
    //每日任务和福利任务分为两个reids hash存储.每日任务的key中包含当天日期,过期时间为一天.福利任务则是永久保存
    Map<String, String> completeFlagMap = new HashMap<>(userIntegralTaskList.size());
    Map<String, String> welfareMap = cacheClient.hgetAll(String.format(RedisKeyConstant.USER_SIGN_WELFARE_TASK, userId));
    if (CollUtil.isNotEmpty(welfareMap)) completeFlagMap.putAll(welfareMap);
    Map<String, String> dailyMap = cacheClient.hgetAll(String.format(RedisKeyConstant.USER_SIGN_DAILY_TASK, LocalDate.now().getDayOfMonth(), userId));
    //把两个hash合并
    if (CollUtil.isNotEmpty(dailyMap)) completeFlagMap.putAll(dailyMap);
    //循环库中的任务列表,并用hash的get方法查询是否完成,然后给到前端
    userIntegralTaskList.forEach(task -> {
        task.setCreateTime(null);
        task.setUpdateTime(null);
        task.setIntegral(null);
        String value = completeFlagMap.get(task.getTaskTag());
        if (null == value) {
            task.setCompleteFlag(0);
        } else {
            task.setCompleteFlag(1);
        }
    });
    responseResult.setData(userIntegralTaskList);
    return responseResult;
}

8.3 完成任务

完成任务的方法.设定为一个公共方法.传入对应的 task_tag标识去完成指定任务.也就只需要判断一下他是每日任务还是福利任务.分别写入不同的redis hash里.

//伪代码
public ResponseResult saveSignInTask(Integer userId, String tag) {
   //查询出mysql中对应的tag任务,获取关键信息.(`integral`)
   ....
   //写入积分记录表.对应当前任务title的记录
   ...
   //在redis里写入当前用户的这个任务完成状态(这里要注意如果是每日任务要给hash 列表给一天的过期时间,防止脏数据长时间不被清理,占用redis的内存空间)
}
< END >

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8