1 签到日历周期
签到周期: 常用的签到周期为一周或者一个月.我们的app采用的是一个月的方案.市面上的签到日历界面都大同小异,接下来我会给大家分享以月为周期的签到日历实现方案以及伴生的签到任务实现方案.
2 展示效果以及接口分析
2.1 效果图
2.2 需求分析
通过图上分析,可大致把这个界面分成四个部分头部的总积分部分
最关键的签到日历展示部分
连续签到文案配置部分
签到任务展示部分
通过分析我把这个界面分成了三个接口/signIn GET协议 用于查询头部的总积分和签到日历部分.
/signIn/configuration GET协议 查询连续签到文案配置,如果不需要后台可配置连续签到获取积分的数量和文案,此接口可省略,前端写死.
/signIn/task GET协议 用于查询签到任务,以及各个任务的完成状态.
3 查询总积分,签到日历接口
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 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 keys = cacheClient.hkeys(retroactiveKey);
TreeMap 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 result = new HashMap<>(2);
//前文有介绍过这个表存储了用户的总积分
UserIntegral userIntegral = userIntegralService.getOne(new LambdaQueryWrapper().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).实测百毫秒内可返回数据
4.查询签到任务以及任务的完成状态
这一部分采用的是redis和mysql结合查询的方式.任务我们做了后台可配置.分为只能完成一次的福利任务和每天都可以重置的每日任务.
4.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='用户任务表'
4.2 任务查询
因为每日任务和福利任务大概也就十条左右,所以mysql查询是非常快速的.然后完成状态储存在redis中,时间复杂度为O(1)
public ResponseResult selectSignInTask(Integer userId) {
ResponseResult responseResult = ResponseResult.newSingleData();
//先查出签到任务的mysql记录.
List userIntegralTaskList = list(new LambdaQueryWrapper()
.orderByDesc(UserIntegralTask::getTaskType).orderByAsc(UserIntegralTask::getSort));
//创建一个map,key为任务的task_tag,value存在则是完成了该任务.
//每日任务和福利任务分为两个reids hash存储.每日任务的key中包含当天日期,过期时间为一天.福利任务则是永久保存
Map completeFlagMap = new HashMap<>(userIntegralTaskList.size());
Map welfareMap = cacheClient.hgetAll(String.format(RedisKeyConstant.USER_SIGN_WELFARE_TASK, userId));
if (CollUtil.isNotEmpty(welfareMap)) completeFlagMap.putAll(welfareMap);
Map 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;
}
4.3 完成任务
完成任务的方法.设定为一个公共方法.传入对应的task_tag标识去完成指定任务.也就只需要判断一下他是每日任务还是福利任务.分别写入不同的redis hash里
//伪代码
public ResponseResult saveSignInTask(Integer userId, String tag) {
//查询出mysql中对应的tag任务,获取关键信息.(`integral`)
....
//写入积分记录表.对应当前任务title的记录
...
//在redis里写入当前用户的这个任务完成状态(这里要注意如果是每日任务要给hash 列表给一天的过期时间,防止脏数据长时间不被清理,占用redis的内存空间)
}
至此一个以redis位图方案的签到功能就实现完毕了.实现内容大致签到,补签,积分增减,可配置任务功能.
原文作者:chenyunxuan
原文出处:掘金